Refactor VPN proxy client implementation
This commit is contained in:
@@ -5,7 +5,7 @@ description: Check and bump Harbor component versions for every runtime, UI, API
|
|||||||
|
|
||||||
# Manage Harbor Versions
|
# Manage Harbor Versions
|
||||||
|
|
||||||
Treat `src/shared/versions.js` as the only component-version source. Do not use the root package version as a release version.
|
Treat `src/shared/versions.ts` as the only component-version source. Do not use the root package version as a release version.
|
||||||
|
|
||||||
## Required workflow
|
## Required workflow
|
||||||
|
|
||||||
@@ -23,4 +23,4 @@ Treat `src/shared/versions.js` as the only component-version source. Do not use
|
|||||||
|
|
||||||
Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component.
|
Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component.
|
||||||
|
|
||||||
Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.js`, keep the initial versions and let the checker report that no baseline exists.
|
Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.ts`, keep the initial versions and let the checker report that no baseline exists.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
.vpn-proxy
|
.vpn-proxy
|
||||||
.runtime
|
.runtime
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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: node:20.19-alpine
|
||||||
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
|
||||||
@@ -16,6 +17,9 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
affected_components: ${{ steps['gateway-build'].outputs.affected_components }}
|
||||||
|
restart_scope: ${{ steps['gateway-build'].outputs.restart_scope }}
|
||||||
steps:
|
steps:
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
env:
|
env:
|
||||||
@@ -24,11 +28,12 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
rm -rf repo
|
rm -rf repo
|
||||||
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
git clone "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
cd repo
|
cd repo
|
||||||
git checkout ${{ gitea.sha }}
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
- name: Build and push gateway image
|
- name: Build and push gateway image
|
||||||
|
id: gateway-build
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd repo
|
cd repo
|
||||||
@@ -38,6 +43,68 @@ jobs:
|
|||||||
CONTROL_IMAGE="${IMAGE}-control"
|
CONTROL_IMAGE="${IMAGE}-control"
|
||||||
DATAPLANE_IMAGE="${IMAGE}-dataplane"
|
DATAPLANE_IMAGE="${IMAGE}-dataplane"
|
||||||
|
|
||||||
|
EVENT_NAME="${{ gitea.event_name }}"
|
||||||
|
BEFORE_SHA="${{ gitea.event.before }}"
|
||||||
|
ZERO_SHA="0000000000000000000000000000000000000000"
|
||||||
|
if [ "$EVENT_NAME" = "push" ] \
|
||||||
|
&& [ -n "$BEFORE_SHA" ] \
|
||||||
|
&& [ "$BEFORE_SHA" != "$ZERO_SHA" ] \
|
||||||
|
&& git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
CHANGED_FILES="$(git diff --no-renames --name-only "$BEFORE_SHA" "${{ gitea.sha }}")"
|
||||||
|
elif [ "$EVENT_NAME" = "push" ]; then
|
||||||
|
CHANGED_FILES="package.json"
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git diff-tree --no-renames --no-commit-id --name-only -r -m HEAD)"
|
||||||
|
fi
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
else
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v node >/dev/null'; then
|
||||||
|
echo "Cannot classify runtime impact: Node and the existing runtime base are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | docker run --rm -i \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.BASE_IMAGE }}" \
|
||||||
|
node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
fi
|
||||||
|
AFFECTED_COMPONENTS="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^affected-components=//p')"
|
||||||
|
RESTART_SCOPE="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^restart-scope=//p')"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
|
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:boundaries
|
||||||
|
npm test
|
||||||
|
npm run build:production
|
||||||
|
else
|
||||||
|
if ! docker image inspect "${{ env.NODE_BUILD_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||||
|
echo "Cannot validate change: host npm and the existing Node 20.19 build image are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
|
sh -lc 'npm ci --no-audit --no-fund && npm run typecheck && npm run check:boundaries && npm test && npm run build:production'
|
||||||
|
fi
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Image build and push skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Build runner: $(hostname)"
|
echo "Build runner: $(hostname)"
|
||||||
echo "Base image: ${{ env.BASE_IMAGE }}"
|
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||||
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||||
@@ -54,23 +121,11 @@ jobs:
|
|||||||
./scripts/build-runtime-base.sh
|
./scripts/build-runtime-base.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v npm >/dev/null 2>&1; then
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
npm run build
|
|
||||||
else
|
|
||||||
echo "Host npm not found; building frontend inside ${{ env.BASE_IMAGE }}"
|
|
||||||
docker run --rm \
|
|
||||||
--network host \
|
|
||||||
-v "$PWD:/work" \
|
|
||||||
-w /work \
|
|
||||||
"${{ env.BASE_IMAGE }}" \
|
|
||||||
sh -lc 'npm ci --no-audit --no-fund && npm run build'
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
--network host \
|
--network host \
|
||||||
--pull=false \
|
--pull=false \
|
||||||
|
--build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
--build-arg INSTALL_RUNTIME_DEPS=false \
|
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||||
@@ -109,13 +164,24 @@ jobs:
|
|||||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}"
|
CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}"
|
||||||
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
||||||
|
AFFECTED_COMPONENTS="${{ needs['build-and-push'].outputs.affected_components }}"
|
||||||
|
RESTART_SCOPE="${{ needs['build-and-push'].outputs.restart_scope }}"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact output: ${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Deploy skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
UPDATE_DATAPLANE=false
|
UPDATE_DATAPLANE=false
|
||||||
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
|
if [ "$RESTART_SCOPE" = "both" ]; then
|
||||||
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singbox|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/(connectivityDiagnosticsService|deviceTrafficService|devicePolicyService))\.js|src/shared/(connectivityDiagnostics|errors)\.js)$'; then
|
|
||||||
UPDATE_DATAPLANE=true
|
UPDATE_DATAPLANE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Deploy runner: $(hostname)"
|
echo "Deploy runner: $(hostname)"
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
echo "Update dataplane: ${UPDATE_DATAPLANE}"
|
echo "Update dataplane: ${UPDATE_DATAPLANE}"
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
|
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ data/
|
|||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.test-dist/
|
||||||
coverage/
|
coverage/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
|||||||
+14
-3
@@ -1,9 +1,21 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
ARG BASE_IMAGE=debian:bookworm-slim
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.12.13
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
ARG INSTALL_RUNTIME_DEPS=true
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
ARG INSTALL_SINGBOX=true
|
ARG INSTALL_SINGBOX=true
|
||||||
COPY dist /app/dist
|
|
||||||
|
|
||||||
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
apt-get update \
|
apt-get update \
|
||||||
@@ -33,9 +45,8 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/dist /app/dist
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY src/server /app/src/server
|
|
||||||
COPY src/shared /app/src/shared
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /entrypoint.sh \
|
RUN chmod +x /entrypoint.sh \
|
||||||
|
|||||||
+6
-7
@@ -1,15 +1,16 @@
|
|||||||
ARG NODE_BUILD_IMAGE=node:20-alpine
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
FROM ${NODE_BUILD_IMAGE} AS web-build
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY index.html vite.config.js ./
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
COPY src/web ./src/web
|
COPY src/web ./src/web
|
||||||
|
COPY src/server ./src/server
|
||||||
COPY src/shared ./src/shared
|
COPY src/shared ./src/shared
|
||||||
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
RUN npm run build
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${RUNTIME_IMAGE}
|
FROM ${RUNTIME_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.12.13
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
@@ -32,10 +33,8 @@ RUN set -eux; \
|
|||||||
rm -rf /tmp/sing-box*
|
rm -rf /tmp/sing-box*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=web-build /src/dist /app/dist
|
COPY --from=build /src/dist /app/dist
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY src/server /app/src/server
|
|
||||||
COPY src/shared /app/src/shared
|
|
||||||
COPY entrypoint.client.sh /entrypoint.client.sh
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
RUN chmod +x /entrypoint.client.sh \
|
RUN chmod +x /entrypoint.client.sh \
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
|||||||
|
|
||||||
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
|
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
|
||||||
|
|
||||||
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
|
Компонентные версии меняются в `src/shared/versions.ts`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
|
||||||
|
|
||||||
Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
|
Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Public API failures use one envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.js`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs.
|
`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.ts`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs.
|
||||||
|
|
||||||
Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`.
|
Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Harbor tracks active browser mutations by operation key instead of one global `b
|
|||||||
- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`;
|
- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`;
|
||||||
- `gatewayAuto`: change the active route preference.
|
- `gatewayAuto`: change the active route preference.
|
||||||
|
|
||||||
Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.js`.
|
Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.ts`.
|
||||||
|
|
||||||
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
|
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
|||||||
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
exec node /app/src/server/index.js
|
exec node /app/dist/server/main.js
|
||||||
|
|||||||
+2
-6
@@ -25,7 +25,7 @@ log() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$APP_COMPONENT" == "control" ]]; then
|
if [[ "$APP_COMPONENT" == "control" ]]; then
|
||||||
exec node /app/src/server/index.js
|
exec node /app/dist/server/main.js
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ipt() {
|
ipt() {
|
||||||
@@ -189,11 +189,7 @@ if ! setup_device_traffic; then
|
|||||||
fi
|
fi
|
||||||
setup_proxy_firewall
|
setup_proxy_firewall
|
||||||
|
|
||||||
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
|
node /app/dist/server/main.js &
|
||||||
node /app/src/server/dataplane.js &
|
|
||||||
else
|
|
||||||
node /app/src/server/index.js &
|
|
||||||
fi
|
|
||||||
APP_PID=$!
|
APP_PID=$!
|
||||||
|
|
||||||
shutdown() {
|
shutdown() {
|
||||||
|
|||||||
+1
-1
@@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/web/App.jsx"></script>
|
<script type="module" src="/src/web/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+505
@@ -12,6 +12,17 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/parser": "7.29.3",
|
||||||
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
|
"@types/node": "22.19.17",
|
||||||
|
"@types/node18": "npm:@types/node@18.19.130",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"postcss": "8.5.14",
|
||||||
|
"postcss-selector-parser": "7.1.4",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -277,6 +288,29 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@csstools/selector-specificity": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss-selector-parser": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
@@ -1116,6 +1150,394 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
|
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.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": {
|
||||||
|
"version": "19.2.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -1207,6 +1629,26 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cssesc": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"cssesc": "bin/cssesc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1435,6 +1877,20 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-selector-parser": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cssesc": "^3.0.0",
|
||||||
|
"util-deprecate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -1549,6 +2005,48 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -1579,6 +2077,13 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.3",
|
"version": "7.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
||||||
|
|||||||
+19
-2
@@ -7,14 +7,31 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"test": "node --test",
|
"build:production": "npm run build && npm run build:server",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"build:test": "npm run build:production && node scripts/clean-test-dist.mjs && tsc -p tsconfig.test.json",
|
||||||
|
"check:boundaries": "node scripts/check-import-boundaries.mjs",
|
||||||
|
"prestart": "npm run build:production",
|
||||||
|
"test": "npm run build:test && node --test",
|
||||||
|
"typecheck": "tsc -p tsconfig.web.json && tsc -p tsconfig.server.json --noEmit",
|
||||||
"version:harbor": "node scripts/harbor-version.mjs",
|
"version:harbor": "node scripts/harbor-version.mjs",
|
||||||
"start": "node src/server/index.js"
|
"start": "node dist/server/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/parser": "7.29.3",
|
||||||
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
|
"@types/node": "22.19.17",
|
||||||
|
"@types/node18": "npm:@types/node@18.19.130",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"postcss": "8.5.14",
|
||||||
|
"postcss-selector-parser": "7.1.4",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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}"
|
||||||
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.12.13}"
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
@@ -62,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 && docker build --pull='${DOCKER_BUILD_PULL}' --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; 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}' ."
|
||||||
if [ "${BUILD_HOST}" = "local" ]; then
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
bash -lc "${BUILD_COMMAND}"
|
bash -lc "${BUILD_COMMAND}"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SyntaxKind } from 'typescript/unstable/ast';
|
||||||
|
import { createScanner } from 'typescript/unstable/ast/scanner';
|
||||||
|
|
||||||
|
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function normalized(value) {
|
||||||
|
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTarget(importer, specifier) {
|
||||||
|
if (!specifier.startsWith('.')) return null;
|
||||||
|
return normalized(path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function featurePath(file) {
|
||||||
|
const match = /^src\/(server|web)\/features\/([^/]+)(?:\/(.*))?$/.exec(file);
|
||||||
|
return match ? { layer: match[1], name: match[2], privatePath: match[3] || '' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importBoundaryViolation(importerValue, specifier) {
|
||||||
|
const importer = normalized(importerValue);
|
||||||
|
const target = relativeTarget(importer, specifier);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (importer.startsWith('src/shared/') && /^src\/(server|web)\//.test(target)) {
|
||||||
|
return 'shared cannot import server or web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/') && target.startsWith('src/web/')) {
|
||||||
|
return 'server cannot import web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/') && target.startsWith('src/server/')) {
|
||||||
|
return 'web cannot import server';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/http/') && target.startsWith('src/server/infrastructure/')) {
|
||||||
|
return 'server/http cannot import infrastructure directly';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/features/') && target.startsWith('src/server/http/')) {
|
||||||
|
return 'server/features cannot import http';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/ui/')
|
||||||
|
&& /^src\/web\/(?:features(?:\/|$)|api(?:\/|\.[cm]?[jt]sx?$|$))/.test(target)) {
|
||||||
|
return 'web/ui cannot import api or features';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromFeature = featurePath(importer);
|
||||||
|
const toFeature = featurePath(target);
|
||||||
|
if (fromFeature && toFeature
|
||||||
|
&& fromFeature.layer === toFeature.layer
|
||||||
|
&& fromFeature.name !== toFeature.name
|
||||||
|
&& toFeature.privatePath
|
||||||
|
&& !/^index(?:\.[cm]?[jt]sx?)?$/.test(toFeature.privatePath)) {
|
||||||
|
return 'cross-feature imports must use the feature index';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesUnder(directory) {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importedSpecifiers(source) {
|
||||||
|
const specifiers = [];
|
||||||
|
const scanner = createScanner(true, undefined, source);
|
||||||
|
const tokens = [];
|
||||||
|
for (let token = scanner.scan(); token !== SyntaxKind.EndOfFile; token = scanner.scan()) {
|
||||||
|
if (token === SyntaxKind.SlashToken) token = scanner.reScanSlashToken();
|
||||||
|
tokens.push({ kind: token, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
const previous = tokens[index - 1];
|
||||||
|
const next = tokens[index + 1];
|
||||||
|
const isProperty = previous?.kind === SyntaxKind.DotToken
|
||||||
|
|| previous?.kind === SyntaxKind.QuestionDotToken;
|
||||||
|
|
||||||
|
if (token.text === 'import' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(next.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenParenToken) {
|
||||||
|
const argument = tokens[index + 2];
|
||||||
|
if (argument?.kind === SyntaxKind.StringLiteral) specifiers.push(argument.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenBraceToken
|
||||||
|
|| next?.kind === SyntaxKind.AsteriskToken
|
||||||
|
|| next?.kind === SyntaxKind.Identifier
|
||||||
|
|| next?.text === 'type') {
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'export' && !isProperty) {
|
||||||
|
if (next?.kind !== SyntaxKind.OpenBraceToken
|
||||||
|
&& next?.kind !== SyntaxKind.AsteriskToken
|
||||||
|
&& next?.text !== 'type') continue;
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'require' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.OpenParenToken
|
||||||
|
&& tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[index + 2].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkImportBoundaries(repositoryRoot = root) {
|
||||||
|
const sourceRoot = path.join(repositoryRoot, 'src');
|
||||||
|
const files = filesUnder(sourceRoot).filter((file) => SOURCE_FILE.test(file));
|
||||||
|
const violations = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const importer = normalized(path.relative(repositoryRoot, file));
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
for (const specifier of importedSpecifiers(source)) {
|
||||||
|
const rule = importBoundaryViolation(importer, specifier);
|
||||||
|
if (rule) violations.push({ importer, specifier, rule });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { filesChecked: files.length, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const result = checkImportBoundaries();
|
||||||
|
if (result.violations.length) {
|
||||||
|
for (const violation of result.violations) {
|
||||||
|
console.error(`${violation.importer}: ${violation.rule} (${violation.specifier})`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`Import boundaries: ${result.filesChecked} files checked.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
fs.rmSync(path.resolve('.test-dist'), { recursive: true, force: true });
|
||||||
@@ -3,10 +3,10 @@ import { execFileSync } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { parseVersion, versionCompatibility } from '../src/shared/versions.js';
|
|
||||||
|
|
||||||
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
|
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
|
||||||
const VERSION_FILE = 'src/shared/versions.js';
|
const VERSION_FILE = 'src/shared/versions.ts';
|
||||||
|
const LEGACY_VERSION_FILE = 'src/shared/versions.js';
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const aliases = {
|
const aliases = {
|
||||||
mac: 'macClient',
|
mac: 'macClient',
|
||||||
@@ -17,6 +17,28 @@ const aliases = {
|
|||||||
'gateway-backend': 'gatewayBackend',
|
'gateway-backend': 'gatewayBackend',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function parseVersion(value) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
||||||
|
return match ? {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
hotfix: Number(match[3]),
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionCompatibility(versions) {
|
||||||
|
const mac = parseVersion(versions?.macClient);
|
||||||
|
const client = parseVersion(versions?.gatewayClient);
|
||||||
|
const backend = parseVersion(versions?.gatewayBackend);
|
||||||
|
const major = Boolean(mac && client && backend
|
||||||
|
&& mac.major === client.major
|
||||||
|
&& client.major === backend.major);
|
||||||
|
const gateway = Boolean(client && backend
|
||||||
|
&& client.major === backend.major
|
||||||
|
&& client.minor === backend.minor);
|
||||||
|
return { compatible: major && gateway, major, gateway };
|
||||||
|
}
|
||||||
|
|
||||||
export function versionsFromSource(source) {
|
export function versionsFromSource(source) {
|
||||||
return Object.fromEntries(COMPONENTS.map((component) => {
|
return Object.fromEntries(COMPONENTS.map((component) => {
|
||||||
const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
|
const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
|
||||||
@@ -30,14 +52,16 @@ 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 (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||||
else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
|
else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||||
add('macClient', 'gatewayClient');
|
add('macClient', 'gatewayClient');
|
||||||
} else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend');
|
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||||
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
|
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
|
||||||
add('macClient');
|
add('macClient');
|
||||||
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
|
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
|
||||||
add('gatewayBackend');
|
add('gatewayBackend');
|
||||||
|
} else if (/^(\.gitea\/workflows\/gateway-build\.yml|scripts\/runtime-impact\.mjs)$/.test(file)) {
|
||||||
|
add('gatewayBackend');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return COMPONENTS.filter((component) => affected.has(component));
|
return COMPONENTS.filter((component) => affected.has(component));
|
||||||
@@ -91,7 +115,7 @@ function git(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changedFiles(base) {
|
function changedFiles(base) {
|
||||||
const tracked = git(['diff', '--name-only', base, '--']).split('\n');
|
const tracked = git(['diff', '--no-renames', '--name-only', base, '--']).split('\n');
|
||||||
const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
|
const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
|
||||||
return [...new Set([...tracked, ...untracked].filter(Boolean))];
|
return [...new Set([...tracked, ...untracked].filter(Boolean))];
|
||||||
}
|
}
|
||||||
@@ -99,10 +123,14 @@ function changedFiles(base) {
|
|||||||
function baselineVersions(base) {
|
function baselineVersions(base) {
|
||||||
try {
|
try {
|
||||||
return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
|
return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return versionsFromSource(git(['show', `${base}:${LEGACY_VERSION_FILE}`]));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function compareVersions(before, after) {
|
function compareVersions(before, after) {
|
||||||
const left = parseVersion(before);
|
const left = parseVersion(before);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
|
||||||
|
const noRuntimeImpact = [
|
||||||
|
/^\.codex\//,
|
||||||
|
/^docs\//,
|
||||||
|
/^test\//,
|
||||||
|
/^workpack\//,
|
||||||
|
/^(?:AGENTS|PRODUCT|README)\.md$/,
|
||||||
|
/^\.env\.example$/,
|
||||||
|
/^\.gitignore$/,
|
||||||
|
/^Dockerfile\.client$/,
|
||||||
|
/^docker-compose\.client(?:\.local)?\.yml$/,
|
||||||
|
/^entrypoint\.client\.sh$/,
|
||||||
|
/^install\.sh$/,
|
||||||
|
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
||||||
|
];
|
||||||
|
const foundation = [
|
||||||
|
/^\.dockerignore$/,
|
||||||
|
/^\.gitea\/workflows\//,
|
||||||
|
/^Dockerfile(?:\.runtime-base)?$/,
|
||||||
|
/^docker-compose\.gateway\.yml$/,
|
||||||
|
/^entrypoint\.sh$/,
|
||||||
|
/^package(?:-lock)?\.json$/,
|
||||||
|
/^scripts\/(?:build-on-107-deploy-111|build-runtime-base|deploy-gateway)\.sh$/,
|
||||||
|
/^scripts\/runtime-impact\.mjs$/,
|
||||||
|
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||||
|
];
|
||||||
|
const controlAndDataplane = [
|
||||||
|
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService)${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
|
||||||
|
/^src\/server\/infrastructure\/dataplane\//,
|
||||||
|
];
|
||||||
|
const dataplane = [
|
||||||
|
new RegExp(`^src/server/dataplane${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:deviceTrafficService|domainTrafficService)${CODE_EXTENSION}`),
|
||||||
|
];
|
||||||
|
const control = [
|
||||||
|
/^index\.html$/,
|
||||||
|
/^monitoring\//,
|
||||||
|
/^public\//,
|
||||||
|
/^src\/server\//,
|
||||||
|
/^src\/shared\//,
|
||||||
|
/^src\/web\//,
|
||||||
|
/^vite\.config\.[cm]?[jt]s$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchesAny(file, patterns) {
|
||||||
|
return patterns.some((pattern) => pattern.test(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFile(file) {
|
||||||
|
return file.trim().replaceAll('\\', '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyRuntimeImpact(files) {
|
||||||
|
const affected = new Set();
|
||||||
|
for (const value of files) {
|
||||||
|
const file = normalizeFile(value);
|
||||||
|
if (!file || matchesAny(file, noRuntimeImpact)) continue;
|
||||||
|
if (matchesAny(file, foundation) || matchesAny(file, controlAndDataplane)) {
|
||||||
|
affected.add('control');
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, dataplane)) {
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, control)) {
|
||||||
|
affected.add('control');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unclassified path: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedComponents = ['control', 'dataplane'].filter((component) => affected.has(component));
|
||||||
|
const restartScope = affected.has('dataplane') ? 'both' : affected.has('control') ? 'control' : 'none';
|
||||||
|
return { affectedComponents, restartScope };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpact(impact) {
|
||||||
|
return [
|
||||||
|
`affected-components=${impact.affectedComponents.join('+') || 'none'}`,
|
||||||
|
`restart-scope=${impact.restartScope}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const files = args.includes('--stdin')
|
||||||
|
? fs.readFileSync(0, 'utf8').split(/\r?\n/)
|
||||||
|
: args;
|
||||||
|
console.log(formatImpact(classifyRuntimeImpact(files)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[runtime-impact] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,21 +5,44 @@ const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
|||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||||
|
|
||||||
export function isDeviceInterface(value) {
|
interface NeighborEntry extends Record<string, unknown> {
|
||||||
|
state?: unknown;
|
||||||
|
lladdr?: unknown;
|
||||||
|
dev?: unknown;
|
||||||
|
dst?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborObservation {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
active: boolean;
|
||||||
|
observedAt: string;
|
||||||
|
source: 'neighbor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function neighborEntry(value: unknown): NeighborEntry {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as NeighborEntry
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeviceInterface(value: unknown) {
|
||||||
const name = String(value || '');
|
const name = String(value || '');
|
||||||
return INTERFACE_PATTERN.test(name)
|
return INTERFACE_PATTERN.test(name)
|
||||||
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) {
|
export function parseNeighborSnapshot(value: unknown, observedAt = new Date().toISOString()): NeighborObservation[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.flatMap((entry) => {
|
return value.flatMap((value) => {
|
||||||
const states = (Array.isArray(entry?.state) ? entry.state : [entry?.state])
|
const entry = neighborEntry(value);
|
||||||
|
const states = (Array.isArray(entry.state) ? entry.state : [entry.state])
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((state) => String(state).toUpperCase());
|
.map((state: unknown) => String(state).toUpperCase());
|
||||||
const mac = String(entry?.lladdr || '').toLowerCase();
|
const mac = String(entry.lladdr || '').toLowerCase();
|
||||||
const deviceInterface = String(entry?.dev || '');
|
const deviceInterface = String(entry.dev || '');
|
||||||
if (!entry?.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
if (!entry.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||||
|| states.some((state) => IGNORED_STATES.has(state))) {
|
|| states.some((state) => IGNORED_STATES.has(state))) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -34,7 +57,7 @@ export function parseNeighborSnapshot(value, observedAt = new Date().toISOString
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) {
|
export function readNeighborSnapshot(run: typeof spawnSync = spawnSync, now = () => new Date()) {
|
||||||
const observedAt = now().toISOString();
|
const observedAt = now().toISOString();
|
||||||
const result = run('ip', ['-j', 'neigh', 'show'], {
|
const result = run('ip', ['-j', 'neigh', 'show'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
@@ -54,6 +77,7 @@ export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${error.message}` };
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
const parsePort = (value, fallback) => {
|
const parsePort = (value: string | undefined, fallback: number) => {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value || '', 10);
|
||||||
return Number.isInteger(parsed) ? parsed : fallback;
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
};
|
};
|
||||||
const proxyPort = parsePort(
|
const proxyPort = parsePort(
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
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';
|
||||||
@@ -40,23 +41,34 @@ const domainTraffic = createDomainTrafficService({
|
|||||||
devices: () => traffic.snapshot().devices,
|
devices: () => traffic.snapshot().devices,
|
||||||
});
|
});
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let trafficTimer = null;
|
let trafficTimer: NodeJS.Timeout | null = null;
|
||||||
let domainTrafficTimer = null;
|
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||||
const MAX_POLICY_BODY_BYTES = 256 * 1024;
|
const MAX_POLICY_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
function readJson(req) {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks = [];
|
const chunks: Buffer[] = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
let tooLarge = false;
|
let tooLarge = false;
|
||||||
req.on('data', (chunk) => {
|
req.on('data', (chunk: Buffer | string) => {
|
||||||
size += chunk.length;
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
|
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
|
||||||
tooLarge = true;
|
tooLarge = true;
|
||||||
reject(new Error('Device policy request слишком большой'));
|
reject(new Error('Device policy request слишком большой'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!tooLarge) chunks.push(chunk);
|
if (!tooLarge) chunks.push(buffer);
|
||||||
});
|
});
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
if (tooLarge) return;
|
if (tooLarge) return;
|
||||||
@@ -70,12 +82,12 @@ function readJson(req) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
res.end(JSON.stringify(payload));
|
res.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
try {
|
try {
|
||||||
if (req.method === 'GET' && req.url === '/status') {
|
if (req.method === 'GET' && req.url === '/status') {
|
||||||
return sendJson(res, ready ? 200 : 503, {
|
return sendJson(res, ready ? 200 : 503, {
|
||||||
@@ -99,11 +111,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendJson(res, 200, devicePolicy.snapshot());
|
return sendJson(res, 200, devicePolicy.snapshot());
|
||||||
}
|
}
|
||||||
if (req.method === 'PUT' && req.url === '/device-policy') {
|
if (req.method === 'PUT' && req.url === '/device-policy') {
|
||||||
const body = await readJson(req);
|
const body = record(await readJson(req));
|
||||||
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||||
const { services = [], target = null } = await readJson(req);
|
const { services = [], target = null } = record(await readJson(req));
|
||||||
return sendJson(res, 200, await connectivityDiagnostics.run({
|
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||||
vpnAvailable: runtime.running,
|
vpnAvailable: runtime.running,
|
||||||
services,
|
services,
|
||||||
@@ -121,7 +133,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
return sendJson(res, 404, { error: 'Не найдено' });
|
return sendJson(res, 404, { error: 'Не найдено' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return sendJson(res, 500, { error: error.message || String(error) });
|
return sendJson(res, 500, { error: errorMessage(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,25 +144,25 @@ server.listen(socketPath, async () => {
|
|||||||
try {
|
try {
|
||||||
await runtime.apply();
|
await runtime.apply();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
|
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
ready = true;
|
ready = true;
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
traffic.refresh()
|
traffic.refresh()
|
||||||
.catch((error) => console.warn(`[dataplane] traffic counters не запущены: ${error.message}`));
|
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||||
});
|
});
|
||||||
trafficTimer = setInterval(() => {
|
trafficTimer = setInterval(() => {
|
||||||
traffic.refresh().catch((error) => console.warn(`[dataplane] traffic counters не обновлены: ${error.message}`));
|
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
trafficTimer.unref();
|
trafficTimer.unref();
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
domainTraffic.refresh()
|
domainTraffic.refresh()
|
||||||
.catch((error) => console.warn(`[dataplane] domain traffic не запущен: ${error.message}`));
|
.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.
|
// 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()
|
domainTraffic.refresh()
|
||||||
.catch((error) => console.warn(`[dataplane] domain traffic не обновлён: ${error.message}`));
|
.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}`);
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { HarborError } from '../shared/errors.js';
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
|
||||||
function request(socketPath, pathname, method = 'GET', body = null, timeoutMs = 6000) {
|
type SendDataplaneRequest = (
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method?: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs?: number,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method = 'GET',
|
||||||
|
body: unknown = null,
|
||||||
|
timeoutMs = 6000,
|
||||||
|
): Promise<unknown> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const encoded = body == null ? null : JSON.stringify(body);
|
const encoded = body == null ? null : JSON.stringify(body);
|
||||||
const req = http.request({
|
const req = http.request({
|
||||||
@@ -13,17 +31,17 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs =
|
|||||||
'content-length': Buffer.byteLength(encoded),
|
'content-length': Buffer.byteLength(encoded),
|
||||||
} : {},
|
} : {},
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
const chunks = [];
|
const chunks: Buffer[] = [];
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
let body = {};
|
let body: unknown = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||||
} catch {
|
} catch {
|
||||||
return reject(new Error('Dataplane вернул невалидный JSON'));
|
return reject(new Error('Dataplane вернул невалидный JSON'));
|
||||||
}
|
}
|
||||||
if ((res.statusCode || 500) >= 400) {
|
if ((res.statusCode || 500) >= 400) {
|
||||||
return reject(new Error(body.error || `Dataplane HTTP ${res.statusCode}`));
|
return reject(new Error(String(record(body).error || `Dataplane HTTP ${res.statusCode}`)));
|
||||||
}
|
}
|
||||||
resolve(body);
|
resolve(body);
|
||||||
});
|
});
|
||||||
@@ -34,11 +52,11 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDataplaneClient(socketPath, send = request) {
|
export function createDataplaneClient(socketPath: string, send: SendDataplaneRequest = request) {
|
||||||
let current = { running: false, startedAt: null };
|
let current: Record<string, unknown> = { running: false, startedAt: null };
|
||||||
const update = async (pathname, method) => {
|
const update = async (pathname: string, method: string) => {
|
||||||
try {
|
try {
|
||||||
current = await send(socketPath, pathname, method);
|
current = record(await send(socketPath, pathname, method));
|
||||||
return current;
|
return current;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (pathname === '/apply' || pathname === '/restart') {
|
if (pathname === '/apply' || pathname === '/restart') {
|
||||||
@@ -56,8 +74,8 @@ export function createDataplaneClient(socketPath, send = request) {
|
|||||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
runConnectivityDiagnostics: async (services = [], target = null) => {
|
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||||
try {
|
try {
|
||||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface ConnectionServiceDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: {
|
||||||
|
readConfig(): unknown | null;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
exists(): boolean;
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
stopCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restartCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeCommandResult =
|
||||||
|
| { ok: true; mutationStarted: true }
|
||||||
|
| { ok: false; mutationStarted: boolean; error: unknown };
|
||||||
|
|
||||||
|
export async function captureRuntimeCommand(
|
||||||
|
command: () => Promise<unknown>,
|
||||||
|
{ preMutationErrorCodes = [] }: { preMutationErrorCodes?: readonly string[] } = {},
|
||||||
|
): Promise<RuntimeCommandResult> {
|
||||||
|
try {
|
||||||
|
await command();
|
||||||
|
return { ok: true, mutationStarted: true };
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
return { ok: false, mutationStarted: !preMutationErrorCodes.includes(code), error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
|
||||||
|
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const requestedId = String(serverId).trim();
|
||||||
|
const requestedTag = String(selectedTag).trim();
|
||||||
|
const resolvedId = requestedId || (() => {
|
||||||
|
const matches = previousState.servers.filter((server) => server.label === requestedTag);
|
||||||
|
return matches.length === 1 ? matches[0].id : '';
|
||||||
|
})();
|
||||||
|
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
|
||||||
|
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
|
||||||
|
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||||
|
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||||
|
const nextConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedServer.id,
|
||||||
|
previousState.routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let desiredCommitStarted = false;
|
||||||
|
let configMutationStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
desiredCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
selectedServerId: selectedServer.id,
|
||||||
|
connectionDesired: 'running',
|
||||||
|
}));
|
||||||
|
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(nextConfig);
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
appliedServerId: selectedServer.id,
|
||||||
|
appliedAt: dependencies.now().toISOString(),
|
||||||
|
appliedRouteRules: state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
let wasRunning: boolean | null = null;
|
||||||
|
try {
|
||||||
|
wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
} catch {}
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.stopCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const restart = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
|
||||||
|
let wasRunning: boolean | null = null;
|
||||||
|
try {
|
||||||
|
wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
} catch {}
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.restartCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
appliedServerId: state.selectedServerId,
|
||||||
|
connectionDesired: 'running',
|
||||||
|
appliedRouteRules: state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { apply, stop, restart };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
captureRuntimeCommand,
|
||||||
|
createConnectionService,
|
||||||
|
type RuntimeCommandResult,
|
||||||
|
type ConnectionService,
|
||||||
|
} from './connectionService.js';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
interface DiagnosticServer {
|
||||||
|
id: unknown;
|
||||||
|
label: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticState {
|
||||||
|
appliedServerId?: unknown;
|
||||||
|
selectedServerId?: unknown;
|
||||||
|
servers?: DiagnosticServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticsResult extends Record<string, unknown> {
|
||||||
|
vpn?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsDependencies {
|
||||||
|
readState(): DiagnosticState;
|
||||||
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new TypeError('Diagnostics adapter returned an invalid result');
|
||||||
|
}
|
||||||
|
return value as DiagnosticsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsUseCase(
|
||||||
|
dependencies: ConnectivityDiagnosticsDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async run(services: unknown, target: unknown) {
|
||||||
|
const state = dependencies.readState();
|
||||||
|
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||||
|
const selected = (Array.isArray(state.servers) ? state.servers : [])
|
||||||
|
.find((server) => server.id === appliedServerId);
|
||||||
|
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||||
|
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
vpn: {
|
||||||
|
...result.vpn,
|
||||||
|
server,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectivityDiagnosticsUseCase = ReturnType<
|
||||||
|
typeof createConnectivityDiagnosticsUseCase
|
||||||
|
>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
createConnectivityDiagnosticsUseCase,
|
||||||
|
type ConnectivityDiagnosticsUseCase,
|
||||||
|
} from './connectivityDiagnosticsUseCase.js';
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface HostNetworkState {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface GatewayAutoServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(
|
||||||
|
subscriptionConfig: unknown,
|
||||||
|
selectedServerId: string,
|
||||||
|
routeRules: StoredState['routeRules'],
|
||||||
|
gatewayAuto: GatewayAutoState,
|
||||||
|
): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): boolean;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
discovery: {
|
||||||
|
readHostNetwork(): HostNetworkState | null;
|
||||||
|
probeGateway(input: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: string;
|
||||||
|
}): Promise<VerifiedGateway>;
|
||||||
|
};
|
||||||
|
transition: {
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
applyPreference(state: GatewayAutoState, enabled: boolean): GatewayAutoState;
|
||||||
|
next(
|
||||||
|
current: GatewayAutoState,
|
||||||
|
input: {
|
||||||
|
network: HostNetworkState | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
): GatewayAutoState;
|
||||||
|
sameRoute(
|
||||||
|
previous: GatewayAutoState['gateway'] | HostNetworkState | null | undefined,
|
||||||
|
current: HostNetworkState | null | undefined,
|
||||||
|
): boolean;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(timer: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRouteChange(state: GatewayAutoState): void;
|
||||||
|
onDiscoveryWarning(reason: string): void;
|
||||||
|
onTimerError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
persistEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error && typeof error === 'object' && 'message' in error && error.message
|
||||||
|
? String(error.message)
|
||||||
|
: 'Gateway presence check failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoService(dependencies: GatewayAutoServiceDependencies) {
|
||||||
|
let current = dependencies.transition.createInitial();
|
||||||
|
let refreshPromise: Promise<GatewayAutoState> | null = null;
|
||||||
|
let discoveryTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCandidate = async (
|
||||||
|
candidate: GatewayAutoState,
|
||||||
|
{ reconfigure = true, persistEnabled }: CommitOptions = {},
|
||||||
|
) => {
|
||||||
|
const previousGatewayAuto = current;
|
||||||
|
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
|
||||||
|
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
|
||||||
|
if (!stateChanged && persistEnabled === undefined) return current;
|
||||||
|
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const subscriptionConfig = modeChanged
|
||||||
|
? dependencies.subscription.readConfig()
|
||||||
|
: null;
|
||||||
|
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
|
||||||
|
? dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
previousState.selectedServerId,
|
||||||
|
previousState.routeRules,
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
|
||||||
|
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let gatewayAutoPublished = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (candidateConfig !== null) {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
if (reconfigure && wasRunning) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) {
|
||||||
|
current = candidate;
|
||||||
|
gatewayAutoPublished = true;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => state);
|
||||||
|
}
|
||||||
|
if (persistEnabled !== undefined) {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
gatewayAutoEnabled: persistEnabled,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||||
|
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Gateway auto rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const network = state.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!network) {
|
||||||
|
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||||
|
const discoveredState = dependencies.transition.next(current, {
|
||||||
|
network: null,
|
||||||
|
error: discoveryError,
|
||||||
|
});
|
||||||
|
const candidate = dependencies.transition.applyPreference(
|
||||||
|
state.subscriptionUrl
|
||||||
|
? { ...discoveredState, lastError: discoveryError }
|
||||||
|
: discoveredState,
|
||||||
|
state.gatewayAutoEnabled !== false,
|
||||||
|
);
|
||||||
|
return commitCandidate(candidate, { reconfigure });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.mode === 'gateway-direct' &&
|
||||||
|
!dependencies.transition.sameRoute(current.gateway, network)
|
||||||
|
) {
|
||||||
|
await commitCandidate(
|
||||||
|
dependencies.transition.next(current, { network }),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verifiedGateway: VerifiedGateway;
|
||||||
|
try {
|
||||||
|
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||||
|
gateway: network.gateway,
|
||||||
|
subscriptionUrl: String(state.subscriptionUrl),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = errorMessage(error);
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestNetwork = latestState.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
if (current.lastError !== reason) dependencies.onDiscoveryWarning(reason);
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, {
|
||||||
|
network: latestNetwork,
|
||||||
|
error: reason,
|
||||||
|
}),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestNetwork = latestState.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, { network: latestNetwork, verifiedGateway }),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = (options: RefreshOptions = {}) => {
|
||||||
|
if (dependencies.appMode !== 'client') return Promise.resolve(current);
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
refreshPromise = dependencies.serialize(() => runRefresh(options)).finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => dependencies.serialize(() => commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(current, enabled),
|
||||||
|
{ persistEnabled: enabled },
|
||||||
|
));
|
||||||
|
|
||||||
|
const startDiscovery = (intervalMs: number) => {
|
||||||
|
if (discoveryTimer) return;
|
||||||
|
discoveryTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
void refresh().catch(dependencies.onTimerError);
|
||||||
|
}, intervalMs);
|
||||||
|
discoveryTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDiscovery = () => {
|
||||||
|
if (!discoveryTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(discoveryTimer);
|
||||||
|
discoveryTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
read: () => current,
|
||||||
|
set: (value: GatewayAutoState) => { current = value; },
|
||||||
|
createInitial: dependencies.transition.createInitial,
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
startDiscovery,
|
||||||
|
stopDiscovery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayAutoService = ReturnType<typeof createGatewayAutoService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createRouteRulesService,
|
||||||
|
type RouteRulesService,
|
||||||
|
} from './routeRulesService.js';
|
||||||
|
export {
|
||||||
|
createGatewayAutoService,
|
||||||
|
type GatewayAutoService,
|
||||||
|
} from './gatewayAutoService.js';
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { normalizeRouteRules } from '../../../shared/routingRules.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface RouteRulesDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||||
|
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
||||||
|
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||||
|
if (!previousState.selectedServerId || !subscriptionConfig) {
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
try {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
previousState.selectedServerId,
|
||||||
|
routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
if (wasRunning) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => {
|
||||||
|
let routeRules: RouteRule[];
|
||||||
|
try {
|
||||||
|
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
const currentRevision = expectedRulesRevision == null
|
||||||
|
? current.revision
|
||||||
|
: current.routeRulesRevision;
|
||||||
|
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||||
|
await dependencies.runOperation(() => applyRules(current, routeRules));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteRulesService = ReturnType<typeof createRouteRulesService>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
checkServerHealth,
|
||||||
|
createServerHealthService,
|
||||||
|
SERVER_HEALTH_CONCURRENCY,
|
||||||
|
SERVER_HEALTH_MAX_COUNT,
|
||||||
|
type ServerHealthService,
|
||||||
|
} from './serverHealth.js';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { HarborServer } from '../../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||||
|
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
type HealthServer = Pick<HarborServer, 'id' | 'label' | 'host' | 'port'>;
|
||||||
|
type Ping = (host: string, port: number) => Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export async function checkServerHealth(
|
||||||
|
servers: HealthServer[],
|
||||||
|
ping: Ping,
|
||||||
|
{
|
||||||
|
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||||
|
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const queue = servers.slice(0, maxCount);
|
||||||
|
const results: Array<Record<string, unknown>> = new Array(queue.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (nextIndex < queue.length) {
|
||||||
|
const index = nextIndex++;
|
||||||
|
const server = queue[index];
|
||||||
|
results[index] = {
|
||||||
|
id: server.id,
|
||||||
|
tag: server.label,
|
||||||
|
...await ping(server.host, server.port),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerHealthDependencies {
|
||||||
|
readServers(): HarborServer[];
|
||||||
|
ping: Ping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthService(dependencies: ServerHealthDependencies) {
|
||||||
|
return {
|
||||||
|
check(serverIds: unknown) {
|
||||||
|
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||||
|
const servers = dependencies.readServers();
|
||||||
|
return checkServerHealth(
|
||||||
|
requestedIds.size
|
||||||
|
? servers.filter((server) => requestedIds.has(server.id))
|
||||||
|
: servers,
|
||||||
|
dependencies.ping,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerHealthService = ReturnType<typeof createServerHealthService>;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
createStateSnapshot,
|
||||||
|
normalizeStoredState,
|
||||||
|
type GatewayAutoState,
|
||||||
|
type OperationState,
|
||||||
|
type StateSnapshot,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
interface RuntimeState {
|
||||||
|
running?: boolean;
|
||||||
|
startedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateReadResult {
|
||||||
|
snapshot: StateSnapshot;
|
||||||
|
storedState: StoredState;
|
||||||
|
gatewayAuto: GatewayAutoState;
|
||||||
|
configExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readStoredState: () => unknown;
|
||||||
|
refreshRuntime: () => Promise<RuntimeState>;
|
||||||
|
getGatewayAutoState: () => GatewayAutoState;
|
||||||
|
getOperationState: () => OperationState;
|
||||||
|
configExists: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionHost(value: unknown) {
|
||||||
|
try {
|
||||||
|
return `${new URL(String(value)).host}/…`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateService(dependencies: StateServiceDependencies) {
|
||||||
|
return {
|
||||||
|
async read(): Promise<StateReadResult> {
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
const storedState = normalizeStoredState(dependencies.readStoredState());
|
||||||
|
const gatewayAuto = dependencies.getGatewayAutoState();
|
||||||
|
const configExists = dependencies.configExists();
|
||||||
|
const snapshot = createStateSnapshot({
|
||||||
|
storedState,
|
||||||
|
runtime,
|
||||||
|
gatewayAuto,
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
configExists,
|
||||||
|
subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
|
||||||
|
operation: dependencies.getOperationState(),
|
||||||
|
});
|
||||||
|
return { snapshot, storedState, gatewayAuto, configExists };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateService = ReturnType<typeof createStateService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createValidateSubscription,
|
||||||
|
type ValidateSubscription,
|
||||||
|
} from './validateSubscription.js';
|
||||||
|
export {
|
||||||
|
createSubscriptionService,
|
||||||
|
type SubscriptionService,
|
||||||
|
} from './subscriptionService.js';
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type {
|
||||||
|
GatewayAutoState,
|
||||||
|
HarborServer,
|
||||||
|
StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface ParsedSubscription {
|
||||||
|
config: unknown;
|
||||||
|
sourceConfig?: unknown;
|
||||||
|
servers: HarborServer[];
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface SubscriptionServiceDependencies {
|
||||||
|
provider: {
|
||||||
|
fetchSubscription(url: string): Promise<ParsedSubscription>;
|
||||||
|
selectRefreshedServer(
|
||||||
|
currentServerId: string,
|
||||||
|
currentServers: HarborServer[],
|
||||||
|
nextServers: HarborServer[],
|
||||||
|
): string;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
read(): unknown;
|
||||||
|
write(value: unknown): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
gatewayAuto: {
|
||||||
|
read(): GatewayAutoState;
|
||||||
|
set(value: GatewayAutoState): void;
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(handle: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRefreshError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetOptions {
|
||||||
|
stopRuntime?: boolean;
|
||||||
|
expectedSubscription?: {
|
||||||
|
url: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionMutationResult extends Record<string, unknown> {
|
||||||
|
success: true;
|
||||||
|
servers: HarborServer[];
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string;
|
||||||
|
selectedServerId: string;
|
||||||
|
selectedTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||||
|
'SUBSCRIPTION_EXPIRED',
|
||||||
|
'SUBSCRIPTION_DISABLED',
|
||||||
|
'SUBSCRIPTION_REJECTED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||||
|
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
|
||||||
|
let refreshTimer: TimerHandle | null = null;
|
||||||
|
let subscriptionGeneration = 0;
|
||||||
|
|
||||||
|
const restoreCache = (previous: unknown) => {
|
||||||
|
if (previous !== null) dependencies.cache.write(previous);
|
||||||
|
else dependencies.cache.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitSubscription = (
|
||||||
|
subscriptionUrl: string,
|
||||||
|
parsed: ParsedSubscription,
|
||||||
|
{ resetSelection = false, expectedGeneration }: {
|
||||||
|
resetSelection?: boolean;
|
||||||
|
expectedGeneration?: number;
|
||||||
|
} = {},
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (
|
||||||
|
subscriptionGeneration !== expectedGeneration ||
|
||||||
|
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
|
||||||
|
) {
|
||||||
|
throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedServerId = resetSelection
|
||||||
|
? ''
|
||||||
|
: dependencies.provider.selectRefreshedServer(
|
||||||
|
previousState.selectedServerId,
|
||||||
|
previousState.servers,
|
||||||
|
parsed.servers,
|
||||||
|
);
|
||||||
|
const candidateConfig = selectedServerId
|
||||||
|
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
|
||||||
|
: null;
|
||||||
|
const previousCache = dependencies.cache.read();
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let restoreRuntime = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((resetSelection || !candidateConfig) && wasRunning) {
|
||||||
|
restoreRuntime = true;
|
||||||
|
await dependencies.runtime.stop();
|
||||||
|
}
|
||||||
|
if (candidateConfig) dependencies.config.write(candidateConfig);
|
||||||
|
else dependencies.config.remove();
|
||||||
|
dependencies.cache.write({
|
||||||
|
url: subscriptionUrl,
|
||||||
|
config: parsed.sourceConfig || parsed.config,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
});
|
||||||
|
if (!resetSelection && wasRunning && candidateConfig) {
|
||||||
|
restoreRuntime = true;
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
}
|
||||||
|
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||||
|
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...(resetSelection ? {
|
||||||
|
routeRules: state.routeRules,
|
||||||
|
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||||
|
connectionDesired: 'stopped',
|
||||||
|
} : state),
|
||||||
|
subscriptionUrl,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
selectedServerId,
|
||||||
|
appliedServerId: selectedServerId,
|
||||||
|
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||||
|
}));
|
||||||
|
subscriptionGeneration += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||||
|
{ run: () => restoreCache(previousCache) },
|
||||||
|
{ run: () => restoreConfig(previousConfig) },
|
||||||
|
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||||
|
], 'Subscription rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
selectedServerId,
|
||||||
|
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const importSubscription = async (subscriptionUrl: string) => {
|
||||||
|
const expectedGeneration = subscriptionGeneration;
|
||||||
|
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||||
|
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSavedSubscription = ({
|
||||||
|
stopRuntime = true,
|
||||||
|
expectedSubscription,
|
||||||
|
}: ResetOptions = {}) => (
|
||||||
|
dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (expectedSubscription && (
|
||||||
|
previousState.subscriptionUrl !== expectedSubscription.url ||
|
||||||
|
subscriptionGeneration !== expectedSubscription.generation
|
||||||
|
)) return false;
|
||||||
|
const previousCache = dependencies.cache.read();
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||||
|
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
|
||||||
|
let restoreRuntime = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (stopRuntime) {
|
||||||
|
restoreRuntime = wasRunning;
|
||||||
|
await dependencies.runtime.stop();
|
||||||
|
}
|
||||||
|
dependencies.config.remove();
|
||||||
|
dependencies.cache.remove();
|
||||||
|
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
|
||||||
|
subscriptionGeneration += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||||
|
{ run: () => restoreCache(previousCache) },
|
||||||
|
{ run: () => restoreConfig(previousConfig) },
|
||||||
|
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||||
|
], 'Subscription rollback failed');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshSavedSubscription = () => {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
|
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
|
||||||
|
const expectedGeneration = subscriptionGeneration;
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||||
|
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||||
|
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error
|
||||||
|
? String(error.code)
|
||||||
|
: '';
|
||||||
|
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
|
||||||
|
const reset = await resetSavedSubscription({
|
||||||
|
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
|
||||||
|
});
|
||||||
|
if (!reset) throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
refreshPromise = operation;
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRefresh = (intervalMs: number) => {
|
||||||
|
if (refreshTimer) return;
|
||||||
|
refreshTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
if (!dependencies.state.read().subscriptionUrl) return;
|
||||||
|
void refreshSavedSubscription().catch(dependencies.onRefreshError);
|
||||||
|
}, intervalMs);
|
||||||
|
refreshTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (!refreshTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
importSubscription,
|
||||||
|
refreshSavedSubscription,
|
||||||
|
resetSavedSubscription,
|
||||||
|
startAutoRefresh,
|
||||||
|
stopAutoRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionService = ReturnType<typeof createSubscriptionService>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
interface SubscriptionResult {
|
||||||
|
servers: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchSubscription = (url: string) => Promise<SubscriptionResult>;
|
||||||
|
|
||||||
|
export function createValidateSubscription(fetchSubscription: FetchSubscription) {
|
||||||
|
return async (url: unknown) => {
|
||||||
|
const parsed = await fetchSubscription(String(url).trim());
|
||||||
|
return { servers: parsed.servers.length };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateSubscription = ReturnType<typeof createValidateSubscription>;
|
||||||
@@ -8,14 +8,43 @@ const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/;
|
|||||||
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
|
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
|
||||||
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
|
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
|
||||||
|
|
||||||
function isIpv4(value) {
|
interface GatewayRoute {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAutoRuntimeState {
|
||||||
|
mode: 'local-vpn' | 'gateway-direct';
|
||||||
|
failures: number;
|
||||||
|
gateway: GatewayRoute | null;
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
lastVerifiedAt: string | null;
|
||||||
|
lastError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: unknown) {
|
||||||
const parts = String(value || '').split('.');
|
const parts = String(value || '').split('.');
|
||||||
return parts.length === 4 && parts.every((part) => (
|
return parts.length === 4 && parts.every((part) => (
|
||||||
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
|
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscriptionSecret(subscriptionUrl) {
|
function subscriptionSecret(subscriptionUrl: unknown) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(String(subscriptionUrl || '').trim());
|
const url = new URL(String(subscriptionUrl || '').trim());
|
||||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||||
@@ -39,7 +68,7 @@ function subscriptionSecret(subscriptionUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
function presenceProof(subscriptionUrl: unknown, nonce: unknown, gatewayId: unknown) {
|
||||||
const credentialUrl = subscriptionSecret(subscriptionUrl);
|
const credentialUrl = subscriptionSecret(subscriptionUrl);
|
||||||
if (!credentialUrl) return '';
|
if (!credentialUrl) return '';
|
||||||
const key = crypto.createHash('sha256')
|
const key = crypto.createHash('sha256')
|
||||||
@@ -50,7 +79,12 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
|
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }: {
|
||||||
|
appMode: unknown;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
gatewayId: unknown;
|
||||||
|
nonce: unknown;
|
||||||
|
}) {
|
||||||
if (!NONCE_RE.test(String(nonce || ''))) {
|
if (!NONCE_RE.test(String(nonce || ''))) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
@@ -79,7 +113,11 @@ export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonc
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
|
export function verifyGatewayPresence(
|
||||||
|
value: unknown,
|
||||||
|
{ subscriptionUrl, nonce }: { subscriptionUrl: unknown; nonce: unknown },
|
||||||
|
) {
|
||||||
|
const payload = record(value);
|
||||||
if (
|
if (
|
||||||
payload?.available !== true ||
|
payload?.available !== true ||
|
||||||
payload?.product !== 'harbor' ||
|
payload?.product !== 'harbor' ||
|
||||||
@@ -91,7 +129,7 @@ export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
|
|||||||
!PROOF_RE.test(String(payload.proof || ''))
|
!PROOF_RE.test(String(payload.proof || ''))
|
||||||
) return false;
|
) return false;
|
||||||
|
|
||||||
const actual = Buffer.from(payload.proof, 'hex');
|
const actual = Buffer.from(String(payload.proof), 'hex');
|
||||||
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
|
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
|
||||||
if (!expectedProof) return false;
|
if (!expectedProof) return false;
|
||||||
const expected = Buffer.from(expectedProof, 'hex');
|
const expected = Buffer.from(expectedProof, 'hex');
|
||||||
@@ -105,7 +143,14 @@ export async function probeGatewayPresence({
|
|||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
timeoutMs = 1000,
|
timeoutMs = 1000,
|
||||||
nonce = crypto.randomBytes(16).toString('hex'),
|
nonce = crypto.randomBytes(16).toString('hex'),
|
||||||
}) {
|
}: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
port?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
timeoutMs?: number;
|
||||||
|
nonce?: string;
|
||||||
|
}): Promise<VerifiedGateway> {
|
||||||
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
||||||
|
|
||||||
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
|
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
|
||||||
@@ -113,26 +158,27 @@ export async function probeGatewayPresence({
|
|||||||
presenceUrl,
|
presenceUrl,
|
||||||
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
||||||
);
|
);
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = record(await response.json().catch(() => ({})));
|
||||||
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
||||||
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gatewayId: payload.gatewayId,
|
gatewayId: String(payload.gatewayId),
|
||||||
uiOrigin: new URL(presenceUrl).origin,
|
uiOrigin: new URL(presenceUrl).origin,
|
||||||
verifiedAt: new Date().toISOString(),
|
verifiedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHostNetworkState(value, {
|
export function normalizeHostNetworkState(value: unknown, {
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
maxAgeMs = 15_000,
|
maxAgeMs = 15_000,
|
||||||
} = {}) {
|
}: { now?: number; maxAgeMs?: number } = {}): GatewayRoute | null {
|
||||||
const gateway = String(value?.gateway || '').trim();
|
const candidate = record(value);
|
||||||
const networkInterface = String(value?.interface || '').trim();
|
const gateway = String(candidate.gateway || '').trim();
|
||||||
const mac = String(value?.mac || '').trim().toLowerCase();
|
const networkInterface = String(candidate.interface || '').trim();
|
||||||
const observedAt = Date.parse(value?.observedAt || '');
|
const mac = String(candidate.mac || '').trim().toLowerCase();
|
||||||
|
const observedAt = Date.parse(String(candidate.observedAt || ''));
|
||||||
|
|
||||||
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
|
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
|
||||||
if (
|
if (
|
||||||
@@ -147,7 +193,7 @@ export function normalizeHostNetworkState(value, {
|
|||||||
return { gateway, interface: networkInterface, mac, observedAt };
|
return { gateway, interface: networkInterface, mac, observedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readHostNetworkState(filePath, options) {
|
export function readHostNetworkState(filePath: string, options?: { now?: number; maxAgeMs?: number }) {
|
||||||
try {
|
try {
|
||||||
return normalizeHostNetworkState(
|
return normalizeHostNetworkState(
|
||||||
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||||
@@ -158,7 +204,7 @@ export function readHostNetworkState(filePath, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameGatewayRoute(previous, current) {
|
export function sameGatewayRoute(previous: GatewayRoute | null, current: GatewayRoute | null) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
previous &&
|
previous &&
|
||||||
current &&
|
current &&
|
||||||
@@ -168,7 +214,7 @@ export function sameGatewayRoute(previous, current) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGatewayAutoState() {
|
export function createGatewayAutoState(): GatewayAutoRuntimeState {
|
||||||
return {
|
return {
|
||||||
mode: 'local-vpn',
|
mode: 'local-vpn',
|
||||||
failures: 0,
|
failures: 0,
|
||||||
@@ -180,18 +226,22 @@ export function createGatewayAutoState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyGatewayPreference(state, enabled) {
|
export function applyGatewayPreference(state: GatewayAutoRuntimeState, enabled: boolean): GatewayAutoRuntimeState {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextGatewayAutoState(current, {
|
export function nextGatewayAutoState(current: GatewayAutoRuntimeState, {
|
||||||
network,
|
network,
|
||||||
verifiedGateway = null,
|
verifiedGateway = null,
|
||||||
error = 'Gateway presence check failed',
|
error = 'Gateway presence check failed',
|
||||||
}) {
|
}: {
|
||||||
|
network: GatewayRoute | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: unknown;
|
||||||
|
}): GatewayAutoRuntimeState {
|
||||||
if (!network) {
|
if (!network) {
|
||||||
if (!current.gatewayId) return createGatewayAutoState();
|
if (!current.gatewayId) return createGatewayAutoState();
|
||||||
return {
|
return {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
const options = { encoding: 'utf8' };
|
const options = { encoding: 'utf8' as const };
|
||||||
|
|
||||||
export function setGatewayInterception(enabled, chain, run = spawnSync) {
|
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) {
|
||||||
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
|
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
|
||||||
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
|
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeHarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
|
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactLogDetails(value: unknown) {
|
||||||
|
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendError(res: ServerResponse, error: unknown) {
|
||||||
|
const harborError = normalizeHarborError(error);
|
||||||
|
const correlationId = crypto.randomUUID();
|
||||||
|
const technical = harborError.cause instanceof Error
|
||||||
|
? harborError.cause.message
|
||||||
|
: harborError.details || error;
|
||||||
|
const technicalMessage = technical instanceof Error
|
||||||
|
? technical.message
|
||||||
|
: technical;
|
||||||
|
console.error(
|
||||||
|
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technicalMessage)}`,
|
||||||
|
);
|
||||||
|
return sendJson(res, harborError.status, {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: harborError.code,
|
||||||
|
message: harborError.message,
|
||||||
|
retryable: harborError.retryable,
|
||||||
|
correlationId,
|
||||||
|
...(harborError.details ? { details: harborError.details } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ConnectionRuntimeRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'stop' | 'restart'>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { singboxRunning: boolean }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionRuntimeRoute(dependencies: ConnectionRuntimeRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await dependencies.withOperation('stop', () => dependencies.connection.stop());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
await dependencies.withOperation('start', () => dependencies.connection.restart());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/index.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsRoute(
|
||||||
|
dependencies: ConnectivityDiagnosticsRouteDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
|
||||||
|
const { services = [], target = null } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.diagnostics.run(services, target);
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DeviceInventoryPort {
|
||||||
|
snapshot(): unknown;
|
||||||
|
refresh(): Promise<unknown>;
|
||||||
|
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||||
|
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInventoryRouteDependencies {
|
||||||
|
deviceInventory: DeviceInventoryPort | null;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||||
|
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||||
|
|
||||||
|
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
|
||||||
|
if (pathname === '/api/devices') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, dependencies.deviceInventory.snapshot());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/devices/refresh') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, await dependencies.deviceInventory.refresh());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||||
|
if (deviceMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const { expectedRevision, ...patch } = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
dependencies.deviceInventory.update(deviceMatch[1], patch, expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyMatch = pathname.match(DEVICE_POLICY_PATH);
|
||||||
|
if (policyMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
await dependencies.deviceInventory.setPolicy(
|
||||||
|
policyMatch[1],
|
||||||
|
body.mode,
|
||||||
|
body.expectedRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { GatewayAutoService } from '../../features/routing/index.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayAutoRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
gatewayAuto: Pick<GatewayAutoService, 'setEnabled'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
readStatePayload(): Promise<Record<string, unknown> & { gatewayAuto?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoRoute(dependencies: GatewayAutoRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/gateway-auto') return false;
|
||||||
|
if (dependencies.appMode !== 'client') throw new HarborError('REQUEST_INVALID');
|
||||||
|
const { enabled } = await dependencies.readBody(req);
|
||||||
|
if (typeof enabled !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||||
|
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'gateway-auto',
|
||||||
|
() => dependencies.gatewayAuto.setEnabled(enabled),
|
||||||
|
);
|
||||||
|
const state = await dependencies.readStatePayload();
|
||||||
|
sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayPresence } from '../../gatewayPresence.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayPresenceState {
|
||||||
|
subscriptionUrl?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayPresenceRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readState(): GatewayPresenceState;
|
||||||
|
getHwid(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayPresenceRoute(dependencies: GatewayPresenceRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (req.method !== 'GET' || requestUrl.pathname !== '/api/gateway-presence') return false;
|
||||||
|
|
||||||
|
const state = dependencies.readState();
|
||||||
|
const gatewayId = dependencies.getHwid();
|
||||||
|
sendJson(res, 200, buildGatewayPresence({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
subscriptionUrl: state.subscriptionUrl,
|
||||||
|
gatewayId,
|
||||||
|
nonce: requestUrl.searchParams.get('nonce'),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendPrometheusMetrics } from '../../prometheusMetrics.js';
|
||||||
|
|
||||||
|
interface MetricsSnapshotPort {
|
||||||
|
metricsSnapshot(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrometheusMetricsRouteDependencies {
|
||||||
|
deviceInventory: MetricsSnapshotPort | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrometheusMetricsRoute(dependencies: PrometheusMetricsRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (pathname !== '/metrics') return false;
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendPrometheusMetrics(res, dependencies.deviceInventory.metricsSnapshot());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { RouteRulesService } from '../../features/routing/index.js';
|
||||||
|
|
||||||
|
interface RouteRulesRouteDependencies {
|
||||||
|
routeRules: RouteRulesService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false;
|
||||||
|
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req);
|
||||||
|
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ServerApplyRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'apply'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||||
|
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'apply-server',
|
||||||
|
() => dependencies.connection.apply(serverId, selectedTag),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ServerHealthService } from '../../features/servers/index.js';
|
||||||
|
|
||||||
|
interface ServerHealthRouteDependencies {
|
||||||
|
serverHealth: ServerHealthService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
|
||||||
|
const { serverIds = [] } = await dependencies.readBody(req);
|
||||||
|
const results = await dependencies.serverHealth.check(serverIds);
|
||||||
|
await dependencies.sendState(res, { results });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildSharedProxyInfo } from '../../sharedProxy.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface SharedProxyRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
proxyPort: unknown;
|
||||||
|
sharedProxyHost: unknown;
|
||||||
|
refreshRuntime(): Promise<{ running?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedProxyRoute(dependencies: SharedProxyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/shared-proxy') return false;
|
||||||
|
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
sendJson(res, 200, buildSharedProxyInfo({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
proxyPort: dependencies.proxyPort,
|
||||||
|
running: runtime.running,
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: dependencies.sharedProxyHost,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeStoredState, type GatewayAutoState, type StateSnapshot } from '../../../shared/contracts/state.js';
|
||||||
|
import type { StateReadResult, StateService } from '../../features/state/stateService.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export interface LegacyStatePayload extends StateSnapshot, Record<string, unknown> {
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
configExists: boolean;
|
||||||
|
singboxRunning: boolean;
|
||||||
|
singboxStartedAt: string | null;
|
||||||
|
subscriptionHost: string;
|
||||||
|
hasSubscription: boolean;
|
||||||
|
selectedTag: string;
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
gatewayAuto: {
|
||||||
|
mode: string;
|
||||||
|
enabled: boolean;
|
||||||
|
available: boolean;
|
||||||
|
address: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
interface: string;
|
||||||
|
failures: number;
|
||||||
|
lastError: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateRouteDependencies {
|
||||||
|
stateService: StateService;
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStateV0Compatibility(
|
||||||
|
{ snapshot, storedState, gatewayAuto, configExists }: StateReadResult,
|
||||||
|
{ port, proxyPort }: Pick<StateRouteDependencies, 'port' | 'proxyPort'>,
|
||||||
|
): LegacyStatePayload {
|
||||||
|
const stored = normalizeStoredState(storedState);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
port,
|
||||||
|
proxyPort,
|
||||||
|
configExists,
|
||||||
|
singboxRunning: snapshot.connection.process === 'running',
|
||||||
|
singboxStartedAt: snapshot.connection.startedAt,
|
||||||
|
subscriptionHost: snapshot.subscription.host,
|
||||||
|
hasSubscription: snapshot.subscription.status === 'ready',
|
||||||
|
selectedTag: stored.selectedTag,
|
||||||
|
userInfo: snapshot.subscription.userInfo,
|
||||||
|
fetchedAt: snapshot.subscription.fetchedAt,
|
||||||
|
gatewayAuto: snapshot.mode === 'client'
|
||||||
|
? legacyGatewayAuto(gatewayAuto, stored.gatewayAutoEnabled !== false)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyGatewayAuto(gatewayAuto: GatewayAutoState, enabled: boolean) {
|
||||||
|
return {
|
||||||
|
mode: gatewayAuto?.mode || 'local-vpn',
|
||||||
|
enabled,
|
||||||
|
available: Boolean(gatewayAuto?.gatewayId),
|
||||||
|
address: gatewayAuto?.gateway?.gateway || '',
|
||||||
|
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||||
|
interface: gatewayAuto?.gateway?.interface || '',
|
||||||
|
failures: Number(gatewayAuto?.failures) || 0,
|
||||||
|
lastError: gatewayAuto?.lastError || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateRoute(dependencies: StateRouteDependencies) {
|
||||||
|
const readPayload = async () => withStateV0Compatibility(
|
||||||
|
await dependencies.stateService.read(),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readPayload,
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/state') return false;
|
||||||
|
sendJson(res, 200, await readPayload());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async send(res: ServerResponse, extra: Record<string, unknown> = {}) {
|
||||||
|
sendJson(res, 200, { success: true, ...extra, state: await readPayload() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateRoute = ReturnType<typeof createStateRoute>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { SubscriptionService } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface SubscriptionMutationRouteDependencies {
|
||||||
|
subscriptionService: Pick<
|
||||||
|
SubscriptionService,
|
||||||
|
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
|
||||||
|
>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'subscription-import',
|
||||||
|
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||||
|
const { success: _success, ...result } = await dependencies.withOperation(
|
||||||
|
'subscription-refresh',
|
||||||
|
() => dependencies.subscriptionService.refreshSavedSubscription(),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'subscription-forget',
|
||||||
|
() => dependencies.subscriptionService.resetSavedSubscription(),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ValidateSubscription } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface SubscriptionValidationRouteDependencies {
|
||||||
|
validateSubscription: ValidateSubscription;
|
||||||
|
readBody: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
||||||
|
sendState: (res: ServerResponse, extra: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionValidationRoute(dependencies: SubscriptionValidationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/subscription/validate') return false;
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
await dependencies.sendState(res, await dependencies.validateSubscription(url));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayVersionInfo } from '../../version.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DataplaneVersionState {
|
||||||
|
gatewayBackendVersion?: unknown;
|
||||||
|
singBoxVersion?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionRouteDependencies {
|
||||||
|
versionInfo: Record<string, unknown>;
|
||||||
|
refreshDataplaneRuntime: (() => Promise<DataplaneVersionState>) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVersionRoute(dependencies: VersionRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/version') return false;
|
||||||
|
|
||||||
|
const payload = dependencies.refreshDataplaneRuntime
|
||||||
|
? buildGatewayVersionInfo(
|
||||||
|
dependencies.versionInfo,
|
||||||
|
await dependencies.refreshDataplaneRuntime(),
|
||||||
|
)
|
||||||
|
: dependencies.versionInfo;
|
||||||
|
sendJson(res, 200, payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,940 +0,0 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import http from 'node:http';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
|
||||||
import { createDataplaneClient } from './dataplaneClient.js';
|
|
||||||
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
|
||||||
import { settings } from './config.js';
|
|
||||||
import {
|
|
||||||
applyGatewayPreference,
|
|
||||||
buildGatewayPresence,
|
|
||||||
createGatewayAutoState,
|
|
||||||
nextGatewayAutoState,
|
|
||||||
probeGatewayPresence,
|
|
||||||
readHostNetworkState,
|
|
||||||
sameGatewayRoute,
|
|
||||||
} from './gatewayPresence.js';
|
|
||||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
|
||||||
import { tcpPing } from './ping.js';
|
|
||||||
import { checkServerHealth } from './serverHealth.js';
|
|
||||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
|
||||||
import {
|
|
||||||
buildGatewayConfig,
|
|
||||||
removeSingboxConfig,
|
|
||||||
restoreSingboxConfig,
|
|
||||||
writeSingboxConfig,
|
|
||||||
} from './singbox.js';
|
|
||||||
import {
|
|
||||||
fetchSubscription,
|
|
||||||
getHwid,
|
|
||||||
normalizeSubscriptionConfig,
|
|
||||||
selectRefreshedServer,
|
|
||||||
} from './subscription.js';
|
|
||||||
import {
|
|
||||||
createStateSnapshot,
|
|
||||||
normalizeStoredState,
|
|
||||||
withStateV0Compatibility,
|
|
||||||
} from '../shared/contracts/state.js';
|
|
||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
|
||||||
import { normalizeRouteRules } from '../shared/routingRules.js';
|
|
||||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
|
||||||
import {
|
|
||||||
createDeviceInventoryService,
|
|
||||||
createVendorLookup,
|
|
||||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
|
||||||
migrateDeviceInventoryState,
|
|
||||||
} from './services/deviceInventoryService.js';
|
|
||||||
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
|
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
|
||||||
import { sendPrometheusMetrics } from './prometheusMetrics.js';
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
|
||||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
|
||||||
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
|
||||||
const DEVICE_DISCOVERY_INTERVAL_MS = 15_000;
|
|
||||||
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
|
||||||
'SUBSCRIPTION_EXPIRED',
|
|
||||||
'SUBSCRIPTION_DISABLED',
|
|
||||||
'SUBSCRIPTION_REJECTED',
|
|
||||||
]);
|
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
|
||||||
|
|
||||||
const stateStore = createStateStore(settings.statePath);
|
|
||||||
const subscriptionCacheStore = createJsonStore({
|
|
||||||
filePath: settings.subscriptionCachePath,
|
|
||||||
defaultValue: null,
|
|
||||||
});
|
|
||||||
const deviceStore = createJsonStore({
|
|
||||||
filePath: settings.deviceStatePath,
|
|
||||||
defaultValue: {},
|
|
||||||
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 cacheRecoveryLogged = false;
|
|
||||||
|
|
||||||
function readSubscriptionCache() {
|
|
||||||
const cached = subscriptionCacheStore.read();
|
|
||||||
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
|
||||||
cacheRecoveryLogged = true;
|
|
||||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
return cached?.config
|
|
||||||
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
|
|
||||||
: cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const 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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
|
||||||
const versionInfo = buildVersionInfo(settings.appMode);
|
|
||||||
const singboxRuntime = remoteDataplane
|
|
||||||
? createDataplaneClient(settings.dataplaneSocket)
|
|
||||||
: createSingboxRuntime({
|
|
||||||
configPath: settings.configPath,
|
|
||||||
gateway: settings.appMode === 'gateway',
|
|
||||||
tproxyChain: settings.tproxyChain,
|
|
||||||
});
|
|
||||||
const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane
|
|
||||||
? createDevicePolicyService({
|
|
||||||
chain: settings.devicePolicyChain,
|
|
||||||
tproxyPort: settings.tproxyPort,
|
|
||||||
tproxyMark: settings.tproxyMark,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const deviceInventory = settings.appMode === 'gateway'
|
|
||||||
? createDeviceInventoryService({
|
|
||||||
store: deviceStore,
|
|
||||||
observe: remoteDataplane
|
|
||||||
? () => singboxRuntime.observeDevices()
|
|
||||||
: () => readNeighborSnapshot(),
|
|
||||||
observeTraffic: remoteDataplane
|
|
||||||
? () => singboxRuntime.observeTraffic()
|
|
||||||
: null,
|
|
||||||
observeDomainTraffic: remoteDataplane
|
|
||||||
? () => singboxRuntime.observeDomainTraffic()
|
|
||||||
: null,
|
|
||||||
observePolicy: remoteDataplane
|
|
||||||
? () => singboxRuntime.observeDevicePolicy()
|
|
||||||
: () => localDevicePolicy.snapshot(),
|
|
||||||
applyPolicies: remoteDataplane
|
|
||||||
? (devices) => singboxRuntime.applyDevicePolicies(devices)
|
|
||||||
: (devices) => localDevicePolicy.apply(devices),
|
|
||||||
vendor: createVendorLookup(),
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const localConnectivityDiagnostics = !remoteDataplane
|
|
||||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
|
||||||
: null;
|
|
||||||
let subscriptionRefreshPromise = null;
|
|
||||||
let subscriptionRefreshTimer = null;
|
|
||||||
let gatewayDiscoveryPromise = null;
|
|
||||||
let gatewayDiscoveryTimer = null;
|
|
||||||
let deviceDiscoveryTimer = null;
|
|
||||||
let gatewayAutoState = createGatewayAutoState();
|
|
||||||
let controlOperation = Promise.resolve();
|
|
||||||
let operationState = stateStore.recovery ? {
|
|
||||||
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;
|
|
||||||
|
|
||||||
function updateStoredState(update) {
|
|
||||||
return stateStore.update((stored) => {
|
|
||||||
const current = normalizeStoredState(stored);
|
|
||||||
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
|
|
||||||
revision = Math.max(revision, current.revision) + 1;
|
|
||||||
next.revision = revision;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withOperation(kind, operation) {
|
|
||||||
operationState = {
|
|
||||||
kind,
|
|
||||||
status: 'running',
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
updateStoredState((state) => state);
|
|
||||||
try {
|
|
||||||
const result = await operation();
|
|
||||||
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
|
||||||
updateStoredState((state) => state);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const harborError = normalizeHarborError(error);
|
|
||||||
operationState = {
|
|
||||||
...operationState,
|
|
||||||
status: 'failed',
|
|
||||||
error: harborError.message,
|
|
||||||
};
|
|
||||||
updateStoredState((state) => state);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeControl(operation) {
|
|
||||||
const result = controlOperation.then(operation, operation);
|
|
||||||
// The caller observes result; this settled tail only keeps the next operation runnable.
|
|
||||||
controlOperation = result.then(() => undefined, () => undefined);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
|
||||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
|
||||||
res.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function redactLogDetails(value) {
|
|
||||||
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendError(res, error) {
|
|
||||||
const harborError = normalizeHarborError(error);
|
|
||||||
const correlationId = crypto.randomUUID();
|
|
||||||
const technical = harborError.cause?.message || harborError.details || error;
|
|
||||||
console.error(
|
|
||||||
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
|
|
||||||
);
|
|
||||||
return sendJson(res, harborError.status, {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: harborError.code,
|
|
||||||
message: harborError.message,
|
|
||||||
retryable: harborError.retryable,
|
|
||||||
correlationId,
|
|
||||||
...(harborError.details ? { details: harborError.details } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBody(req) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks = [];
|
|
||||||
let size = 0;
|
|
||||||
let tooLarge = false;
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
if (tooLarge) return;
|
|
||||||
size += chunk.length;
|
|
||||||
if (size > MAX_BODY_BYTES) {
|
|
||||||
tooLarge = true;
|
|
||||||
reject(new HarborError('REQUEST_INVALID'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.on('end', () => {
|
|
||||||
if (tooLarge) return;
|
|
||||||
if (!chunks.length) return resolve({});
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
||||||
} catch (cause) {
|
|
||||||
reject(new HarborError('REQUEST_INVALID', { cause }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscriptionHost(url) {
|
|
||||||
try {
|
|
||||||
return `${new URL(url).host}/…`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActiveConfig(
|
|
||||||
subscriptionConfig,
|
|
||||||
selectedServerId,
|
|
||||||
routeRules = stateStore.read().routeRules,
|
|
||||||
) {
|
|
||||||
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
|
||||||
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
|
||||||
routeRules,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopSingbox = () => singboxRuntime.stop();
|
|
||||||
const startSingbox = () => singboxRuntime.apply();
|
|
||||||
|
|
||||||
function resetSavedSubscription({ stopRuntime = true } = {}) {
|
|
||||||
return serializeControl(async () => {
|
|
||||||
if (stopRuntime) await stopSingbox();
|
|
||||||
removeSingboxConfig();
|
|
||||||
subscriptionCacheStore.remove();
|
|
||||||
updateStoredState((state) => ({ routeRules: state.routeRules }));
|
|
||||||
gatewayAutoState = createGatewayAutoState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publicState() {
|
|
||||||
const runtime = await singboxRuntime.refresh();
|
|
||||||
const state = normalizeStoredState(stateStore.read());
|
|
||||||
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
|
||||||
const configExists = fs.existsSync(settings.configPath);
|
|
||||||
const snapshot = createStateSnapshot({
|
|
||||||
storedState: state,
|
|
||||||
runtime,
|
|
||||||
gatewayAuto: gatewayAutoState,
|
|
||||||
appMode: settings.appMode,
|
|
||||||
configExists,
|
|
||||||
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
|
||||||
operation: operationState,
|
|
||||||
});
|
|
||||||
return withStateV0Compatibility(snapshot, {
|
|
||||||
storedState: { ...state, gatewayAutoEnabled },
|
|
||||||
gatewayAuto: gatewayAutoState,
|
|
||||||
port: settings.port,
|
|
||||||
proxyPort: settings.proxyPort,
|
|
||||||
configExists,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCurrentConfig() {
|
|
||||||
const state = stateStore.read();
|
|
||||||
const cached = readSubscriptionCache();
|
|
||||||
if (!state.selectedServerId || !cached?.config) return false;
|
|
||||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
|
||||||
const previousState = gatewayAutoState;
|
|
||||||
const stateChanged = !isDeepStrictEqual(previousState, nextState);
|
|
||||||
const modeChanged = previousState.mode !== nextState.mode;
|
|
||||||
gatewayAutoState = nextState;
|
|
||||||
if (!modeChanged) {
|
|
||||||
if (stateChanged) updateStoredState((state) => state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
|
||||||
: null;
|
|
||||||
const wasRunning = singboxRuntime.running;
|
|
||||||
try {
|
|
||||||
const configured = writeCurrentConfig();
|
|
||||||
if (reconfigure && configured && wasRunning) await startSingbox();
|
|
||||||
} catch (error) {
|
|
||||||
gatewayAutoState = previousState;
|
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
|
||||||
else restoreSingboxConfig(previousConfig);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateChanged) updateStoredState((state) => state);
|
|
||||||
|
|
||||||
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
|
|
||||||
console.log(`[control] client route: ${nextState.mode}${route}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
|
||||||
if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState);
|
|
||||||
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
|
|
||||||
|
|
||||||
gatewayDiscoveryPromise = serializeControl(async () => {
|
|
||||||
const state = stateStore.read();
|
|
||||||
const network = state.subscriptionUrl
|
|
||||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!network) {
|
|
||||||
const discoveryError = 'macOS default gateway недоступен или устарел';
|
|
||||||
const discoveredState = nextGatewayAutoState(gatewayAutoState, {
|
|
||||||
network: null,
|
|
||||||
error: discoveryError,
|
|
||||||
});
|
|
||||||
const nextState = applyGatewayPreference(
|
|
||||||
state.subscriptionUrl
|
|
||||||
? { ...discoveredState, lastError: discoveryError }
|
|
||||||
: discoveredState,
|
|
||||||
state.gatewayAutoEnabled !== false,
|
|
||||||
);
|
|
||||||
await applyGatewayAutoState(
|
|
||||||
nextState,
|
|
||||||
{ reconfigure },
|
|
||||||
);
|
|
||||||
return gatewayAutoState;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
gatewayAutoState.mode === 'gateway-direct' &&
|
|
||||||
!sameGatewayRoute(gatewayAutoState.gateway, network)
|
|
||||||
) {
|
|
||||||
await applyGatewayAutoState(
|
|
||||||
nextGatewayAutoState(gatewayAutoState, { network }),
|
|
||||||
{ reconfigure },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verifiedGateway = await probeGatewayPresence({
|
|
||||||
gateway: network.gateway,
|
|
||||||
port: settings.gatewayPresencePort,
|
|
||||||
subscriptionUrl: state.subscriptionUrl,
|
|
||||||
});
|
|
||||||
const latestState = stateStore.read();
|
|
||||||
const latestNetwork = latestState.subscriptionUrl
|
|
||||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
|
||||||
!sameGatewayRoute(network, latestNetwork)
|
|
||||||
) {
|
|
||||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
|
||||||
return gatewayAutoState;
|
|
||||||
}
|
|
||||||
await applyGatewayAutoState(
|
|
||||||
applyGatewayPreference(
|
|
||||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
|
||||||
latestState.gatewayAutoEnabled !== false,
|
|
||||||
),
|
|
||||||
{ reconfigure },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const reason = error?.message || 'Gateway presence check failed';
|
|
||||||
const latestState = stateStore.read();
|
|
||||||
const latestNetwork = latestState.subscriptionUrl
|
|
||||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
|
||||||
: null;
|
|
||||||
if (
|
|
||||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
|
||||||
!sameGatewayRoute(network, latestNetwork)
|
|
||||||
) {
|
|
||||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
|
||||||
return gatewayAutoState;
|
|
||||||
}
|
|
||||||
if (gatewayAutoState.lastError !== reason) {
|
|
||||||
console.warn(`[control] Gateway не используется: ${reason}`);
|
|
||||||
}
|
|
||||||
await applyGatewayAutoState(
|
|
||||||
applyGatewayPreference(
|
|
||||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
|
||||||
latestState.gatewayAutoEnabled !== false,
|
|
||||||
),
|
|
||||||
{ reconfigure },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return gatewayAutoState;
|
|
||||||
}).finally(() => {
|
|
||||||
gatewayDiscoveryPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return gatewayDiscoveryPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
|
|
||||||
const cached = readSubscriptionCache();
|
|
||||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
|
||||||
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
|
|
||||||
|
|
||||||
if (persist) {
|
|
||||||
updateStoredState((state) => ({
|
|
||||||
...state,
|
|
||||||
selectedServerId,
|
|
||||||
connectionDesired: 'running',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
|
||||||
: null;
|
|
||||||
writeSingboxConfig(nextConfig);
|
|
||||||
try {
|
|
||||||
await startSingbox();
|
|
||||||
} catch (error) {
|
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
|
||||||
else restoreSingboxConfig(previousConfig);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
updateStoredState((state) => ({
|
|
||||||
...state,
|
|
||||||
...(persist ? {
|
|
||||||
appliedServerId: selectedServerId,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
} : {}),
|
|
||||||
appliedRouteRules: state.routeRules,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyRouteRules(routeRules) {
|
|
||||||
const state = normalizeStoredState(stateStore.read());
|
|
||||||
const cached = readSubscriptionCache();
|
|
||||||
if (!state.selectedServerId || !cached?.config) {
|
|
||||||
updateStoredState((current) => ({
|
|
||||||
...current,
|
|
||||||
routeRules,
|
|
||||||
routeRulesRevision: current.routeRulesRevision + 1,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
|
||||||
: null;
|
|
||||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
|
||||||
try {
|
|
||||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
|
|
||||||
if (wasRunning) await startSingbox();
|
|
||||||
updateStoredState((current) => ({
|
|
||||||
...current,
|
|
||||||
routeRules,
|
|
||||||
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
|
||||||
routeRulesRevision: current.routeRulesRevision + 1,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
|
||||||
else restoreSingboxConfig(previousConfig);
|
|
||||||
if (wasRunning) {
|
|
||||||
try {
|
|
||||||
await startSingbox();
|
|
||||||
} catch (rollbackError) {
|
|
||||||
throw new HarborError('PROCESS_START_FAILED', {
|
|
||||||
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
|
||||||
return serializeControl(async () => {
|
|
||||||
const previousState = normalizeStoredState(stateStore.read());
|
|
||||||
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
|
||||||
throw new HarborError('STATE_CONFLICT');
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedServerId = resetSelection
|
|
||||||
? ''
|
|
||||||
: selectRefreshedServer(
|
|
||||||
previousState.selectedServerId,
|
|
||||||
previousState.servers,
|
|
||||||
parsed.servers,
|
|
||||||
);
|
|
||||||
const candidateConfig = selectedServerId
|
|
||||||
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
|
|
||||||
: null;
|
|
||||||
const previousCache = readSubscriptionCache();
|
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
|
||||||
: null;
|
|
||||||
const previousGatewayAutoState = gatewayAutoState;
|
|
||||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
|
|
||||||
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
|
||||||
else removeSingboxConfig();
|
|
||||||
subscriptionCacheStore.write({
|
|
||||||
url: subscriptionUrl,
|
|
||||||
config: parsed.sourceConfig || parsed.config,
|
|
||||||
servers: parsed.servers,
|
|
||||||
userInfo: parsed.userInfo,
|
|
||||||
fetchedAt: parsed.fetchedAt,
|
|
||||||
});
|
|
||||||
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
|
||||||
|
|
||||||
updateStoredState((state) => ({
|
|
||||||
...(resetSelection ? {
|
|
||||||
routeRules: state.routeRules,
|
|
||||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
|
||||||
connectionDesired: 'stopped',
|
|
||||||
} : state),
|
|
||||||
subscriptionUrl,
|
|
||||||
servers: parsed.servers,
|
|
||||||
userInfo: parsed.userInfo,
|
|
||||||
fetchedAt: parsed.fetchedAt,
|
|
||||||
selectedServerId,
|
|
||||||
appliedServerId: selectedServerId,
|
|
||||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
|
||||||
}));
|
|
||||||
if (resetSelection) gatewayAutoState = createGatewayAutoState();
|
|
||||||
} catch (error) {
|
|
||||||
gatewayAutoState = previousGatewayAutoState;
|
|
||||||
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
|
|
||||||
else subscriptionCacheStore.remove();
|
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
|
||||||
else restoreSingboxConfig(previousConfig);
|
|
||||||
if (wasRunning) {
|
|
||||||
try {
|
|
||||||
await startSingbox();
|
|
||||||
} catch (rollbackError) {
|
|
||||||
throw new HarborError('PROCESS_START_FAILED', {
|
|
||||||
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
servers: parsed.servers,
|
|
||||||
userInfo: parsed.userInfo,
|
|
||||||
fetchedAt: parsed.fetchedAt,
|
|
||||||
selectedServerId,
|
|
||||||
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importSubscription(subscriptionUrl) {
|
|
||||||
const parsed = await fetchSubscription(subscriptionUrl);
|
|
||||||
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshSavedSubscription() {
|
|
||||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
|
||||||
|
|
||||||
subscriptionRefreshPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const subscriptionUrl = stateStore.read().subscriptionUrl;
|
|
||||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
|
||||||
const parsed = await fetchSubscription(subscriptionUrl);
|
|
||||||
return await commitSubscription(subscriptionUrl, parsed);
|
|
||||||
} catch (error) {
|
|
||||||
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
|
|
||||||
await resetSavedSubscription();
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
})().finally(() => {
|
|
||||||
subscriptionRefreshPromise = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return subscriptionRefreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendState(res, extra = {}) {
|
|
||||||
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApi(req, res) {
|
|
||||||
if (req.method === 'GET' && req.url === '/api/state') {
|
|
||||||
return sendJson(res, 200, await publicState());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/version') {
|
|
||||||
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
|
|
||||||
const runtime = await singboxRuntime.refresh();
|
|
||||||
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
|
||||||
return sendJson(res, 200, buildSharedProxyInfo({
|
|
||||||
appMode: settings.appMode,
|
|
||||||
proxyPort: settings.proxyPort,
|
|
||||||
running: (await singboxRuntime.refresh()).running,
|
|
||||||
hostHeader: req.headers.host,
|
|
||||||
sharedProxyHost: settings.sharedProxyHost,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
|
||||||
if (requestUrl.pathname === '/api/devices') {
|
|
||||||
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
|
||||||
return sendJson(res, 200, deviceInventory.snapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestUrl.pathname === '/api/devices/refresh') {
|
|
||||||
if (!deviceInventory || req.method !== 'POST') throw new HarborError('ENDPOINT_NOT_FOUND');
|
|
||||||
return sendJson(res, 200, await deviceInventory.refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/);
|
|
||||||
if (deviceMatch && req.method === 'PUT') {
|
|
||||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
|
||||||
const body = await readBody(req);
|
|
||||||
const { expectedRevision, ...patch } = body;
|
|
||||||
return sendJson(res, 200, deviceInventory.update(deviceMatch[1], patch, expectedRevision));
|
|
||||||
}
|
|
||||||
|
|
||||||
const devicePolicyMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/);
|
|
||||||
if (devicePolicyMatch && req.method === 'PUT') {
|
|
||||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
|
||||||
const body = await readBody(req);
|
|
||||||
return sendJson(res, 200, await deviceInventory.setPolicy(
|
|
||||||
devicePolicyMatch[1],
|
|
||||||
body.mode,
|
|
||||||
body.expectedRevision,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
|
||||||
const state = stateStore.read();
|
|
||||||
return sendJson(res, 200, buildGatewayPresence({
|
|
||||||
appMode: settings.appMode,
|
|
||||||
subscriptionUrl: state.subscriptionUrl,
|
|
||||||
gatewayId: getHwid(),
|
|
||||||
nonce: requestUrl.searchParams.get('nonce'),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
|
||||||
const state = stateStore.read();
|
|
||||||
const { serverIds = [] } = await readBody(req);
|
|
||||||
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
|
||||||
const servers = requestedIds.size
|
|
||||||
? (state.servers || []).filter((server) => requestedIds.has(server.id))
|
|
||||||
: state.servers || [];
|
|
||||||
const results = await checkServerHealth(servers, tcpPing);
|
|
||||||
return sendState(res, { results });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
|
||||||
const { services = [], target = null } = await readBody(req);
|
|
||||||
const state = stateStore.read();
|
|
||||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
|
||||||
const selected = (state.servers || []).find((server) => server.id === appliedServerId);
|
|
||||||
const result = remoteDataplane
|
|
||||||
? await singboxRuntime.runConnectivityDiagnostics(services, target)
|
|
||||||
: await localConnectivityDiagnostics.run({
|
|
||||||
vpnAvailable: (await singboxRuntime.refresh()).running,
|
|
||||||
services,
|
|
||||||
target,
|
|
||||||
});
|
|
||||||
return sendJson(res, 200, {
|
|
||||||
...result,
|
|
||||||
vpn: {
|
|
||||||
...result.vpn,
|
|
||||||
server: selected ? { id: selected.id, label: selected.label } : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
|
||||||
const { url = '' } = await readBody(req);
|
|
||||||
const normalizedUrl = String(url).trim();
|
|
||||||
const parsed = await withOperation('subscription-import', async () => {
|
|
||||||
return importSubscription(normalizedUrl);
|
|
||||||
});
|
|
||||||
return sendState(res, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
|
|
||||||
const { url = '' } = await readBody(req);
|
|
||||||
const parsed = await fetchSubscription(String(url).trim());
|
|
||||||
return sendState(res, { servers: parsed.servers.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
|
||||||
const { success, ...result } = await withOperation(
|
|
||||||
'subscription-refresh',
|
|
||||||
() => refreshSavedSubscription(),
|
|
||||||
);
|
|
||||||
return sendState(res, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
|
||||||
if (settings.appMode !== 'client') {
|
|
||||||
throw new HarborError('REQUEST_INVALID');
|
|
||||||
}
|
|
||||||
const { enabled } = await readBody(req);
|
|
||||||
if (typeof enabled !== 'boolean') {
|
|
||||||
throw new HarborError('REQUEST_INVALID');
|
|
||||||
}
|
|
||||||
await withOperation('gateway-auto', () => serializeControl(async () => {
|
|
||||||
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
|
||||||
updateStoredState((state) => ({
|
|
||||||
...state,
|
|
||||||
gatewayAutoEnabled: enabled,
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
const state = await publicState();
|
|
||||||
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'PUT' && req.url === '/api/route-rules') {
|
|
||||||
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
|
|
||||||
let routeRules;
|
|
||||||
try {
|
|
||||||
routeRules = normalizeRouteRules(rules, { strict: true });
|
|
||||||
} catch (cause) {
|
|
||||||
throw new HarborError('REQUEST_INVALID', { cause });
|
|
||||||
}
|
|
||||||
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
|
||||||
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
|
|
||||||
throw new HarborError('REQUEST_INVALID');
|
|
||||||
}
|
|
||||||
await serializeControl(async () => {
|
|
||||||
const current = normalizeStoredState(stateStore.read());
|
|
||||||
const currentRevision = expectedRulesRevision == null
|
|
||||||
? current.revision
|
|
||||||
: current.routeRulesRevision;
|
|
||||||
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
|
||||||
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
|
||||||
await withOperation('route-rules', () => applyRouteRules(routeRules));
|
|
||||||
});
|
|
||||||
return sendState(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
|
||||||
await withOperation('subscription-forget', () => resetSavedSubscription());
|
|
||||||
return sendState(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
|
||||||
const { serverId = '', selectedTag = '' } = await readBody(req);
|
|
||||||
const state = normalizeStoredState(stateStore.read());
|
|
||||||
const id = String(serverId).trim() || (() => {
|
|
||||||
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
|
|
||||||
return matches.length === 1 ? matches[0].id : '';
|
|
||||||
})();
|
|
||||||
if (!id || !state.servers.some((server) => server.id === id)) {
|
|
||||||
throw new HarborError('SERVER_NOT_FOUND');
|
|
||||||
}
|
|
||||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
|
|
||||||
return sendState(res, {
|
|
||||||
serverId: id,
|
|
||||||
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
|
||||||
await withOperation('stop', () => serializeControl(async () => {
|
|
||||||
await stopSingbox();
|
|
||||||
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
|
|
||||||
}));
|
|
||||||
return sendState(res, { singboxRunning: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
|
||||||
await withOperation('start', () => serializeControl(async () => {
|
|
||||||
if (!fs.existsSync(settings.configPath)) {
|
|
||||||
throw new HarborError('CONFIG_INVALID');
|
|
||||||
}
|
|
||||||
await singboxRuntime.restart();
|
|
||||||
updateStoredState((state) => ({
|
|
||||||
...state,
|
|
||||||
appliedServerId: state.selectedServerId,
|
|
||||||
connectionDesired: 'running',
|
|
||||||
appliedRouteRules: state.routeRules,
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
return sendState(res, { singboxRunning: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mime = {
|
|
||||||
'.html': 'text/html; charset=utf-8',
|
|
||||||
'.js': 'text/javascript; charset=utf-8',
|
|
||||||
'.css': 'text/css; charset=utf-8',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.json': 'application/json; charset=utf-8',
|
|
||||||
};
|
|
||||||
|
|
||||||
function serveStatic(req, res) {
|
|
||||||
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
|
||||||
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
||||||
const filePath = path.resolve(settings.distDir, requested);
|
|
||||||
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
|
||||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
||||||
res.writeHead(403);
|
|
||||||
return res.end('Forbidden');
|
|
||||||
}
|
|
||||||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
|
||||||
? filePath
|
|
||||||
: path.join(settings.distDir, 'index.html');
|
|
||||||
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
|
||||||
fs.createReadStream(finalPath).pipe(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
|
||||||
if (requestUrl.pathname === '/metrics') {
|
|
||||||
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
|
||||||
return sendPrometheusMetrics(res, deviceInventory.metricsSnapshot());
|
|
||||||
}
|
|
||||||
return requestUrl.pathname.startsWith('/api/')
|
|
||||||
? await handleApi(req, res)
|
|
||||||
: serveStatic(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
return sendError(res, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function shutdown() {
|
|
||||||
clearInterval(subscriptionRefreshTimer);
|
|
||||||
clearInterval(gatewayDiscoveryTimer);
|
|
||||||
clearInterval(deviceDiscoveryTimer);
|
|
||||||
await serializeControl(() => singboxRuntime.shutdown());
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGTERM', shutdown);
|
|
||||||
process.on('SIGINT', shutdown);
|
|
||||||
|
|
||||||
await refreshGatewayAutoMode({ reconfigure: false })
|
|
||||||
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
|
||||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
|
||||||
try {
|
|
||||||
writeCurrentConfig();
|
|
||||||
} catch (error) {
|
|
||||||
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
|
||||||
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
|
|
||||||
await resetSavedSubscription({ stopRuntime: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await startSingbox()
|
|
||||||
.then(() => {
|
|
||||||
if (fs.existsSync(settings.configPath)) {
|
|
||||||
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
|
||||||
|
|
||||||
if (deviceInventory) {
|
|
||||||
await deviceInventory.reconcilePolicies()
|
|
||||||
.catch((error) => console.warn(`[control] device policy не применена: ${error.message}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
|
||||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
subscriptionRefreshTimer = setInterval(() => {
|
|
||||||
if (!stateStore.read().subscriptionUrl) return;
|
|
||||||
refreshSavedSubscription()
|
|
||||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
|
||||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
|
||||||
subscriptionRefreshTimer.unref();
|
|
||||||
|
|
||||||
gatewayDiscoveryTimer = setInterval(() => {
|
|
||||||
refreshGatewayAutoMode()
|
|
||||||
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
|
|
||||||
}, GATEWAY_DISCOVERY_INTERVAL_MS);
|
|
||||||
gatewayDiscoveryTimer.unref();
|
|
||||||
|
|
||||||
if (deviceInventory) {
|
|
||||||
deviceInventory.refresh()
|
|
||||||
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
|
|
||||||
deviceDiscoveryTimer = setInterval(() => {
|
|
||||||
deviceInventory.refresh()
|
|
||||||
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
|
|
||||||
}, DEVICE_DISCOVERY_INTERVAL_MS);
|
|
||||||
deviceDiscoveryTimer.unref();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { createDataplaneClient } from './dataplaneClient.js';
|
||||||
|
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
import {
|
||||||
|
applyGatewayPreference,
|
||||||
|
createGatewayAutoState,
|
||||||
|
nextGatewayAutoState,
|
||||||
|
probeGatewayPresence,
|
||||||
|
readHostNetworkState,
|
||||||
|
sameGatewayRoute,
|
||||||
|
} from './gatewayPresence.js';
|
||||||
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
|
import { tcpPing } from './ping.js';
|
||||||
|
import {
|
||||||
|
buildGatewayConfig,
|
||||||
|
removeSingboxConfig,
|
||||||
|
restoreSingboxConfig,
|
||||||
|
writeSingboxConfig,
|
||||||
|
} from './singbox.js';
|
||||||
|
import {
|
||||||
|
fetchSubscription,
|
||||||
|
getHwid,
|
||||||
|
normalizeSubscriptionConfig,
|
||||||
|
selectRefreshedServer,
|
||||||
|
} from './subscription.js';
|
||||||
|
import {
|
||||||
|
normalizeStoredState,
|
||||||
|
type OperationState,
|
||||||
|
type RouteRule,
|
||||||
|
type StoredState,
|
||||||
|
} from '../shared/contracts/state.js';
|
||||||
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
|
import {
|
||||||
|
createDeviceInventoryService,
|
||||||
|
createVendorLookup,
|
||||||
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
|
migrateDeviceInventoryState,
|
||||||
|
type InventoryState,
|
||||||
|
} from './services/deviceInventoryService.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createStateService } from './features/state/stateService.js';
|
||||||
|
import { createStateRoute } from './http/routes/stateRoute.js';
|
||||||
|
import { sendError } from './http/response.js';
|
||||||
|
import {
|
||||||
|
createSubscriptionService,
|
||||||
|
createValidateSubscription,
|
||||||
|
} from './features/subscription/index.js';
|
||||||
|
import { createSubscriptionValidationRoute } from './http/routes/subscriptionValidationRoute.js';
|
||||||
|
import { createSubscriptionMutationRoute } from './http/routes/subscriptionMutationRoute.js';
|
||||||
|
import { createServerHealthService } from './features/servers/index.js';
|
||||||
|
import { createServerHealthRoute } from './http/routes/serverHealthRoute.js';
|
||||||
|
import {
|
||||||
|
captureRuntimeCommand,
|
||||||
|
createConnectionService,
|
||||||
|
} from './features/connection/index.js';
|
||||||
|
import { createServerApplyRoute } from './http/routes/serverApplyRoute.js';
|
||||||
|
import { createConnectionRuntimeRoute } from './http/routes/connectionRuntimeRoute.js';
|
||||||
|
import {
|
||||||
|
createGatewayAutoService,
|
||||||
|
createRouteRulesService,
|
||||||
|
} from './features/routing/index.js';
|
||||||
|
import { createRouteRulesRoute } from './http/routes/routeRulesRoute.js';
|
||||||
|
import { createGatewayAutoRoute } from './http/routes/gatewayAutoRoute.js';
|
||||||
|
import { createDeviceInventoryRoute } from './http/routes/deviceInventoryRoute.js';
|
||||||
|
import { createPrometheusMetricsRoute } from './http/routes/prometheusMetricsRoute.js';
|
||||||
|
import { createConnectivityDiagnosticsUseCase } from './features/diagnostics/index.js';
|
||||||
|
import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDiagnosticsRoute.js';
|
||||||
|
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
||||||
|
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||||
|
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||||
|
const DEVICE_DISCOVERY_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const stateStore = createStateStore(settings.statePath);
|
||||||
|
const subscriptionCacheStore = createJsonStore({
|
||||||
|
filePath: settings.subscriptionCachePath,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
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 cacheRecoveryLogged = false;
|
||||||
|
|
||||||
|
function readRawSubscriptionCache() {
|
||||||
|
const cached = subscriptionCacheStore.read();
|
||||||
|
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||||
|
cacheRecoveryLogged = true;
|
||||||
|
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSubscriptionCache() {
|
||||||
|
const raw = readRawSubscriptionCache();
|
||||||
|
const cached = record(raw);
|
||||||
|
return cached.config
|
||||||
|
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
|
||||||
|
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||||
|
const versionInfo = buildVersionInfo(settings.appMode);
|
||||||
|
const remoteRuntime = remoteDataplane ? createDataplaneClient(settings.dataplaneSocket) : null;
|
||||||
|
const localRuntime = remoteDataplane ? null : createSingboxRuntime({
|
||||||
|
configPath: settings.configPath,
|
||||||
|
gateway: settings.appMode === 'gateway',
|
||||||
|
tproxyChain: settings.tproxyChain,
|
||||||
|
});
|
||||||
|
function selectRuntime() {
|
||||||
|
if (remoteRuntime) return remoteRuntime;
|
||||||
|
if (localRuntime) return localRuntime;
|
||||||
|
throw new Error('Harbor runtime is not configured');
|
||||||
|
}
|
||||||
|
const singboxRuntime = selectRuntime();
|
||||||
|
|
||||||
|
function requireRemoteRuntime() {
|
||||||
|
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
||||||
|
return remoteRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLocalDevicePolicy() {
|
||||||
|
if (!localDevicePolicy) throw new Error('Harbor local device policy is not configured');
|
||||||
|
return localDevicePolicy;
|
||||||
|
}
|
||||||
|
const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane
|
||||||
|
? createDevicePolicyService({
|
||||||
|
chain: settings.devicePolicyChain,
|
||||||
|
tproxyPort: settings.tproxyPort,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const deviceInventory = settings.appMode === 'gateway'
|
||||||
|
? createDeviceInventoryService({
|
||||||
|
store: deviceStore,
|
||||||
|
observe: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDevices()
|
||||||
|
: () => readNeighborSnapshot(),
|
||||||
|
observeTraffic: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeTraffic()
|
||||||
|
: null,
|
||||||
|
observeDomainTraffic: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDomainTraffic()
|
||||||
|
: null,
|
||||||
|
observePolicy: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDevicePolicy()
|
||||||
|
: () => requireLocalDevicePolicy().snapshot(),
|
||||||
|
applyPolicies: remoteDataplane
|
||||||
|
? (devices) => requireRemoteRuntime().applyDevicePolicies(devices)
|
||||||
|
: (devices) => requireLocalDevicePolicy().apply(devices),
|
||||||
|
vendor: createVendorLookup(),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const localConnectivityDiagnostics = !remoteDataplane
|
||||||
|
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function requireLocalConnectivityDiagnostics() {
|
||||||
|
if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured');
|
||||||
|
return localConnectivityDiagnostics;
|
||||||
|
}
|
||||||
|
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
||||||
|
let controlOperation: Promise<unknown> = Promise.resolve();
|
||||||
|
let operationState: OperationState = stateStore.recovery ? {
|
||||||
|
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;
|
||||||
|
const gatewayAutoService = createGatewayAutoService({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
|
||||||
|
buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||||
|
clientDirect: settings.appMode === 'client' && gatewayAuto.mode === 'gateway-direct',
|
||||||
|
routeRules,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: () => Boolean(singboxRuntime.running),
|
||||||
|
applyCommand: () => captureRuntimeCommand(
|
||||||
|
() => startSingbox(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
restoreRunning: () => startSingbox(),
|
||||||
|
},
|
||||||
|
discovery: {
|
||||||
|
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
|
||||||
|
probeGateway: ({ gateway, subscriptionUrl }) => probeGatewayPresence({
|
||||||
|
gateway,
|
||||||
|
port: settings.gatewayPresencePort,
|
||||||
|
subscriptionUrl,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
createInitial: createGatewayAutoState,
|
||||||
|
applyPreference: applyGatewayPreference,
|
||||||
|
next: nextGatewayAutoState,
|
||||||
|
sameRoute: sameGatewayRoute,
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
scheduler: {
|
||||||
|
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
||||||
|
clearInterval: (timer) => clearInterval(timer),
|
||||||
|
},
|
||||||
|
onRouteChange: (state) => {
|
||||||
|
const route = state.gateway?.gateway ? ` (${state.gateway.gateway})` : '';
|
||||||
|
console.log(`[control] client route: ${state.mode}${route}`);
|
||||||
|
},
|
||||||
|
onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`),
|
||||||
|
onTimerError: (error) => console.warn(`[control] Gateway detection failed: ${errorMessage(error)}`),
|
||||||
|
});
|
||||||
|
const stateService = createStateService({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
readStoredState: () => stateStore.read(),
|
||||||
|
refreshRuntime: () => singboxRuntime.refresh(),
|
||||||
|
getGatewayAutoState: gatewayAutoService.read,
|
||||||
|
getOperationState: () => operationState,
|
||||||
|
configExists: () => fs.existsSync(settings.configPath),
|
||||||
|
});
|
||||||
|
const stateRoute = createStateRoute({
|
||||||
|
stateService,
|
||||||
|
port: settings.port,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
});
|
||||||
|
const gatewayAutoRoute = createGatewayAutoRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
gatewayAuto: gatewayAutoService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
readStatePayload: stateRoute.readPayload,
|
||||||
|
});
|
||||||
|
const deviceInventoryRoute = createDeviceInventoryRoute({
|
||||||
|
deviceInventory,
|
||||||
|
readBody,
|
||||||
|
});
|
||||||
|
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||||
|
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||||
|
readState: () => stateStore.read(),
|
||||||
|
runDiagnostics: async (services, target) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
||||||
|
: requireLocalConnectivityDiagnostics().run({
|
||||||
|
vpnAvailable: Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
services,
|
||||||
|
target,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
|
diagnostics: connectivityDiagnostics,
|
||||||
|
readBody,
|
||||||
|
});
|
||||||
|
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
readState: () => stateStore.read(),
|
||||||
|
getHwid,
|
||||||
|
});
|
||||||
|
const sharedProxyRoute = createSharedProxyRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
sharedProxyHost: settings.sharedProxyHost,
|
||||||
|
refreshRuntime: () => singboxRuntime.refresh(),
|
||||||
|
});
|
||||||
|
const versionRoute = createVersionRoute({
|
||||||
|
versionInfo,
|
||||||
|
refreshDataplaneRuntime: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().refresh()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||||
|
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||||
|
readBody,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const subscriptionService = createSubscriptionService({
|
||||||
|
provider: { fetchSubscription, selectRefreshedServer },
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
read: readRawSubscriptionCache,
|
||||||
|
write: (value) => { subscriptionCacheStore.write(value); },
|
||||||
|
remove: () => subscriptionCacheStore.remove(),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
stop: () => stopSingbox(),
|
||||||
|
start: () => startSingbox(),
|
||||||
|
},
|
||||||
|
gatewayAuto: {
|
||||||
|
read: gatewayAutoService.read,
|
||||||
|
set: gatewayAutoService.set,
|
||||||
|
createInitial: gatewayAutoService.createInitial,
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
scheduler: {
|
||||||
|
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
||||||
|
clearInterval: (timer) => clearInterval(timer),
|
||||||
|
},
|
||||||
|
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
|
||||||
|
});
|
||||||
|
const subscriptionMutationRoute = createSubscriptionMutationRoute({
|
||||||
|
subscriptionService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const serverHealthRoute = createServerHealthRoute({
|
||||||
|
serverHealth: createServerHealthService({
|
||||||
|
readServers: () => normalizeStoredState(stateStore.read()).servers,
|
||||||
|
ping: tcpPing,
|
||||||
|
}),
|
||||||
|
readBody,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const connectionService = createConnectionService({
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
exists: () => fs.existsSync(settings.configPath),
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
start: () => startSingbox(),
|
||||||
|
stop: () => stopSingbox(),
|
||||||
|
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
|
||||||
|
restartCommand: () => captureRuntimeCommand(
|
||||||
|
() => singboxRuntime.restart(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
now: () => new Date(),
|
||||||
|
});
|
||||||
|
const serverApplyRoute = createServerApplyRoute({
|
||||||
|
connection: connectionService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const connectionRuntimeRoute = createConnectionRuntimeRoute({
|
||||||
|
connection: connectionService,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const routeRulesService = createRouteRulesService({
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
applyCommand: () => captureRuntimeCommand(
|
||||||
|
() => startSingbox(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
restoreRunning: () => startSingbox(),
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
runOperation: (operation) => withOperation('route-rules', operation),
|
||||||
|
});
|
||||||
|
const routeRulesRoute = createRouteRulesRoute({
|
||||||
|
routeRules: routeRulesService,
|
||||||
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
|
||||||
|
return stateStore.update((stored) => {
|
||||||
|
const current = normalizeStoredState(stored);
|
||||||
|
const schemaVersion = stored.schemaVersion;
|
||||||
|
const next = normalizeStoredState({ schemaVersion, ...update(current) });
|
||||||
|
revision = Math.max(revision, current.revision) + 1;
|
||||||
|
next.revision = revision;
|
||||||
|
return { ...next, schemaVersion };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
operationState = {
|
||||||
|
kind,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const harborError = normalizeHarborError(error);
|
||||||
|
operationState = {
|
||||||
|
...operationState,
|
||||||
|
status: 'failed',
|
||||||
|
error: harborError.message,
|
||||||
|
};
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeControl<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = controlOperation.then(() => operation(), () => operation());
|
||||||
|
// The caller observes result; this settled tail only keeps the next operation runnable.
|
||||||
|
controlOperation = result.then(() => undefined, () => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk: Buffer | string) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
reject(new HarborError('REQUEST_INVALID'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
if (!chunks.length) return resolve({});
|
||||||
|
try {
|
||||||
|
resolve(record(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
|
||||||
|
} catch (cause) {
|
||||||
|
reject(new HarborError('REQUEST_INVALID', { cause }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveConfig(
|
||||||
|
subscriptionConfig: unknown,
|
||||||
|
selectedServerId: string,
|
||||||
|
routeRules: RouteRule[] = stateStore.read().routeRules,
|
||||||
|
) {
|
||||||
|
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||||
|
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
|
||||||
|
routeRules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopSingbox = () => singboxRuntime.stop();
|
||||||
|
const startSingbox = () => singboxRuntime.apply();
|
||||||
|
|
||||||
|
function writeCurrentConfig() {
|
||||||
|
const state = stateStore.read();
|
||||||
|
const cached = readSubscriptionCache();
|
||||||
|
if (!state.selectedServerId || !cached?.config) return false;
|
||||||
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (await stateRoute.handle(req, res)) return;
|
||||||
|
if (await subscriptionValidationRoute.handle(req, res)) return;
|
||||||
|
if (await subscriptionMutationRoute.handle(req, res)) return;
|
||||||
|
if (await serverHealthRoute.handle(req, res)) return;
|
||||||
|
if (await serverApplyRoute.handle(req, res)) return;
|
||||||
|
if (await connectionRuntimeRoute.handle(req, res)) return;
|
||||||
|
if (await routeRulesRoute.handle(req, res)) return;
|
||||||
|
if (await gatewayAutoRoute.handle(req, res)) return;
|
||||||
|
if (await connectivityDiagnosticsRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await versionRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await sharedProxyRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await deviceInventoryRoute.handle(req, res)) return;
|
||||||
|
if (await gatewayPresenceRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', `http://localhost:${settings.port}`).pathname;
|
||||||
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||||
|
const filePath = path.resolve(settings.distDir, requested);
|
||||||
|
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
return res.end('Forbidden');
|
||||||
|
}
|
||||||
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: path.join(settings.distDir, 'index.html');
|
||||||
|
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
||||||
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (await prometheusMetricsRoute.handle(req, res)) return;
|
||||||
|
const requestUrl = new URL(req.url || '/', `http://localhost:${settings.port}`);
|
||||||
|
return requestUrl.pathname.startsWith('/api/')
|
||||||
|
? await handleApi(req, res)
|
||||||
|
: serveStatic(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
subscriptionService.stopAutoRefresh();
|
||||||
|
gatewayAutoService.stopDiscovery();
|
||||||
|
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||||
|
await serializeControl(() => singboxRuntime.shutdown());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
await gatewayAutoService.refresh({ reconfigure: false })
|
||||||
|
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
|
||||||
|
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||||
|
try {
|
||||||
|
writeCurrentConfig();
|
||||||
|
} catch (error) {
|
||||||
|
const candidate = record(error);
|
||||||
|
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||||
|
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||||
|
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await startSingbox()
|
||||||
|
.then(() => {
|
||||||
|
if (fs.existsSync(settings.configPath)) {
|
||||||
|
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
|
||||||
|
|
||||||
|
if (deviceInventory) {
|
||||||
|
await deviceInventory.reconcilePolicies()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriptionService.startAutoRefresh(SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
gatewayAutoService.startDiscovery(GATEWAY_DISCOVERY_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (deviceInventory) {
|
||||||
|
deviceInventory.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`));
|
||||||
|
deviceDiscoveryTimer = setInterval(() => {
|
||||||
|
deviceInventory.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`));
|
||||||
|
}, DEVICE_DISCOVERY_INTERVAL_MS);
|
||||||
|
deviceDiscoveryTimer.unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
process.env.DIST_DIR ||= path.resolve('dist');
|
||||||
|
|
||||||
|
if (process.env.APP_COMPONENT === 'dataplane') {
|
||||||
|
await import('./dataplane.js');
|
||||||
|
} else {
|
||||||
|
await import('./index.js');
|
||||||
|
}
|
||||||
@@ -6,13 +6,20 @@ import dns from "node:dns/promises";
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT = 3000;
|
const DEFAULT_TIMEOUT = 3000;
|
||||||
|
|
||||||
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
export interface PingResult {
|
||||||
|
ok: boolean;
|
||||||
|
latency: number | null;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tcpPing(host: string, port: number, timeout = DEFAULT_TIMEOUT): Promise<PingResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
return new Promise((resolve) => {
|
return new Promise<PingResult>((resolve) => {
|
||||||
const socket = new net.Socket();
|
const socket = new net.Socket();
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
const finish = (result) => {
|
const finish = (result: PingResult) => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
socket.removeAllListeners();
|
socket.removeAllListeners();
|
||||||
@@ -27,19 +34,19 @@ export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
|||||||
socket.once("timeout", () =>
|
socket.once("timeout", () =>
|
||||||
finish({ ok: false, latency: null, error: "timeout" }),
|
finish({ ok: false, latency: null, error: "timeout" }),
|
||||||
);
|
);
|
||||||
socket.once("error", (err) =>
|
socket.once("error", (err: NodeJS.ErrnoException) =>
|
||||||
finish({ ok: false, latency: null, error: err.code || err.message }),
|
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.connect(port, host);
|
socket.connect(port, host);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
finish({ ok: false, latency: null, error: err.message });
|
finish({ ok: false, latency: null, error: err instanceof Error ? err.message : String(err) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveHost(host) {
|
export async function resolveHost(host: string): Promise<string | null> {
|
||||||
if (net.isIP(host)) return host;
|
if (net.isIP(host)) return host;
|
||||||
try {
|
try {
|
||||||
const result = await dns.lookup(host);
|
const result = await dns.lookup(host);
|
||||||
@@ -1,41 +1,56 @@
|
|||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
const COUNTER_PATTERN = /^\d+$/;
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
|
||||||
const labelValue = (value) => String(value ?? '')
|
const labelValue = (value: unknown) => String(value ?? '')
|
||||||
.replaceAll('\\', '\\\\')
|
.replaceAll('\\', '\\\\')
|
||||||
.replaceAll('\n', '\\n')
|
.replaceAll('\n', '\\n')
|
||||||
.replaceAll('"', '\\"');
|
.replaceAll('"', '\\"');
|
||||||
|
|
||||||
const labels = (values) => Object.entries(values)
|
const labels = (values: Record<string, unknown>) => Object.entries(values)
|
||||||
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
||||||
.join(',');
|
.join(',');
|
||||||
|
|
||||||
function counter(value) {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(value: unknown) {
|
||||||
const decimal = String(value ?? '');
|
const decimal = String(value ?? '');
|
||||||
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
||||||
return decimal;
|
return decimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timestamp(value) {
|
function timestamp(value: unknown) {
|
||||||
const milliseconds = Date.parse(value);
|
const milliseconds = Date.parse(String(value ?? ''));
|
||||||
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function metric(lines, name, metricLabels, value) {
|
function metric(
|
||||||
|
lines: string[],
|
||||||
|
name: string,
|
||||||
|
metricLabels: Record<string, unknown>,
|
||||||
|
value: unknown,
|
||||||
|
) {
|
||||||
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPrometheusMetrics(snapshot) {
|
export function renderPrometheusMetrics(value: unknown) {
|
||||||
|
const snapshot = record(value);
|
||||||
|
const traffic = record(snapshot.traffic);
|
||||||
const lines = [
|
const lines = [
|
||||||
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
||||||
'# TYPE harbor_traffic_bytes_total counter',
|
'# TYPE harbor_traffic_bytes_total counter',
|
||||||
];
|
];
|
||||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(snapshot?.traffic?.gatewayBytes));
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(traffic.gatewayBytes));
|
||||||
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(snapshot?.traffic?.proxyBytes));
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(traffic.proxyBytes));
|
||||||
|
|
||||||
const globalFreshness = [
|
const globalFreshness = [
|
||||||
['gateway', snapshot?.traffic?.gatewayObservedAt],
|
['gateway', traffic.gatewayObservedAt],
|
||||||
['proxy', snapshot?.traffic?.proxyObservedAt],
|
['proxy', traffic.proxyObservedAt],
|
||||||
].map(([source, observedAt]) => [source, timestamp(observedAt)]).filter(([, observedAt]) => observedAt);
|
].map(([source, observedAt]) => [source, timestamp(observedAt)] as const).filter(([, observedAt]) => observedAt);
|
||||||
if (globalFreshness.length) {
|
if (globalFreshness.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
||||||
@@ -46,7 +61,7 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = Array.isArray(snapshot?.devices) ? snapshot.devices : [];
|
const devices = Array.isArray(snapshot.devices) ? snapshot.devices.map(record) : [];
|
||||||
if (devices.length) {
|
if (devices.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
||||||
@@ -63,18 +78,18 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
|
|
||||||
const deviceTraffic = devices.flatMap((device) => [
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
timestamp(device.trafficObservedAt)
|
timestamp(device.trafficObservedAt)
|
||||||
? [device, 'gateway', timestamp(device.trafficObservedAt), device.uploadBytes, device.downloadBytes]
|
? { device, source: 'gateway', observedAt: timestamp(device.trafficObservedAt), uploadBytes: device.uploadBytes, downloadBytes: device.downloadBytes }
|
||||||
: null,
|
: null,
|
||||||
timestamp(device.proxyTrafficObservedAt)
|
timestamp(device.proxyTrafficObservedAt)
|
||||||
? [device, 'proxy', timestamp(device.proxyTrafficObservedAt), device.proxyUploadBytes, device.proxyDownloadBytes]
|
? { device, source: 'proxy', observedAt: timestamp(device.proxyTrafficObservedAt), uploadBytes: device.proxyUploadBytes, downloadBytes: device.proxyDownloadBytes }
|
||||||
: null,
|
: null,
|
||||||
].filter(Boolean));
|
].filter((entry): entry is NonNullable<typeof entry> => entry !== null));
|
||||||
if (deviceTraffic.length) {
|
if (deviceTraffic.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
||||||
'# TYPE harbor_device_traffic_bytes_total counter',
|
'# TYPE harbor_device_traffic_bytes_total counter',
|
||||||
);
|
);
|
||||||
for (const [device, source, , uploadBytes, downloadBytes] of deviceTraffic) {
|
for (const { device, source, uploadBytes, downloadBytes } of deviceTraffic) {
|
||||||
metric(lines, 'harbor_device_traffic_bytes_total', {
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
source,
|
source,
|
||||||
@@ -91,7 +106,7 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
||||||
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
||||||
);
|
);
|
||||||
for (const [device, source, observedAt] of deviceTraffic) {
|
for (const { device, source, observedAt } of deviceTraffic) {
|
||||||
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
||||||
device_id: device.id,
|
device_id: device.id,
|
||||||
source,
|
source,
|
||||||
@@ -99,8 +114,8 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainTraffic = snapshot?.domainTraffic;
|
const domainTraffic = record(snapshot.domainTraffic);
|
||||||
const domainSeries = Array.isArray(domainTraffic?.series) ? domainTraffic.series : [];
|
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
|
||||||
if (domainSeries.length) {
|
if (domainSeries.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_device_domain_traffic_bytes_total Traffic observed by sing-box for a device and domain.',
|
'# HELP harbor_device_domain_traffic_bytes_total Traffic observed by sing-box for a device and domain.',
|
||||||
@@ -121,7 +136,7 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const domainObservedAt = timestamp(domainTraffic?.observedAt);
|
const domainObservedAt = timestamp(domainTraffic.observedAt);
|
||||||
if (domainObservedAt) {
|
if (domainObservedAt) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_domain_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box connection observation.',
|
'# HELP harbor_domain_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box connection observation.',
|
||||||
@@ -129,15 +144,15 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
);
|
);
|
||||||
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
||||||
}
|
}
|
||||||
if (domainTraffic?.overflowConnections != null) {
|
if (domainTraffic.overflowConnections != null) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.',
|
'# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.',
|
||||||
'# TYPE harbor_domain_traffic_overflow_connections_total counter',
|
'# TYPE harbor_domain_traffic_overflow_connections_total counter',
|
||||||
);
|
);
|
||||||
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
||||||
}
|
}
|
||||||
const attributionEvents = domainTraffic?.attributionEvents;
|
const attributionEvents = record(domainTraffic.attributionEvents);
|
||||||
if (attributionEvents) {
|
if (domainTraffic.attributionEvents) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
||||||
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
||||||
@@ -155,7 +170,7 @@ export function renderPrometheusMetrics(snapshot) {
|
|||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendPrometheusMetrics(res, snapshot) {
|
export function sendPrometheusMetrics(res: ServerResponse, snapshot: unknown) {
|
||||||
const body = renderPrometheusMetrics(snapshot);
|
const body = renderPrometheusMetrics(snapshot);
|
||||||
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export const SERVER_HEALTH_MAX_COUNT = 30;
|
|
||||||
export const SERVER_HEALTH_CONCURRENCY = 4;
|
|
||||||
|
|
||||||
export async function checkServerHealth(servers, ping, {
|
|
||||||
maxCount = SERVER_HEALTH_MAX_COUNT,
|
|
||||||
concurrency = SERVER_HEALTH_CONCURRENCY,
|
|
||||||
} = {}) {
|
|
||||||
const queue = servers.slice(0, maxCount);
|
|
||||||
const results = new Array(queue.length);
|
|
||||||
let nextIndex = 0;
|
|
||||||
|
|
||||||
async function worker() {
|
|
||||||
while (nextIndex < queue.length) {
|
|
||||||
const index = nextIndex++;
|
|
||||||
const server = queue[index];
|
|
||||||
results[index] = {
|
|
||||||
id: server.id,
|
|
||||||
tag: server.label,
|
|
||||||
...await ping(server.host, server.port),
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
+168
-48
@@ -7,18 +7,100 @@ import {
|
|||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
} from '../../shared/connectivityDiagnostics.js';
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
type PathKind = 'direct' | 'vpn';
|
||||||
|
|
||||||
|
interface CurlExecution {
|
||||||
|
exitCode: number | null;
|
||||||
|
error: string;
|
||||||
|
stderr: string;
|
||||||
|
stdout: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
|
||||||
|
type DnsLookup = typeof dnsLookup;
|
||||||
|
|
||||||
|
interface BaseProbe {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbe extends BaseProbe {
|
||||||
|
family: 4 | 6;
|
||||||
|
address: (body: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbe extends BaseProbe {
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string;
|
||||||
|
validationError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
body?: boolean;
|
||||||
|
ipv4?: boolean;
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestResult {
|
||||||
|
ok: boolean;
|
||||||
|
body: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbeResult {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
family: 4 | 6;
|
||||||
|
address: string | null;
|
||||||
|
attempts: number;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbeResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiagnosticTarget =
|
||||||
|
| { kind: 'ip'; probe: IpProbe }
|
||||||
|
| { kind: 'site'; probe: SiteProbe };
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
||||||
|
|
||||||
const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||||
...probe,
|
...probe,
|
||||||
|
family: probe.family === 6 ? 6 : 4,
|
||||||
address: probe.id === 'cloudflare'
|
address: probe.id === 'cloudflare'
|
||||||
? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||||
: probe.id === 'yandex-internet'
|
: probe.id === 'yandex-internet'
|
||||||
? (body) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||||
: (body) => body.trim(),
|
: (body: string) => body.trim(),
|
||||||
}));
|
}));
|
||||||
const SITE_PROBES = CONNECTIVITY_SITES;
|
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
|
||||||
const TARGET_SAMPLE_COUNT = 3;
|
const TARGET_SAMPLE_COUNT = 3;
|
||||||
|
|
||||||
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||||
@@ -27,18 +109,18 @@ for (const [address, prefix] of [
|
|||||||
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
||||||
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
['192.168.0.0', 16], ['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],
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
]) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||||
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
||||||
for (const [address, prefix] of [
|
for (const [address, prefix] of [
|
||||||
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
||||||
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
||||||
]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
function runCurl(args) {
|
function runCurl(args: string[]): Promise<CurlExecution> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
||||||
resolve({
|
resolve({
|
||||||
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
|
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
|
||||||
error: error?.message || '',
|
error: error?.message || '',
|
||||||
stderr: stderr || '',
|
stderr: stderr || '',
|
||||||
stdout: stdout || '',
|
stdout: stdout || '',
|
||||||
@@ -47,27 +129,27 @@ function runCurl(args) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageFor(exitCode) {
|
function stageFor(exitCode: number | null) {
|
||||||
if (exitCode === 6) return 'dns';
|
if (exitCode === 6) return 'dns';
|
||||||
if (exitCode === 7) return 'tcp';
|
if (exitCode === 7) return 'tcp';
|
||||||
if ([35, 51, 58, 60].includes(exitCode)) return 'tls';
|
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
|
||||||
if (exitCode === 28) return 'timeout';
|
if (exitCode === 28) return 'timeout';
|
||||||
return 'request';
|
return 'request';
|
||||||
}
|
}
|
||||||
|
|
||||||
function milliseconds(value) {
|
function milliseconds(value: unknown) {
|
||||||
const seconds = Number(value);
|
const seconds = Number(value);
|
||||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function average(values) {
|
function average(values: Array<number | null>) {
|
||||||
const numbers = values.filter(Number.isFinite);
|
const numbers = values.filter((value): value is number => Number.isFinite(value));
|
||||||
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mostCommon(values) {
|
function mostCommon<T>(values: T[]): T | null {
|
||||||
const counts = new Map();
|
const counts = new Map<T, number>();
|
||||||
let selected = null;
|
let selected: T | null = null;
|
||||||
let selectedCount = 0;
|
let selectedCount = 0;
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const count = (counts.get(value) || 0) + 1;
|
const count = (counts.get(value) || 0) + 1;
|
||||||
@@ -80,12 +162,12 @@ function mostCommon(values) {
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(probe, path, proxyPort, execute, {
|
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
|
||||||
body = false,
|
body = false,
|
||||||
ipv4 = false,
|
ipv4 = false,
|
||||||
follow = true,
|
follow = true,
|
||||||
resolve = null,
|
resolve = null,
|
||||||
} = {}) {
|
}: RequestOptions = {}): Promise<RequestResult> {
|
||||||
const args = [
|
const args = [
|
||||||
'--silent',
|
'--silent',
|
||||||
'--show-error',
|
'--show-error',
|
||||||
@@ -114,13 +196,15 @@ async function request(probe, path, proxyPort, execute, {
|
|||||||
const result = await execute(args);
|
const result = await execute(args);
|
||||||
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
|
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
|
||||||
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
|
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
|
||||||
let meta = {};
|
let meta: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
meta = JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}');
|
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
|
||||||
} catch {
|
} catch {
|
||||||
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
|
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
|
||||||
}
|
}
|
||||||
const exitCode = Number.isInteger(meta.exitcode) ? meta.exitcode : result.exitCode;
|
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
|
||||||
|
? meta.exitcode
|
||||||
|
: result.exitCode;
|
||||||
const ok = exitCode === 0;
|
const ok = exitCode === 0;
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
@@ -134,8 +218,14 @@ async function request(probe, path, proxyPort, execute, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
async function ipProbe(
|
||||||
const samples = [];
|
probe: IpProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<IpProbeResult> {
|
||||||
|
const samples: Array<RequestResult & { address: string | null }> = [];
|
||||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||||
const parsed = result.ok ? probe.address(result.body) : null;
|
const parsed = result.ok ? probe.address(result.body) : null;
|
||||||
@@ -144,7 +234,7 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
|||||||
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const address = mostCommon(samples.map((sample) => sample.address).filter(Boolean));
|
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
|
||||||
const matching = samples.filter((sample) => sample.address === address);
|
const matching = samples.filter((sample) => sample.address === address);
|
||||||
return {
|
return {
|
||||||
source: probe.id,
|
source: probe.id,
|
||||||
@@ -157,13 +247,13 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publicIps(path, proxyPort, execute) {
|
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
|
||||||
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
||||||
const ipv4 = probes.filter((probe) => probe.family === 4);
|
const ipv4 = probes.filter((probe) => probe.family === 4);
|
||||||
const ipv6 = probes.find((probe) => probe.family === 6);
|
const ipv6 = probes.find((probe) => probe.family === 6);
|
||||||
return {
|
return {
|
||||||
ipv4: {
|
ipv4: {
|
||||||
addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))],
|
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
|
||||||
sources: ipv4,
|
sources: ipv4,
|
||||||
},
|
},
|
||||||
ipv6: ipv6?.address || null,
|
ipv6: ipv6?.address || null,
|
||||||
@@ -171,20 +261,21 @@ async function publicIps(path, proxyPort, execute) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPublicAddress(address, family) {
|
function isPublicAddress(address: string, family: number) {
|
||||||
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||||
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||||
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareCustomProbes(services, lookup) {
|
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
|
||||||
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
||||||
return Promise.all(requested.map(async (service, index) => {
|
return Promise.all(requested.map(async (service, index) => {
|
||||||
const requestedId = String(service?.id || '');
|
const value = record(service);
|
||||||
|
const requestedId = String(value.id || '');
|
||||||
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(String(service?.url || '').trim());
|
parsed = new URL(String(value.url || '').trim());
|
||||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
||||||
throw new Error('Разрешены только публичные HTTPS-адреса');
|
throw new Error('Разрешены только публичные HTTPS-адреса');
|
||||||
}
|
}
|
||||||
@@ -198,7 +289,7 @@ async function prepareCustomProbes(services, lookup) {
|
|||||||
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: String(service?.label || '').trim().slice(0, 40) || hostname,
|
label: String(value.label || '').trim().slice(0, 40) || hostname,
|
||||||
url: parsed.href,
|
url: parsed.href,
|
||||||
follow: false,
|
follow: false,
|
||||||
resolve: `${hostname}:443:${pinned}`,
|
resolve: `${hostname}:443:${pinned}`,
|
||||||
@@ -206,19 +297,28 @@ async function prepareCustomProbes(services, lookup) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||||
validationError: error.message || 'Некорректный адрес',
|
url: '',
|
||||||
|
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function siteStatus(result) {
|
function siteStatus(result: RequestResult) {
|
||||||
if (!result.ok) return 'unavailable';
|
if (!result.ok) return 'unavailable';
|
||||||
return result.httpStatus >= 200 && result.httpStatus < 400 ? 'available' : 'responded';
|
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
|
||||||
|
? 'available'
|
||||||
|
: 'responded';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
async function siteProbe(
|
||||||
|
probe: SiteProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<SiteProbeResult> {
|
||||||
if (probe.validationError) return {
|
if (probe.validationError) return {
|
||||||
id: probe.id,
|
id: probe.id,
|
||||||
label: probe.label,
|
label: probe.label,
|
||||||
@@ -235,12 +335,13 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
|||||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
}
|
}
|
||||||
if (sampleCount === 1 && !samples[0].ok) {
|
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
}
|
}
|
||||||
const status = mostCommon(samples.map(siteStatus));
|
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||||
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||||
const representative = matching.at(-1);
|
const representative = matching.at(-1) || samples.at(-1);
|
||||||
|
if (!representative) throw new Error('Diagnostic probe produced no samples');
|
||||||
return {
|
return {
|
||||||
id: probe.id,
|
id: probe.id,
|
||||||
label: probe.label,
|
label: probe.label,
|
||||||
@@ -254,7 +355,12 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probePath(path, proxyPort, execute, sites) {
|
async function probePath(
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sites: SiteProbe[],
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
const [ip, siteResults] = await Promise.all([
|
const [ip, siteResults] = await Promise.all([
|
||||||
publicIps(path, proxyPort, execute),
|
publicIps(path, proxyPort, execute),
|
||||||
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||||
@@ -269,7 +375,7 @@ async function probePath(path, proxyPort, execute, sites) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function unavailablePath() {
|
function unavailablePath(): ConnectivityPathResult {
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
reason: 'vpn-off',
|
reason: 'vpn-off',
|
||||||
@@ -281,7 +387,7 @@ function unavailablePath() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTarget(targetId, sites) {
|
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
|
||||||
if (typeof targetId !== 'string') return null;
|
if (typeof targetId !== 'string') return null;
|
||||||
if (targetId.startsWith('ip:')) {
|
if (targetId.startsWith('ip:')) {
|
||||||
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||||
@@ -294,7 +400,12 @@ function resolveTarget(targetId, sites) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeTarget(target, path, proxyPort, execute) {
|
async function probeTarget(
|
||||||
|
target: DiagnosticTarget,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
const ip = target.kind === 'ip'
|
const ip = target.kind === 'ip'
|
||||||
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||||
: null;
|
: null;
|
||||||
@@ -308,7 +419,7 @@ async function probeTarget(target, path, proxyPort, execute) {
|
|||||||
available: true,
|
available: true,
|
||||||
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
||||||
ipv4: {
|
ipv4: {
|
||||||
addresses: ipv4Sources.map(({ address }) => address).filter(Boolean),
|
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
|
||||||
sources: ipv4Sources,
|
sources: ipv4Sources,
|
||||||
},
|
},
|
||||||
ipv6: ipv6Source?.address || null,
|
ipv6: ipv6Source?.address || null,
|
||||||
@@ -324,10 +435,19 @@ export function createConnectivityDiagnosticsService({
|
|||||||
execute = runCurl,
|
execute = runCurl,
|
||||||
lookup = dnsLookup,
|
lookup = dnsLookup,
|
||||||
now = () => new Date().toISOString(),
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
proxyPort: number;
|
||||||
|
execute?: CurlExecutor;
|
||||||
|
lookup?: DnsLookup;
|
||||||
|
now?: () => string;
|
||||||
}) {
|
}) {
|
||||||
async function runOnce({ vpnAvailable, services = [], target: targetId = null }) {
|
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
|
||||||
const requestedServices = targetId?.startsWith('site:custom-')
|
vpnAvailable: boolean;
|
||||||
? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId)
|
services?: unknown;
|
||||||
|
target?: unknown;
|
||||||
|
}) {
|
||||||
|
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||||
|
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||||
: targetId ? [] : services;
|
: targetId ? [] : services;
|
||||||
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
+415
-158
@@ -5,6 +5,154 @@ import { HarborError } from '../../shared/errors.js';
|
|||||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||||
|
|
||||||
|
type DevicePolicyMode = 'vpn' | 'direct';
|
||||||
|
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
||||||
|
|
||||||
|
interface CounterBaseline {
|
||||||
|
epoch: string;
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficTotal {
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
observedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterTotal {
|
||||||
|
upload: bigint;
|
||||||
|
download: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalTrafficSource {
|
||||||
|
epoch: string | null;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
baselinesByMac: Record<string, CounterBaseline>;
|
||||||
|
rebaselineMacs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyTrafficState {
|
||||||
|
schemaVersion: number;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
baselinesByMac: Record<string, CounterBaseline>;
|
||||||
|
totalsByMac: Record<string, TrafficTotal>;
|
||||||
|
rebaselineMacs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevicePolicyEntry {
|
||||||
|
desired: DevicePolicyMode;
|
||||||
|
applied: DevicePolicyMode;
|
||||||
|
status: DevicePolicyStatus;
|
||||||
|
appliedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
operationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevicePolicyState {
|
||||||
|
schemaVersion: number;
|
||||||
|
defaultMode: DevicePolicyMode;
|
||||||
|
dataplaneEpoch: string | null;
|
||||||
|
generation: string | null;
|
||||||
|
fingerprint: string | null;
|
||||||
|
lastAppliedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
byMac: Record<string, DevicePolicyEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryDevice {
|
||||||
|
id: string;
|
||||||
|
alias: string;
|
||||||
|
pinned: boolean;
|
||||||
|
hostname: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
mac: string;
|
||||||
|
ip: string;
|
||||||
|
interface: string;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
source: string;
|
||||||
|
confidence: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryTrafficState {
|
||||||
|
epoch: string | null;
|
||||||
|
generation: string | null;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
baselinesByMac: Record<string, CounterBaseline>;
|
||||||
|
totalsByMac: Record<string, TrafficTotal>;
|
||||||
|
rebaselineMacs: string[];
|
||||||
|
proxy: ProxyTrafficState;
|
||||||
|
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryState {
|
||||||
|
schemaVersion: number;
|
||||||
|
revision: number;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
policy: DevicePolicyState;
|
||||||
|
traffic: InventoryTrafficState;
|
||||||
|
devices: InventoryDevice[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectDevice {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyAck {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
fingerprint: string;
|
||||||
|
observedAt: string;
|
||||||
|
appliedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryStore {
|
||||||
|
read(): unknown;
|
||||||
|
update(transform: (stored: unknown) => InventoryState): InventoryState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficSample {
|
||||||
|
observedAt: string | null | undefined;
|
||||||
|
gatewayBytes: string;
|
||||||
|
proxyBytes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficCursor {
|
||||||
|
signature: string;
|
||||||
|
gateway: bigint;
|
||||||
|
proxy: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceObservation {
|
||||||
|
mac: string;
|
||||||
|
ip: string;
|
||||||
|
interface: string;
|
||||||
|
active: boolean;
|
||||||
|
observedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
|
export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
|
||||||
const ONLINE_MS = 2 * 60 * 1000;
|
const ONLINE_MS = 2 * 60 * 1000;
|
||||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -14,11 +162,11 @@ const COUNTER_PATTERN = /^\d+$/;
|
|||||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
const POLICY_MODES = new Set(['vpn', 'direct']);
|
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
|
||||||
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
|
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||||
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
||||||
|
|
||||||
const DEFAULT_DEVICE_POLICY = Object.freeze({
|
const DEFAULT_DEVICE_POLICY: Readonly<DevicePolicyEntry> = Object.freeze({
|
||||||
desired: 'vpn',
|
desired: 'vpn',
|
||||||
applied: 'vpn',
|
applied: 'vpn',
|
||||||
status: 'applied',
|
status: 'applied',
|
||||||
@@ -27,7 +175,7 @@ const DEFAULT_DEVICE_POLICY = Object.freeze({
|
|||||||
operationId: null,
|
operationId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_POLICY_STATE = {
|
const DEFAULT_POLICY_STATE: DevicePolicyState = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
defaultMode: 'vpn',
|
defaultMode: 'vpn',
|
||||||
dataplaneEpoch: null,
|
dataplaneEpoch: null,
|
||||||
@@ -38,7 +186,7 @@ const DEFAULT_POLICY_STATE = {
|
|||||||
byMac: {},
|
byMac: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROXY_TRAFFIC = {
|
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
@@ -47,7 +195,7 @@ const DEFAULT_PROXY_TRAFFIC = {
|
|||||||
rebaselineMacs: [],
|
rebaselineMacs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
|
const DEFAULT_GLOBAL_TRAFFIC_SOURCE: GlobalTrafficSource = {
|
||||||
epoch: null,
|
epoch: null,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
uploadBytes: '0',
|
uploadBytes: '0',
|
||||||
@@ -56,7 +204,7 @@ const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
|
|||||||
rebaselineMacs: [],
|
rebaselineMacs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE: InventoryState = {
|
||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
@@ -79,23 +227,86 @@ const DEFAULT_STATE = {
|
|||||||
devices: [],
|
devices: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
|
const normalizeMac = (value: unknown) => String(value || '').trim().toLowerCase();
|
||||||
export const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
export const deviceId = (mac: string) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
|
||||||
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
const isPrivateMac = (mac: string) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
|
||||||
const recordEntries = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
const recordEntries = (value: unknown): Array<[string, Record<string, unknown>]> => (
|
||||||
? Object.entries(value)
|
Object.entries(record(value)).map(([key, entry]) => [key, record(entry)])
|
||||||
: [];
|
);
|
||||||
const parseStoredCounter = (value) => {
|
const parseStoredCounter = (value: unknown) => {
|
||||||
const counter = String(value ?? '');
|
const counter = String(value ?? '');
|
||||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac)
|
const validTimestamp = (value: unknown): value is string => (
|
||||||
.reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n)
|
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||||
|
const device = record(value);
|
||||||
|
const mac = normalizeMac(device.mac);
|
||||||
|
const ip = typeof device.ip === 'string' ? device.ip : '';
|
||||||
|
const deviceInterface = typeof device.interface === 'string' ? device.interface : '';
|
||||||
|
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|
||||||
|
|| !validTimestamp(device.firstSeenAt) || !validTimestamp(device.lastSeenAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const confidence = ['high', 'medium', 'ambiguous'].includes(String(device.confidence))
|
||||||
|
? String(device.confidence)
|
||||||
|
: isPrivateMac(mac) ? 'medium' : 'high';
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
id: typeof device.id === 'string' && DEVICE_ID_PATTERN.test(device.id)
|
||||||
|
? device.id
|
||||||
|
: deviceId(mac),
|
||||||
|
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||||
|
pinned: device.pinned === true,
|
||||||
|
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
||||||
|
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
||||||
|
mac,
|
||||||
|
ip,
|
||||||
|
interface: deviceInterface,
|
||||||
|
firstSeenAt: device.firstSeenAt,
|
||||||
|
lastSeenAt: device.lastSeenAt,
|
||||||
|
source: typeof device.source === 'string' && device.source ? device.source : 'neighbor',
|
||||||
|
confidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceObservation(value: unknown): DeviceObservation | null {
|
||||||
|
const observation = record(value);
|
||||||
|
const mac = normalizeMac(observation.mac);
|
||||||
|
const ip = typeof observation.ip === 'string' ? observation.ip : '';
|
||||||
|
const deviceInterface = typeof observation.interface === 'string' ? observation.interface : '';
|
||||||
|
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|
||||||
|
|| typeof observation.active !== 'boolean' || !validTimestamp(observation.observedAt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mac,
|
||||||
|
ip,
|
||||||
|
interface: deviceInterface,
|
||||||
|
active: observation.active,
|
||||||
|
observedAt: observation.observedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumStoredTotals = (totalsByMac: unknown, key: string) => recordEntries(totalsByMac)
|
||||||
|
.reduce((total, [, value]) => total + BigInt(String(value[key] || '0')), 0n)
|
||||||
.toString();
|
.toString();
|
||||||
|
|
||||||
function normalizeGlobalTrafficSource(value, fallback, version) {
|
function normalizeGlobalTrafficSource(
|
||||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
value: unknown,
|
||||||
|
fallback: {
|
||||||
|
epoch: string | null;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
baselinesByMac: Record<string, CounterBaseline>;
|
||||||
|
totalsByMac: Record<string, TrafficTotal>;
|
||||||
|
rebaselineMacs: string[];
|
||||||
|
},
|
||||||
|
version: number,
|
||||||
|
): GlobalTrafficSource {
|
||||||
|
const source = record(value);
|
||||||
const fallbackMacs = new Set([
|
const fallbackMacs = new Set([
|
||||||
...Object.keys(fallback.baselinesByMac),
|
...Object.keys(fallback.baselinesByMac),
|
||||||
...Object.keys(fallback.totalsByMac),
|
...Object.keys(fallback.totalsByMac),
|
||||||
@@ -111,9 +322,9 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
|
|||||||
rebaselineMacs: [...fallback.rebaselineMacs],
|
rebaselineMacs: [...fallback.rebaselineMacs],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
|
const rebaselineMacs = new Set<string>((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
|
||||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
const baselinesByMac = {};
|
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||||
let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
|
let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
|
||||||
|| Array.isArray(source.baselinesByMac);
|
|| Array.isArray(source.baselinesByMac);
|
||||||
for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
|
for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
|
||||||
@@ -141,12 +352,12 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProxyTraffic(value, devices) {
|
function normalizeProxyTraffic(value: unknown, devices: InventoryDevice[]): ProxyTrafficState {
|
||||||
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const proxy = record(value);
|
||||||
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
if (typeof proxy.schemaVersion === 'number' && Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
||||||
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
|
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
|
||||||
}
|
}
|
||||||
const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
|
const rebaselineMacs = new Set<string>((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
|
||||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
let recovered = value !== undefined && (
|
let recovered = value !== undefined && (
|
||||||
proxy !== value || proxy.schemaVersion !== 1
|
proxy !== value || proxy.schemaVersion !== 1
|
||||||
@@ -156,7 +367,7 @@ function normalizeProxyTraffic(value, devices) {
|
|||||||
if (recovered) {
|
if (recovered) {
|
||||||
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||||
}
|
}
|
||||||
const baselinesByMac = {};
|
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||||
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
|
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
|
||||||
const mac = normalizeMac(rawMac);
|
const mac = normalizeMac(rawMac);
|
||||||
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||||
@@ -169,7 +380,7 @@ function normalizeProxyTraffic(value, devices) {
|
|||||||
}
|
}
|
||||||
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||||
}
|
}
|
||||||
const totalsByMac = {};
|
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||||
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
|
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
|
||||||
const mac = normalizeMac(rawMac);
|
const mac = normalizeMac(rawMac);
|
||||||
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||||
@@ -203,9 +414,9 @@ function normalizeProxyTraffic(value, devices) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePolicyState(value) {
|
function normalizePolicyState(value: unknown): DevicePolicyState {
|
||||||
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const policy = record(value);
|
||||||
const byMac = {};
|
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||||
let recovered = value !== undefined && (
|
let recovered = value !== undefined && (
|
||||||
policy.schemaVersion !== 1
|
policy.schemaVersion !== 1
|
||||||
|| policy.defaultMode !== 'vpn'
|
|| policy.defaultMode !== 'vpn'
|
||||||
@@ -223,9 +434,9 @@ function normalizePolicyState(value) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byMac[mac] = {
|
byMac[mac] = {
|
||||||
desired: entry.desired,
|
desired: entry.desired as DevicePolicyMode,
|
||||||
applied: entry.applied,
|
applied: entry.applied as DevicePolicyMode,
|
||||||
status: entry.status,
|
status: entry.status as DevicePolicyStatus,
|
||||||
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
|
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
|
||||||
error: typeof entry.error === 'string' ? entry.error : null,
|
error: typeof entry.error === 'string' ? entry.error : null,
|
||||||
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
|
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
|
||||||
@@ -246,8 +457,8 @@ function normalizePolicyState(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOuiVendors(text) {
|
export function parseOuiVendors(text: unknown) {
|
||||||
const vendors = new Map();
|
const vendors = new Map<string, string>();
|
||||||
for (const line of String(text || '').split(/\r?\n/)) {
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
|
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
|
||||||
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
|
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
|
||||||
@@ -256,8 +467,8 @@ export function parseOuiVendors(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
||||||
let vendors;
|
let vendors: Map<string, string> | undefined;
|
||||||
return (mac) => {
|
return (mac: string) => {
|
||||||
if (!mac || isPrivateMac(mac)) return null;
|
if (!mac || isPrivateMac(mac)) return null;
|
||||||
if (!vendors) {
|
if (!vendors) {
|
||||||
try {
|
try {
|
||||||
@@ -270,20 +481,22 @@ export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateDeviceInventoryState(value) {
|
export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
const state = record(value);
|
||||||
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
|
const version = typeof state.schemaVersion === 'number' && Number.isSafeInteger(state.schemaVersion)
|
||||||
|
? state.schemaVersion
|
||||||
|
: 0;
|
||||||
if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) {
|
if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) {
|
||||||
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
|
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
|
||||||
}
|
}
|
||||||
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
|
const traffic = record(state.traffic);
|
||||||
? state.traffic
|
|
||||||
: {};
|
|
||||||
const devices = Array.isArray(state.devices)
|
const devices = Array.isArray(state.devices)
|
||||||
? state.devices.filter((device) => isDeviceInterface(device?.interface))
|
? state.devices
|
||||||
|
.map(normalizeInventoryDevice)
|
||||||
|
.filter((device): device is InventoryDevice => device !== null)
|
||||||
: [];
|
: [];
|
||||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||||
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
let recoveredTraffic = version >= 2 && (
|
let recoveredTraffic = version >= 2 && (
|
||||||
traffic !== state.traffic
|
traffic !== state.traffic
|
||||||
@@ -293,7 +506,7 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
if (recoveredTraffic) {
|
if (recoveredTraffic) {
|
||||||
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||||
}
|
}
|
||||||
const baselinesByMac = {};
|
const baselinesByMac: Record<string, CounterBaseline> = {};
|
||||||
for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) {
|
for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) {
|
||||||
const mac = normalizeMac(rawMac);
|
const mac = normalizeMac(rawMac);
|
||||||
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||||
@@ -306,7 +519,7 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
}
|
}
|
||||||
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||||
}
|
}
|
||||||
const totalsByMac = {};
|
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||||
for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) {
|
for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) {
|
||||||
const mac = normalizeMac(rawMac);
|
const mac = normalizeMac(rawMac);
|
||||||
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||||
@@ -329,14 +542,14 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const global = {
|
const global = {
|
||||||
gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, {
|
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
||||||
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||||
lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
|
lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
|
||||||
baselinesByMac,
|
baselinesByMac,
|
||||||
totalsByMac,
|
totalsByMac,
|
||||||
rebaselineMacs: [...rebaselineMacs],
|
rebaselineMacs: [...rebaselineMacs],
|
||||||
}, version),
|
}, version),
|
||||||
proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, {
|
proxy: normalizeGlobalTrafficSource(record(traffic.global).proxy, {
|
||||||
epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
|
epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
|
||||||
lastObservedAt: proxyTraffic.lastObservedAt,
|
lastObservedAt: proxyTraffic.lastObservedAt,
|
||||||
baselinesByMac: proxyTraffic.baselinesByMac,
|
baselinesByMac: proxyTraffic.baselinesByMac,
|
||||||
@@ -348,14 +561,14 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
...state,
|
...state,
|
||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||||
policy: normalizePolicyState(state.policy),
|
policy: normalizePolicyState(state.policy),
|
||||||
traffic: {
|
traffic: {
|
||||||
...DEFAULT_STATE.traffic,
|
...DEFAULT_STATE.traffic,
|
||||||
...traffic,
|
...traffic,
|
||||||
lastError: recoveredTraffic
|
lastError: recoveredTraffic
|
||||||
? 'Повреждённый traffic checkpoint восстановлен из корректных данных'
|
? 'Повреждённый traffic checkpoint восстановлен из корректных данных'
|
||||||
: traffic.lastError || null,
|
: typeof traffic.lastError === 'string' ? traffic.lastError : null,
|
||||||
baselinesByMac,
|
baselinesByMac,
|
||||||
totalsByMac,
|
totalsByMac,
|
||||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||||
@@ -366,17 +579,23 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceStatus(lastSeenAt, now) {
|
function deviceStatus(lastSeenAt: string, now: Date): 'online' | 'recent' | 'offline' {
|
||||||
const age = now.getTime() - new Date(lastSeenAt).getTime();
|
const age = now.getTime() - new Date(lastSeenAt).getTime();
|
||||||
if (age <= ONLINE_MS) return 'online';
|
if (age <= ONLINE_MS) return 'online';
|
||||||
if (age <= RECENT_MS) return 'recent';
|
if (age <= RECENT_MS) return 'recent';
|
||||||
return 'offline';
|
return 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) {
|
function accumulateGlobalTraffic(
|
||||||
|
source: GlobalTrafficSource,
|
||||||
|
countersByMac: Map<string, CounterTotal>,
|
||||||
|
epoch: string,
|
||||||
|
observedAt: string | null,
|
||||||
|
label: string,
|
||||||
|
): GlobalTrafficSource {
|
||||||
const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
|
const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
|
||||||
const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac };
|
const baselinesByMac: Record<string, CounterBaseline> = epochChanged ? {} : { ...source.baselinesByMac };
|
||||||
const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs);
|
const rebaselineMacs = new Set<string>(epochChanged ? [] : source.rebaselineMacs);
|
||||||
let uploadBytes = BigInt(source.uploadBytes);
|
let uploadBytes = BigInt(source.uploadBytes);
|
||||||
let downloadBytes = BigInt(source.downloadBytes);
|
let downloadBytes = BigInt(source.downloadBytes);
|
||||||
for (const [mac, processTotal] of countersByMac) {
|
for (const [mac, processTotal] of countersByMac) {
|
||||||
@@ -419,14 +638,23 @@ export function createDeviceInventoryService({
|
|||||||
applyPolicies = null,
|
applyPolicies = null,
|
||||||
vendor = () => null,
|
vendor = () => null,
|
||||||
now = () => new Date(),
|
now = () => new Date(),
|
||||||
|
}: {
|
||||||
|
store: InventoryStore;
|
||||||
|
observe: () => unknown | Promise<unknown>;
|
||||||
|
observeTraffic?: (() => unknown | Promise<unknown>) | null;
|
||||||
|
observeDomainTraffic?: (() => unknown | Promise<unknown>) | null;
|
||||||
|
observePolicy?: (() => unknown | Promise<unknown>) | null;
|
||||||
|
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
|
||||||
|
vendor?: (mac: string) => string | null;
|
||||||
|
now?: () => Date;
|
||||||
}) {
|
}) {
|
||||||
let refreshPromise = null;
|
let refreshPromise: Promise<unknown> | null = null;
|
||||||
let policyQueue = Promise.resolve();
|
let policyQueue: Promise<unknown> = Promise.resolve();
|
||||||
const trafficHistoryByMac = new Map();
|
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
|
||||||
const trafficCursorByMac = new Map();
|
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||||
let globalTrafficHistory = [];
|
let globalTrafficHistory: TrafficSample[] = [];
|
||||||
let globalTrafficCursor = null;
|
let globalTrafficCursor: TrafficCursor | null = null;
|
||||||
let domainTrafficSnapshot = {
|
let domainTrafficSnapshot: Record<string, unknown> = {
|
||||||
epoch: null,
|
epoch: null,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null },
|
||||||
@@ -439,7 +667,7 @@ export function createDeviceInventoryService({
|
|||||||
series: [],
|
series: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function captureTrafficHistory(state) {
|
function captureTrafficHistory(state: InventoryState) {
|
||||||
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
||||||
for (const device of state.devices) {
|
for (const device of state.devices) {
|
||||||
const traffic = state.traffic.totalsByMac[device.mac];
|
const traffic = state.traffic.totalsByMac[device.mac];
|
||||||
@@ -481,23 +709,24 @@ export function createDeviceInventoryService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePolicy(action) {
|
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
|
||||||
const result = policyQueue.then(action, action);
|
const result = policyQueue.then(() => action(), () => action());
|
||||||
policyQueue = result.catch(() => {});
|
policyQueue = result.catch(() => {});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function policyFor(state, mac) {
|
function policyFor(state: InventoryState, mac: string): Readonly<DevicePolicyEntry> {
|
||||||
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
|
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function policyIdentity(device) {
|
function policyIdentity(device: InventoryDevice | null | undefined) {
|
||||||
return Boolean(device) && device.confidence !== 'ambiguous'
|
if (!device) return false;
|
||||||
|
return device.confidence !== 'ambiguous'
|
||||||
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
|
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
|
||||||
&& isDeviceInterface(device.interface);
|
&& isDeviceInterface(device.interface);
|
||||||
}
|
}
|
||||||
|
|
||||||
function directDevices(state) {
|
function directDevices(state: InventoryState): DirectDevice[] {
|
||||||
return state.devices
|
return state.devices
|
||||||
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
|
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
|
||||||
.map(({ id, ip, mac, interface: deviceInterface }) => ({
|
.map(({ id, ip, mac, interface: deviceInterface }) => ({
|
||||||
@@ -508,26 +737,27 @@ export function createDeviceInventoryService({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePolicyAck(result, requested) {
|
function validatePolicyAck(result: unknown, requested: DirectDevice[]): PolicyAck {
|
||||||
const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : [];
|
const value = record(result);
|
||||||
|
const appliedIds = Array.isArray(value.appliedIds) ? value.appliedIds : [];
|
||||||
const expectedIds = new Set(requested.map(({ id }) => id));
|
const expectedIds = new Set(requested.map(({ id }) => id));
|
||||||
if (typeof result?.epoch !== 'string' || !result.epoch
|
if (typeof value.epoch !== 'string' || !value.epoch
|
||||||
|| typeof result.generation !== 'string' || !result.generation
|
|| typeof value.generation !== 'string' || !value.generation
|
||||||
|| !FINGERPRINT_PATTERN.test(result.fingerprint)
|
|| typeof value.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(value.fingerprint)
|
||||||
|| typeof result.observedAt !== 'string' || !result.observedAt
|
|| typeof value.observedAt !== 'string' || !value.observedAt
|
||||||
|| result.fingerprint !== fingerprintDirectDevices(requested)
|
|| value.fingerprint !== fingerprintDirectDevices(requested)
|
||||||
|| appliedIds.length !== expectedIds.size
|
|| appliedIds.length !== expectedIds.size
|
||||||
|| new Set(appliedIds).size !== appliedIds.length
|
|| new Set(appliedIds).size !== appliedIds.length
|
||||||
|| appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|
|| appliedIds.some((id) => typeof id !== 'string' || !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|
||||||
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
|
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
|
||||||
}
|
}
|
||||||
return result;
|
return value as unknown as PolicyAck;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const state = migrateDeviceInventoryState(store.read());
|
const state = migrateDeviceInventoryState(store.read());
|
||||||
const current = now();
|
const current = now();
|
||||||
const rank = { online: 0, recent: 1, offline: 2 };
|
const rank: Record<'online' | 'recent' | 'offline', number> = { online: 0, recent: 1, offline: 2 };
|
||||||
const devices = state.devices.map((device) => {
|
const devices = state.devices.map((device) => {
|
||||||
const traffic = state.traffic.totalsByMac[device.mac];
|
const traffic = state.traffic.totalsByMac[device.mac];
|
||||||
const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
|
const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
|
||||||
@@ -601,24 +831,27 @@ export function createDeviceInventoryService({
|
|||||||
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
|
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
function markPolicyEpoch(observed) {
|
function markPolicyEpoch(observed: unknown) {
|
||||||
if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return;
|
const value = record(observed);
|
||||||
|
if (typeof value.epoch !== 'string' || !value.epoch || !Array.isArray(value.appliedIds)) return;
|
||||||
|
const epoch = value.epoch;
|
||||||
|
const acknowledgedIds = value.appliedIds;
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state;
|
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === epoch) return state;
|
||||||
const appliedIds = new Set(observed.appliedIds);
|
const appliedIds = new Set(acknowledgedIds);
|
||||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
const byMac = {};
|
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||||
const device = devicesByMac.get(mac);
|
const device = devicesByMac.get(mac);
|
||||||
if (!device) continue;
|
if (!device) continue;
|
||||||
const applied = appliedIds.has(device.id) ? 'direct' : 'vpn';
|
const applied: DevicePolicyMode = appliedIds.has(device.id) ? 'direct' : 'vpn';
|
||||||
if (entry.desired === 'vpn' && applied === 'vpn') continue;
|
if (entry.desired === 'vpn' && applied === 'vpn') continue;
|
||||||
byMac[mac] = {
|
byMac[mac] = {
|
||||||
...entry,
|
...entry,
|
||||||
applied,
|
applied,
|
||||||
status: entry.desired === applied ? 'applied' : 'pending',
|
status: entry.desired === applied ? 'applied' : 'pending',
|
||||||
appliedAt: observed.observedAt || entry.appliedAt,
|
appliedAt: typeof value.observedAt === 'string' ? value.observedAt : entry.appliedAt,
|
||||||
error: entry.desired === applied
|
error: entry.desired === applied
|
||||||
? null
|
? null
|
||||||
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
|
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
|
||||||
@@ -630,10 +863,12 @@ export function createDeviceInventoryService({
|
|||||||
revision: state.revision + 1,
|
revision: state.revision + 1,
|
||||||
policy: {
|
policy: {
|
||||||
...state.policy,
|
...state.policy,
|
||||||
dataplaneEpoch: observed.epoch,
|
dataplaneEpoch: epoch,
|
||||||
generation: typeof observed.generation === 'string' ? observed.generation : null,
|
generation: typeof value.generation === 'string' ? value.generation : null,
|
||||||
fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null,
|
fingerprint: typeof value.fingerprint === 'string' && FINGERPRINT_PATTERN.test(value.fingerprint)
|
||||||
lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt,
|
? value.fingerprint
|
||||||
|
: null,
|
||||||
|
lastAppliedAt: typeof value.observedAt === 'string' ? value.observedAt : state.policy.lastAppliedAt,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
byMac,
|
byMac,
|
||||||
},
|
},
|
||||||
@@ -641,12 +876,12 @@ export function createDeviceInventoryService({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitPolicySuccess(result) {
|
function commitPolicySuccess(result: PolicyAck) {
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []);
|
const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []);
|
||||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
const byMac = {};
|
const byMac: Record<string, DevicePolicyEntry> = {};
|
||||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||||
const device = devicesByMac.get(mac);
|
const device = devicesByMac.get(mac);
|
||||||
if (!device) continue;
|
if (!device) continue;
|
||||||
@@ -677,11 +912,11 @@ export function createDeviceInventoryService({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitPolicyFailure(error) {
|
function commitPolicyFailure(error: unknown) {
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
const message = error.message || String(error);
|
const message = errorMessage(error);
|
||||||
const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
|
const byMac: Record<string, DevicePolicyEntry> = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
|
||||||
...entry,
|
...entry,
|
||||||
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
|
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
|
||||||
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
|
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
|
||||||
@@ -695,7 +930,7 @@ export function createDeviceInventoryService({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reconcileLocked(observedPolicy, throwOnError) {
|
async function reconcileLocked(observedPolicy: unknown, throwOnError: boolean) {
|
||||||
if (!applyPolicies) return snapshot();
|
if (!applyPolicies) return snapshot();
|
||||||
markPolicyEpoch(observedPolicy);
|
markPolicyEpoch(observedPolicy);
|
||||||
const state = migrateDeviceInventoryState(store.read());
|
const state = migrateDeviceInventoryState(store.read());
|
||||||
@@ -713,42 +948,44 @@ export function createDeviceInventoryService({
|
|||||||
|
|
||||||
async function performRefresh() {
|
async function performRefresh() {
|
||||||
const [result, trafficResult, policyResult, domainTrafficResult] = await Promise.all([
|
const [result, trafficResult, policyResult, domainTrafficResult] = await Promise.all([
|
||||||
Promise.resolve().then(() => observe()).catch((error) => ({
|
Promise.resolve().then(() => observe()).catch((error: unknown) => ({
|
||||||
observedAt: now().toISOString(),
|
observedAt: now().toISOString(),
|
||||||
observations: [],
|
observations: [],
|
||||||
error: error.message || String(error),
|
error: errorMessage(error),
|
||||||
})),
|
})).then(record),
|
||||||
observeTraffic
|
observeTraffic
|
||||||
? Promise.resolve().then(() => observeTraffic())
|
? Promise.resolve().then(() => observeTraffic())
|
||||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||||
: null,
|
: null,
|
||||||
observePolicy
|
observePolicy
|
||||||
? Promise.resolve().then(() => observePolicy())
|
? Promise.resolve().then(() => observePolicy())
|
||||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||||
: null,
|
: null,
|
||||||
observeDomainTraffic
|
observeDomainTraffic
|
||||||
? Promise.resolve().then(() => observeDomainTraffic())
|
? Promise.resolve().then(() => observeDomainTraffic())
|
||||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
const observedAt = result?.observedAt || now().toISOString();
|
const observedAt = validTimestamp(result.observedAt) ? result.observedAt : now().toISOString();
|
||||||
const observations = (Array.isArray(result?.observations) ? result.observations : [])
|
const observations = (Array.isArray(result.observations) ? result.observations : [])
|
||||||
.filter((observation) => isDeviceInterface(observation?.interface));
|
.map(normalizeDeviceObservation)
|
||||||
const identitiesByMac = new Map();
|
.filter((observation): observation is DeviceObservation => observation !== null);
|
||||||
|
const identitiesByMac = new Map<string, Set<string>>();
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
const mac = normalizeMac(observation.mac);
|
const mac = normalizeMac(observation.mac);
|
||||||
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
||||||
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
|
const identities = identitiesByMac.get(mac) || new Set<string>();
|
||||||
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
|
||||||
|
identitiesByMac.set(mac, identities);
|
||||||
}
|
}
|
||||||
return serializePolicy(async () => {
|
return serializePolicy(async () => {
|
||||||
if (domainTrafficResult?.transportError) {
|
if (typeof domainTrafficResult?.transportError === 'string') {
|
||||||
domainTrafficSnapshot = {
|
domainTrafficSnapshot = {
|
||||||
...domainTrafficSnapshot,
|
...domainTrafficSnapshot,
|
||||||
source: { error: domainTrafficResult.transportError },
|
source: { error: domainTrafficResult.transportError },
|
||||||
};
|
};
|
||||||
} else if (domainTrafficResult) {
|
} else if (domainTrafficResult) {
|
||||||
domainTrafficSnapshot = domainTrafficResult;
|
domainTrafficSnapshot = record(domainTrafficResult);
|
||||||
}
|
}
|
||||||
const nextState = store.update((stored) => {
|
const nextState = store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
@@ -757,8 +994,9 @@ export function createDeviceInventoryService({
|
|||||||
const mac = normalizeMac(observation.mac);
|
const mac = normalizeMac(observation.mac);
|
||||||
if (!mac) continue;
|
if (!mac) continue;
|
||||||
const previous = byMac.get(mac);
|
const previous = byMac.get(mac);
|
||||||
|
const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
|
||||||
const lastSeenAt = observation.active || !previous
|
const lastSeenAt = observation.active || !previous
|
||||||
? observation.observedAt || observedAt
|
? observationTime
|
||||||
: previous.lastSeenAt;
|
: previous.lastSeenAt;
|
||||||
byMac.set(mac, {
|
byMac.set(mac, {
|
||||||
id: previous?.id || deviceId(mac),
|
id: previous?.id || deviceId(mac),
|
||||||
@@ -769,10 +1007,10 @@ export function createDeviceInventoryService({
|
|||||||
mac,
|
mac,
|
||||||
ip: String(observation.ip || previous?.ip || ''),
|
ip: String(observation.ip || previous?.ip || ''),
|
||||||
interface: String(observation.interface || previous?.interface || ''),
|
interface: String(observation.interface || previous?.interface || ''),
|
||||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
firstSeenAt: previous?.firstSeenAt || observationTime,
|
||||||
lastSeenAt,
|
lastSeenAt,
|
||||||
source: 'neighbor',
|
source: 'neighbor',
|
||||||
confidence: identitiesByMac.get(mac)?.size > 1
|
confidence: (identitiesByMac.get(mac)?.size || 0) > 1
|
||||||
? 'ambiguous'
|
? 'ambiguous'
|
||||||
: isPrivateMac(mac) ? 'medium' : 'high',
|
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||||
});
|
});
|
||||||
@@ -783,23 +1021,33 @@ export function createDeviceInventoryService({
|
|||||||
));
|
));
|
||||||
let traffic = state.traffic;
|
let traffic = state.traffic;
|
||||||
if (trafficResult) {
|
if (trafficResult) {
|
||||||
if (trafficResult.transportError) {
|
if (typeof trafficResult.transportError === 'string') {
|
||||||
traffic = { ...traffic, lastError: trafficResult.transportError };
|
traffic = { ...traffic, lastError: trafficResult.transportError };
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
|
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
|
||||||
throw new Error('Dataplane не вернул traffic epoch');
|
throw new Error('Dataplane не вернул traffic epoch');
|
||||||
}
|
}
|
||||||
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : [];
|
const trafficEpoch = trafficResult.epoch;
|
||||||
const processByMac = new Map();
|
const trafficObservedAt = typeof trafficResult.observedAt === 'string'
|
||||||
const proxyByMac = new Map();
|
? trafficResult.observedAt
|
||||||
|
: null;
|
||||||
|
const trafficGeneration = typeof trafficResult.generation === 'string'
|
||||||
|
? trafficResult.generation
|
||||||
|
: null;
|
||||||
|
const trafficSourceError = typeof record(trafficResult.source).error === 'string'
|
||||||
|
? String(record(trafficResult.source).error)
|
||||||
|
: null;
|
||||||
|
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : [];
|
||||||
|
const processByMac = new Map<string, CounterTotal>();
|
||||||
|
const proxyByMac = new Map<string, CounterTotal>();
|
||||||
let proxyRows = 0;
|
let proxyRows = 0;
|
||||||
let legacyRows = 0;
|
let legacyRows = 0;
|
||||||
let proxySampleError = null;
|
let proxySampleError: string | null = null;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const mac = normalizeMac(row?.mac);
|
const mac = normalizeMac(row.mac);
|
||||||
const upload = String(row?.uploadBytes ?? '');
|
const upload = String(row.uploadBytes ?? '');
|
||||||
const download = String(row?.downloadBytes ?? '');
|
const download = String(row.downloadBytes ?? '');
|
||||||
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) {
|
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) {
|
||||||
throw new Error('Dataplane вернул невалидный traffic counter');
|
throw new Error('Dataplane вернул невалидный traffic counter');
|
||||||
}
|
}
|
||||||
@@ -808,8 +1056,8 @@ export function createDeviceInventoryService({
|
|||||||
upload: previous.upload + BigInt(upload),
|
upload: previous.upload + BigInt(upload),
|
||||||
download: previous.download + BigInt(download),
|
download: previous.download + BigInt(download),
|
||||||
});
|
});
|
||||||
const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes');
|
const hasProxyUpload = Object.hasOwn(row, 'proxyUploadBytes');
|
||||||
const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes');
|
const hasProxyDownload = Object.hasOwn(row, 'proxyDownloadBytes');
|
||||||
if (!hasProxyUpload && !hasProxyDownload) {
|
if (!hasProxyUpload && !hasProxyDownload) {
|
||||||
legacyRows += 1;
|
legacyRows += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -840,7 +1088,7 @@ export function createDeviceInventoryService({
|
|||||||
if (!knownMacs.has(mac)) continue;
|
if (!knownMacs.has(mac)) continue;
|
||||||
const baseline = baselinesByMac[mac];
|
const baseline = baselinesByMac[mac];
|
||||||
const recovering = rebaselineMacs.has(mac);
|
const recovering = rebaselineMacs.has(mac);
|
||||||
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
|
||||||
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||||
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||||
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||||
@@ -852,10 +1100,10 @@ export function createDeviceInventoryService({
|
|||||||
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||||
downloadBytes: (BigInt(total.downloadBytes)
|
downloadBytes: (BigInt(total.downloadBytes)
|
||||||
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||||
observedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
observedAt: trafficObservedAt || traffic.lastObservedAt,
|
||||||
};
|
};
|
||||||
baselinesByMac[mac] = {
|
baselinesByMac[mac] = {
|
||||||
epoch: trafficResult.epoch,
|
epoch: trafficEpoch,
|
||||||
uploadBytes: processTotal.upload.toString(),
|
uploadBytes: processTotal.upload.toString(),
|
||||||
downloadBytes: processTotal.download.toString(),
|
downloadBytes: processTotal.download.toString(),
|
||||||
};
|
};
|
||||||
@@ -864,8 +1112,8 @@ export function createDeviceInventoryService({
|
|||||||
const globalGateway = accumulateGlobalTraffic(
|
const globalGateway = accumulateGlobalTraffic(
|
||||||
traffic.global.gateway,
|
traffic.global.gateway,
|
||||||
processByMac,
|
processByMac,
|
||||||
trafficResult.epoch,
|
trafficEpoch,
|
||||||
trafficResult.observedAt || traffic.lastObservedAt,
|
trafficObservedAt || traffic.lastObservedAt,
|
||||||
'Gateway',
|
'Gateway',
|
||||||
);
|
);
|
||||||
for (const mac of Object.keys(totalsByMac)) {
|
for (const mac of Object.keys(totalsByMac)) {
|
||||||
@@ -916,7 +1164,7 @@ export function createDeviceInventoryService({
|
|||||||
if (!knownMacs.has(mac)) continue;
|
if (!knownMacs.has(mac)) continue;
|
||||||
const baseline = nextProxyBaselines[mac];
|
const baseline = nextProxyBaselines[mac];
|
||||||
const recovering = nextProxyRebaseline.has(mac);
|
const recovering = nextProxyRebaseline.has(mac);
|
||||||
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
|
||||||
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||||
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||||
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||||
@@ -928,10 +1176,10 @@ export function createDeviceInventoryService({
|
|||||||
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||||
downloadBytes: (BigInt(total.downloadBytes)
|
downloadBytes: (BigInt(total.downloadBytes)
|
||||||
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||||
observedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
observedAt: trafficObservedAt || proxy.lastObservedAt,
|
||||||
};
|
};
|
||||||
nextProxyBaselines[mac] = {
|
nextProxyBaselines[mac] = {
|
||||||
epoch: trafficResult.epoch,
|
epoch: trafficEpoch,
|
||||||
uploadBytes: processTotal.upload.toString(),
|
uploadBytes: processTotal.upload.toString(),
|
||||||
downloadBytes: processTotal.download.toString(),
|
downloadBytes: processTotal.download.toString(),
|
||||||
};
|
};
|
||||||
@@ -940,14 +1188,14 @@ export function createDeviceInventoryService({
|
|||||||
const nextGlobalProxy = accumulateGlobalTraffic(
|
const nextGlobalProxy = accumulateGlobalTraffic(
|
||||||
traffic.global.proxy,
|
traffic.global.proxy,
|
||||||
proxyByMac,
|
proxyByMac,
|
||||||
trafficResult.epoch,
|
trafficEpoch,
|
||||||
trafficResult.observedAt || proxy.lastObservedAt,
|
trafficObservedAt || proxy.lastObservedAt,
|
||||||
'proxy',
|
'proxy',
|
||||||
);
|
);
|
||||||
proxy = {
|
proxy = {
|
||||||
...proxy,
|
...proxy,
|
||||||
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
lastObservedAt: trafficObservedAt || proxy.lastObservedAt,
|
||||||
lastError: trafficResult.source?.error
|
lastError: trafficSourceError
|
||||||
|| (nextProxyRebaseline.size ? proxy.lastError : null),
|
|| (nextProxyRebaseline.size ? proxy.lastError : null),
|
||||||
baselinesByMac: nextProxyBaselines,
|
baselinesByMac: nextProxyBaselines,
|
||||||
totalsByMac: nextProxyTotals,
|
totalsByMac: nextProxyTotals,
|
||||||
@@ -955,15 +1203,15 @@ export function createDeviceInventoryService({
|
|||||||
};
|
};
|
||||||
globalProxy = nextGlobalProxy;
|
globalProxy = nextGlobalProxy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
proxy = { ...proxy, lastError: error.message || String(error) };
|
proxy = { ...proxy, lastError: errorMessage(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
traffic = {
|
traffic = {
|
||||||
...traffic,
|
...traffic,
|
||||||
epoch: trafficResult.epoch,
|
epoch: trafficEpoch,
|
||||||
generation: trafficResult.generation || traffic.generation,
|
generation: trafficGeneration || traffic.generation,
|
||||||
lastObservedAt: trafficResult.observedAt || traffic.lastObservedAt,
|
lastObservedAt: trafficObservedAt || traffic.lastObservedAt,
|
||||||
lastError: trafficResult.source?.error
|
lastError: trafficSourceError
|
||||||
|| (rebaselineMacs.size ? traffic.lastError : null),
|
|| (rebaselineMacs.size ? traffic.lastError : null),
|
||||||
baselinesByMac,
|
baselinesByMac,
|
||||||
totalsByMac,
|
totalsByMac,
|
||||||
@@ -972,7 +1220,7 @@ export function createDeviceInventoryService({
|
|||||||
global: { gateway: globalGateway, proxy: globalProxy },
|
global: { gateway: globalGateway, proxy: globalProxy },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
traffic = { ...traffic, lastError: errorMessage(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,13 +1228,15 @@ export function createDeviceInventoryService({
|
|||||||
...state,
|
...state,
|
||||||
revision: state.revision + 1,
|
revision: state.revision + 1,
|
||||||
lastObservedAt: observedAt,
|
lastObservedAt: observedAt,
|
||||||
lastError: result?.error || null,
|
lastError: typeof result.error === 'string' ? result.error : null,
|
||||||
traffic,
|
traffic,
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
captureTrafficHistory(nextState);
|
captureTrafficHistory(nextState);
|
||||||
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
if (typeof policyResult?.transportError === 'string') {
|
||||||
|
commitPolicyFailure(new Error(policyResult.transportError));
|
||||||
|
}
|
||||||
return reconcileLocked(policyResult, false);
|
return reconcileLocked(policyResult, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1000,52 +1250,59 @@ export function createDeviceInventoryService({
|
|||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(id, patch, expectedRevision) {
|
function update(id: string, patch: unknown, expectedRevision: unknown) {
|
||||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
const aliasProvided = Object.hasOwn(patch, 'alias');
|
const value = record(patch);
|
||||||
const pinProvided = Object.hasOwn(patch, 'pinned');
|
const aliasProvided = Object.hasOwn(value, 'alias');
|
||||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
const pinProvided = Object.hasOwn(value, 'pinned');
|
||||||
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||||
|| (!aliasProvided && !pinProvided)
|
|| (!aliasProvided && !pinProvided)
|
||||||
|| (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64))
|
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
||||||
|| (pinProvided && typeof patch.pinned !== 'boolean')) {
|
|| (pinProvided && typeof value.pinned !== 'boolean')) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
const revision = expectedRevision;
|
||||||
|
const alias = typeof value.alias === 'string' ? value.alias : '';
|
||||||
|
const pinned = value.pinned === true;
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||||
const index = state.devices.findIndex((device) => device.id === id);
|
const index = state.devices.findIndex((device) => device.id === id);
|
||||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||||
const devices = [...state.devices];
|
const devices = [...state.devices];
|
||||||
devices[index] = {
|
devices[index] = {
|
||||||
...devices[index],
|
...devices[index],
|
||||||
...(aliasProvided ? { alias: patch.alias.trim() } : {}),
|
...(aliasProvided ? { alias: alias.trim() } : {}),
|
||||||
...(pinProvided ? { pinned: patch.pinned } : {}),
|
...(pinProvided ? { pinned } : {}),
|
||||||
};
|
};
|
||||||
return { ...state, revision: state.revision + 1, devices };
|
return { ...state, revision: state.revision + 1, devices };
|
||||||
});
|
});
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPolicy(id, mode, expectedRevision) {
|
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
|
||||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||||
|
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
const revision = expectedRevision;
|
||||||
|
const desiredMode = mode as DevicePolicyMode;
|
||||||
return serializePolicy(async () => {
|
return serializePolicy(async () => {
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
const state = migrateDeviceInventoryState(stored);
|
||||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||||
const device = state.devices.find((candidate) => candidate.id === id);
|
const device = state.devices.find((candidate) => candidate.id === id);
|
||||||
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
|
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
|
||||||
if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
|
if (desiredMode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
|
||||||
const current = policyFor(state, device.mac);
|
const current = policyFor(state, device.mac);
|
||||||
if (current.desired === mode && current.status === 'applied') return state;
|
if (current.desired === desiredMode && current.status === 'applied') return state;
|
||||||
const byMac = {
|
const byMac: Record<string, DevicePolicyEntry> = {
|
||||||
...state.policy.byMac,
|
...state.policy.byMac,
|
||||||
[device.mac]: {
|
[device.mac]: {
|
||||||
...current,
|
...current,
|
||||||
desired: mode,
|
desired: desiredMode,
|
||||||
status: 'applying',
|
status: 'applying',
|
||||||
error: null,
|
error: null,
|
||||||
operationId: crypto.randomUUID(),
|
operationId: crypto.randomUUID(),
|
||||||
+54
-18
@@ -1,33 +1,56 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
||||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
const COMMAND_OPTIONS = { encoding: 'utf8' as const, timeout: 2_000, killSignal: 'SIGKILL' as const };
|
||||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
||||||
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
||||||
const MAX_DEVICES = 512;
|
const MAX_DEVICES = 512;
|
||||||
|
|
||||||
const childChain = (chain, slot) => `${chain}_${slot}`;
|
export interface DirectDevice {
|
||||||
const fingerprint = (devices) => crypto.createHash('sha256')
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicySnapshot {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
fingerprint: string;
|
||||||
|
observedAt: string;
|
||||||
|
appliedIds: string[];
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||||
|
const fingerprint = (devices: readonly DirectDevice[]) => crypto.createHash('sha256')
|
||||||
.update(JSON.stringify(devices))
|
.update(JSON.stringify(devices))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
|
||||||
function commandError(command, result) {
|
function commandError(command: string, result: SpawnSyncReturns<string>) {
|
||||||
return new Error(String(
|
return new Error(String(
|
||||||
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||||
).trim());
|
).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDirectDevices(value) {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDirectDevices(value: unknown): DirectDevice[] {
|
||||||
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
|
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
|
||||||
throw new Error('Некорректный набор device policy');
|
throw new Error('Некорректный набор device policy');
|
||||||
}
|
}
|
||||||
const ids = new Set();
|
const ids = new Set<string>();
|
||||||
const tuples = new Set();
|
const tuples = new Set<string>();
|
||||||
const devices = value.map((device) => {
|
const devices = value.map((value) => {
|
||||||
|
const device = record(value);
|
||||||
const normalized = {
|
const normalized = {
|
||||||
id: String(device?.id || ''),
|
id: String(device?.id || ''),
|
||||||
ip: String(device?.ip || ''),
|
ip: String(device?.ip || ''),
|
||||||
@@ -47,9 +70,15 @@ export function normalizeDirectDevices(value) {
|
|||||||
return devices.sort((left, right) => left.id.localeCompare(right.id));
|
return devices.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value));
|
export const fingerprintDirectDevices = (value: unknown) => fingerprint(normalizeDirectDevices(value));
|
||||||
|
|
||||||
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) {
|
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }: {
|
||||||
|
devices: readonly DirectDevice[];
|
||||||
|
chain: string;
|
||||||
|
slot: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
}) {
|
||||||
const child = childChain(chain, slot);
|
const child = childChain(chain, slot);
|
||||||
const rules = ['*mangle', `-F ${child}`];
|
const rules = ['*mangle', `-F ${child}`];
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
@@ -71,6 +100,13 @@ export function createDevicePolicyService({
|
|||||||
run = spawnSync,
|
run = spawnSync,
|
||||||
now = () => new Date(),
|
now = () => new Date(),
|
||||||
nextGeneration = () => crypto.randomUUID(),
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}: {
|
||||||
|
chain: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
run?: typeof spawnSync;
|
||||||
|
now?: () => Date;
|
||||||
|
nextGeneration?: () => string;
|
||||||
}) {
|
}) {
|
||||||
if (!CHAIN_PATTERN.test(String(chain || ''))
|
if (!CHAIN_PATTERN.test(String(chain || ''))
|
||||||
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|
||||||
@@ -78,19 +114,19 @@ export function createDevicePolicyService({
|
|||||||
throw new Error('Некорректная конфигурация device policy');
|
throw new Error('Некорректная конфигурация device policy');
|
||||||
}
|
}
|
||||||
const epoch = nextGeneration();
|
const epoch = nextGeneration();
|
||||||
let activeSlot = 'A';
|
let activeSlot: 'A' | 'B' = 'A';
|
||||||
let activeSignature = JSON.stringify([]);
|
let activeSignature = JSON.stringify([]);
|
||||||
let generation = epoch;
|
let generation = epoch;
|
||||||
let appliedDevices = [];
|
let appliedDevices: DirectDevice[] = [];
|
||||||
let observedAt = now().toISOString();
|
let observedAt = now().toISOString();
|
||||||
let queue = Promise.resolve();
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
function execute(command, args, input) {
|
function execute(command: string, args: string[], input?: string) {
|
||||||
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
|
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
|
||||||
if (result.error || result.status !== 0) throw commandError(command, result);
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(changed = false) {
|
function snapshot(changed = false): PolicySnapshot {
|
||||||
return {
|
return {
|
||||||
epoch,
|
epoch,
|
||||||
generation,
|
generation,
|
||||||
@@ -101,7 +137,7 @@ export function createDevicePolicyService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function performApply(value) {
|
function performApply(value: unknown) {
|
||||||
const devices = normalizeDirectDevices(value);
|
const devices = normalizeDirectDevices(value);
|
||||||
const signature = JSON.stringify(devices);
|
const signature = JSON.stringify(devices);
|
||||||
if (signature === activeSignature) return snapshot(false);
|
if (signature === activeSignature) return snapshot(false);
|
||||||
@@ -124,7 +160,7 @@ export function createDevicePolicyService({
|
|||||||
return snapshot(true);
|
return snapshot(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(devices) {
|
function apply(devices: unknown): Promise<PolicySnapshot> {
|
||||||
const result = queue.then(() => performApply(devices));
|
const result = queue.then(() => performApply(devices));
|
||||||
queue = result.catch(() => {});
|
queue = result.catch(() => {});
|
||||||
return result;
|
return result;
|
||||||
+140
-64
@@ -1,9 +1,51 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import { isDeviceInterface } from '../adapters/neighbors.js';
|
import { isDeviceInterface, type NeighborObservation } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
interface CommandOptions {
|
||||||
|
encoding: BufferEncoding;
|
||||||
|
timeout: number;
|
||||||
|
killSignal: NodeJS.Signals;
|
||||||
|
input?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficDevice {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
|
||||||
|
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
|
||||||
|
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
|
||||||
|
type CounterValues = Record<CounterField, bigint>;
|
||||||
|
|
||||||
|
interface RetiredCounters {
|
||||||
|
slot: 'A' | 'B';
|
||||||
|
devices: TrafficDevice[];
|
||||||
|
counters: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficSnapshot {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
observedAt: string | null;
|
||||||
|
source: { error: string | null };
|
||||||
|
devices: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunCommand = (command: string, args: string[], options?: CommandOptions) => Promise<CommandResult>;
|
||||||
|
|
||||||
|
const COMMAND_OPTIONS: CommandOptions = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
||||||
const COUNTERS = [
|
const COUNTERS = [
|
||||||
@@ -11,37 +53,38 @@ const COUNTERS = [
|
|||||||
['download', 'download', 'downloadBytes'],
|
['download', 'download', 'downloadBytes'],
|
||||||
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
||||||
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
||||||
];
|
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
|
||||||
|
|
||||||
const childChain = (chain, slot) => `${chain}_${slot}`;
|
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||||
const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`;
|
const proxyChildChain = (chain: string, slot: string) => `${childChain(chain, slot)}_P`;
|
||||||
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
|
const counterKey = ({ ip, mac, interface: deviceInterface }: Omit<TrafficDevice, 'key'>) => crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(`${ip}|${mac}|${deviceInterface}`)
|
.update(`${ip}|${mac}|${deviceInterface}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
.slice(0, 16);
|
.slice(0, 16);
|
||||||
|
|
||||||
function commandError(command, result) {
|
function commandError(command: string, result: CommandResult) {
|
||||||
|
const cause = result.error instanceof Error ? result.error.message : result.error;
|
||||||
return new Error(String(
|
return new Error(String(
|
||||||
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
result.stderr || result.stdout || cause || `${command} завершился с ошибкой`,
|
||||||
).trim());
|
).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCommand(command, args, options = COMMAND_OPTIONS) {
|
function runCommand(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS): Promise<CommandResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let child;
|
let child: ChildProcessWithoutNullStreams;
|
||||||
try {
|
try {
|
||||||
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
resolve({ status: null, stdout: '', stderr: '', error });
|
resolve({ status: null, stdout: '', stderr: '', error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stdout = [];
|
const stdout: Buffer[] = [];
|
||||||
const stderr = [];
|
const stderr: Buffer[] = [];
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let timer;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const finish = (result) => {
|
const finish = (result: Pick<CommandResult, 'status'> & { error?: unknown }) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -51,8 +94,8 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
|
|||||||
...result,
|
...result,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||||
child.on('error', (error) => finish({ status: null, error }));
|
child.on('error', (error) => finish({ status: null, error }));
|
||||||
child.on('close', (status) => finish({
|
child.on('close', (status) => finish({
|
||||||
status,
|
status,
|
||||||
@@ -67,36 +110,43 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIpv4Cidr(value) {
|
function isIpv4Cidr(value: unknown) {
|
||||||
const [address, prefix, extra] = String(value).split('/');
|
const [address, prefix, extra] = String(value).split('/');
|
||||||
const size = Number(prefix);
|
const size = Number(prefix);
|
||||||
return extra === undefined && net.isIPv4(address)
|
return extra === undefined && net.isIPv4(address)
|
||||||
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
const zeroCounters = () => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||||
|
|
||||||
export function selectTrafficDevices(observations) {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
const candidates = new Map();
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
const ipsByMac = new Map();
|
? value as Record<string, unknown>
|
||||||
const locationsByIp = new Map();
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
for (const observation of Array.isArray(observations) ? observations : []) {
|
export function selectTrafficDevices(observations: unknown): TrafficDevice[] {
|
||||||
const ip = String(observation?.ip || '');
|
const candidates = new Map<string, Omit<TrafficDevice, 'key'>>();
|
||||||
const mac = String(observation?.mac || '').toLowerCase();
|
const ipsByMac = new Map<string, Set<string>>();
|
||||||
const deviceInterface = String(observation?.interface || '');
|
const locationsByIp = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const value of Array.isArray(observations) ? observations : []) {
|
||||||
|
const observation = record(value);
|
||||||
|
const ip = String(observation.ip || '');
|
||||||
|
const mac = String(observation.mac || '').toLowerCase();
|
||||||
|
const deviceInterface = String(observation.interface || '');
|
||||||
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
|
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
|
||||||
|
|
||||||
const location = `${mac}|${deviceInterface}`;
|
const location = `${mac}|${deviceInterface}`;
|
||||||
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
||||||
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||||
ipsByMac.get(mac).add(ip);
|
ipsByMac.get(mac)?.add(ip);
|
||||||
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
|
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
|
||||||
locationsByIp.get(ip).add(location);
|
locationsByIp.get(ip)?.add(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...candidates.values()]
|
return [...candidates.values()]
|
||||||
.filter(({ ip, mac }) => ipsByMac.get(mac).size === 1 && locationsByIp.get(ip).size === 1)
|
.filter(({ ip, mac }) => ipsByMac.get(mac)?.size === 1 && locationsByIp.get(ip)?.size === 1)
|
||||||
.map((device) => ({ ...device, key: counterKey(device) }))
|
.map((device) => ({ ...device, key: counterKey(device) }))
|
||||||
.sort((left, right) => (
|
.sort((left, right) => (
|
||||||
left.ip.localeCompare(right.ip)
|
left.ip.localeCompare(right.ip)
|
||||||
@@ -112,6 +162,13 @@ export function buildTrafficRestore({
|
|||||||
downloadChain,
|
downloadChain,
|
||||||
slot,
|
slot,
|
||||||
proxyPort,
|
proxyPort,
|
||||||
|
}: {
|
||||||
|
devices: readonly TrafficDevice[];
|
||||||
|
bypassCidrs: readonly string[];
|
||||||
|
uploadChain: string;
|
||||||
|
downloadChain: string;
|
||||||
|
slot: string;
|
||||||
|
proxyPort: number;
|
||||||
}) {
|
}) {
|
||||||
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
||||||
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
||||||
@@ -160,12 +217,12 @@ export function buildTrafficRestore({
|
|||||||
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
|
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTrafficCounters(text, chain) {
|
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
|
||||||
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const linePattern = new RegExp(
|
const linePattern = new RegExp(
|
||||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
||||||
);
|
);
|
||||||
const counters = new Map();
|
const counters = new Map<string, string>();
|
||||||
for (const line of String(text || '').split(/\r?\n/)) {
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
const match = line.match(linePattern);
|
const match = line.match(linePattern);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
@@ -183,17 +240,25 @@ export function createDeviceTrafficService({
|
|||||||
proxyPort,
|
proxyPort,
|
||||||
run = runCommand,
|
run = runCommand,
|
||||||
nextGeneration = () => crypto.randomUUID(),
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}: {
|
||||||
|
observe: () => Promise<unknown> | unknown;
|
||||||
|
uploadChain: string;
|
||||||
|
downloadChain: string;
|
||||||
|
bypassCidrs: string[];
|
||||||
|
proxyPort: number;
|
||||||
|
run?: RunCommand;
|
||||||
|
nextGeneration?: () => string;
|
||||||
}) {
|
}) {
|
||||||
const epoch = nextGeneration();
|
const epoch = nextGeneration();
|
||||||
let activeSlot = null;
|
let activeSlot: 'A' | 'B' | null = null;
|
||||||
let activeDevices = [];
|
let activeDevices: TrafficDevice[] = [];
|
||||||
let activeSignature = '';
|
let activeSignature = '';
|
||||||
let activeCounters = new Map();
|
let activeCounters = new Map<string, string>();
|
||||||
let pendingRetired = null;
|
let pendingRetired: RetiredCounters | null = null;
|
||||||
let refreshPromise = null;
|
let refreshPromise: Promise<TrafficSnapshot> | null = null;
|
||||||
const finalized = new Map();
|
const finalized = new Map<string, CounterValues>();
|
||||||
const devicesByKey = new Map();
|
const devicesByKey = new Map<string, TrafficDevice>();
|
||||||
let current = {
|
let current: TrafficSnapshot = {
|
||||||
epoch,
|
epoch,
|
||||||
generation: epoch,
|
generation: epoch,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
@@ -201,13 +266,13 @@ export function createDeviceTrafficService({
|
|||||||
devices: [],
|
devices: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function execute(command, args, options = COMMAND_OPTIONS) {
|
async function execute(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS) {
|
||||||
const result = await run(command, args, options);
|
const result = await run(command, args, options);
|
||||||
if (result.error || result.status !== 0) throw commandError(command, result);
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
return String(result.stdout || '');
|
return String(result.stdout || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepare(slot, devices) {
|
async function prepare(slot: 'A' | 'B', devices: TrafficDevice[]) {
|
||||||
const input = buildTrafficRestore({
|
const input = buildTrafficRestore({
|
||||||
devices,
|
devices,
|
||||||
bypassCidrs,
|
bypassCidrs,
|
||||||
@@ -219,7 +284,7 @@ export function createDeviceTrafficService({
|
|||||||
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
|
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchTo(slot) {
|
async function switchTo(slot: 'A' | 'B') {
|
||||||
const uploadChild = childChain(uploadChain, slot);
|
const uploadChild = childChain(uploadChain, slot);
|
||||||
const downloadChild = childChain(downloadChain, slot);
|
const downloadChild = childChain(downloadChain, slot);
|
||||||
const replace = activeSlot ? '-R' : '-A';
|
const replace = activeSlot ? '-R' : '-A';
|
||||||
@@ -242,8 +307,8 @@ export function createDeviceTrafficService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readCounters(devices, slot) {
|
async function readCounters(devices: TrafficDevice[], slot: 'A' | 'B' | null): Promise<Map<string, string>> {
|
||||||
if (!slot) return new Map();
|
if (!slot) return new Map<string, string>();
|
||||||
const [raw, mangle] = await Promise.all([
|
const [raw, mangle] = await Promise.all([
|
||||||
execute('iptables-save', ['-c', '-t', 'raw']),
|
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||||
execute('iptables-save', ['-c', '-t', 'mangle']),
|
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||||
@@ -254,7 +319,7 @@ export function createDeviceTrafficService({
|
|||||||
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
||||||
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
||||||
};
|
};
|
||||||
const counters = new Map();
|
const counters = new Map<string, string>();
|
||||||
for (const { key } of devices) {
|
for (const { key } of devices) {
|
||||||
for (const [kind, field] of COUNTERS) {
|
for (const [kind, field] of COUNTERS) {
|
||||||
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
||||||
@@ -263,11 +328,11 @@ export function createDeviceTrafficService({
|
|||||||
return counters;
|
return counters;
|
||||||
}
|
}
|
||||||
|
|
||||||
function counter(counters, key, direction) {
|
function counter(counters: Map<string, string>, key: string, direction: CounterKind) {
|
||||||
return BigInt(counters.get(`${key}:${direction}`) || '0');
|
return BigInt(counters.get(`${key}:${direction}`) || '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
function remember(devices) {
|
function remember(devices: TrafficDevice[]) {
|
||||||
for (const device of devices) devicesByKey.set(device.key, device);
|
for (const device of devices) devicesByKey.set(device.key, device);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +341,7 @@ export function createDeviceTrafficService({
|
|||||||
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
|
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||||
for (const { key } of pendingRetired.devices) {
|
for (const { key } of pendingRetired.devices) {
|
||||||
const previous = finalized.get(key) || zeroCounters();
|
const previous = finalized.get(key) || zeroCounters();
|
||||||
const next = { ...previous };
|
const next: CounterValues = { ...previous };
|
||||||
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
||||||
finalized.set(key, next);
|
finalized.set(key, next);
|
||||||
}
|
}
|
||||||
@@ -286,12 +351,15 @@ export function createDeviceTrafficService({
|
|||||||
|
|
||||||
function processTotals() {
|
function processTotals() {
|
||||||
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
||||||
const totalsByMac = new Map();
|
const totalsByMac = new Map<string, TrafficDevice & CounterValues>();
|
||||||
for (const [key, remembered] of devicesByKey) {
|
for (const [key, remembered] of devicesByKey) {
|
||||||
const base = finalized.get(key) || zeroCounters();
|
const base = finalized.get(key) || zeroCounters();
|
||||||
const pending = pendingRetired?.counters || new Map();
|
const pending = pendingRetired?.counters || new Map();
|
||||||
const previous = totalsByMac.get(remembered.mac) || zeroCounters();
|
const previous = totalsByMac.get(remembered.mac) || { ...remembered, ...zeroCounters() };
|
||||||
const total = { ...(activeByMac.get(remembered.mac) || remembered) };
|
const total: TrafficDevice & CounterValues = {
|
||||||
|
...(activeByMac.get(remembered.mac) || remembered),
|
||||||
|
...zeroCounters(),
|
||||||
|
};
|
||||||
for (const [kind, field] of COUNTERS) {
|
for (const [kind, field] of COUNTERS) {
|
||||||
total[field] = previous[field] + base[field]
|
total[field] = previous[field] + base[field]
|
||||||
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
|
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
|
||||||
@@ -299,22 +367,28 @@ export function createDeviceTrafficService({
|
|||||||
totalsByMac.set(remembered.mac, total);
|
totalsByMac.set(remembered.mac, total);
|
||||||
}
|
}
|
||||||
return [...totalsByMac.values()]
|
return [...totalsByMac.values()]
|
||||||
.map((total) => Object.fromEntries([
|
.map((total) => {
|
||||||
...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)),
|
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
|
||||||
...COUNTERS.map(([, field, output]) => [output, total[field].toString()]),
|
return {
|
||||||
]))
|
...device,
|
||||||
|
uploadBytes: upload.toString(),
|
||||||
|
downloadBytes: download.toString(),
|
||||||
|
proxyUploadBytes: proxyUpload.toString(),
|
||||||
|
proxyDownloadBytes: proxyDownload.toString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((left, right) => left.mac.localeCompare(right.mac));
|
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performRefresh() {
|
async function performRefresh() {
|
||||||
let observed;
|
let observed: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
observed = await observe();
|
observed = record(await observe());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
observed = { observedAt: new Date().toISOString(), observations: [], error: error.message || String(error) };
|
observed = { observedAt: new Date().toISOString(), observations: [], error: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceError = observed?.error || null;
|
let sourceError = observed.error ? String(observed.error) : null;
|
||||||
const nextDevices = sourceError
|
const nextDevices = sourceError
|
||||||
? activeDevices
|
? activeDevices
|
||||||
: selectTrafficDevices(observed?.observations);
|
: selectTrafficDevices(observed?.observations);
|
||||||
@@ -325,7 +399,7 @@ export function createDeviceTrafficService({
|
|||||||
try {
|
try {
|
||||||
countersRead = await finalizeRetired() || countersRead;
|
countersRead = await finalizeRetired() || countersRead;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sourceError = sourceError || error.message || String(error);
|
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +422,7 @@ export function createDeviceTrafficService({
|
|||||||
current.generation = nextGeneration();
|
current.generation = nextGeneration();
|
||||||
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
|
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sourceError = error.message || String(error);
|
sourceError = error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,12 +430,14 @@ export function createDeviceTrafficService({
|
|||||||
activeCounters = await readCounters(activeDevices, activeSlot);
|
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||||
countersRead = true;
|
countersRead = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sourceError = sourceError || error.message || String(error);
|
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
current = {
|
current = {
|
||||||
epoch,
|
epoch,
|
||||||
generation: current.generation,
|
generation: current.generation,
|
||||||
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
|
observedAt: countersRead && typeof observed.observedAt === 'string'
|
||||||
|
? observed.observedAt
|
||||||
|
: current.observedAt,
|
||||||
source: { error: sourceError },
|
source: { error: sourceError },
|
||||||
devices: countersRead ? processTotals() : current.devices,
|
devices: countersRead ? processTotals() : current.devices,
|
||||||
};
|
};
|
||||||
+99
-35
@@ -7,15 +7,67 @@ import { deviceId } from './deviceInventoryService.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;
|
||||||
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
||||||
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'];
|
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
|
||||||
|
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
|
||||||
const SERVICE_DOMAINS = [
|
const SERVICE_DOMAINS = [
|
||||||
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
||||||
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
const matchesDomain = (domain, suffix) => domain === suffix || domain.endsWith(`.${suffix}`);
|
interface ParsedBaseConnection {
|
||||||
|
id: string;
|
||||||
|
upload: bigint;
|
||||||
|
download: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyDomain(value) {
|
type ParsedConnection =
|
||||||
|
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
|
||||||
|
| (ParsedBaseConnection & {
|
||||||
|
outcome: 'classified' | 'unresolved_host';
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
source: 'gateway' | 'proxy';
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PreviousConnection {
|
||||||
|
outcome: AttributionOutcome | 'classified';
|
||||||
|
key?: string;
|
||||||
|
requestedKey?: string;
|
||||||
|
countedUpload: bigint | null;
|
||||||
|
countedDownload: bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainSeriesTotal {
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
source: string;
|
||||||
|
uploadBytes: bigint;
|
||||||
|
downloadBytes: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainTrafficSnapshot {
|
||||||
|
epoch: string;
|
||||||
|
observedAt: string | null;
|
||||||
|
source: { error: string | null };
|
||||||
|
overflowConnections: string;
|
||||||
|
attributionEvents: Record<AttributionOutcome, string>;
|
||||||
|
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||||
|
|
||||||
|
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||||
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
|
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||||
if (domain.startsWith('www.')) domain = domain.slice(4);
|
if (domain.startsWith('www.')) domain = domain.slice(4);
|
||||||
const labels = domain.split('.');
|
const labels = domain.split('.');
|
||||||
@@ -28,19 +80,20 @@ export function classifyDomain(value) {
|
|||||||
return { domain, service: domain };
|
return { domain, service: domain };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceFor(type) {
|
function sourceFor(type: string): 'gateway' | 'proxy' | null {
|
||||||
if (type === 'tproxy/tproxy-in') return 'gateway';
|
if (type === 'tproxy/tproxy-in') return 'gateway';
|
||||||
if (type === 'mixed/mixed-in') return 'proxy';
|
if (type === 'mixed/mixed-in') return 'proxy';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConnection(connection, devicesByIp) {
|
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
|
||||||
const id = String(connection?.id || '');
|
const connection = record(value);
|
||||||
const metadata = connection?.metadata;
|
const id = String(connection.id || '');
|
||||||
const upload = connection?.upload;
|
const metadata = record(connection.metadata);
|
||||||
const download = connection?.download;
|
const upload = connection.upload;
|
||||||
if (!id || !Number.isSafeInteger(upload) || upload < 0
|
const download = connection.download;
|
||||||
|| !Number.isSafeInteger(download) || download < 0) {
|
if (!id || typeof upload !== 'number' || !Number.isSafeInteger(upload) || upload < 0
|
||||||
|
|| typeof download !== 'number' || !Number.isSafeInteger(download) || download < 0) {
|
||||||
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
||||||
}
|
}
|
||||||
const parsed = {
|
const parsed = {
|
||||||
@@ -48,11 +101,11 @@ function parseConnection(connection, devicesByIp) {
|
|||||||
upload: BigInt(upload),
|
upload: BigInt(upload),
|
||||||
download: BigInt(download),
|
download: BigInt(download),
|
||||||
};
|
};
|
||||||
const source = sourceFor(String(metadata?.type || ''));
|
const source = sourceFor(String(metadata.type || ''));
|
||||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||||
const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || ''));
|
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
|
||||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||||
const classifiedDomain = classifyDomain(metadata?.host);
|
const classifiedDomain = classifyDomain(metadata.host);
|
||||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -63,13 +116,13 @@ function parseConnection(connection, devicesByIp) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readSingboxConnections(port, timeoutMs = 1500) {
|
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) => {
|
||||||
const chunks = [];
|
const chunks: Buffer[] = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
let tooLarge = false;
|
let tooLarge = false;
|
||||||
response.on('data', (chunk) => {
|
response.on('data', (chunk: Buffer) => {
|
||||||
if (tooLarge) return;
|
if (tooLarge) return;
|
||||||
size += chunk.length;
|
size += chunk.length;
|
||||||
if (size > MAX_RESPONSE_BYTES) {
|
if (size > MAX_RESPONSE_BYTES) {
|
||||||
@@ -102,26 +155,35 @@ export function createDomainTrafficService({
|
|||||||
devices,
|
devices,
|
||||||
now = () => new Date(),
|
now = () => new Date(),
|
||||||
maxSeries = DEFAULT_MAX_SERIES,
|
maxSeries = DEFAULT_MAX_SERIES,
|
||||||
|
}: {
|
||||||
|
observe: () => Promise<unknown> | unknown;
|
||||||
|
devices: () => unknown;
|
||||||
|
now?: () => Date;
|
||||||
|
maxSeries?: number;
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
|
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
|
||||||
const epoch = crypto.randomUUID();
|
const epoch = crypto.randomUUID();
|
||||||
const totals = new Map();
|
const totals = new Map<string, DomainSeriesTotal>();
|
||||||
const normalSeriesLimit = maxSeries - 2;
|
const normalSeriesLimit = maxSeries - 2;
|
||||||
let normalSeries = 0;
|
let normalSeries = 0;
|
||||||
let previousConnections = new Map();
|
let previousConnections = new Map<string, PreviousConnection>();
|
||||||
let overflowConnections = 0n;
|
let overflowConnections = 0n;
|
||||||
const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n]));
|
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||||
let refreshPromise = null;
|
unresolved_host: 0n,
|
||||||
let current = {
|
unknown_device: 0n,
|
||||||
|
unsupported_source: 0n,
|
||||||
|
};
|
||||||
|
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||||
|
let current: DomainTrafficSnapshot = {
|
||||||
epoch,
|
epoch,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null },
|
||||||
overflowConnections: '0',
|
overflowConnections: '0',
|
||||||
attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])),
|
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||||
series: [],
|
series: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildSnapshot(error = null) {
|
function buildSnapshot(error: string | null = null): DomainTrafficSnapshot {
|
||||||
return {
|
return {
|
||||||
epoch,
|
epoch,
|
||||||
observedAt: current.observedAt,
|
observedAt: current.observedAt,
|
||||||
@@ -129,7 +191,7 @@ export function createDomainTrafficService({
|
|||||||
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()]),
|
||||||
),
|
) as Record<AttributionOutcome, string>,
|
||||||
series: [...totals.values()]
|
series: [...totals.values()]
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -147,17 +209,18 @@ export function createDomainTrafficService({
|
|||||||
|
|
||||||
async function performRefresh() {
|
async function performRefresh() {
|
||||||
try {
|
try {
|
||||||
const response = await observe();
|
const response = record(await observe());
|
||||||
if (!Array.isArray(response?.connections)) throw new Error('Sing-box не вернул connections array');
|
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||||
const devicesByIp = new Map();
|
const devicesByIp = new Map<string, string | null>();
|
||||||
const observedDevices = devices();
|
const observedDevices = devices();
|
||||||
for (const device of Array.isArray(observedDevices) ? observedDevices : []) {
|
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||||
const ip = String(device?.ip || '');
|
const device = record(value);
|
||||||
const id = typeof device?.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
const ip = String(device.ip || '');
|
||||||
|
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||||
if (!net.isIPv4(ip) || !id) continue;
|
if (!net.isIPv4(ip) || !id) continue;
|
||||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||||
}
|
}
|
||||||
const activeConnections = new Map();
|
const activeConnections = new Map<string, PreviousConnection>();
|
||||||
for (const rawConnection of response.connections) {
|
for (const rawConnection of response.connections) {
|
||||||
const connection = parseConnection(rawConnection, devicesByIp);
|
const connection = parseConnection(rawConnection, devicesByIp);
|
||||||
const previous = previousConnections.get(connection.id);
|
const previous = previousConnections.get(connection.id);
|
||||||
@@ -172,8 +235,9 @@ export function createDomainTrafficService({
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||||
let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey;
|
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||||
let domain = connection.domain;
|
let domain = connection.domain;
|
||||||
let service = connection.service;
|
let service = connection.service;
|
||||||
if (key !== requestedKey) {
|
if (key !== requestedKey) {
|
||||||
@@ -217,7 +281,7 @@ export function createDomainTrafficService({
|
|||||||
current = buildSnapshot();
|
current = buildSnapshot();
|
||||||
return current;
|
return current;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
current = buildSnapshot(error.message || String(error));
|
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { HarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
|
export interface RollbackStep {
|
||||||
|
run(): unknown | Promise<unknown>;
|
||||||
|
runtime?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishRollback(
|
||||||
|
originalError: unknown,
|
||||||
|
steps: RollbackStep[],
|
||||||
|
message: string,
|
||||||
|
): Promise<never> {
|
||||||
|
const rollbackErrors: unknown[] = [];
|
||||||
|
let runtimeRollbackFailed = false;
|
||||||
|
for (const step of steps) {
|
||||||
|
try {
|
||||||
|
await step.run();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
rollbackErrors.push(rollbackError);
|
||||||
|
runtimeRollbackFailed ||= Boolean(step.runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rollbackErrors.length) {
|
||||||
|
const cause = new AggregateError([originalError, ...rollbackErrors], message);
|
||||||
|
if (runtimeRollbackFailed) throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw originalError;
|
||||||
|
}
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
|
||||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 4;
|
|
||||||
|
|
||||||
const clone = (value) => structuredClone(value);
|
|
||||||
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
|
||||||
|
|
||||||
function syncDirectory(directory) {
|
|
||||||
let descriptor;
|
|
||||||
try {
|
|
||||||
descriptor = fs.openSync(directory, 'r');
|
|
||||||
fs.fsyncSync(descriptor);
|
|
||||||
} catch (error) {
|
|
||||||
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(error.code)) throw error;
|
|
||||||
} finally {
|
|
||||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function atomicWriteFile(filePath, contents, { beforeRename, mode } = {}) {
|
|
||||||
const directory = path.dirname(filePath);
|
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
|
||||||
const temporaryPath = path.join(
|
|
||||||
directory,
|
|
||||||
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
||||||
);
|
|
||||||
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
|
||||||
let descriptor;
|
|
||||||
|
|
||||||
try {
|
|
||||||
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
|
||||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
|
||||||
fs.fsyncSync(descriptor);
|
|
||||||
fs.closeSync(descriptor);
|
|
||||||
descriptor = undefined;
|
|
||||||
beforeRename?.(temporaryPath, filePath);
|
|
||||||
fs.renameSync(temporaryPath, filePath);
|
|
||||||
syncDirectory(directory);
|
|
||||||
} finally {
|
|
||||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
||||||
fs.rmSync(temporaryPath, { force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function atomicWriteJson(filePath, value, options) {
|
|
||||||
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function migrateStoredState(value) {
|
|
||||||
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
||||||
const version = Number.isSafeInteger(stored.schemaVersion) ? stored.schemaVersion : 0;
|
|
||||||
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
|
||||||
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
|
||||||
}
|
|
||||||
const routeRules = version < 3
|
|
||||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
|
||||||
: stored.routeRules;
|
|
||||||
return {
|
|
||||||
...normalizeStoredState({ ...stored, routeRules }),
|
|
||||||
schemaVersion: STATE_SCHEMA_VERSION,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createJsonStore({
|
|
||||||
filePath,
|
|
||||||
defaultValue,
|
|
||||||
migrate = (value) => value,
|
|
||||||
initializeMissing = false,
|
|
||||||
backupWhen = () => false,
|
|
||||||
now = () => new Date(),
|
|
||||||
} = {}) {
|
|
||||||
let recovery = null;
|
|
||||||
let migration = null;
|
|
||||||
|
|
||||||
function write(value, options) {
|
|
||||||
const migrated = migrate(clone(value));
|
|
||||||
atomicWriteJson(filePath, migrated, options);
|
|
||||||
return clone(migrated);
|
|
||||||
}
|
|
||||||
|
|
||||||
function read() {
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
const initial = migrate(clone(defaultValue));
|
|
||||||
return initializeMissing ? write(initial) : clone(initial);
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch (cause) {
|
|
||||||
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
|
||||||
fs.renameSync(filePath, backupPath);
|
|
||||||
try {
|
|
||||||
const recovered = write(defaultValue);
|
|
||||||
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
|
||||||
return recovered;
|
|
||||||
} catch (error) {
|
|
||||||
fs.renameSync(backupPath, filePath);
|
|
||||||
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrated = migrate(clone(parsed));
|
|
||||||
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
|
||||||
if (backupWhen(parsed, migrated)) {
|
|
||||||
const fromVersion = Number.isSafeInteger(parsed?.schemaVersion) ? parsed.schemaVersion : 0;
|
|
||||||
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
|
||||||
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
|
||||||
migration = {
|
|
||||||
fromVersion,
|
|
||||||
toVersion: migrated.schemaVersion,
|
|
||||||
backupPath,
|
|
||||||
migratedAt: now().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
atomicWriteJson(filePath, migrated);
|
|
||||||
}
|
|
||||||
return clone(migrated);
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(mutator) {
|
|
||||||
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
|
||||||
const next = mutator(read());
|
|
||||||
if (next && typeof next.then === 'function') {
|
|
||||||
throw new TypeError('State store mutator must be synchronous');
|
|
||||||
}
|
|
||||||
return write(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
read,
|
|
||||||
write,
|
|
||||||
update,
|
|
||||||
remove: () => fs.rmSync(filePath, { force: true }),
|
|
||||||
get recovery() { return recovery; },
|
|
||||||
get migration() { return migration; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStateStore(filePath, options = {}) {
|
|
||||||
return createJsonStore({
|
|
||||||
filePath,
|
|
||||||
defaultValue: {},
|
|
||||||
migrate: migrateStoredState,
|
|
||||||
initializeMissing: true,
|
|
||||||
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
|
||||||
|
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||||
|
|
||||||
|
export const STATE_SCHEMA_VERSION = 4;
|
||||||
|
|
||||||
|
export interface AtomicWriteOptions {
|
||||||
|
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||||
|
mode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryState {
|
||||||
|
kind: 'corrupt-json';
|
||||||
|
backupPath: string;
|
||||||
|
recoveredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MigrationState {
|
||||||
|
fromVersion: number;
|
||||||
|
toVersion: unknown;
|
||||||
|
backupPath: string;
|
||||||
|
migratedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonStoreBaseOptions {
|
||||||
|
filePath: string;
|
||||||
|
initializeMissing?: boolean;
|
||||||
|
backupWhen?: (before: unknown, after: unknown) => boolean;
|
||||||
|
now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonStoreOptions<T> extends JsonStoreBaseOptions {
|
||||||
|
defaultValue: T;
|
||||||
|
migrate: (value: unknown) => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawJsonStoreOptions extends JsonStoreBaseOptions {
|
||||||
|
defaultValue: unknown;
|
||||||
|
migrate?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonStore<T> {
|
||||||
|
read(): T;
|
||||||
|
write(value: T, options?: AtomicWriteOptions): T;
|
||||||
|
update(mutator: (value: T) => T): T;
|
||||||
|
remove(): void;
|
||||||
|
readonly recovery: RecoveryState | null;
|
||||||
|
readonly migration: MigrationState | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = <T>(value: T): T => structuredClone(value);
|
||||||
|
const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-');
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDirectory(directory: string) {
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(directory, 'r');
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error;
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atomicWriteFile(
|
||||||
|
filePath: string,
|
||||||
|
contents: string | NodeJS.ArrayBufferView,
|
||||||
|
{ beforeRename, mode }: AtomicWriteOptions = {},
|
||||||
|
) {
|
||||||
|
const directory = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const temporaryPath = path.join(
|
||||||
|
directory,
|
||||||
|
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
|
||||||
|
let descriptor: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
|
||||||
|
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
descriptor = undefined;
|
||||||
|
beforeRename?.(temporaryPath, filePath);
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
syncDirectory(directory);
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) {
|
||||||
|
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
|
||||||
|
const stored = record(value);
|
||||||
|
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
|
||||||
|
if (version < 0 || version > STATE_SCHEMA_VERSION) {
|
||||||
|
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
|
||||||
|
}
|
||||||
|
const routeRules = version < 3
|
||||||
|
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||||
|
: stored.routeRules;
|
||||||
|
return {
|
||||||
|
...normalizeStoredState({ ...stored, routeRules }),
|
||||||
|
schemaVersion: STATE_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
|
||||||
|
export function createJsonStore(options: RawJsonStoreOptions): JsonStore<unknown>;
|
||||||
|
export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStoreOptions): JsonStore<unknown> {
|
||||||
|
const {
|
||||||
|
filePath,
|
||||||
|
defaultValue,
|
||||||
|
initializeMissing = false,
|
||||||
|
backupWhen = () => false,
|
||||||
|
now = () => new Date(),
|
||||||
|
} = options;
|
||||||
|
const migrate = options.migrate || ((value: unknown) => value);
|
||||||
|
let recovery: RecoveryState | null = null;
|
||||||
|
let migration: MigrationState | null = null;
|
||||||
|
|
||||||
|
function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown {
|
||||||
|
const migrated = migrate(clone(value));
|
||||||
|
atomicWriteJson(filePath, migrated, writeOptions);
|
||||||
|
return clone(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(): unknown {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
const initial = migrate(clone(defaultValue));
|
||||||
|
return initializeMissing ? write(initial) : clone(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (cause) {
|
||||||
|
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
|
||||||
|
fs.renameSync(filePath, backupPath);
|
||||||
|
try {
|
||||||
|
const recovered = write(defaultValue);
|
||||||
|
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
|
||||||
|
return recovered;
|
||||||
|
} catch (error) {
|
||||||
|
fs.renameSync(backupPath, filePath);
|
||||||
|
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrated = migrate(clone(parsed));
|
||||||
|
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
|
||||||
|
if (backupWhen(parsed, migrated)) {
|
||||||
|
const parsedRecord = record(parsed);
|
||||||
|
const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0;
|
||||||
|
const migratedRecord = record(migrated);
|
||||||
|
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
|
||||||
|
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
|
||||||
|
migration = {
|
||||||
|
fromVersion,
|
||||||
|
toVersion: migratedRecord.schemaVersion,
|
||||||
|
backupPath,
|
||||||
|
migratedAt: now().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
atomicWriteJson(filePath, migrated);
|
||||||
|
}
|
||||||
|
return clone(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(mutator: (value: unknown) => unknown): unknown {
|
||||||
|
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
|
||||||
|
const next = mutator(read());
|
||||||
|
if (next && typeof next === 'object' && 'then' in next) {
|
||||||
|
throw new TypeError('State store mutator must be synchronous');
|
||||||
|
}
|
||||||
|
return write(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
update,
|
||||||
|
remove: () => fs.rmSync(filePath, { force: true }),
|
||||||
|
get recovery() { return recovery; },
|
||||||
|
get migration() { return migration; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateStore(
|
||||||
|
filePath: string,
|
||||||
|
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
|
||||||
|
) {
|
||||||
|
return createJsonStore<StoredState & { schemaVersion: number }>({
|
||||||
|
filePath,
|
||||||
|
defaultValue: migrateStoredState({}),
|
||||||
|
migrate: migrateStoredState,
|
||||||
|
initializeMissing: true,
|
||||||
|
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
function proxyHostFromHeader(hostHeader) {
|
function proxyHostFromHeader(hostHeader: unknown) {
|
||||||
const raw = String(hostHeader || "").trim();
|
const raw = String(hostHeader || "").trim();
|
||||||
if (!raw) return "";
|
if (!raw) return "";
|
||||||
if (raw.startsWith("[")) {
|
if (raw.startsWith("[")) {
|
||||||
@@ -14,9 +14,15 @@ export function buildSharedProxyInfo({
|
|||||||
running,
|
running,
|
||||||
hostHeader,
|
hostHeader,
|
||||||
sharedProxyHost,
|
sharedProxyHost,
|
||||||
|
}: {
|
||||||
|
appMode: unknown;
|
||||||
|
proxyPort: unknown;
|
||||||
|
running: unknown;
|
||||||
|
hostHeader: unknown;
|
||||||
|
sharedProxyHost: unknown;
|
||||||
}) {
|
}) {
|
||||||
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||||
const port = Number.parseInt(proxyPort, 10);
|
const port = Number.parseInt(String(proxyPort), 10);
|
||||||
const available =
|
const available =
|
||||||
appMode === "gateway" &&
|
appMode === "gateway" &&
|
||||||
Boolean(running) &&
|
Boolean(running) &&
|
||||||
@@ -11,20 +11,33 @@ const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
|
|||||||
const SNIFF_TIMEOUT = '1s';
|
const SNIFF_TIMEOUT = '1s';
|
||||||
const SNIFFERS = ['http', 'tls', 'quic'];
|
const SNIFFERS = ['http', 'tls', 'quic'];
|
||||||
|
|
||||||
function findOutbound(subscriptionConfig, selectedTag) {
|
interface ProxyOutbound extends Record<string, unknown> {
|
||||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
tag?: string;
|
||||||
? subscriptionConfig.outbounds
|
type?: string;
|
||||||
|
packet_encoding?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOutbound(subscriptionConfig: unknown, selectedTag: unknown): ProxyOutbound | undefined {
|
||||||
|
const config = record(subscriptionConfig);
|
||||||
|
const outbounds = Array.isArray(config.outbounds)
|
||||||
|
? config.outbounds.map(record)
|
||||||
: [];
|
: [];
|
||||||
const tag = String(selectedTag || '').trim();
|
const tag = String(selectedTag || '').trim();
|
||||||
return outbounds.find((outbound) => (
|
return outbounds.find((outbound) => (
|
||||||
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
|
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(String(outbound.type || ''))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unknown, {
|
||||||
clientDirect = false,
|
clientDirect = false,
|
||||||
routeRules = [],
|
routeRules = [],
|
||||||
} = {}) {
|
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
||||||
const clientMode = settings.appMode === 'client';
|
const clientMode = settings.appMode === 'client';
|
||||||
const directClient = clientMode && clientDirect;
|
const directClient = clientMode && clientDirect;
|
||||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||||
@@ -109,11 +122,11 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeSingboxConfig(config) {
|
export function writeSingboxConfig(config: unknown) {
|
||||||
atomicWriteJson(settings.configPath, config);
|
atomicWriteJson(settings.configPath, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreSingboxConfig(contents) {
|
export function restoreSingboxConfig(contents: string) {
|
||||||
atomicWriteFile(settings.configPath, contents);
|
atomicWriteFile(settings.configPath, contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { spawn, spawnSync } 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';
|
||||||
|
|
||||||
export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) {
|
export function createSingboxRuntime({
|
||||||
let child = null;
|
configPath,
|
||||||
|
gateway = false,
|
||||||
|
tproxyChain = '',
|
||||||
|
}: {
|
||||||
|
configPath: string;
|
||||||
|
gateway?: boolean;
|
||||||
|
tproxyChain?: string;
|
||||||
|
}) {
|
||||||
|
let child: ChildProcess | null = null;
|
||||||
let configHash = '';
|
let configHash = '';
|
||||||
let startedAt = null;
|
let startedAt: string | null = null;
|
||||||
|
|
||||||
const state = () => ({ running: Boolean(child), startedAt });
|
const state = () => ({ running: Boolean(child), startedAt });
|
||||||
|
|
||||||
@@ -23,7 +31,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
|||||||
child = null;
|
child = null;
|
||||||
configHash = '';
|
configHash = '';
|
||||||
startedAt = null;
|
startedAt = null;
|
||||||
await new Promise((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
current.kill('SIGKILL');
|
current.kill('SIGKILL');
|
||||||
resolve();
|
resolve();
|
||||||
@@ -54,12 +62,12 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain
|
|||||||
if (!force && child && nextHash === configHash) return state();
|
if (!force && child && nextHash === configHash) return state();
|
||||||
|
|
||||||
await stop();
|
await stop();
|
||||||
let current;
|
let current: ChildProcess;
|
||||||
try {
|
try {
|
||||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
current = spawn('sing-box', ['run', '-c', configPath], {
|
||||||
stdio: ['ignore', 'inherit', 'inherit'],
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
});
|
});
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
current.once('spawn', resolve);
|
current.once('spawn', resolve);
|
||||||
current.once('error', reject);
|
current.once('error', reject);
|
||||||
});
|
});
|
||||||
@@ -6,24 +6,49 @@ import {
|
|||||||
createServerId,
|
createServerId,
|
||||||
normalizeServer,
|
normalizeServer,
|
||||||
serverIdentityKey,
|
serverIdentityKey,
|
||||||
|
type NormalizedServer,
|
||||||
} from '../shared/serverIdentity.js';
|
} from '../shared/serverIdentity.js';
|
||||||
|
import type { HarborServer } from '../shared/contracts/state.js';
|
||||||
import { atomicWriteFile } from './services/stateStore.js';
|
import { atomicWriteFile } from './services/stateStore.js';
|
||||||
|
|
||||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
|
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
|
||||||
|
|
||||||
function usableProxyOutbound(outbound) {
|
interface SubscriptionOutbound extends Record<string, unknown> {
|
||||||
const host = String(outbound?.server || '').trim().toLowerCase();
|
type?: unknown;
|
||||||
const port = Number(outbound?.server_port);
|
tag?: unknown;
|
||||||
|
server?: unknown;
|
||||||
|
server_port?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchSubscriptionOptions {
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function outboundRecord(value: unknown): SubscriptionOutbound {
|
||||||
|
return record(value) as SubscriptionOutbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usableProxyOutbound(value: unknown) {
|
||||||
|
const outbound = outboundRecord(value);
|
||||||
|
const host = String(outbound.server || '').trim().toLowerCase();
|
||||||
|
const port = Number(outbound.server_port);
|
||||||
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
|
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectedSubscriptionCode(outbounds) {
|
function rejectedSubscriptionCode(outbounds: unknown[]) {
|
||||||
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
|
const labels = outbounds.map((value) => String(outboundRecord(value).tag || '').toLowerCase()).join(' ');
|
||||||
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
|
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
|
||||||
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
|
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
|
||||||
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
|
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
|
||||||
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
|
return outbounds.some((value) => UNSPECIFIED_HOSTS.has(String(outboundRecord(value).server || '').trim().toLowerCase()))
|
||||||
? 'SUBSCRIPTION_REJECTED'
|
? 'SUBSCRIPTION_REJECTED'
|
||||||
: 'SUBSCRIPTION_INVALID';
|
: 'SUBSCRIPTION_INVALID';
|
||||||
}
|
}
|
||||||
@@ -48,8 +73,8 @@ export function subscriptionHeaders() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUserInfo(headerValue) {
|
export function parseUserInfo(headerValue: unknown): Record<string, number> {
|
||||||
const result = {};
|
const result: Record<string, number> = {};
|
||||||
if (!headerValue) return result;
|
if (!headerValue) return result;
|
||||||
|
|
||||||
for (const part of String(headerValue).split(';')) {
|
for (const part of String(headerValue).split(';')) {
|
||||||
@@ -62,7 +87,7 @@ export function parseUserInfo(headerValue) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseVlessUrl(rawUrl) {
|
export function parseVlessUrl(rawUrl: string) {
|
||||||
if (!rawUrl.startsWith('vless://')) {
|
if (!rawUrl.startsWith('vless://')) {
|
||||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||||
}
|
}
|
||||||
@@ -115,7 +140,7 @@ export function parseVlessUrl(rawUrl) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeDecodeBase64(content) {
|
function maybeDecodeBase64(content: string) {
|
||||||
const compact = content.trim().replace(/\s+/g, '');
|
const compact = content.trim().replace(/\s+/g, '');
|
||||||
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
||||||
|
|
||||||
@@ -127,18 +152,19 @@ function maybeDecodeBase64(content) {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSubscriptionConfig(value) {
|
export function normalizeSubscriptionConfig(value: unknown) {
|
||||||
const parsedConfig = value && typeof value === 'object' ? value : {};
|
const parsedConfig = record(value);
|
||||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||||
const servers = [];
|
const servers: NormalizedServer[] = [];
|
||||||
const rejectedOutbounds = [];
|
const rejectedOutbounds: unknown[] = [];
|
||||||
const seen = new Set();
|
const seen = new Set<string>();
|
||||||
const normalizedOutbounds = outbounds.flatMap((outbound) => {
|
const normalizedOutbounds = outbounds.flatMap((value): Record<string, unknown>[] => {
|
||||||
if (!outbound || typeof outbound !== 'object') {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
rejectedOutbounds.push(outbound);
|
rejectedOutbounds.push(value);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
|
const outbound = outboundRecord(value);
|
||||||
|
if (!PROXY_TYPES.has(String(outbound.type || ''))) return [outbound];
|
||||||
if (!usableProxyOutbound(outbound)) {
|
if (!usableProxyOutbound(outbound)) {
|
||||||
rejectedOutbounds.push(outbound);
|
rejectedOutbounds.push(outbound);
|
||||||
return [];
|
return [];
|
||||||
@@ -155,8 +181,8 @@ export function normalizeSubscriptionConfig(value) {
|
|||||||
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
|
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSubscriptionBody(body) {
|
export function parseSubscriptionBody(body: string) {
|
||||||
let parsedConfig;
|
let parsedConfig: unknown;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
parsedConfig = JSON.parse(body);
|
parsedConfig = JSON.parse(body);
|
||||||
@@ -179,8 +205,11 @@ export function parseSubscriptionBody(body) {
|
|||||||
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
|
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
|
async function requestSubscription(
|
||||||
let parsedUrl;
|
url: string,
|
||||||
|
{ fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs }: FetchSubscriptionOptions = {},
|
||||||
|
) {
|
||||||
|
let parsedUrl: URL;
|
||||||
try {
|
try {
|
||||||
parsedUrl = new URL(url);
|
parsedUrl = new URL(url);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -191,7 +220,7 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
|
|||||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetchImpl(parsedUrl, {
|
response = await fetchImpl(parsedUrl, {
|
||||||
headers: subscriptionHeaders(),
|
headers: subscriptionHeaders(),
|
||||||
@@ -209,7 +238,11 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
|
export function selectRefreshedServer(
|
||||||
|
currentServerId: string,
|
||||||
|
currentServers: readonly HarborServer[],
|
||||||
|
nextServers: readonly HarborServer[],
|
||||||
|
) {
|
||||||
if (!currentServerId) return '';
|
if (!currentServerId) return '';
|
||||||
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
||||||
const previous = currentServers.find((server) => server.id === currentServerId);
|
const previous = currentServers.find((server) => server.id === currentServerId);
|
||||||
@@ -219,7 +252,7 @@ export function selectRefreshedServer(currentServerId, currentServers, nextServe
|
|||||||
return matches.length === 1 ? matches[0].id : '';
|
return matches.length === 1 ? matches[0].id : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscription(url, options) {
|
export async function fetchSubscription(url: string, options: FetchSubscriptionOptions = {}) {
|
||||||
const response = await requestSubscription(url, options);
|
const response = await requestSubscription(url, options);
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { HARBOR_VERSIONS } from '../shared/versions.js';
|
import { HARBOR_VERSIONS } from '../shared/versions.js';
|
||||||
|
|
||||||
export function detectSingBoxVersion(run = spawnSync) {
|
export function detectSingBoxVersion(run: typeof spawnSync = spawnSync) {
|
||||||
const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 });
|
const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 });
|
||||||
const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`);
|
const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`);
|
||||||
return match?.[1] || null;
|
return match?.[1] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildVersionInfo(appMode, run = spawnSync) {
|
export function buildVersionInfo(appMode: string, run: typeof spawnSync = spawnSync) {
|
||||||
const client = appMode === 'client';
|
const client = appMode === 'client';
|
||||||
return {
|
return {
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
@@ -19,7 +19,10 @@ export function buildVersionInfo(appMode, run = spawnSync) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayVersionInfo(controlInfo, dataplaneState) {
|
export function buildGatewayVersionInfo(
|
||||||
|
controlInfo: Record<string, unknown>,
|
||||||
|
dataplaneState: { gatewayBackendVersion?: unknown; singBoxVersion?: unknown } | null | undefined,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
...controlInfo,
|
...controlInfo,
|
||||||
runtime: {
|
runtime: {
|
||||||
@@ -19,7 +19,24 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
|||||||
|
|
||||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||||
|
|
||||||
export function assessConnectivity(direct, vpn) {
|
export interface ConnectivitySiteResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
httpStatus?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectivityPathResult {
|
||||||
|
available?: boolean;
|
||||||
|
internetAvailable: boolean;
|
||||||
|
ipv4: { addresses: string[]; [key: string]: unknown };
|
||||||
|
ipv6: string | null;
|
||||||
|
sites: ConnectivitySiteResult[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessConnectivity(direct: ConnectivityPathResult, vpn: ConnectivityPathResult) {
|
||||||
const comparisons = direct.sites.map(({ id, label }) => {
|
const comparisons = direct.sites.map(({ id, label }) => {
|
||||||
const directSite = direct.sites.find((site) => site.id === id);
|
const directSite = direct.sites.find((site) => site.id === id);
|
||||||
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
||||||
@@ -29,7 +46,7 @@ export function assessConnectivity(direct, vpn) {
|
|||||||
assessment = 'available';
|
assessment = 'available';
|
||||||
} else if (
|
} else if (
|
||||||
directSite?.status === 'responded'
|
directSite?.status === 'responded'
|
||||||
&& [403, 451].includes(directSite.httpStatus)
|
&& [403, 451].includes(Number(directSite.httpStatus))
|
||||||
&& vpnSite?.status === 'available'
|
&& vpnSite?.status === 'available'
|
||||||
) assessment = 'likely-direct-restriction';
|
) assessment = 'likely-direct-restriction';
|
||||||
else if (
|
else if (
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { normalizeRouteRules } from '../routingRules.js';
|
|
||||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
|
||||||
|
|
||||||
const MODES = new Set(['client', 'gateway']);
|
|
||||||
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
|
||||||
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
|
|
||||||
|
|
||||||
const text = (value) => String(value || '').trim();
|
|
||||||
const nullableText = (value) => value == null ? null : String(value);
|
|
||||||
const dateOrNull = (value) => (
|
|
||||||
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
|
||||||
);
|
|
||||||
|
|
||||||
export function normalizeStoredState(value) {
|
|
||||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
||||||
const servers = normalizeServers(state.servers);
|
|
||||||
const selectedServerId = resolveServerId(servers, state.selectedServerId, state.selectedTag);
|
|
||||||
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
|
|
||||||
? resolveServerId(servers, state.appliedServerId)
|
|
||||||
: resolveServerId(servers, '', state.appliedTag || state.selectedTag);
|
|
||||||
const selectedServer = servers.find((server) => server.id === selectedServerId);
|
|
||||||
const appliedServer = servers.find((server) => server.id === appliedServerId);
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
|
|
||||||
selectedServerId,
|
|
||||||
appliedServerId,
|
|
||||||
selectedTag: selectedServer?.label || '',
|
|
||||||
appliedTag: appliedServer?.label || '',
|
|
||||||
servers,
|
|
||||||
routeRules: normalizeRouteRules(state.routeRules),
|
|
||||||
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
|
|
||||||
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
|
||||||
? state.routeRulesRevision
|
|
||||||
: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStateSnapshot({
|
|
||||||
storedState,
|
|
||||||
runtime,
|
|
||||||
gatewayAuto,
|
|
||||||
appMode,
|
|
||||||
configExists,
|
|
||||||
subscriptionHost,
|
|
||||||
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
|
||||||
now = new Date(),
|
|
||||||
}) {
|
|
||||||
const stored = normalizeStoredState(storedState);
|
|
||||||
const mode = MODES.has(appMode) ? appMode : 'gateway';
|
|
||||||
const hasSubscription = Boolean(stored.subscriptionUrl);
|
|
||||||
const desired = CONNECTION_STATES.has(stored.connectionDesired)
|
|
||||||
? stored.connectionDesired
|
|
||||||
: configExists ? 'running' : 'stopped';
|
|
||||||
const servers = stored.servers;
|
|
||||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
|
||||||
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
|
|
||||||
const routeReason = mode !== 'client'
|
|
||||||
? 'gateway-host'
|
|
||||||
: !gatewayAutoEnabled
|
|
||||||
? 'disabled'
|
|
||||||
: routeMode === 'gateway-direct'
|
|
||||||
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
|
||||||
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
|
||||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
|
||||||
|
|
||||||
return assertStateSnapshot({
|
|
||||||
apiVersion: 1,
|
|
||||||
revision: stored.revision,
|
|
||||||
generatedAt: now.toISOString(),
|
|
||||||
mode,
|
|
||||||
subscription: {
|
|
||||||
status: hasSubscription ? 'ready' : 'missing',
|
|
||||||
host: hasSubscription ? subscriptionHost : '',
|
|
||||||
fetchedAt: dateOrNull(stored.fetchedAt),
|
|
||||||
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
|
||||||
},
|
|
||||||
selection: {
|
|
||||||
desiredServerId: stored.selectedServerId,
|
|
||||||
appliedServerId: stored.appliedServerId,
|
|
||||||
},
|
|
||||||
connection: {
|
|
||||||
desired,
|
|
||||||
process: runtime?.running ? 'running' : 'stopped',
|
|
||||||
startedAt: dateOrNull(runtime?.startedAt),
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
route: {
|
|
||||||
mode: routeMode,
|
|
||||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
|
||||||
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
|
||||||
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
|
|
||||||
autoEnabled: mode === 'client' && gatewayAutoEnabled,
|
|
||||||
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
|
|
||||||
reason: routeReason,
|
|
||||||
localRules: stored.routeRules,
|
|
||||||
activeLocalRules,
|
|
||||||
localRulesRevision: stored.routeRulesRevision,
|
|
||||||
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
|
||||||
},
|
|
||||||
operation: {
|
|
||||||
kind: nullableText(operation.kind),
|
|
||||||
status: operation.status,
|
|
||||||
startedAt: nullableText(operation.startedAt),
|
|
||||||
error: nullableText(operation.error),
|
|
||||||
},
|
|
||||||
servers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withStateV0Compatibility(snapshot, {
|
|
||||||
storedState,
|
|
||||||
gatewayAuto,
|
|
||||||
port,
|
|
||||||
proxyPort,
|
|
||||||
configExists,
|
|
||||||
}) {
|
|
||||||
const stored = normalizeStoredState(storedState);
|
|
||||||
return {
|
|
||||||
...snapshot,
|
|
||||||
port,
|
|
||||||
proxyPort,
|
|
||||||
configExists,
|
|
||||||
singboxRunning: snapshot.connection.process === 'running',
|
|
||||||
singboxStartedAt: snapshot.connection.startedAt,
|
|
||||||
subscriptionHost: snapshot.subscription.host,
|
|
||||||
hasSubscription: snapshot.subscription.status === 'ready',
|
|
||||||
selectedTag: stored.selectedTag,
|
|
||||||
userInfo: snapshot.subscription.userInfo,
|
|
||||||
fetchedAt: snapshot.subscription.fetchedAt,
|
|
||||||
gatewayAuto: snapshot.mode === 'client' ? {
|
|
||||||
mode: gatewayAuto?.mode || 'local-vpn',
|
|
||||||
enabled: stored.gatewayAutoEnabled !== false,
|
|
||||||
available: Boolean(gatewayAuto?.gatewayId),
|
|
||||||
address: gatewayAuto?.gateway?.gateway || '',
|
|
||||||
uiOrigin: gatewayAuto?.uiOrigin || '',
|
|
||||||
interface: gatewayAuto?.gateway?.interface || '',
|
|
||||||
failures: Number(gatewayAuto?.failures) || 0,
|
|
||||||
lastError: gatewayAuto?.lastError || '',
|
|
||||||
} : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertStateSnapshot(snapshot) {
|
|
||||||
const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
||||||
const nullableDate = (value) => value === null || validDate(value);
|
|
||||||
const nullableString = (value) => value === null || typeof value === 'string';
|
|
||||||
const validServer = (server) => (
|
|
||||||
server &&
|
|
||||||
typeof server.id === 'string' &&
|
|
||||||
typeof server.label === 'string' &&
|
|
||||||
typeof server.host === 'string' &&
|
|
||||||
Number.isInteger(server.port) &&
|
|
||||||
server.port >= 0 &&
|
|
||||||
typeof server.protocol === 'string'
|
|
||||||
);
|
|
||||||
const validRouteRule = (rule) => (
|
|
||||||
rule &&
|
|
||||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
|
||||||
typeof rule.value === 'string' &&
|
|
||||||
Boolean(rule.value) &&
|
|
||||||
typeof rule.enabled === 'boolean'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!snapshot ||
|
|
||||||
snapshot.apiVersion !== 1 ||
|
|
||||||
!Number.isSafeInteger(snapshot.revision) ||
|
|
||||||
snapshot.revision < 0 ||
|
|
||||||
!validDate(snapshot.generatedAt) ||
|
|
||||||
!MODES.has(snapshot.mode) ||
|
|
||||||
!snapshot.subscription ||
|
|
||||||
!['missing', 'ready'].includes(snapshot.subscription.status) ||
|
|
||||||
typeof snapshot.subscription.host !== 'string' ||
|
|
||||||
Object.hasOwn(snapshot.subscription, 'url') ||
|
|
||||||
!nullableDate(snapshot.subscription.fetchedAt) ||
|
|
||||||
!snapshot.subscription.userInfo ||
|
|
||||||
typeof snapshot.subscription.userInfo !== 'object' ||
|
|
||||||
!snapshot.selection ||
|
|
||||||
typeof snapshot.selection.desiredServerId !== 'string' ||
|
|
||||||
typeof snapshot.selection.appliedServerId !== 'string' ||
|
|
||||||
!snapshot.connection ||
|
|
||||||
!CONNECTION_STATES.has(snapshot.connection.desired) ||
|
|
||||||
!CONNECTION_STATES.has(snapshot.connection.process) ||
|
|
||||||
!nullableDate(snapshot.connection.startedAt) ||
|
|
||||||
!nullableString(snapshot.connection.lastError) ||
|
|
||||||
!snapshot.route ||
|
|
||||||
typeof snapshot.route.mode !== 'string' ||
|
|
||||||
!nullableString(snapshot.route.gatewayAddress) ||
|
|
||||||
!nullableString(snapshot.route.gatewayUiOrigin) ||
|
|
||||||
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
|
||||||
typeof snapshot.route.autoEnabled !== 'boolean' ||
|
|
||||||
typeof snapshot.route.fallbackPreference !== 'string' ||
|
|
||||||
typeof snapshot.route.reason !== 'string' ||
|
|
||||||
!Array.isArray(snapshot.route.localRules) ||
|
|
||||||
!snapshot.route.localRules.every(validRouteRule) ||
|
|
||||||
!Array.isArray(snapshot.route.activeLocalRules) ||
|
|
||||||
!snapshot.route.activeLocalRules.every(validRouteRule) ||
|
|
||||||
!Number.isSafeInteger(snapshot.route.localRulesRevision) ||
|
|
||||||
snapshot.route.localRulesRevision < 0 ||
|
|
||||||
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
|
||||||
!snapshot.operation ||
|
|
||||||
!nullableString(snapshot.operation.kind) ||
|
|
||||||
!OPERATION_STATES.has(snapshot.operation.status) ||
|
|
||||||
!nullableDate(snapshot.operation.startedAt) ||
|
|
||||||
!nullableString(snapshot.operation.error) ||
|
|
||||||
!Array.isArray(snapshot.servers) ||
|
|
||||||
!snapshot.servers.every(validServer)
|
|
||||||
) {
|
|
||||||
throw new TypeError('Invalid Harbor state snapshot v1');
|
|
||||||
}
|
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameRules(left, right) {
|
|
||||||
return JSON.stringify(left) === JSON.stringify(right);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { normalizeRouteRules } from '../routingRules.js';
|
||||||
|
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||||
|
|
||||||
|
export type HarborMode = 'client' | 'gateway';
|
||||||
|
export type ConnectionState = 'running' | 'stopped';
|
||||||
|
export type OperationStatus = 'idle' | 'running' | 'failed';
|
||||||
|
|
||||||
|
export interface HarborServer {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRule {
|
||||||
|
type: 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||||
|
value: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateSnapshot {
|
||||||
|
apiVersion: 1;
|
||||||
|
revision: number;
|
||||||
|
generatedAt: string;
|
||||||
|
mode: HarborMode;
|
||||||
|
subscription: {
|
||||||
|
status: 'missing' | 'ready';
|
||||||
|
host: string;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
selection: { desiredServerId: string; appliedServerId: string };
|
||||||
|
connection: {
|
||||||
|
desired: ConnectionState;
|
||||||
|
process: ConnectionState;
|
||||||
|
startedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
};
|
||||||
|
route: {
|
||||||
|
mode: string;
|
||||||
|
gatewayAddress: string | null;
|
||||||
|
gatewayUiOrigin: string | null;
|
||||||
|
lastVerifiedAt: string | null;
|
||||||
|
autoEnabled: boolean;
|
||||||
|
fallbackPreference: string;
|
||||||
|
reason: string;
|
||||||
|
localRules: RouteRule[];
|
||||||
|
activeLocalRules: RouteRule[];
|
||||||
|
localRulesRevision: number;
|
||||||
|
localRulesPendingRestart: boolean;
|
||||||
|
};
|
||||||
|
operation: {
|
||||||
|
kind: string | null;
|
||||||
|
status: OperationStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
servers: HarborServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredState extends Record<string, unknown> {
|
||||||
|
revision: number;
|
||||||
|
selectedServerId: string;
|
||||||
|
appliedServerId: string;
|
||||||
|
selectedTag: string;
|
||||||
|
appliedTag: string;
|
||||||
|
servers: HarborServer[];
|
||||||
|
routeRules: RouteRule[];
|
||||||
|
appliedRouteRules: RouteRule[];
|
||||||
|
routeRulesRevision: number;
|
||||||
|
subscriptionUrl?: string;
|
||||||
|
connectionDesired?: ConnectionState;
|
||||||
|
gatewayAutoEnabled?: boolean;
|
||||||
|
userInfo?: Record<string, unknown>;
|
||||||
|
fetchedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeState {
|
||||||
|
running?: boolean;
|
||||||
|
startedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAutoState {
|
||||||
|
mode?: string;
|
||||||
|
gatewayId?: string;
|
||||||
|
gateway?: { gateway?: string; interface?: string } | null;
|
||||||
|
uiOrigin?: string;
|
||||||
|
failures?: number;
|
||||||
|
lastError?: string;
|
||||||
|
lastVerifiedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationState {
|
||||||
|
kind: string | null;
|
||||||
|
status: OperationStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODES = new Set<HarborMode>(['client', 'gateway']);
|
||||||
|
const CONNECTION_STATES = new Set<ConnectionState>(['running', 'stopped']);
|
||||||
|
const OPERATION_STATES = new Set<OperationStatus>(['idle', 'running', 'failed']);
|
||||||
|
|
||||||
|
const nullableText = (value: unknown) => value == null ? null : String(value);
|
||||||
|
const identityText = (value: unknown) => String(value || '').trim();
|
||||||
|
const dateOrNull = (value: unknown) => (
|
||||||
|
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
||||||
|
);
|
||||||
|
|
||||||
|
export function normalizeStoredState(value: unknown): StoredState {
|
||||||
|
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const servers = normalizeServers(state.servers) as HarborServer[];
|
||||||
|
const selectedServerId = resolveServerId(
|
||||||
|
servers,
|
||||||
|
identityText(state.selectedServerId),
|
||||||
|
identityText(state.selectedTag),
|
||||||
|
);
|
||||||
|
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
|
||||||
|
? resolveServerId(servers, identityText(state.appliedServerId))
|
||||||
|
: resolveServerId(servers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
|
||||||
|
const selectedServer = servers.find((server: HarborServer) => server.id === selectedServerId);
|
||||||
|
const appliedServer = servers.find((server: HarborServer) => server.id === appliedServerId);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
|
||||||
|
? state.revision
|
||||||
|
: 0,
|
||||||
|
selectedServerId,
|
||||||
|
appliedServerId,
|
||||||
|
selectedTag: selectedServer?.label || '',
|
||||||
|
appliedTag: appliedServer?.label || '',
|
||||||
|
servers,
|
||||||
|
routeRules: normalizeRouteRules(state.routeRules) as RouteRule[],
|
||||||
|
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[],
|
||||||
|
routeRulesRevision: typeof state.routeRulesRevision === 'number'
|
||||||
|
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||||
|
? state.routeRulesRevision
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateSnapshot({
|
||||||
|
storedState,
|
||||||
|
runtime,
|
||||||
|
gatewayAuto,
|
||||||
|
appMode,
|
||||||
|
configExists,
|
||||||
|
subscriptionHost,
|
||||||
|
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
||||||
|
now = new Date(),
|
||||||
|
}: {
|
||||||
|
storedState: unknown;
|
||||||
|
runtime?: RuntimeState | null;
|
||||||
|
gatewayAuto?: GatewayAutoState | null;
|
||||||
|
appMode?: string;
|
||||||
|
configExists: boolean;
|
||||||
|
subscriptionHost: string;
|
||||||
|
operation?: OperationState;
|
||||||
|
now?: Date;
|
||||||
|
}): StateSnapshot {
|
||||||
|
const stored = normalizeStoredState(storedState);
|
||||||
|
const mode: HarborMode = appMode === 'client' || appMode === 'gateway' ? appMode : 'gateway';
|
||||||
|
const hasSubscription = Boolean(stored.subscriptionUrl);
|
||||||
|
const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
|
||||||
|
? stored.connectionDesired
|
||||||
|
: configExists ? 'running' : 'stopped';
|
||||||
|
const servers = stored.servers;
|
||||||
|
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||||
|
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
|
||||||
|
const routeReason = mode !== 'client'
|
||||||
|
? 'gateway-host'
|
||||||
|
: !gatewayAutoEnabled
|
||||||
|
? 'disabled'
|
||||||
|
: routeMode === 'gateway-direct'
|
||||||
|
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
||||||
|
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
||||||
|
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||||
|
|
||||||
|
return assertStateSnapshot({
|
||||||
|
apiVersion: 1,
|
||||||
|
revision: stored.revision,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
mode,
|
||||||
|
subscription: {
|
||||||
|
status: hasSubscription ? 'ready' : 'missing',
|
||||||
|
host: hasSubscription ? subscriptionHost : '',
|
||||||
|
fetchedAt: dateOrNull(stored.fetchedAt),
|
||||||
|
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
desiredServerId: stored.selectedServerId,
|
||||||
|
appliedServerId: stored.appliedServerId,
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
desired,
|
||||||
|
process: runtime?.running ? 'running' : 'stopped',
|
||||||
|
startedAt: dateOrNull(runtime?.startedAt),
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
mode: routeMode,
|
||||||
|
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||||
|
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
||||||
|
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
|
||||||
|
autoEnabled: mode === 'client' && gatewayAutoEnabled,
|
||||||
|
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
|
||||||
|
reason: routeReason,
|
||||||
|
localRules: stored.routeRules,
|
||||||
|
activeLocalRules,
|
||||||
|
localRulesRevision: stored.routeRulesRevision,
|
||||||
|
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
kind: nullableText(operation.kind),
|
||||||
|
status: operation.status,
|
||||||
|
startedAt: nullableText(operation.startedAt),
|
||||||
|
error: nullableText(operation.error),
|
||||||
|
},
|
||||||
|
servers: servers as HarborServer[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||||
|
const candidate = snapshot as StateSnapshot;
|
||||||
|
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||||
|
const nullableDate = (value: unknown) => value === null || validDate(value);
|
||||||
|
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||||
|
const validServer = (server: HarborServer) => (
|
||||||
|
server &&
|
||||||
|
typeof server.id === 'string' &&
|
||||||
|
typeof server.label === 'string' &&
|
||||||
|
typeof server.host === 'string' &&
|
||||||
|
Number.isInteger(server.port) &&
|
||||||
|
server.port >= 0 &&
|
||||||
|
typeof server.protocol === 'string'
|
||||||
|
);
|
||||||
|
const validRouteRule = (rule: RouteRule) => (
|
||||||
|
rule &&
|
||||||
|
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||||
|
typeof rule.value === 'string' &&
|
||||||
|
Boolean(rule.value) &&
|
||||||
|
typeof rule.enabled === 'boolean'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!snapshot ||
|
||||||
|
candidate.apiVersion !== 1 ||
|
||||||
|
!Number.isSafeInteger(candidate.revision) ||
|
||||||
|
candidate.revision < 0 ||
|
||||||
|
!validDate(candidate.generatedAt) ||
|
||||||
|
!MODES.has(candidate.mode) ||
|
||||||
|
!candidate.subscription ||
|
||||||
|
!['missing', 'ready'].includes(candidate.subscription.status) ||
|
||||||
|
typeof candidate.subscription.host !== 'string' ||
|
||||||
|
Object.hasOwn(candidate.subscription, 'url') ||
|
||||||
|
!nullableDate(candidate.subscription.fetchedAt) ||
|
||||||
|
!candidate.subscription.userInfo ||
|
||||||
|
typeof candidate.subscription.userInfo !== 'object' ||
|
||||||
|
!candidate.selection ||
|
||||||
|
typeof candidate.selection.desiredServerId !== 'string' ||
|
||||||
|
typeof candidate.selection.appliedServerId !== 'string' ||
|
||||||
|
!candidate.connection ||
|
||||||
|
!CONNECTION_STATES.has(candidate.connection.desired) ||
|
||||||
|
!CONNECTION_STATES.has(candidate.connection.process) ||
|
||||||
|
!nullableDate(candidate.connection.startedAt) ||
|
||||||
|
!nullableString(candidate.connection.lastError) ||
|
||||||
|
!candidate.route ||
|
||||||
|
typeof candidate.route.mode !== 'string' ||
|
||||||
|
!nullableString(candidate.route.gatewayAddress) ||
|
||||||
|
!nullableString(candidate.route.gatewayUiOrigin) ||
|
||||||
|
!nullableDate(candidate.route.lastVerifiedAt) ||
|
||||||
|
typeof candidate.route.autoEnabled !== 'boolean' ||
|
||||||
|
typeof candidate.route.fallbackPreference !== 'string' ||
|
||||||
|
typeof candidate.route.reason !== 'string' ||
|
||||||
|
!Array.isArray(candidate.route.localRules) ||
|
||||||
|
!candidate.route.localRules.every(validRouteRule) ||
|
||||||
|
!Array.isArray(candidate.route.activeLocalRules) ||
|
||||||
|
!candidate.route.activeLocalRules.every(validRouteRule) ||
|
||||||
|
!Number.isSafeInteger(candidate.route.localRulesRevision) ||
|
||||||
|
candidate.route.localRulesRevision < 0 ||
|
||||||
|
typeof candidate.route.localRulesPendingRestart !== 'boolean' ||
|
||||||
|
!candidate.operation ||
|
||||||
|
!nullableString(candidate.operation.kind) ||
|
||||||
|
!OPERATION_STATES.has(candidate.operation.status) ||
|
||||||
|
!nullableDate(candidate.operation.startedAt) ||
|
||||||
|
!nullableString(candidate.operation.error) ||
|
||||||
|
!Array.isArray(candidate.servers) ||
|
||||||
|
!candidate.servers.every(validServer)
|
||||||
|
) {
|
||||||
|
throw new TypeError('Invalid Harbor state snapshot v1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameRules(left: RouteRule[], right: RouteRule[]) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
interface ErrorDefinition {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const ERROR_DEFINITIONS = Object.freeze({
|
export const ERROR_DEFINITIONS = Object.freeze({
|
||||||
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
|
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
|
||||||
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
|
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
|
||||||
@@ -18,24 +24,33 @@ export const ERROR_DEFINITIONS = Object.freeze({
|
|||||||
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
||||||
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
||||||
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
|
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
|
||||||
});
|
} satisfies Record<string, ErrorDefinition>);
|
||||||
|
|
||||||
export function errorDefinition(code) {
|
export type HarborErrorCode = keyof typeof ERROR_DEFINITIONS;
|
||||||
return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN;
|
|
||||||
|
export function errorDefinition(code: unknown): ErrorDefinition {
|
||||||
|
return typeof code === 'string' && Object.hasOwn(ERROR_DEFINITIONS, code)
|
||||||
|
? ERROR_DEFINITIONS[code as HarborErrorCode]
|
||||||
|
: ERROR_DEFINITIONS.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HarborError extends Error {
|
export class HarborError extends Error {
|
||||||
constructor(code, { cause, details } = {}) {
|
code: HarborErrorCode;
|
||||||
|
status: number;
|
||||||
|
retryable: boolean;
|
||||||
|
details: unknown;
|
||||||
|
|
||||||
|
constructor(code: string, { cause, details }: { cause?: unknown; details?: unknown } = {}) {
|
||||||
const definition = errorDefinition(code);
|
const definition = errorDefinition(code);
|
||||||
super(definition.message, { cause });
|
super(definition.message, { cause });
|
||||||
this.name = 'HarborError';
|
this.name = 'HarborError';
|
||||||
this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN';
|
this.code = Object.hasOwn(ERROR_DEFINITIONS, code) ? code as HarborErrorCode : 'UNKNOWN';
|
||||||
this.status = definition.status;
|
this.status = definition.status;
|
||||||
this.retryable = definition.retryable;
|
this.retryable = definition.retryable;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHarborError(error) {
|
export function normalizeHarborError(error: unknown) {
|
||||||
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
|
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,21 @@ export const INITIAL_ROUTE_RULES = Object.freeze([
|
|||||||
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
||||||
export const MAX_ROUTE_RULES = 200;
|
export const MAX_ROUTE_RULES = 200;
|
||||||
|
|
||||||
function hostname(value) {
|
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||||
|
|
||||||
|
export interface NormalizedRouteRule {
|
||||||
|
type: RouteRuleType;
|
||||||
|
value: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostname(value: unknown) {
|
||||||
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
|
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
|
||||||
if (!input) throw new TypeError('Domain rule value is required');
|
if (!input) throw new TypeError('Domain rule value is required');
|
||||||
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`);
|
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`);
|
||||||
@@ -14,22 +28,26 @@ function hostname(value) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRule(rule) {
|
function normalizeRule(input: unknown): NormalizedRouteRule {
|
||||||
const type = String(rule?.type || '').trim();
|
const rule = record(input);
|
||||||
|
const type = String(rule.type || '').trim();
|
||||||
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
|
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
|
||||||
if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') {
|
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
|
||||||
throw new TypeError('Invalid domain rule enabled state');
|
throw new TypeError('Invalid domain rule enabled state');
|
||||||
}
|
}
|
||||||
const value = type === 'domain_keyword'
|
const value = type === 'domain_keyword'
|
||||||
? String(rule?.value || '').trim().toLowerCase()
|
? String(rule.value || '').trim().toLowerCase()
|
||||||
: hostname(rule?.value);
|
: hostname(rule.value);
|
||||||
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
||||||
throw new TypeError('Invalid domain rule value');
|
throw new TypeError('Invalid domain rule value');
|
||||||
}
|
}
|
||||||
return { type, value, enabled: rule?.enabled !== false };
|
return { type: type as RouteRuleType, value, enabled: rule.enabled !== false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRouteRules(value, { strict = false } = {}) {
|
export function normalizeRouteRules(
|
||||||
|
value: unknown,
|
||||||
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
|
): NormalizedRouteRule[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
if (strict) throw new TypeError('Route rules must be an array');
|
if (strict) throw new TypeError('Route rules must be an array');
|
||||||
return [];
|
return [];
|
||||||
@@ -38,8 +56,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
|
|||||||
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
|
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set();
|
const seen = new Set<string>();
|
||||||
const normalized = [];
|
const normalized: NormalizedRouteRule[] = [];
|
||||||
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
|
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
|
||||||
try {
|
try {
|
||||||
const rule = normalizeRule(candidate);
|
const rule = normalizeRule(candidate);
|
||||||
@@ -54,8 +72,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAppendRouteRule(rules) {
|
export function canAppendRouteRule(rules: unknown) {
|
||||||
return Array.isArray(rules) &&
|
return Array.isArray(rules) &&
|
||||||
rules.length < MAX_ROUTE_RULES &&
|
rules.length < MAX_ROUTE_RULES &&
|
||||||
rules.every((rule) => String(rule?.value || '').trim());
|
rules.every((rule) => String(record(rule).value || '').trim());
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,39 @@
|
|||||||
const text = (value) => String(value || '').trim();
|
export interface ServerIdentityInput extends Record<string, unknown> {
|
||||||
|
id?: unknown;
|
||||||
|
label?: unknown;
|
||||||
|
tag?: unknown;
|
||||||
|
host?: unknown;
|
||||||
|
server?: unknown;
|
||||||
|
port?: unknown;
|
||||||
|
server_port?: unknown;
|
||||||
|
protocol?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
country?: unknown;
|
||||||
|
city?: unknown;
|
||||||
|
provider?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
function hash64(value) {
|
export interface NormalizedServer extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
tag: string;
|
||||||
|
server: string;
|
||||||
|
server_port: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (value: unknown) => String(value || '').trim();
|
||||||
|
|
||||||
|
function record(value: unknown): ServerIdentityInput {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as ServerIdentityInput
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash64(value: string) {
|
||||||
let hash = 0xcbf29ce484222325n;
|
let hash = 0xcbf29ce484222325n;
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
hash ^= BigInt(value.charCodeAt(index));
|
hash ^= BigInt(value.charCodeAt(index));
|
||||||
@@ -9,19 +42,20 @@ function hash64(value) {
|
|||||||
return hash.toString(16).padStart(16, '0');
|
return hash.toString(16).padStart(16, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverIdentityKey(server) {
|
export function serverIdentityKey(value: unknown) {
|
||||||
const protocol = text(server?.protocol || server?.type).toLowerCase();
|
const server = record(value);
|
||||||
const host = text(server?.host || server?.server).toLowerCase();
|
const protocol = text(server.protocol || server.type).toLowerCase();
|
||||||
const port = Number(server?.port || server?.server_port) || 0;
|
const host = text(server.host || server.server).toLowerCase();
|
||||||
|
const port = Number(server.port || server.server_port) || 0;
|
||||||
return `${protocol}\u0000${host}\u0000${port}`;
|
return `${protocol}\u0000${host}\u0000${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerId(server) {
|
export function createServerId(server: unknown) {
|
||||||
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
|
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeServer(server) {
|
export function normalizeServer(server: unknown): NormalizedServer {
|
||||||
const source = server && typeof server === 'object' ? server : {};
|
const source = record(server);
|
||||||
const protocol = text(source.protocol || source.type).toLowerCase();
|
const protocol = text(source.protocol || source.type).toLowerCase();
|
||||||
const host = text(source.host || source.server);
|
const host = text(source.host || source.server);
|
||||||
const port = Number(source.port || source.server_port) || 0;
|
const port = Number(source.port || source.server_port) || 0;
|
||||||
@@ -48,8 +82,8 @@ export function normalizeServer(server) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeServers(servers) {
|
export function normalizeServers(servers: unknown): NormalizedServer[] {
|
||||||
const seen = new Set();
|
const seen = new Set<string>();
|
||||||
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
|
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
|
||||||
const normalized = normalizeServer(server);
|
const normalized = normalizeServer(server);
|
||||||
if (!normalized.id || seen.has(normalized.id)) return [];
|
if (!normalized.id || seen.has(normalized.id)) return [];
|
||||||
@@ -58,7 +92,11 @@ export function normalizeServers(servers) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveServerId(servers, serverId, legacyTag = '') {
|
export function resolveServerId(
|
||||||
|
servers: readonly Pick<NormalizedServer, 'id' | 'label'>[],
|
||||||
|
serverId: unknown,
|
||||||
|
legacyTag: unknown = '',
|
||||||
|
) {
|
||||||
const id = text(serverId);
|
const id = text(serverId);
|
||||||
if (id) return servers.some((server) => server.id === id) ? id : '';
|
if (id) return servers.some((server) => server.id === id) ? id : '';
|
||||||
const tag = text(legacyTag);
|
const tag = text(legacyTag);
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.20.5',
|
macClient: '0.20.36',
|
||||||
gatewayClient: '0.21.3',
|
gatewayClient: '0.21.21',
|
||||||
gatewayBackend: '0.21.1',
|
gatewayBackend: '0.21.20',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export interface ParsedVersion {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
hotfix: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HarborVersions {
|
||||||
|
macClient: string;
|
||||||
|
gatewayClient: string;
|
||||||
|
gatewayBackend: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVersion(value: unknown): ParsedVersion | null {
|
||||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
||||||
return match ? {
|
return match ? {
|
||||||
major: Number(match[1]),
|
major: Number(match[1]),
|
||||||
@@ -13,7 +25,7 @@ export function parseVersion(value) {
|
|||||||
} : null;
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function versionCompatibility(versions) {
|
export function versionCompatibility(versions: Partial<HarborVersions> | null | undefined) {
|
||||||
const mac = parseVersion(versions?.macClient);
|
const mac = parseVersion(versions?.macClient);
|
||||||
const client = parseVersion(versions?.gatewayClient);
|
const client = parseVersion(versions?.gatewayClient);
|
||||||
const backend = parseVersion(versions?.gatewayBackend);
|
const backend = parseVersion(versions?.gatewayBackend);
|
||||||
@@ -1,46 +1,67 @@
|
|||||||
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import './styles.css';
|
|
||||||
import { api, HarborApiError } from './api.js';
|
|
||||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
|
||||||
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
|
|
||||||
import {
|
import {
|
||||||
compatibleSnapshot,
|
api,
|
||||||
|
harborClient,
|
||||||
|
HarborApiError,
|
||||||
|
parseHarborState,
|
||||||
|
} from './api/harborClient.js';
|
||||||
|
import { ClientOverviewPage } from './components/ClientOverviewPage.js';
|
||||||
|
import { BootStatePage, StaleBanner } from './components/SyncStatus.js';
|
||||||
|
import {
|
||||||
harborReducer,
|
harborReducer,
|
||||||
initialHarborState,
|
initialHarborState,
|
||||||
} from './state/harborReducer.js';
|
} from './state/harborReducer.js';
|
||||||
import { createOperationRegistry } from './state/operations.js';
|
import {
|
||||||
|
createOperationRegistry,
|
||||||
|
type OperationKey,
|
||||||
|
type OperationRegistrySnapshot,
|
||||||
|
} from './state/operations.js';
|
||||||
|
|
||||||
function App() {
|
const componentActions = {
|
||||||
|
validateSubscription: api.subscription.validate,
|
||||||
|
listDevices: api.devices.list,
|
||||||
|
refreshDevices: api.devices.refresh,
|
||||||
|
updateDevice: api.devices.update,
|
||||||
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
|
pingServers: api.servers.ping,
|
||||||
|
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UiError {
|
||||||
|
context: string;
|
||||||
|
message: string;
|
||||||
|
code: string;
|
||||||
|
correlationId: string;
|
||||||
|
retry: (() => unknown) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
||||||
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
|
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
|
||||||
harborReducer,
|
harborReducer,
|
||||||
initialHarborState,
|
initialHarborState,
|
||||||
);
|
);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [operations, setOperations] = useState({});
|
const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<UiError | null>(null);
|
||||||
const [versionInfo, setVersionInfo] = useState(null);
|
const [versionInfo, setVersionInfo] = useState<unknown>(null);
|
||||||
const pollGeneration = useRef(0);
|
const pollGeneration = useRef(0);
|
||||||
const operationRegistry = useRef(null);
|
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
|
||||||
if (!operationRegistry.current) {
|
if (!operationRegistry.current) {
|
||||||
operationRegistry.current = createOperationRegistry(setOperations);
|
operationRegistry.current = createOperationRegistry((next) => {
|
||||||
|
setOperations(next);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPendingServerId(serverId) {
|
function setPendingServerId(serverId: string) {
|
||||||
dispatch({ type: 'select-server', serverId });
|
dispatch({ type: 'select-server', serverId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadState({ retry = false } = {}) {
|
async function loadState({ retry = false }: { retry?: boolean } = {}) {
|
||||||
if (retry) dispatch({ type: 'retry-sync' });
|
if (retry) dispatch({ type: 'retry-sync' });
|
||||||
const generation = pollGeneration.current;
|
const generation = pollGeneration.current;
|
||||||
try {
|
try {
|
||||||
const snapshot = await api.state();
|
const snapshot = await harborClient.getState();
|
||||||
if (!compatibleSnapshot(snapshot)) {
|
|
||||||
const incompatible = new Error('Ожидался Harbor state apiVersion 1');
|
|
||||||
incompatible.code = 'INCOMPATIBLE_API';
|
|
||||||
throw incompatible;
|
|
||||||
}
|
|
||||||
if (generation === pollGeneration.current) {
|
if (generation === pollGeneration.current) {
|
||||||
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
|
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
@@ -61,8 +82,9 @@ function App() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api.version().then((info) => {
|
api.version().then((info) => {
|
||||||
if (!cancelled) setVersionInfo(info);
|
if (!cancelled) setVersionInfo(info);
|
||||||
}).catch((requestError) => {
|
}).catch((requestError: unknown) => {
|
||||||
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
|
const message = requestError instanceof Error ? requestError.message : String(requestError);
|
||||||
|
console.warn(`[version] Не удалось получить runtime-версию: ${message}`);
|
||||||
if (!cancelled) setVersionInfo(null);
|
if (!cancelled) setVersionInfo(null);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
@@ -72,20 +94,20 @@ function App() {
|
|||||||
if (!state?.mode) return;
|
if (!state?.mode) return;
|
||||||
const isGateway = state.mode === 'gateway';
|
const isGateway = state.mode === 'gateway';
|
||||||
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
|
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
|
||||||
document.getElementById('harbor-favicon').href = isGateway
|
const favicon = document.getElementById('harbor-favicon') as HTMLLinkElement | null;
|
||||||
? '/harbor-gateway.svg?v=2'
|
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
|
||||||
: '/harbor-connect.svg?v=2';
|
|
||||||
}, [state?.mode]);
|
}, [state?.mode]);
|
||||||
|
|
||||||
function run(key, action, context) {
|
function run(key: OperationKey, action: () => Promise<unknown>, context: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
return operationRegistry.current.run(key, async () => {
|
return operationRegistry.current!.run(key, async () => {
|
||||||
try {
|
try {
|
||||||
return await applyMutation(action);
|
return await applyMutation(action);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
|
||||||
const safeError = err instanceof HarborApiError
|
const safeError = err instanceof HarborApiError
|
||||||
? err
|
? err
|
||||||
: new HarborApiError({ code: err?.code }, err?.status);
|
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
|
||||||
setError({
|
setError({
|
||||||
context,
|
context,
|
||||||
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||||
@@ -102,14 +124,18 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyMutation(action) {
|
async function applyMutation(action: () => Promise<unknown>) {
|
||||||
pollGeneration.current += 1;
|
pollGeneration.current += 1;
|
||||||
const result = await action();
|
const response = await action();
|
||||||
if (!result?.state) throw new Error('Harbor API не вернул state snapshot');
|
if (!response || typeof response !== 'object' || Array.isArray(response)) {
|
||||||
if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1');
|
throw new Error('Harbor API не вернул state snapshot');
|
||||||
|
}
|
||||||
|
const result = response as Record<string, unknown>;
|
||||||
|
if (!result.state) throw new Error('Harbor API не вернул state snapshot');
|
||||||
|
const snapshot = parseHarborState(result.state);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'sync-succeeded',
|
type: 'sync-succeeded',
|
||||||
snapshot: result.state,
|
snapshot,
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -138,20 +164,22 @@ function App() {
|
|||||||
|
|
||||||
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
|
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
|
||||||
|
|
||||||
|
const displayState = previewReady ? {
|
||||||
|
...state,
|
||||||
|
mode: 'client' as const,
|
||||||
|
subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' },
|
||||||
|
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
|
||||||
|
clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
|
||||||
|
} : state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
|
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
|
||||||
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
|
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
|
||||||
<div className="app-body client-mode">
|
<div className="app-body client-mode">
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<ClientOverviewPage
|
<ClientOverviewPage
|
||||||
state={previewReady ? {
|
actions={componentActions}
|
||||||
...state,
|
state={displayState}
|
||||||
mode: 'client',
|
|
||||||
hasSubscription: true,
|
|
||||||
subscriptionHost: 'harbor.example',
|
|
||||||
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
|
|
||||||
proxyPort: 8082,
|
|
||||||
} : state}
|
|
||||||
versionInfo={versionInfo}
|
versionInfo={versionInfo}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
error={error}
|
error={error}
|
||||||
@@ -169,11 +197,11 @@ function App() {
|
|||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onRefreshSubscription={refreshSubscription}
|
onRefreshSubscription={refreshSubscription}
|
||||||
onForgetSubscription={forgetSubscription}
|
onForgetSubscription={forgetSubscription}
|
||||||
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
|
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')}
|
||||||
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
||||||
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
||||||
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
||||||
onSaveRouteRules={(rules, expectedRevision) => run(
|
onSaveRouteRules={(rules: unknown[], expectedRevision: number) => run(
|
||||||
'routeRules',
|
'routeRules',
|
||||||
() => api.routeRules.update(rules, expectedRevision),
|
() => api.routeRules.update(rules, expectedRevision),
|
||||||
'routing',
|
'routing',
|
||||||
@@ -185,5 +213,3 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<App />);
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
|
|
||||||
|
|
||||||
export class HarborApiError extends Error {
|
|
||||||
constructor(payload = {}, status = 0) {
|
|
||||||
const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
|
|
||||||
const definition = errorDefinition(code);
|
|
||||||
super(definition.message);
|
|
||||||
this.name = 'HarborApiError';
|
|
||||||
this.code = code;
|
|
||||||
this.status = status >= 400 ? status : definition.status;
|
|
||||||
this.retryable = definition.retryable;
|
|
||||||
this.details = payload.details;
|
|
||||||
this.correlationId = payload.correlationId
|
|
||||||
|| globalThis.crypto?.randomUUID?.()
|
|
||||||
|| new Date().toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function request(url, options = {}, fetchImpl = fetch) {
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await fetchImpl(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') throw error;
|
|
||||||
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = {};
|
|
||||||
try {
|
|
||||||
data = await response.json();
|
|
||||||
} catch {
|
|
||||||
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
|
||||||
}
|
|
||||||
if (!response.ok || data?.success === false) {
|
|
||||||
const payload = data?.error && typeof data.error === 'object'
|
|
||||||
? data.error
|
|
||||||
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
|
|
||||||
throw new HarborApiError(payload, response.status);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
state: () => request('/api/state'),
|
|
||||||
version: () => request('/api/version'),
|
|
||||||
subscription: {
|
|
||||||
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url }),
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
fetch: (url) => request('/api/subscription/fetch', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ url }),
|
|
||||||
}),
|
|
||||||
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
|
|
||||||
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
|
||||||
},
|
|
||||||
apply: (serverId) => request('/api/apply', {
|
|
||||||
method: 'POST',
|
|
||||||
// selectedTag keeps this client compatible with pre-ID Harbor backends.
|
|
||||||
body: JSON.stringify({ serverId, selectedTag: serverId }),
|
|
||||||
}),
|
|
||||||
gatewayAuto: {
|
|
||||||
setEnabled: (enabled) => request('/api/gateway-auto', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
routeRules: {
|
|
||||||
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ rules, expectedRulesRevision }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
list: () => request('/api/devices'),
|
|
||||||
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
|
||||||
update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
|
||||||
}),
|
|
||||||
setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ mode, expectedRevision }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
diagnostics: {
|
|
||||||
connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ services, target }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
singbox: {
|
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
|
||||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
|
||||||
},
|
|
||||||
servers: {
|
|
||||||
ping: (serverIds) => request('/api/servers/ping-all', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ serverIds }),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
||||||
|
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface JsonResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchImplementation = (url: string, options: RequestOptions) => Promise<JsonResponse>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HarborApiError extends Error {
|
||||||
|
code: string;
|
||||||
|
status: number;
|
||||||
|
retryable: boolean;
|
||||||
|
details: unknown;
|
||||||
|
correlationId: string;
|
||||||
|
|
||||||
|
constructor(payload: unknown = {}, status = 0) {
|
||||||
|
const candidate = record(payload);
|
||||||
|
const requestedCode = typeof candidate.code === 'string' ? candidate.code : '';
|
||||||
|
const code = Object.hasOwn(ERROR_DEFINITIONS, requestedCode) ? requestedCode : 'UNKNOWN';
|
||||||
|
const definition = errorDefinition(code);
|
||||||
|
super(definition.message);
|
||||||
|
this.name = 'HarborApiError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status >= 400 ? status : definition.status;
|
||||||
|
this.retryable = definition.retryable;
|
||||||
|
this.details = candidate.details;
|
||||||
|
this.correlationId = typeof candidate.correlationId === 'string' && candidate.correlationId
|
||||||
|
? candidate.correlationId
|
||||||
|
: globalThis.crypto?.randomUUID?.() || new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request(
|
||||||
|
url: string,
|
||||||
|
options: RequestOptions = {},
|
||||||
|
fetchImpl: FetchImplementation = fetch,
|
||||||
|
): Promise<unknown> {
|
||||||
|
let response: JsonResponse;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (record(error).name === 'AbortError') throw error;
|
||||||
|
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown = {};
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
||||||
|
}
|
||||||
|
const payload = record(data);
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
const errorPayload = payload.error && typeof payload.error === 'object'
|
||||||
|
? payload.error
|
||||||
|
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
|
||||||
|
throw new HarborApiError(errorPayload, response.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
version: () => request('/api/version'),
|
||||||
|
subscription: {
|
||||||
|
validate: (url: string, { signal }: { signal?: AbortSignal } = {}) => request(
|
||||||
|
'/api/subscription/validate',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fetch: (url: string) => request('/api/subscription/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
}),
|
||||||
|
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
|
||||||
|
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
apply: (serverId: string) => request('/api/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
// selectedTag keeps this client compatible with pre-ID Harbor backends.
|
||||||
|
body: JSON.stringify({ serverId, selectedTag: serverId }),
|
||||||
|
}),
|
||||||
|
gatewayAuto: {
|
||||||
|
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
routeRules: {
|
||||||
|
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ rules, expectedRulesRevision }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
list: () => request('/api/devices'),
|
||||||
|
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
||||||
|
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
|
||||||
|
`/api/devices/${id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
|
||||||
|
`/api/devices/${id}/policy`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ mode, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
connectivity: (services: unknown[] = [], target: unknown = null) => request(
|
||||||
|
'/api/diagnostics/connectivity',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ services, target }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
singbox: {
|
||||||
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
servers: {
|
||||||
|
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverIds }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HarborClientState extends StateSnapshot {
|
||||||
|
clientRuntime: {
|
||||||
|
proxyPort: number;
|
||||||
|
configured: boolean;
|
||||||
|
gatewayAvailable: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHarborState(value: unknown): HarborClientState {
|
||||||
|
let snapshot: StateSnapshot;
|
||||||
|
try {
|
||||||
|
snapshot = assertStateSnapshot(value);
|
||||||
|
} catch (cause) {
|
||||||
|
throw Object.assign(new Error('Ожидался Harbor state apiVersion 1', { cause }), {
|
||||||
|
code: 'INCOMPATIBLE_API',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const payload = record(value);
|
||||||
|
const gatewayAuto = record(payload.gatewayAuto);
|
||||||
|
const parsedProxyPort = Number(payload.proxyPort);
|
||||||
|
const canonical: StateSnapshot = {
|
||||||
|
apiVersion: snapshot.apiVersion,
|
||||||
|
revision: snapshot.revision,
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
mode: snapshot.mode,
|
||||||
|
subscription: snapshot.subscription,
|
||||||
|
selection: snapshot.selection,
|
||||||
|
connection: snapshot.connection,
|
||||||
|
route: snapshot.route,
|
||||||
|
operation: snapshot.operation,
|
||||||
|
servers: snapshot.servers,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...canonical,
|
||||||
|
clientRuntime: {
|
||||||
|
proxyPort: Number.isInteger(parsedProxyPort) && parsedProxyPort > 0
|
||||||
|
? parsedProxyPort
|
||||||
|
: snapshot.mode === 'gateway' ? 8080 : 8082,
|
||||||
|
configured: payload.configExists === true,
|
||||||
|
gatewayAvailable: gatewayAuto.available === true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const harborClient = {
|
||||||
|
async getState(): Promise<HarborClientState> {
|
||||||
|
return parseHarborState(await request('/api/state'));
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,652 @@
|
|||||||
|
import React, {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
copyText,
|
||||||
|
localProxyUrls,
|
||||||
|
} from '../utils/clientControls.js';
|
||||||
|
import {
|
||||||
|
operationBlocked,
|
||||||
|
type OperationKey,
|
||||||
|
type OperationRegistrySnapshot,
|
||||||
|
} from '../state/operations.js';
|
||||||
|
import { ConnectionPanel } from '../features/connection/index.js';
|
||||||
|
import {
|
||||||
|
SubscriptionDeleteDialog,
|
||||||
|
SubscriptionPanel,
|
||||||
|
SubscriptionToggle,
|
||||||
|
useSubscriptionFeature,
|
||||||
|
} from '../features/subscription/index.js';
|
||||||
|
import { ServerPicker } from '../features/servers/index.js';
|
||||||
|
import {
|
||||||
|
RoutingDiscardDialog,
|
||||||
|
RoutingPanel,
|
||||||
|
RoutingPendingStatus,
|
||||||
|
RoutingToggle,
|
||||||
|
useRoutingFeature,
|
||||||
|
} from '../features/routing/index.js';
|
||||||
|
import {
|
||||||
|
DevicesPanel,
|
||||||
|
DevicesToggle,
|
||||||
|
GatewayTrafficSummary,
|
||||||
|
useDevicesFeature,
|
||||||
|
} from '../features/devices/index.js';
|
||||||
|
import {
|
||||||
|
ConnectivityDiagnosticsPanel,
|
||||||
|
DiagnosticsToggle,
|
||||||
|
useDiagnosticsFeature,
|
||||||
|
} from '../features/diagnostics/index.js';
|
||||||
|
import {
|
||||||
|
InstructionsPanel,
|
||||||
|
InstructionsToggle,
|
||||||
|
useInstructionsFeature,
|
||||||
|
} from '../features/instructions/index.js';
|
||||||
|
import {
|
||||||
|
HARBOR_VERSIONS,
|
||||||
|
parseVersion,
|
||||||
|
versionCompatibility,
|
||||||
|
} from '../../shared/versions.js';
|
||||||
|
import type {
|
||||||
|
HarborServer,
|
||||||
|
RouteRule,
|
||||||
|
StateSnapshot,
|
||||||
|
} from '../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
const VERSION_PARTS = [
|
||||||
|
['major', 'Major'],
|
||||||
|
['minor', 'Minor'],
|
||||||
|
['hotfix', 'Hotfix'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface UiError {
|
||||||
|
context?: string;
|
||||||
|
message?: string;
|
||||||
|
correlationId?: string;
|
||||||
|
retry?: (() => unknown) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionBadgeProps {
|
||||||
|
code: string;
|
||||||
|
component: string;
|
||||||
|
componentKey: string;
|
||||||
|
version: unknown;
|
||||||
|
runtime?: string | null;
|
||||||
|
incompatible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComponentActions {
|
||||||
|
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
|
||||||
|
listDevices: () => Promise<unknown>;
|
||||||
|
refreshDevices: () => Promise<unknown>;
|
||||||
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
|
pingServers: (ids: string[]) => Promise<unknown>;
|
||||||
|
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientViewState extends StateSnapshot {
|
||||||
|
clientRuntime: {
|
||||||
|
proxyPort: number;
|
||||||
|
configured: boolean;
|
||||||
|
gatewayAvailable: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientOverviewPageProps {
|
||||||
|
actions: ComponentActions;
|
||||||
|
state: ClientViewState;
|
||||||
|
versionInfo: unknown;
|
||||||
|
operations?: OperationRegistrySnapshot;
|
||||||
|
error: UiError | null;
|
||||||
|
subscriptionUrl: string;
|
||||||
|
setSubscriptionUrl: (value: string) => void;
|
||||||
|
servers: HarborServer[];
|
||||||
|
pendingServerId: string;
|
||||||
|
setPendingServerId: (id: string) => void;
|
||||||
|
onFetchSubscription: () => Promise<unknown>;
|
||||||
|
onRefreshSubscription: () => Promise<unknown>;
|
||||||
|
onForgetSubscription: () => Promise<unknown>;
|
||||||
|
onApply: (serverId: string) => Promise<unknown>;
|
||||||
|
onRestart: () => Promise<unknown>;
|
||||||
|
onStop: () => Promise<unknown>;
|
||||||
|
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||||
|
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||||
|
onDismissError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopyKind = 'gateway' | 'socks5' | 'http';
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionBadge({
|
||||||
|
code,
|
||||||
|
component,
|
||||||
|
componentKey,
|
||||||
|
version,
|
||||||
|
runtime,
|
||||||
|
incompatible = false,
|
||||||
|
}: VersionBadgeProps) {
|
||||||
|
const parsed = parseVersion(version);
|
||||||
|
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–'];
|
||||||
|
|
||||||
|
function description(key: 'major' | 'minor' | 'hotfix') {
|
||||||
|
if (key === 'major') {
|
||||||
|
return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.';
|
||||||
|
}
|
||||||
|
if (key === 'minor') {
|
||||||
|
return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.';
|
||||||
|
}
|
||||||
|
return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
|
||||||
|
<span className="harbor-version-code" aria-hidden="true">{code}</span>
|
||||||
|
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
|
||||||
|
{VERSION_PARTS.map(([key, label], index) => {
|
||||||
|
const tooltipId = `harbor-version-${componentKey}-${key}`;
|
||||||
|
return <React.Fragment key={key}>
|
||||||
|
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
|
||||||
|
<span
|
||||||
|
className="harbor-version-part"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-describedby={tooltipId}
|
||||||
|
>
|
||||||
|
{values[index]}
|
||||||
|
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
|
||||||
|
<strong>{component} · {label} {values[index]}</strong>
|
||||||
|
<span>{description(key)}</span>
|
||||||
|
{runtime && <small>{runtime}</small>}
|
||||||
|
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</React.Fragment>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionDisplay({ isGateway, versionInfo }: { isGateway: boolean; versionInfo: unknown }) {
|
||||||
|
const info = record(versionInfo);
|
||||||
|
const runtime = record(info.runtime);
|
||||||
|
const components = record(info.components);
|
||||||
|
const runtimeSingBox = typeof runtime.singBox === 'string' ? runtime.singBox : null;
|
||||||
|
if (!isGateway) {
|
||||||
|
return <aside className="harbor-versions" aria-label="Версия Harbor">
|
||||||
|
<VersionBadge
|
||||||
|
code="M"
|
||||||
|
component="Mac client"
|
||||||
|
componentKey="macClient"
|
||||||
|
version={typeof components.macClient === 'string' ? components.macClient : HARBOR_VERSIONS.macClient}
|
||||||
|
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
|
||||||
|
/>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendVersion = typeof components.gatewayBackend === 'string' ? components.gatewayBackend : '';
|
||||||
|
const dataplaneVersion = typeof runtime.dataplaneVersion === 'string' ? runtime.dataplaneVersion : '';
|
||||||
|
const compatibility = backendVersion && versionCompatibility({
|
||||||
|
...HARBOR_VERSIONS,
|
||||||
|
gatewayBackend: backendVersion,
|
||||||
|
});
|
||||||
|
const incompatible = Boolean(compatibility && !compatibility.compatible);
|
||||||
|
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
|
||||||
|
<VersionBadge
|
||||||
|
code="C"
|
||||||
|
component="Gateway client UI"
|
||||||
|
componentKey="gatewayClient"
|
||||||
|
version={HARBOR_VERSIONS.gatewayClient}
|
||||||
|
incompatible={incompatible}
|
||||||
|
/>
|
||||||
|
<VersionBadge
|
||||||
|
code="B"
|
||||||
|
component="Gateway control backend"
|
||||||
|
componentKey="gatewayBackend"
|
||||||
|
version={backendVersion}
|
||||||
|
incompatible={incompatible}
|
||||||
|
/>
|
||||||
|
<VersionBadge
|
||||||
|
code="D"
|
||||||
|
component="Gateway dataplane"
|
||||||
|
componentKey="gatewayDataplane"
|
||||||
|
version={dataplaneVersion}
|
||||||
|
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
|
||||||
|
/>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineError({ error, context }: { error?: UiError | null; context: string }) {
|
||||||
|
if (!error || error.context !== context) return null;
|
||||||
|
return (
|
||||||
|
<div className={`client-inline-error is-${context}`} role="alert">
|
||||||
|
<span>{error.message}</span>
|
||||||
|
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
|
||||||
|
{error.correlationId && (
|
||||||
|
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
|
||||||
|
connection: ['connection', 'Меняем состояние подключения…'],
|
||||||
|
serverApply: ['connection', 'Применяем сервер…'],
|
||||||
|
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||||
|
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||||
|
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function InlineProgress({ operations, context }: {
|
||||||
|
operations: OperationRegistrySnapshot;
|
||||||
|
context: string;
|
||||||
|
}) {
|
||||||
|
const active = (Object.entries(operationProgress) as Array<[
|
||||||
|
OperationKey,
|
||||||
|
readonly [string, string],
|
||||||
|
]>).find(([key, [operationContext]]) => (
|
||||||
|
operationContext === context && operations[key]?.status === 'running'
|
||||||
|
));
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
|
||||||
|
<span>{active[1][1]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
|
||||||
|
isGateway: boolean;
|
||||||
|
gatewayAvailable: boolean;
|
||||||
|
gatewayDirect: boolean;
|
||||||
|
blocked: boolean;
|
||||||
|
onSetGatewayAuto: (enabled: boolean) => unknown;
|
||||||
|
}) {
|
||||||
|
const [modeAnimating, setModeAnimating] = useState(false);
|
||||||
|
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
|
||||||
|
const stopModeAnimationRef = useRef(false);
|
||||||
|
const previousGatewayDirectRef = useRef(gatewayDirect);
|
||||||
|
const product = isGateway ? 'Gateway' : 'Connect';
|
||||||
|
const switchable = !isGateway && gatewayAvailable;
|
||||||
|
const label = gatewayDirect
|
||||||
|
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
|
||||||
|
: 'Использовать обнаруженный Harbor Gateway';
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (previousGatewayDirectRef.current === gatewayDirect) return;
|
||||||
|
previousGatewayDirectRef.current = gatewayDirect;
|
||||||
|
setArrowTurns((turns) => turns + 0.5);
|
||||||
|
}, [gatewayDirect]);
|
||||||
|
|
||||||
|
function startModeAnimation() {
|
||||||
|
stopModeAnimationRef.current = false;
|
||||||
|
setModeAnimating(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishModeAnimation() {
|
||||||
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setModeAnimating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopModeAnimationRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = <div className="harbor-brand-content">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<circle cx="16" cy="6" r="3" />
|
||||||
|
<path d="M16 9v15M10 14h12" />
|
||||||
|
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
</svg>
|
||||||
|
<span className="harbor-brand-name">
|
||||||
|
<strong>Harbor</strong>
|
||||||
|
{switchable ? <span className="harbor-mode-control">
|
||||||
|
<span className="harbor-mode-stack" aria-hidden="true">
|
||||||
|
<em className="harbor-mode-connect">Connect</em>
|
||||||
|
<em className="harbor-mode-gateway"><span>Gateway</span></em>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className="harbor-mode-swap"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
|
||||||
|
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
|
||||||
|
</svg>
|
||||||
|
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
|
||||||
|
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
|
||||||
|
<span>{gatewayDirect
|
||||||
|
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
|
||||||
|
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
|
||||||
|
</span>
|
||||||
|
</span> : <em>{product}</em>}
|
||||||
|
</span>
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||||||
|
{switchable ? <button
|
||||||
|
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-describedby="harbor-mode-tooltip"
|
||||||
|
aria-pressed={gatewayDirect}
|
||||||
|
disabled={blocked}
|
||||||
|
onPointerEnter={startModeAnimation}
|
||||||
|
onPointerLeave={finishModeAnimation}
|
||||||
|
onFocus={startModeAnimation}
|
||||||
|
onBlur={finishModeAnimation}
|
||||||
|
onAnimationIteration={(event) => {
|
||||||
|
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
|
||||||
|
stopModeAnimationRef.current = false;
|
||||||
|
setModeAnimating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => onSetGatewayAuto(!gatewayDirect)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientOverviewPage({
|
||||||
|
actions,
|
||||||
|
state,
|
||||||
|
versionInfo,
|
||||||
|
operations = {},
|
||||||
|
error,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
servers,
|
||||||
|
pendingServerId,
|
||||||
|
setPendingServerId,
|
||||||
|
onFetchSubscription,
|
||||||
|
onRefreshSubscription,
|
||||||
|
onForgetSubscription,
|
||||||
|
onApply,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
onSetGatewayAuto,
|
||||||
|
onSaveRouteRules,
|
||||||
|
onDismissError,
|
||||||
|
}: ClientOverviewPageProps) {
|
||||||
|
const isGateway = state?.mode === 'gateway';
|
||||||
|
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
|
||||||
|
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
|
||||||
|
const connected = state?.connection?.process === 'running';
|
||||||
|
const hasSubscription = state?.subscription?.status === 'ready';
|
||||||
|
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||||
|
const appliedServerId = state?.selection?.appliedServerId || '';
|
||||||
|
const appliedServer = servers.find(({ id }) => id === appliedServerId);
|
||||||
|
const desiredServer = servers.find(({ id }) => id === selectedServerId);
|
||||||
|
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [showIntro, setShowIntro] = useState(true);
|
||||||
|
const [copyFeedback, setCopyFeedback] = useState<{ kind: CopyKind; failed: boolean } | null>(null);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
|
const controlHost = window.location.host || `${gatewayAddress}:3456`;
|
||||||
|
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
|
||||||
|
const connectionBlocked = operationBlocked(operations, 'connection');
|
||||||
|
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||||||
|
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||||
|
const switchingServer = Boolean(
|
||||||
|
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
|
||||||
|
);
|
||||||
|
const subscriptionFeature = useSubscriptionFeature({
|
||||||
|
subscription: state?.subscription,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
operations,
|
||||||
|
error,
|
||||||
|
serverCount: servers.length,
|
||||||
|
isGateway,
|
||||||
|
gatewayDirect,
|
||||||
|
validateSubscription: actions.validateSubscription,
|
||||||
|
onImport: onFetchSubscription,
|
||||||
|
onRefresh: onRefreshSubscription,
|
||||||
|
onForget: onForgetSubscription,
|
||||||
|
onDismissError,
|
||||||
|
});
|
||||||
|
const subscriptionContentReady = subscriptionFeature.contentReady;
|
||||||
|
const routingFeature = useRoutingFeature({
|
||||||
|
route: state?.route,
|
||||||
|
connected,
|
||||||
|
operations,
|
||||||
|
onSave: onSaveRouteRules,
|
||||||
|
onDismissError,
|
||||||
|
});
|
||||||
|
const devicesFeature = useDevicesFeature({
|
||||||
|
isGateway,
|
||||||
|
listDevices: actions.listDevices,
|
||||||
|
refreshDevices: actions.refreshDevices,
|
||||||
|
updateDevice: actions.updateDevice,
|
||||||
|
setDevicePolicy: actions.setDevicePolicy,
|
||||||
|
});
|
||||||
|
const diagnosticsFeature = useDiagnosticsFeature();
|
||||||
|
const instructionsFeature = useInstructionsFeature({
|
||||||
|
isGateway,
|
||||||
|
host: gatewayAddress,
|
||||||
|
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
|
||||||
|
controlHost,
|
||||||
|
});
|
||||||
|
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
if (!isGateway && (!connected || !state?.connection?.startedAt)) return undefined;
|
||||||
|
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isGateway, connected, state?.connection?.startedAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showIntro) return undefined;
|
||||||
|
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showIntro]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSubscription) {
|
||||||
|
routingFeature.forceClose();
|
||||||
|
if (!isGateway) {
|
||||||
|
instructionsFeature.close();
|
||||||
|
devicesFeature.close();
|
||||||
|
diagnosticsFeature.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasSubscription, isGateway]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
||||||
|
}, [diagnosticsAvailable]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function selectServer(serverId: string) {
|
||||||
|
setPendingServerId(serverId);
|
||||||
|
if (connected && serverId) onApply(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProxy(kind: CopyKind) {
|
||||||
|
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||||||
|
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||||
|
try {
|
||||||
|
await copyText(value);
|
||||||
|
setCopyFeedback({ kind, failed: false });
|
||||||
|
} catch {
|
||||||
|
setCopyFeedback({ kind, failed: true });
|
||||||
|
}
|
||||||
|
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRouting() {
|
||||||
|
subscriptionFeature.close();
|
||||||
|
instructionsFeature.close();
|
||||||
|
devicesFeature.close();
|
||||||
|
diagnosticsFeature.close();
|
||||||
|
routingFeature.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||||||
|
>
|
||||||
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
|
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||||
|
</div>
|
||||||
|
<HarborBrand
|
||||||
|
isGateway={isGateway}
|
||||||
|
gatewayAvailable={gatewayAvailable}
|
||||||
|
gatewayDirect={gatewayDirect}
|
||||||
|
blocked={gatewayAutoBlocked}
|
||||||
|
onSetGatewayAuto={onSetGatewayAuto}
|
||||||
|
/>
|
||||||
|
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||||
|
{isGateway && <SubscriptionToggle
|
||||||
|
feature={subscriptionFeature}
|
||||||
|
onToggle={() => {
|
||||||
|
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||||
|
instructionsFeature.close();
|
||||||
|
devicesFeature.close();
|
||||||
|
diagnosticsFeature.close();
|
||||||
|
subscriptionFeature.toggle();
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
<InstructionsToggle
|
||||||
|
feature={instructionsFeature}
|
||||||
|
onToggle={() => {
|
||||||
|
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||||
|
subscriptionFeature.close();
|
||||||
|
devicesFeature.close();
|
||||||
|
diagnosticsFeature.close();
|
||||||
|
instructionsFeature.toggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isGateway && <DevicesToggle
|
||||||
|
feature={devicesFeature}
|
||||||
|
onToggle={() => {
|
||||||
|
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||||
|
subscriptionFeature.close();
|
||||||
|
instructionsFeature.close();
|
||||||
|
diagnosticsFeature.close();
|
||||||
|
devicesFeature.toggle();
|
||||||
|
}}
|
||||||
|
/>}
|
||||||
|
<DiagnosticsToggle
|
||||||
|
feature={diagnosticsFeature}
|
||||||
|
onToggle={() => {
|
||||||
|
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||||
|
subscriptionFeature.close();
|
||||||
|
instructionsFeature.close();
|
||||||
|
devicesFeature.close();
|
||||||
|
diagnosticsFeature.toggle();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RoutingToggle
|
||||||
|
feature={routingFeature}
|
||||||
|
gatewayDirect={gatewayDirect}
|
||||||
|
isGateway={isGateway}
|
||||||
|
hasSubscription={hasSubscription}
|
||||||
|
onOpen={openRouting}
|
||||||
|
/>
|
||||||
|
</nav>}
|
||||||
|
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
|
||||||
|
<ConnectionPanel
|
||||||
|
visible={showPower}
|
||||||
|
isGateway={isGateway}
|
||||||
|
connected={connected}
|
||||||
|
gatewayDirect={gatewayDirect}
|
||||||
|
selectedServerId={selectedServerId}
|
||||||
|
configured={Boolean(state?.clientRuntime?.configured)}
|
||||||
|
startedAt={state?.connection?.startedAt}
|
||||||
|
gatewayAddress={gatewayAddress}
|
||||||
|
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
|
||||||
|
gatewayRouteAddress={state?.route?.gatewayAddress}
|
||||||
|
proxyPort={state?.clientRuntime?.proxyPort}
|
||||||
|
now={now}
|
||||||
|
blocked={connectionBlocked}
|
||||||
|
copyFeedback={copyFeedback}
|
||||||
|
onCopyProxy={copyProxy}
|
||||||
|
onApply={onApply}
|
||||||
|
onRestart={onRestart}
|
||||||
|
onStop={onStop}
|
||||||
|
routingSlot={<RoutingPendingStatus
|
||||||
|
feature={routingFeature}
|
||||||
|
blocked={connectionBlocked}
|
||||||
|
onRestart={onRestart}
|
||||||
|
/>}
|
||||||
|
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
|
||||||
|
<span className="client-gateway-summary-kicker">Сейчас</span>
|
||||||
|
<strong id="gateway-summary-title">
|
||||||
|
{appliedServer?.label || 'VPN-сервер не используется'}
|
||||||
|
</strong>
|
||||||
|
<div className="client-gateway-route-slot">
|
||||||
|
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
statusSlot={<>
|
||||||
|
<InlineError error={error} context="connection" />
|
||||||
|
<InlineProgress operations={operations} context="connection" />
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
|
||||||
|
|
||||||
|
<SubscriptionPanel
|
||||||
|
feature={subscriptionFeature}
|
||||||
|
statusSlot={<>
|
||||||
|
<InlineError error={subscriptionFeature.error || error} context="subscription" />
|
||||||
|
<InlineProgress operations={operations} context="subscription" />
|
||||||
|
</>}
|
||||||
|
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
|
||||||
|
pingServers={actions.pingServers}
|
||||||
|
servers={servers}
|
||||||
|
selectedServerId={selectedServerId}
|
||||||
|
disabled={serverApplyBlocked}
|
||||||
|
prompt={!showPower}
|
||||||
|
leaving={subscriptionFeature.serversLeaving}
|
||||||
|
revealVersion={subscriptionFeature.serverRevealVersion}
|
||||||
|
onSelect={selectServer}
|
||||||
|
/>}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
|
||||||
|
feature={instructionsFeature}
|
||||||
|
isGateway={isGateway}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
{isGateway && <DevicesPanel feature={devicesFeature} />}
|
||||||
|
|
||||||
|
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||||
|
feature={diagnosticsFeature}
|
||||||
|
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||||
|
isGateway={isGateway}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
{hasSubscription && subscriptionContentReady && <RoutingPanel
|
||||||
|
feature={routingFeature}
|
||||||
|
statusSlot={<>
|
||||||
|
<InlineError error={error} context="routing" />
|
||||||
|
<InlineProgress operations={operations} context="routing" />
|
||||||
|
</>}
|
||||||
|
/>}
|
||||||
|
<RoutingDiscardDialog feature={routingFeature} />
|
||||||
|
<SubscriptionDeleteDialog feature={subscriptionFeature} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { HarborReducerState } from '../state/harborReducer.js';
|
||||||
|
|
||||||
|
type TransportState = HarborReducerState['transport'];
|
||||||
|
interface SyncStatusProps { transport: TransportState; onRetry: () => void }
|
||||||
|
|
||||||
const bootCopy = {
|
const bootCopy = {
|
||||||
'control-unreachable': {
|
'control-unreachable': {
|
||||||
@@ -15,10 +19,10 @@ const bootCopy = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BootStatePage({ transport, onRetry }) {
|
export function BootStatePage({ transport, onRetry }: SyncStatusProps) {
|
||||||
if (transport.bootStatus === 'loading') return <div className="app-loading">Harbor</div>;
|
if (transport.bootStatus === 'loading') return <div className="app-loading">Harbor</div>;
|
||||||
|
|
||||||
const copy = bootCopy[transport.bootStatus] || bootCopy.fatal;
|
const copy = transport.bootStatus === 'ready' ? bootCopy.fatal : bootCopy[transport.bootStatus];
|
||||||
return (
|
return (
|
||||||
<main className="app-boot">
|
<main className="app-boot">
|
||||||
<span>Harbor</span>
|
<span>Harbor</span>
|
||||||
@@ -34,7 +38,7 @@ export function BootStatePage({ transport, onRetry }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StaleBanner({ transport, onRetry }) {
|
export function StaleBanner({ transport, onRetry }: SyncStatusProps) {
|
||||||
if (!transport.stale) return null;
|
if (!transport.stale) return null;
|
||||||
const lastSync = transport.lastSuccessfulSyncAt
|
const lastSync = transport.lastSuccessfulSyncAt
|
||||||
? new Date(transport.lastSuccessfulSyncAt).toLocaleTimeString('ru-RU')
|
? new Date(transport.lastSuccessfulSyncAt).toLocaleTimeString('ru-RU')
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
connectionDurationParts,
|
||||||
|
localProxyUrls,
|
||||||
|
} from '../../utils/clientControls.js';
|
||||||
|
|
||||||
|
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||||
|
|
||||||
|
type CopyKind = 'gateway' | 'socks5' | 'http';
|
||||||
|
|
||||||
|
interface CopyFeedback {
|
||||||
|
kind: CopyKind;
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DurationUnit {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionPanelProps {
|
||||||
|
visible: boolean;
|
||||||
|
isGateway: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
gatewayDirect: boolean;
|
||||||
|
selectedServerId: string;
|
||||||
|
configured: boolean;
|
||||||
|
startedAt?: string | null;
|
||||||
|
gatewayAddress: string;
|
||||||
|
gatewayUiOrigin?: string | null;
|
||||||
|
gatewayRouteAddress?: string | null;
|
||||||
|
proxyPort?: number;
|
||||||
|
now: number;
|
||||||
|
blocked: boolean;
|
||||||
|
copyFeedback?: CopyFeedback | null;
|
||||||
|
routingSlot?: ReactNode;
|
||||||
|
serverSlot?: ReactNode;
|
||||||
|
statusSlot?: ReactNode;
|
||||||
|
onCopyProxy: (kind: CopyKind) => unknown;
|
||||||
|
onApply: (serverId: string) => unknown;
|
||||||
|
onRestart: () => unknown;
|
||||||
|
onStop: () => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DurationPart({ name, children }: { name: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedSeconds({ value, padded = true }: { value: number; padded?: boolean }) {
|
||||||
|
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
|
||||||
|
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionPanel({
|
||||||
|
visible,
|
||||||
|
isGateway,
|
||||||
|
connected,
|
||||||
|
gatewayDirect,
|
||||||
|
selectedServerId,
|
||||||
|
configured,
|
||||||
|
startedAt,
|
||||||
|
gatewayAddress,
|
||||||
|
gatewayUiOrigin,
|
||||||
|
gatewayRouteAddress,
|
||||||
|
proxyPort,
|
||||||
|
now,
|
||||||
|
blocked,
|
||||||
|
copyFeedback,
|
||||||
|
routingSlot,
|
||||||
|
serverSlot,
|
||||||
|
statusSlot,
|
||||||
|
onCopyProxy,
|
||||||
|
onApply,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
}: ConnectionPanelProps) {
|
||||||
|
const [durationMode, setDurationMode] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
|
||||||
|
} catch {
|
||||||
|
return 'digital';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [confirmingStop, setConfirmingStop] = useState(false);
|
||||||
|
const canStart = Boolean(selectedServerId || configured);
|
||||||
|
const powerUnavailable = isGateway && !connected && !canStart;
|
||||||
|
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
|
||||||
|
const duration = connectionDurationParts(startedAt, now);
|
||||||
|
const clockUnits: Array<[string, DurationUnit]> = [
|
||||||
|
['hours', duration.hours],
|
||||||
|
['minutes', duration.minutes],
|
||||||
|
['seconds', duration.seconds],
|
||||||
|
];
|
||||||
|
const wordClockDuration = clockUnits
|
||||||
|
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
|
||||||
|
const connectionTitle = connected
|
||||||
|
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||||||
|
: 'Подключение выключено';
|
||||||
|
const proxyKinds: Array<[CopyKind, string]> = isGateway
|
||||||
|
? [
|
||||||
|
['gateway', 'GATEWAY'],
|
||||||
|
['socks5', 'SOCKS5'],
|
||||||
|
['http', 'HTTP'],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
['socks5', 'SOCKS5'],
|
||||||
|
['http', 'HTTP'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleConnection() {
|
||||||
|
const action = connectionAction({ connected, selectedServerId, configExists: configured });
|
||||||
|
if (action?.type === 'stop') {
|
||||||
|
setConfirmingStop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action?.type === 'apply') return onApply(action.serverId);
|
||||||
|
if (action?.type === 'restart') return onRestart();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopConnection() {
|
||||||
|
if (!await onStop()) return;
|
||||||
|
setConfirmingStop(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDurationMode() {
|
||||||
|
setDurationMode((mode) => {
|
||||||
|
const nextMode = mode === 'digital' ? 'words' : 'digital';
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
|
||||||
|
} catch {
|
||||||
|
// The visual preference still works for this session.
|
||||||
|
}
|
||||||
|
return nextMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const powerButton = <button
|
||||||
|
className="client-power"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={connected}
|
||||||
|
aria-label={isGateway
|
||||||
|
? connected ? 'Остановить VPN' : 'Запустить VPN'
|
||||||
|
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||||
|
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||||
|
disabled={blocked || (!connected && !canStart)}
|
||||||
|
onClick={toggleConnection}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
|
</svg>
|
||||||
|
</button>;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{visible && <section className="client-power-section" aria-labelledby="connection-title">
|
||||||
|
{isGateway ? <span
|
||||||
|
className="client-power-control client-tooltip-anchor"
|
||||||
|
tabIndex={powerUnavailable ? 0 : undefined}
|
||||||
|
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
|
||||||
|
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||||
|
>
|
||||||
|
{powerButton}
|
||||||
|
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
|
||||||
|
Сначала добавьте подписку и выберите сервер
|
||||||
|
</span>}
|
||||||
|
</span> : powerButton}
|
||||||
|
{routingSlot}
|
||||||
|
<div className="client-state-copy" aria-live="polite">
|
||||||
|
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||||
|
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||||
|
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||||
|
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||||
|
</h2>
|
||||||
|
{serverSlot}
|
||||||
|
<div className="client-state-detail">
|
||||||
|
{connected ? (
|
||||||
|
<button
|
||||||
|
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key="duration"
|
||||||
|
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||||
|
onClick={toggleDurationMode}
|
||||||
|
>
|
||||||
|
<span className="client-duration-stack">
|
||||||
|
<time
|
||||||
|
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
|
||||||
|
aria-hidden={durationMode !== 'digital'}
|
||||||
|
>
|
||||||
|
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||||
|
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||||
|
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||||||
|
</time>
|
||||||
|
<time
|
||||||
|
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
|
||||||
|
aria-hidden={durationMode !== 'words'}
|
||||||
|
>
|
||||||
|
{duration.days.value > 0 && (
|
||||||
|
<span className="client-duration-word-row is-calendar">
|
||||||
|
<span className="client-duration-unit" data-unit="days">
|
||||||
|
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
|
||||||
|
<DurationPart name="days-label">{duration.days.label}</DurationPart>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="client-duration-word-row is-clock">
|
||||||
|
{wordClockDuration.map(([name, part]) => (
|
||||||
|
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||||
|
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||||||
|
? <AnimatedSeconds value={part.value} padded={false} />
|
||||||
|
: part.value}</DurationPart>{' '}
|
||||||
|
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
|
<span className="client-tooltip" role="tooltip">
|
||||||
|
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p key="hint">
|
||||||
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||||
|
<div className="client-access-point">
|
||||||
|
{!isGateway && (
|
||||||
|
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||||||
|
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
||||||
|
<span className={gatewayDirect ? 'is-active' : ''}>
|
||||||
|
Через <a href={gatewayUiOrigin || `http://${gatewayRouteAddress}:3456`}>Harbor Gateway</a> · {gatewayRouteAddress}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<strong className="client-proxy-address">
|
||||||
|
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
|
</strong>
|
||||||
|
<div className="client-proxy-actions">
|
||||||
|
{proxyKinds.map(([kind, label]) => (
|
||||||
|
<button
|
||||||
|
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key={kind}
|
||||||
|
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||||
|
onClick={() => onCopyProxy(kind)}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">{label}</span>
|
||||||
|
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{statusSlot}
|
||||||
|
</section>}
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={confirmingStop}
|
||||||
|
id="stop-connection"
|
||||||
|
kicker="Защита от случайного отключения"
|
||||||
|
title="Отключить VPN?"
|
||||||
|
description="Harbor остановит текущее VPN-подключение. Локальный прокси перестанет передавать трафик до повторного включения."
|
||||||
|
cancelLabel="Оставить включённым"
|
||||||
|
confirmLabel="Отключить VPN"
|
||||||
|
busy={blocked}
|
||||||
|
onCancel={() => setConfirmingStop(false)}
|
||||||
|
onConfirm={stopConnection}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ConnectionPanel } from './ConnectionPanel.js';
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { formatByteString, formatLastSeen } from '../../utils/format.js';
|
||||||
|
import { TrafficChart } from './TrafficChart.js';
|
||||||
|
import {
|
||||||
|
parseDeviceSnapshot,
|
||||||
|
type Device,
|
||||||
|
type DevicePolicy,
|
||||||
|
type DeviceSnapshot,
|
||||||
|
} from './deviceSnapshot.js';
|
||||||
|
|
||||||
|
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||||
|
|
||||||
|
interface DevicesFeatureOptions {
|
||||||
|
isGateway: boolean;
|
||||||
|
listDevices: () => Promise<unknown>;
|
||||||
|
refreshDevices: () => Promise<unknown>;
|
||||||
|
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||||
|
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestError {
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestError(value: unknown): RequestError {
|
||||||
|
if (!record(value)) return {};
|
||||||
|
return { code: typeof value.code === 'string' ? value.code : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevicesFeature({
|
||||||
|
isGateway,
|
||||||
|
listDevices,
|
||||||
|
refreshDevices,
|
||||||
|
updateDevice: requestDeviceUpdate,
|
||||||
|
setDevicePolicy,
|
||||||
|
}: DevicesFeatureOptions) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [snapshot, setSnapshot] = useState<DeviceSnapshot | null>(null);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'refreshing' | 'ready' | 'error'>('idle');
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
|
const [savingId, setSavingId] = useState('');
|
||||||
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
function publish(value: unknown) {
|
||||||
|
const next = parseDeviceSnapshot(value);
|
||||||
|
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(quiet = false, discover = false) {
|
||||||
|
if (!isGateway) return;
|
||||||
|
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
publish(await (discover ? refreshDevices() : listDevices()));
|
||||||
|
setError(null);
|
||||||
|
setStatus('ready');
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError);
|
||||||
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
setRefreshCycle((cycle) => cycle + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDevice(device: Device, patch: Record<string, unknown>) {
|
||||||
|
if (!snapshot) return false;
|
||||||
|
setSavingId(device.id);
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
|
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
||||||
|
throw caught;
|
||||||
|
}
|
||||||
|
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
setError(null);
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePolicy(device: Device, mode: DevicePolicy) {
|
||||||
|
if (!snapshot) return;
|
||||||
|
setSavingId(device.id);
|
||||||
|
try {
|
||||||
|
let next: DeviceSnapshot;
|
||||||
|
try {
|
||||||
|
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, snapshot.revision));
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||||
|
const latest = parseDeviceSnapshot(await listDevices());
|
||||||
|
publish(latest);
|
||||||
|
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||||
|
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw caught;
|
||||||
|
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, latest.revision));
|
||||||
|
}
|
||||||
|
publish(next);
|
||||||
|
setError(null);
|
||||||
|
} catch (caught) {
|
||||||
|
if (requestError(caught).code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||||||
|
try {
|
||||||
|
publish(parseDeviceSnapshot(await listDevices()));
|
||||||
|
} catch {
|
||||||
|
// Keep the policy error as the actionable result.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(caught);
|
||||||
|
} finally {
|
||||||
|
setSavingId('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGateway) return undefined;
|
||||||
|
load();
|
||||||
|
return undefined;
|
||||||
|
}, [isGateway]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGateway || refreshing || status === 'loading') return undefined;
|
||||||
|
const timer = setTimeout(() => load(true), DEVICE_AUTO_REFRESH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isGateway, refreshCycle, refreshing, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||||
|
const closeDevices = (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);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeDevices);
|
||||||
|
document.addEventListener('keydown', closeDevices);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('pointerdown', closeDevices);
|
||||||
|
document.removeEventListener('keydown', closeDevices);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
snapshot,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refreshing,
|
||||||
|
refreshCycle,
|
||||||
|
savingId,
|
||||||
|
panelRef,
|
||||||
|
toggleRef,
|
||||||
|
closeRef,
|
||||||
|
load,
|
||||||
|
updateDevice,
|
||||||
|
updatePolicy,
|
||||||
|
close: () => setIsOpen(false),
|
||||||
|
toggle: () => setIsOpen((open) => !open),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
|
||||||
|
|
||||||
|
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
|
||||||
|
return <button
|
||||||
|
ref={feature.toggleRef}
|
||||||
|
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={feature.isOpen}
|
||||||
|
aria-controls="client-devices"
|
||||||
|
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
|
||||||
|
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
|
||||||
|
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
|
||||||
|
</svg>
|
||||||
|
<span>Устройства</span>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
|
||||||
|
const globalTraffic = feature.snapshot?.traffic;
|
||||||
|
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|
||||||
|
|| feature.snapshot?.source?.traffic?.proxy?.error
|
||||||
|
|| (feature.status === 'error' ? feature.error : null);
|
||||||
|
const trafficFreshness = globalTraffic?.observedAt
|
||||||
|
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
|
||||||
|
: 'Нет данных';
|
||||||
|
|
||||||
|
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
|
||||||
|
<div className="client-gateway-traffic-heading">
|
||||||
|
<span>Учтено Harbor</span>
|
||||||
|
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="client-gateway-traffic-chart">
|
||||||
|
<TrafficChart
|
||||||
|
samples={globalTraffic?.history || []}
|
||||||
|
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
|
||||||
|
routeLabel="Gateway"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
|
||||||
|
{trafficSourceError
|
||||||
|
? `Трафик не обновляется · последние данные ${trafficFreshness}`
|
||||||
|
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -1,24 +1,45 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, {
|
||||||
import { api } from '../api.js';
|
useEffect,
|
||||||
import { copyText } from '../utils/clientControls.js';
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { copyText } from '../../utils/clientControls.js';
|
||||||
import {
|
import {
|
||||||
byteString,
|
byteString,
|
||||||
formatByteString,
|
formatByteString,
|
||||||
formatLastSeen,
|
formatLastSeen,
|
||||||
positiveByteDelta,
|
positiveByteDelta,
|
||||||
stabilizeDevicesByTraffic,
|
stabilizeDevicesByTraffic,
|
||||||
} from '../utils/format.js';
|
} from '../../utils/format.js';
|
||||||
import { TrafficChart } from './TrafficChart.jsx';
|
import { TrafficChart } from './TrafficChart.js';
|
||||||
|
import { type Device } from './deviceSnapshot.js';
|
||||||
|
import type { DevicesFeature } from './DevicesFeature.js';
|
||||||
|
|
||||||
const DEVICE_MOVE_MS = 520;
|
const DEVICE_MOVE_MS = 520;
|
||||||
const COPY_FEEDBACK_MS = 800;
|
const COPY_FEEDBACK_MS = 800;
|
||||||
const TRAFFIC_DELTA_MS = 2_200;
|
const TRAFFIC_DELTA_MS = 2_200;
|
||||||
|
|
||||||
function Tooltip({ children }) {
|
interface TrafficDelta {
|
||||||
|
gateway?: string;
|
||||||
|
proxy?: string;
|
||||||
|
total?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMessage(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
const message: unknown = Reflect.get(value, 'message');
|
||||||
|
return typeof message === 'string' ? message : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ children }: { children: ReactNode }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextMorph({ from, to }) {
|
function TextMorph({ from, to }: { from: string; to: string }) {
|
||||||
const anchor = from.length >= to.length ? from : to;
|
const anchor = from.length >= to.length ? from : to;
|
||||||
return <span className="client-text-morph" aria-hidden="true">
|
return <span className="client-text-morph" aria-hidden="true">
|
||||||
<span className="client-text-morph-anchor">{anchor}</span>
|
<span className="client-text-morph-anchor">{anchor}</span>
|
||||||
@@ -27,41 +48,55 @@ function TextMorph({ from, to }) {
|
|||||||
</span>;
|
</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficValue({ value, delta }) {
|
function TrafficValue({ value, delta }: { value: string; delta?: string }) {
|
||||||
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
|
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
|
||||||
<span className="is-total">{value}</span>
|
<span className="is-total">{value}</span>
|
||||||
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
|
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
|
||||||
</strong>;
|
</strong>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevicesPanel({
|
export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
||||||
open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle,
|
const {
|
||||||
onLoad, onSnapshot, onError,
|
isOpen: open,
|
||||||
}) {
|
panelRef,
|
||||||
|
closeRef,
|
||||||
|
snapshot,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
refreshing,
|
||||||
|
refreshCycle,
|
||||||
|
savingId,
|
||||||
|
load: onLoad,
|
||||||
|
updateDevice,
|
||||||
|
updatePolicy,
|
||||||
|
close: onClose,
|
||||||
|
} = feature;
|
||||||
const [editingId, setEditingId] = useState('');
|
const [editingId, setEditingId] = useState('');
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
const [savingId, setSavingId] = useState('');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [sortDirection, setSortDirection] = useState('desc');
|
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
|
||||||
const [trafficScale, setTrafficScale] = useState('linear');
|
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
|
||||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
|
||||||
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||||
const [trafficDeltas, setTrafficDeltas] = useState({});
|
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
||||||
const deviceNodes = useRef(new Map());
|
const deviceNodes = useRef(new Map<string, HTMLElement>());
|
||||||
const previousPositions = useRef(new Map());
|
const previousPositions = useRef(new Map<string, DOMRect>());
|
||||||
const previousOrder = useRef([]);
|
const previousOrder = useRef<string[]>([]);
|
||||||
const previousScrollTop = useRef(0);
|
const previousScrollTop = useRef(0);
|
||||||
const movementAnimations = useRef(new Map());
|
const movementAnimations = useRef(new Map<string, Animation>());
|
||||||
const previousTraffic = useRef(new Map());
|
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
|
||||||
const aliasBaseline = useRef({ id: '', value: '' });
|
const aliasBaseline = useRef({ id: '', value: '' });
|
||||||
const copyTimer = useRef(null);
|
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const trafficDeltaTimer = useRef(null);
|
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
|
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
|
||||||
const devices = useMemo(
|
const devices = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const previousIds = trafficOrder.current.direction === sortDirection
|
const previousIds = trafficOrder.current.direction === sortDirection
|
||||||
? trafficOrder.current.ids
|
? trafficOrder.current.ids
|
||||||
: [];
|
: [];
|
||||||
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds);
|
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as {
|
||||||
|
ids: string[];
|
||||||
|
devices: Device[];
|
||||||
|
};
|
||||||
trafficOrder.current = { direction: sortDirection, ids: result.ids };
|
trafficOrder.current = { direction: sortDirection, ids: result.ids };
|
||||||
return result.devices;
|
return result.devices;
|
||||||
},
|
},
|
||||||
@@ -69,20 +104,20 @@ export function DevicesPanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(copyTimer.current);
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||||
clearTimeout(trafficDeltaTimer.current);
|
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
previousTraffic.current.clear();
|
previousTraffic.current.clear();
|
||||||
clearTimeout(trafficDeltaTimer.current);
|
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||||
setTrafficDeltas({});
|
setTrafficDeltas({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = new Map();
|
const next = new Map<string, { gateway: bigint; proxy: bigint }>();
|
||||||
const deltas = {};
|
const deltas: Record<string, TrafficDelta> = {};
|
||||||
for (const device of snapshot?.devices || []) {
|
for (const device of snapshot?.devices || []) {
|
||||||
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
||||||
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
||||||
@@ -98,7 +133,7 @@ export function DevicesPanel({
|
|||||||
previousTraffic.current = next;
|
previousTraffic.current = next;
|
||||||
if (!Object.keys(deltas).length) return;
|
if (!Object.keys(deltas).length) return;
|
||||||
setTrafficDeltas(deltas);
|
setTrafficDeltas(deltas);
|
||||||
clearTimeout(trafficDeltaTimer.current);
|
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||||
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
||||||
}, [snapshot?.devices, open]);
|
}, [snapshot?.devices, open]);
|
||||||
|
|
||||||
@@ -111,7 +146,7 @@ export function DevicesPanel({
|
|||||||
movementAnimations.current.clear();
|
movementAnimations.current.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const positions = new Map();
|
const positions = new Map<string, DOMRect>();
|
||||||
for (const [id, node] of deviceNodes.current) {
|
for (const [id, node] of deviceNodes.current) {
|
||||||
movementAnimations.current.get(id)?.cancel();
|
movementAnimations.current.get(id)?.cancel();
|
||||||
positions.set(id, node.getBoundingClientRect());
|
positions.set(id, node.getBoundingClientRect());
|
||||||
@@ -143,34 +178,7 @@ export function DevicesPanel({
|
|||||||
previousScrollTop.current = currentScrollTop;
|
previousScrollTop.current = currentScrollTop;
|
||||||
}, [devices, open, panelRef]);
|
}, [devices, open, panelRef]);
|
||||||
|
|
||||||
async function updateDevice(device, patch) {
|
async function saveAlias(device: Device) {
|
||||||
setSavingId(device.id);
|
|
||||||
try {
|
|
||||||
let next;
|
|
||||||
try {
|
|
||||||
next = await api.devices.update(device.id, patch, snapshot.revision);
|
|
||||||
} catch (requestError) {
|
|
||||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
|
||||||
const latest = await api.devices.list();
|
|
||||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
|
||||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
|
||||||
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
|
||||||
throw requestError;
|
|
||||||
}
|
|
||||||
next = await api.devices.update(device.id, patch, latest.revision);
|
|
||||||
}
|
|
||||||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
|
||||||
onError(null);
|
|
||||||
return true;
|
|
||||||
} catch (requestError) {
|
|
||||||
onError(requestError);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setSavingId('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAlias(device) {
|
|
||||||
const nextAlias = alias.trim();
|
const nextAlias = alias.trim();
|
||||||
if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) {
|
if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) {
|
||||||
setEditingId((current) => current === device.id ? '' : current);
|
setEditingId((current) => current === device.id ? '' : current);
|
||||||
@@ -180,40 +188,9 @@ export function DevicesPanel({
|
|||||||
setEditingId((current) => current === device.id ? '' : current);
|
setEditingId((current) => current === device.id ? '' : current);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePolicy(device, mode) {
|
async function copyDeviceIp(device: Device) {
|
||||||
setSavingId(device.id);
|
|
||||||
try {
|
|
||||||
let next;
|
|
||||||
try {
|
|
||||||
next = await api.devices.setPolicy(device.id, mode, snapshot.revision);
|
|
||||||
} catch (requestError) {
|
|
||||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
|
||||||
const latest = await api.devices.list();
|
|
||||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
|
||||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
|
||||||
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
|
|
||||||
next = await api.devices.setPolicy(device.id, mode, latest.revision);
|
|
||||||
}
|
|
||||||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
|
||||||
onError(null);
|
|
||||||
} catch (requestError) {
|
|
||||||
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
|
||||||
try {
|
|
||||||
const latest = await api.devices.list();
|
|
||||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
|
||||||
} catch {
|
|
||||||
// Keep the policy error as the actionable result.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onError(requestError);
|
|
||||||
} finally {
|
|
||||||
setSavingId('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyDeviceIp(device) {
|
|
||||||
if (!device.ip) return;
|
if (!device.ip) return;
|
||||||
clearTimeout(copyTimer.current);
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||||
try {
|
try {
|
||||||
await copyText(device.ip);
|
await copyText(device.ip);
|
||||||
setCopyFeedback({ id: device.id, failed: false });
|
setCopyFeedback({ id: device.id, failed: false });
|
||||||
@@ -223,7 +200,7 @@ export function DevicesPanel({
|
|||||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditing(device) {
|
function startEditing(device: Device) {
|
||||||
const value = device.alias || device.hostname || '';
|
const value = device.alias || device.hostname || '';
|
||||||
aliasBaseline.current = { id: device.id, value };
|
aliasBaseline.current = { id: device.id, value };
|
||||||
setEditingId(device.id);
|
setEditingId(device.id);
|
||||||
@@ -294,29 +271,29 @@ export function DevicesPanel({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{snapshot?.source?.error && (
|
{Boolean(snapshot?.source?.error) && (
|
||||||
<p className="client-devices-source" role="status">
|
<p className="client-devices-source" role="status">
|
||||||
Список временно не обновляется. Показаны последние сохранённые данные.
|
Список временно не обновляется. Показаны последние сохранённые данные.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{snapshot?.source?.traffic?.error && (
|
{Boolean(snapshot?.source?.traffic?.error) && (
|
||||||
<p className="client-devices-source" role="status">
|
<p className="client-devices-source" role="status">
|
||||||
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{snapshot?.source?.traffic?.proxy?.error && (
|
{Boolean(snapshot?.source?.traffic?.proxy?.error) && (
|
||||||
<p className="client-devices-source" role="status">
|
<p className="client-devices-source" role="status">
|
||||||
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
|
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{snapshot?.source?.policy?.error && (
|
{Boolean(snapshot?.source?.policy?.error) && (
|
||||||
<p className="client-devices-source" role="status">
|
<p className="client-devices-source" role="status">
|
||||||
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
|
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{Boolean(error) && (
|
||||||
<div className="client-devices-error" role="alert">
|
<div className="client-devices-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{requestMessage(error)}</span>
|
||||||
<button type="button" onClick={() => onLoad()}>Повторить</button>
|
<button type="button" onClick={() => onLoad()}>Повторить</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -395,8 +372,8 @@ export function DevicesPanel({
|
|||||||
<input
|
<input
|
||||||
className="client-device-alias-input"
|
className="client-device-alias-input"
|
||||||
value={alias}
|
value={alias}
|
||||||
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` }}
|
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` } as CSSProperties}
|
||||||
maxLength="64"
|
maxLength={64}
|
||||||
autoFocus
|
autoFocus
|
||||||
aria-label="Название устройства"
|
aria-label="Название устройства"
|
||||||
aria-busy={saving}
|
aria-busy={saving}
|
||||||
@@ -440,9 +417,9 @@ export function DevicesPanel({
|
|||||||
</button>
|
</button>
|
||||||
<Tooltip>Изменить название</Tooltip>
|
<Tooltip>Изменить название</Tooltip>
|
||||||
</span>}
|
</span>}
|
||||||
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex={0}>
|
||||||
<time
|
<time
|
||||||
dateTime={device.lastSeenAt}
|
dateTime={device.lastSeenAt || undefined}
|
||||||
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
|
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
|
||||||
>
|
>
|
||||||
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
|
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
|
||||||
@@ -453,7 +430,7 @@ export function DevicesPanel({
|
|||||||
<span
|
<span
|
||||||
className="client-device-traffic"
|
className="client-device-traffic"
|
||||||
role="group"
|
role="group"
|
||||||
tabIndex="0"
|
tabIndex={0}
|
||||||
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
|
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
|
||||||
>
|
>
|
||||||
<span className="client-device-traffic-total" aria-hidden="true">
|
<span className="client-device-traffic-total" aria-hidden="true">
|
||||||
@@ -487,7 +464,7 @@ export function DevicesPanel({
|
|||||||
<TrafficChart
|
<TrafficChart
|
||||||
samples={device.trafficHistory || []}
|
samples={device.trafficHistory || []}
|
||||||
scale={trafficScale}
|
scale={trafficScale}
|
||||||
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||||
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
|
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
|
||||||
pinned={device.pinned}
|
pinned={device.pinned}
|
||||||
/>
|
/>
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
import React, { useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
byteString,
|
byteString,
|
||||||
formatByteString,
|
formatByteString,
|
||||||
trafficAxisMid,
|
trafficAxisMid,
|
||||||
trafficScaleRatio,
|
trafficScaleRatio,
|
||||||
} from '../utils/format.js';
|
} from '../../utils/format.js';
|
||||||
|
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||||
|
|
||||||
const TRAFFIC_CHART_HEADROOM = 10;
|
const TRAFFIC_CHART_HEADROOM = 10;
|
||||||
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||||
|
|
||||||
function chartTime(value) {
|
function chartTime(value: string) {
|
||||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function smoothTrafficPath(points, valueKey) {
|
interface ChartPoint {
|
||||||
|
sample: TrafficSample;
|
||||||
|
x: number;
|
||||||
|
gateway: bigint;
|
||||||
|
proxy: bigint;
|
||||||
|
gatewayY: number;
|
||||||
|
proxyY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoveredPoint extends ChartPoint {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') {
|
||||||
if (!points.length) return '';
|
if (!points.length) return '';
|
||||||
return points.slice(1).reduce((path, point, index) => {
|
return points.slice(1).reduce((path, point, index) => {
|
||||||
const previous = points[index];
|
const previous = points[index];
|
||||||
@@ -25,7 +40,7 @@ function smoothTrafficPath(points, valueKey) {
|
|||||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficSeriesMax(samples) {
|
function trafficSeriesMax(samples: TrafficSample[]) {
|
||||||
return samples.reduce((largest, sample) => {
|
return samples.reduce((largest, sample) => {
|
||||||
const gateway = byteString(sample.gatewayBytes);
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
const proxy = byteString(sample.proxyBytes);
|
const proxy = byteString(sample.proxyBytes);
|
||||||
@@ -35,10 +50,22 @@ function trafficSeriesMax(samples) {
|
|||||||
}, 0n);
|
}, 0n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel, pinned = true }) {
|
export function TrafficChart({
|
||||||
const [hovered, setHovered] = useState(null);
|
samples,
|
||||||
const previousPoints = useRef([]);
|
scale = 'linear',
|
||||||
const previousScale = useRef(scale);
|
capacity,
|
||||||
|
routeLabel,
|
||||||
|
pinned = true,
|
||||||
|
}: {
|
||||||
|
samples: TrafficSample[];
|
||||||
|
scale?: TrafficScale;
|
||||||
|
capacity: number;
|
||||||
|
routeLabel: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
}) {
|
||||||
|
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
|
||||||
|
const previousPoints = useRef<ChartPoint[]>([]);
|
||||||
|
const previousScale = useRef<TrafficScale>(scale);
|
||||||
const max = trafficSeriesMax(samples);
|
const max = trafficSeriesMax(samples);
|
||||||
const mid = trafficAxisMid(max, scale);
|
const mid = trafficAxisMid(max, scale);
|
||||||
const firstSlot = capacity - samples.length;
|
const firstSlot = capacity - samples.length;
|
||||||
@@ -68,7 +95,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
|
|||||||
previousScale.current = scale;
|
previousScale.current = scale;
|
||||||
}, [points, scale]);
|
}, [points, scale]);
|
||||||
|
|
||||||
function trackPointer(event) {
|
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
||||||
const index = slot - firstSlot;
|
const index = slot - firstSlot;
|
||||||
@@ -103,7 +130,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
|
|||||||
style={{
|
style={{
|
||||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||||
}}
|
} as CSSProperties}
|
||||||
>
|
>
|
||||||
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||||||
<span className="is-max">{formatByteString(max)}</span>
|
<span className="is-max">{formatByteString(max)}</span>
|
||||||
@@ -117,7 +144,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
|
|||||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||||
<line x1="0" x2="100" y1="100" y2="100" />
|
<line x1="0" x2="100" y1="100" y2="100" />
|
||||||
</g>}
|
</g>}
|
||||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
|
||||||
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
|
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
|
||||||
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||||
</path>}
|
</path>}
|
||||||
@@ -142,7 +169,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
|
|||||||
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||||
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
||||||
<span>15 с</span>
|
<span>15 с</span>
|
||||||
<time dateTime={samples.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
|
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time>
|
||||||
</span>}
|
</span>}
|
||||||
{tooltip}
|
{tooltip}
|
||||||
</span>;
|
</span>;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
export type ByteValue = string;
|
||||||
|
export type TrafficScale = 'linear' | 'log';
|
||||||
|
export type DevicePolicy = 'vpn' | 'direct';
|
||||||
|
type DeviceStatus = 'online' | 'recent' | 'offline';
|
||||||
|
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
||||||
|
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
|
||||||
|
|
||||||
|
export interface TrafficSample extends Record<string, unknown> {
|
||||||
|
observedAt: string;
|
||||||
|
gatewayBytes: ByteValue;
|
||||||
|
proxyBytes: ByteValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
alias: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
status: DeviceStatus;
|
||||||
|
pinned: boolean;
|
||||||
|
downloadBytes: ByteValue;
|
||||||
|
uploadBytes: ByteValue;
|
||||||
|
proxyDownloadBytes: ByteValue;
|
||||||
|
proxyUploadBytes: ByteValue;
|
||||||
|
policyStatus: DevicePolicyStatus;
|
||||||
|
policyError: string | null;
|
||||||
|
desiredPolicy: DevicePolicy;
|
||||||
|
appliedPolicy: DevicePolicy;
|
||||||
|
confidence: DeviceConfidence;
|
||||||
|
trafficHistory: TrafficSample[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotSource extends Record<string, unknown> {
|
||||||
|
kind: 'neighbor';
|
||||||
|
error: unknown;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
traffic: {
|
||||||
|
error: unknown;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
proxy: {
|
||||||
|
error: unknown;
|
||||||
|
lastObservedAt: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
policy: {
|
||||||
|
error: unknown;
|
||||||
|
lastAppliedAt: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSnapshot extends Record<string, unknown> {
|
||||||
|
revision: number;
|
||||||
|
devices: Device[];
|
||||||
|
trafficHistoryCapacity: number;
|
||||||
|
traffic: {
|
||||||
|
gatewayBytes: ByteValue;
|
||||||
|
proxyBytes: ByteValue;
|
||||||
|
totalBytes: ByteValue;
|
||||||
|
gatewayObservedAt: string | null;
|
||||||
|
proxyObservedAt: string | null;
|
||||||
|
observedAt: string | null;
|
||||||
|
history: TrafficSample[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
source: SnapshotSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown): value is string | null {
|
||||||
|
return value === null || typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length > 0 && Number.isFinite(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableTimestamp(value: unknown): value is string | null {
|
||||||
|
return value === null || timestamp(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes(value: unknown): value is ByteValue {
|
||||||
|
return typeof value === 'string' && /^\d+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTrafficSample(value: unknown): value is TrafficSample {
|
||||||
|
return record(value)
|
||||||
|
&& timestamp(value.observedAt)
|
||||||
|
&& bytes(value.gatewayBytes)
|
||||||
|
&& bytes(value.proxyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validHistory(value: unknown): value is TrafficSample[] {
|
||||||
|
return Array.isArray(value) && value.every(validTrafficSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDevice(value: unknown): value is Device {
|
||||||
|
return record(value)
|
||||||
|
&& typeof value.id === 'string'
|
||||||
|
&& /^dev_[a-f0-9]{16}$/.test(value.id)
|
||||||
|
&& nullableString(value.alias)
|
||||||
|
&& nullableString(value.hostname)
|
||||||
|
&& nullableString(value.ip)
|
||||||
|
&& nullableTimestamp(value.lastSeenAt)
|
||||||
|
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
||||||
|
&& typeof value.pinned === 'boolean'
|
||||||
|
&& bytes(value.downloadBytes)
|
||||||
|
&& bytes(value.uploadBytes)
|
||||||
|
&& bytes(value.proxyDownloadBytes)
|
||||||
|
&& bytes(value.proxyUploadBytes)
|
||||||
|
&& (value.policyStatus === 'applied' || value.policyStatus === 'applying'
|
||||||
|
|| value.policyStatus === 'pending' || value.policyStatus === 'failed')
|
||||||
|
&& nullableString(value.policyError)
|
||||||
|
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
|
||||||
|
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
|
||||||
|
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
|
||||||
|
&& validHistory(value.trafficHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSource(value: unknown): value is SnapshotSource {
|
||||||
|
return record(value)
|
||||||
|
&& value.kind === 'neighbor'
|
||||||
|
&& Object.hasOwn(value, 'error')
|
||||||
|
&& nullableTimestamp(value.lastObservedAt)
|
||||||
|
&& record(value.traffic)
|
||||||
|
&& Object.hasOwn(value.traffic, 'error')
|
||||||
|
&& nullableTimestamp(value.traffic.lastObservedAt)
|
||||||
|
&& record(value.traffic.proxy)
|
||||||
|
&& Object.hasOwn(value.traffic.proxy, 'error')
|
||||||
|
&& nullableTimestamp(value.traffic.proxy.lastObservedAt)
|
||||||
|
&& record(value.policy)
|
||||||
|
&& Object.hasOwn(value.policy, 'error')
|
||||||
|
&& nullableTimestamp(value.policy.lastAppliedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
|
||||||
|
return record(value)
|
||||||
|
&& bytes(value.gatewayBytes)
|
||||||
|
&& bytes(value.proxyBytes)
|
||||||
|
&& bytes(value.totalBytes)
|
||||||
|
&& nullableTimestamp(value.gatewayObservedAt)
|
||||||
|
&& nullableTimestamp(value.proxyObservedAt)
|
||||||
|
&& nullableTimestamp(value.observedAt)
|
||||||
|
&& validHistory(value.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
||||||
|
if (!record(value)
|
||||||
|
|| !Number.isSafeInteger(value.revision)
|
||||||
|
|| typeof value.revision !== 'number'
|
||||||
|
|| value.revision < 0
|
||||||
|
|| !Array.isArray(value.devices)
|
||||||
|
|| !value.devices.every(validDevice)
|
||||||
|
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|
||||||
|
|| typeof value.trafficHistoryCapacity !== 'number'
|
||||||
|
|| value.trafficHistoryCapacity <= 0
|
||||||
|
|| !validTraffic(value.traffic)
|
||||||
|
|| !validSource(value.source)) {
|
||||||
|
throw new TypeError('Harbor device inventory returned an invalid snapshot');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
|
||||||
|
assertDeviceSnapshot(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { DevicesPanel } from './DevicesPanel.js';
|
||||||
|
export {
|
||||||
|
DevicesToggle,
|
||||||
|
GatewayTrafficSummary,
|
||||||
|
useDevicesFeature,
|
||||||
|
} from './DevicesFeature.js';
|
||||||
+121
-45
@@ -1,44 +1,95 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
|
||||||
import {
|
import {
|
||||||
CONNECTIVITY_IP_SOURCES,
|
CONNECTIVITY_IP_SOURCES,
|
||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
} from '../../shared/connectivityDiagnostics.js';
|
} from '../../../shared/connectivityDiagnostics.js';
|
||||||
|
import {
|
||||||
|
parseConnectivityResult,
|
||||||
|
type ConnectivityResult,
|
||||||
|
type DiagnosticPath,
|
||||||
|
type DiagnosticSiteResult,
|
||||||
|
} from './connectivityResult.js';
|
||||||
|
import type { DiagnosticsFeature } from './DiagnosticsFeature.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';
|
||||||
|
|
||||||
function readCustomServices() {
|
interface DiagnosticService extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpSourceDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
family: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusValue = [className: string, label: string];
|
||||||
|
type RunConnectivityDiagnostics = (
|
||||||
|
services: DiagnosticService[],
|
||||||
|
target: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCustomService(value: unknown): value is DiagnosticService {
|
||||||
|
return record(value)
|
||||||
|
&& typeof Reflect.get(value, 'id') === 'string'
|
||||||
|
&& String(Reflect.get(value, 'id')).startsWith('custom-')
|
||||||
|
&& typeof Reflect.get(value, 'label') === 'string'
|
||||||
|
&& typeof Reflect.get(value, 'url') === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDetails(value: unknown) {
|
||||||
|
if (!record(value)) return { message: undefined, retryable: false };
|
||||||
|
const message = Reflect.get(value, 'message');
|
||||||
|
return {
|
||||||
|
message: typeof message === 'string' ? message : undefined,
|
||||||
|
retryable: Boolean(Reflect.get(value, 'retryable')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCustomServices(): DiagnosticService[] {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
|
const value: unknown = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
|
||||||
return Array.isArray(value)
|
return Array.isArray(value)
|
||||||
? value.filter((service) => (
|
? value.filter(validCustomService).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
|
||||||
service
|
|
||||||
&& typeof service.id === 'string'
|
|
||||||
&& service.id.startsWith('custom-')
|
|
||||||
&& typeof service.label === 'string'
|
|
||||||
&& typeof service.url === 'string'
|
|
||||||
)).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
|
|
||||||
: [];
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readHiddenServices() {
|
function readHiddenServices(): string[] {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
|
const value: unknown = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
|
||||||
return Array.isArray(value)
|
return Array.isArray(value)
|
||||||
? value.filter((id) => CONNECTIVITY_SITES.some((service) => service.id === id))
|
? value.filter((id): id is string => (
|
||||||
|
typeof id === 'string' && CONNECTIVITY_SITES.some((service) => service.id === id)
|
||||||
|
))
|
||||||
: [];
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultStatus(site, pending, available = true) {
|
function resultStatus(
|
||||||
|
site: DiagnosticSiteResult | undefined,
|
||||||
|
pending: boolean,
|
||||||
|
available = true,
|
||||||
|
): StatusValue {
|
||||||
if (!available) return ['is-muted', '—'];
|
if (!available) return ['is-muted', '—'];
|
||||||
if (pending) return ['is-running', 'Тестируем'];
|
if (pending) return ['is-running', 'Тестируем'];
|
||||||
if (!site) return ['is-muted', '—'];
|
if (!site) return ['is-muted', '—'];
|
||||||
@@ -47,7 +98,7 @@ function resultStatus(site, pending, available = true) {
|
|||||||
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
|
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Status({ value, route }) {
|
function Status({ value, route }: { value: StatusValue; route: string }) {
|
||||||
const [className, label] = value;
|
const [className, label] = value;
|
||||||
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>
|
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -55,14 +106,24 @@ function Status({ value, route }) {
|
|||||||
</span>;
|
</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipResult(path, source) {
|
function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) {
|
||||||
if (!path?.available) return null;
|
if (!path?.available) return null;
|
||||||
return source.family === 6
|
return source.family === 6
|
||||||
? path.ipv6Source
|
? path.ipv6Source
|
||||||
: path.ipv4?.sources?.find((item) => item.source === source.id);
|
: path.ipv4?.sources?.find((item) => item.source === source.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IpCell({ path, source, pending, route }) {
|
function IpCell({
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
pending,
|
||||||
|
route,
|
||||||
|
}: {
|
||||||
|
path: DiagnosticPath | undefined;
|
||||||
|
source: IpSourceDefinition;
|
||||||
|
pending: boolean;
|
||||||
|
route: string;
|
||||||
|
}) {
|
||||||
const value = ipResult(path, source);
|
const value = ipResult(path, source);
|
||||||
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
||||||
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
||||||
@@ -71,22 +132,24 @@ function IpCell({ path, source, pending, route }) {
|
|||||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeItems(previous = [], incoming = [], key) {
|
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
|
||||||
const merged = [...previous];
|
const merged = [...previous];
|
||||||
for (const item of incoming) {
|
for (const item of incoming) {
|
||||||
const index = merged.findIndex((value) => value[key] === item[key]);
|
const index = merged.findIndex((value) => key(value) === key(item));
|
||||||
if (index >= 0) merged[index] = item;
|
if (index >= 0) merged[index] = item;
|
||||||
else merged.push(item);
|
else merged.push(item);
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePath(previous, incoming) {
|
function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPath): DiagnosticPath {
|
||||||
const sources = mergeItems(previous?.ipv4?.sources, incoming.ipv4?.sources, 'source');
|
const sources = mergeItems(previous?.ipv4.sources, incoming.ipv4.sources, ({ source }) => source);
|
||||||
const sites = mergeItems(previous?.sites, incoming.sites, 'id');
|
const sites = mergeItems(previous?.sites, incoming.sites, ({ id }) => id);
|
||||||
const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null;
|
const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null;
|
||||||
const ipv6 = ipv6Source?.address || null;
|
const ipv6 = ipv6Source?.address || null;
|
||||||
const addresses = [...new Set(sources.map(({ address }) => address).filter(Boolean))];
|
const addresses = [...new Set(sources
|
||||||
|
.map(({ address }) => address)
|
||||||
|
.filter((address): address is string => Boolean(address)))];
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
...incoming,
|
...incoming,
|
||||||
@@ -100,17 +163,25 @@ function mergePath(previous, incoming) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeResult(previous, incoming) {
|
function mergeResult(previous: ConnectivityResult | null, incoming: ConnectivityResult): ConnectivityResult {
|
||||||
const direct = mergePath(previous?.direct, incoming.direct);
|
const direct = mergePath(previous?.direct, incoming.direct);
|
||||||
const vpn = mergePath(previous?.vpn, incoming.vpn);
|
const vpn = { ...mergePath(previous?.vpn, incoming.vpn), server: incoming.vpn.server };
|
||||||
return { ...incoming, direct, vpn };
|
return { ...incoming, direct, vpn };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
|
export function ConnectivityDiagnosticsPanel({
|
||||||
const [result, setResult] = useState(null);
|
feature,
|
||||||
const [status, setStatus] = useState('idle');
|
runConnectivityDiagnostics,
|
||||||
const [activeTarget, setActiveTarget] = useState(null);
|
isGateway,
|
||||||
const [error, setError] = useState(null);
|
}: {
|
||||||
|
feature: DiagnosticsFeature;
|
||||||
|
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||||
|
isGateway: boolean;
|
||||||
|
}) {
|
||||||
|
const [result, setResult] = useState<ConnectivityResult | null>(null);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
|
||||||
|
const [activeTarget, setActiveTarget] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||||
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -118,9 +189,10 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
const [serviceName, setServiceName] = useState('');
|
const [serviceName, setServiceName] = useState('');
|
||||||
const [serviceUrl, setServiceUrl] = useState('');
|
const [serviceUrl, setServiceUrl] = useState('');
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
const sheetRef = useRef(null);
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
const runnerRef = useRef(null);
|
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||||
const previousTargetRef = useRef(null);
|
const previousTargetRef = useRef<string | null>(null);
|
||||||
|
const requestError = requestDetails(error);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -148,7 +220,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = [...sheet.querySelectorAll('[data-diagnostic-target]')]
|
const row = [...sheet.querySelectorAll<HTMLElement>('[data-diagnostic-target]')]
|
||||||
.find((item) => item.dataset.diagnosticTarget === activeTarget);
|
.find((item) => item.dataset.diagnosticTarget === activeTarget);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const rowRect = row.getBoundingClientRect();
|
const rowRect = row.getBoundingClientRect();
|
||||||
@@ -173,7 +245,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
];
|
];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
setActiveTarget(target);
|
setActiveTarget(target);
|
||||||
const partial = await api.diagnostics.connectivity(customServices, target);
|
const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target));
|
||||||
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
|
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
|
||||||
next = legacyFullResult ? partial : mergeResult(next, partial);
|
next = legacyFullResult ? partial : mergeResult(next, partial);
|
||||||
setResult(next);
|
setResult(next);
|
||||||
@@ -188,7 +260,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addService(event) {
|
function addService(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||||
@@ -205,11 +277,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
setAdding(false);
|
setAdding(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
setFormError(validationError.message || 'Проверьте адрес.');
|
const message = validationError && typeof validationError === 'object' && !Array.isArray(validationError)
|
||||||
|
? Reflect.get(validationError, 'message')
|
||||||
|
: undefined;
|
||||||
|
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeService(serviceId) {
|
function removeService(serviceId: string) {
|
||||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
finishRemoveService(serviceId);
|
finishRemoveService(serviceId);
|
||||||
return;
|
return;
|
||||||
@@ -217,7 +292,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
setRemovingServiceId(serviceId);
|
setRemovingServiceId(serviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRemoveService(serviceId) {
|
function finishRemoveService(serviceId: string) {
|
||||||
const update = () => flushSync(() => {
|
const update = () => flushSync(() => {
|
||||||
if (serviceId === 'draft') {
|
if (serviceId === 'draft') {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
@@ -246,6 +321,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
];
|
];
|
||||||
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
||||||
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||||
|
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -287,10 +363,10 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <div className="client-diagnostics-feedback">
|
{Boolean(error) && <div className="client-diagnostics-feedback">
|
||||||
<div className="client-diagnostics-error" role="alert">
|
<div className="client-diagnostics-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{requestError.message}</span>
|
||||||
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@@ -366,7 +442,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength="40"
|
maxLength={40}
|
||||||
placeholder="Название"
|
placeholder="Название"
|
||||||
aria-label="Название сервиса"
|
aria-label="Название сервиса"
|
||||||
value={serviceName}
|
value={serviceName}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export function useDiagnosticsFeature() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||||
|
const closeDiagnostics = (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);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeDiagnostics);
|
||||||
|
document.addEventListener('keydown', closeDiagnostics);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('pointerdown', closeDiagnostics);
|
||||||
|
document.removeEventListener('keydown', closeDiagnostics);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
panelRef,
|
||||||
|
toggleRef,
|
||||||
|
closeRef,
|
||||||
|
close: () => setIsOpen(false),
|
||||||
|
toggle: () => setIsOpen((open) => !open),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiagnosticsFeature = ReturnType<typeof useDiagnosticsFeature>;
|
||||||
|
|
||||||
|
export function DiagnosticsToggle({
|
||||||
|
feature,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
feature: DiagnosticsFeature;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return <button
|
||||||
|
ref={feature.toggleRef}
|
||||||
|
className={`client-instructions-toggle client-diagnostics-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={feature.isOpen}
|
||||||
|
aria-controls="client-diagnostics"
|
||||||
|
aria-label={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path className="client-rail-diagnostics-base" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||||
|
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||||
|
</svg>
|
||||||
|
<span>Диагностика</span>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export type DiagnosticSiteStatus = 'available' | 'responded' | 'unavailable';
|
||||||
|
|
||||||
|
export interface DiagnosticIpResult extends Record<string, unknown> {
|
||||||
|
source: string;
|
||||||
|
address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticSiteResult extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
status: DiagnosticSiteStatus;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticServer extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticPath extends Record<string, unknown> {
|
||||||
|
available: boolean;
|
||||||
|
internetAvailable: boolean;
|
||||||
|
ipv4: {
|
||||||
|
addresses: string[];
|
||||||
|
sources: DiagnosticIpResult[];
|
||||||
|
};
|
||||||
|
ipv6: string | null;
|
||||||
|
ipv6Source: DiagnosticIpResult | null;
|
||||||
|
sites: DiagnosticSiteResult[];
|
||||||
|
server?: DiagnosticServer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectivityResult extends Record<string, unknown> {
|
||||||
|
direct: DiagnosticPath;
|
||||||
|
vpn: DiagnosticPath & { server: DiagnosticServer | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableNonnegativeNumber(value: unknown): value is number | null {
|
||||||
|
return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIpResult(value: unknown): value is DiagnosticIpResult {
|
||||||
|
return record(value)
|
||||||
|
&& typeof value.source === 'string'
|
||||||
|
&& value.source.length > 0
|
||||||
|
&& (value.address === null || (typeof value.address === 'string' && value.address.length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validSiteResult(value: unknown): value is DiagnosticSiteResult {
|
||||||
|
return record(value)
|
||||||
|
&& typeof value.id === 'string'
|
||||||
|
&& value.id.length > 0
|
||||||
|
&& (value.status === 'available' || value.status === 'responded' || value.status === 'unavailable')
|
||||||
|
&& nullableNonnegativeNumber(value.httpStatus)
|
||||||
|
&& nullableNonnegativeNumber(value.latencyMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validServer(value: unknown): value is DiagnosticServer | null {
|
||||||
|
return value === null || (record(value)
|
||||||
|
&& typeof value.id === 'string'
|
||||||
|
&& typeof value.label === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPath(value: unknown): value is DiagnosticPath {
|
||||||
|
return record(value)
|
||||||
|
&& typeof value.available === 'boolean'
|
||||||
|
&& typeof value.internetAvailable === 'boolean'
|
||||||
|
&& record(value.ipv4)
|
||||||
|
&& Array.isArray(value.ipv4.addresses)
|
||||||
|
&& value.ipv4.addresses.every((address) => typeof address === 'string' && address.length > 0)
|
||||||
|
&& Array.isArray(value.ipv4.sources)
|
||||||
|
&& value.ipv4.sources.every(validIpResult)
|
||||||
|
&& (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0))
|
||||||
|
&& (value.ipv6Source === null || validIpResult(value.ipv6Source))
|
||||||
|
&& Array.isArray(value.sites)
|
||||||
|
&& value.sites.every(validSiteResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConnectivityResult(value: unknown): asserts value is ConnectivityResult {
|
||||||
|
if (!record(value)
|
||||||
|
|| !validPath(value.direct)
|
||||||
|
|| !validPath(value.vpn)
|
||||||
|
|| !Object.hasOwn(value.vpn, 'server')
|
||||||
|
|| !validServer(value.vpn.server)) {
|
||||||
|
throw new TypeError('Harbor connectivity diagnostics returned an invalid result');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConnectivityResult(value: unknown): ConnectivityResult {
|
||||||
|
assertConnectivityResult(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.js';
|
||||||
|
export {
|
||||||
|
DiagnosticsToggle,
|
||||||
|
useDiagnosticsFeature,
|
||||||
|
type DiagnosticsFeature,
|
||||||
|
} from './DiagnosticsFeature.js';
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { copyText } from '../../utils/clientControls.js';
|
||||||
|
import { instructionBlocks } from './instructionBlocks.js';
|
||||||
|
|
||||||
|
interface InstructionLinkStep {
|
||||||
|
before?: string;
|
||||||
|
link: [string, string];
|
||||||
|
after?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstructionCopyAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstructionBlockData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
paragraphs?: string[];
|
||||||
|
steps?: Array<string | InstructionLinkStep>;
|
||||||
|
code?: string;
|
||||||
|
multilineCode?: boolean;
|
||||||
|
copies?: InstructionCopyAction[];
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstructionsFeatureOptions {
|
||||||
|
isGateway: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
controlHost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
|
||||||
|
if (typeof step === 'string') return step;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{step.before}
|
||||||
|
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||||||
|
{step.after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstructionBlock({
|
||||||
|
block,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
block: InstructionBlockData;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
|
||||||
|
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyInstruction(action: InstructionCopyAction) {
|
||||||
|
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||||
|
try {
|
||||||
|
await copyText(action.text);
|
||||||
|
setCopyFeedback({ id: action.id, failed: false });
|
||||||
|
} catch {
|
||||||
|
setCopyFeedback({ id: action.id, failed: true });
|
||||||
|
}
|
||||||
|
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||||
|
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="client-instruction-summary"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span>{block.label}</span>
|
||||||
|
<strong>{block.title}</strong>
|
||||||
|
<small>{block.summary}</small>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||||||
|
<div className="client-instruction-body">
|
||||||
|
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
|
{block.steps && (
|
||||||
|
<ol>
|
||||||
|
{block.steps.map((step) => (
|
||||||
|
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||||||
|
<InstructionStep step={step} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{block.code && (block.multilineCode
|
||||||
|
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
|
||||||
|
: <code>{block.code}</code>)}
|
||||||
|
{block.copies && <div className="client-instruction-copies">
|
||||||
|
{block.copies.map((action) => {
|
||||||
|
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
|
||||||
|
return <div className="client-instruction-copy" key={action.id}>
|
||||||
|
<span>{action.label}</span>
|
||||||
|
<button
|
||||||
|
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyInstruction(action)}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">Скопировать</span>
|
||||||
|
{feedback && <span className="client-copy-feedback" aria-hidden="true">
|
||||||
|
{feedback.failed ? 'Ошибка' : 'Скопировано'}
|
||||||
|
</span>}
|
||||||
|
</button>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<span className="client-live-region" role="status" aria-live="polite">
|
||||||
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||||
|
</span>
|
||||||
|
</div>}
|
||||||
|
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstructionsFeature({
|
||||||
|
isGateway,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
controlHost,
|
||||||
|
}: InstructionsFeatureOptions) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||||
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
|
||||||
|
const openInstruction = guides.find((block) => block.id === openInstructionId);
|
||||||
|
const orderedGuides = openInstruction
|
||||||
|
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
|
||||||
|
: guides;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return undefined;
|
||||||
|
const closeOutside = (event: PointerEvent) => {
|
||||||
|
if (panelRef.current?.contains(event.target as Node)) return;
|
||||||
|
if (toggleRef.current?.contains(event.target as Node)) return;
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOutside);
|
||||||
|
return () => document.removeEventListener('pointerdown', closeOutside);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
function toggleInstruction(id: string) {
|
||||||
|
const update = () => flushSync(() => {
|
||||||
|
setOpenInstructionId((current) => current === id ? '' : id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.startViewTransition(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
openInstructionId,
|
||||||
|
intro,
|
||||||
|
guides: orderedGuides,
|
||||||
|
panelRef,
|
||||||
|
toggleRef,
|
||||||
|
closeRef,
|
||||||
|
close: () => setIsOpen(false),
|
||||||
|
toggle: () => setIsOpen((open) => !open),
|
||||||
|
toggleInstruction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
|
||||||
|
|
||||||
|
export function InstructionsToggle({
|
||||||
|
feature,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
feature: InstructionsFeature;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return <button
|
||||||
|
ref={feature.toggleRef}
|
||||||
|
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={feature.isOpen}
|
||||||
|
aria-controls="client-instructions"
|
||||||
|
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
|
||||||
|
<path d="M12 11v5M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<span>Как использовать</span>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstructionsPanel({
|
||||||
|
feature,
|
||||||
|
isGateway,
|
||||||
|
}: {
|
||||||
|
feature: InstructionsFeature;
|
||||||
|
isGateway: boolean;
|
||||||
|
}) {
|
||||||
|
return <aside
|
||||||
|
ref={feature.panelRef}
|
||||||
|
id="client-instructions"
|
||||||
|
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
|
||||||
|
aria-labelledby="instructions-title"
|
||||||
|
aria-hidden={!feature.isOpen}
|
||||||
|
inert={!feature.isOpen ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-drawer-sheet client-instructions-sheet">
|
||||||
|
<button
|
||||||
|
ref={feature.closeRef}
|
||||||
|
className="client-drawer-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть инструкции"
|
||||||
|
onClick={feature.close}
|
||||||
|
>×</button>
|
||||||
|
<header className="client-instructions-header">
|
||||||
|
<span>Подключение</span>
|
||||||
|
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||||
|
<div className="client-instructions-intro">
|
||||||
|
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="client-instruction-list">
|
||||||
|
{feature.guides.map((block) => (
|
||||||
|
<InstructionBlock
|
||||||
|
block={block}
|
||||||
|
key={block.id}
|
||||||
|
open={block.id === feature.openInstructionId}
|
||||||
|
onToggle={() => feature.toggleInstruction(block.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
InstructionsPanel,
|
||||||
|
InstructionsToggle,
|
||||||
|
useInstructionsFeature,
|
||||||
|
type InstructionsFeature,
|
||||||
|
} from './InstructionsFeature.js';
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
|
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
|
||||||
|
|
||||||
export function instructionBlocks({ isGateway, host, port, controlHost }) {
|
export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
||||||
|
isGateway: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
controlHost: string;
|
||||||
|
}) {
|
||||||
const httpProxy = `http://${host}:${port}`;
|
const httpProxy = `http://${host}:${port}`;
|
||||||
const socksProxy = `socks5://${host}:${port}`;
|
const socksProxy = `socks5://${host}:${port}`;
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import dashboard from '../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
|
import dashboard from '../../../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
|
||||||
|
|
||||||
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
|
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
|
||||||
|
|
||||||
export function prometheusScrapeConfig(controlHost) {
|
export function prometheusScrapeConfig(controlHost: string) {
|
||||||
return `scrape_configs:
|
return `scrape_configs:
|
||||||
- job_name: harbor_gateway
|
- job_name: harbor_gateway
|
||||||
scrape_interval: 30s
|
scrape_interval: 30s
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user