From 34d8b681ad2ece804f8f54c43e55c112bd61c1b3 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 9 Aug 2026 00:41:52 +0300 Subject: [PATCH] Refactor VPN proxy client implementation --- .codex/skills/manage-harbor-versions/SKILL.md | 4 +- .dockerignore | 1 + .gitea/workflows/gateway-build.yml | 98 +- .gitignore | 1 + Dockerfile | 17 +- Dockerfile.client | 13 +- README.md | 2 +- docs/product/error-contract.md | 2 +- docs/product/frontend-operations.md | 2 +- entrypoint.client.sh | 2 +- entrypoint.sh | 8 +- index.html | 2 +- package-lock.json | 505 ++ package.json | 21 +- scripts/build-on-107-deploy-111.sh | 3 +- scripts/check-import-boundaries.mjs | 151 + scripts/clean-test-dist.mjs | 5 + scripts/harbor-version.mjs | 42 +- scripts/runtime-impact.mjs | 102 + .../adapters/{neighbors.js => neighbors.ts} | 44 +- src/server/{config.js => config.ts} | 4 +- src/server/{dataplane.js => dataplane.ts} | 46 +- ...{dataplaneClient.js => dataplaneClient.ts} | 40 +- .../features/connection/connectionService.ts | 169 + src/server/features/connection/index.ts | 6 + .../connectivityDiagnosticsUseCase.ts | 52 + src/server/features/diagnostics/index.ts | 4 + .../features/routing/gatewayAutoService.ts | 292 + src/server/features/routing/index.ts | 8 + .../features/routing/routeRulesService.ts | 119 + src/server/features/servers/index.ts | 7 + src/server/features/servers/serverHealth.ts | 58 + src/server/features/state/stateService.ts | 60 + src/server/features/subscription/index.ts | 8 + .../subscription/subscriptionService.ts | 289 + .../subscription/validateSubscription.ts | 14 + ...{gatewayPresence.js => gatewayPresence.ts} | 92 +- .../{gatewayRouting.js => gatewayRouting.ts} | 4 +- src/server/http/response.ts | 37 + .../http/routes/connectionRuntimeRoute.ts | 27 + .../routes/connectivityDiagnosticsRoute.ts | 23 + .../http/routes/deviceInventoryRoute.ts | 77 + src/server/http/routes/gatewayAutoRoute.ts | 32 + .../http/routes/gatewayPresenceRoute.ts | 33 + .../http/routes/prometheusMetricsRoute.ts | 26 + src/server/http/routes/routeRulesRoute.ts | 21 + src/server/http/routes/serverApplyRoute.ts | 25 + src/server/http/routes/serverHealthRoute.ts | 21 + src/server/http/routes/sharedProxyRoute.ts | 29 + src/server/http/routes/stateRoute.ts | 91 + .../http/routes/subscriptionMutationRoute.ts | 49 + .../routes/subscriptionValidationRoute.ts | 20 + src/server/http/routes/versionRoute.ts | 31 + src/server/index.js | 940 --- src/server/index.ts | 665 ++ src/server/main.ts | 9 + src/server/{ping.js => ping.ts} | 19 +- ...metheusMetrics.js => prometheusMetrics.ts} | 65 +- src/server/serverHealth.js | 27 - ...e.js => connectivityDiagnosticsService.ts} | 216 +- ...ryService.js => deviceInventoryService.ts} | 573 +- ...olicyService.js => devicePolicyService.ts} | 72 +- ...fficService.js => deviceTrafficService.ts} | 204 +- ...fficService.js => domainTrafficService.ts} | 134 +- src/server/services/rollback.ts | 29 + src/server/services/stateStore.js | 154 - src/server/services/stateStore.ts | 217 + src/server/{sharedProxy.js => sharedProxy.ts} | 10 +- src/server/{singbox.js => singbox.ts} | 29 +- .../{singboxRuntime.js => singboxRuntime.ts} | 22 +- .../{subscription.js => subscription.ts} | 85 +- src/server/{version.js => version.ts} | 9 +- ...gnostics.js => connectivityDiagnostics.ts} | 21 +- src/shared/contracts/state.js | 218 - src/shared/contracts/state.ts | 302 + src/shared/{errors.js => errors.ts} | 27 +- .../{routingRules.js => routingRules.ts} | 42 +- .../{serverIdentity.js => serverIdentity.ts} | 62 +- src/shared/{versions.js => versions.ts} | 22 +- src/web/{App.jsx => App.tsx} | 120 +- src/web/api.js | 110 - src/web/api/harborClient.ts | 203 + src/web/components/ClientOverviewPage.jsx | 1821 ------ src/web/components/ClientOverviewPage.tsx | 652 ++ .../{SyncStatus.jsx => SyncStatus.tsx} | 10 +- .../features/connection/ConnectionPanel.tsx | 283 + src/web/features/connection/index.ts | 1 + src/web/features/devices/DevicesFeature.tsx | 235 + .../devices/DevicesPanel.tsx} | 191 +- .../devices/TrafficChart.tsx} | 55 +- src/web/features/devices/deviceSnapshot.ts | 172 + src/web/features/devices/index.ts | 6 + .../ConnectivityDiagnosticsPanel.tsx} | 166 +- .../diagnostics/DiagnosticsFeature.tsx | 65 + .../diagnostics/connectivityResult.ts | 96 + src/web/features/diagnostics/index.ts | 6 + .../instructions/InstructionsFeature.tsx | 275 + src/web/features/instructions/index.ts | 6 + .../instructions/instructionBlocks.ts} | 7 +- .../instructions/prometheus.ts} | 4 +- src/web/features/routing/RoutingFeature.tsx | 584 ++ src/web/features/routing/index.ts | 7 + .../servers/ServerPicker.tsx} | 121 +- src/web/features/servers/index.ts | 1 + src/web/features/servers/serverPickerModel.ts | 73 + .../subscription/SubscriptionFeature.tsx | 557 ++ src/web/features/subscription/index.ts | 6 + src/web/features/subscription/requestError.ts | 32 + src/web/main.tsx | 8 + .../{harborReducer.js => harborReducer.ts} | 47 +- .../state/{operations.js => operations.ts} | 27 +- src/web/styles.css | 5412 ----------------- src/web/styles/base.css | 140 + src/web/styles/features/connection.css | 412 ++ src/web/styles/features/devices.css | 1004 +++ src/web/styles/features/diagnostics.css | 429 ++ src/web/styles/features/instructions.css | 239 + src/web/styles/features/routing.css | 401 ++ src/web/styles/features/servers.css | 556 ++ src/web/styles/features/subscription.css | 421 ++ src/web/styles/index.css | 12 + src/web/styles/layout.css | 792 +++ src/web/styles/primitives.css | 788 +++ src/web/styles/themes.css | 195 + src/web/styles/tokens.css | 25 + .../ConfirmationDialog.tsx} | 44 +- .../{clientControls.js => clientControls.ts} | 48 +- src/web/utils/{format.js => format.ts} | 36 +- src/web/utils/serverPicker.js | 31 - test/architecture/import-boundaries.test.js | 115 + test/architecture/typescript-cutover.test.js | 42 + test/build/test-artifacts.test.js | 20 + test/data-consistency-regression.test.js | 15 +- test/deploy/runtime-impact.test.js | 170 + test/server/compiled-entrypoint.test.js | 155 + test/server/connection-service.test.js | 486 ++ test/server/connectivity-diagnostics.test.js | 152 +- test/server/dataplane-client.test.js | 2 +- test/server/deploy-split.test.js | 23 +- test/server/device-inventory.test.js | 76 +- test/server/device-policy.test.js | 2 +- test/server/device-routes.test.js | 191 + test/server/device-traffic.test.js | 4 +- test/server/domain-traffic.test.js | 4 +- test/server/entrypoint-tproxy.test.js | 7 +- test/server/errors.test.js | 2 +- test/server/gateway-auto-service.test.js | 506 ++ test/server/gateway-presence-route.test.js | 141 + test/server/gateway-presence.test.js | 4 +- test/server/gateway-routing.test.js | 2 +- test/server/prometheus-metrics.test.js | 98 +- test/server/rollback.test.js | 56 + test/server/route-rules-service.test.js | 228 + test/server/server-health.test.js | 55 +- test/server/shared-proxy.test.js | 176 +- test/server/singbox-client-mode.test.js | 2 +- test/server/singbox-gateway-mode.test.js | 2 +- test/server/singbox-runtime.test.js | 2 +- test/server/state-contract.test.js | 33 +- test/server/state-store.test.js | 14 +- test/server/subscription-mutation.test.js | 539 ++ test/server/subscription-validation.test.js | 39 + test/server/subscription.test.js | 12 +- test/server/version.test.js | 117 +- test/shared/routing-rules.test.js | 2 +- test/version-script.test.js | 47 +- test/web/api-errors.test.js | 104 +- test/web/app-shell-contract.test.js | 25 + test/web/client-controls.test.js | 4 +- test/web/component-actions-contract.test.js | 36 + test/web/connection-panel-contract.test.js | 46 + test/web/device-inventory-contract.test.js | 94 +- test/web/devices-feature-contract.test.js | 116 + test/web/diagnostics-feature-contract.test.js | 87 + test/web/harbor-state.test.js | 38 +- .../web/instructions-feature-contract.test.js | 50 + test/web/operations.test.js | 2 +- test/web/prometheus-instructions.test.js | 25 +- test/web/responsive-layout-contract.test.js | 75 +- test/web/routing-feature-contract.test.js | 44 + test/web/rule-editor-contract.test.js | 93 +- test/web/server-picker.test.js | 44 +- test/web/style-boundaries.test.js | 300 + test/web/style-source.js | 1003 +++ .../web/subscription-feature-contract.test.js | 88 + tsconfig.base.json | 12 + tsconfig.server.json | 13 + tsconfig.test.json | 28 + tsconfig.web.json | 13 + vite.config.js => vite.config.ts | 0 190 files changed, 20064 insertions(+), 9761 deletions(-) create mode 100644 scripts/check-import-boundaries.mjs create mode 100644 scripts/clean-test-dist.mjs create mode 100644 scripts/runtime-impact.mjs rename src/server/adapters/{neighbors.js => neighbors.ts} (53%) rename src/server/{config.js => config.ts} (94%) rename src/server/{dataplane.js => dataplane.ts} (76%) rename src/server/{dataplaneClient.js => dataplaneClient.ts} (63%) create mode 100644 src/server/features/connection/connectionService.ts create mode 100644 src/server/features/connection/index.ts create mode 100644 src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts create mode 100644 src/server/features/diagnostics/index.ts create mode 100644 src/server/features/routing/gatewayAutoService.ts create mode 100644 src/server/features/routing/index.ts create mode 100644 src/server/features/routing/routeRulesService.ts create mode 100644 src/server/features/servers/index.ts create mode 100644 src/server/features/servers/serverHealth.ts create mode 100644 src/server/features/state/stateService.ts create mode 100644 src/server/features/subscription/index.ts create mode 100644 src/server/features/subscription/subscriptionService.ts create mode 100644 src/server/features/subscription/validateSubscription.ts rename src/server/{gatewayPresence.js => gatewayPresence.ts} (70%) rename src/server/{gatewayRouting.js => gatewayRouting.ts} (79%) create mode 100644 src/server/http/response.ts create mode 100644 src/server/http/routes/connectionRuntimeRoute.ts create mode 100644 src/server/http/routes/connectivityDiagnosticsRoute.ts create mode 100644 src/server/http/routes/deviceInventoryRoute.ts create mode 100644 src/server/http/routes/gatewayAutoRoute.ts create mode 100644 src/server/http/routes/gatewayPresenceRoute.ts create mode 100644 src/server/http/routes/prometheusMetricsRoute.ts create mode 100644 src/server/http/routes/routeRulesRoute.ts create mode 100644 src/server/http/routes/serverApplyRoute.ts create mode 100644 src/server/http/routes/serverHealthRoute.ts create mode 100644 src/server/http/routes/sharedProxyRoute.ts create mode 100644 src/server/http/routes/stateRoute.ts create mode 100644 src/server/http/routes/subscriptionMutationRoute.ts create mode 100644 src/server/http/routes/subscriptionValidationRoute.ts create mode 100644 src/server/http/routes/versionRoute.ts delete mode 100644 src/server/index.js create mode 100644 src/server/index.ts create mode 100644 src/server/main.ts rename src/server/{ping.js => ping.ts} (67%) rename src/server/{prometheusMetrics.js => prometheusMetrics.ts} (70%) delete mode 100644 src/server/serverHealth.js rename src/server/services/{connectivityDiagnosticsService.js => connectivityDiagnosticsService.ts} (63%) rename src/server/services/{deviceInventoryService.js => deviceInventoryService.ts} (66%) rename src/server/services/{devicePolicyService.js => devicePolicyService.ts} (68%) rename src/server/services/{deviceTrafficService.js => deviceTrafficService.ts} (64%) rename src/server/services/{domainTrafficService.js => domainTrafficService.ts} (64%) create mode 100644 src/server/services/rollback.ts delete mode 100644 src/server/services/stateStore.js create mode 100644 src/server/services/stateStore.ts rename src/server/{sharedProxy.js => sharedProxy.ts} (78%) rename src/server/{singbox.js => singbox.ts} (80%) rename src/server/{singboxRuntime.js => singboxRuntime.ts} (85%) rename src/server/{subscription.js => subscription.ts} (72%) rename src/server/{version.js => version.ts} (70%) rename src/shared/{connectivityDiagnostics.js => connectivityDiagnostics.ts} (85%) delete mode 100644 src/shared/contracts/state.js create mode 100644 src/shared/contracts/state.ts rename src/shared/{errors.js => errors.ts} (79%) rename src/shared/{routingRules.js => routingRules.ts} (59%) rename src/shared/{serverIdentity.js => serverIdentity.ts} (51%) rename src/shared/{versions.js => versions.ts} (61%) rename src/web/{App.jsx => App.tsx} (60%) delete mode 100644 src/web/api.js create mode 100644 src/web/api/harborClient.ts delete mode 100644 src/web/components/ClientOverviewPage.jsx create mode 100644 src/web/components/ClientOverviewPage.tsx rename src/web/components/{SyncStatus.jsx => SyncStatus.tsx} (81%) create mode 100644 src/web/features/connection/ConnectionPanel.tsx create mode 100644 src/web/features/connection/index.ts create mode 100644 src/web/features/devices/DevicesFeature.tsx rename src/web/{components/DevicesPanel.jsx => features/devices/DevicesPanel.tsx} (82%) rename src/web/{components/TrafficChart.jsx => features/devices/TrafficChart.tsx} (83%) create mode 100644 src/web/features/devices/deviceSnapshot.ts create mode 100644 src/web/features/devices/index.ts rename src/web/{components/ConnectivityDiagnosticsPanel.jsx => features/diagnostics/ConnectivityDiagnosticsPanel.tsx} (75%) create mode 100644 src/web/features/diagnostics/DiagnosticsFeature.tsx create mode 100644 src/web/features/diagnostics/connectivityResult.ts create mode 100644 src/web/features/diagnostics/index.ts create mode 100644 src/web/features/instructions/InstructionsFeature.tsx create mode 100644 src/web/features/instructions/index.ts rename src/web/{instructions.js => features/instructions/instructionBlocks.ts} (98%) rename src/web/{prometheus.js => features/instructions/prometheus.ts} (62%) create mode 100644 src/web/features/routing/RoutingFeature.tsx create mode 100644 src/web/features/routing/index.ts rename src/web/{components/ServerPicker.jsx => features/servers/ServerPicker.tsx} (82%) create mode 100644 src/web/features/servers/index.ts create mode 100644 src/web/features/servers/serverPickerModel.ts create mode 100644 src/web/features/subscription/SubscriptionFeature.tsx create mode 100644 src/web/features/subscription/index.ts create mode 100644 src/web/features/subscription/requestError.ts create mode 100644 src/web/main.tsx rename src/web/state/{harborReducer.js => harborReducer.ts} (57%) rename src/web/state/{operations.js => operations.ts} (58%) delete mode 100644 src/web/styles.css create mode 100644 src/web/styles/base.css create mode 100644 src/web/styles/features/connection.css create mode 100644 src/web/styles/features/devices.css create mode 100644 src/web/styles/features/diagnostics.css create mode 100644 src/web/styles/features/instructions.css create mode 100644 src/web/styles/features/routing.css create mode 100644 src/web/styles/features/servers.css create mode 100644 src/web/styles/features/subscription.css create mode 100644 src/web/styles/index.css create mode 100644 src/web/styles/layout.css create mode 100644 src/web/styles/primitives.css create mode 100644 src/web/styles/themes.css create mode 100644 src/web/styles/tokens.css rename src/web/{components/ConfirmationPopup.jsx => ui/ConfirmationDialog.tsx} (70%) rename src/web/utils/{clientControls.js => clientControls.ts} (69%) rename src/web/utils/{format.js => format.ts} (78%) delete mode 100644 src/web/utils/serverPicker.js create mode 100644 test/architecture/import-boundaries.test.js create mode 100644 test/architecture/typescript-cutover.test.js create mode 100644 test/build/test-artifacts.test.js create mode 100644 test/deploy/runtime-impact.test.js create mode 100644 test/server/compiled-entrypoint.test.js create mode 100644 test/server/connection-service.test.js create mode 100644 test/server/device-routes.test.js create mode 100644 test/server/gateway-auto-service.test.js create mode 100644 test/server/gateway-presence-route.test.js create mode 100644 test/server/rollback.test.js create mode 100644 test/server/route-rules-service.test.js create mode 100644 test/server/subscription-mutation.test.js create mode 100644 test/server/subscription-validation.test.js create mode 100644 test/web/app-shell-contract.test.js create mode 100644 test/web/component-actions-contract.test.js create mode 100644 test/web/connection-panel-contract.test.js create mode 100644 test/web/devices-feature-contract.test.js create mode 100644 test/web/diagnostics-feature-contract.test.js create mode 100644 test/web/instructions-feature-contract.test.js create mode 100644 test/web/routing-feature-contract.test.js create mode 100644 test/web/style-boundaries.test.js create mode 100644 test/web/style-source.js create mode 100644 test/web/subscription-feature-contract.test.js create mode 100644 tsconfig.base.json create mode 100644 tsconfig.server.json create mode 100644 tsconfig.test.json create mode 100644 tsconfig.web.json rename vite.config.js => vite.config.ts (100%) diff --git a/.codex/skills/manage-harbor-versions/SKILL.md b/.codex/skills/manage-harbor-versions/SKILL.md index 0aa4ab0..244be5c 100644 --- a/.codex/skills/manage-harbor-versions/SKILL.md +++ b/.codex/skills/manage-harbor-versions/SKILL.md @@ -5,7 +5,7 @@ description: Check and bump Harbor component versions for every runtime, UI, API # 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 @@ -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. -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. diff --git a/.dockerignore b/.dockerignore index 5d44064..16a4d79 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ node_modules +dist .vpn-proxy .runtime .git diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index e99ea75..1957385 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -8,6 +8,7 @@ on: env: DEPLOY_PATH: /opt/vpn-proxy 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 APT_MIRROR: http://mirror.yandex.ru/debian APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security @@ -16,6 +17,9 @@ env: jobs: build-and-push: runs-on: ubuntu-22.04 + outputs: + affected_components: ${{ steps['gateway-build'].outputs.affected_components }} + restart_scope: ${{ steps['gateway-build'].outputs.restart_scope }} steps: - name: Clone repository env: @@ -24,11 +28,12 @@ jobs: set -euo pipefail SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||') 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 git checkout ${{ gitea.sha }} - name: Build and push gateway image + id: gateway-build run: | set -euo pipefail cd repo @@ -38,6 +43,68 @@ jobs: CONTROL_IMAGE="${IMAGE}-control" 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 "Base image: ${{ env.BASE_IMAGE }}" echo "Docker context: $(docker context show 2>/dev/null || true)" @@ -54,23 +121,11 @@ jobs: ./scripts/build-runtime-base.sh 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 DOCKER_BUILDKIT=1 docker build \ --network host \ --pull=false \ + --build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \ --build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \ --build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \ --build-arg INSTALL_RUNTIME_DEPS=false \ @@ -109,13 +164,24 @@ jobs: IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway" CONTROL_IMAGE="${IMAGE}-control:${{ 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 - if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \ - '^(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 + if [ "$RESTART_SCOPE" = "both" ]; then UPDATE_DATAPLANE=true fi echo "Deploy runner: $(hostname)" + echo "Affected components: ${AFFECTED_COMPONENTS}" + echo "Restart scope: ${RESTART_SCOPE}" echo "Update dataplane: ${UPDATE_DATAPLANE}" echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \ diff --git a/.gitignore b/.gitignore index 5963232..159201c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ data/ # Node/Vite node_modules/ dist/ +.test-dist/ coverage/ npm-debug.log* yarn-debug.log* diff --git a/Dockerfile b/Dockerfile index 8030144..0aa06bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,21 @@ +ARG NODE_BUILD_IMAGE=node:20.19-alpine 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} ARG SINGBOX_VERSION=1.12.13 ARG INSTALL_RUNTIME_DEPS=true ARG INSTALL_SINGBOX=true -COPY dist /app/dist RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \ apt-get update \ @@ -33,9 +45,8 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \ fi WORKDIR /app +COPY --from=build /src/dist /app/dist COPY package.json /app/package.json -COPY src/server /app/src/server -COPY src/shared /app/src/shared COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh \ diff --git a/Dockerfile.client b/Dockerfile.client index f66ba5d..d40ddf8 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -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 -FROM ${NODE_BUILD_IMAGE} AS web-build +FROM ${NODE_BUILD_IMAGE} AS build WORKDIR /src COPY package.json package-lock.json ./ RUN npm ci -COPY index.html vite.config.js ./ +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 +RUN npm run build:production FROM ${RUNTIME_IMAGE} ARG SINGBOX_VERSION=1.12.13 @@ -32,10 +33,8 @@ RUN set -eux; \ rm -rf /tmp/sing-box* WORKDIR /app -COPY --from=web-build /src/dist /app/dist +COPY --from=build /src/dist /app/dist 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 RUN chmod +x /entrypoint.client.sh \ diff --git a/README.md b/README.md index d36b3ad..eb637dc 100644 --- a/README.md +++ b/README.md @@ -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`. -Компонентные версии меняются в `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 [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`. diff --git a/docs/product/error-contract.md b/docs/product/error-contract.md index d1def36..6739103 100644 --- a/docs/product/error-contract.md +++ b/docs/product/error-contract.md @@ -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 `Повторить`. diff --git a/docs/product/frontend-operations.md b/docs/product/frontend-operations.md index e429de2..52edbf3 100644 --- a/docs/product/frontend-operations.md +++ b/docs/product/frontend-operations.md @@ -7,7 +7,7 @@ Harbor tracks active browser mutations by operation key instead of one global `b - `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`; - `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. diff --git a/entrypoint.client.sh b/entrypoint.client.sh index fbccb43..9214852 100755 --- a/entrypoint.client.sh +++ b/entrypoint.client.sh @@ -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}" 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 diff --git a/entrypoint.sh b/entrypoint.sh index 9aaee84..49a0dd3 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -25,7 +25,7 @@ log() { } if [[ "$APP_COMPONENT" == "control" ]]; then - exec node /app/src/server/index.js + exec node /app/dist/server/main.js fi ipt() { @@ -189,11 +189,7 @@ if ! setup_device_traffic; then fi setup_proxy_firewall -if [[ "$APP_COMPONENT" == "dataplane" ]]; then - node /app/src/server/dataplane.js & -else - node /app/src/server/index.js & -fi +node /app/dist/server/main.js & APP_PID=$! shutdown() { diff --git a/index.html b/index.html index 8987116..e507817 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/package-lock.json b/package-lock.json index 709dc72..4124567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,17 @@ "react": "^19.0.0", "react-dom": "^19.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": { @@ -277,6 +288,29 @@ "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": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1116,6 +1150,394 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "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": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1207,6 +1629,26 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1435,6 +1877,20 @@ "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": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -1549,6 +2005,48 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1579,6 +2077,13 @@ "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": { "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", diff --git a/package.json b/package.json index aed71f5..dfc3ac0 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,31 @@ "scripts": { "dev": "vite --host 0.0.0.0", "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", - "start": "node src/server/index.js" + "start": "node dist/server/main.js" }, "dependencies": { "@vitejs/plugin-react": "^5.0.0", "react": "^19.0.0", "react-dom": "^19.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" } } diff --git a/scripts/build-on-107-deploy-111.sh b/scripts/build-on-107-deploy-111.sh index f6fcca8..fba6afb 100755 --- a/scripts/build-on-107-deploy-111.sh +++ b/scripts/build-on-107-deploy-111.sh @@ -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)}" GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}" BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}" +NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}" RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}" DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}" @@ -62,7 +63,7 @@ else fi echo "Building image on ${BUILD_HOST}" -BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && 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 bash -lc "${BUILD_COMMAND}" else diff --git a/scripts/check-import-boundaries.mjs b/scripts/check-import-boundaries.mjs new file mode 100644 index 0000000..1b23e87 --- /dev/null +++ b/scripts/check-import-boundaries.mjs @@ -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.`); + } +} diff --git a/scripts/clean-test-dist.mjs b/scripts/clean-test-dist.mjs new file mode 100644 index 0000000..f6e42a1 --- /dev/null +++ b/scripts/clean-test-dist.mjs @@ -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 }); diff --git a/scripts/harbor-version.mjs b/scripts/harbor-version.mjs index f5eb3ed..8a234db 100644 --- a/scripts/harbor-version.mjs +++ b/scripts/harbor-version.mjs @@ -3,10 +3,10 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { parseVersion, versionCompatibility } from '../src/shared/versions.js'; 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 aliases = { mac: 'macClient', @@ -17,6 +17,28 @@ const aliases = { '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) { return Object.fromEntries(COMPONENTS.map((component) => { 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)); for (const file of files) { if (file === VERSION_FILE) continue; - if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS); - else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) { + if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS); + else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) { 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)) { 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)) { add('gatewayBackend'); + } else if (/^(\.gitea\/workflows\/gateway-build\.yml|scripts\/runtime-impact\.mjs)$/.test(file)) { + add('gatewayBackend'); } } return COMPONENTS.filter((component) => affected.has(component)); @@ -91,7 +115,7 @@ function git(args) { } 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'); return [...new Set([...tracked, ...untracked].filter(Boolean))]; } @@ -100,7 +124,11 @@ function baselineVersions(base) { try { return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`])); } catch { - return null; + try { + return versionsFromSource(git(['show', `${base}:${LEGACY_VERSION_FILE}`])); + } catch { + return null; + } } } diff --git a/scripts/runtime-impact.mjs b/scripts/runtime-impact.mjs new file mode 100644 index 0000000..b770342 --- /dev/null +++ b/scripts/runtime-impact.mjs @@ -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; + } +} diff --git a/src/server/adapters/neighbors.js b/src/server/adapters/neighbors.ts similarity index 53% rename from src/server/adapters/neighbors.js rename to src/server/adapters/neighbors.ts index 0f303d9..c3c17a2 100644 --- a/src/server/adapters/neighbors.js +++ b/src/server/adapters/neighbors.ts @@ -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 INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i; -export function isDeviceInterface(value) { +interface NeighborEntry extends Record { + 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 || ''); return INTERFACE_PATTERN.test(name) && 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 []; - return value.flatMap((entry) => { - const states = (Array.isArray(entry?.state) ? entry.state : [entry?.state]) + return value.flatMap((value) => { + const entry = neighborEntry(value); + const states = (Array.isArray(entry.state) ? entry.state : [entry.state]) .filter(Boolean) - .map((state) => String(state).toUpperCase()); - const mac = String(entry?.lladdr || '').toLowerCase(); - const deviceInterface = String(entry?.dev || ''); - if (!entry?.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac) + .map((state: unknown) => String(state).toUpperCase()); + const mac = String(entry.lladdr || '').toLowerCase(); + const deviceInterface = String(entry.dev || ''); + if (!entry.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac) || states.some((state) => IGNORED_STATES.has(state))) { 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 result = run('ip', ['-j', 'neigh', 'show'], { encoding: 'utf8', @@ -54,6 +77,7 @@ export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) { error: null, }; } 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}` }; } } diff --git a/src/server/config.js b/src/server/config.ts similarity index 94% rename from src/server/config.js rename to src/server/config.ts index 61e351b..2ae7192 100644 --- a/src/server/config.js +++ b/src/server/config.ts @@ -1,8 +1,8 @@ import path from "node:path"; const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy"); -const parsePort = (value, fallback) => { - const parsed = Number.parseInt(value, 10); +const parsePort = (value: string | undefined, fallback: number) => { + const parsed = Number.parseInt(value || '', 10); return Number.isInteger(parsed) ? parsed : fallback; }; const proxyPort = parsePort( diff --git a/src/server/dataplane.js b/src/server/dataplane.ts similarity index 76% rename from src/server/dataplane.js rename to src/server/dataplane.ts index d492eac..c3643e7 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { settings } from './config.js'; import { createSingboxRuntime } from './singboxRuntime.js'; import { buildVersionInfo } from './version.js'; @@ -40,23 +41,34 @@ const domainTraffic = createDomainTrafficService({ devices: () => traffic.snapshot().devices, }); let ready = false; -let trafficTimer = null; -let domainTrafficTimer = null; +let trafficTimer: NodeJS.Timeout | null = null; +let domainTrafficTimer: NodeJS.Timeout | null = null; const MAX_POLICY_BODY_BYTES = 256 * 1024; -function readJson(req) { +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function readJson(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { - const chunks = []; + const chunks: Buffer[] = []; let size = 0; let tooLarge = false; - req.on('data', (chunk) => { - size += chunk.length; + req.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; if (!tooLarge && size > MAX_POLICY_BODY_BYTES) { tooLarge = true; reject(new Error('Device policy request слишком большой')); return; } - if (!tooLarge) chunks.push(chunk); + if (!tooLarge) chunks.push(buffer); }); req.on('end', () => { 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.end(JSON.stringify(payload)); } -const server = http.createServer(async (req, res) => { +const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => { try { if (req.method === 'GET' && req.url === '/status') { return sendJson(res, ready ? 200 : 503, { @@ -99,11 +111,11 @@ const server = http.createServer(async (req, res) => { return sendJson(res, 200, devicePolicy.snapshot()); } 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)); } 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({ vpnAvailable: runtime.running, services, @@ -121,7 +133,7 @@ const server = http.createServer(async (req, res) => { } return sendJson(res, 404, { 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 { await runtime.apply(); } catch (error) { - console.warn(`[dataplane] sing-box не запущен: ${error.message}`); + console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); } finally { ready = true; setImmediate(() => { traffic.refresh() - .catch((error) => console.warn(`[dataplane] traffic counters не запущены: ${error.message}`)); + .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); }); 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); trafficTimer.unref(); setImmediate(() => { 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. domainTrafficTimer = setInterval(() => { domainTraffic.refresh() - .catch((error) => console.warn(`[dataplane] domain traffic не обновлён: ${error.message}`)); + .catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); }, 2_000); domainTrafficTimer.unref(); console.log(`[dataplane] control socket: ${socketPath}`); diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.ts similarity index 63% rename from src/server/dataplaneClient.js rename to src/server/dataplaneClient.ts index 715068e..402e771 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.ts @@ -1,7 +1,25 @@ import http from 'node:http'; 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; + +function record(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : {}; +} + +function request( + socketPath: string, + pathname: string, + method = 'GET', + body: unknown = null, + timeoutMs = 6000, +): Promise { return new Promise((resolve, reject) => { const encoded = body == null ? null : JSON.stringify(body); const req = http.request({ @@ -13,17 +31,17 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs = 'content-length': Buffer.byteLength(encoded), } : {}, }, (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { - let body = {}; + let body: unknown = {}; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return reject(new Error('Dataplane вернул невалидный JSON')); } 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); }); @@ -34,11 +52,11 @@ function request(socketPath, pathname, method = 'GET', body = null, timeoutMs = }); } -export function createDataplaneClient(socketPath, send = request) { - let current = { running: false, startedAt: null }; - const update = async (pathname, method) => { +export function createDataplaneClient(socketPath: string, send: SendDataplaneRequest = request) { + let current: Record = { running: false, startedAt: null }; + const update = async (pathname: string, method: string) => { try { - current = await send(socketPath, pathname, method); + current = record(await send(socketPath, pathname, method)); return current; } catch (cause) { if (pathname === '/apply' || pathname === '/restart') { @@ -56,8 +74,8 @@ export function createDataplaneClient(socketPath, send = request) { observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), - applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }), - runConnectivityDiagnostics: async (services = [], target = null) => { + applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }), + runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => { try { return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000); } catch (cause) { diff --git a/src/server/features/connection/connectionService.ts b/src/server/features/connection/connectionService.ts new file mode 100644 index 0000000..c8846be --- /dev/null +++ b/src/server/features/connection/connectionService.ts @@ -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): 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; + start(): Promise; + stop(): Promise; + stopCommand(): Promise; + restartCommand(): Promise; + }; + serialize(operation: () => Promise): Promise; + now(): Date; +} + +export type RuntimeCommandResult = + | { ok: true; mutationStarted: true } + | { ok: false; mutationStarted: boolean; error: unknown }; + +export async function captureRuntimeCommand( + command: () => Promise, + { preMutationErrorCodes = [] }: { preMutationErrorCodes?: readonly string[] } = {}, +): Promise { + 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; diff --git a/src/server/features/connection/index.ts b/src/server/features/connection/index.ts new file mode 100644 index 0000000..573e6cf --- /dev/null +++ b/src/server/features/connection/index.ts @@ -0,0 +1,6 @@ +export { + captureRuntimeCommand, + createConnectionService, + type RuntimeCommandResult, + type ConnectionService, +} from './connectionService.js'; diff --git a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts new file mode 100644 index 0000000..4fe138a --- /dev/null +++ b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts @@ -0,0 +1,52 @@ +interface DiagnosticServer { + id: unknown; + label: unknown; +} + +interface DiagnosticState { + appliedServerId?: unknown; + selectedServerId?: unknown; + servers?: DiagnosticServer[]; +} + +interface DiagnosticsResult extends Record { + vpn?: Record; +} + +interface ConnectivityDiagnosticsDependencies { + readState(): DiagnosticState; + runDiagnostics(services: unknown, target: unknown): Promise; +} + +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 +>; diff --git a/src/server/features/diagnostics/index.ts b/src/server/features/diagnostics/index.ts new file mode 100644 index 0000000..c729023 --- /dev/null +++ b/src/server/features/diagnostics/index.ts @@ -0,0 +1,4 @@ +export { + createConnectivityDiagnosticsUseCase, + type ConnectivityDiagnosticsUseCase, +} from './connectivityDiagnosticsUseCase.js'; diff --git a/src/server/features/routing/gatewayAutoService.ts b/src/server/features/routing/gatewayAutoService.ts new file mode 100644 index 0000000..5fa01d0 --- /dev/null +++ b/src/server/features/routing/gatewayAutoService.ts @@ -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): 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; + restoreRunning(): Promise; + }; + discovery: { + readHostNetwork(): HostNetworkState | null; + probeGateway(input: { + gateway: string; + subscriptionUrl: string; + }): Promise; + }; + 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(operation: () => Promise): Promise; + 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 | 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; diff --git a/src/server/features/routing/index.ts b/src/server/features/routing/index.ts new file mode 100644 index 0000000..72c3657 --- /dev/null +++ b/src/server/features/routing/index.ts @@ -0,0 +1,8 @@ +export { + createRouteRulesService, + type RouteRulesService, +} from './routeRulesService.js'; +export { + createGatewayAutoService, + type GatewayAutoService, +} from './gatewayAutoService.js'; diff --git a/src/server/features/routing/routeRulesService.ts b/src/server/features/routing/routeRulesService.ts new file mode 100644 index 0000000..21d6f4e --- /dev/null +++ b/src/server/features/routing/routeRulesService.ts @@ -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): 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; + applyCommand(): Promise; + restoreRunning(): Promise; + }; + serialize(operation: () => Promise): Promise; + runOperation(operation: () => Promise): Promise; +} + +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; diff --git a/src/server/features/servers/index.ts b/src/server/features/servers/index.ts new file mode 100644 index 0000000..ae47564 --- /dev/null +++ b/src/server/features/servers/index.ts @@ -0,0 +1,7 @@ +export { + checkServerHealth, + createServerHealthService, + SERVER_HEALTH_CONCURRENCY, + SERVER_HEALTH_MAX_COUNT, + type ServerHealthService, +} from './serverHealth.js'; diff --git a/src/server/features/servers/serverHealth.ts b/src/server/features/servers/serverHealth.ts new file mode 100644 index 0000000..72efde0 --- /dev/null +++ b/src/server/features/servers/serverHealth.ts @@ -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; +type Ping = (host: string, port: number) => Promise>; + +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> = 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; diff --git a/src/server/features/state/stateService.ts b/src/server/features/state/stateService.ts new file mode 100644 index 0000000..22cb285 --- /dev/null +++ b/src/server/features/state/stateService.ts @@ -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; + 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 { + 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; diff --git a/src/server/features/subscription/index.ts b/src/server/features/subscription/index.ts new file mode 100644 index 0000000..fbb7f9f --- /dev/null +++ b/src/server/features/subscription/index.ts @@ -0,0 +1,8 @@ +export { + createValidateSubscription, + type ValidateSubscription, +} from './validateSubscription.js'; +export { + createSubscriptionService, + type SubscriptionService, +} from './subscriptionService.js'; diff --git a/src/server/features/subscription/subscriptionService.ts b/src/server/features/subscription/subscriptionService.ts new file mode 100644 index 0000000..e47de63 --- /dev/null +++ b/src/server/features/subscription/subscriptionService.ts @@ -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; + fetchedAt: string; +} + +type TimerHandle = NodeJS.Timeout; + +interface SubscriptionServiceDependencies { + provider: { + fetchSubscription(url: string): Promise; + selectRefreshedServer( + currentServerId: string, + currentServers: HarborServer[], + nextServers: HarborServer[], + ): string; + }; + state: { + read(): StoredState; + update(mutator: (state: StoredState) => Record): 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; + stop(): Promise; + start(): Promise; + }; + gatewayAuto: { + read(): GatewayAutoState; + set(value: GatewayAutoState): void; + createInitial(): GatewayAutoState; + }; + serialize(operation: () => Promise): Promise; + 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 { + success: true; + servers: HarborServer[]; + userInfo: Record; + 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 | 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; diff --git a/src/server/features/subscription/validateSubscription.ts b/src/server/features/subscription/validateSubscription.ts new file mode 100644 index 0000000..e1f3946 --- /dev/null +++ b/src/server/features/subscription/validateSubscription.ts @@ -0,0 +1,14 @@ +interface SubscriptionResult { + servers: unknown[]; +} + +type FetchSubscription = (url: string) => Promise; + +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; diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.ts similarity index 70% rename from src/server/gatewayPresence.js rename to src/server/gatewayPresence.ts index 3d1995a..12d1ff8 100644 --- a/src/server/gatewayPresence.js +++ b/src/server/gatewayPresence.ts @@ -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 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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function isIpv4(value: unknown) { const parts = String(value || '').split('.'); return parts.length === 4 && parts.every((part) => ( /^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255 )); } -function subscriptionSecret(subscriptionUrl) { +function subscriptionSecret(subscriptionUrl: unknown) { try { const url = new URL(String(subscriptionUrl || '').trim()); 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); if (!credentialUrl) return ''; const key = crypto.createHash('sha256') @@ -50,7 +79,12 @@ function presenceProof(subscriptionUrl, nonce, gatewayId) { .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 || ''))) { 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 ( payload?.available !== true || payload?.product !== 'harbor' || @@ -91,7 +129,7 @@ export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) { !PROOF_RE.test(String(payload.proof || '')) ) 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)); if (!expectedProof) return false; const expected = Buffer.from(expectedProof, 'hex'); @@ -105,7 +143,14 @@ export async function probeGatewayPresence({ fetchImpl = fetch, timeoutMs = 1000, nonce = crypto.randomBytes(16).toString('hex'), -}) { +}: { + gateway: string; + subscriptionUrl: unknown; + port?: number; + fetchImpl?: typeof fetch; + timeoutMs?: number; + nonce?: string; +}): Promise { if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway'); const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`; @@ -113,26 +158,27 @@ export async function probeGatewayPresence({ presenceUrl, { 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 })) { throw new Error('Текущий default gateway не является доверенным Harbor Gateway'); } return { - gatewayId: payload.gatewayId, + gatewayId: String(payload.gatewayId), uiOrigin: new URL(presenceUrl).origin, verifiedAt: new Date().toISOString(), }; } -export function normalizeHostNetworkState(value, { +export function normalizeHostNetworkState(value: unknown, { now = Date.now(), maxAgeMs = 15_000, -} = {}) { - const gateway = String(value?.gateway || '').trim(); - const networkInterface = String(value?.interface || '').trim(); - const mac = String(value?.mac || '').trim().toLowerCase(); - const observedAt = Date.parse(value?.observedAt || ''); +}: { now?: number; maxAgeMs?: number } = {}): GatewayRoute | null { + const candidate = record(value); + const gateway = String(candidate.gateway || '').trim(); + const networkInterface = String(candidate.interface || '').trim(); + 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. if ( @@ -147,7 +193,7 @@ export function normalizeHostNetworkState(value, { return { gateway, interface: networkInterface, mac, observedAt }; } -export function readHostNetworkState(filePath, options) { +export function readHostNetworkState(filePath: string, options?: { now?: number; maxAgeMs?: number }) { try { return normalizeHostNetworkState( 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( previous && current && @@ -168,7 +214,7 @@ export function sameGatewayRoute(previous, current) { ); } -export function createGatewayAutoState() { +export function createGatewayAutoState(): GatewayAutoRuntimeState { return { mode: 'local-vpn', failures: 0, @@ -180,18 +226,22 @@ export function createGatewayAutoState() { }; } -export function applyGatewayPreference(state, enabled) { +export function applyGatewayPreference(state: GatewayAutoRuntimeState, enabled: boolean): GatewayAutoRuntimeState { return { ...state, mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn', }; } -export function nextGatewayAutoState(current, { +export function nextGatewayAutoState(current: GatewayAutoRuntimeState, { network, verifiedGateway = null, error = 'Gateway presence check failed', -}) { +}: { + network: GatewayRoute | null; + verifiedGateway?: VerifiedGateway | null; + error?: unknown; +}): GatewayAutoRuntimeState { if (!network) { if (!current.gatewayId) return createGatewayAutoState(); return { diff --git a/src/server/gatewayRouting.js b/src/server/gatewayRouting.ts similarity index 79% rename from src/server/gatewayRouting.js rename to src/server/gatewayRouting.ts index 5568c35..8d251bd 100644 --- a/src/server/gatewayRouting.js +++ b/src/server/gatewayRouting.ts @@ -1,8 +1,8 @@ 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 exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0; diff --git a/src/server/http/response.ts b/src/server/http/response.ts new file mode 100644 index 0000000..d6a9819 --- /dev/null +++ b/src/server/http/response.ts @@ -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 } : {}), + }, + }); +} diff --git a/src/server/http/routes/connectionRuntimeRoute.ts b/src/server/http/routes/connectionRuntimeRoute.ts new file mode 100644 index 0000000..4359cbb --- /dev/null +++ b/src/server/http/routes/connectionRuntimeRoute.ts @@ -0,0 +1,27 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { ConnectionService } from '../../features/connection/index.js'; + +interface ConnectionRuntimeRouteDependencies { + connection: Pick; + withOperation(kind: string, operation: () => Promise): Promise; + sendState(res: ServerResponse, extra: { singboxRunning: boolean }): Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/connectivityDiagnosticsRoute.ts b/src/server/http/routes/connectivityDiagnosticsRoute.ts new file mode 100644 index 0000000..44a7ee4 --- /dev/null +++ b/src/server/http/routes/connectivityDiagnosticsRoute.ts @@ -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; + readBody(req: IncomingMessage): Promise>; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/deviceInventoryRoute.ts b/src/server/http/routes/deviceInventoryRoute.ts new file mode 100644 index 0000000..38cdfdb --- /dev/null +++ b/src/server/http/routes/deviceInventoryRoute.ts @@ -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; + update(deviceId: string, patch: Record, expectedRevision: unknown): unknown; + setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise; +} + +interface DeviceInventoryRouteDependencies { + deviceInventory: DeviceInventoryPort | null; + readBody(req: IncomingMessage): Promise>; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/gatewayAutoRoute.ts b/src/server/http/routes/gatewayAutoRoute.ts new file mode 100644 index 0000000..ca41bd8 --- /dev/null +++ b/src/server/http/routes/gatewayAutoRoute.ts @@ -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; + readBody(req: IncomingMessage): Promise>; + withOperation(kind: string, operation: () => Promise): Promise; + readStatePayload(): Promise & { 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; + }, + }; +} diff --git a/src/server/http/routes/gatewayPresenceRoute.ts b/src/server/http/routes/gatewayPresenceRoute.ts new file mode 100644 index 0000000..86bdd2b --- /dev/null +++ b/src/server/http/routes/gatewayPresenceRoute.ts @@ -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; + }, + }; +} diff --git a/src/server/http/routes/prometheusMetricsRoute.ts b/src/server/http/routes/prometheusMetricsRoute.ts new file mode 100644 index 0000000..877ab74 --- /dev/null +++ b/src/server/http/routes/prometheusMetricsRoute.ts @@ -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; + }, + }; +} diff --git a/src/server/http/routes/routeRulesRoute.ts b/src/server/http/routes/routeRulesRoute.ts new file mode 100644 index 0000000..22cbc77 --- /dev/null +++ b/src/server/http/routes/routeRulesRoute.ts @@ -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>; + sendState(res: ServerResponse): Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/serverApplyRoute.ts b/src/server/http/routes/serverApplyRoute.ts new file mode 100644 index 0000000..065028b --- /dev/null +++ b/src/server/http/routes/serverApplyRoute.ts @@ -0,0 +1,25 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { ConnectionService } from '../../features/connection/index.js'; + +interface ServerApplyRouteDependencies { + connection: Pick; + readBody(req: IncomingMessage): Promise>; + withOperation(kind: string, operation: () => Promise): Promise; + sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/serverHealthRoute.ts b/src/server/http/routes/serverHealthRoute.ts new file mode 100644 index 0000000..15d423c --- /dev/null +++ b/src/server/http/routes/serverHealthRoute.ts @@ -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>; + sendState(res: ServerResponse, extra: { results: Array> }): Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/sharedProxyRoute.ts b/src/server/http/routes/sharedProxyRoute.ts new file mode 100644 index 0000000..e4cbab9 --- /dev/null +++ b/src/server/http/routes/sharedProxyRoute.ts @@ -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; + }, + }; +} diff --git a/src/server/http/routes/stateRoute.ts b/src/server/http/routes/stateRoute.ts new file mode 100644 index 0000000..65154e8 --- /dev/null +++ b/src/server/http/routes/stateRoute.ts @@ -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 { + port: number; + proxyPort: number; + configExists: boolean; + singboxRunning: boolean; + singboxStartedAt: string | null; + subscriptionHost: string; + hasSubscription: boolean; + selectedTag: string; + userInfo: Record; + 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, +): 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 = {}) { + sendJson(res, 200, { success: true, ...extra, state: await readPayload() }); + }, + }; +} + +export type StateRoute = ReturnType; diff --git a/src/server/http/routes/subscriptionMutationRoute.ts b/src/server/http/routes/subscriptionMutationRoute.ts new file mode 100644 index 0000000..c610aaa --- /dev/null +++ b/src/server/http/routes/subscriptionMutationRoute.ts @@ -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>; + withOperation(kind: string, operation: () => Promise): Promise; + sendState(res: ServerResponse, extra?: Record): Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/subscriptionValidationRoute.ts b/src/server/http/routes/subscriptionValidationRoute.ts new file mode 100644 index 0000000..507e1d3 --- /dev/null +++ b/src/server/http/routes/subscriptionValidationRoute.ts @@ -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>; + sendState: (res: ServerResponse, extra: Record) => Promise; +} + +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; + }, + }; +} diff --git a/src/server/http/routes/versionRoute.ts b/src/server/http/routes/versionRoute.ts new file mode 100644 index 0000000..7e2c03a --- /dev/null +++ b/src/server/http/routes/versionRoute.ts @@ -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; + refreshDataplaneRuntime: (() => Promise) | 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; + }, + }; +} diff --git a/src/server/index.js b/src/server/index.js deleted file mode 100644 index d11f09c..0000000 --- a/src/server/index.js +++ /dev/null @@ -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(); -} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..e567791 --- /dev/null +++ b/src/server/index.ts @@ -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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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({ + 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 = 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) { + 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(kind: string, operation: () => Promise): Promise { + 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: () => Promise): Promise { + 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> { + 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 = { + '.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(); +} diff --git a/src/server/main.ts b/src/server/main.ts new file mode 100644 index 0000000..b44dde2 --- /dev/null +++ b/src/server/main.ts @@ -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'); +} diff --git a/src/server/ping.js b/src/server/ping.ts similarity index 67% rename from src/server/ping.js rename to src/server/ping.ts index b64b0b5..226f1de 100644 --- a/src/server/ping.js +++ b/src/server/ping.ts @@ -6,13 +6,20 @@ import dns from "node:dns/promises"; 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 { const start = Date.now(); - return new Promise((resolve) => { + return new Promise((resolve) => { const socket = new net.Socket(); let done = false; - const finish = (result) => { + const finish = (result: PingResult) => { if (done) return; done = true; socket.removeAllListeners(); @@ -27,19 +34,19 @@ export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) { socket.once("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 }), ); try { socket.connect(port, host); } 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 { if (net.isIP(host)) return host; try { const result = await dns.lookup(host); diff --git a/src/server/prometheusMetrics.js b/src/server/prometheusMetrics.ts similarity index 70% rename from src/server/prometheusMetrics.js rename to src/server/prometheusMetrics.ts index 852e3d8..bb51d3e 100644 --- a/src/server/prometheusMetrics.js +++ b/src/server/prometheusMetrics.ts @@ -1,41 +1,56 @@ +import type { ServerResponse } from 'node:http'; + const COUNTER_PATTERN = /^\d+$/; -const labelValue = (value) => String(value ?? '') +const labelValue = (value: unknown) => String(value ?? '') .replaceAll('\\', '\\\\') .replaceAll('\n', '\\n') .replaceAll('"', '\\"'); -const labels = (values) => Object.entries(values) +const labels = (values: Record) => Object.entries(values) .map(([key, value]) => `${key}="${labelValue(value)}"`) .join(','); -function counter(value) { +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function counter(value: unknown) { const decimal = String(value ?? ''); if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`); return decimal; } -function timestamp(value) { - const milliseconds = Date.parse(value); +function timestamp(value: unknown) { + const milliseconds = Date.parse(String(value ?? '')); return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null; } -function metric(lines, name, metricLabels, value) { +function metric( + lines: string[], + name: string, + metricLabels: Record, + value: unknown, +) { 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 = [ '# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.', '# 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: 'proxy' }, counter(snapshot?.traffic?.proxyBytes)); + metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(traffic.gatewayBytes)); + metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(traffic.proxyBytes)); const globalFreshness = [ - ['gateway', snapshot?.traffic?.gatewayObservedAt], - ['proxy', snapshot?.traffic?.proxyObservedAt], - ].map(([source, observedAt]) => [source, timestamp(observedAt)]).filter(([, observedAt]) => observedAt); + ['gateway', traffic.gatewayObservedAt], + ['proxy', traffic.proxyObservedAt], + ].map(([source, observedAt]) => [source, timestamp(observedAt)] as const).filter(([, observedAt]) => observedAt); if (globalFreshness.length) { lines.push( '# 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) { lines.push( '# HELP harbor_device_info Current Harbor device identity metadata.', @@ -63,18 +78,18 @@ export function renderPrometheusMetrics(snapshot) { const deviceTraffic = devices.flatMap((device) => [ 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, 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, - ].filter(Boolean)); + ].filter((entry): entry is NonNullable => entry !== null)); if (deviceTraffic.length) { lines.push( '# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.', '# 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', { device_id: device.id, 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.', '# 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', { device_id: device.id, source, @@ -99,8 +114,8 @@ export function renderPrometheusMetrics(snapshot) { } } - const domainTraffic = snapshot?.domainTraffic; - const domainSeries = Array.isArray(domainTraffic?.series) ? domainTraffic.series : []; + const domainTraffic = record(snapshot.domainTraffic); + const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : []; if (domainSeries.length) { lines.push( '# 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) { lines.push( '# 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}`); } - if (domainTraffic?.overflowConnections != null) { + if (domainTraffic.overflowConnections != null) { lines.push( '# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.', '# TYPE harbor_domain_traffic_overflow_connections_total counter', ); lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`); } - const attributionEvents = domainTraffic?.attributionEvents; - if (attributionEvents) { + const attributionEvents = record(domainTraffic.attributionEvents); + if (domainTraffic.attributionEvents) { lines.push( '# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.', '# TYPE harbor_domain_traffic_attribution_events_total counter', @@ -155,7 +170,7 @@ export function renderPrometheusMetrics(snapshot) { return `${lines.join('\n')}\n`; } -export function sendPrometheusMetrics(res, snapshot) { +export function sendPrometheusMetrics(res: ServerResponse, snapshot: unknown) { const body = renderPrometheusMetrics(snapshot); res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); res.end(body); diff --git a/src/server/serverHealth.js b/src/server/serverHealth.js deleted file mode 100644 index a7ca582..0000000 --- a/src/server/serverHealth.js +++ /dev/null @@ -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; -} diff --git a/src/server/services/connectivityDiagnosticsService.js b/src/server/services/connectivityDiagnosticsService.ts similarity index 63% rename from src/server/services/connectivityDiagnosticsService.js rename to src/server/services/connectivityDiagnosticsService.ts index f072282..de96118 100644 --- a/src/server/services/connectivityDiagnosticsService.js +++ b/src/server/services/connectivityDiagnosticsService.ts @@ -7,18 +7,100 @@ import { CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, } 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; +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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} 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, + family: probe.family === 6 ? 6 : 4, address: probe.id === 'cloudflare' - ? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim() + ? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim() : 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) => body.trim(), + ? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1] + : (body: string) => body.trim(), })); -const SITE_PROBES = CONNECTIVITY_SITES; +const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES]; const TARGET_SAMPLE_COUNT = 3; 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], ['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], -]) 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(); for (const [address, prefix] of [ ['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7], ['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 { return new Promise((resolve) => { execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => { 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 || '', stderr: stderr || '', stdout: stdout || '', @@ -47,27 +129,27 @@ function runCurl(args) { }); } -function stageFor(exitCode) { +function stageFor(exitCode: number | null) { if (exitCode === 6) return 'dns'; 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'; return 'request'; } -function milliseconds(value) { +function milliseconds(value: unknown) { const seconds = Number(value); return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null; } -function average(values) { - const numbers = values.filter(Number.isFinite); +function average(values: Array) { + 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; } -function mostCommon(values) { - const counts = new Map(); - let selected = null; +function mostCommon(values: T[]): T | null { + const counts = new Map(); + let selected: T | null = null; let selectedCount = 0; for (const value of values) { const count = (counts.get(value) || 0) + 1; @@ -80,12 +162,12 @@ function mostCommon(values) { return selected; } -async function request(probe, path, proxyPort, execute, { +async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, { body = false, ipv4 = false, follow = true, resolve = null, -} = {}) { +}: RequestOptions = {}): Promise { const args = [ '--silent', '--show-error', @@ -114,13 +196,15 @@ async function request(probe, path, proxyPort, execute, { const result = await execute(args); const marker = result.stdout.lastIndexOf(CURL_META_MARKER); const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : ''; - let meta = {}; + let meta: Record = {}; 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 { // 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; return { ok, @@ -134,8 +218,14 @@ async function request(probe, path, proxyPort, execute, { }; } -async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) { - const samples = []; +async function ipProbe( + probe: IpProbe, + path: PathKind, + proxyPort: number, + execute: CurlExecutor, + sampleCount = 1, +): Promise { + const samples: Array = []; for (let attempt = 0; attempt < sampleCount; attempt += 1) { const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 }); 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, }); } - 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); return { 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 ipv4 = probes.filter((probe) => probe.family === 4); const ipv6 = probes.find((probe) => probe.family === 6); return { 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, }, 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 blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES; return Boolean(type && net.isIP(address) === family && !blocked.check(address, type)); } -async function prepareCustomProbes(services, lookup) { +async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise { const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : []; 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}`; let parsed; 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')) { throw new Error('Разрешены только публичные HTTPS-адреса'); } @@ -198,7 +289,7 @@ async function prepareCustomProbes(services, lookup) { const pinned = target.family === 6 ? `[${target.address}]` : target.address; return { id, - label: String(service?.label || '').trim().slice(0, 40) || hostname, + label: String(value.label || '').trim().slice(0, 40) || hostname, url: parsed.href, follow: false, resolve: `${hostname}:443:${pinned}`, @@ -206,19 +297,28 @@ async function prepareCustomProbes(services, lookup) { } catch (error) { return { id, - label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`, - validationError: error.message || 'Некорректный адрес', + label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`, + url: '', + validationError: error instanceof Error ? error.message : 'Некорректный адрес', }; } })); } -function siteStatus(result) { +function siteStatus(result: RequestResult) { 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 { if (probe.validationError) return { id: probe.id, label: probe.label, @@ -235,12 +335,13 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) { for (let attempt = 0; attempt < sampleCount; attempt += 1) { 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)); } - const status = mostCommon(samples.map(siteStatus)); + const status = mostCommon(samples.map(siteStatus)) || 'unavailable'; 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 { id: probe.id, 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 { const [ip, siteResults] = await Promise.all([ publicIps(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 { available: false, 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 (targetId.startsWith('ip:')) { const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3)); @@ -294,7 +400,12 @@ function resolveTarget(targetId, sites) { return null; } -async function probeTarget(target, path, proxyPort, execute) { +async function probeTarget( + target: DiagnosticTarget, + path: PathKind, + proxyPort: number, + execute: CurlExecutor, +): Promise { const ip = target.kind === 'ip' ? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT) : null; @@ -308,7 +419,7 @@ async function probeTarget(target, path, proxyPort, execute) { available: true, internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')), ipv4: { - addresses: ipv4Sources.map(({ address }) => address).filter(Boolean), + addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)), sources: ipv4Sources, }, ipv6: ipv6Source?.address || null, @@ -324,10 +435,19 @@ export function createConnectivityDiagnosticsService({ execute = runCurl, lookup = dnsLookup, now = () => new Date().toISOString(), +}: { + proxyPort: number; + execute?: CurlExecutor; + lookup?: DnsLookup; + now?: () => string; }) { - async function runOnce({ vpnAvailable, services = [], target: targetId = null }) { - const requestedServices = targetId?.startsWith('site:custom-') - ? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId) + async function runOnce({ vpnAvailable, services = [], target: targetId = null }: { + vpnAvailable: boolean; + 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; const customProbes = await prepareCustomProbes(requestedServices, lookup); const siteProbes = [...SITE_PROBES, ...customProbes]; diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.ts similarity index 66% rename from src/server/services/deviceInventoryService.js rename to src/server/services/deviceInventoryService.ts index fed55b0..8be2f08 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.ts @@ -5,6 +5,154 @@ import { HarborError } from '../../shared/errors.js'; import { isDeviceInterface } from '../adapters/neighbors.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; + rebaselineMacs: string[]; +} + +interface ProxyTrafficState { + schemaVersion: number; + lastObservedAt: string | null; + lastError: string | null; + baselinesByMac: Record; + totalsByMac: Record; + 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; +} + +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; + totalsByMac: Record; + 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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + export const DEVICE_INVENTORY_SCHEMA_VERSION = 3; const ONLINE_MS = 2 * 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 MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/; -const POLICY_MODES = new Set(['vpn', 'direct']); -const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']); +const POLICY_MODES: ReadonlySet = new Set(['vpn', 'direct']); +const POLICY_STATUSES: ReadonlySet = new Set(['applied', 'applying', 'pending', 'failed']); const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных'; -const DEFAULT_DEVICE_POLICY = Object.freeze({ +const DEFAULT_DEVICE_POLICY: Readonly = Object.freeze({ desired: 'vpn', applied: 'vpn', status: 'applied', @@ -27,7 +175,7 @@ const DEFAULT_DEVICE_POLICY = Object.freeze({ operationId: null, }); -const DEFAULT_POLICY_STATE = { +const DEFAULT_POLICY_STATE: DevicePolicyState = { schemaVersion: 1, defaultMode: 'vpn', dataplaneEpoch: null, @@ -38,7 +186,7 @@ const DEFAULT_POLICY_STATE = { byMac: {}, }; -const DEFAULT_PROXY_TRAFFIC = { +const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = { schemaVersion: 1, lastObservedAt: null, lastError: null, @@ -47,7 +195,7 @@ const DEFAULT_PROXY_TRAFFIC = { rebaselineMacs: [], }; -const DEFAULT_GLOBAL_TRAFFIC_SOURCE = { +const DEFAULT_GLOBAL_TRAFFIC_SOURCE: GlobalTrafficSource = { epoch: null, lastObservedAt: null, uploadBytes: '0', @@ -56,7 +204,7 @@ const DEFAULT_GLOBAL_TRAFFIC_SOURCE = { rebaselineMacs: [], }; -const DEFAULT_STATE = { +const DEFAULT_STATE: InventoryState = { schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, revision: 0, lastObservedAt: null, @@ -79,23 +227,86 @@ const DEFAULT_STATE = { devices: [], }; -const normalizeMac = (value) => String(value || '').trim().toLowerCase(); -export const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`; -const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0; -const recordEntries = (value) => value && typeof value === 'object' && !Array.isArray(value) - ? Object.entries(value) - : []; -const parseStoredCounter = (value) => { +const normalizeMac = (value: unknown) => String(value || '').trim().toLowerCase(); +export const deviceId = (mac: string) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`; +const isPrivateMac = (mac: string) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0; +const recordEntries = (value: unknown): Array<[string, Record]> => ( + Object.entries(record(value)).map(([key, entry]) => [key, record(entry)]) +); +const parseStoredCounter = (value: unknown) => { const counter = String(value ?? ''); return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null; }; -const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac) - .reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n) +const validTimestamp = (value: unknown): value is string => ( + 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(); -function normalizeGlobalTrafficSource(value, fallback, version) { - const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +function normalizeGlobalTrafficSource( + value: unknown, + fallback: { + epoch: string | null; + lastObservedAt: string | null; + baselinesByMac: Record; + totalsByMac: Record; + rebaselineMacs: string[]; + }, + version: number, +): GlobalTrafficSource { + const source = record(value); const fallbackMacs = new Set([ ...Object.keys(fallback.baselinesByMac), ...Object.keys(fallback.totalsByMac), @@ -111,9 +322,9 @@ function normalizeGlobalTrafficSource(value, fallback, version) { rebaselineMacs: [...fallback.rebaselineMacs], }; } - const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : []) + const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); - const baselinesByMac = {}; + const baselinesByMac: Record = {}; let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object' || Array.isArray(source.baselinesByMac); for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) { @@ -141,12 +352,12 @@ function normalizeGlobalTrafficSource(value, fallback, version) { }; } -function normalizeProxyTraffic(value, devices) { - const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) { +function normalizeProxyTraffic(value: unknown, devices: InventoryDevice[]): ProxyTrafficState { + const proxy = record(value); + if (typeof proxy.schemaVersion === 'number' && Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) { throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`); } - const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : []) + const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); let recovered = value !== undefined && ( proxy !== value || proxy.schemaVersion !== 1 @@ -156,7 +367,7 @@ function normalizeProxyTraffic(value, devices) { if (recovered) { for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac)); } - const baselinesByMac = {}; + const baselinesByMac: Record = {}; for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) { const mac = normalizeMac(rawMac); const uploadBytes = parseStoredCounter(baseline?.uploadBytes); @@ -169,7 +380,7 @@ function normalizeProxyTraffic(value, devices) { } baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes }; } - const totalsByMac = {}; + const totalsByMac: Record = {}; for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) { const mac = normalizeMac(rawMac); const uploadBytes = parseStoredCounter(total?.uploadBytes); @@ -203,9 +414,9 @@ function normalizeProxyTraffic(value, devices) { }; } -function normalizePolicyState(value) { - const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - const byMac = {}; +function normalizePolicyState(value: unknown): DevicePolicyState { + const policy = record(value); + const byMac: Record = {}; let recovered = value !== undefined && ( policy.schemaVersion !== 1 || policy.defaultMode !== 'vpn' @@ -223,9 +434,9 @@ function normalizePolicyState(value) { continue; } byMac[mac] = { - desired: entry.desired, - applied: entry.applied, - status: entry.status, + desired: entry.desired as DevicePolicyMode, + applied: entry.applied as DevicePolicyMode, + status: entry.status as DevicePolicyStatus, appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null, error: typeof entry.error === 'string' ? entry.error : null, operationId: typeof entry.operationId === 'string' ? entry.operationId : null, @@ -246,8 +457,8 @@ function normalizePolicyState(value) { }; } -export function parseOuiVendors(text) { - const vendors = new Map(); +export function parseOuiVendors(text: unknown) { + const vendors = new Map(); 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); 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') { - let vendors; - return (mac) => { + let vendors: Map | undefined; + return (mac: string) => { if (!mac || isPrivateMac(mac)) return null; if (!vendors) { try { @@ -270,20 +481,22 @@ export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') { }; } -export function migrateDeviceInventoryState(value) { - const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0; +export function migrateDeviceInventoryState(value: unknown): InventoryState { + const state = record(value); + const version = typeof state.schemaVersion === 'number' && Number.isSafeInteger(state.schemaVersion) + ? state.schemaVersion + : 0; if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) { throw new Error(`Unsupported device inventory schemaVersion: ${version}`); } - const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic) - ? state.traffic - : {}; + const traffic = record(state.traffic); 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 rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) + const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); let recoveredTraffic = version >= 2 && ( traffic !== state.traffic @@ -293,7 +506,7 @@ export function migrateDeviceInventoryState(value) { if (recoveredTraffic) { for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac)); } - const baselinesByMac = {}; + const baselinesByMac: Record = {}; for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) { const mac = normalizeMac(rawMac); const uploadBytes = parseStoredCounter(baseline?.uploadBytes); @@ -306,7 +519,7 @@ export function migrateDeviceInventoryState(value) { } baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes }; } - const totalsByMac = {}; + const totalsByMac: Record = {}; for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) { const mac = normalizeMac(rawMac); const uploadBytes = parseStoredCounter(total?.uploadBytes); @@ -329,14 +542,14 @@ export function migrateDeviceInventoryState(value) { } } const global = { - gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, { + gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, { epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null, lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null, baselinesByMac, totalsByMac, rebaselineMacs: [...rebaselineMacs], }, version), - proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, { + proxy: normalizeGlobalTrafficSource(record(traffic.global).proxy, { epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null, lastObservedAt: proxyTraffic.lastObservedAt, baselinesByMac: proxyTraffic.baselinesByMac, @@ -348,14 +561,14 @@ export function migrateDeviceInventoryState(value) { ...DEFAULT_STATE, ...state, 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), traffic: { ...DEFAULT_STATE.traffic, ...traffic, lastError: recoveredTraffic ? 'Повреждённый traffic checkpoint восстановлен из корректных данных' - : traffic.lastError || null, + : typeof traffic.lastError === 'string' ? traffic.lastError : null, baselinesByMac, totalsByMac, 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(); if (age <= ONLINE_MS) return 'online'; if (age <= RECENT_MS) return 'recent'; return 'offline'; } -function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) { +function accumulateGlobalTraffic( + source: GlobalTrafficSource, + countersByMac: Map, + epoch: string, + observedAt: string | null, + label: string, +): GlobalTrafficSource { const epochChanged = Boolean(source.epoch && source.epoch !== epoch); - const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac }; - const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs); + const baselinesByMac: Record = epochChanged ? {} : { ...source.baselinesByMac }; + const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs); let uploadBytes = BigInt(source.uploadBytes); let downloadBytes = BigInt(source.downloadBytes); for (const [mac, processTotal] of countersByMac) { @@ -419,14 +638,23 @@ export function createDeviceInventoryService({ applyPolicies = null, vendor = () => null, now = () => new Date(), +}: { + store: InventoryStore; + observe: () => unknown | Promise; + observeTraffic?: (() => unknown | Promise) | null; + observeDomainTraffic?: (() => unknown | Promise) | null; + observePolicy?: (() => unknown | Promise) | null; + applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise) | null; + vendor?: (mac: string) => string | null; + now?: () => Date; }) { - let refreshPromise = null; - let policyQueue = Promise.resolve(); - const trafficHistoryByMac = new Map(); - const trafficCursorByMac = new Map(); - let globalTrafficHistory = []; - let globalTrafficCursor = null; - let domainTrafficSnapshot = { + let refreshPromise: Promise | null = null; + let policyQueue: Promise = Promise.resolve(); + const trafficHistoryByMac = new Map(); + const trafficCursorByMac = new Map(); + let globalTrafficHistory: TrafficSample[] = []; + let globalTrafficCursor: TrafficCursor | null = null; + let domainTrafficSnapshot: Record = { epoch: null, observedAt: null, source: { error: null }, @@ -439,7 +667,7 @@ export function createDeviceInventoryService({ series: [], }; - function captureTrafficHistory(state) { + function captureTrafficHistory(state: InventoryState) { const knownMacs = new Set(state.devices.map(({ mac }) => mac)); for (const device of state.devices) { const traffic = state.traffic.totalsByMac[device.mac]; @@ -481,23 +709,24 @@ export function createDeviceInventoryService({ } } - function serializePolicy(action) { - const result = policyQueue.then(action, action); + function serializePolicy(action: () => Promise | T): Promise { + const result = policyQueue.then(() => action(), () => action()); policyQueue = result.catch(() => {}); return result; } - function policyFor(state, mac) { + function policyFor(state: InventoryState, mac: string): Readonly { return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY; } - function policyIdentity(device) { - return Boolean(device) && device.confidence !== 'ambiguous' + function policyIdentity(device: InventoryDevice | null | undefined) { + if (!device) return false; + return device.confidence !== 'ambiguous' && net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac) && isDeviceInterface(device.interface); } - function directDevices(state) { + function directDevices(state: InventoryState): DirectDevice[] { return state.devices .filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device)) .map(({ id, ip, mac, interface: deviceInterface }) => ({ @@ -508,26 +737,27 @@ export function createDeviceInventoryService({ })); } - function validatePolicyAck(result, requested) { - const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : []; + function validatePolicyAck(result: unknown, requested: DirectDevice[]): PolicyAck { + const value = record(result); + const appliedIds = Array.isArray(value.appliedIds) ? value.appliedIds : []; const expectedIds = new Set(requested.map(({ id }) => id)); - if (typeof result?.epoch !== 'string' || !result.epoch - || typeof result.generation !== 'string' || !result.generation - || !FINGERPRINT_PATTERN.test(result.fingerprint) - || typeof result.observedAt !== 'string' || !result.observedAt - || result.fingerprint !== fingerprintDirectDevices(requested) + if (typeof value.epoch !== 'string' || !value.epoch + || typeof value.generation !== 'string' || !value.generation + || typeof value.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(value.fingerprint) + || typeof value.observedAt !== 'string' || !value.observedAt + || value.fingerprint !== fingerprintDirectDevices(requested) || appliedIds.length !== expectedIds.size || 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'); } - return result; + return value as unknown as PolicyAck; } function snapshot() { const state = migrateDeviceInventoryState(store.read()); 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 traffic = state.traffic.totalsByMac[device.mac]; const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac]; @@ -601,24 +831,27 @@ export function createDeviceInventoryService({ return { ...snapshot(), domainTraffic: domainTrafficSnapshot }; } - function markPolicyEpoch(observed) { - if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return; + function markPolicyEpoch(observed: unknown) { + 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) => { const state = migrateDeviceInventoryState(stored); - if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state; - const appliedIds = new Set(observed.appliedIds); + if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === epoch) return state; + const appliedIds = new Set(acknowledgedIds); const devicesByMac = new Map(state.devices.map((device) => [device.mac, device])); - const byMac = {}; + const byMac: Record = {}; for (const [mac, entry] of Object.entries(state.policy.byMac)) { const device = devicesByMac.get(mac); 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; byMac[mac] = { ...entry, applied, status: entry.desired === applied ? 'applied' : 'pending', - appliedAt: observed.observedAt || entry.appliedAt, + appliedAt: typeof value.observedAt === 'string' ? value.observedAt : entry.appliedAt, error: entry.desired === applied ? null : 'Dataplane перезапущен, маршрут ожидает повторного применения', @@ -630,10 +863,12 @@ export function createDeviceInventoryService({ revision: state.revision + 1, policy: { ...state.policy, - dataplaneEpoch: observed.epoch, - generation: typeof observed.generation === 'string' ? observed.generation : null, - fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null, - lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt, + dataplaneEpoch: epoch, + generation: typeof value.generation === 'string' ? value.generation : null, + fingerprint: typeof value.fingerprint === 'string' && FINGERPRINT_PATTERN.test(value.fingerprint) + ? value.fingerprint + : null, + lastAppliedAt: typeof value.observedAt === 'string' ? value.observedAt : state.policy.lastAppliedAt, lastError: null, byMac, }, @@ -641,12 +876,12 @@ export function createDeviceInventoryService({ }); } - function commitPolicySuccess(result) { + function commitPolicySuccess(result: PolicyAck) { store.update((stored) => { const state = migrateDeviceInventoryState(stored); const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []); const devicesByMac = new Map(state.devices.map((device) => [device.mac, device])); - const byMac = {}; + const byMac: Record = {}; for (const [mac, entry] of Object.entries(state.policy.byMac)) { const device = devicesByMac.get(mac); if (!device) continue; @@ -677,11 +912,11 @@ export function createDeviceInventoryService({ }); } - function commitPolicyFailure(error) { + function commitPolicyFailure(error: unknown) { store.update((stored) => { const state = migrateDeviceInventoryState(stored); - const message = error.message || String(error); - const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, { + const message = errorMessage(error); + const byMac: Record = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, { ...entry, status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed', 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(); markPolicyEpoch(observedPolicy); const state = migrateDeviceInventoryState(store.read()); @@ -713,42 +948,44 @@ export function createDeviceInventoryService({ async function performRefresh() { const [result, trafficResult, policyResult, domainTrafficResult] = await Promise.all([ - Promise.resolve().then(() => observe()).catch((error) => ({ + Promise.resolve().then(() => observe()).catch((error: unknown) => ({ observedAt: now().toISOString(), observations: [], - error: error.message || String(error), - })), + error: errorMessage(error), + })).then(record), observeTraffic ? Promise.resolve().then(() => observeTraffic()) - .catch((error) => ({ transportError: error.message || String(error) })) + .catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record) : null, observePolicy ? Promise.resolve().then(() => observePolicy()) - .catch((error) => ({ transportError: error.message || String(error) })) + .catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record) : null, observeDomainTraffic ? Promise.resolve().then(() => observeDomainTraffic()) - .catch((error) => ({ transportError: error.message || String(error) })) + .catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record) : null, ]); - const observedAt = result?.observedAt || now().toISOString(); - const observations = (Array.isArray(result?.observations) ? result.observations : []) - .filter((observation) => isDeviceInterface(observation?.interface)); - const identitiesByMac = new Map(); + const observedAt = validTimestamp(result.observedAt) ? result.observedAt : now().toISOString(); + const observations = (Array.isArray(result.observations) ? result.observations : []) + .map(normalizeDeviceObservation) + .filter((observation): observation is DeviceObservation => observation !== null); + const identitiesByMac = new Map>(); for (const observation of observations) { const mac = normalizeMac(observation.mac); if (!mac || !net.isIPv4(String(observation.ip || ''))) continue; - if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set()); - identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`); + const identities = identitiesByMac.get(mac) || new Set(); + identities.add(`${String(observation.ip)}|${observation.interface || ''}`); + identitiesByMac.set(mac, identities); } return serializePolicy(async () => { - if (domainTrafficResult?.transportError) { + if (typeof domainTrafficResult?.transportError === 'string') { domainTrafficSnapshot = { ...domainTrafficSnapshot, source: { error: domainTrafficResult.transportError }, }; } else if (domainTrafficResult) { - domainTrafficSnapshot = domainTrafficResult; + domainTrafficSnapshot = record(domainTrafficResult); } const nextState = store.update((stored) => { const state = migrateDeviceInventoryState(stored); @@ -757,8 +994,9 @@ export function createDeviceInventoryService({ const mac = normalizeMac(observation.mac); if (!mac) continue; const previous = byMac.get(mac); + const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt; const lastSeenAt = observation.active || !previous - ? observation.observedAt || observedAt + ? observationTime : previous.lastSeenAt; byMac.set(mac, { id: previous?.id || deviceId(mac), @@ -769,10 +1007,10 @@ export function createDeviceInventoryService({ mac, ip: String(observation.ip || previous?.ip || ''), interface: String(observation.interface || previous?.interface || ''), - firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt, + firstSeenAt: previous?.firstSeenAt || observationTime, lastSeenAt, source: 'neighbor', - confidence: identitiesByMac.get(mac)?.size > 1 + confidence: (identitiesByMac.get(mac)?.size || 0) > 1 ? 'ambiguous' : isPrivateMac(mac) ? 'medium' : 'high', }); @@ -783,23 +1021,33 @@ export function createDeviceInventoryService({ )); let traffic = state.traffic; if (trafficResult) { - if (trafficResult.transportError) { + if (typeof trafficResult.transportError === 'string') { traffic = { ...traffic, lastError: trafficResult.transportError }; } else { try { if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) { throw new Error('Dataplane не вернул traffic epoch'); } - const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : []; - const processByMac = new Map(); - const proxyByMac = new Map(); + const trafficEpoch = trafficResult.epoch; + const trafficObservedAt = typeof trafficResult.observedAt === 'string' + ? 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(); + const proxyByMac = new Map(); let proxyRows = 0; let legacyRows = 0; - let proxySampleError = null; + let proxySampleError: string | null = null; for (const row of rows) { - const mac = normalizeMac(row?.mac); - const upload = String(row?.uploadBytes ?? ''); - const download = String(row?.downloadBytes ?? ''); + const mac = normalizeMac(row.mac); + const upload = String(row.uploadBytes ?? ''); + const download = String(row.downloadBytes ?? ''); if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) { throw new Error('Dataplane вернул невалидный traffic counter'); } @@ -808,8 +1056,8 @@ export function createDeviceInventoryService({ upload: previous.upload + BigInt(upload), download: previous.download + BigInt(download), }); - const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes'); - const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes'); + const hasProxyUpload = Object.hasOwn(row, 'proxyUploadBytes'); + const hasProxyDownload = Object.hasOwn(row, 'proxyDownloadBytes'); if (!hasProxyUpload && !hasProxyDownload) { legacyRows += 1; continue; @@ -840,7 +1088,7 @@ export function createDeviceInventoryService({ if (!knownMacs.has(mac)) continue; const baseline = baselinesByMac[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 baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n; if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) { @@ -852,10 +1100,10 @@ export function createDeviceInventoryService({ + (recovering ? 0n : processTotal.upload - baselineUpload)).toString(), downloadBytes: (BigInt(total.downloadBytes) + (recovering ? 0n : processTotal.download - baselineDownload)).toString(), - observedAt: trafficResult.observedAt || traffic.lastObservedAt, + observedAt: trafficObservedAt || traffic.lastObservedAt, }; baselinesByMac[mac] = { - epoch: trafficResult.epoch, + epoch: trafficEpoch, uploadBytes: processTotal.upload.toString(), downloadBytes: processTotal.download.toString(), }; @@ -864,8 +1112,8 @@ export function createDeviceInventoryService({ const globalGateway = accumulateGlobalTraffic( traffic.global.gateway, processByMac, - trafficResult.epoch, - trafficResult.observedAt || traffic.lastObservedAt, + trafficEpoch, + trafficObservedAt || traffic.lastObservedAt, 'Gateway', ); for (const mac of Object.keys(totalsByMac)) { @@ -916,7 +1164,7 @@ export function createDeviceInventoryService({ if (!knownMacs.has(mac)) continue; const baseline = nextProxyBaselines[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 baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n; if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) { @@ -928,10 +1176,10 @@ export function createDeviceInventoryService({ + (recovering ? 0n : processTotal.upload - baselineUpload)).toString(), downloadBytes: (BigInt(total.downloadBytes) + (recovering ? 0n : processTotal.download - baselineDownload)).toString(), - observedAt: trafficResult.observedAt || proxy.lastObservedAt, + observedAt: trafficObservedAt || proxy.lastObservedAt, }; nextProxyBaselines[mac] = { - epoch: trafficResult.epoch, + epoch: trafficEpoch, uploadBytes: processTotal.upload.toString(), downloadBytes: processTotal.download.toString(), }; @@ -940,14 +1188,14 @@ export function createDeviceInventoryService({ const nextGlobalProxy = accumulateGlobalTraffic( traffic.global.proxy, proxyByMac, - trafficResult.epoch, - trafficResult.observedAt || proxy.lastObservedAt, + trafficEpoch, + trafficObservedAt || proxy.lastObservedAt, 'proxy', ); proxy = { ...proxy, - lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt, - lastError: trafficResult.source?.error + lastObservedAt: trafficObservedAt || proxy.lastObservedAt, + lastError: trafficSourceError || (nextProxyRebaseline.size ? proxy.lastError : null), baselinesByMac: nextProxyBaselines, totalsByMac: nextProxyTotals, @@ -955,15 +1203,15 @@ export function createDeviceInventoryService({ }; globalProxy = nextGlobalProxy; } catch (error) { - proxy = { ...proxy, lastError: error.message || String(error) }; + proxy = { ...proxy, lastError: errorMessage(error) }; } } traffic = { ...traffic, - epoch: trafficResult.epoch, - generation: trafficResult.generation || traffic.generation, - lastObservedAt: trafficResult.observedAt || traffic.lastObservedAt, - lastError: trafficResult.source?.error + epoch: trafficEpoch, + generation: trafficGeneration || traffic.generation, + lastObservedAt: trafficObservedAt || traffic.lastObservedAt, + lastError: trafficSourceError || (rebaselineMacs.size ? traffic.lastError : null), baselinesByMac, totalsByMac, @@ -972,7 +1220,7 @@ export function createDeviceInventoryService({ global: { gateway: globalGateway, proxy: globalProxy }, }; } catch (error) { - traffic = { ...traffic, lastError: error.message || String(error) }; + traffic = { ...traffic, lastError: errorMessage(error) }; } } } @@ -980,13 +1228,15 @@ export function createDeviceInventoryService({ ...state, revision: state.revision + 1, lastObservedAt: observedAt, - lastError: result?.error || null, + lastError: typeof result.error === 'string' ? result.error : null, traffic, devices, }; }); captureTrafficHistory(nextState); - if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError)); + if (typeof policyResult?.transportError === 'string') { + commitPolicyFailure(new Error(policyResult.transportError)); + } return reconcileLocked(policyResult, false); }); } @@ -1000,52 +1250,59 @@ export function createDeviceInventoryService({ return refreshPromise; } - function update(id, patch, expectedRevision) { + function update(id: string, patch: unknown, expectedRevision: unknown) { if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { throw new HarborError('REQUEST_INVALID'); } - const aliasProvided = Object.hasOwn(patch, 'alias'); - const pinProvided = Object.hasOwn(patch, 'pinned'); - if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 + const value = record(patch); + const aliasProvided = Object.hasOwn(value, 'alias'); + const pinProvided = Object.hasOwn(value, 'pinned'); + if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || (!aliasProvided && !pinProvided) - || (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64)) - || (pinProvided && typeof patch.pinned !== 'boolean')) { + || (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64)) + || (pinProvided && typeof value.pinned !== 'boolean')) { throw new HarborError('REQUEST_INVALID'); } + const revision = expectedRevision; + const alias = typeof value.alias === 'string' ? value.alias : ''; + const pinned = value.pinned === true; store.update((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); if (index < 0) throw new HarborError('DEVICE_NOT_FOUND'); const devices = [...state.devices]; devices[index] = { ...devices[index], - ...(aliasProvided ? { alias: patch.alias.trim() } : {}), - ...(pinProvided ? { pinned: patch.pinned } : {}), + ...(aliasProvided ? { alias: alias.trim() } : {}), + ...(pinProvided ? { pinned } : {}), }; return { ...state, revision: state.revision + 1, devices }; }); return snapshot(); } - function setPolicy(id, mode, expectedRevision) { - if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) { + function setPolicy(id: string, mode: unknown, expectedRevision: unknown) { + if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) + || expectedRevision < 0 || !POLICY_MODES.has(mode)) { throw new HarborError('REQUEST_INVALID'); } + const revision = expectedRevision; + const desiredMode = mode as DevicePolicyMode; return serializePolicy(async () => { store.update((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); 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); - if (current.desired === mode && current.status === 'applied') return state; - const byMac = { + if (current.desired === desiredMode && current.status === 'applied') return state; + const byMac: Record = { ...state.policy.byMac, [device.mac]: { ...current, - desired: mode, + desired: desiredMode, status: 'applying', error: null, operationId: crypto.randomUUID(), diff --git a/src/server/services/devicePolicyService.js b/src/server/services/devicePolicyService.ts similarity index 68% rename from src/server/services/devicePolicyService.js rename to src/server/services/devicePolicyService.ts index 7e26fe6..75240da 100644 --- a/src/server/services/devicePolicyService.js +++ b/src/server/services/devicePolicyService.ts @@ -1,33 +1,56 @@ import crypto from 'node:crypto'; 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'; -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 MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i; const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i; const MAX_DEVICES = 512; -const childChain = (chain, slot) => `${chain}_${slot}`; -const fingerprint = (devices) => crypto.createHash('sha256') +export interface DirectDevice { + 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)) .digest('hex'); -function commandError(command, result) { +function commandError(command: string, result: SpawnSyncReturns) { return new Error(String( result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`, ).trim()); } -export function normalizeDirectDevices(value) { +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +export function normalizeDirectDevices(value: unknown): DirectDevice[] { if (!Array.isArray(value) || value.length > MAX_DEVICES) { throw new Error('Некорректный набор device policy'); } - const ids = new Set(); - const tuples = new Set(); - const devices = value.map((device) => { + const ids = new Set(); + const tuples = new Set(); + const devices = value.map((value) => { + const device = record(value); const normalized = { id: String(device?.id || ''), ip: String(device?.ip || ''), @@ -47,9 +70,15 @@ export function normalizeDirectDevices(value) { 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 rules = ['*mangle', `-F ${child}`]; for (const device of devices) { @@ -71,6 +100,13 @@ export function createDevicePolicyService({ run = spawnSync, now = () => new Date(), nextGeneration = () => crypto.randomUUID(), +}: { + chain: string; + tproxyPort: number; + tproxyMark: string; + run?: typeof spawnSync; + now?: () => Date; + nextGeneration?: () => string; }) { if (!CHAIN_PATTERN.test(String(chain || '')) || !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535 @@ -78,19 +114,19 @@ export function createDevicePolicyService({ throw new Error('Некорректная конфигурация device policy'); } const epoch = nextGeneration(); - let activeSlot = 'A'; + let activeSlot: 'A' | 'B' = 'A'; let activeSignature = JSON.stringify([]); let generation = epoch; - let appliedDevices = []; + let appliedDevices: DirectDevice[] = []; let observedAt = now().toISOString(); - let queue = Promise.resolve(); + let queue: Promise = 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 }); if (result.error || result.status !== 0) throw commandError(command, result); } - function snapshot(changed = false) { + function snapshot(changed = false): PolicySnapshot { return { epoch, generation, @@ -101,7 +137,7 @@ export function createDevicePolicyService({ }; } - function performApply(value) { + function performApply(value: unknown) { const devices = normalizeDirectDevices(value); const signature = JSON.stringify(devices); if (signature === activeSignature) return snapshot(false); @@ -124,7 +160,7 @@ export function createDevicePolicyService({ return snapshot(true); } - function apply(devices) { + function apply(devices: unknown): Promise { const result = queue.then(() => performApply(devices)); queue = result.catch(() => {}); return result; diff --git a/src/server/services/deviceTrafficService.js b/src/server/services/deviceTrafficService.ts similarity index 64% rename from src/server/services/deviceTrafficService.js rename to src/server/services/deviceTrafficService.ts index d5ba3bf..a1076f0 100644 --- a/src/server/services/deviceTrafficService.js +++ b/src/server/services/deviceTrafficService.ts @@ -1,9 +1,51 @@ import crypto from 'node:crypto'; import net from 'node:net'; -import { spawn } from 'node:child_process'; -import { isDeviceInterface } from '../adapters/neighbors.js'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +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; + +interface RetiredCounters { + slot: 'A' | 'B'; + devices: TrafficDevice[]; + counters: Map; +} + +interface TrafficSnapshot { + epoch: string; + generation: string; + observedAt: string | null; + source: { error: string | null }; + devices: Record[]; +} + +type RunCommand = (command: string, args: string[], options?: CommandOptions) => Promise; + +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 CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i; const COUNTERS = [ @@ -11,37 +53,38 @@ const COUNTERS = [ ['download', 'download', 'downloadBytes'], ['proxy-upload', 'proxyUpload', 'proxyUploadBytes'], ['proxy-download', 'proxyDownload', 'proxyDownloadBytes'], -]; +] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[]; -const childChain = (chain, slot) => `${chain}_${slot}`; -const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`; -const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto +const childChain = (chain: string, slot: string) => `${chain}_${slot}`; +const proxyChildChain = (chain: string, slot: string) => `${childChain(chain, slot)}_P`; +const counterKey = ({ ip, mac, interface: deviceInterface }: Omit) => crypto .createHash('sha256') .update(`${ip}|${mac}|${deviceInterface}`) .digest('hex') .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( - result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`, + result.stderr || result.stdout || cause || `${command} завершился с ошибкой`, ).trim()); } -function runCommand(command, args, options = COMMAND_OPTIONS) { +function runCommand(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS): Promise { return new Promise((resolve) => { - let child; + let child: ChildProcessWithoutNullStreams; try { child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); } catch (error) { resolve({ status: null, stdout: '', stderr: '', error }); return; } - const stdout = []; - const stderr = []; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; let settled = false; let timedOut = false; - let timer; - const finish = (result) => { + let timer: ReturnType | undefined; + const finish = (result: Pick & { error?: unknown }) => { if (settled) return; settled = true; clearTimeout(timer); @@ -51,8 +94,8 @@ function runCommand(command, args, options = COMMAND_OPTIONS) { ...result, }); }; - child.stdout.on('data', (chunk) => stdout.push(chunk)); - child.stderr.on('data', (chunk) => stderr.push(chunk)); + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); child.on('error', (error) => finish({ status: null, error })); child.on('close', (status) => finish({ 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 size = Number(prefix); return extra === undefined && net.isIPv4(address) && 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) { - const candidates = new Map(); - const ipsByMac = new Map(); - const locationsByIp = new Map(); +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} - for (const observation of Array.isArray(observations) ? observations : []) { - const ip = String(observation?.ip || ''); - const mac = String(observation?.mac || '').toLowerCase(); - const deviceInterface = String(observation?.interface || ''); +export function selectTrafficDevices(observations: unknown): TrafficDevice[] { + const candidates = new Map>(); + const ipsByMac = new Map>(); + const locationsByIp = new Map>(); + + 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; const location = `${mac}|${deviceInterface}`; candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface }); 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()); - locationsByIp.get(ip).add(location); + locationsByIp.get(ip)?.add(location); } 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) })) .sort((left, right) => ( left.ip.localeCompare(right.ip) @@ -112,6 +162,13 @@ export function buildTrafficRestore({ downloadChain, slot, proxyPort, +}: { + devices: readonly TrafficDevice[]; + bypassCidrs: readonly string[]; + uploadChain: string; + downloadChain: string; + slot: string; + proxyPort: number; }) { if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain) || !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort) @@ -160,12 +217,12 @@ export function buildTrafficRestore({ return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n'); } -export function parseTrafficCounters(text, chain) { +export function parseTrafficCounters(text: unknown, chain: string): Map { const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const linePattern = new RegExp( `^\\[(\\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(); for (const line of String(text || '').split(/\r?\n/)) { const match = line.match(linePattern); if (!match) continue; @@ -183,17 +240,25 @@ export function createDeviceTrafficService({ proxyPort, run = runCommand, nextGeneration = () => crypto.randomUUID(), +}: { + observe: () => Promise | unknown; + uploadChain: string; + downloadChain: string; + bypassCidrs: string[]; + proxyPort: number; + run?: RunCommand; + nextGeneration?: () => string; }) { const epoch = nextGeneration(); - let activeSlot = null; - let activeDevices = []; + let activeSlot: 'A' | 'B' | null = null; + let activeDevices: TrafficDevice[] = []; let activeSignature = ''; - let activeCounters = new Map(); - let pendingRetired = null; - let refreshPromise = null; - const finalized = new Map(); - const devicesByKey = new Map(); - let current = { + let activeCounters = new Map(); + let pendingRetired: RetiredCounters | null = null; + let refreshPromise: Promise | null = null; + const finalized = new Map(); + const devicesByKey = new Map(); + let current: TrafficSnapshot = { epoch, generation: epoch, observedAt: null, @@ -201,13 +266,13 @@ export function createDeviceTrafficService({ 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); if (result.error || result.status !== 0) throw commandError(command, result); return String(result.stdout || ''); } - async function prepare(slot, devices) { + async function prepare(slot: 'A' | 'B', devices: TrafficDevice[]) { const input = buildTrafficRestore({ devices, bypassCidrs, @@ -219,7 +284,7 @@ export function createDeviceTrafficService({ 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 downloadChild = childChain(downloadChain, slot); const replace = activeSlot ? '-R' : '-A'; @@ -242,8 +307,8 @@ export function createDeviceTrafficService({ } } - async function readCounters(devices, slot) { - if (!slot) return new Map(); + async function readCounters(devices: TrafficDevice[], slot: 'A' | 'B' | null): Promise> { + if (!slot) return new Map(); const [raw, mangle] = await Promise.all([ execute('iptables-save', ['-c', '-t', 'raw']), execute('iptables-save', ['-c', '-t', 'mangle']), @@ -254,7 +319,7 @@ export function createDeviceTrafficService({ proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)), proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)), }; - const counters = new Map(); + const counters = new Map(); for (const { key } of devices) { for (const [kind, field] of COUNTERS) { counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0'); @@ -263,11 +328,11 @@ export function createDeviceTrafficService({ return counters; } - function counter(counters, key, direction) { + function counter(counters: Map, key: string, direction: CounterKind) { return BigInt(counters.get(`${key}:${direction}`) || '0'); } - function remember(devices) { + function remember(devices: TrafficDevice[]) { 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); for (const { key } of pendingRetired.devices) { 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); finalized.set(key, next); } @@ -286,12 +351,15 @@ export function createDeviceTrafficService({ function processTotals() { const activeByMac = new Map(activeDevices.map((device) => [device.mac, device])); - const totalsByMac = new Map(); + const totalsByMac = new Map(); for (const [key, remembered] of devicesByKey) { const base = finalized.get(key) || zeroCounters(); const pending = pendingRetired?.counters || new Map(); - const previous = totalsByMac.get(remembered.mac) || zeroCounters(); - const total = { ...(activeByMac.get(remembered.mac) || remembered) }; + const previous = totalsByMac.get(remembered.mac) || { ...remembered, ...zeroCounters() }; + const total: TrafficDevice & CounterValues = { + ...(activeByMac.get(remembered.mac) || remembered), + ...zeroCounters(), + }; for (const [kind, field] of COUNTERS) { total[field] = previous[field] + base[field] + counter(pending, key, kind) + counter(activeCounters, key, kind); @@ -299,22 +367,28 @@ export function createDeviceTrafficService({ totalsByMac.set(remembered.mac, total); } return [...totalsByMac.values()] - .map((total) => Object.fromEntries([ - ...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)), - ...COUNTERS.map(([, field, output]) => [output, total[field].toString()]), - ])) + .map((total) => { + const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total; + return { + ...device, + uploadBytes: upload.toString(), + downloadBytes: download.toString(), + proxyUploadBytes: proxyUpload.toString(), + proxyDownloadBytes: proxyDownload.toString(), + }; + }) .sort((left, right) => left.mac.localeCompare(right.mac)); } async function performRefresh() { - let observed; + let observed: Record; try { - observed = await observe(); + observed = record(await observe()); } 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 ? activeDevices : selectTrafficDevices(observed?.observations); @@ -325,7 +399,7 @@ export function createDeviceTrafficService({ try { countersRead = await finalizeRetired() || countersRead; } 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(); if (pendingRetired) countersRead = await finalizeRetired() || countersRead; } 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); countersRead = true; } catch (error) { - sourceError = sourceError || error.message || String(error); + sourceError = sourceError || (error instanceof Error ? error.message : String(error)); } current = { epoch, generation: current.generation, - observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt, + observedAt: countersRead && typeof observed.observedAt === 'string' + ? observed.observedAt + : current.observedAt, source: { error: sourceError }, devices: countersRead ? processTotals() : current.devices, }; diff --git a/src/server/services/domainTrafficService.js b/src/server/services/domainTrafficService.ts similarity index 64% rename from src/server/services/domainTrafficService.js rename to src/server/services/domainTrafficService.ts index e0361c2..73aaffc 100644 --- a/src/server/services/domainTrafficService.js +++ b/src/server/services/domainTrafficService.ts @@ -7,15 +7,67 @@ import { deviceId } from './deviceInventoryService.js'; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const DEFAULT_MAX_SERIES = 4096; 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 = [ ['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.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; + series: Array & { + uploadBytes: string; + downloadBytes: string; + }>; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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(); if (domain.startsWith('www.')) domain = domain.slice(4); const labels = domain.split('.'); @@ -28,19 +80,20 @@ export function classifyDomain(value) { return { domain, service: domain }; } -function sourceFor(type) { +function sourceFor(type: string): 'gateway' | 'proxy' | null { if (type === 'tproxy/tproxy-in') return 'gateway'; if (type === 'mixed/mixed-in') return 'proxy'; return null; } -function parseConnection(connection, devicesByIp) { - const id = String(connection?.id || ''); - const metadata = connection?.metadata; - const upload = connection?.upload; - const download = connection?.download; - if (!id || !Number.isSafeInteger(upload) || upload < 0 - || !Number.isSafeInteger(download) || download < 0) { +function parseConnection(value: unknown, devicesByIp: Map): ParsedConnection { + const connection = record(value); + const id = String(connection.id || ''); + const metadata = record(connection.metadata); + const upload = connection.upload; + const download = connection.download; + 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'); } const parsed = { @@ -48,11 +101,11 @@ function parseConnection(connection, devicesByIp) { upload: BigInt(upload), download: BigInt(download), }; - const source = sourceFor(String(metadata?.type || '')); + const source = sourceFor(String(metadata.type || '')); 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' }; - const classifiedDomain = classifyDomain(metadata?.host); + const classifiedDomain = classifyDomain(metadata.host); const domain = classifiedDomain || UNKNOWN_DOMAIN; return { ...parsed, @@ -63,13 +116,13 @@ function parseConnection(connection, devicesByIp) { }; } -export function readSingboxConnections(port, timeoutMs = 1500) { +export function readSingboxConnections(port: number, timeoutMs = 1500): Promise { return new Promise((resolve, reject) => { const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => { - const chunks = []; + const chunks: Buffer[] = []; let size = 0; let tooLarge = false; - response.on('data', (chunk) => { + response.on('data', (chunk: Buffer) => { if (tooLarge) return; size += chunk.length; if (size > MAX_RESPONSE_BYTES) { @@ -102,26 +155,35 @@ export function createDomainTrafficService({ devices, now = () => new Date(), maxSeries = DEFAULT_MAX_SERIES, +}: { + observe: () => Promise | unknown; + devices: () => unknown; + now?: () => Date; + maxSeries?: number; }) { if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2'); const epoch = crypto.randomUUID(); - const totals = new Map(); + const totals = new Map(); const normalSeriesLimit = maxSeries - 2; let normalSeries = 0; - let previousConnections = new Map(); + let previousConnections = new Map(); let overflowConnections = 0n; - const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n])); - let refreshPromise = null; - let current = { + const attributionEvents: Record = { + unresolved_host: 0n, + unknown_device: 0n, + unsupported_source: 0n, + }; + let refreshPromise: Promise | null = null; + let current: DomainTrafficSnapshot = { epoch, observedAt: null, source: { error: null }, overflowConnections: '0', - attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])), + attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, series: [], }; - function buildSnapshot(error = null) { + function buildSnapshot(error: string | null = null): DomainTrafficSnapshot { return { epoch, observedAt: current.observedAt, @@ -129,7 +191,7 @@ export function createDomainTrafficService({ overflowConnections: overflowConnections.toString(), attributionEvents: Object.fromEntries( ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), - ), + ) as Record, series: [...totals.values()] .map((entry) => ({ ...entry, @@ -147,17 +209,18 @@ export function createDomainTrafficService({ async function performRefresh() { try { - const response = await observe(); - if (!Array.isArray(response?.connections)) throw new Error('Sing-box не вернул connections array'); - const devicesByIp = new Map(); + const response = record(await observe()); + if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array'); + const devicesByIp = new Map(); const observedDevices = devices(); - for (const device of Array.isArray(observedDevices) ? observedDevices : []) { - const ip = String(device?.ip || ''); - const id = typeof device?.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null; + for (const value of Array.isArray(observedDevices) ? observedDevices : []) { + const device = record(value); + const ip = String(device.ip || ''); + const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null; if (!net.isIPv4(ip) || !id) continue; devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); } - const activeConnections = new Map(); + const activeConnections = new Map(); for (const rawConnection of response.connections) { const connection = parseConnection(rawConnection, devicesByIp); const previous = previousConnections.get(connection.id); @@ -172,8 +235,9 @@ export function createDomainTrafficService({ }); continue; } + if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись'); const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`; - let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey; + let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey; let domain = connection.domain; let service = connection.service; if (key !== requestedKey) { @@ -217,7 +281,7 @@ export function createDomainTrafficService({ current = buildSnapshot(); return current; } catch (error) { - current = buildSnapshot(error.message || String(error)); + current = buildSnapshot(error instanceof Error ? error.message : String(error)); throw error; } } diff --git a/src/server/services/rollback.ts b/src/server/services/rollback.ts new file mode 100644 index 0000000..d69a8c0 --- /dev/null +++ b/src/server/services/rollback.ts @@ -0,0 +1,29 @@ +import { HarborError } from '../../shared/errors.js'; + +export interface RollbackStep { + run(): unknown | Promise; + runtime?: boolean; +} + +export async function finishRollback( + originalError: unknown, + steps: RollbackStep[], + message: string, +): Promise { + 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; +} diff --git a/src/server/services/stateStore.js b/src/server/services/stateStore.js deleted file mode 100644 index b425e07..0000000 --- a/src/server/services/stateStore.js +++ /dev/null @@ -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, - }); -} diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts new file mode 100644 index 0000000..9b6028c --- /dev/null +++ b/src/server/services/stateStore.ts @@ -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 extends JsonStoreBaseOptions { + defaultValue: T; + migrate: (value: unknown) => T; +} + +export interface RawJsonStoreOptions extends JsonStoreBaseOptions { + defaultValue: unknown; + migrate?: never; +} + +export interface JsonStore { + 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 = (value: T): T => structuredClone(value); +const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-'); + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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(options: JsonStoreOptions): JsonStore; +export function createJsonStore(options: RawJsonStoreOptions): JsonStore; +export function createJsonStore(options: JsonStoreOptions | RawJsonStoreOptions): JsonStore { + 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, 'filePath' | 'defaultValue' | 'migrate'>> = {}, +) { + return createJsonStore({ + filePath, + defaultValue: migrateStoredState({}), + migrate: migrateStoredState, + initializeMissing: true, + backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion, + ...options, + }); +} diff --git a/src/server/sharedProxy.js b/src/server/sharedProxy.ts similarity index 78% rename from src/server/sharedProxy.js rename to src/server/sharedProxy.ts index b9c1c66..b762f68 100644 --- a/src/server/sharedProxy.js +++ b/src/server/sharedProxy.ts @@ -1,4 +1,4 @@ -function proxyHostFromHeader(hostHeader) { +function proxyHostFromHeader(hostHeader: unknown) { const raw = String(hostHeader || "").trim(); if (!raw) return ""; if (raw.startsWith("[")) { @@ -14,9 +14,15 @@ export function buildSharedProxyInfo({ running, hostHeader, sharedProxyHost, +}: { + appMode: unknown; + proxyPort: unknown; + running: unknown; + hostHeader: unknown; + sharedProxyHost: unknown; }) { const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader); - const port = Number.parseInt(proxyPort, 10); + const port = Number.parseInt(String(proxyPort), 10); const available = appMode === "gateway" && Boolean(running) && diff --git a/src/server/singbox.js b/src/server/singbox.ts similarity index 80% rename from src/server/singbox.js rename to src/server/singbox.ts index 38ad0ef..c7cbe6f 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.ts @@ -11,20 +11,33 @@ const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in'; const SNIFF_TIMEOUT = '1s'; const SNIFFERS = ['http', 'tls', 'quic']; -function findOutbound(subscriptionConfig, selectedTag) { - const outbounds = Array.isArray(subscriptionConfig?.outbounds) - ? subscriptionConfig.outbounds +interface ProxyOutbound extends Record { + tag?: string; + type?: string; + packet_encoding?: string; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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(); 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, routeRules = [], -} = {}) { +}: { clientDirect?: boolean; routeRules?: unknown } = {}) { const clientMode = settings.appMode === 'client'; const directClient = clientMode && clientDirect; 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); } -export function restoreSingboxConfig(contents) { +export function restoreSingboxConfig(contents: string) { atomicWriteFile(settings.configPath, contents); } diff --git a/src/server/singboxRuntime.js b/src/server/singboxRuntime.ts similarity index 85% rename from src/server/singboxRuntime.js rename to src/server/singboxRuntime.ts index be8205a..d9bf59a 100644 --- a/src/server/singboxRuntime.js +++ b/src/server/singboxRuntime.ts @@ -1,13 +1,21 @@ import crypto from 'node:crypto'; 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 { HarborError } from '../shared/errors.js'; -export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) { - let child = null; +export function createSingboxRuntime({ + configPath, + gateway = false, + tproxyChain = '', +}: { + configPath: string; + gateway?: boolean; + tproxyChain?: string; +}) { + let child: ChildProcess | null = null; let configHash = ''; - let startedAt = null; + let startedAt: string | null = null; const state = () => ({ running: Boolean(child), startedAt }); @@ -23,7 +31,7 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain child = null; configHash = ''; startedAt = null; - await new Promise((resolve) => { + await new Promise((resolve) => { const timeout = setTimeout(() => { current.kill('SIGKILL'); resolve(); @@ -54,12 +62,12 @@ export function createSingboxRuntime({ configPath, gateway = false, tproxyChain if (!force && child && nextHash === configHash) return state(); await stop(); - let current; + let current: ChildProcess; try { current = spawn('sing-box', ['run', '-c', configPath], { stdio: ['ignore', 'inherit', 'inherit'], }); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { current.once('spawn', resolve); current.once('error', reject); }); diff --git a/src/server/subscription.js b/src/server/subscription.ts similarity index 72% rename from src/server/subscription.js rename to src/server/subscription.ts index 1ba078c..3c0f6d7 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.ts @@ -6,24 +6,49 @@ import { createServerId, normalizeServer, serverIdentityKey, + type NormalizedServer, } from '../shared/serverIdentity.js'; +import type { HarborServer } from '../shared/contracts/state.js'; import { atomicWriteFile } from './services/stateStore.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']); -function usableProxyOutbound(outbound) { - const host = String(outbound?.server || '').trim().toLowerCase(); - const port = Number(outbound?.server_port); +interface SubscriptionOutbound extends Record { + type?: unknown; + tag?: unknown; + server?: unknown; + server_port?: unknown; +} + +interface FetchSubscriptionOptions { + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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; } -function rejectedSubscriptionCode(outbounds) { - const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' '); +function rejectedSubscriptionCode(outbounds: unknown[]) { + const labels = outbounds.map((value) => String(outboundRecord(value).tag || '').toLowerCase()).join(' '); if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED'; if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED'; 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_INVALID'; } @@ -48,8 +73,8 @@ export function subscriptionHeaders() { }; } -export function parseUserInfo(headerValue) { - const result = {}; +export function parseUserInfo(headerValue: unknown): Record { + const result: Record = {}; if (!headerValue) return result; for (const part of String(headerValue).split(';')) { @@ -62,7 +87,7 @@ export function parseUserInfo(headerValue) { return result; } -export function parseVlessUrl(rawUrl) { +export function parseVlessUrl(rawUrl: string) { if (!rawUrl.startsWith('vless://')) { 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, ''); if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content; @@ -127,18 +152,19 @@ function maybeDecodeBase64(content) { return content; } -export function normalizeSubscriptionConfig(value) { - const parsedConfig = value && typeof value === 'object' ? value : {}; +export function normalizeSubscriptionConfig(value: unknown) { + const parsedConfig = record(value); const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : []; - const servers = []; - const rejectedOutbounds = []; - const seen = new Set(); - const normalizedOutbounds = outbounds.flatMap((outbound) => { - if (!outbound || typeof outbound !== 'object') { - rejectedOutbounds.push(outbound); + const servers: NormalizedServer[] = []; + const rejectedOutbounds: unknown[] = []; + const seen = new Set(); + const normalizedOutbounds = outbounds.flatMap((value): Record[] => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + rejectedOutbounds.push(value); 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)) { rejectedOutbounds.push(outbound); return []; @@ -155,8 +181,8 @@ export function normalizeSubscriptionConfig(value) { return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers }; } -export function parseSubscriptionBody(body) { - let parsedConfig; +export function parseSubscriptionBody(body: string) { + let parsedConfig: unknown; try { parsedConfig = JSON.parse(body); @@ -179,8 +205,11 @@ export function parseSubscriptionBody(body) { return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig }; } -async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) { - let parsedUrl; +async function requestSubscription( + url: string, + { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs }: FetchSubscriptionOptions = {}, +) { + let parsedUrl: URL; try { parsedUrl = new URL(url); } catch (cause) { @@ -191,7 +220,7 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting throw new HarborError('SUBSCRIPTION_INVALID'); } - let response; + let response: Response; try { response = await fetchImpl(parsedUrl, { headers: subscriptionHeaders(), @@ -209,7 +238,11 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting return response; } -export function selectRefreshedServer(currentServerId, currentServers, nextServers) { +export function selectRefreshedServer( + currentServerId: string, + currentServers: readonly HarborServer[], + nextServers: readonly HarborServer[], +) { if (!currentServerId) return ''; if (nextServers.some((server) => server.id === currentServerId)) return 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 : ''; } -export async function fetchSubscription(url, options) { +export async function fetchSubscription(url: string, options: FetchSubscriptionOptions = {}) { const response = await requestSubscription(url, options); const body = await response.text(); diff --git a/src/server/version.js b/src/server/version.ts similarity index 70% rename from src/server/version.js rename to src/server/version.ts index 35f0dfc..1653fed 100644 --- a/src/server/version.js +++ b/src/server/version.ts @@ -1,13 +1,13 @@ import { spawnSync } from 'node:child_process'; 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 match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`); return match?.[1] || null; } -export function buildVersionInfo(appMode, run = spawnSync) { +export function buildVersionInfo(appMode: string, run: typeof spawnSync = spawnSync) { const client = appMode === 'client'; return { apiVersion: 1, @@ -19,7 +19,10 @@ export function buildVersionInfo(appMode, run = spawnSync) { }; } -export function buildGatewayVersionInfo(controlInfo, dataplaneState) { +export function buildGatewayVersionInfo( + controlInfo: Record, + dataplaneState: { gatewayBackendVersion?: unknown; singBoxVersion?: unknown } | null | undefined, +) { return { ...controlInfo, runtime: { diff --git a/src/shared/connectivityDiagnostics.js b/src/shared/connectivityDiagnostics.ts similarity index 85% rename from src/shared/connectivityDiagnostics.js rename to src/shared/connectivityDiagnostics.ts index 16de8a5..167ef36 100644 --- a/src/shared/connectivityDiagnostics.js +++ b/src/shared/connectivityDiagnostics.ts @@ -19,7 +19,24 @@ export const CONNECTIVITY_SITES = Object.freeze([ 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 directSite = direct.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'; } else if ( directSite?.status === 'responded' - && [403, 451].includes(directSite.httpStatus) + && [403, 451].includes(Number(directSite.httpStatus)) && vpnSite?.status === 'available' ) assessment = 'likely-direct-restriction'; else if ( diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js deleted file mode 100644 index d098b0b..0000000 --- a/src/shared/contracts/state.js +++ /dev/null @@ -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); -} diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts new file mode 100644 index 0000000..25e6964 --- /dev/null +++ b/src/shared/contracts/state.ts @@ -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; + }; + 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 { + 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; + 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(['client', 'gateway']); +const CONNECTION_STATES = new Set(['running', 'stopped']); +const OPERATION_STATES = new Set(['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 = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + 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); +} diff --git a/src/shared/errors.js b/src/shared/errors.ts similarity index 79% rename from src/shared/errors.js rename to src/shared/errors.ts index f87faf7..ceb6412 100644 --- a/src/shared/errors.js +++ b/src/shared/errors.ts @@ -1,3 +1,9 @@ +interface ErrorDefinition { + status: number; + message: string; + retryable: boolean; +} + export const ERROR_DEFINITIONS = Object.freeze({ CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true }, 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 }, OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true }, UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false }, -}); +} satisfies Record); -export function errorDefinition(code) { - return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN; +export type HarborErrorCode = keyof typeof ERROR_DEFINITIONS; + +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 { - 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); super(definition.message, { cause }); 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.retryable = definition.retryable; this.details = details; } } -export function normalizeHarborError(error) { +export function normalizeHarborError(error: unknown) { return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error }); } diff --git a/src/shared/routingRules.js b/src/shared/routingRules.ts similarity index 59% rename from src/shared/routingRules.js rename to src/shared/routingRules.ts index 951895b..9eb8e89 100644 --- a/src/shared/routingRules.js +++ b/src/shared/routingRules.ts @@ -5,7 +5,21 @@ export const INITIAL_ROUTE_RULES = Object.freeze([ const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']); 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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function hostname(value: unknown) { const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, ''); 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}`); @@ -14,22 +28,26 @@ function hostname(value) { return normalized; } -function normalizeRule(rule) { - const type = String(rule?.type || '').trim(); +function normalizeRule(input: unknown): NormalizedRouteRule { + const rule = record(input); + const type = String(rule.type || '').trim(); 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'); } const value = type === 'domain_keyword' - ? String(rule?.value || '').trim().toLowerCase() - : hostname(rule?.value); + ? String(rule.value || '').trim().toLowerCase() + : hostname(rule.value); if (!value || value.length > 253 || /[\s/:?#]/.test(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 (strict) throw new TypeError('Route rules must be an array'); return []; @@ -38,8 +56,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) { throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`); } - const seen = new Set(); - const normalized = []; + const seen = new Set(); + const normalized: NormalizedRouteRule[] = []; for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { try { const rule = normalizeRule(candidate); @@ -54,8 +72,8 @@ export function normalizeRouteRules(value, { strict = false } = {}) { return normalized; } -export function canAppendRouteRule(rules) { +export function canAppendRouteRule(rules: unknown) { return Array.isArray(rules) && rules.length < MAX_ROUTE_RULES && - rules.every((rule) => String(rule?.value || '').trim()); + rules.every((rule) => String(record(rule).value || '').trim()); } diff --git a/src/shared/serverIdentity.js b/src/shared/serverIdentity.ts similarity index 51% rename from src/shared/serverIdentity.js rename to src/shared/serverIdentity.ts index 49a7bbc..7692f4c 100644 --- a/src/shared/serverIdentity.js +++ b/src/shared/serverIdentity.ts @@ -1,6 +1,39 @@ -const text = (value) => String(value || '').trim(); +export interface ServerIdentityInput extends Record { + 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 { + 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; for (let index = 0; index < value.length; index += 1) { hash ^= BigInt(value.charCodeAt(index)); @@ -9,19 +42,20 @@ function hash64(value) { return hash.toString(16).padStart(16, '0'); } -export function serverIdentityKey(server) { - const protocol = text(server?.protocol || server?.type).toLowerCase(); - const host = text(server?.host || server?.server).toLowerCase(); - const port = Number(server?.port || server?.server_port) || 0; +export function serverIdentityKey(value: unknown) { + const server = record(value); + const protocol = text(server.protocol || server.type).toLowerCase(); + const host = text(server.host || server.server).toLowerCase(); + const port = Number(server.port || server.server_port) || 0; return `${protocol}\u0000${host}\u0000${port}`; } -export function createServerId(server) { +export function createServerId(server: unknown) { return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`; } -export function normalizeServer(server) { - const source = server && typeof server === 'object' ? server : {}; +export function normalizeServer(server: unknown): NormalizedServer { + const source = record(server); const protocol = text(source.protocol || source.type).toLowerCase(); const host = text(source.host || source.server); const port = Number(source.port || source.server_port) || 0; @@ -48,8 +82,8 @@ export function normalizeServer(server) { }; } -export function normalizeServers(servers) { - const seen = new Set(); +export function normalizeServers(servers: unknown): NormalizedServer[] { + const seen = new Set(); return (Array.isArray(servers) ? servers : []).flatMap((server) => { const normalized = normalizeServer(server); 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[], + serverId: unknown, + legacyTag: unknown = '', +) { const id = text(serverId); if (id) return servers.some((server) => server.id === id) ? id : ''; const tag = text(legacyTag); diff --git a/src/shared/versions.js b/src/shared/versions.ts similarity index 61% rename from src/shared/versions.js rename to src/shared/versions.ts index 450492b..2cef7ad 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.ts @@ -1,10 +1,22 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.20.5', - gatewayClient: '0.21.3', - gatewayBackend: '0.21.1', + macClient: '0.20.36', + gatewayClient: '0.21.21', + 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 || '')); return match ? { major: Number(match[1]), @@ -13,7 +25,7 @@ export function parseVersion(value) { } : null; } -export function versionCompatibility(versions) { +export function versionCompatibility(versions: Partial | null | undefined) { const mac = parseVersion(versions?.macClient); const client = parseVersion(versions?.gatewayClient); const backend = parseVersion(versions?.gatewayBackend); diff --git a/src/web/App.jsx b/src/web/App.tsx similarity index 60% rename from src/web/App.jsx rename to src/web/App.tsx index 2216aac..2e6b4a2 100644 --- a/src/web/App.jsx +++ b/src/web/App.tsx @@ -1,46 +1,67 @@ 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 { - compatibleSnapshot, + api, + harborClient, + HarborApiError, + parseHarborState, +} from './api/harborClient.js'; +import { ClientOverviewPage } from './components/ClientOverviewPage.js'; +import { BootStatePage, StaleBanner } from './components/SyncStatus.js'; +import { harborReducer, initialHarborState, } 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 [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer( harborReducer, initialHarborState, ); const [subscriptionUrl, setSubscriptionUrl] = useState(''); - const [operations, setOperations] = useState({}); - const [error, setError] = useState(null); - const [versionInfo, setVersionInfo] = useState(null); + const [operations, setOperations] = useState({}); + const [error, setError] = useState(null); + const [versionInfo, setVersionInfo] = useState(null); const pollGeneration = useRef(0); - const operationRegistry = useRef(null); + const operationRegistry = useRef | null>(null); 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 }); } - async function loadState({ retry = false } = {}) { + async function loadState({ retry = false }: { retry?: boolean } = {}) { if (retry) dispatch({ type: 'retry-sync' }); const generation = pollGeneration.current; try { - const snapshot = await api.state(); - if (!compatibleSnapshot(snapshot)) { - const incompatible = new Error('Ожидался Harbor state apiVersion 1'); - incompatible.code = 'INCOMPATIBLE_API'; - throw incompatible; - } + const snapshot = await harborClient.getState(); if (generation === pollGeneration.current) { dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() }); } @@ -61,8 +82,9 @@ function App() { let cancelled = false; api.version().then((info) => { if (!cancelled) setVersionInfo(info); - }).catch((requestError) => { - console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`); + }).catch((requestError: unknown) => { + const message = requestError instanceof Error ? requestError.message : String(requestError); + console.warn(`[version] Не удалось получить runtime-версию: ${message}`); if (!cancelled) setVersionInfo(null); }); return () => { cancelled = true; }; @@ -72,20 +94,20 @@ function App() { if (!state?.mode) return; const isGateway = state.mode === 'gateway'; document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect'; - document.getElementById('harbor-favicon').href = isGateway - ? '/harbor-gateway.svg?v=2' - : '/harbor-connect.svg?v=2'; + const favicon = document.getElementById('harbor-favicon') as HTMLLinkElement | null; + if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2'; }, [state?.mode]); - function run(key, action, context) { + function run(key: OperationKey, action: () => Promise, context: string) { setError(null); - return operationRegistry.current.run(key, async () => { + return operationRegistry.current!.run(key, async () => { try { return await applyMutation(action); } catch (err) { + const candidate = err && typeof err === 'object' ? err as Record : {}; const safeError = err instanceof HarborApiError ? err - : new HarborApiError({ code: err?.code }, err?.status); + : new HarborApiError({ code: candidate.code }, Number(candidate.status)); setError({ context, message: context === 'routing' && safeError.code === 'STATE_CONFLICT' @@ -102,14 +124,18 @@ function App() { }); } - async function applyMutation(action) { + async function applyMutation(action: () => Promise) { pollGeneration.current += 1; - const result = await action(); - if (!result?.state) throw new Error('Harbor API не вернул state snapshot'); - if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1'); + const response = await action(); + if (!response || typeof response !== 'object' || Array.isArray(response)) { + throw new Error('Harbor API не вернул state snapshot'); + } + const result = response as Record; + if (!result.state) throw new Error('Harbor API не вернул state snapshot'); + const snapshot = parseHarborState(result.state); dispatch({ type: 'sync-succeeded', - snapshot: result.state, + snapshot, receivedAt: new Date().toISOString(), }); return result; @@ -138,20 +164,22 @@ function App() { if (!state) return 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 (
loadState({ retry: true })} />
run('serverApply', () => api.apply(serverId), 'connection')} + onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')} onRestart={() => run('connection', api.singbox.restart, 'connection')} onStop={() => run('connection', api.singbox.stop, 'connection')} - onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} - onSaveRouteRules={(rules, expectedRevision) => run( + onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} + onSaveRouteRules={(rules: unknown[], expectedRevision: number) => run( 'routeRules', () => api.routeRules.update(rules, expectedRevision), 'routing', @@ -185,5 +213,3 @@ function App() {
); } - -createRoot(document.getElementById('root')).render(); diff --git a/src/web/api.js b/src/web/api.js deleted file mode 100644 index 2b5de00..0000000 --- a/src/web/api.js +++ /dev/null @@ -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 }), - }), - }, -}; diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts new file mode 100644 index 0000000..a715216 --- /dev/null +++ b/src/web/api/harborClient.ts @@ -0,0 +1,203 @@ +import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js'; +import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js'; + +type RequestOptions = Omit & { + headers?: Record; +}; + +interface JsonResponse { + ok: boolean; + status: number; + json(): Promise; +} + +type FetchImplementation = (url: string, options: RequestOptions) => Promise; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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 { + 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, 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 { + return parseHarborState(await request('/api/state')); + }, +}; diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx deleted file mode 100644 index dff025a..0000000 --- a/src/web/components/ClientOverviewPage.jsx +++ /dev/null @@ -1,1821 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { flushSync } from 'react-dom'; -import { api } from '../api.js'; -import { - connectionAction, - connectionDurationParts, - copyText, - isSubscriptionUrlValid, - localProxyUrls, - subscriptionDomain, - subscriptionDaysLeft, - subscriptionUsage, -} from '../utils/clientControls.js'; -import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js'; -import { instructionBlocks } from '../instructions.js'; -import { operationBlocked } from '../state/operations.js'; -import { ConfirmationPopup } from './ConfirmationPopup.jsx'; -import { DevicesPanel } from './DevicesPanel.jsx'; -import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx'; -import { ServerPicker } from './ServerPicker.jsx'; -import { TrafficChart } from './TrafficChart.jsx'; -import { ERROR_DEFINITIONS } from '../../shared/errors.js'; -import { canAppendRouteRule } from '../../shared/routingRules.js'; -import { - HARBOR_VERSIONS, - parseVersion, - versionCompatibility, -} from '../../shared/versions.js'; - -const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; -const DEVICE_AUTO_REFRESH_MS = 15_000; -const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode'; - -function CloudTooltip({ children, id }) { - return {children}; -} - -const VERSION_PARTS = [ - ['major', 'Major'], - ['minor', 'Minor'], - ['hotfix', 'Hotfix'], -]; - -function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) { - const parsed = parseVersion(version); - const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–']; - - function description(key) { - if (key === 'major') { - return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.'; - } - if (key === 'minor') { - return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.'; - } - return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.'; - } - - return ( -
- - - {VERSION_PARTS.map(([key, label], index) => { - const tooltipId = `harbor-version-${componentKey}-${key}`; - return - {index > 0 && } - - {values[index]} - - {component} · {label} {values[index]} - {description(key)} - {runtime && {runtime}} - {incompatible && Версии Gateway несовместимы.} - - - ; - })} - -
- ); -} - -function VersionDisplay({ isGateway, versionInfo }) { - const runtimeSingBox = versionInfo?.runtime?.singBox; - if (!isGateway) { - return ; - } - - const backendVersion = versionInfo?.components?.gatewayBackend; - const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion; - const compatibility = backendVersion && versionCompatibility({ - ...HARBOR_VERSIONS, - gatewayBackend: backendVersion, - }); - const incompatible = compatibility && !compatibility.compatible; - return ; -} - -function InlineError({ error, context }) { - if (!error || error.context !== context) return null; - return ( -
- {error.message} - {error.retry && } - {error.correlationId && ( - Код: {error.correlationId.slice(0, 8)} - )} -
- ); -} - -const operationProgress = { - connection: ['connection', 'Меняем состояние подключения…'], - serverApply: ['connection', 'Применяем сервер…'], - subscriptionImport: ['subscription', 'Загружаем подписку…'], - subscriptionDelete: ['subscription', 'Удаляем подписку…'], - routeRules: ['routing', 'Применяем локальные правила…'], -}; - -function InlineProgress({ operations, context }) { - const active = Object.entries(operationProgress).find(([key, [operationContext]]) => ( - operationContext === context && operations[key]?.status === 'running' - )); - if (!active) return null; - return ( -
- {active[1][1]} -
- ); -} - -function InstructionStep({ step }) { - if (typeof step === 'string') return step; - return ( - <> - {step.before} - {step.link[0]} - {step.after} - - ); -} - -function InstructionBlock({ block, open, onToggle }) { - const [copyFeedback, setCopyFeedback] = useState(null); - const copyTimer = useRef(null); - - useEffect(() => () => clearTimeout(copyTimer.current), []); - - async function copyInstruction(action) { - 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 ( -
-