Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 . <backup-file>`.
|
||||
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 . <backup-file>`.
|
||||
3. Restore only matching state/cache backups to their original filenames.
|
||||
4. Start Harbor and verify `GET /api/state` before applying a profile.
|
||||
|
||||
A pre-v5 binary cannot interpret canonical profiles. Downgrade therefore requires both pre-migration backups; deploying old code alone is not a safe rollback.
|
||||
|
||||
Reference in New Issue
Block a user