diff --git a/README.md b/README.md index d6dc6ec..41426be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Harbor -Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`. +Harbor помогает пользоваться несколькими VPN-подписками дома и на Mac без ручной настройки `sing-box`. Проект работает в двух режимах: @@ -9,7 +9,7 @@ Harbor помогает пользоваться одной VPN-подписко | **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси | | **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси | -В Harbor Connect подписка и выбор сервера остаются на основном экране. Harbor Gateway открывается как панель маршрутизации даже без подписки: Home, «Устройства» и «Диагностика» доступны сразу, а VPN-подписка настраивается отдельной верхней кнопкой в правой панели. +Основной экран Connect и Gateway всегда показывает фактически применённые подписку и сервер. Управление подписками открывается отдельной верхней кнопкой в правой панели; Home, «Устройства» и «Диагностика» Gateway доступны и без подписки. ## Что понадобится @@ -62,10 +62,10 @@ http://АДРЕС-GATEWAY:3456 ### 4. При необходимости добавьте подписку -1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway. -2. Вставьте ссылку VPN-подписки. -3. Нажмите «Сохранить подписку». -4. Выберите сервер. +1. Нажмите «Подписки» — верхнюю кнопку в правой панели Gateway. +2. Нажмите «Добавить подписку», задайте понятное имя и вставьте ссылку VPN-провайдера. +3. Выберите сервер внутри добавленной группы. +4. При нескольких группах выберите нужную действием «Сделать активной». 5. Включите VPN. После подключения Harbor покажет два варианта использования: @@ -114,7 +114,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | ### 3. Добавьте подписку -Откройте `http://127.0.0.1:3456`, вставьте ссылку подписки, выберите сервер и включите VPN. +Откройте `http://127.0.0.1:3456`, добавьте подписку с понятным именем, выберите сервер внутри её группы и включите VPN. Остальные подписки можно добавить через правую панель «Подписки»; у каждой сохраняются собственные серверы, лимит и выбор. Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS. @@ -167,15 +167,15 @@ networksetup -setsocksfirewallproxystate Wi-Fi off ## Автоматическое использование домашнего Gateway -Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway. +Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же выбранной VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway. Для этого: -1. добавьте одну и ту же ссылку подписки в Gateway и Connect; +1. добавьте одну и ту же ссылку подписки в Gateway и Connect и выберите соответствующий профиль на обоих устройствах; 2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`; 3. оставьте автоматический режим включённым в Harbor Connect. -Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN. +Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. До отдельного pairing-flow Connect не получает от Gateway имя фактически применённых подписки и сервера, поэтому в режиме Gateway честно показывает `Gateway · сервер не определён`. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN. ## Повседневные команды diff --git a/docs/product/application-state.md b/docs/product/application-state.md index 24192c4..f066fdd 100644 --- a/docs/product/application-state.md +++ b/docs/product/application-state.md @@ -1,85 +1,98 @@ # Harbor application state v1 -`GET /api/state` is the canonical Harbor domain snapshot. Successful POST and DELETE endpoints return the same snapshot as `state` while retaining their v0 response fields for compatibility. +`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v5; React keeps only drafts, disclosure, focus, animation and transport freshness. + +An abbreviated snapshot: ```json { "apiVersion": 1, "revision": 42, - "generatedAt": "2026-07-11T15:00:00.000Z", "mode": "client", - "subscription": { - "status": "ready", - "host": "provider.example/…", - "fetchedAt": "2026-07-11T14:58:00.000Z", - "userInfo": {} - }, - "selection": { - "desiredServerId": "srv_4d7c5d1bcd60d665", - "appliedServerId": "srv_4d7c5d1bcd60d665" - }, - "connection": { - "desired": "running", - "process": "running", - "startedAt": "2026-07-11T14:59:10.000Z", - "lastError": null - }, - "route": { - "mode": "local-vpn", - "gatewayAddress": null, - "lastVerifiedAt": null, - "reason": "auto" - }, - "operation": { - "kind": null, - "status": "idle", - "startedAt": null, - "error": null - }, - "servers": [ + "profiles": [ { - "id": "srv_4d7c5d1bcd60d665", + "id": "profile_primary", + "label": "Личный", + "subscription": { + "status": "ready", + "host": "provider.example/…", + "fetchedAt": "2026-08-11T12:00:00.000Z", + "userInfo": {}, + "lastRefreshAttemptAt": null, + "errorCode": null + }, + "desiredServerId": "srv_amsterdam", + "servers": [ + { + "id": "srv_amsterdam", + "label": "Amsterdam", + "host": "nl.example.net", + "port": 443, + "protocol": "vless" + } + ] + } + ], + "selection": { + "desiredProfileId": "profile_primary", + "desiredServerId": "srv_amsterdam", + "appliedProfileId": "profile_primary", + "appliedServerId": "srv_amsterdam", + "appliedServerSnapshot": { + "id": "srv_amsterdam", "label": "Amsterdam", "host": "nl.example.net", "port": 443, "protocol": "vless" } - ] + }, + "operation": { + "kind": null, + "status": "idle", + "startedAt": null, + "error": null, + "profileId": null, + "serverId": null + } } ``` -The backend owns subscription metadata, servers, desired/applied selection, desired/process connection state, route and current operation. React may keep only unsaved form values, pending selection and visual state. Browser transport freshness is not part of this contract. +Every profile owns one private provider URL/config, public metadata, server list and desired server. The URL/config never enters the public snapshot. `subscription` and top-level `servers` remain a one-release projection of the desired profile for older clients; they are not a second owner. ## Revision rules -`revision` is persisted in the existing `state.json` and increases on externally visible transitions, including operation start/completion/failure, import, refresh, forget, apply, start, stop and Gateway Auto changes. `generatedAt` is response metadata and does not change revision by itself. +`revision` increases for every visible domain transition, including operation start, completion and failure. Commands carry `expectedRevision`; stale commands fail with `STATE_CONFLICT`. Duplicate profile labels are rejected by preflight without a provider request or revision change. -A consumer must eventually apply only snapshots whose revision is at least its current revision. The frontend comparison and stale/offline transport envelope are intentionally handled by TASK-002 and TASK-003. +The frontend accepts only newer snapshots. Equal revisions preserve object identity, and older polling responses cannot overwrite mutation results. After a failed mutation the browser immediately synchronizes the authoritative snapshot before allowing another command or retry. -The frontend keeps the accepted snapshot in one reducer and replaces it only when `incoming.revision` is greater. Equal revisions preserve object identity so background polling does not replay decorative transitions. Mutation responses are applied directly; polling requests started before a mutation are logically invalidated and cannot overwrite its result. A locally pending server choice remains local until a newer snapshot acknowledges it or removes that server. +Browser boot/offline/stale state remains a transport envelope beside the domain snapshot. A transport failure retains the last accepted domain state. -Browser transport state lives beside, not inside, the domain snapshot. It records boot status, last successful sync time and consecutive failures. Three failed polls mark the retained snapshot stale; the next successful GET or mutation clears that marker. An initial failure shows `control-unreachable`, `incompatible-api` or `fatal` without inventing domain state. +## Desired and applied identity -Gateway discovery follows the same retain-and-mark-stale rule. Once a concrete default Gateway has been verified, transient presence failures or a briefly stale macOS route snapshot keep `gateway-direct` active and report `route.reason = gateway-stale`; they do not restart sing-box into `local-vpn`. Local routing resumes only after the user disables Gateway mode or macOS reports a different default Gateway identity. +`desiredProfileId` and each profile's `desiredServerId` record the next local choice. `appliedProfileId`, `appliedServerId` and `appliedServerSnapshot` describe the runtime that actually owns traffic. There is no third `activeProfileId`. -## Desired and applied state +While stopped, selecting or activating a profile only updates desired state. While running, changing the applied profile/server builds a candidate config, starts it, then publishes desired and applied identity in one final state commit. Until that commit the old applied pair remains authoritative. A failure restores the previous config, runtime and state. -`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded. +If refresh removes the applied server, the running process is not silently switched. The provider list and desired selection are cleared as needed, while `appliedServerSnapshot` retains the last applied label until explicit stop or a successful switch. Stop clears applied identity and keeps the desired pair. -Server IDs are deterministic from normalized protocol, host and port, while provider order and the human-readable `label` are separate. Duplicate labels remain separate servers; reorder and cosmetic rename keep the same ID. Ping results, React keys, persisted selection and apply commands use the ID. If the selected endpoint disappears, Harbor stops the active process, clears selection and requires an explicit new choice instead of silently switching traffic. +## Profile operations -## Subscription import and refresh +The canonical API is scoped by profile: -The browser validates only the shape and `http`/`https` protocol of a subscription URL. The provider is contacted once, after explicit submit. The backend fetches and parses the complete response before entering the serialized commit. +- `POST /api/profiles` adds a profile after one explicit provider fetch; +- `PATCH /api/profiles/:id` renames it locally; +- `PUT /api/profiles/:id/server` selects one of its servers; +- `POST /api/profiles/:id/activate` activates/switches it; +- `POST /api/profiles/:id/refresh` refreshes only that provider; +- `DELETE /api/profiles/:id` deletes it, with explicit `stop-and-delete` for a running applied profile; +- `POST /api/profiles/:id/servers/ping` performs bounded transient health checks. -Import and refresh share one commit path. It prepares the candidate server list and sing-box config first, then updates cache, config, runtime and canonical state. If provider fetch, parsing, config validation or runtime apply fails, the previous subscription cache, selected server, config and running process remain active. Refreshes for the saved URL share one in-flight Promise; a refresh that finishes after another import is rejected with `STATE_CONFLICT` instead of overwriting the newer subscription. - -The existing background refresh remains every 15 minutes. Provider requests time out after 15 seconds by default (`SUBSCRIPTION_TIMEOUT_MS` may override it). A failed background refresh logs a redacted warning and keeps the last successful subscription snapshot. +Provider failure retains the last successful list and metadata, marks only the target profile stale and records the last successful timestamp. Refreshing, pinging or deleting an inactive profile does not mutate the applied config/runtime. Background refresh iterates profiles independently every 15 minutes. ## Compatibility and migration -No path, volume or file is renamed. A legacy `state.json` without stable IDs is migrated to schema v4. A unique `selectedTag` is matched to its normalized endpoint and stored as `selectedServerId`/`appliedServerId`; an ambiguous or missing tag explicitly clears selection. The raw provider config remains unchanged in subscription cache and is normalized only in memory, so an older Harbor build can still use its original tags after rollback. Existing unknown fields remain untouched. +Schema v5 migrates the legacy singleton and `subscription-cache.json` into one profile named `Основной`. Stable endpoint identity preserves unambiguous desired/applied selection, including transport variants whose normalized IDs differ from old labels. An explicitly stopped legacy state does not resurrect an old applied target. -During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL. +Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config. -Rollback is code-only: deploy the previous build. The v4 state keeps `selectedTag`, `appliedTag` and server aliases for older builds, while subscription cache keeps raw provider tags. The added ID fields are ignored by the previous implementation. +The old HTTP projection remains bounded for one release, but schema v5 persistence is not downgrade-compatible. To roll back to a pre-v5 build, stop Harbor and restore both pre-migration backups rather than deploying old code over v5 data. diff --git a/docs/product/frontend-operations.md b/docs/product/frontend-operations.md index 52edbf3..457b5f4 100644 --- a/docs/product/frontend-operations.md +++ b/docs/product/frontend-operations.md @@ -3,14 +3,14 @@ Harbor tracks active browser mutations by operation key instead of one global `busy` flag: - `connection`: start, stop and restart; -- `serverApply`: apply the selected server; -- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`; +- `serverApply`: apply a `(profileId, serverId)` pair; +- `profileAdd`, `profileRename`, `profileSelect`, `profileActivate`, `profileRefresh`, `profileDelete`; - `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.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. -Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; the explicit import operation performs the single provider request and reports provider failures through the structured subscription error. +Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; explicit profile add performs the single provider request and reports provider failures at that profile. -The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run. +The registry is local transport/UI state for immediate feedback. It does not replace backend `snapshot.operation`, which preserves the target across polling, reloads and other windows. A `diagnostics` key is intentionally deferred until diagnostics become a conflicting mutation. diff --git a/docs/recovery/state-recovery.md b/docs/recovery/state-recovery.md index 5412c78..ac40bca 100644 --- a/docs/recovery/state-recovery.md +++ b/docs/recovery/state-recovery.md @@ -1,36 +1,48 @@ # Harbor state recovery -Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 4`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 added rule `enabled` state. Schema v4 adds stable server IDs and migrates an unambiguous legacy `selectedTag` to `selectedServerId`. +Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 5`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, and v5 embeds the canonical `profiles[]` collection with desired/applied profile identity. ## Atomic writes Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file. -## Migration +Profile/server switching prepares candidate config and runtime before the final state publication. If any later step fails, Harbor restores the previous config, runtime and canonical state. -On startup, a legacy `state.json` without `schemaVersion`, or any v1-v3 state, is normalized and migrated to the current schema. Existing custom rules are preserved. Server identity is derived from protocol, host and port; a unique legacy tag keeps selection, while duplicate or missing matches require a new explicit choice. Before replacement Harbor saves the original beside it: +## Migration to profiles + +On startup, a legacy state is normalized before the process starts. Harbor creates one profile named `Основной`, moves the provider URL/config and metadata into it, and preserves unambiguous desired/applied server identity. A legacy state explicitly marked stopped clears stale applied residue. + +Before replacing state Harbor saves the original beside it: ```text -state.json.backup-v0-2026-07-11T12-00-00-000Z +state.json.backup-v4-2026-08-11T12-00-00-000Z ``` -The migration preserves compatibility aliases, adds normalized revision, selection and server fields, and does not rename the volume. Subscription cache keeps the raw provider config so older builds can still use its original outbound tags. The backup remains the safest manual recovery source. +After a valid profile has been committed, the raw legacy cache is saved and removed as a second owner: + +```text +subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z +``` + +An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state. ## Corrupt JSON If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: ```text -state.json.corrupt-2026-07-11T12-00-00-000Z +state.json.corrupt-2026-08-11T12-00-00-000Z ``` -It then creates a valid empty current-schema state and reports `storage-recovery` through `snapshot.operation`. A corrupt subscription cache is preserved with the same suffix and reported in control logs. +It then creates a valid empty current-schema state and reports storage recovery. A corrupt legacy subscription cache is preserved with the same suffix and is never used to start stale config. -Recovery should be performed while Harbor is stopped: +## Manual recovery and downgrade -1. Copy the whole data directory before changing anything. -2. Inspect a backup with `jq . `. -3. Restore only a valid JSON backup to the original filename. -4. Start Harbor and verify `GET /api/state` before applying or importing anything. +Perform recovery while Harbor is stopped: -Generated config rollback also uses the atomic writer. No automatic recovery tries to guess missing subscription credentials or repair semantically invalid sing-box configuration. +1. Copy the whole data directory. +2. Inspect the intended backup with `jq . `. +3. Restore only matching state/cache backups to their original filenames. +4. Start Harbor and verify `GET /api/state` before applying a profile. + +A pre-v5 binary cannot interpret canonical profiles. Downgrade therefore requires both pre-migration backups; deploying old code alone is not a safe rollback. diff --git a/src/server/features/connection/connectionService.ts b/src/server/features/connection/connectionService.ts index c8846be..e64d545 100644 --- a/src/server/features/connection/connectionService.ts +++ b/src/server/features/connection/connectionService.ts @@ -1,4 +1,8 @@ -import type { StoredState } from '../../../shared/contracts/state.js'; +import { + profileById, + type StoredProfile, + type StoredState, +} from '../../../shared/contracts/state.js'; import { HarborError } from '../../../shared/errors.js'; import { finishRollback } from '../../services/rollback.js'; @@ -7,11 +11,7 @@ interface ConnectionServiceDependencies { 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; @@ -25,6 +25,7 @@ interface ConnectionServiceDependencies { stopCommand(): Promise; restartCommand(): Promise; }; + route?: { isGatewayDirect(): boolean }; serialize(operation: () => Promise): Promise; now(): Date; } @@ -46,65 +47,125 @@ export async function captureRuntimeCommand( } } +function requireExpectedRevision(state: StoredState, expectedRevision: unknown) { + if (expectedRevision === undefined) return; + if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) { + throw new HarborError('STATE_CONFLICT'); + } +} + +function resolveProfile(state: StoredState, profileId: unknown): StoredProfile { + const requested = String(profileId || '').trim(); + const profile = profileById(state, requested) + || (!requested && state.profiles.length === 1 ? state.profiles[0] : null); + if (!profile) throw new HarborError('PROFILE_NOT_FOUND'); + return profile; +} + +function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: string) { + const nextProfile = { ...profile, desiredServerId: serverId }; + return { + ...state, + profiles: state.profiles.map((candidate) => candidate.id === profile.id ? nextProfile : candidate), + desiredProfileId: profile.id, + }; +} + 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 applyWithinQueue = async ( + previousState: StoredState, + profile: StoredProfile, + serverIdValue: unknown, + selectedTagValue: unknown, + ) => { + const requestedId = String(serverIdValue || '').trim(); + const requestedTag = String(selectedTagValue || '').trim(); const resolvedId = requestedId || (() => { - const matches = previousState.servers.filter((server) => server.label === requestedTag); + const matches = profile.servers.filter((server) => server.label === requestedTag); return matches.length === 1 ? matches[0].id : ''; })(); - const selectedServer = previousState.servers.find((server) => server.id === resolvedId); + const selectedServer = profile.servers.find((server) => server.id === resolvedId); if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND'); + if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID'); + + if (dependencies.route?.isGatewayDirect()) { + dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id)); + return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label }; + } - const subscriptionConfig = dependencies.subscription.readConfig(); - if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID'); const nextConfig = dependencies.config.build( - subscriptionConfig, + profile.subscriptionConfig, selectedServer.id, previousState.routeRules, ); const previousConfig = dependencies.config.read(); const wasRunning = await dependencies.runtime.isRunning(); - let desiredCommitStarted = false; let configMutationStarted = false; + let runtimeMutationStarted = false; + let stateCommitStarted = false; try { - desiredCommitStarted = true; - dependencies.state.update((state) => ({ - ...state, - selectedServerId: selectedServer.id, - connectionDesired: 'running', - })); - configMutationStarted = true; dependencies.config.write(nextConfig); + runtimeMutationStarted = true; await dependencies.runtime.start(); + stateCommitStarted = true; dependencies.state.update((state) => ({ - ...state, + ...withDesiredServer(state, profile, selectedServer.id), + connectionDesired: 'running', + appliedProfileId: profile.id, appliedServerId: selectedServer.id, + appliedServerSnapshot: selectedServer, appliedAt: dependencies.now().toISOString(), appliedRouteRules: state.routeRules, })); } catch (error) { await finishRollback(error, [ + ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ...(configMutationStarted ? [{ run: () => previousConfig === null ? dependencies.config.remove() : dependencies.config.restore(previousConfig), }] : []), - ...(configMutationStarted ? [{ + ...(runtimeMutationStarted ? [{ 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 }; + return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label }; + }; + + const apply = ( + profileId: unknown, + serverId: unknown, + selectedTag: unknown = '', + expectedRevision?: unknown, + ) => dependencies.serialize(async () => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + return applyWithinQueue(state, resolveProfile(state, profileId), serverId, selectedTag); }); + const activate = (profileId: unknown, expectedRevision?: unknown) => ( + dependencies.serialize(async () => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + const profile = resolveProfile(state, profileId); + const selectedServer = profile.servers.find((server) => server.id === profile.desiredServerId); + if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND'); + const running = await dependencies.runtime.isRunning(); + if (!running || dependencies.route?.isGatewayDirect()) { + if (state.desiredProfileId !== profile.id) { + dependencies.state.update((current) => ({ ...current, desiredProfileId: profile.id })); + } + return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label }; + } + return applyWithinQueue(state, profile, selectedServer.id, ''); + }) + ); + const stop = () => dependencies.serialize(async () => { const previousState = dependencies.state.read(); let wasRunning: boolean | null = null; @@ -119,7 +180,13 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc runtimeMutationStarted = command.mutationStarted; if (!command.ok) throw command.error; stateCommitStarted = true; - dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' })); + dependencies.state.update((state) => ({ + ...state, + connectionDesired: 'stopped', + appliedProfileId: '', + appliedServerId: '', + appliedServerSnapshot: null, + })); } catch (error) { await finishRollback(error, [ ...(runtimeMutationStarted && wasRunning !== null ? [{ @@ -133,28 +200,53 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc 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 {} + const wasRunning = await dependencies.runtime.isRunning(); + const targetProfileId = wasRunning + ? previousState.appliedProfileId + : previousState.desiredProfileId; + const targetServerId = wasRunning + ? previousState.appliedServerId + : resolveProfile(previousState, targetProfileId).desiredServerId; + const profile = resolveProfile(previousState, targetProfileId); + const server = profile.servers.find((candidate) => candidate.id === targetServerId) + || (previousState.appliedServerSnapshot?.id === targetServerId + ? previousState.appliedServerSnapshot + : null); + if (!server || !profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID'); + const candidateConfig = dependencies.config.build( + profile.subscriptionConfig, + server.id, + previousState.routeRules, + ); + const previousConfig = dependencies.config.read(); + let configMutationStarted = false; let runtimeMutationStarted = false; let stateCommitStarted = false; try { + configMutationStarted = true; + dependencies.config.write(candidateConfig); 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, + desiredProfileId: wasRunning ? state.desiredProfileId : profile.id, + appliedProfileId: profile.id, + appliedServerId: server.id, + appliedServerSnapshot: server, connectionDesired: 'running', appliedRouteRules: state.routeRules, })); } catch (error) { await finishRollback(error, [ - ...(runtimeMutationStarted && wasRunning !== null ? [{ + ...(configMutationStarted ? [{ + run: () => previousConfig === null + ? dependencies.config.remove() + : dependencies.config.restore(previousConfig), + }] : []), + ...(runtimeMutationStarted ? [{ run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(), runtime: true, }] : []), @@ -163,7 +255,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc } }); - return { apply, stop, restart }; + return { apply, activate, stop, restart }; } export type ConnectionService = ReturnType; diff --git a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts index 4fe138a..d2e1c48 100644 --- a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts +++ b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts @@ -1,12 +1,11 @@ -interface DiagnosticServer { - id: unknown; - label: unknown; -} +import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js'; interface DiagnosticState { + desiredProfileId?: unknown; + appliedProfileId?: unknown; appliedServerId?: unknown; - selectedServerId?: unknown; - servers?: DiagnosticServer[]; + appliedServerSnapshot?: HarborServer | null; + profiles?: StoredProfile[]; } interface DiagnosticsResult extends Record { @@ -25,15 +24,24 @@ function diagnosticsResult(value: unknown): DiagnosticsResult { return value as DiagnosticsResult; } +function selectedServer(state: DiagnosticState) { + const profiles = Array.isArray(state.profiles) ? state.profiles : []; + const appliedProfile = profiles.find((profile) => profile.id === state.appliedProfileId); + const applied = appliedProfile?.servers.find((server) => server.id === state.appliedServerId) + || (state.appliedServerSnapshot?.id === state.appliedServerId + ? state.appliedServerSnapshot + : null); + if (state.appliedServerId) return applied; + const desiredProfile = profiles.find((profile) => profile.id === state.desiredProfileId); + return desiredProfile?.servers.find((server) => server.id === desiredProfile.desiredServerId) || null; +} + 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 selected = selectedServer(dependencies.readState()); const server = selected ? { id: selected.id, label: selected.label } : null; const result = diagnosticsResult(await dependencies.runDiagnostics(services, target)); return { diff --git a/src/server/features/routing/gatewayAutoService.ts b/src/server/features/routing/gatewayAutoService.ts index 5fa01d0..d564a20 100644 --- a/src/server/features/routing/gatewayAutoService.ts +++ b/src/server/features/routing/gatewayAutoService.ts @@ -1,6 +1,12 @@ import { isDeepStrictEqual } from 'node:util'; -import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js'; +import { + appliedProfile, + desiredProfile, + type GatewayAutoState, + type StoredState, +} from '../../../shared/contracts/state.js'; +import { HarborError } from '../../../shared/errors.js'; import type { RuntimeCommandResult } from '../connection/index.js'; import { finishRollback } from '../../services/rollback.js'; @@ -25,7 +31,7 @@ interface GatewayAutoServiceDependencies { read(): StoredState; update(mutator: (state: StoredState) => Record): StoredState; }; - subscription: { readConfig(): unknown | null }; + subscription: { readConfig(profileId: string): unknown | null }; config: { build( subscriptionConfig: unknown, @@ -42,6 +48,7 @@ interface GatewayAutoServiceDependencies { isRunning(): boolean; applyCommand(): Promise; restoreRunning(): Promise; + stopCommand(): Promise; }; discovery: { readHostNetwork(): HostNetworkState | null; @@ -108,22 +115,76 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende const previousGatewayAuto = current; const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate); const modeChanged = previousGatewayAuto.mode !== candidate.mode; + const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct' + && candidate.mode !== 'gateway-direct'; if (!stateChanged && persistEnabled === undefined) return current; const previousState = dependencies.state.read(); - const subscriptionConfig = modeChanged - ? dependencies.subscription.readConfig() + const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false; + const targetProfile = wasRunning + ? appliedProfile(previousState) + : desiredProfile(previousState); + const targetServerId = wasRunning + ? previousState.appliedServerId + : targetProfile?.desiredServerId || ''; + const subscriptionConfig = modeChanged && targetProfile + ? dependencies.subscription.readConfig(targetProfile.id) : null; - const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig - ? dependencies.config.build( + const stopUnavailableTarget = async (cause: unknown) => { + let runtimeMutationStarted = false; + let gatewayAutoPublished = false; + let stateCommitStarted = false; + try { + const command = await dependencies.runtime.stopCommand(); + runtimeMutationStarted = command.mutationStarted; + if (!command.ok) throw command.error; + current = candidate; + gatewayAutoPublished = true; + stateCommitStarted = true; + dependencies.state.update((state) => ({ + ...state, + connectionDesired: 'stopped', + appliedProfileId: '', + appliedServerId: '', + appliedServerSnapshot: null, + ...(persistEnabled === undefined ? {} : { gatewayAutoEnabled: persistEnabled }), + })); + } catch (error) { + await finishRollback(error, [ + ...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []), + ...(runtimeMutationStarted ? [{ + run: () => dependencies.runtime.restoreRunning(), + runtime: true, + }] : []), + ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), + ], 'Gateway auto safe-stop rollback failed'); + } + dependencies.onDiscoveryWarning(errorMessage(cause)); + if (modeChanged) dependencies.onRouteChange(candidate); + throw cause; + }; + if (modeChanged && wasRunning && (!targetProfile || !targetServerId || !subscriptionConfig)) { + if (leavesGatewayDirect) return stopUnavailableTarget(new HarborError('CONFIG_INVALID')); + throw new HarborError('CONFIG_INVALID'); + } + let candidateConfig: unknown | null = null; + if (modeChanged && targetServerId && subscriptionConfig) { + try { + candidateConfig = dependencies.config.build( subscriptionConfig, - previousState.selectedServerId, + targetServerId, previousState.routeRules, candidate, - ) - : null; + ); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (wasRunning && leavesGatewayDirect && ['CONFIG_INVALID', 'SERVER_NOT_FOUND'].includes(code)) { + return stopUnavailableTarget(error); + } + throw error; + } + } 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; @@ -171,7 +232,9 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende const runRefresh = async ({ reconfigure = true }: RefreshOptions) => { const state = dependencies.state.read(); - const network = state.subscriptionUrl + const profile = desiredProfile(state); + const subscriptionUrl = profile?.subscriptionUrl || ''; + const network = subscriptionUrl ? dependencies.discovery.readHostNetwork() : null; @@ -182,7 +245,7 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende error: discoveryError, }); const candidate = dependencies.transition.applyPreference( - state.subscriptionUrl + subscriptionUrl ? { ...discoveredState, lastError: discoveryError } : discoveredState, state.gatewayAutoEnabled !== false, @@ -204,16 +267,17 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende try { verifiedGateway = await dependencies.discovery.probeGateway({ gateway: network.gateway, - subscriptionUrl: String(state.subscriptionUrl), + subscriptionUrl, }); } catch (error) { const reason = errorMessage(error); const latestState = dependencies.state.read(); - const latestNetwork = latestState.subscriptionUrl + const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || ''; + const latestNetwork = latestSubscriptionUrl ? dependencies.discovery.readHostNetwork() : null; if ( - latestState.subscriptionUrl !== state.subscriptionUrl || + latestSubscriptionUrl !== subscriptionUrl || !dependencies.transition.sameRoute(network, latestNetwork) ) { return commitCandidate(dependencies.transition.createInitial(), { reconfigure }); @@ -232,11 +296,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende } const latestState = dependencies.state.read(); - const latestNetwork = latestState.subscriptionUrl + const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || ''; + const latestNetwork = latestSubscriptionUrl ? dependencies.discovery.readHostNetwork() : null; if ( - latestState.subscriptionUrl !== state.subscriptionUrl || + latestSubscriptionUrl !== subscriptionUrl || !dependencies.transition.sameRoute(network, latestNetwork) ) { return commitCandidate(dependencies.transition.createInitial(), { reconfigure }); diff --git a/src/server/features/routing/routeRulesService.ts b/src/server/features/routing/routeRulesService.ts index 21d6f4e..a35f17e 100644 --- a/src/server/features/routing/routeRulesService.ts +++ b/src/server/features/routing/routeRulesService.ts @@ -1,6 +1,11 @@ import { isDeepStrictEqual } from 'node:util'; -import type { RouteRule, StoredState } from '../../../shared/contracts/state.js'; +import { + appliedProfile, + desiredProfile, + type RouteRule, + type 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'; @@ -11,7 +16,7 @@ interface RouteRulesDependencies { read(): StoredState; update(mutator: (state: StoredState) => Record): StoredState; }; - subscription: { readConfig(): unknown | null }; + subscription: { readConfig(profileId: string): unknown | null }; config: { build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown; read(): string | null; @@ -30,8 +35,17 @@ interface RouteRulesDependencies { export function createRouteRulesService(dependencies: RouteRulesDependencies) { const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => { - const subscriptionConfig = dependencies.subscription.readConfig(); - if (!previousState.selectedServerId || !subscriptionConfig) { + const wasRunning = await dependencies.runtime.isRunning(); + const targetProfile = wasRunning + ? appliedProfile(previousState) + : desiredProfile(previousState); + const targetServerId = wasRunning + ? previousState.appliedServerId + : targetProfile?.desiredServerId || ''; + const subscriptionConfig = targetProfile + ? dependencies.subscription.readConfig(targetProfile.id) + : null; + if (!targetServerId || !subscriptionConfig) { let stateCommitStarted = false; try { stateCommitStarted = true; @@ -50,11 +64,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) { const candidateConfig = dependencies.config.build( subscriptionConfig, - previousState.selectedServerId, + targetServerId, routeRules, ); const previousConfig = dependencies.config.read(); - const wasRunning = await dependencies.runtime.isRunning(); let configMutationStarted = false; let runtimeMutationStarted = false; let stateCommitStarted = false; diff --git a/src/server/features/servers/serverHealth.ts b/src/server/features/servers/serverHealth.ts index 72efde0..74beaab 100644 --- a/src/server/features/servers/serverHealth.ts +++ b/src/server/features/servers/serverHealth.ts @@ -1,4 +1,5 @@ -import type { HarborServer } from '../../../shared/contracts/state.js'; +import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js'; +import { HarborError } from '../../../shared/errors.js'; export const SERVER_HEALTH_MAX_COUNT = 30; export const SERVER_HEALTH_CONCURRENCY = 4; @@ -36,19 +37,23 @@ export async function checkServerHealth( } interface ServerHealthDependencies { - readServers(): HarborServer[]; + readProfiles(): StoredProfile[]; + readDesiredProfileId(): string; ping: Ping; } export function createServerHealthService(dependencies: ServerHealthDependencies) { return { - check(serverIds: unknown) { + check(profileIdValue: unknown, serverIds: unknown) { + const requestedProfileId = String(profileIdValue || '').trim(); + const profileId = requestedProfileId || dependencies.readDesiredProfileId(); + const profile = dependencies.readProfiles().find((candidate) => candidate.id === profileId); + if (!profile) throw new HarborError('PROFILE_NOT_FOUND'); 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, + ? profile.servers.filter((server) => requestedIds.has(server.id)) + : profile.servers, dependencies.ping, ); }, diff --git a/src/server/features/subscription/subscriptionService.ts b/src/server/features/subscription/subscriptionService.ts index e47de63..2023e19 100644 --- a/src/server/features/subscription/subscriptionService.ts +++ b/src/server/features/subscription/subscriptionService.ts @@ -1,14 +1,17 @@ -import type { - GatewayAutoState, - HarborServer, - StoredState, +import crypto from 'node:crypto'; + +import { + profileById, + type GatewayAutoState, + type HarborServer, + type StoredProfile, + type 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; @@ -29,11 +32,6 @@ interface SubscriptionServiceDependencies { 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; @@ -57,216 +55,385 @@ interface SubscriptionServiceDependencies { clearInterval(handle: TimerHandle): void; }; onRefreshError(error: unknown): void; + now?: () => Date; } -interface ResetOptions { - stopRuntime?: boolean; - expectedSubscription?: { - url: string; - generation: number; - }; -} - -export interface SubscriptionMutationResult extends Record { +export interface ProfileMutationResult extends Record { success: true; - servers: HarborServer[]; - userInfo: Record; - fetchedAt: string; - selectedServerId: string; - selectedTag: string; + profileId: string; + label: string; } -const TERMINAL_SUBSCRIPTION_CODES = new Set([ - 'SUBSCRIPTION_EXPIRED', - 'SUBSCRIPTION_DISABLED', - 'SUBSCRIPTION_REJECTED', -]); +const safeErrorCode = (error: unknown) => ( + error && typeof error === 'object' && 'code' in error + ? String(error.code) + : 'UNKNOWN' +); + +const cleanLabel = (value: unknown) => String(value || '').trim(); +const foldedLabel = (value: unknown) => cleanLabel(value).toLocaleLowerCase('ru'); + +function requireLabel(value: unknown) { + const label = cleanLabel(value); + if (!label || label.length > 64) throw new HarborError('REQUEST_INVALID'); + return label; +} + +function requireExpectedRevision(state: StoredState, expectedRevision: unknown) { + if (expectedRevision === undefined) return; + if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== state.revision) { + throw new HarborError('STATE_CONFLICT'); + } +} + +function requireProfile(state: StoredState, profileId: unknown) { + const profile = profileById(state, profileId); + if (!profile) throw new HarborError('PROFILE_NOT_FOUND'); + return profile; +} + +function assertUniqueLabel(state: StoredState, label: string, exceptProfileId = '') { + if (state.profiles.some((profile) => ( + profile.id !== exceptProfileId && foldedLabel(profile.label) === foldedLabel(label) + ))) throw new HarborError('PROFILE_NAME_CONFLICT'); +} + +function replaceProfile(state: StoredState, nextProfile: StoredProfile) { + return state.profiles.map((profile) => profile.id === nextProfile.id ? nextProfile : profile); +} + +function mutationResult(profile: Pick): ProfileMutationResult { + return { success: true, profileId: profile.id, label: profile.label }; +} export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) { - let refreshPromise: Promise | null = null; + const refreshPromises = new Map>(); let refreshTimer: TimerHandle | null = null; - let subscriptionGeneration = 0; - - const restoreCache = (previous: unknown) => { - if (previous !== null) dependencies.cache.write(previous); - else dependencies.cache.remove(); - }; + const now = dependencies.now || (() => new Date()); const restoreConfig = (previous: string | null) => { if (previous === null) dependencies.config.remove(); else dependencies.config.restore(previous); }; - const commitSubscription = ( + const preflightAddProfile = (labelValue: unknown, expectedRevision?: unknown) => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + assertUniqueLabel(state, requireLabel(labelValue)); + }; + + const preflightRenameProfile = ( + profileId: unknown, + labelValue: unknown, + expectedRevision?: unknown, + ) => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + const profile = requireProfile(state, profileId); + assertUniqueLabel(state, requireLabel(labelValue), profile.id); + }; + + const addProfile = async ( + labelValue: unknown, + subscriptionUrlValue: unknown, + expectedRevision?: unknown, + ) => { + const label = requireLabel(labelValue); + const subscriptionUrl = String(subscriptionUrlValue || '').trim(); + const preflight = dependencies.state.read(); + requireExpectedRevision(preflight, expectedRevision); + assertUniqueLabel(preflight, label); + const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl); + + // Admission CAS already passed; background freshness may advance the global revision during provider I/O. + return dependencies.serialize(async () => { + const state = dependencies.state.read(); + assertUniqueLabel(state, label); + const profile: StoredProfile = { + id: `profile_${crypto.randomUUID()}`, + label, + subscriptionUrl, + subscriptionConfig: parsed.config, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + desiredServerId: '', + lastRefreshAttemptAt: parsed.fetchedAt, + lastRefreshErrorCode: null, + }; + dependencies.state.update((current) => ({ + ...current, + profiles: [...current.profiles, profile], + desiredProfileId: current.profiles.length ? current.desiredProfileId : profile.id, + })); + return mutationResult(profile); + }); + }; + + const renameProfile = (profileId: unknown, labelValue: unknown, expectedRevision?: unknown) => ( + dependencies.serialize(async () => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + const profile = requireProfile(state, profileId); + const label = requireLabel(labelValue); + if (profile.label === label) return mutationResult(profile); + assertUniqueLabel(state, label, profile.id); + const renamed = { ...profile, label }; + dependencies.state.update((current) => ({ + ...current, + profiles: replaceProfile(current, renamed), + })); + return mutationResult(renamed); + }) + ); + + const selectProfileServer = ( + profileId: unknown, + serverIdValue: unknown, + expectedRevision?: unknown, + ) => dependencies.serialize(async () => { + const state = dependencies.state.read(); + requireExpectedRevision(state, expectedRevision); + const profile = requireProfile(state, profileId); + const serverId = String(serverIdValue || '').trim(); + if (!profile.servers.some((server) => server.id === serverId)) { + throw new HarborError('SERVER_NOT_FOUND'); + } + if (profile.desiredServerId === serverId) return mutationResult(profile); + const selected = { ...profile, desiredServerId: serverId }; + dependencies.state.update((current) => ({ + ...current, + profiles: replaceProfile(current, selected), + })); + return mutationResult(selected); + }); + + const recordRefreshError = async ( + profileId: string, + subscriptionUrl: string, + error: unknown, + ) => dependencies.serialize(async () => { + const state = dependencies.state.read(); + const profile = requireProfile(state, profileId); + if (profile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT'); + const failed = { + ...profile, + lastRefreshAttemptAt: now().toISOString(), + lastRefreshErrorCode: safeErrorCode(error), + }; + dependencies.state.update((current) => ({ + ...current, + profiles: replaceProfile(current, failed), + })); + }); + + const commitRefresh = ( + profileId: string, subscriptionUrl: string, parsed: ParsedSubscription, - { resetSelection = false, expectedGeneration }: { - resetSelection?: boolean; - expectedGeneration?: number; - } = {}, ) => dependencies.serialize(async () => { + // Re-read the profile after provider I/O and guard its owner instead of rejecting background-only revisions. const previousState = dependencies.state.read(); - if ( - subscriptionGeneration !== expectedGeneration || - (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) - ) { - throw new HarborError('STATE_CONFLICT'); - } + const previousProfile = requireProfile(previousState, profileId); + if (previousProfile.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, + const desiredServerId = dependencies.provider.selectRefreshedServer( + previousProfile.desiredServerId, + previousProfile.servers, + parsed.servers, + ); + const refreshedProfile: StoredProfile = { + ...previousProfile, + subscriptionConfig: parsed.config, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, - selectedServerId, - selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '', + desiredServerId, + lastRefreshAttemptAt: parsed.fetchedAt, + lastRefreshErrorCode: null, }; + const running = await dependencies.runtime.isRunning(); + const refreshesApplied = running && previousState.appliedProfileId === profileId; + const nextAppliedServerId = refreshesApplied + ? dependencies.provider.selectRefreshedServer( + previousState.appliedServerId, + previousProfile.servers, + parsed.servers, + ) + : ''; + + if (!refreshesApplied || !nextAppliedServerId) { + dependencies.state.update((current) => ({ + ...current, + profiles: replaceProfile(current, refreshedProfile), + })); + return mutationResult(refreshedProfile); + } + + const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!; + const candidateConfig = dependencies.config.build( + parsed.config, + nextAppliedServerId, + previousState.routeRules, + ); + const previousConfig = dependencies.config.read(); + let configMutationStarted = false; + let runtimeMutationStarted = false; + let stateCommitStarted = false; + + try { + configMutationStarted = true; + dependencies.config.write(candidateConfig); + runtimeMutationStarted = true; + await dependencies.runtime.start(); + stateCommitStarted = true; + dependencies.state.update((current) => ({ + ...current, + profiles: replaceProfile(current, refreshedProfile), + appliedServerId: nextAppliedServerId, + appliedServerSnapshot: nextAppliedServer, + })); + } catch (error) { + await finishRollback(error, [ + ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), + ...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []), + ...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []), + ], 'Subscription refresh rollback failed'); + } + return mutationResult(refreshedProfile); }); - const importSubscription = async (subscriptionUrl: string) => { - const expectedGeneration = subscriptionGeneration; - const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl); - return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration }); + const refreshProfile = (profileIdValue: unknown, expectedRevision?: unknown) => { + const profileId = String(profileIdValue || '').trim(); + const existing = refreshPromises.get(profileId); + if (existing) return existing; + const initialState = dependencies.state.read(); + requireExpectedRevision(initialState, expectedRevision); + const initialProfile = requireProfile(initialState, profileId); + const operation = (async () => { + let parsed: ParsedSubscription; + try { + parsed = await dependencies.provider.fetchSubscription(initialProfile.subscriptionUrl); + } catch (error) { + if (safeErrorCode(error) !== 'STATE_CONFLICT') { + await recordRefreshError( + profileId, + initialProfile.subscriptionUrl, + error, + ); + } + throw error; + } + return commitRefresh( + profileId, + initialProfile.subscriptionUrl, + parsed, + ); + })().finally(() => refreshPromises.delete(profileId)); + refreshPromises.set(profileId, operation); + return operation; + }; + + const deleteProfile = ( + profileIdValue: unknown, + modeValue: unknown = 'delete', + expectedRevision?: unknown, + ) => dependencies.serialize(async () => { + const previousState = dependencies.state.read(); + requireExpectedRevision(previousState, expectedRevision); + const profile = requireProfile(previousState, profileIdValue); + const mode = String(modeValue || 'delete'); + if (!['delete', 'stop-and-delete'].includes(mode)) throw new HarborError('REQUEST_INVALID'); + const running = await dependencies.runtime.isRunning(); + const applied = running && previousState.appliedProfileId === profile.id; + if (applied && mode !== 'stop-and-delete') throw new HarborError('PROFILE_IN_USE'); + + const previousConfig = dependencies.config.read(); + const previousGatewayAuto = dependencies.gatewayAuto.read(); + const removesAppliedTarget = previousState.appliedProfileId === profile.id; + let runtimeMutationStarted = false; + let configMutationStarted = false; + let gatewayMutationStarted = false; + let stateCommitStarted = false; + + try { + if (applied) { + runtimeMutationStarted = true; + await dependencies.runtime.stop(); + } + if (removesAppliedTarget) { + configMutationStarted = true; + dependencies.config.remove(); + } + if ( + previousState.desiredProfileId === profile.id + && !(running && previousState.appliedProfileId !== profile.id) + ) { + gatewayMutationStarted = true; + dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial()); + } + stateCommitStarted = true; + dependencies.state.update((current) => ({ + ...current, + profiles: current.profiles.filter((candidate) => candidate.id !== profile.id), + desiredProfileId: current.desiredProfileId === profile.id ? '' : current.desiredProfileId, + appliedProfileId: current.appliedProfileId === profile.id ? '' : current.appliedProfileId, + appliedServerId: current.appliedProfileId === profile.id ? '' : current.appliedServerId, + appliedServerSnapshot: current.appliedProfileId === profile.id + ? null + : current.appliedServerSnapshot, + ...(current.appliedProfileId === profile.id ? { connectionDesired: 'stopped' } : {}), + })); + } catch (error) { + await finishRollback(error, [ + ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), + ...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []), + ...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []), + ...(runtimeMutationStarted ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []), + ], 'Subscription delete rollback failed'); + } + return mutationResult(profile); + }); + + // One-release compatibility for the old single-subscription client. + const importSubscription = (subscriptionUrl: string, expectedRevision?: unknown) => { + const state = dependencies.state.read(); + if (state.profiles.length) throw new HarborError('STATE_CONFLICT'); + return addProfile('Основной', subscriptionUrl, expectedRevision); + }; + + const refreshSavedSubscription = (expectedRevision?: unknown) => { + const state = dependencies.state.read(); + if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT'); + return refreshProfile(state.profiles[0].id, expectedRevision); }; 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; + expectedRevision, + }: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => { + const state = dependencies.state.read(); + if (!state.profiles.length) return Promise.resolve(false); + if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT'); + return deleteProfile( + state.profiles[0].id, + stopRuntime ? 'stop-and-delete' : 'delete', + expectedRevision, + ).then(() => true); }; const startAutoRefresh = (intervalMs: number) => { if (refreshTimer) return; refreshTimer = dependencies.scheduler.setInterval(() => { - if (!dependencies.state.read().subscriptionUrl) return; - void refreshSavedSubscription().catch(dependencies.onRefreshError); + void (async () => { + for (const { id } of dependencies.state.read().profiles) { + try { + await refreshProfile(id); + } catch (error) { + dependencies.onRefreshError(error); + } + } + })(); }, intervalMs); refreshTimer.unref(); }; @@ -278,6 +445,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen }; return { + preflightAddProfile, + preflightRenameProfile, + addProfile, + renameProfile, + selectProfileServer, + refreshProfile, + deleteProfile, importSubscription, refreshSavedSubscription, resetSavedSubscription, diff --git a/src/server/http/routes/serverApplyRoute.ts b/src/server/http/routes/serverApplyRoute.ts index 065028b..2b3fcf3 100644 --- a/src/server/http/routes/serverApplyRoute.ts +++ b/src/server/http/routes/serverApplyRoute.ts @@ -5,18 +5,33 @@ 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; + withOperation( + kind: string, + operation: (operationRevision: number) => Promise, + options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown }, + ): Promise; + sendState(res: ServerResponse, extra: { profileId: string; 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 { + profileId = '', + serverId = '', + selectedTag = '', + expectedRevision, + } = await dependencies.readBody(req); const result = await dependencies.withOperation( 'apply-server', - () => dependencies.connection.apply(serverId, selectedTag), + (operationRevision) => dependencies.connection.apply( + profileId, + serverId, + selectedTag, + operationRevision, + ), + { expectedRevision, profileId, serverId }, ); await dependencies.sendState(res, result); return true; diff --git a/src/server/http/routes/serverHealthRoute.ts b/src/server/http/routes/serverHealthRoute.ts index 15d423c..5ff89b1 100644 --- a/src/server/http/routes/serverHealthRoute.ts +++ b/src/server/http/routes/serverHealthRoute.ts @@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js'; interface ServerHealthRouteDependencies { serverHealth: ServerHealthService; readBody(req: IncomingMessage): Promise>; - sendState(res: ServerResponse, extra: { results: Array> }): Promise; + sendState(res: ServerResponse, extra: { + profileId: string; + 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 pathname = new URL(req.url || '/', 'http://localhost').pathname; + const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/servers\/ping$/); + if (req.method !== 'POST' || (!profileMatch && pathname !== '/api/servers/ping-all')) return false; const { serverIds = [] } = await dependencies.readBody(req); - const results = await dependencies.serverHealth.check(serverIds); - await dependencies.sendState(res, { results }); + const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : ''; + const results = await dependencies.serverHealth.check(profileId, serverIds); + await dependencies.sendState(res, { profileId, results }); return true; }, }; diff --git a/src/server/http/routes/stateRoute.ts b/src/server/http/routes/stateRoute.ts index 65154e8..e6dc3c0 100644 --- a/src/server/http/routes/stateRoute.ts +++ b/src/server/http/routes/stateRoute.ts @@ -46,7 +46,7 @@ function withStateV0Compatibility( singboxRunning: snapshot.connection.process === 'running', singboxStartedAt: snapshot.connection.startedAt, subscriptionHost: snapshot.subscription.host, - hasSubscription: snapshot.subscription.status === 'ready', + hasSubscription: snapshot.subscription.status !== 'missing', selectedTag: stored.selectedTag, userInfo: snapshot.subscription.userInfo, fetchedAt: snapshot.subscription.fetchedAt, diff --git a/src/server/http/routes/subscriptionMutationRoute.ts b/src/server/http/routes/subscriptionMutationRoute.ts index c610aaa..11e787e 100644 --- a/src/server/http/routes/subscriptionMutationRoute.ts +++ b/src/server/http/routes/subscriptionMutationRoute.ts @@ -1,43 +1,170 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { ConnectionService } from '../../features/connection/index.js'; import type { SubscriptionService } from '../../features/subscription/index.js'; +interface OperationOptions { + expectedRevision?: unknown; + profileId?: unknown; + serverId?: unknown; +} + interface SubscriptionMutationRouteDependencies { subscriptionService: Pick< SubscriptionService, - 'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription' + | 'preflightAddProfile' + | 'preflightRenameProfile' + | 'addProfile' + | 'renameProfile' + | 'selectProfileServer' + | 'refreshProfile' + | 'deleteProfile' + | 'importSubscription' + | 'refreshSavedSubscription' + | 'resetSavedSubscription' >; + connection: Pick; readBody(req: IncomingMessage): Promise>; - withOperation(kind: string, operation: () => Promise): Promise; + withOperation( + kind: string, + operation: (operationRevision: number) => Promise, + options?: OperationOptions, + ): 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 pathname = new URL(req.url || '/', 'http://localhost').pathname; + const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)$/); + const serverMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/server$/); + const activateMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/activate$/); + const refreshMatch = pathname.match(/^\/api\/profiles\/([^/]+)\/refresh$/); + + if (req.method === 'POST' && pathname === '/api/profiles') { + const { label = '', url = '', expectedRevision } = await dependencies.readBody(req); + dependencies.subscriptionService.preflightAddProfile(label, expectedRevision); + const result = await dependencies.withOperation( + 'profile-add', + (operationRevision) => dependencies.subscriptionService.addProfile( + label, + url, + operationRevision, + ), + { expectedRevision }, + ); + await dependencies.sendState(res, result); + return true; + } + + if (req.method === 'PATCH' && profileMatch) { + const profileId = decodeURIComponent(profileMatch[1]); + const { label = '', expectedRevision } = await dependencies.readBody(req); + dependencies.subscriptionService.preflightRenameProfile(profileId, label, expectedRevision); + const result = await dependencies.withOperation( + 'profile-rename', + (operationRevision) => dependencies.subscriptionService.renameProfile( + profileId, + label, + operationRevision, + ), + { expectedRevision, profileId }, + ); + await dependencies.sendState(res, result); + return true; + } + + if (req.method === 'PUT' && serverMatch) { + const profileId = decodeURIComponent(serverMatch[1]); + const { serverId = '', expectedRevision } = await dependencies.readBody(req); + const result = await dependencies.withOperation( + 'profile-select-server', + (operationRevision) => dependencies.subscriptionService.selectProfileServer( + profileId, + serverId, + operationRevision, + ), + { expectedRevision, profileId, serverId }, + ); + await dependencies.sendState(res, result); + return true; + } + + if (req.method === 'POST' && activateMatch) { + const profileId = decodeURIComponent(activateMatch[1]); + const { expectedRevision } = await dependencies.readBody(req); + const result = await dependencies.withOperation( + 'profile-activate', + (operationRevision) => dependencies.connection.activate(profileId, operationRevision), + { expectedRevision, profileId }, + ); + await dependencies.sendState(res, result); + return true; + } + + if (req.method === 'POST' && refreshMatch) { + const profileId = decodeURIComponent(refreshMatch[1]); + const { expectedRevision } = await dependencies.readBody(req); + const result = await dependencies.withOperation( + 'profile-refresh', + (operationRevision) => dependencies.subscriptionService.refreshProfile( + profileId, + operationRevision, + ), + { expectedRevision, profileId }, + ); + await dependencies.sendState(res, result); + return true; + } + + if (req.method === 'DELETE' && profileMatch) { + const profileId = decodeURIComponent(profileMatch[1]); + const { mode = 'delete', expectedRevision } = await dependencies.readBody(req); + const result = await dependencies.withOperation( + 'profile-delete', + (operationRevision) => dependencies.subscriptionService.deleteProfile( + profileId, + mode, + operationRevision, + ), + { expectedRevision, profileId }, + ); + await dependencies.sendState(res, result); + return true; + } + + // One-release compatibility for the old single-subscription client. + if (req.method === 'POST' && pathname === '/api/subscription/fetch') { const { url = '' } = await dependencies.readBody(req); const result = await dependencies.withOperation( 'subscription-import', - () => dependencies.subscriptionService.importSubscription(String(url).trim()), + (operationRevision) => dependencies.subscriptionService.importSubscription( + String(url).trim(), + operationRevision, + ), ); await dependencies.sendState(res, result); return true; } - if (req.method === 'POST' && req.url === '/api/subscription/refresh') { - const { success: _success, ...result } = await dependencies.withOperation( + if (req.method === 'POST' && pathname === '/api/subscription/refresh') { + const result = await dependencies.withOperation( 'subscription-refresh', - () => dependencies.subscriptionService.refreshSavedSubscription(), + (operationRevision) => dependencies.subscriptionService.refreshSavedSubscription( + operationRevision, + ), ); await dependencies.sendState(res, result); return true; } - if (req.method === 'DELETE' && req.url === '/api/subscription') { + if (req.method === 'DELETE' && pathname === '/api/subscription') { await dependencies.withOperation( 'subscription-forget', - () => dependencies.subscriptionService.resetSavedSubscription(), + (operationRevision) => dependencies.subscriptionService.resetSavedSubscription({ + expectedRevision: operationRevision, + }), ); await dependencies.sendState(res); return true; diff --git a/src/server/index.ts b/src/server/index.ts index 0ce98f2..124a632 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,13 +28,19 @@ import { selectRefreshedServer, } from './subscription.js'; import { + desiredProfile, normalizeStoredState, type OperationState, type RouteRule, type StoredState, } from '../shared/contracts/state.js'; +import { serverIdentityKey } from '../shared/serverIdentity.js'; import { HarborError, normalizeHarborError } from '../shared/errors.js'; -import { createJsonStore, createStateStore } from './services/stateStore.js'; +import { + atomicWriteFile, + createJsonStore, + createStateStore, +} from './services/stateStore.js'; import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createDeviceInventoryService, @@ -93,11 +99,49 @@ function errorMessage(error: unknown) { fs.mkdirSync(settings.dataDir, { recursive: true }); -const stateStore = createStateStore(settings.statePath); +const stateFileExisted = fs.existsSync(settings.statePath); +const legacyStateBytes = stateFileExisted + ? fs.readFileSync(settings.statePath, 'utf8') + : null; +let legacyStateRecord: Record = {}; +try { + legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes)); +} catch {} +const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion) + ? Number(legacyStateRecord.schemaVersion) + : 0; +const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath) + ? fs.readFileSync(settings.subscriptionCachePath, 'utf8') + : null; const subscriptionCacheStore = createJsonStore({ filePath: settings.subscriptionCachePath, defaultValue: null, }); +const rawLegacySubscriptionCache = subscriptionCacheStore.read(); +const legacyCacheRecord = record(rawLegacySubscriptionCache); +const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim(); +const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim(); +const legacyCacheOwnerMismatch = legacyStateVersion < 5 + && Boolean(legacyCacheRecord.config) + && (legacyStateSubscriptionUrl + ? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl + : !legacyCacheSubscriptionUrl); +let legacySubscriptionCache = rawLegacySubscriptionCache; +let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery); +if (legacyCacheOwnerMismatch) { + legacySubscriptionCache = null; +} else if (legacyCacheRecord.config) { + try { + legacySubscriptionCache = { + ...legacyCacheRecord, + ...normalizeSubscriptionConfig(legacyCacheRecord.config), + }; + } catch { + legacySubscriptionCache = null; + legacySubscriptionCacheRejected = true; + } +} +const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache }); const deviceStore = createJsonStore({ filePath: settings.deviceStatePath, defaultValue: migrateDeviceInventoryState({}), @@ -112,32 +156,77 @@ if (deviceStore.migration) { 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(); +let initialStoredState = stateStore.read(); if (stateStore.migration) { console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`); } if (stateStore.recovery) { console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`); } +if (subscriptionCacheStore.recovery) { + console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`); +} +const rejectedLegacyMigration = legacySubscriptionCacheRejected + && ( + !stateFileExisted + || Boolean(stateStore.recovery) + || Boolean(stateStore.migration && stateStore.migration.fromVersion < 5) + ); +const mismatchedLegacyMigration = legacyCacheOwnerMismatch + && ( + !stateFileExisted + || Boolean(stateStore.recovery) + || Boolean(stateStore.migration && stateStore.migration.fromVersion < 5) + ); +if (rejectedLegacyMigration) { + initialStoredState = stateStore.update((state) => ({ + ...state, + profiles: [], + desiredProfileId: '', + appliedProfileId: '', + appliedServerId: '', + appliedServerSnapshot: null, + connectionDesired: 'stopped', + })); + removeSingboxConfig(); +} else if (mismatchedLegacyMigration) { + initialStoredState = stateStore.update((state) => ({ + ...state, + appliedProfileId: '', + appliedServerId: '', + appliedServerSnapshot: null, + connectionDesired: 'stopped', + })); + removeSingboxConfig(); +} +if ( + legacySubscriptionCacheBytes !== null + && ( + Boolean(subscriptionCacheStore.recovery) + || ( + Boolean(legacyCacheRecord.config) + && ( + legacySubscriptionCacheRejected + || legacyCacheOwnerMismatch + || normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig) + ) + ) + ) +) { + const backupPath = subscriptionCacheStore.recovery?.backupPath + || `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`; + if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes); + subscriptionCacheStore.remove(); + console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`); +} + +function readProfileConfig(profileId = '') { + const state = normalizeStoredState(stateStore.read()); + const profile = profileId + ? state.profiles.find((candidate) => candidate.id === profileId) + : desiredProfile(state); + return profile?.subscriptionConfig || null; +} const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET); const versionInfo = buildVersionInfo(settings.appMode); @@ -215,7 +304,7 @@ const gatewayAutoService = createGatewayAutoService({ update: updateStoredState, }, subscription: { - readConfig: () => readSubscriptionCache()?.config || null, + readConfig: (profileId) => readProfileConfig(profileId), }, config: { build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => ( @@ -238,6 +327,7 @@ const gatewayAutoService = createGatewayAutoService({ { preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] }, ), restoreRunning: () => startSingbox(), + stopCommand: () => captureRuntimeCommand(() => stopSingbox()), }, discovery: { readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath), @@ -291,7 +381,7 @@ const deviceInventoryRoute = createDeviceInventoryRoute({ }); const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory }); const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ - readState: () => stateStore.read(), + readState: () => normalizeStoredState(stateStore.read()), runDiagnostics: async (services, target) => remoteDataplane ? requireRemoteRuntime().runConnectivityDiagnostics(services, target) : requireLocalConnectivityDiagnostics().run({ @@ -306,7 +396,7 @@ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({ }); const gatewayPresenceRoute = createGatewayPresenceRoute({ appMode: settings.appMode, - readState: () => stateStore.read(), + readState: () => normalizeStoredState(stateStore.read()), getHwid, }); const sharedProxyRoute = createSharedProxyRoute({ @@ -332,11 +422,6 @@ const subscriptionService = createSubscriptionService({ 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) @@ -364,16 +449,12 @@ const subscriptionService = createSubscriptionService({ clearInterval: (timer) => clearInterval(timer), }, onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`), -}); -const subscriptionMutationRoute = createSubscriptionMutationRoute({ - subscriptionService, - readBody, - withOperation, - sendState: (res, extra) => stateRoute.send(res, extra), + now: () => new Date(), }); const serverHealthRoute = createServerHealthRoute({ serverHealth: createServerHealthService({ - readServers: () => normalizeStoredState(stateStore.read()).servers, + readProfiles: () => normalizeStoredState(stateStore.read()).profiles, + readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId, ping: tcpPing, }), readBody, @@ -384,11 +465,7 @@ const connectionService = createConnectionService({ 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) ), @@ -399,6 +476,10 @@ const connectionService = createConnectionService({ restore: restoreSingboxConfig, remove: removeSingboxConfig, }, + route: { + isGatewayDirect: () => settings.appMode === 'client' + && gatewayAutoService.read().mode === 'gateway-direct', + }, runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running), start: () => startSingbox(), @@ -412,6 +493,13 @@ const connectionService = createConnectionService({ serialize: serializeControl, now: () => new Date(), }); +const subscriptionMutationRoute = createSubscriptionMutationRoute({ + subscriptionService, + connection: connectionService, + readBody, + withOperation, + sendState: (res, extra) => stateRoute.send(res, extra), +}); const serverApplyRoute = createServerApplyRoute({ connection: connectionService, readBody, @@ -429,7 +517,7 @@ const routeRulesService = createRouteRulesService({ update: updateStoredState, }, subscription: { - readConfig: () => readSubscriptionCache()?.config || null, + readConfig: (profileId) => readProfileConfig(profileId), }, config: { build: (subscriptionConfig, selectedServerId, routeRules) => ( @@ -460,27 +548,64 @@ const routeRulesRoute = createRouteRulesRoute({ }); function updateStoredState(update: (state: StoredState) => Record) { - return stateStore.update((stored) => { + return normalizeStoredState(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 { +async function withOperation( + kind: string, + operation: (operationRevision: number) => Promise, + { + expectedRevision, + profileId = null, + serverId = null, + }: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {}, +): Promise { + if (operationState.status === 'running') throw new HarborError('OPERATION_IN_PROGRESS'); + const currentRevision = normalizeStoredState(stateStore.read()).revision; + if (expectedRevision !== undefined) { + if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) !== currentRevision) { + throw new HarborError('STATE_CONFLICT'); + } + } operationState = { kind, status: 'running', startedAt: new Date().toISOString(), error: null, + profileId: profileId == null ? null : String(profileId), + serverId: serverId == null ? null : String(serverId), }; - updateStoredState((state) => state); + let operationRevision: number; try { - const result = await operation(); - operationState = { kind: null, status: 'idle', startedAt: null, error: null }; + operationRevision = updateStoredState((state) => state).revision; + } catch (error) { + operationState = { + kind: null, + status: 'idle', + startedAt: null, + error: null, + profileId: null, + serverId: null, + }; + throw error; + } + try { + const result = await operation(operationRevision); + operationState = { + kind: null, + status: 'idle', + startedAt: null, + error: null, + profileId: null, + serverId: null, + }; updateStoredState((state) => state); return result; } catch (error) { @@ -536,7 +661,8 @@ function buildActiveConfig( selectedServerId: string, routeRules: RouteRule[] = stateStore.read().routeRules, ) { - return buildGatewayConfig(subscriptionConfig, selectedServerId, { + const normalizedConfig = normalizeSubscriptionConfig(subscriptionConfig).config; + return buildGatewayConfig(normalizedConfig, selectedServerId, { clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct', routeRules, }); @@ -545,33 +671,22 @@ function buildActiveConfig( const stopSingbox = () => singboxRuntime.stop(); const startSingbox = () => singboxRuntime.apply(); -function writeCurrentConfig(onlyIfSelectionChanged = false) { +function writeCurrentConfig() { const state = normalizeStoredState(stateStore.read()); - const cached = readSubscriptionCache(); - if (!state.selectedServerId || !cached?.config) return false; - const cachedServers = cached.servers as StoredState['servers']; - const selectedServerId = selectRefreshedServer( - state.selectedServerId, - state.servers, - cachedServers, - ); - if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false; - const activeConfig = selectedServerId - ? buildActiveConfig(cached.config, selectedServerId) - : null; + const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId); + const profile = hasAppliedTarget + ? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null + : desiredProfile(state); + const serverId = hasAppliedTarget ? state.appliedServerId : profile?.desiredServerId; + const server = profile?.servers.find((candidate) => candidate.id === serverId); + const subscriptionConfig = profile ? readProfileConfig(profile.id) : null; + if (!profile || !server || !subscriptionConfig) return null; + const activeConfig = buildActiveConfig(subscriptionConfig, server.id); const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; try { - if (activeConfig) writeSingboxConfig(activeConfig); - else removeSingboxConfig(); - updateStoredState((current) => ({ - ...current, - servers: cachedServers, - selectedServerId, - appliedServerId: selectedServerId, - ...(!selectedServerId ? { connectionDesired: 'stopped' } : {}), - })); + writeSingboxConfig(activeConfig); } catch (error) { try { if (previousConfig === null) removeSingboxConfig(); @@ -581,7 +696,61 @@ function writeCurrentConfig(onlyIfSelectionChanged = false) { } throw error; } - return Boolean(selectedServerId); + return { profile, server }; +} + +const CONFIG_PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); + +function currentConfigMatchesAppliedTarget(state: StoredState) { + if (!state.appliedProfileId || !state.appliedServerId || !state.appliedServerSnapshot) return false; + let config: Record; + try { + config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8'))); + } catch { + return false; + } + const proxyOutbounds = (Array.isArray(config.outbounds) ? config.outbounds : []) + .map(record) + .filter((outbound) => CONFIG_PROXY_TYPES.has(String(outbound.type || ''))); + const exactMatches = proxyOutbounds.filter((outbound) => ( + String(outbound.tag || '') === state.appliedServerId + )); + const targetMatches = exactMatches.length + ? exactMatches + : proxyOutbounds.filter((outbound) => ( + serverIdentityKey(outbound) === serverIdentityKey(state.appliedServerSnapshot) + )); + if (targetMatches.length !== 1) return false; + const outboundTag = String(targetMatches[0].tag || ''); + const routeFinal = String(record(config.route).final || ''); + const expectsGatewayDirect = settings.appMode === 'client' + && gatewayAutoService.read().mode === 'gateway-direct'; + return expectsGatewayDirect ? routeFinal === 'direct' : routeFinal === outboundTag; +} + +async function reconcileStoppedBoot({ removeConfig = false } = {}) { + try { + await stopSingbox(); + } catch (error) { + console.warn(`[control] sing-box не остановлен при startup reconcile: ${errorMessage(error)}`); + return; + } + if (removeConfig) removeSingboxConfig(); + const state = normalizeStoredState(stateStore.read()); + if ( + state.connectionDesired !== 'stopped' + || state.appliedProfileId + || state.appliedServerId + || state.appliedServerSnapshot + ) { + updateStoredState((current) => ({ + ...current, + connectionDesired: 'stopped', + appliedProfileId: '', + appliedServerId: '', + appliedServerSnapshot: null, + })); + } } async function handleApi(req: IncomingMessage, res: ServerResponse) { @@ -654,21 +823,45 @@ process.on('SIGINT', shutdown); await gatewayAutoService.refresh({ reconfigure: false }) .catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`)); -try { - writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath)); -} catch (error) { - const candidate = record(error); - if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error; - console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`); - await subscriptionService.resetSavedSubscription({ stopRuntime: false }); +const bootState = normalizeStoredState(stateStore.read()); +const bootWantsRunning = bootState.connectionDesired === 'running' + || (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath)); +if (bootWantsRunning) { + let target: ReturnType = null; + try { + target = writeCurrentConfig(); + } catch (error) { + console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`); + } + const canReuseCurrentConfig = target === null + && fs.existsSync(settings.configPath) + && currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read())); + if (target || canReuseCurrentConfig) { + await startSingbox() + .then(() => { + const current = normalizeStoredState(stateStore.read()); + const appliedProfile = target?.profile + || current.profiles.find((profile) => profile.id === current.appliedProfileId); + const appliedServer = target?.server + || current.appliedServerSnapshot; + if (appliedProfile && appliedServer) { + updateStoredState((state: StoredState) => ({ + ...state, + connectionDesired: 'running', + appliedProfileId: appliedProfile.id, + appliedServerId: appliedServer.id, + appliedServerSnapshot: appliedServer, + ...(target ? { appliedRouteRules: state.routeRules } : {}), + })); + } + }) + .catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`)); + } else { + await reconcileStoppedBoot({ removeConfig: true }); + } +} else { + await reconcileStoppedBoot(); } -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() diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts index 9b6028c..b2d9fff 100644 --- a/src/server/services/stateStore.ts +++ b/src/server/services/stateStore.ts @@ -1,10 +1,19 @@ 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 { + normalizeStoredState, + type PersistedState, +} from '../../shared/contracts/state.js'; import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; +import { + normalizeServers, + resolveServerId, + serverIdentityKey, + type NormalizedServer, +} from '../../shared/serverIdentity.js'; -export const STATE_SCHEMA_VERSION = 4; +export const STATE_SCHEMA_VERSION = 5; export interface AtomicWriteOptions { beforeRename?: (temporaryPath: string, filePath: string) => void; @@ -59,6 +68,23 @@ function record(value: unknown): Record { : {}; } +function remapLegacyServerId( + previousServers: NormalizedServer[], + nextServers: NormalizedServer[], + serverId: unknown, + legacyTag: unknown = '', +) { + const direct = resolveServerId(nextServers, serverId, legacyTag); + if (direct) return direct; + const previousId = resolveServerId(previousServers, serverId, legacyTag); + const previous = previousServers.find((server) => server.id === previousId); + if (!previous) return ''; + const matches = nextServers.filter((server) => ( + serverIdentityKey(server) === serverIdentityKey(previous) + )); + return matches.length === 1 ? matches[0].id : ''; +} + function syncDirectory(directory: string) { let descriptor: number | undefined; try { @@ -105,7 +131,10 @@ export function atomicWriteJson(filePath: string, value: unknown, options?: Atom atomicWriteFile(filePath, JSON.stringify(value, null, 2), options); } -export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } { +export function migrateStoredState( + value: unknown, + legacySubscriptionCache: unknown = null, +): PersistedState & { schemaVersion: number } { const stored = record(value); const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0; if (version < 0 || version > STATE_SCHEMA_VERSION) { @@ -114,10 +143,60 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio const routeRules = version < 3 ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] : stored.routeRules; + const legacyCache = record(legacySubscriptionCache); + const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim(); + const cachedSubscriptionUrl = String(legacyCache.url || '').trim(); + const cacheOwnsStoredSubscription = Boolean(cachedSubscriptionUrl) + && (!storedSubscriptionUrl || cachedSubscriptionUrl === storedSubscriptionUrl); + const previousServers = normalizeServers(stored.servers); + const cachedServers = cacheOwnsStoredSubscription + ? normalizeServers(legacyCache.servers) + : []; + const migratedServers = cachedServers.length ? cachedServers : previousServers; + const selectedServerId = remapLegacyServerId( + previousServers, + migratedServers, + stored.selectedServerId, + stored.selectedTag, + ); + const appliedServerId = remapLegacyServerId( + previousServers, + migratedServers, + stored.appliedServerId, + stored.appliedTag || stored.selectedTag, + ); + const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped'); + const normalized = normalizeStoredState({ + ...stored, + routeRules, + ...(version < 5 && !Array.isArray(stored.profiles) ? { + subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''), + subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null, + servers: migratedServers, + selectedServerId, + selectedTag: '', + appliedServerId: keepLegacyApplied ? appliedServerId : '', + appliedServerSnapshot: keepLegacyApplied ? stored.appliedServerSnapshot : null, + appliedTag: '', + userInfo: stored.userInfo || (cacheOwnsStoredSubscription ? legacyCache.userInfo : undefined), + fetchedAt: stored.fetchedAt || (cacheOwnsStoredSubscription ? legacyCache.fetchedAt : undefined), + } : {}), + }); + const canonical = { ...normalized } as Record; + for (const key of [ + 'subscriptionUrl', + 'selectedServerId', + 'selectedTag', + 'appliedTag', + 'servers', + 'userInfo', + 'fetchedAt', + 'subscriptionConfig', + ]) delete canonical[key]; return { - ...normalizeStoredState({ ...stored, routeRules }), + ...canonical, schemaVersion: STATE_SCHEMA_VERSION, - }; + } as PersistedState & { schemaVersion: number }; } export function createJsonStore(options: JsonStoreOptions): JsonStore; @@ -204,14 +283,17 @@ export function createJsonStore(options: JsonStoreOptions | RawJsonStor export function createStateStore( filePath: string, - options: Partial, 'filePath' | 'defaultValue' | 'migrate'>> = {}, + options: Partial, 'filePath' | 'defaultValue' | 'migrate'>> & { + legacySubscriptionCache?: unknown; + } = {}, ) { - return createJsonStore({ + const { legacySubscriptionCache = null, ...storeOptions } = options; + return createJsonStore({ filePath, - defaultValue: migrateStoredState({}), - migrate: migrateStoredState, + defaultValue: migrateStoredState({}, legacySubscriptionCache), + migrate: (value) => migrateStoredState(value, legacySubscriptionCache), initializeMissing: true, backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion, - ...options, + ...storeOptions, }); } diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts index 25e6964..1be467d 100644 --- a/src/shared/contracts/state.ts +++ b/src/shared/contracts/state.ts @@ -20,18 +20,53 @@ export interface RouteRule { enabled: boolean; } +export interface StoredProfile { + id: string; + label: string; + subscriptionUrl: string; + subscriptionConfig: unknown; + servers: HarborServer[]; + userInfo: Record; + fetchedAt: string | null; + desiredServerId: string; + lastRefreshAttemptAt: string | null; + lastRefreshErrorCode: string | null; +} + +export interface ProfileSnapshot { + id: string; + label: string; + subscription: { + status: 'ready' | 'stale'; + host: string; + fetchedAt: string | null; + userInfo: Record; + lastRefreshAttemptAt: string | null; + errorCode: string | null; + }; + desiredServerId: string; + servers: HarborServer[]; +} + export interface StateSnapshot { apiVersion: 1; revision: number; generatedAt: string; mode: HarborMode; + profiles: ProfileSnapshot[]; subscription: { - status: 'missing' | 'ready'; + status: 'missing' | 'ready' | 'stale'; host: string; fetchedAt: string | null; userInfo: Record; }; - selection: { desiredServerId: string; appliedServerId: string }; + selection: { + desiredProfileId: string; + desiredServerId: string; + appliedProfileId: string; + appliedServerId: string; + appliedServerSnapshot: HarborServer | null; + }; connection: { desired: ConnectionState; process: ConnectionState; @@ -56,24 +91,36 @@ export interface StateSnapshot { status: OperationStatus; startedAt: string | null; error: string | null; + profileId: string | null; + serverId: string | null; }; + // One-release projection of the desired profile for older clients. servers: HarborServer[]; } -export interface StoredState extends Record { +export interface PersistedState extends Record { revision: number; - selectedServerId: string; + profiles: StoredProfile[]; + desiredProfileId: string; + appliedProfileId: string; appliedServerId: string; - selectedTag: string; - appliedTag: string; - servers: HarborServer[]; + appliedServerSnapshot: HarborServer | null; routeRules: RouteRule[]; appliedRouteRules: RouteRule[]; routeRulesRevision: number; - subscriptionUrl?: string; connectionDesired?: ConnectionState; gatewayAutoEnabled?: boolean; - userInfo?: Record; +} + +// Legacy fields are derived in memory for bounded callers during the v5 cutover. +// migrateStoredState strips them before every persisted write. +export interface StoredState extends PersistedState { + subscriptionUrl: string; + selectedServerId: string; + selectedTag: string; + appliedTag: string; + servers: HarborServer[]; + userInfo: Record; fetchedAt?: string; } @@ -97,6 +144,8 @@ export interface OperationState { status: OperationStatus; startedAt: string | null; error: string | null; + profileId?: string | null; + serverId?: string | null; } const MODES = new Set(['client', 'gateway']); @@ -109,37 +158,152 @@ 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) +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; - const servers = normalizeServers(state.servers) as HarborServer[]; - const selectedServerId = resolveServerId( +} + +function publicSubscriptionHost(value: unknown) { + try { + return `${new URL(String(value)).host}/…`; + } catch { + return ''; + } +} + +function normalizeProfile(value: unknown, index: number): StoredProfile { + const candidate = record(value); + const servers = normalizeServers(candidate.servers) as HarborServer[]; + const desiredServerId = resolveServerId( servers, + identityText(candidate.desiredServerId), + identityText(candidate.selectedTag), + ); + return { + id: identityText(candidate.id) || `profile_${index + 1}`, + label: identityText(candidate.label) || `Подписка ${index + 1}`, + subscriptionUrl: identityText(candidate.subscriptionUrl), + subscriptionConfig: candidate.subscriptionConfig ?? null, + servers, + userInfo: record(candidate.userInfo), + fetchedAt: dateOrNull(candidate.fetchedAt), + desiredServerId, + lastRefreshAttemptAt: dateOrNull(candidate.lastRefreshAttemptAt), + lastRefreshErrorCode: nullableText(candidate.lastRefreshErrorCode), + }; +} + +function normalizeAppliedServer(value: unknown): HarborServer | null { + return (normalizeServers(value ? [value] : []) as HarborServer[])[0] || null; +} + +export function profileById(state: Pick, profileId: unknown) { + const id = identityText(profileId); + return state.profiles.find((profile) => profile.id === id) || null; +} + +export function desiredProfile(state: Pick) { + return profileById(state, state.desiredProfileId); +} + +export function appliedProfile(state: Pick) { + return profileById(state, state.appliedProfileId); +} + +export function normalizeStoredState(value: unknown): StoredState { + const state = record(value); + const legacyServers = normalizeServers(state.servers) as HarborServer[]; + const legacySelectedServerId = resolveServerId( + legacyServers, 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); + const legacyAppliedServerId = Object.hasOwn(state, 'appliedServerId') + ? resolveServerId(legacyServers, identityText(state.appliedServerId)) + : resolveServerId(legacyServers, '', identityText(state.appliedTag) || identityText(state.selectedTag)); + const suppliedProfiles = Array.isArray(state.profiles) + ? state.profiles.map(normalizeProfile) + : []; + const profiles = Array.isArray(state.profiles) + ? suppliedProfiles.filter((profile, index) => ( + suppliedProfiles.findIndex((candidate) => candidate.id === profile.id) === index + )) + : identityText(state.subscriptionUrl) || legacyServers.length + ? [normalizeProfile({ + id: 'profile_primary', + label: 'Основной', + subscriptionUrl: state.subscriptionUrl, + subscriptionConfig: state.subscriptionConfig, + servers: legacyServers, + userInfo: state.userInfo, + fetchedAt: state.fetchedAt, + desiredServerId: legacySelectedServerId, + }, 0)] + : []; + + const requestedDesiredProfileId = identityText(state.desiredProfileId); + const desiredProfileId = profileById({ profiles }, requestedDesiredProfileId)?.id + || (profiles.length === 1 ? profiles[0].id : ''); + const requestedAppliedProfileId = identityText(state.appliedProfileId); + const appliedProfileId = profileById({ profiles }, requestedAppliedProfileId)?.id + || (legacyAppliedServerId && profiles.length === 1 ? profiles[0].id : ''); + const selectedProfile = profileById({ profiles }, desiredProfileId); + const currentAppliedProfile = profileById({ profiles }, appliedProfileId); + const normalizedSnapshot = normalizeAppliedServer(state.appliedServerSnapshot); + const explicitAppliedServerId = identityText(state.appliedServerId); + const appliedServerId = explicitAppliedServerId && ( + currentAppliedProfile?.servers.some((server) => server.id === explicitAppliedServerId) + || normalizedSnapshot?.id === explicitAppliedServerId + ) + ? explicitAppliedServerId + : legacyAppliedServerId; + const appliedServerSnapshot = currentAppliedProfile?.servers.find( + (server) => server.id === appliedServerId, + ) || (normalizedSnapshot?.id === appliedServerId ? normalizedSnapshot : null); + const selectedServerId = selectedProfile?.desiredServerId || ''; + const selectedServer = selectedProfile?.servers.find((server) => server.id === selectedServerId); + return { ...state, revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0, - selectedServerId, + profiles, + desiredProfileId, + appliedProfileId, appliedServerId, - selectedTag: selectedServer?.label || '', - appliedTag: appliedServer?.label || '', - servers, + appliedServerSnapshot, 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, + subscriptionUrl: selectedProfile?.subscriptionUrl || '', + selectedServerId, + selectedTag: selectedServer?.label || '', + appliedTag: appliedServerSnapshot?.label || '', + servers: selectedProfile?.servers || [], + userInfo: selectedProfile?.userInfo || {}, + fetchedAt: selectedProfile?.fetchedAt || undefined, + }; +} + +function profileSnapshot(profile: StoredProfile): ProfileSnapshot { + return { + id: profile.id, + label: profile.label, + subscription: { + status: profile.lastRefreshErrorCode ? 'stale' : 'ready', + host: publicSubscriptionHost(profile.subscriptionUrl), + fetchedAt: dateOrNull(profile.fetchedAt), + userInfo: profile.userInfo, + lastRefreshAttemptAt: dateOrNull(profile.lastRefreshAttemptAt), + errorCode: nullableText(profile.lastRefreshErrorCode), + }, + desiredServerId: profile.desiredServerId, + servers: profile.servers, }; } @@ -149,7 +313,6 @@ export function createStateSnapshot({ gatewayAuto, appMode, configExists, - subscriptionHost, operation = { kind: null, status: 'idle', startedAt: null, error: null }, now = new Date(), }: { @@ -158,17 +321,19 @@ export function createStateSnapshot({ gatewayAuto?: GatewayAutoState | null; appMode?: string; configExists: boolean; - subscriptionHost: string; + 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 selectedProfile = desiredProfile(stored); + const profiles = stored.profiles.map(profileSnapshot); + const selectedSnapshot = profiles.find((profile) => profile.id === stored.desiredProfileId) || null; const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired) ? stored.connectionDesired : configExists ? 'running' : 'stopped'; - const servers = stored.servers; + const running = Boolean(runtime?.running); const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false; const routeReason = mode !== 'client' @@ -178,26 +343,36 @@ export function createStateSnapshot({ : routeMode === 'gateway-direct' ? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found' : gatewayAuto?.lastError ? 'gateway-lost' : 'local'; - const activeLocalRules = runtime?.running ? stored.appliedRouteRules : []; + const activeLocalRules = running ? stored.appliedRouteRules : []; + const appliedServerSnapshot = stored.appliedServerSnapshot; 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 : {}, + profiles, + subscription: selectedSnapshot ? { + status: selectedSnapshot.subscription.status, + host: selectedSnapshot.subscription.host, + fetchedAt: selectedSnapshot.subscription.fetchedAt, + userInfo: selectedSnapshot.subscription.userInfo, + } : { + status: 'missing', + host: '', + fetchedAt: null, + userInfo: {}, }, selection: { - desiredServerId: stored.selectedServerId, + desiredProfileId: selectedProfile?.id || '', + desiredServerId: selectedProfile?.desiredServerId || '', + appliedProfileId: stored.appliedProfileId, appliedServerId: stored.appliedServerId, + appliedServerSnapshot, }, connection: { desired, - process: runtime?.running ? 'running' : 'stopped', + process: running ? 'running' : 'stopped', startedAt: dateOrNull(runtime?.startedAt), lastError: null, }, @@ -219,8 +394,10 @@ export function createStateSnapshot({ status: operation.status, startedAt: nullableText(operation.startedAt), error: nullableText(operation.error), + profileId: nullableText(operation.profileId), + serverId: nullableText(operation.serverId), }, - servers: servers as HarborServer[], + servers: selectedProfile?.servers || [], }); } @@ -245,6 +422,23 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { Boolean(rule.value) && typeof rule.enabled === 'boolean' ); + const validProfile = (profile: ProfileSnapshot) => ( + profile && + typeof profile.id === 'string' && Boolean(profile.id) && + typeof profile.label === 'string' && Boolean(profile.label) && + !Object.hasOwn(profile, 'subscriptionUrl') && + !Object.hasOwn(profile, 'subscriptionConfig') && + profile.subscription && + ['ready', 'stale'].includes(profile.subscription.status) && + typeof profile.subscription.host === 'string' && + !Object.hasOwn(profile.subscription, 'url') && + nullableDate(profile.subscription.fetchedAt) && + profile.subscription.userInfo && typeof profile.subscription.userInfo === 'object' && + nullableDate(profile.subscription.lastRefreshAttemptAt) && + nullableString(profile.subscription.errorCode) && + typeof profile.desiredServerId === 'string' && + Array.isArray(profile.servers) && profile.servers.every(validServer) + ); if ( !snapshot || @@ -253,16 +447,22 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { candidate.revision < 0 || !validDate(candidate.generatedAt) || !MODES.has(candidate.mode) || + !Array.isArray(candidate.profiles) || + !candidate.profiles.every(validProfile) || + new Set(candidate.profiles.map((profile) => profile.id)).size !== candidate.profiles.length || !candidate.subscription || - !['missing', 'ready'].includes(candidate.subscription.status) || + !['missing', 'ready', 'stale'].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.desiredProfileId !== 'string' || typeof candidate.selection.desiredServerId !== 'string' || + typeof candidate.selection.appliedProfileId !== 'string' || typeof candidate.selection.appliedServerId !== 'string' || + !(candidate.selection.appliedServerSnapshot === null || validServer(candidate.selection.appliedServerSnapshot)) || !candidate.connection || !CONNECTION_STATES.has(candidate.connection.desired) || !CONNECTION_STATES.has(candidate.connection.process) || @@ -288,6 +488,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { !OPERATION_STATES.has(candidate.operation.status) || !nullableDate(candidate.operation.startedAt) || !nullableString(candidate.operation.error) || + !nullableString(candidate.operation.profileId) || + !nullableString(candidate.operation.serverId) || !Array.isArray(candidate.servers) || !candidate.servers.every(validServer) ) { diff --git a/src/shared/errors.ts b/src/shared/errors.ts index ceb6412..2164f19 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -14,6 +14,9 @@ export const ERROR_DEFINITIONS = Object.freeze({ SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false }, SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false }, PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true }, + PROFILE_NOT_FOUND: { status: 404, message: 'Подписка больше недоступна.', retryable: false }, + PROFILE_NAME_CONFLICT: { status: 409, message: 'Подписка с таким именем уже существует.', retryable: false }, + PROFILE_IN_USE: { status: 409, message: 'Сначала переключите или остановите активную подписку.', retryable: false }, STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false }, diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 7d232fc..05b2efa 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.22.1', - gatewayClient: '0.23.1', - gatewayBackend: '0.23.1', + macClient: '0.23.0', + gatewayClient: '0.24.0', + gatewayBackend: '0.24.0', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index 2e6b4a2..0a875af 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -18,7 +18,6 @@ import { } from './state/operations.js'; const componentActions = { - validateSubscription: api.subscription.validate, listDevices: api.devices.list, refreshDevices: api.devices.refresh, updateDevice: api.devices.update, @@ -29,23 +28,43 @@ const componentActions = { interface UiError { context: string; + profileId: string; message: string; code: string; correlationId: string; retry: (() => unknown) | null; } +const operationErrorContext: Record = { + start: 'connection', + stop: 'connection', + 'apply-server': 'connection', + 'profile-activate': 'connection', + 'gateway-auto': 'connection', + 'profile-add': 'subscription', + 'profile-rename': 'subscription', + 'profile-select-server': 'subscription', + 'profile-refresh': 'subscription', + 'profile-delete': 'subscription', + 'subscription-import': 'subscription', + 'subscription-refresh': 'subscription', + 'subscription-forget': 'subscription', + 'route-rules': 'routing', +}; + export function App() { const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); - const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer( + const [{ snapshot: state, transport }, dispatch] = useReducer( harborReducer, initialHarborState, ); - const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [operations, setOperations] = useState({}); const [error, setError] = useState(null); + const [dismissedCanonicalError, setDismissedCanonicalError] = useState(''); const [versionInfo, setVersionInfo] = useState(null); const pollGeneration = useRef(0); + const revisionRef = useRef(0); + const hasAcceptedSnapshotRef = useRef(false); const operationRegistry = useRef | null>(null); if (!operationRegistry.current) { operationRegistry.current = createOperationRegistry((next) => { @@ -53,16 +72,16 @@ export function App() { }); } - function setPendingServerId(serverId: string) { - dispatch({ type: 'select-server', serverId }); - } - async function loadState({ retry = false }: { retry?: boolean } = {}) { if (retry) dispatch({ type: 'retry-sync' }); const generation = pollGeneration.current; try { const snapshot = await harborClient.getState(); if (generation === pollGeneration.current) { + if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) { + hasAcceptedSnapshotRef.current = true; + revisionRef.current = snapshot.revision; + } dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() }); } } catch (requestError) { @@ -98,30 +117,43 @@ export function App() { if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2'; }, [state?.mode]); - function run(key: OperationKey, action: () => Promise, context: string) { + const canonicalErrorId = state?.operation?.status === 'failed' && state.operation.error + ? [state.operation.kind, state.operation.startedAt, state.operation.profileId, state.operation.error].join(':') + : ''; + useEffect(() => setDismissedCanonicalError(''), [canonicalErrorId]); + + function run( + key: OperationKey, + action: () => Promise, + context: string, + target = '', + profileId = '', + ) { setError(null); return operationRegistry.current!.run(key, async () => { try { return await applyMutation(action); } catch (err) { + await loadState(); const candidate = err && typeof err === 'object' ? err as Record : {}; const safeError = err instanceof HarborApiError ? err : new HarborApiError({ code: candidate.code }, Number(candidate.status)); setError({ context, + profileId, message: context === 'routing' && safeError.code === 'STATE_CONFLICT' ? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.' : safeError.message, code: safeError.code, correlationId: safeError.correlationId, retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT' - ? () => run(key, action, context) + ? () => run(key, action, context, target, profileId) : null, }); return false; } - }); + }, target); } async function applyMutation(action: () => Promise) { @@ -133,6 +165,10 @@ export function App() { const result = response as Record; if (!result.state) throw new Error('Harbor API не вернул state snapshot'); const snapshot = parseHarborState(result.state); + if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) { + hasAcceptedSnapshotRef.current = true; + revisionRef.current = snapshot.revision; + } dispatch({ type: 'sync-succeeded', snapshot, @@ -141,36 +177,59 @@ export function App() { return result; } - async function fetchSubscription() { - return run('subscriptionImport', async () => { - const data = await api.subscription.fetch(subscriptionUrl); - dispatch({ type: 'clear-pending-server' }); - return data; - }, 'subscription'); - } - - async function refreshSubscription() { - return run('subscriptionRefresh', api.subscription.refresh, 'subscription'); - } - - async function forgetSubscription() { - return run('subscriptionDelete', async () => { - const data = await api.subscription.forget(); - setSubscriptionUrl(''); - dispatch({ type: 'clear-pending-server' }); - return data; - }, 'subscription'); - } - if (!state) return loadState({ retry: true })} />; + const previewServer = { + id: 'preview-amsterdam', + label: 'Amsterdam', + host: '127.0.0.1', + port: 443, + protocol: 'vless', + }; const displayState = previewReady ? { ...state, mode: 'client' as const, + profiles: [{ + id: 'preview-personal', + label: 'Личный', + subscription: { + status: 'ready' as const, + host: 'harbor.example/…', + fetchedAt: new Date().toISOString(), + userInfo: {}, + lastRefreshAttemptAt: new Date().toISOString(), + errorCode: null, + }, + desiredServerId: previewServer.id, + servers: [previewServer], + }], subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' }, - selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' }, + selection: { + ...state.selection, + desiredProfileId: 'preview-personal', + desiredServerId: 'preview-amsterdam', + appliedProfileId: 'preview-personal', + appliedServerId: 'preview-amsterdam', + appliedServerSnapshot: previewServer, + }, + servers: [previewServer], clientRuntime: { ...state.clientRuntime, proxyPort: 8082 }, } : state; + const canonicalErrorContext = operationErrorContext[state.operation.kind || '']; + const visibleError = error || ( + canonicalErrorId + && canonicalErrorId !== dismissedCanonicalError + && canonicalErrorContext + ? { + context: canonicalErrorContext, + profileId: state.operation.profileId || '', + message: state.operation.error || 'Операция не выполнена.', + code: 'UNKNOWN', + correlationId: '', + retry: null, + } + : null + ); return (
@@ -182,22 +241,55 @@ export function App() { state={displayState} versionInfo={versionInfo} operations={operations} - error={error} - subscriptionUrl={subscriptionUrl} - setSubscriptionUrl={setSubscriptionUrl} - servers={previewReady ? [{ - id: 'preview-amsterdam', - label: 'Amsterdam', - host: '127.0.0.1', - port: 443, - protocol: 'vless', - }] : state.servers || []} - pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId} - setPendingServerId={setPendingServerId} - onFetchSubscription={fetchSubscription} - onRefreshSubscription={refreshSubscription} - onForgetSubscription={forgetSubscription} - onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')} + error={visibleError} + onAddProfile={(label: string, url: string) => run( + 'profileAdd', + () => api.profiles.add(label, url, revisionRef.current), + 'subscription', + label, + )} + onRenameProfile={(profileId: string, label: string) => run( + 'profileRename', + () => api.profiles.rename(profileId, label, revisionRef.current), + 'subscription', + profileId, + profileId, + )} + onSelectProfileServer={(profileId: string, serverId: string) => run( + 'profileSelect', + () => api.profiles.selectServer(profileId, serverId, revisionRef.current), + 'subscription', + `${profileId}:${serverId}`, + profileId, + )} + onActivateProfile={(profileId: string) => run( + 'profileActivate', + () => api.profiles.activate(profileId, revisionRef.current), + 'connection', + profileId, + profileId, + )} + onRefreshProfile={(profileId: string) => run( + 'profileRefresh', + () => api.profiles.refresh(profileId, revisionRef.current), + 'subscription', + profileId, + profileId, + )} + onForgetProfile={(profileId: string, mode: 'delete' | 'stop-and-delete') => run( + 'profileDelete', + () => api.profiles.forget(profileId, mode, revisionRef.current), + 'subscription', + profileId, + profileId, + )} + onApply={(profileId: string, serverId: string) => run( + 'serverApply', + () => api.apply(profileId, serverId, revisionRef.current), + 'connection', + `${profileId}:${serverId}`, + profileId, + )} onRestart={() => run('connection', api.singbox.restart, 'connection')} onStop={() => run('connection', api.singbox.stop, 'connection')} onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} @@ -206,7 +298,10 @@ export function App() { () => api.routeRules.update(rules, expectedRevision), 'routing', )} - onDismissError={() => setError(null)} + onDismissError={() => { + setError(null); + setDismissedCanonicalError(canonicalErrorId); + }} />
diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index a715216..6f67272 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -96,10 +96,50 @@ export const api = { refresh: () => request('/api/subscription/refresh', { method: 'POST' }), forget: () => request('/api/subscription', { method: 'DELETE' }), }, - apply: (serverId: string) => request('/api/apply', { + profiles: { + add: (label: string, url: string, expectedRevision: number) => request('/api/profiles', { + method: 'POST', + body: JSON.stringify({ label, url, expectedRevision }), + }), + rename: (profileId: string, label: string, expectedRevision: number) => request( + `/api/profiles/${encodeURIComponent(profileId)}`, + { + method: 'PATCH', + body: JSON.stringify({ label, expectedRevision }), + }, + ), + selectServer: (profileId: string, serverId: string, expectedRevision: number) => request( + `/api/profiles/${encodeURIComponent(profileId)}/server`, + { + method: 'PUT', + body: JSON.stringify({ serverId, expectedRevision }), + }, + ), + activate: (profileId: string, expectedRevision: number) => request( + `/api/profiles/${encodeURIComponent(profileId)}/activate`, + { + method: 'POST', + body: JSON.stringify({ expectedRevision }), + }, + ), + refresh: (profileId: string, expectedRevision: number) => request( + `/api/profiles/${encodeURIComponent(profileId)}/refresh`, + { + method: 'POST', + body: JSON.stringify({ expectedRevision }), + }, + ), + forget: (profileId: string, mode: 'delete' | 'stop-and-delete', expectedRevision: number) => request( + `/api/profiles/${encodeURIComponent(profileId)}`, + { + method: 'DELETE', + body: JSON.stringify({ mode, expectedRevision }), + }, + ), + }, + apply: (profileId: string, serverId: string, expectedRevision: number) => request('/api/apply', { method: 'POST', - // selectedTag keeps this client compatible with pre-ID Harbor backends. - body: JSON.stringify({ serverId, selectedTag: serverId }), + body: JSON.stringify({ profileId, serverId, expectedRevision }), }), gatewayAuto: { setEnabled: (enabled: boolean) => request('/api/gateway-auto', { @@ -145,10 +185,13 @@ export const api = { restart: () => request('/api/singbox/restart', { method: 'POST' }), }, servers: { - ping: (serverIds: string[]) => request('/api/servers/ping-all', { + ping: (profileId: string, serverIds: string[]) => request( + `/api/profiles/${encodeURIComponent(profileId)}/servers/ping`, + { method: 'POST', body: JSON.stringify({ serverIds }), - }), + }, + ), }, }; @@ -177,6 +220,7 @@ export function parseHarborState(value: unknown): HarborClientState { revision: snapshot.revision, generatedAt: snapshot.generatedAt, mode: snapshot.mode, + profiles: snapshot.profiles, subscription: snapshot.subscription, selection: snapshot.selection, connection: snapshot.connection, diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 00355ab..e81fac1 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -51,7 +51,7 @@ import { versionCompatibility, } from '../../shared/versions.js'; import type { - HarborServer, + ProfileSnapshot, RouteRule, StateSnapshot, } from '../../shared/contracts/state.js'; @@ -64,6 +64,7 @@ const VERSION_PARTS = [ interface UiError { context?: string; + profileId?: string; message?: string; correlationId?: string; retry?: (() => unknown) | null; @@ -79,12 +80,11 @@ interface VersionBadgeProps { } interface ComponentActions { - validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise; listDevices: () => Promise; refreshDevices: () => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise; - pingServers: (ids: string[]) => Promise; + pingServers: (profileId: string, ids: string[]) => Promise; runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise; } @@ -102,15 +102,13 @@ interface ClientOverviewPageProps { versionInfo: unknown; operations?: OperationRegistrySnapshot; error: UiError | null; - subscriptionUrl: string; - setSubscriptionUrl: (value: string) => void; - servers: HarborServer[]; - pendingServerId: string; - setPendingServerId: (id: string) => void; - onFetchSubscription: () => Promise; - onRefreshSubscription: () => Promise; - onForgetSubscription: () => Promise; - onApply: (serverId: string) => Promise; + onAddProfile: (label: string, url: string) => Promise; + onRenameProfile: (profileId: string, label: string) => Promise; + onSelectProfileServer: (profileId: string, serverId: string) => Promise; + onActivateProfile: (profileId: string) => Promise; + onRefreshProfile: (profileId: string) => Promise; + onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise; + onApply: (profileId: string, serverId: string) => Promise; onRestart: () => Promise; onStop: () => Promise; onSetGatewayAuto: (enabled: boolean) => Promise; @@ -243,11 +241,30 @@ function InlineError({ error, context }: { error?: UiError | null; context: stri const operationProgress: Partial> = { connection: ['connection', 'Меняем состояние подключения…'], serverApply: ['connection', 'Применяем сервер…'], - subscriptionImport: ['subscription', 'Загружаем подписку…'], - subscriptionDelete: ['subscription', 'Удаляем подписку…'], + profileActivate: ['connection', 'Переключаем подписку…'], + profileAdd: ['subscription', 'Добавляем подписку…'], + profileRefresh: ['subscription', 'Обновляем подписку…'], + profileDelete: ['subscription', 'Удаляем подписку…'], routeRules: ['routing', 'Применяем локальные правила…'], }; +const canonicalOperationKeys: Record = { + start: 'connection', + stop: 'connection', + 'apply-server': 'serverApply', + 'profile-add': 'profileAdd', + 'profile-rename': 'profileRename', + 'profile-select-server': 'profileSelect', + 'profile-activate': 'profileActivate', + 'profile-refresh': 'profileRefresh', + 'profile-delete': 'profileDelete', + 'gateway-auto': 'gatewayAuto', + 'route-rules': 'routeRules', + 'subscription-import': 'profileAdd', + 'subscription-refresh': 'profileRefresh', + 'subscription-forget': 'profileDelete', +}; + function InlineProgress({ operations, context }: { operations: OperationRegistrySnapshot; context: string; @@ -266,6 +283,29 @@ function InlineProgress({ operations, context }: { ); } +function AppliedIdentity({ identity, operation }: { identity: string; operation: string }) { + const [current, setCurrent] = useState(identity); + const [previous, setPrevious] = useState(''); + + useEffect(() => { + if (identity === current) return undefined; + setPrevious(current); + setCurrent(identity); + const timer = setTimeout(() => setPrevious(''), 360); + return () => clearTimeout(timer); + }, [identity]); + + return
+ +
+ {operation && {operation}} +
+
; +} + function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: { isGateway: boolean; connected: boolean; @@ -369,14 +409,12 @@ export function ClientOverviewPage({ versionInfo, operations = {}, error, - subscriptionUrl, - setSubscriptionUrl, - servers, - pendingServerId, - setPendingServerId, - onFetchSubscription, - onRefreshSubscription, - onForgetSubscription, + onAddProfile, + onRenameProfile, + onSelectProfileServer, + onActivateProfile, + onRefreshProfile, + onForgetProfile, onApply, onRestart, onStop, @@ -388,12 +426,18 @@ export function ClientOverviewPage({ const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct'; const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable); const connected = state?.connection?.process === 'running'; - const hasSubscription = state?.subscription?.status === 'ready'; - const selectedServerId = pendingServerId || state?.selection?.desiredServerId || ''; + const profiles = state?.profiles || []; + const hasSubscription = profiles.length > 0; + const desiredProfile = profiles.find(({ id }) => id === state?.selection?.desiredProfileId); + const appliedProfile = profiles.find(({ id }) => id === state?.selection?.appliedProfileId); + const selectedServerId = desiredProfile?.desiredServerId || ''; const appliedServerId = state?.selection?.appliedServerId || ''; - const appliedServer = servers.find(({ id }) => id === appliedServerId); - const desiredServer = servers.find(({ id }) => id === selectedServerId); - const showPower = isGateway || (hasSubscription && Boolean(selectedServerId)); + const appliedServer = appliedProfile?.servers.find(({ id }) => id === appliedServerId) + || (state?.selection?.appliedServerSnapshot?.id === appliedServerId + ? state.selection.appliedServerSnapshot + : null); + const desiredServer = desiredProfile?.servers.find(({ id }) => id === selectedServerId); + const showPower = hasSubscription; const [now, setNow] = useState(Date.now()); const [showIntro, setShowIntro] = useState(true); const [copyFeedback, setCopyFeedback] = useState({}); @@ -403,32 +447,62 @@ export function ClientOverviewPage({ const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const controlHost = window.location.host || `${gatewayAddress}:3456`; const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress); - const connectionBlocked = operationBlocked(operations, 'connection'); - const serverApplyBlocked = operationBlocked(operations, 'serverApply'); - const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto'); - const switchingServer = Boolean( - selectedServerId && selectedServerId !== appliedServerId && desiredServer, - ); + const canonicalOperationKey = state.operation.status === 'running' + ? canonicalOperationKeys[state.operation.kind || ''] + : undefined; + const canonicalTarget = state.operation.profileId + ? `${state.operation.profileId}${state.operation.serverId ? `:${state.operation.serverId}` : ''}` + : ''; + const visibleOperations = canonicalOperationKey && !operations[canonicalOperationKey] + ? { + ...operations, + [canonicalOperationKey]: { + status: 'running' as const, + startedAt: state.operation.startedAt || state.generatedAt, + target: canonicalTarget, + }, + } + : operations; + const connectionBlocked = operationBlocked(visibleOperations, 'connection'); + const serverApplyBlocked = operationBlocked(visibleOperations, 'serverApply'); + const gatewayAutoBlocked = operationBlocked(visibleOperations, 'gatewayAuto'); + const localApplyTarget = operations.serverApply?.target.split(':') || []; + const canonicalSwitch = state.operation.status === 'running' + && ['profile-activate', 'apply-server'].includes(state.operation.kind || ''); + const operationProfileId = canonicalSwitch + ? state.operation.profileId || '' + : operations.profileActivate?.target || localApplyTarget[0] || ''; + const operationProfile = profiles.find(({ id }) => id === operationProfileId); + const operationServerId = canonicalSwitch + ? state.operation.serverId || operationProfile?.desiredServerId || '' + : localApplyTarget[1] || operationProfile?.desiredServerId || ''; + const operationServer = operationProfile?.servers.find(({ id }) => id === operationServerId); + const localSwitch = operations.profileActivate?.status === 'running' + || operations.serverApply?.status === 'running'; + const switchingServer = connected + && !gatewayDirect + && (canonicalSwitch || localSwitch) + && Boolean(operationProfile && operationServer) + && (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id); const subscriptionFeature = useSubscriptionFeature({ - subscription: state?.subscription, - subscriptionUrl, - setSubscriptionUrl, - operations, + profiles, + selection: state.selection, + connected, + operations: visibleOperations, error, - serverCount: servers.length, isGateway, gatewayDirect, - validateSubscription: actions.validateSubscription, - onImport: onFetchSubscription, - onRefresh: onRefreshSubscription, - onForget: onForgetSubscription, + onAdd: onAddProfile, + onRename: onRenameProfile, + onRefresh: onRefreshProfile, + onForget: onForgetProfile, + onActivate: onActivateProfile, onDismissError, }); - const subscriptionContentReady = subscriptionFeature.contentReady; const routingFeature = useRoutingFeature({ route: state?.route, connected, - operations, + operations: visibleOperations, onSave: onSaveRouteRules, onDismissError, }); @@ -446,7 +520,7 @@ export function ClientOverviewPage({ port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082), controlHost, }); - const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady); + const diagnosticsAvailable = hasSubscription; useEffect(() => { setNow(Date.now()); @@ -465,11 +539,9 @@ export function ClientOverviewPage({ useEffect(() => { if (!hasSubscription) { routingFeature.forceClose(); - if (!isGateway) { - instructionsFeature.close(); - devicesFeature.close(); - diagnosticsFeature.close(); - } + instructionsFeature.close(); + devicesFeature.close(); + diagnosticsFeature.close(); } }, [hasSubscription, isGateway]); @@ -482,9 +554,12 @@ export function ClientOverviewPage({ copyAttemptsRef.current = {}; }, []); - function selectServer(serverId: string) { - setPendingServerId(serverId); - if (connected && serverId) onApply(serverId); + function selectServer(profile: ProfileSnapshot, serverId: string) { + if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) { + onApply(profile.id, serverId); + return; + } + onSelectProfileServer(profile.id, serverId); } async function copyProxy(kind: CopyKind) { @@ -529,16 +604,31 @@ export function ClientOverviewPage({ routingFeature.open(); } + const mainIdentity = gatewayDirect + ? 'Gateway · сервер не определён' + : connected + ? appliedProfile && appliedServer + ? `${appliedProfile.label} · ${appliedServer.label}` + : 'VPN · сервер не определён' + : desiredProfile && desiredServer + ? `Выбран: ${desiredProfile.label} · ${desiredServer.label}` + : 'Сервер не выбран'; + const switchIdentity = gatewayDirect + ? 'Данные применённого сервера Gateway недоступны' + : switchingServer && operationProfile && operationServer + ? `Переключаем на ${operationProfile.label} · ${operationServer.label}` + : ''; + return (
{copyAnnouncement.text}
- {(isGateway || (hasSubscription && subscriptionContentReady)) && } -
+
} copyFeedback={copyFeedback} onCopyProxy={copyProxy} - onApply={onApply} - onRestart={onRestart} + onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)} onStop={onStop} routingSlot={} - serverSlot={isGateway &&
- Сейчас - - {appliedServer?.label || 'VPN-сервер не используется'} - -
- {switchingServer && desiredServer && Переключаем на {desiredServer.label}} -
-
} + serverSlot={} statusSlot={<> - + } /> - {isGateway && } + {isGateway && hasSubscription && } - } - serverSlot={hasSubscription && subscriptionContentReady && selectServer(profile, serverId)} />} />
- {(isGateway || (hasSubscription && subscriptionContentReady)) && } - {isGateway && } + {isGateway && hasSubscription && } {diagnosticsAvailable && } - {hasSubscription && subscriptionContentReady && - + } />} diff --git a/src/web/features/connection/ConnectionPanel.tsx b/src/web/features/connection/ConnectionPanel.tsx index f8119b7..cdb7209 100644 --- a/src/web/features/connection/ConnectionPanel.tsx +++ b/src/web/features/connection/ConnectionPanel.tsx @@ -26,7 +26,6 @@ interface ConnectionPanelProps { connected: boolean; gatewayDirect: boolean; selectedServerId: string; - configured: boolean; startedAt?: string | null; gatewayAddress: string; gatewayUiOrigin?: string | null; @@ -41,7 +40,6 @@ interface ConnectionPanelProps { statusSlot?: ReactNode; onCopyProxy: (kind: CopyKind) => unknown; onApply: (serverId: string) => unknown; - onRestart: () => unknown; onStop: () => unknown; } @@ -67,7 +65,6 @@ export function ConnectionPanel({ connected, gatewayDirect, selectedServerId, - configured, startedAt, gatewayAddress, gatewayUiOrigin, @@ -82,7 +79,6 @@ export function ConnectionPanel({ statusSlot, onCopyProxy, onApply, - onRestart, onStop, }: ConnectionPanelProps) { const [durationMode, setDurationMode] = useState(() => { @@ -93,8 +89,9 @@ export function ConnectionPanel({ } }); const [confirmingStop, setConfirmingStop] = useState(false); - const canStart = Boolean(selectedServerId || configured); - const powerUnavailable = isGateway && !connected && !canStart; + const remoteOwned = !isGateway && gatewayDirect; + const canStart = Boolean(selectedServerId); + const powerUnavailable = remoteOwned || (!connected && !canStart); const proxyUrls = localProxyUrls(proxyPort, gatewayAddress); const duration = connectionDurationParts(startedAt, now); const clockUnits: Array<[string, DurationUnit]> = [ @@ -104,9 +101,9 @@ export function ConnectionPanel({ ]; const wordClockDuration = clockUnits .filter(([name, part]) => duration.days.value || part.value || name === 'seconds'); - const connectionTitle = connected - ? gatewayDirect ? 'Gateway подключён' : 'VPN включён' - : 'Подключение выключено'; + const connectionTitle = remoteOwned + ? 'Gateway подключён' + : connected ? 'VPN включён' : 'Подключение выключено'; const proxyKinds: Array<[CopyKind, string]> = isGateway ? [ ['gateway', 'GATEWAY'], @@ -119,13 +116,12 @@ export function ConnectionPanel({ ]; function toggleConnection() { - const action = connectionAction({ connected, selectedServerId, configExists: configured }); + const action = connectionAction({ connected, selectedServerId }); if (action?.type === 'stop') { setConfirmingStop(true); return; } if (action?.type === 'apply') return onApply(action.serverId); - if (action?.type === 'restart') return onRestart(); } async function stopConnection() { @@ -149,12 +145,14 @@ export function ConnectionPanel({ className="client-power" type="button" role="switch" - aria-checked={connected} - aria-label={isGateway + aria-checked={connected || remoteOwned} + aria-label={remoteOwned + ? 'Подключением управляет Harbor Gateway' + : isGateway ? connected ? 'Остановить VPN' : 'Запустить VPN' : connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'} aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined} - disabled={blocked || (!connected && !canStart)} + disabled={blocked || powerUnavailable} onClick={toggleConnection} >
- {connected ? ( + {connected && !remoteOwned ? (
{routingSlot}

- - - + + +

{serverSlot}
diff --git a/src/web/features/servers/ServerPicker.tsx b/src/web/features/servers/ServerPicker.tsx index a0a8662..a239d4f 100644 --- a/src/web/features/servers/ServerPicker.tsx +++ b/src/web/features/servers/ServerPicker.tsx @@ -53,9 +53,9 @@ function write(key: string, value: string | string[]) { } } -function readAuto() { +function readAuto(key: string) { try { - return localStorage.getItem(AUTO_KEY) === 'true'; + return localStorage.getItem(key) === 'true'; } catch { return false; } @@ -153,7 +153,8 @@ function ServerRow({ } interface ServerPickerProps { - pingServers: (ids: string[]) => Promise; + profileId: string; + pingServers: (profileId: string, ids: string[]) => Promise; servers: PickerServer[]; selectedServerId: string; disabled: boolean; @@ -164,6 +165,7 @@ interface ServerPickerProps { } export function ServerPicker({ + profileId, pingServers, servers, selectedServerId, @@ -173,13 +175,16 @@ export function ServerPicker({ revealVersion, onSelect, }: ServerPickerProps) { + const favoritesKey = `${FAVORITES_KEY}:${profileId}`; + const recentKey = `${RECENT_KEY}:${profileId}`; + const autoKey = `${AUTO_KEY}:${profileId}`; const [query, setQuery] = useState(''); const [advanced, setAdvanced] = useState(false); const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all'); const [page, setPage] = useState(0); - const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY)); - const [recent, setRecent] = useState(() => readList(RECENT_KEY)); - const [autoActive, setAutoActive] = useState(readAuto); + const [favorites, setFavorites] = useState(() => readList(favoritesKey)); + const [recent, setRecent] = useState(() => readList(recentKey)); + const [autoActive, setAutoActive] = useState(() => readAuto(autoKey)); const [collapsed, setCollapsed] = useState([]); const [pings, setPings] = useState({}); const [checking, setChecking] = useState(false); @@ -208,18 +213,18 @@ export function ServerPicker({ function toggleFavorite(id: string) { setFavorites((current) => { const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current]; - write(FAVORITES_KEY, next); + write(favoritesKey, next); return next; }); } function select(id: string, automatic = false) { setAutoActive(automatic); - write(AUTO_KEY, String(automatic)); + write(autoKey, String(automatic)); if (!automatic) { setRecent((current) => { const next = [id, ...current.filter((item) => item !== id)].slice(0, 5); - write(RECENT_KEY, next); + write(recentKey, next); return next; }); } @@ -236,7 +241,7 @@ export function ServerPicker({ ...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])), })); try { - const results = parseServerPingResults(await pingServers(ids)) as PingResult[]; + const results = parseServerPingResults(await pingServers(profileId, ids)) as PingResult[]; setPings((current) => ({ ...current, ...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])), @@ -251,7 +256,10 @@ export function ServerPicker({ }])), })); } finally { - await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt)))); + const elapsed = performance.now() - startedAt; + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed))); setPings((current) => ({ ...current, ...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])), @@ -324,7 +332,7 @@ export function ServerPicker({ inert={advanced ? true : undefined} >
-
+
{simpleServers.map((server, index) => }
-
+
{!visible.length &&

Серверы не найдены

} {grouped ? groupServers(visible).map(([group, items]) => { const isCollapsed = collapsed.includes(group); diff --git a/src/web/features/subscription/SubscriptionFeature.tsx b/src/web/features/subscription/SubscriptionFeature.tsx index 09970d5..0e13f1b 100644 --- a/src/web/features/subscription/SubscriptionFeature.tsx +++ b/src/web/features/subscription/SubscriptionFeature.tsx @@ -3,10 +3,14 @@ import { useRef, useState, type ReactNode, - type RefObject, } from 'react'; import { ERROR_DEFINITIONS } from '../../../shared/errors.js'; -import { operationBlocked } from '../../state/operations.js'; +import type { ProfileSnapshot, StateSnapshot } from '../../../shared/contracts/state.js'; +import { + operationBlocked, + type OperationKey, + type OperationRegistrySnapshot, +} from '../../state/operations.js'; import { isSubscriptionUrlValid, subscriptionDaysLeft, @@ -15,552 +19,584 @@ import { } from '../../utils/clientControls.js'; import { formatBytes } from '../../utils/format.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; -import { normalizeRequestError, type RequestError } from './requestError.js'; +import type { RequestError } from './requestError.js'; -const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; +interface ProfileError extends RequestError { + profileId?: string; +} -interface SubscriptionState { - status?: string; - host?: string | null; - userInfo?: Record | null; +interface ServerPickerRenderState { + disabled: boolean; + leaving: boolean; + revealVersion: number; } interface SubscriptionFeatureOptions { - subscription?: SubscriptionState | null; - subscriptionUrl: string; - setSubscriptionUrl: (value: string) => void; - operations: Record; - error?: RequestError | null; - serverCount: number; + profiles: ProfileSnapshot[]; + selection: StateSnapshot['selection']; + connected: boolean; isGateway: boolean; gatewayDirect: boolean; - validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise; - onImport: () => Promise; - onRefresh: () => Promise; - onForget: () => Promise; + operations: OperationRegistrySnapshot; + error?: ProfileError | null; + onAdd: (label: string, url: string) => Promise; + onRename: (profileId: string, label: string) => Promise; + onRefresh: (profileId: string) => Promise; + onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise; + onActivate: (profileId: string) => Promise; onDismissError: () => void; } -interface SubscriptionValidation { - url: string; - status: 'idle' | 'checking' | 'valid' | 'invalid'; - error: RequestError | null; +const foldLabel = (value: string) => value.trim().toLocaleLowerCase('ru-RU'); + +function operationText(key: OperationKey) { + return ({ + profileAdd: 'Добавляем подписку…', + profileRename: 'Переименовываем подписку…', + profileSelect: 'Сохраняем выбор сервера…', + profileActivate: 'Переключаем подписку…', + profileRefresh: 'Обновляем подписку…', + profileDelete: 'Удаляем подписку…', + serverApply: 'Применяем сервер…', + } as Partial>)[key] || ''; } -function CloudTooltip({ children }: { children: ReactNode }) { - return {children}; +function profileServer(profile: ProfileSnapshot | undefined, serverId: string) { + return profile?.servers.find((server) => server.id === serverId) || null; } export function useSubscriptionFeature({ - subscription, - subscriptionUrl, - setSubscriptionUrl, - operations, - error, - serverCount, + profiles, + selection, + connected, isGateway, gatewayDirect, - validateSubscription, - onImport, + operations, + error, + onAdd, + onRename, onRefresh, onForget, + onActivate, onDismissError, }: SubscriptionFeatureOptions) { - const hasSubscription = subscription?.status === 'ready'; - const [editing, setEditing] = useState(!hasSubscription); - const [contentReady, setContentReady] = useState(hasSubscription); - const [validation, setValidation] = useState({ - url: '', - status: 'idle', - error: null, - }); - const [validationAttempt, setValidationAttempt] = useState(0); - const [refreshing, setRefreshing] = useState(false); - const [usageUpdated, setUsageUpdated] = useState(false); - const [serverRevealVersion, setServerRevealVersion] = useState(0); - const [serversLeaving, setServersLeaving] = useState(false); const [open, setOpen] = useState(false); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const inputRef = useRef(null); - const subscriptionRef = useRef(null); + const [expanded, setExpanded] = useState([]); + const [visited, setVisited] = useState([]); + const [adding, setAdding] = useState(profiles.length === 0); + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(''); + const [renamingId, setRenamingId] = useState(''); + const [renameLabel, setRenameLabel] = useState(''); + const [menuId, setMenuId] = useState(''); + const [deleteId, setDeleteId] = useState(''); + const [refreshingIds, setRefreshingIds] = useState([]); + const [revealVersions, setRevealVersions] = useState>({}); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); - const confirmingDeleteRef = useRef(confirmingDelete); - const previousHasSubscriptionRef = useRef(hasSubscription); - confirmingDeleteRef.current = confirmingDelete; + const labelRef = useRef(null); + const addInvokerRef = useRef(null); + const deleteIdRef = useRef(deleteId); + const previousProfileCountRef = useRef(profiles.length); + deleteIdRef.current = deleteId; - const usage = subscriptionUsage(subscription?.userInfo || undefined); - const [displayedUsed, setDisplayedUsed] = useState(usage.used); - const hasUsage = Boolean( - subscription?.userInfo - && ['upload', 'download', 'total', 'expire'].some((key) => key in subscription.userInfo!), - ); - const normalizedUrl = subscriptionUrl.trim(); - const currentValidation = validation.url === normalizedUrl ? validation : null; - const localError = normalizedUrl && !isSubscriptionUrlValid(normalizedUrl) - ? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message } - : null; - const subscriptionError = currentValidation?.error - || localError - || (error?.context === 'subscription' ? error : null); + const normalizedUrl = url.trim(); + const normalizedLabel = label.trim(); + const duplicateLabel = Boolean(normalizedLabel && profiles.some( + (profile) => foldLabel(profile.label) === foldLabel(normalizedLabel), + )); + const addError = !error?.profileId && error?.context === 'subscription' ? error : null; const validationStatus = !normalizedUrl ? 'idle' - : subscriptionError || !isSubscriptionUrlValid(normalizedUrl) - ? 'invalid' - : currentValidation?.status || 'checking'; - const waiting = hasSubscription && !contentReady; - const importBlocked = operationBlocked(operations, 'subscriptionImport'); - const refreshBlocked = operationBlocked(operations, 'subscriptionRefresh'); - const deleteBlocked = operationBlocked(operations, 'subscriptionDelete'); + : isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid'; + const renameDuplicate = Boolean(renameLabel.trim() && profiles.some((profile) => ( + profile.id !== renamingId && foldLabel(profile.label) === foldLabel(renameLabel) + ))); + const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null; + const deleteStopsVpn = connected && selection.appliedProfileId === deleteId; + const activeOperation = (Object.entries(operations) as Array<[ + OperationKey, + OperationRegistrySnapshot[OperationKey], + ]>).find(([, operation]) => operation?.status === 'running'); useEffect(() => { - if (editing) inputRef.current?.focus(); - }, [editing]); - - useEffect(() => { - if (!normalizedUrl || !isSubscriptionUrlValid(normalizedUrl)) return undefined; - const controller = new AbortController(); - setValidation({ url: normalizedUrl, status: 'checking', error: null }); - const timer = setTimeout(async () => { - try { - await validateSubscription(normalizedUrl, { signal: controller.signal }); - setValidation({ url: normalizedUrl, status: 'valid', error: null }); - } catch (caught) { - const requestError = normalizeRequestError(caught); - if (requestError.name === 'AbortError') return; - setValidation({ - url: normalizedUrl, - status: 'invalid', - error: { - context: 'subscription', - message: requestError.message, - correlationId: requestError.correlationId, - retry: requestError.retryable - ? () => setValidationAttempt((attempt) => attempt + 1) - : null, - }, - }); - } - }, 300); - return () => { - clearTimeout(timer); - controller.abort(); - }; - }, [normalizedUrl, validationAttempt, validateSubscription]); - - useEffect(() => { - const previouslyHadSubscription = previousHasSubscriptionRef.current; - previousHasSubscriptionRef.current = hasSubscription; - - if (!hasSubscription) { - setContentReady(false); - return undefined; + const ids = new Set(profiles.map((profile) => profile.id)); + setExpanded((current) => current.filter((id) => ids.has(id))); + setVisited((current) => current.filter((id) => ids.has(id))); + if (profiles.length === 0) { + setAdding(true); + if (previousProfileCountRef.current > 0) setOpen(false); } - if (previouslyHadSubscription) { - setContentReady(true); - return undefined; + if (previousProfileCountRef.current === 0 && profiles.length > 0) { + const first = profiles[0].id; + setOpen(true); + setAdding(false); + setExpanded((current) => current.includes(first) ? current : [...current, first]); + setVisited((current) => current.includes(first) ? current : [...current, first]); } - if (matchMedia('(prefers-reduced-motion: reduce)').matches) { - setContentReady(true); - return undefined; - } - - const timer = setTimeout(() => setContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS); - return () => clearTimeout(timer); - }, [hasSubscription]); + previousProfileCountRef.current = profiles.length; + }, [profiles, isGateway]); useEffect(() => { - if (!hasSubscription) setEditing(true); - }, [hasSubscription]); - - useEffect(() => { - if (!editing || !hasSubscription || subscriptionUrl) return undefined; - const timer = setTimeout(() => setEditing(false), 5000); - return () => clearTimeout(timer); - }, [editing, hasSubscription, subscriptionUrl]); - - useEffect(() => { - if (!editing || !hasSubscription) return undefined; - const closeOnOutsideClick = (event: PointerEvent) => { - if (subscriptionRef.current?.contains(event.target as Node | null)) return; - setSubscriptionUrl(''); - setEditing(false); - }; - document.addEventListener('pointerdown', closeOnOutsideClick); - return () => document.removeEventListener('pointerdown', closeOnOutsideClick); - }, [editing, hasSubscription, setSubscriptionUrl]); - - useEffect(() => { - if (!hasSubscription) return undefined; - onRefresh(); - return undefined; - }, [hasSubscription]); - - useEffect(() => { - const from = displayedUsed; - const to = usage.used; - if (from === to) return undefined; - const startedAt = performance.now(); - let frame: number; - const tick = (now: number) => { - const progress = Math.min(1, (now - startedAt) / 900); - const eased = 1 - Math.pow(1 - progress, 4); - setDisplayedUsed(from + (to - from) * eased); - if (progress < 1) frame = requestAnimationFrame(tick); - }; - frame = requestAnimationFrame(tick); + if (!adding || (profiles.length > 0 && !open)) return undefined; + const frame = requestAnimationFrame(() => labelRef.current?.focus()); return () => cancelAnimationFrame(frame); - }, [usage.used]); + }, [adding, open, profiles.length, isGateway]); useEffect(() => { if (!open) return undefined; const frame = requestAnimationFrame(() => closeRef.current?.focus()); - const closeSubscription = (event: PointerEvent | KeyboardEvent) => { - if (confirmingDeleteRef.current) return; + const closeDrawer = (event: PointerEvent | KeyboardEvent) => { + if (deleteIdRef.current) return; if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return; const target = event.target as Node | null; if (event.type !== 'keydown' && ( panelRef.current?.contains(target) || toggleRef.current?.contains(target) )) return; setOpen(false); + setMenuId(''); }; - document.addEventListener('pointerdown', closeSubscription); - document.addEventListener('keydown', closeSubscription); + document.addEventListener('pointerdown', closeDrawer); + document.addEventListener('keydown', closeDrawer); return () => { cancelAnimationFrame(frame); - document.removeEventListener('pointerdown', closeSubscription); - document.removeEventListener('keydown', closeSubscription); + document.removeEventListener('pointerdown', closeDrawer); + document.removeEventListener('keydown', closeDrawer); requestAnimationFrame(() => { if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus(); }); }; }, [open]); - async function submit() { - if (validationStatus !== 'valid') return; - if (!await onImport()) return; - setSubscriptionUrl(''); - setEditing(false); + function resetAdd() { + setLabel(''); + setUrl(''); + if (profiles.length) setAdding(false); + const invoker = addInvokerRef.current; + addInvokerRef.current = null; + if (invoker) requestAnimationFrame(() => invoker.focus()); } - async function refresh() { + async function addProfile() { + if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return; + if (!await onAdd(normalizedLabel, normalizedUrl)) return; + resetAdd(); + } + + function startRename(profile: ProfileSnapshot) { + setMenuId(''); + setRenamingId(profile.id); + setRenameLabel(profile.label); + onDismissError(); + } + + function cancelRename() { + const profileId = renamingId; + setRenamingId(''); + requestAnimationFrame(() => document.getElementById(`client-profile-menu-${profileId}`)?.focus()); + } + + async function saveRename() { + const next = renameLabel.trim(); + if (!next || renameDuplicate) return; + if (!await onRename(renamingId, next)) return; + cancelRename(); + } + + async function refresh(profileId: string) { const startedAt = performance.now(); - setRefreshing(true); + setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]); try { - if (!await onRefresh()) return; - setUsageUpdated(false); - requestAnimationFrame(() => setUsageUpdated(true)); - setTimeout(() => setUsageUpdated(false), 900); - setServersLeaving(true); - await new Promise((resolve) => setTimeout( - resolve, - 420 + Math.min(7, Math.max(0, serverCount - 1)) * 90, - )); - setServerRevealVersion((version) => version + 1); - setServersLeaving(false); + if (!await onRefresh(profileId)) return; + setRevealVersions((current) => ({ + ...current, + [profileId]: (current[profileId] || 0) + 1, + })); } finally { const elapsed = performance.now() - startedAt; - const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900); - await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed)); - setRefreshing(false); + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed))); + setRefreshingIds((current) => current.filter((id) => id !== profileId)); } } - async function forget() { - if (!await onForget()) return; - setConfirmingDelete(false); + async function confirmDelete() { + if (!deleteProfile) return; + if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return; + setDeleteId(''); + setMenuId(''); } - function changeUrl(value: string) { - if (error?.context === 'subscription') onDismissError(); - setValidation({ url: '', status: 'idle', error: null }); - setSubscriptionUrl(value); - } - - function cancelEditing() { - if (!hasSubscription) return; - setSubscriptionUrl(''); - setEditing(false); + function toggleProfile(profileId: string) { + setVisited((current) => current.includes(profileId) ? current : [...current, profileId]); + setExpanded((current) => current.includes(profileId) + ? current.filter((id) => id !== profileId) + : [...current, profileId]); } return { - subscription, - subscriptionUrl, - hasSubscription, - hasUsage, + profiles, + selection, + connected, isGateway, gatewayDirect, - editing, - contentReady, - waiting, + operations, + error, open, - confirmingDelete, - refreshing, - usageUpdated, - usage, - displayedUsed, + expanded, + visited, + adding, + label, + url, validationStatus, - normalizedUrl, - error: subscriptionError, - importBlocked, - refreshBlocked, - deleteBlocked, - serversLeaving, - serverRevealVersion, - inputRef, - subscriptionRef, + duplicateLabel, + addError, + renamingId, + renameLabel, + renameDuplicate, + menuId, + deleteProfile, + deleteStopsVpn, + activeOperation, + refreshingIds, + revealVersions, panelRef, toggleRef, closeRef, + labelRef, + addBlocked: operationBlocked(operations, 'profileAdd'), + renameBlocked: operationBlocked(operations, 'profileRename'), + refreshBlocked: operationBlocked(operations, 'profileRefresh'), + deleteBlocked: operationBlocked(operations, 'profileDelete'), + activateBlocked: operationBlocked(operations, 'profileActivate'), toggle: () => setOpen((current) => !current), - close: () => setOpen(false), - edit: () => setEditing(true), - changeUrl, - cancelEditing, - submit, + close: () => { setOpen(false); setMenuId(''); }, + showAdd: () => { + addInvokerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setAdding(true); + setMenuId(''); + onDismissError(); + }, + cancelAdd: resetAdd, + setLabel: (value: string) => { setLabel(value); onDismissError(); }, + setUrl: (value: string) => { + setUrl(value); + onDismissError(); + }, + addProfile, + toggleProfile, + startRename, + setRenameLabel, + cancelRename, + saveRename, + toggleMenu: (profileId: string) => setMenuId((current) => current === profileId ? '' : profileId), + requestDelete: (profileId: string) => setDeleteId(profileId), + cancelDelete: () => setDeleteId(''), + confirmDelete, refresh, - requestDelete: () => setConfirmingDelete(true), - cancelDelete: () => setConfirmingDelete(false), - forget, + onActivate, }; } type SubscriptionFeatureController = ReturnType; -interface SubscriptionToggleProps { +export function SubscriptionToggle({ + feature, + onToggle, +}: { feature: SubscriptionFeatureController; onToggle: () => void; -} - -export function SubscriptionToggle({ feature, onToggle }: SubscriptionToggleProps) { +}) { return ; } -interface SubscriptionPanelProps { - feature: SubscriptionFeatureController; - statusSlot?: ReactNode; - serverSlot?: ReactNode; +function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) { + const message = feature.duplicateLabel + ? 'Такое имя уже используется.' + : feature.validationStatus === 'invalid' + ? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message + : feature.addError?.message || ''; + return
{ + event.preventDefault(); + feature.addProfile(); + }} + > + + +
+ {message || '\u00a0'} +
+
+ {feature.profiles.length > 0 && } + +
+
; } -export function SubscriptionPanel({ feature, statusSlot, serverSlot }: SubscriptionPanelProps) { - const { - subscription, - subscriptionUrl, - hasSubscription, - hasUsage, - isGateway, - gatewayDirect, - editing, - contentReady, - waiting, - open, - refreshing, - usageUpdated, - usage, - displayedUsed, - validationStatus, - normalizedUrl, - error, - importBlocked, - refreshBlocked, - deleteBlocked, - inputRef, - subscriptionRef, - panelRef, - closeRef, - } = feature; +function ProfileUsage({ profile }: { profile: ProfileSnapshot }) { + const usage = subscriptionUsage(profile.subscription.userInfo); + const expires = usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) + ? subscriptionDaysLeft(usage.expiresAt) + : ''; + if (!usage.used && !usage.total && !expires) return null; + return + {formatBytes(usage.used)}{usage.total ? ` / ${formatBytes(usage.total)}` : ''}{expires ? ` · ${expires}` : ''} + ; +} - return
- {isGateway && } -
-
ReactNode; +}) { + const expanded = feature.expanded.includes(profile.id); + const visited = feature.visited.includes(profile.id); + const applied = feature.connected + && !feature.gatewayDirect + && feature.selection.appliedProfileId === profile.id; + const desired = !feature.connected + && !feature.gatewayDirect + && feature.selection.desiredProfileId === profile.id; + const profileError = feature.error?.profileId === profile.id ? feature.error : null; + const refreshing = feature.refreshingIds.includes(profile.id) + || feature.operations.profileRefresh?.target === profile.id; + const controlsBlocked = operationBlocked(feature.operations, 'profileSelect'); + const selectedServer = profileServer(profile, profile.desiredServerId); + return
+
+