Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf3f22b12 | ||
|
|
4d066cb879 | ||
|
|
f4882c53c2 | ||
|
|
116686a138 | ||
|
|
7e15cc199f | ||
|
|
f977874da6 | ||
|
|
14b3c2afac | ||
|
|
a84cca0668 | ||
|
|
8f2f418569 | ||
|
|
4a566e082a | ||
|
|
f451c65b2f | ||
|
|
64462d3639 | ||
|
|
12f2f30212 | ||
|
|
1ee453b3b6 | ||
|
|
c27c898ad5 | ||
|
|
74227ae38c | ||
|
|
9055934e92 | ||
|
|
3b515ee355 | ||
|
|
cedd31cc16 | ||
|
|
d4f228284e | ||
|
|
b843970ec2 | ||
|
|
daec12e013 | ||
|
|
416b2b294a | ||
|
|
df865fbe3d | ||
|
|
7cb25d0633 | ||
|
|
dc1fd76c44 | ||
|
|
0a0a932057 | ||
|
|
286a89051a | ||
|
|
f3be0b2fd0 | ||
|
|
bc86741397 | ||
|
|
7c255192b0 | ||
|
|
0b39211fbd | ||
|
|
8eccdd4050 | ||
|
|
804e08727e | ||
|
|
978fe71628 | ||
|
|
5a21a09b82 | ||
|
|
a9eca0e9d4 | ||
|
|
32217f4d17 | ||
|
|
019930924d | ||
|
|
0ea2f9d548 | ||
|
|
0290784526 | ||
|
|
b86812d02b | ||
|
|
08cc013def | ||
|
|
72c085a5b8 | ||
|
|
9d43e74d97 | ||
|
|
9e52ccc24d | ||
|
|
068a7f9890 | ||
|
|
6381760b27 | ||
|
|
3566f4bc0b | ||
|
|
501c498edf | ||
|
|
7e4da4bdcf | ||
|
|
17849ffd73 | ||
|
|
021cdb28d0 | ||
|
|
ded7b740dc | ||
|
|
79ffff194f | ||
|
|
396c5d1917 | ||
|
|
444f26c401 | ||
|
|
9ad0307333 | ||
|
|
9a8191dc91 | ||
|
|
5cad3e9061 | ||
|
|
af1c45e424 | ||
|
|
9432112fe2 | ||
|
|
aa9c959368 | ||
|
|
c89e56942a | ||
|
|
f233660dc3 | ||
|
|
ec68ba6a7b | ||
|
|
3a4173db43 | ||
|
|
d349ca5e29 | ||
|
|
3204ecf4a5 | ||
|
|
d612e227fa | ||
|
|
03b2ed5fb0 | ||
|
|
5da9686c27 | ||
|
|
f40221969b | ||
|
|
90433d7cd8 | ||
|
|
71ede44be0 | ||
|
|
f04b1752f4 |
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: design-harbor-device-ecosystem
|
||||
description: Design, plan, implement, review, or document Harbor Gateway integrations with current and future companion devices. Use for LAN advertisement and discovery, Connect-Gateway pairing, trusted device identity, connected-client presence, ecosystem membership in the Gateway device list, multi-Gateway selection, capability or protocol negotiation, and related API, persistence, migration, or UI contracts. Do not use for traffic-only LAN inventory or visual polish that does not change ecosystem behavior.
|
||||
---
|
||||
|
||||
# Design Harbor Device Ecosystem
|
||||
|
||||
Keep discovery, trust, application presence, routing, and LAN observation separate. A device is not trusted because mDNS or the neighbor table reports it, and a paired device is not necessarily connected or routed through this Gateway.
|
||||
|
||||
## Scope and non-goals
|
||||
|
||||
- Apply this skill to Gateway integration with Harbor Connect on macOS and future clients, extensions, appliances, or companion services.
|
||||
- Keep one platform-neutral protocol core. Isolate native DNS-SD and private-key storage behind thin platform adapters, and state whether a new target is desktop, headless, mobile, or browser-bound before choosing an adapter.
|
||||
- Preserve the existing MAC-based inventory as an observational traffic and policy surface; do not turn it into ecosystem identity.
|
||||
- Do not introduce a generic event bus, mesh, cloud account, fleet controller, or WAN discovery without a concrete requirement.
|
||||
- Do not define a new visible pairing flow, badge, layout, wording, or management surface without the exact owner decision required by `workpack/DESIGN_OWNER_POLICY.md`.
|
||||
- Do not deploy, SSH, pair live devices, or operate a live Gateway unless the current user request explicitly authorizes that exact operation.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read `PRODUCT.md`, `workpack/PRODUCT_PRIORITIES.md`, `workpack/DATA_CONSISTENCY_MODEL.md`, `workpack/DESIGN_OWNER_POLICY.md`, `workpack/STATUS.md`, and the selected workpack task.
|
||||
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
||||
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
||||
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
||||
5. Reuse standard DNS-SD/mDNS for local discovery and the existing request/response control-plane shape for the first protocol slice. Do not treat the current plaintext HTTP transport as sufficient protection for invitations or credentials; require pinned end-to-end identity and confidentiality. Add push transport only when a concrete feature cannot work with bounded polling or heartbeats.
|
||||
6. Keep persisted relationship mutations schema-versioned, atomic, revision-safe, and reversible. Keep transient session loss from deleting paired identity.
|
||||
7. Join ecosystem membership to network inventory in the backend and expose one revisioned projection. Never join independent authoritative lists in React. Also use `design-vpn-client-ui` for any visible Connect UI work.
|
||||
8. For runtime, API, dependency, UI, or deployment changes, also use `manage-harbor-versions` and run its affected-component gate. Skill-only and documentation-only changes need no Harbor version bump.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Advertise Gateway availability through a versioned DNS-SD service on the local link. Treat every advertisement and resolved address as an untrusted candidate until paired identity is cryptographically verified.
|
||||
- Put no subscription URL, pairing secret, device list, credential, or private identity material in mDNS/DNS-SD records, logs, fixtures, screenshots, or user-visible diagnostics.
|
||||
- Pair explicitly. Use a short-lived, single-use invitation; bind the stable Gateway identity and client installation identity; persist each side atomically only after confirmation; make cross-device finalization idempotent and recoverable; support revoke and credential rotation.
|
||||
- Use a high-entropy QR/manual invitation, or a vetted PAKE plus mutual confirmation for a short human code. Never send a low-entropy code as a bearer secret over unauthenticated HTTP.
|
||||
- Give every Harbor installation a stable cryptographic device identity. Keep it independent of IP, hostname, interface, and randomized MAC.
|
||||
- Define states precisely: `discovered` means advertised; `paired` means a persisted trust relationship; `connected` means a current authenticated lease; `routed` means this Gateway is the active route. Never collapse them into one `online` boolean.
|
||||
- Derive lease freshness from Gateway receipt time. Do not trust a client-supplied clock, IP address, or MAC address as authoritative.
|
||||
- Correlate a paired client with the existing neighbor inventory only as a derived, confidence-bearing binding. A missing or ambiguous binding must not erase membership or fabricate traffic attribution.
|
||||
- Allow a client to pair with multiple Gateways, but keep one explicit active routing target. A discovered or unpaired Gateway must never hijack route selection.
|
||||
- Preserve legacy subscription-based presence only as a bounded transition path. Never silently convert matching subscription URLs into permanent pairing records.
|
||||
- Keep capability advertisement versioned and additive. Ignore unknown capabilities; block incompatible protocol versions explicitly. Never authorize a mutation from self-declared capabilities alone.
|
||||
- Keep the Gateway backend as the source of truth for its paired-device registry and active leases. Preserve revision-safe frontend application and transport freshness separation.
|
||||
|
||||
## Acceptance pass
|
||||
|
||||
Before handing off an ecosystem change, verify:
|
||||
|
||||
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
||||
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
||||
- Pairing works without a subscription URL; invitations expire, are single-use, and reject replay.
|
||||
- The Gateway can distinguish an ordinary LAN neighbor, a paired but disconnected device, a connected Harbor client, and a client currently routed through it.
|
||||
- A paired Mac remains the same ecosystem device across DHCP, interface, or randomized-MAC changes; only its derived inventory binding changes.
|
||||
- Discovery loss does not unpair a device; heartbeat loss expires the lease; revocation rejects the next authenticated request immediately.
|
||||
- Multiple Gateways and multiple clients do not collide. Any aggregate network identity includes Gateway scope.
|
||||
- Persisted writes are atomic and migrated with rollback; delayed snapshots cannot overwrite newer state.
|
||||
- Legacy installs retain the documented transition behavior without exposing or copying subscription secrets.
|
||||
- Automated tests cover discovery deduplication, pairing expiry/replay, authentication, lease expiry, revocation, binding ambiguity, revision ordering, and migration.
|
||||
- Any visible UI outcome has a separately recorded owner decision and preserves loading, empty, stale, error, keyboard, reduced-motion, and responsive behavior.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report:
|
||||
|
||||
- current owner and traced data flow;
|
||||
- chosen source of truth for identity, trust, presence, route, and inventory binding;
|
||||
- protocol and persistence changes, including version negotiation;
|
||||
- migration, compatibility, rollback, and security boundaries;
|
||||
- exact automated checks and what remains unverified;
|
||||
- unresolved owner decisions, especially visible UI and short-code pairing treatment.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Design Harbor Device Ecosystem"
|
||||
short_description: "Design trusted Harbor device integration."
|
||||
default_prompt: "Use $design-harbor-device-ecosystem to design Harbor Gateway discovery, pairing, and connected-device contracts."
|
||||
@@ -0,0 +1,159 @@
|
||||
# Harbor device ecosystem contract
|
||||
|
||||
Use this reference for Gateway advertisement, discovery, pairing, connected-device lists, or integration with future Harbor-capable subsystems.
|
||||
|
||||
## Layer ownership
|
||||
|
||||
| Layer | Source of truth | Identity | Lifetime | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| Discovery | DNS-SD browser cache | advertised Gateway instance | ephemeral | a compatible service may be reachable |
|
||||
| Gateway trust | client pairing store | `gatewayId` plus pinned public identity | persisted | this is a Gateway the owner paired |
|
||||
| Peer registry | Gateway pairing store | stable client installation ID plus public identity | persisted | this client belongs to the Harbor ecosystem |
|
||||
| Presence | Gateway session owner | authenticated client ID plus lease | ephemeral | this paired client is currently connected to the control plane |
|
||||
| Route | Connect route owner | selected `gatewayId` plus applied route state | runtime/canonical | this client currently intends to use this Gateway |
|
||||
| Network inventory | existing device inventory | Gateway-scoped MAC-derived `dev_*` ID | observed/persisted | this network endpoint was seen and may own traffic or policy |
|
||||
| Binding | Gateway backend projection | ecosystem client ID -> network inventory ID | derived | current correlation, never trust identity |
|
||||
|
||||
Do not reuse the current MAC-derived `dev_*` ID as an ecosystem ID. For a future fleet view, scope network inventory IDs by `gatewayId`.
|
||||
|
||||
## Minimal discovery profile
|
||||
|
||||
Advertise one TCP DNS-SD service such as `_harbor-gw._tcp.local.`. Use the service instance name as a short, user-friendly Gateway name; do not encode a MAC address or serial number in it.
|
||||
|
||||
Use SRV/A/AAAA for endpoint resolution. Keep TXT metadata small and additive:
|
||||
|
||||
- `txtvers=1` for the TXT schema;
|
||||
- `protovers=1` for the application protocol;
|
||||
- public `id=<gatewayId>` for candidate deduplication;
|
||||
- compact capability flags and whether a pairing window is open.
|
||||
|
||||
Never advertise credentials, pairing invitations, subscription data, client names, client counts, or private network policy. Unknown TXT keys must be ignored. Resolve addresses immediately before connection and verify the paired identity after connecting.
|
||||
|
||||
mDNS is link-local. For another VLAN or routed segment, use an explicitly configured address, QR/manual endpoint, or unicast DNS-SD. Do not invent a custom UDP broadcast or assume multicast crosses routers.
|
||||
|
||||
In the current Gateway deployment, first inspect the Compose network boundary: the control service is bridge-networked while the dataplane owns host networking. Keep identity and trust in control, but publish the public DNS-SD descriptor through host Avahi or a narrow host-network publisher. Do not move the whole control plane to host networking merely to gain multicast. On macOS, prefer the native Bonjour/`dns-sd` surface through the existing host-side network monitor over a second custom discovery stack.
|
||||
|
||||
Discovery answers only “where might a Gateway be?” It does not answer “do I trust it?”, “am I connected?”, or “is my traffic routed through it?”.
|
||||
|
||||
## Identity and pairing
|
||||
|
||||
Maintain a stable Gateway identity and a stable key pair in persisted, backup-aware storage. Maintain a stable installation identity and key pair per client; store the client private key in the platform credential store, including macOS Keychain.
|
||||
|
||||
Do not reuse the current subscription `hwid` as public ecosystem identity: it is also sent to the subscription provider as `x-hwid`. Create a separate local identity so LAN and provider correlation remain independent.
|
||||
|
||||
Model the minimum persisted relationship records:
|
||||
|
||||
```text
|
||||
PairedGateway = gatewayId, displayName, pinnedPublicIdentity,
|
||||
capabilities, pairedAt
|
||||
|
||||
PairedDevice = deviceId, displayName, deviceType, publicIdentity,
|
||||
capabilities, pairedAt, revokedAt?
|
||||
```
|
||||
|
||||
Treat display names, platform, version, and capabilities as metadata, not authentication or authorization.
|
||||
|
||||
Use this pairing sequence:
|
||||
|
||||
1. Let the Gateway open a bounded pairing window and create a short-lived, single-use invitation.
|
||||
2. Bind the invitation to the Gateway ID and public-identity fingerprint.
|
||||
3. Let the client create or load its installation key and submit its public identity and minimal metadata.
|
||||
4. Confirm the peer on a trusted surface before committing both records.
|
||||
5. Issue a per-device credential or register proof-of-possession for later authenticated requests.
|
||||
6. Consume the invitation atomically; reject expiry, replay, mismatch, or reuse.
|
||||
7. Support revoke and credential rotation without changing unrelated LAN inventory.
|
||||
|
||||
Persist each local trust record atomically and make finalization idempotent so an interrupted client can resume or safely retry. Pairing spans two devices and is not a distributed transaction; never report it as cross-device atomicity.
|
||||
|
||||
For the smallest safe first slice, encode the same grouped high-entropy invitation in QR and manual copy/paste form. If the owner requires a short numeric code, use a vetted password-authenticated key exchange such as SPAKE2 and mutual key confirmation, or require an independent confirmation that provides equivalent protection. Use platform or Node standard cryptography for identity and signatures; do not design custom cryptography or add a crypto dependency without need.
|
||||
|
||||
Do not use the subscription URL as a pairing credential. Keep the existing subscription-HMAC presence only as an explicitly labelled legacy trust source during the transition window.
|
||||
|
||||
## Authenticated presence and route state
|
||||
|
||||
After pairing, let the client renew a bounded authenticated lease. Start with periodic requests using the existing control-plane API shape, but protect invitations and credentials with pinned end-to-end identity and confidentiality; the current plaintext HTTP transport alone is not sufficient. Do not add WebSocket, broker, or event-stream infrastructure until a concrete server-push feature requires it.
|
||||
|
||||
The Gateway derives:
|
||||
|
||||
```text
|
||||
paired = persisted relationship exists and is not revoked
|
||||
connected = paired and authenticated lease has not expired
|
||||
routed = connected and current Connect route state selects this gatewayId
|
||||
inventory = optional current network binding exists
|
||||
```
|
||||
|
||||
Store or project `lastAuthenticatedAt` and `leaseExpiresAt`; derive status rather than persisting a mutable `online` boolean. Use Gateway receipt time for freshness. Let the client report `deviceType`, app/protocol version, capabilities, and selected route as authenticated metadata, but distinguish a reported route from dataplane-confirmed traffic.
|
||||
|
||||
Discovery failure marks the candidate stale. It does not revoke pairing or immediately terminate a still-valid lease. Lease expiry marks the client disconnected without deleting its relationship. Revocation invalidates the credential immediately.
|
||||
|
||||
## Joining a Mac to the Gateway device view
|
||||
|
||||
When a paired Mac renews its lease:
|
||||
|
||||
1. Authenticate its stable ecosystem identity.
|
||||
2. Derive the remote source address from the accepted connection; do not trust a claimed IP.
|
||||
3. Correlate that address with the current neighbor snapshot to obtain an optional MAC-derived inventory ID.
|
||||
4. Publish the relationship, presence, route, and binding together from the Gateway backend.
|
||||
|
||||
Represent the binding with `networkDeviceId`, `confidence`, and `observedAt`. A randomized MAC, DHCP change, or interface switch updates or removes only this binding. The stable Mac relationship remains.
|
||||
|
||||
If the source address is NATed, missing, duplicated, or maps to an ambiguous neighbor, keep the Mac paired/connected but leave traffic and policy attribution unavailable. Never attach another device's counters by hostname or client-provided MAC.
|
||||
|
||||
Expose one revisioned Gateway projection to the UI. It may extend the current device snapshot or use a dedicated ecosystem snapshot, but the backend must perform the join. React must not fetch authoritative pairing, presence, and inventory lists independently and guess the relationship.
|
||||
|
||||
## Multi-Gateway behavior
|
||||
|
||||
- Let Connect persist several `PairedGateway` records keyed by stable Gateway ID.
|
||||
- Let discovery resolve zero or more current endpoints for each identity.
|
||||
- Keep one active route target and make selection or automatic policy explicit.
|
||||
- Require a paired identity match before automatic handoff. A new advertisement is never enough.
|
||||
- Preserve a verified active Gateway through transient discovery failure, but surface stale freshness.
|
||||
- Let one Gateway register many clients without assuming macOS; branch on negotiated capabilities, not hard-coded platform paths.
|
||||
- Do not add fleet federation. If it becomes real, aggregate with compound `{gatewayId, deviceId}` identities.
|
||||
|
||||
## Canonical data and failure rules
|
||||
|
||||
- Give the pairing registry its own `schemaVersion` and monotonic `revision`, or include it in an existing canonical aggregate with equivalent guarantees.
|
||||
- Reuse the existing atomic JSON-store pattern before considering a database or another persistence dependency.
|
||||
- Make pair, rename, revoke, and credential rotation atomic. Return the complete new snapshot after a mutation.
|
||||
- Keep active leases process-local unless restart continuity has a demonstrated need. Persist last-seen metadata at a bounded cadence if required; never write every heartbeat by default.
|
||||
- Apply incoming frontend snapshots only when their revision is not older than the current one. Keep transport stale/error state outside the domain snapshot.
|
||||
- Preserve last-good discovery and registry data on source errors; expose freshness and the error separately.
|
||||
- Treat public-key mismatch as an identity error requiring explicit repair or re-pairing, not an address update.
|
||||
- Treat an unsupported protocol version as an incompatible state, not a generic offline state.
|
||||
|
||||
## Compatibility sequence
|
||||
|
||||
1. Add the new identity, discovery, pairing, and presence contract without changing current route behavior.
|
||||
2. Prefer evolving the existing Gateway presence protocol to a version that signs challenges with ecosystem identity instead of creating a second overlapping presence subsystem.
|
||||
3. Keep the subscription-HMAC default-gateway proof as a labelled `legacy-default-route` source for one documented transition release.
|
||||
4. Publish the stable `gatewayId` in canonical route state; keep address, UI origin, and freshness as changeable observations.
|
||||
5. Prefer a paired Gateway when its verified identity matches the current route candidate. Preserve the approved sticky verified-Gateway behavior through transient discovery failure.
|
||||
6. Never auto-mint a pairing record from a matching subscription URL.
|
||||
7. When forgetting the active Gateway, demote route state atomically before deleting trust.
|
||||
8. Preserve existing `dev_*` inventory IDs, aliases, traffic totals, and policies; add only a derived ecosystem binding.
|
||||
9. Remove the legacy proof only after migration evidence and an explicit cutover task.
|
||||
|
||||
## Automated evidence
|
||||
|
||||
Cover at least:
|
||||
|
||||
- zero, one, and multiple advertisements; deduplication and endpoint changes;
|
||||
- mDNS loss and recovery without unpairing;
|
||||
- no secrets in TXT metadata or logs;
|
||||
- invitation expiry, replay, mismatch, and atomic single-use consumption;
|
||||
- valid/invalid proof-of-possession, credential rotation, and revocation;
|
||||
- lease renewal and expiry using Gateway receipt time;
|
||||
- paired/connected/routed state separation;
|
||||
- DHCP, interface, and randomized-MAC changes;
|
||||
- missing and ambiguous inventory binding without false traffic attribution;
|
||||
- multiple Gateways with one active route;
|
||||
- revision ordering, migration, rollback, and legacy transition.
|
||||
|
||||
## Standards basis
|
||||
|
||||
- [RFC 6762: Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762)
|
||||
- [RFC 6763: DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763)
|
||||
- [RFC 9382: SPAKE2](https://www.rfc-editor.org/rfc/rfc9382)
|
||||
|
||||
These standards define discovery and an available PAKE building block. They do not make an mDNS advertisement trustworthy; preserve end-to-end identity verification.
|
||||
@@ -18,6 +18,15 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
|
||||
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||
8. Run `npm test`, `npm run build`, and `git diff --check`. Perform manual visual inspection only when explicitly requested.
|
||||
|
||||
## Communicating a proposed change
|
||||
|
||||
For implementation proposals and progress summaries, combine structure instead of maximizing it:
|
||||
|
||||
- start with one compact table grouped by affected modules such as frontend, UI, backend/API, styles, tests, and versions;
|
||||
- use short lists only for cross-cutting details such as states, accessibility, motion, or changed files;
|
||||
- keep unaffected modules visible in the table when that clarifies scope;
|
||||
- avoid both an unstructured paragraph and a separate table for every subsection.
|
||||
|
||||
## Non-negotiable decisions
|
||||
|
||||
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
|
||||
|
||||
@@ -20,6 +20,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
|
||||
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
|
||||
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
|
||||
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
|
||||
- Give repeated row actions one fixed-width trailing slot aligned to the same edge. Reserve that slot when labels wrap or statuses change; never place the action at the end of intrinsic label text.
|
||||
- Preserve a generous invisible hit area around icon-only controls.
|
||||
- Center proxy address and protocol actions with the power column.
|
||||
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
|
||||
@@ -60,3 +61,4 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
|
||||
- Show expiry as both date and remaining days, with correct Russian forms.
|
||||
- If there is no total, say `без лимита` and omit the progress bar.
|
||||
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.
|
||||
- Keep `not tested`, `running`, `success`, and `failed` as separate row states. A result from one row must not turn untouched sibling rows into failures.
|
||||
|
||||
+4
-1
@@ -4,16 +4,19 @@ CLIENT_UI_PORT=3456
|
||||
CLIENT_PROXY_PORT=8082
|
||||
HARBOR_GATEWAY_CONTROL_PORT=3456
|
||||
BASE_IMAGE=debian:bookworm-slim
|
||||
SINGBOX_VERSION=1.12.13
|
||||
SINGBOX_VERSION=1.14.0-rc.5
|
||||
INSTALL_RUNTIME_DEPS=true
|
||||
INSTALL_SINGBOX=true
|
||||
PROXY_PORT=8080
|
||||
PROXY_BIND_IP=0.0.0.0
|
||||
SING_BOX_API_PORT=19090
|
||||
SING_BOX_TRAFFIC_SOURCE=snapshot
|
||||
TPROXY_PORT=7895
|
||||
TPROXY_MARK=1
|
||||
TPROXY_TABLE=100
|
||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||
DIRECT_TRAFFIC_CHAIN=VPN_PROXY_DIRECT
|
||||
DIRECT_TRAFFIC_MARK=0x40000000
|
||||
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||
|
||||
@@ -4,15 +4,21 @@ on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
hard_deploy:
|
||||
description: Always rebuild and deploy both Gateway images
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
DEPLOY_PATH: /opt/vpn-proxy
|
||||
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||
NODE_BUILD_IMAGE: node:20.19-alpine
|
||||
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:20.19-bookworm
|
||||
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||
SINGBOX_VERSION: 1.12.13
|
||||
SINGBOX_VERSION: 1.14.0-rc.5
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -34,6 +40,8 @@ jobs:
|
||||
|
||||
- name: Build and push gateway image
|
||||
id: gateway-build
|
||||
env:
|
||||
HARD_DEPLOY_INPUT: ${{ inputs.hard_deploy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo
|
||||
@@ -44,6 +52,11 @@ jobs:
|
||||
DATAPLANE_IMAGE="${IMAGE}-dataplane"
|
||||
|
||||
EVENT_NAME="${{ gitea.event_name }}"
|
||||
case "${EVENT_NAME}:${HARD_DEPLOY_INPUT}" in
|
||||
workflow_dispatch:true) HARD_DEPLOY=true ;;
|
||||
workflow_dispatch:false|workflow_dispatch:|push:false|push:) HARD_DEPLOY=false ;;
|
||||
*) echo "Invalid hard deploy request: ${EVENT_NAME}:${HARD_DEPLOY_INPUT}" >&2; exit 1 ;;
|
||||
esac
|
||||
BEFORE_SHA="${{ gitea.event.before }}"
|
||||
ZERO_SHA="0000000000000000000000000000000000000000"
|
||||
if [ "$EVENT_NAME" = "push" ] \
|
||||
@@ -76,6 +89,13 @@ jobs:
|
||||
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||
*) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;;
|
||||
esac
|
||||
DOCKER_BUILD_OPTIONS=()
|
||||
if [ "$HARD_DEPLOY" = "true" ]; then
|
||||
echo "Hard deploy requested: forcing no-cache rebuild and deploy of both Gateway images."
|
||||
AFFECTED_COMPONENTS="control+dataplane"
|
||||
RESTART_SCOPE="both"
|
||||
DOCKER_BUILD_OPTIONS=(--no-cache)
|
||||
fi
|
||||
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||
echo "Restart scope: ${RESTART_SCOPE}"
|
||||
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||
@@ -87,9 +107,8 @@ jobs:
|
||||
npm test
|
||||
npm run build:production
|
||||
else
|
||||
if ! docker image inspect "${{ env.NODE_BUILD_IMAGE }}" >/dev/null 2>&1 \
|
||||
|| ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||
echo "Cannot validate change: host npm and the existing Node 20.19 build image are unavailable." >&2
|
||||
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
||||
echo "Cannot validate change: host npm and the Node 20.19 build toolchain are unavailable." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||
@@ -98,7 +117,16 @@ jobs:
|
||||
-v "$PWD:/work" \
|
||||
-w /work \
|
||||
"${{ env.NODE_BUILD_IMAGE }}" \
|
||||
sh -lc 'npm ci --no-audit --no-fund && npm run typecheck && npm run check:boundaries && npm test && npm run build:production'
|
||||
sh -lc '
|
||||
npm ci --no-audit --no-fund
|
||||
npm_status=$?
|
||||
if [ "$npm_status" -ne 0 ] || [ ! -x node_modules/.bin/tsc ]; then
|
||||
echo "npm ci failed to install the validation toolchain." >&2
|
||||
tail -n 200 /root/.npm/_logs/*-debug-0.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
npm run typecheck && npm run check:boundaries && npm test && npm run build:production
|
||||
'
|
||||
fi
|
||||
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||
echo "Image build and push skipped: no Gateway runtime impact."
|
||||
@@ -111,8 +139,9 @@ jobs:
|
||||
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||
|
||||
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
|
||||
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc \
|
||||
'command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"'; then
|
||||
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm or sing-box ${{ env.SINGBOX_VERSION }}; building it now."
|
||||
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||
@@ -123,6 +152,7 @@ jobs:
|
||||
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
"${DOCKER_BUILD_OPTIONS[@]}" \
|
||||
--network host \
|
||||
--pull=false \
|
||||
--build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||
@@ -135,6 +165,10 @@ jobs:
|
||||
-t "${DATAPLANE_IMAGE}:latest" \
|
||||
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \
|
||||
.
|
||||
docker run --rm --entrypoint sing-box "${CONTROL_IMAGE}:${{ gitea.sha }}" version 2>&1 \
|
||||
| grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"
|
||||
docker run --rm --entrypoint sing-box "${DATAPLANE_IMAGE}:${{ gitea.sha }}" version 2>&1 \
|
||||
| grep -Fx "sing-box version ${{ env.SINGBOX_VERSION }}"
|
||||
docker push "${CONTROL_IMAGE}:latest"
|
||||
docker push "${CONTROL_IMAGE}:${{ gitea.sha }}"
|
||||
docker push "${DATAPLANE_IMAGE}:latest"
|
||||
@@ -189,3 +223,7 @@ jobs:
|
||||
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
|
||||
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
|
||||
bash scripts/deploy-gateway.sh
|
||||
VERSION_JSON="$(curl --noproxy '*' -fsS http://127.0.0.1:3456/api/version)"
|
||||
printf '%s\n' "$VERSION_JSON"
|
||||
printf '%s\n' "$VERSION_JSON" \
|
||||
| grep -F "\"singBox\":\"${{ env.SINGBOX_VERSION }}\""
|
||||
|
||||
@@ -13,6 +13,7 @@ data/
|
||||
node_modules/
|
||||
dist/
|
||||
.test-dist/
|
||||
.tmp-tests/
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -4,4 +4,6 @@ Use the checked-in `workpack/` directory as the only roadmap source. Do not requ
|
||||
|
||||
Follow `workpack/AGENTS.md` for every roadmap task, including status updates. Completed tasks must not be selected or implemented again unless the user explicitly asks to reopen one.
|
||||
|
||||
Before implementing any feature, record or refresh its plan in the selected `workpack/tasks/TASK-*.md` file using the mandatory feature-plan contract from `workpack/AGENTS.md`. Write the whole plan in simple language understandable without knowledge of the codebase: explain technical terms on first use, and use file paths or code names only as supporting detail. The plan must explain the implementation sequence and affected system components. For user-visible work it must also specify layout and states, exact icons, animation/motion behavior, accessibility and reduced-motion behavior; otherwise it must explicitly state that UI, icons and motion are unaffected. A proposed visible design is not owner approval.
|
||||
|
||||
For every runtime, UI, API, dependency or deployment-config change, use `.codex/skills/manage-harbor-versions/SKILL.md`. Before completion, classify the affected components, bump the required version level and run `npm run version:harbor -- check <base>`. Documentation- and test-only changes do not require a bump.
|
||||
|
||||
+7
-3
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
||||
RUN npm run build:production
|
||||
|
||||
FROM ${BASE_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||
ARG INSTALL_RUNTIME_DEPS=true
|
||||
ARG INSTALL_SINGBOX=true
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||
arm64) sb_arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||
chmod +x /usr/local/bin/sing-box; \
|
||||
@@ -46,6 +46,9 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/dist /app/dist
|
||||
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||
COPY package.json /app/package.json
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
@@ -59,6 +62,7 @@ ENV PORT=3456 \
|
||||
TPROXY_PORT=7895 \
|
||||
DATA_DIR=/var/lib/vpn-proxy \
|
||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||
SING_BOX_TRAFFIC_SOURCE=snapshot
|
||||
|
||||
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||
|
||||
+6
-2
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
||||
RUN npm run build:production
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||
@@ -26,7 +26,7 @@ RUN set -eux; \
|
||||
arm64) sb_arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||
chmod +x /usr/local/bin/sing-box; \
|
||||
@@ -34,6 +34,9 @@ RUN set -eux; \
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/dist /app/dist
|
||||
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||
COPY package.json /app/package.json
|
||||
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||
|
||||
@@ -49,6 +52,7 @@ ENV APP_MODE=client \
|
||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||
ROUTING_RU_DIRECT=true \
|
||||
SING_BOX_TRAFFIC_SOURCE=native \
|
||||
LOG_LEVEL=info
|
||||
|
||||
EXPOSE 3456 8082
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
ARG SINGBOX_VERSION=1.12.13
|
||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||
ARG HTTP_PROXY
|
||||
@@ -45,7 +45,7 @@ RUN set -eux; \
|
||||
arm64) sb_arch="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||
chmod +x /usr/local/bin/sing-box; \
|
||||
|
||||
@@ -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 покажет два варианта использования:
|
||||
@@ -75,15 +75,29 @@ http://АДРЕС-GATEWAY:3456
|
||||
|
||||
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
|
||||
|
||||
### Резервный канал Gateway
|
||||
|
||||
После добавления подписок откройте «Резерв» — вторую кнопку в правой панели. Выберите основной и резервный серверы (они могут быть из одной или разных подписок), сервисы для проверки и отдельный таймаут каждого сервиса. Там же настраиваются длительность сбоя и восстановления, порог активного трафика, период тишины и защита от частых переключений.
|
||||
|
||||
При включении Harbor заранее проверяет dual-конфигурацию. Если VPN остановлен, она начнёт работать только после следующего обычного нажатия питания; сохранение само VPN не включает. Переключение меняет маршрут только для новых соединений — уже открытые соединения не закрываются. Если через VPN идёт активный трафик или его активность нельзя надёжно определить, Harbor ждёт и показывает скорость, число передающих соединений и безопасные подписи основных блокирующих потоков.
|
||||
|
||||
Выключенный резерв полностью пассивен: Harbor не запускает проверки, таймер выбора и отдельный подсчёт активности. Если dual-конфигурация уже загружена, отключение не перезапускает VPN и не меняет текущий маршрут; обычный stop и следующий запуск вернут single-channel config. Последние важные события — включение VPN, обновления подписок, переключения и ошибки — доступны в последней кнопке «Журнал» и хранятся 30 дней без ссылок подписок и сырых диагностических ответов.
|
||||
|
||||
### Устройства Gateway
|
||||
|
||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
|
||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||
|
||||
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
||||
|
||||
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
||||
|
||||
Устройство, впервые замеченное после обновления Gateway, по умолчанию идёт `Напрямую` и первые семь дней отмечается `NEW`; исчезновение метки маршрут не меняет. Уже известные при обновлении устройства сохраняют текущий VPN, даже если метка ещё видна по их `firstSeenAt`. VPN разрешается существующей последней иконкой маршрута. Если новый device пока распознан неоднозначно, Harbor сохраняет Direct-намерение, временно оставляет фактический VPN и применяет Direct после однозначного наблюдения.
|
||||
|
||||
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||
|
||||
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||
Список приблизительный: имя и пользовательские настройки привязаны к MAC и сохраняются при обычной смене IP, но новый private/randomized MAC считается новым устройством — переносить имя по одному только DHCP-адресу небезопасно. Запись автоматически удаляется после 30 дней без подтверждённого контакта независимо от имени, закрепления или фонового положения; временная ошибка чтения сети этот срок не продвигает. Один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||
|
||||
Home показывает фактически применённый VPN-сервер и общий график тех же счётчиков. `Учтено Harbor` — накопленная сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткий график последних 15-секундных интервалов после перезапуска начинает заполняться заново.
|
||||
Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
|
||||
|
||||
## Установка Harbor Connect на macOS
|
||||
|
||||
@@ -114,10 +128,12 @@ 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.
|
||||
|
||||
Кнопка «Трафик» в правой панели показывает активные соединения, которые прошли через Harbor Connect. Данные о приложениях macOS недоступны, потому что sing-box работает внутри Docker.
|
||||
|
||||
### Другие порты
|
||||
|
||||
Передайте нужные значения при повторном запуске установщика:
|
||||
@@ -131,13 +147,15 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
||||
|
||||
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
|
||||
|
||||
## Локальные правила маршрутизации
|
||||
## Правила маршрутизации
|
||||
|
||||
После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер.
|
||||
После добавления подписки откройте «Правила маршрутизации» справа от основного экрана. При первом обновлении Harbor добавит включённое правило `*.ru → Напрямую`. Для каждого точного домена, suffix или фрагмента имени можно выбрать результат `VPN` либо `Напрямую`, выключить правило или удалить его. Правила проверяются сверху вниз, первое совпадение выбирает маршрут. Чтобы изменить порядок, возьмите строку за три точки слева и перетащите; с клавиатуры нажмите на этом хвате `Space` или `Enter`, переместите правило стрелками и повторно нажмите для размещения.
|
||||
|
||||
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила — результат уже найденного совпадения. Для устройства Gateway с маршрутом `VPN` и при обычном локальном VPN список применяется.
|
||||
|
||||
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
||||
|
||||
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
|
||||
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
|
||||
|
||||
## Системный прокси macOS
|
||||
|
||||
@@ -167,15 +185,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.
|
||||
|
||||
## Повседневные команды
|
||||
|
||||
@@ -252,12 +270,24 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
||||
| `PROXY_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
|
||||
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
||||
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
||||
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
||||
| `SING_BOX_TRAFFIC_SOURCE` | `snapshot` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
|
||||
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
||||
|
||||
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
||||
|
||||
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||
|
||||
`snapshot` сохраняет прежний опрос Clash API раз в 2 секунды. `shadow` дополнительно читает native lifecycle, но оставляет snapshot единственным источником публичных totals. `native` делает lifecycle единственным writer и не опрашивает `/connections`; Clash API остаётся только для selector/failover. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке.
|
||||
|
||||
Rollback сохраняет volumes и возвращает прежний writer:
|
||||
|
||||
```bash
|
||||
SINGBOX_VERSION=1.13.18 \
|
||||
SING_BOX_TRAFFIC_SOURCE=snapshot \
|
||||
docker compose -f docker-compose.gateway.yml up -d --build
|
||||
```
|
||||
|
||||
## Prometheus и Grafana
|
||||
|
||||
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||
@@ -274,9 +304,15 @@ scrape_configs:
|
||||
|
||||
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||
|
||||
Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
||||
|
||||
Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера.
|
||||
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||
|
||||
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
|
||||
|
||||
Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters.
|
||||
|
||||
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split.
|
||||
|
||||
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||
|
||||
@@ -323,14 +359,24 @@ docker compose -f docker-compose.client.local.yml config
|
||||
docker compose -f docker-compose.client.local.yml up -d --build
|
||||
```
|
||||
|
||||
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
|
||||
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить тестовый стек с сохранением его volumes можно командой:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.client.local.yml down -v
|
||||
docker compose -f docker-compose.client.local.yml down
|
||||
```
|
||||
|
||||
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
|
||||
|
||||
Для rollback canary на стабильный sing-box без инспектора используйте:
|
||||
|
||||
```bash
|
||||
SINGBOX_VERSION=1.13.18 \
|
||||
SING_BOX_TRAFFIC_SOURCE=disabled \
|
||||
docker compose -f docker-compose.client.local.yml up -d --build
|
||||
```
|
||||
|
||||
Не добавляйте `-v` к `down`, если хотите сохранить тестовые подписки и настройки.
|
||||
|
||||
## Служебные команды
|
||||
|
||||
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
version: v2
|
||||
clean: true
|
||||
inputs:
|
||||
- directory: proto/sing-box/v1.14.0-rc.5
|
||||
plugins:
|
||||
- local: protoc-gen-es
|
||||
out: src/server/generated
|
||||
opt:
|
||||
- target=ts
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Read-only аудит failover/failback
|
||||
|
||||
## Короткий вывод
|
||||
|
||||
Штатное переключение между `primary` и `reserve` меняет маршрут **только для новых соединений**. Уже установленные TCP/UDP-соединения не переносятся и не закрываются самим Harbor.
|
||||
|
||||
Поэтому:
|
||||
|
||||
- здоровая загрузка, игра или поток продолжаются через старый канал;
|
||||
- если старый канал действительно умер, существующая сессия может оборваться независимо от переключения;
|
||||
- после переключения новые соединения идут через новый канал;
|
||||
- бесшовной миграции уже открытого TCP/UDP-сеанса на другой внешний адрес нет.
|
||||
|
||||
## 1. Точный механизм
|
||||
|
||||
### Конфигурация
|
||||
|
||||
Gateway собирает один dual-channel `sing-box` config:
|
||||
|
||||
- `channel-primary`;
|
||||
- `channel-reserve`;
|
||||
- selector `channel-selector`;
|
||||
- стабильные `tproxy-in` и `mixed-in`;
|
||||
- отдельные diagnostic inbounds для проверки каждого канала.
|
||||
|
||||
Пользовательский трафик направляется в selector, а selector настроен с:
|
||||
|
||||
```text
|
||||
interrupt_exist_connections: false
|
||||
```
|
||||
|
||||
См. `src/server/singbox.ts:204-247`.
|
||||
|
||||
Роль меняется через localhost Clash API:
|
||||
|
||||
- `PUT /proxies/channel-selector`;
|
||||
- затем Harbor читает selector обратно и подтверждает выбранную роль.
|
||||
|
||||
См. `src/server/services/singboxSelectorService.ts:29-81`.
|
||||
|
||||
Failover API доступен только в Gateway:
|
||||
|
||||
- `PUT /api/failover`;
|
||||
- `POST /api/failover/pause`;
|
||||
- `POST /api/failover/switch`;
|
||||
- `POST /api/failover/check`.
|
||||
|
||||
См. `src/server/http/routes/failoverRoute.ts:18-39`.
|
||||
|
||||
Переключение selector не вызывает `sing-box` restart/apply/stop.
|
||||
|
||||
### Автоматический failover
|
||||
|
||||
`FailoverService`:
|
||||
|
||||
1. Проверяет оба канала через отдельные diagnostic inbound и выбранные HTTPS-сервисы.
|
||||
2. Считает канал healthy только если все проверки успешны; неизвестный результат даёт `unknown`.
|
||||
3. При сбое primary ждёт `failureWindowMs`.
|
||||
4. Переключается на reserve только если reserve healthy.
|
||||
5. При включённом traffic guard ждёт свежий quiet-window.
|
||||
6. Перед самой сменой повторно проверяет здоровье и активность.
|
||||
7. Выполняет selector PUT, read-back и только после этого обновляет canonical applied state.
|
||||
|
||||
См. `src/server/features/failover/failoverService.ts:225-245`, `:320-514`; state machine — `src/shared/failover.ts:278-354`.
|
||||
|
||||
Дефолты:
|
||||
|
||||
- проверка каждые 60 секунд;
|
||||
- сбой primary — 120 секунд;
|
||||
- восстановление primary — 15 минут;
|
||||
- quiet-window — 30 секунд;
|
||||
- активный трафик — выше 32 КБ/с;
|
||||
- минимум на reserve — 10 минут;
|
||||
- после 3 failover за 24 часа — карантин primary на 6 часов.
|
||||
|
||||
См. `src/shared/failover.ts:137-148`.
|
||||
|
||||
### Автоматический failback
|
||||
|
||||
Отдельной реализации нет: это обратная ветка той же state machine.
|
||||
|
||||
Из reserve Harbor возвращается на primary только после:
|
||||
|
||||
- полного `recoveryWindowMs`;
|
||||
- `minimumReserveMs`;
|
||||
- окончания quarantine, если он действует;
|
||||
- quiet-window при включённом traffic guard;
|
||||
- подтверждения, что primary healthy.
|
||||
|
||||
Причина переключения публикуется как `primary-recovered`. См. `src/shared/failover.ts:311-354`.
|
||||
|
||||
## 2. Судьба существующих соединений
|
||||
|
||||
| Событие | Уже открытый TCP/UDP flow | Новые соединения |
|
||||
|---|---|---|
|
||||
| Автоматический failover primary → reserve | Остаётся на прежнем outbound; Harbor его не закрывает и не мигрирует | Идут через reserve |
|
||||
| Автоматический failback reserve → primary | Остаётся на reserve | Идут через primary |
|
||||
| Ручной selector switch | Не закрывается, если старый outbound ещё работает | Сразу идёт через выбранную роль |
|
||||
| Pause | Ничего не меняет | Идут через текущую роль |
|
||||
| Disable failover | Selector и текущий маршрут не меняются; dual config временно остаётся загруженным | Идут через текущую роль |
|
||||
| Обычный stop/restart/config replacement | Процесс `sing-box` останавливается, поэтому TCP/UDP-сессии прерываются | После запуска — по новой конфигурации |
|
||||
| Реальная авария primary | Уже существующий flow может оборваться сам; Harbor не может перенести его на другой внешний IP | После selector switch новые flow идут через reserve |
|
||||
|
||||
Проверка `interrupt_exist_connections: false` непосредственно подтверждена TCP- и UDP-fixture-тестом: существующие TCP socket и UDP association продолжают обмен после switch, а новые идут через reserve. См. `test/server/singbox-selector-capability.test.js:141-142`, `:237-356`.
|
||||
|
||||
## 3. Отличия режимов
|
||||
|
||||
### Ручное переключение
|
||||
|
||||
`POST /api/failover/switch` вызывает selector напрямую:
|
||||
|
||||
- traffic guard не проверяется;
|
||||
- состояние здоровья целевого канала backend не проверяет;
|
||||
- после успешного ручного переключения `failoverPolicy.paused` становится `true`;
|
||||
- автоматический failback не произойдёт, пока пользователь не возобновит автоматическое переключение.
|
||||
|
||||
См. `src/server/features/failover/failoverService.ts:248-317`, `:614-631`.
|
||||
|
||||
Это означает, что ручной switch может быть выполнен даже на канал, который сейчас не подтверждён healthy. Это важная оговорка.
|
||||
|
||||
### Автоматический failover
|
||||
|
||||
Автоматическое переключение:
|
||||
|
||||
- ждёт failure window;
|
||||
- требует healthy reserve;
|
||||
- при включённом guard блокируется активным или неизвестным трафиком;
|
||||
- повторно валидирует условия непосредственно перед selector mutation;
|
||||
- не перезапускает `sing-box`.
|
||||
|
||||
### Восстановление primary
|
||||
|
||||
Восстановившийся primary не получает новые соединения сразу. Сначала выдерживаются recovery/hold/quarantine условия и quiet-window. Пока они не выполнены, новые подключения остаются на reserve.
|
||||
|
||||
### Pause и Disable
|
||||
|
||||
`pause` приостанавливает решения, но dual config и наблюдение остаются активными.
|
||||
|
||||
`disable` останавливает scheduler, probes и failover activity collector, но не переключает selector и не перезапускает процесс. После обычного stop/следующего запуска собирается single-channel config.
|
||||
|
||||
См. `README.md:78-84`, `docs/product/application-state.md:102-110`.
|
||||
|
||||
## 4. Практические сценарии
|
||||
|
||||
- **Загрузка файла:** при обычном автоматическом failover активная передача по умолчанию задерживает switch. Если primary всё же упал, текущая TCP-загрузка не переносится на reserve; она может завершиться ошибкой. Возобновление или новый HTTP-запрос после switch пойдёт через reserve.
|
||||
- **Игровая сессия:** существующий TCP-сеанс остаётся на старом канале. TCP-сессия оборвётся, если primary реально недоступен. UDP-flow также не мигрирует и может начать терять пакеты или истечь по timeout; новая сессия после switch пойдёт через reserve.
|
||||
- **Стрим:** активный поток обычно блокирует автоматический switch при включённом guard. Ручной switch может быть выполнен сразу, но существующий TCP/QUIC-поток остаётся на старом outbound. При аварии старого канала плеер должен переподключиться.
|
||||
- **WebSocket/долгий polling:** действующий flow не переносится; новые подключения после switch используют новую роль.
|
||||
- **Молчащее соединение:** наличие открытого socket само по себе не считается активностью. Guard смотрит на дельты переданных байтов.
|
||||
|
||||
## 5. Условия и оговорки
|
||||
|
||||
- Failover реализован только для **Gateway**, не для локального Connect или `gateway-direct`.
|
||||
- Активность собирается существующим `/connections` observer каждые 2 секунды, с bounded окном до 10 секунд. См. `src/server/index.ts:311-333`, `src/server/services/domainTrafficService.ts:323-416`, `:437-495`.
|
||||
- Короткое соединение, полностью завершившееся между двумя снимками, может не попасть в activity guard.
|
||||
- Неизвестная или устаревшая activity-информация блокирует автоматический switch; ручной switch остаётся доступен.
|
||||
- Отключение traffic guard разрешает автоматический switch без ожидания тишины, но `interrupt_exist_connections: false` всё равно защищает уже открытые connections от закрытия самим selector.
|
||||
- При изменении policy во время работающего single-channel VPN dual config становится `pending`; скрытого restart нет. См. `src/server/features/connection/connectionService.ts:140-201`, `:288-361`.
|
||||
- Явный stop/restart или обычная смена сервера вне активного failover уже является disruptive operation: `sing-box` получает SIGTERM и текущие сессии прекращаются. См. `src/server/singboxRuntime.ts:39-62`, `:65-122`.
|
||||
- Оба канала находятся в одном процессе `sing-box`; process-wide crash не защищён selector-механизмом.
|
||||
|
||||
## 6. Основные файлы
|
||||
|
||||
- `src/server/singbox.ts:204-247` — dual config, selector, inbound routing.
|
||||
- `src/server/services/singboxSelectorService.ts:29-81` — selector PUT/read-back.
|
||||
- `src/server/features/failover/failoverService.ts:225-245` — проверки каналов.
|
||||
- `src/server/features/failover/failoverService.ts:248-317` — selector switch, commit и rollback.
|
||||
- `src/server/features/failover/failoverService.ts:320-514` — автоматический раунд и traffic guard.
|
||||
- `src/server/features/failover/failoverService.ts:614-631` — ручное переключение.
|
||||
- `src/shared/failover.ts:137-148`, `:278-354` — дефолты и state machine.
|
||||
- `src/server/services/domainTrafficService.ts:129-160`, `:323-416`, `:437-495` — классификация и activity.
|
||||
- `src/server/features/connection/connectionService.ts:140-201`, `:243-361` — обычный apply/stop/restart.
|
||||
- `src/server/singboxRuntime.ts:39-122` — фактическая остановка и перезапуск процесса.
|
||||
- `README.md:78-84` — пользовательская документация.
|
||||
- `docs/product/application-state.md:102-110` — контракт состояния.
|
||||
- `workpack/tasks/TASK-021-auto-server-selection-failover.md:93-106`, `:187-204` — относящийся план и ограничения; задача не выбиралась и не изменялась.
|
||||
|
||||
## 7. Тесты, подтверждающие выводы
|
||||
|
||||
- `test/server/singbox-selector-capability.test.js:141-356`
|
||||
Интеграционная TCP/UDP-проверка: активный трафик задерживает failover, существующие TCP/UDP продолжают работать после switch, новые соединения идут через reserve, PID процесса не меняется. Тест opt-in и пропускается без `HARBOR_SINGBOX_IMAGE`.
|
||||
- `test/server/singbox-gateway-mode.test.js:69-110`
|
||||
Проверяет selector, `interrupt_exist_connections: false`, отдельные diagnostic routes и primary/reserve outbounds.
|
||||
- `test/server/failover-service.test.js:83-200`
|
||||
Failure window, traffic guard, both-unhealthy и failback recovery/hold/quarantine.
|
||||
- `test/server/failover-service.test.js:255-420`
|
||||
Disabled zero-work, отмена устаревших наблюдений и непосредственная revalidation активности.
|
||||
- `test/server/failover-service.test.js:423-520`
|
||||
Selector rollback, commit ordering и ручной switch с pause.
|
||||
- `test/server/domain-traffic.test.js:209-239`
|
||||
Activity считается по пользовательскому VPN traffic, diagnostic connection не блокирует switch.
|
||||
- `test/server/connection-service.test.js:265-310`, `:340-390`
|
||||
Restart dual config и rollback; pending edits для работающего single-channel.
|
||||
- `test/server/singbox-runtime.test.js:16-50`
|
||||
При изменении config runtime запускает новый процесс.
|
||||
- `test/server/failover-route.test.js:10-53`
|
||||
Gateway-only API и маршруты ручного switch/pause/check.
|
||||
- `test/web/failover-feature-contract.test.js:19-76`
|
||||
UI явно сообщает: новые подключения переключаются, открытые остаются на прежнем канале.
|
||||
|
||||
## Review findings и residual risks
|
||||
|
||||
- **medium — `src/server/features/failover/failoverService.ts:614-620`:** ручной switch не проверяет health целевого канала и не применяет traffic guard; он сразу меняет selector и ставит automation на pause.
|
||||
- **medium — `test/server/singbox-selector-capability.test.js:141-356`:** capability test opt-in и использует deterministic `direct` outbounds, а не реальный VLESS/Trojan outage.
|
||||
- **medium — `src/server/services/domainTrafficService.ts:414-416`, `:451-479`:** activity основана на polling и byte deltas; короткие или очень малые потоки могут не блокировать автоматическое решение.
|
||||
- **info — `src/server/singboxRuntime.ts:39-122`:** явный stop/restart отличается от selector switch и прерывает существующие соединения.
|
||||
- **info — архитектура одного процесса:** падение всего `sing-box` не компенсируется selector failover.
|
||||
|
||||
Файлы не изменялись. Тесты и live Gateway в рамках read-only аудита не запускались.
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.client
|
||||
args:
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||
container_name: harbor-connect
|
||||
environment:
|
||||
APP_MODE: client
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
||||
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
|
||||
@@ -5,7 +5,7 @@ x-gateway-image: &gateway-image
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
||||
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
|
||||
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
|
||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||
volumes:
|
||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||
@@ -49,6 +52,7 @@ services:
|
||||
DATA_DIR: /var/lib/vpn-proxy
|
||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||
ports:
|
||||
- "${PORT:-3456}:${PORT:-3456}"
|
||||
|
||||
@@ -1,85 +1,120 @@
|
||||
# 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 v8; 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.
|
||||
Failover health and traffic observations are transient: they do not write `state.json` or increase the domain `revision` every few seconds. Each control-process lifetime publishes a new `observationEpoch` and increasing `observationSequence`. At the same domain revision the browser accepts only a newer sequence from the active epoch; after accepting a new epoch it retires the old one so a late response cannot restore stale health.
|
||||
|
||||
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.
|
||||
## Desired and applied identity
|
||||
|
||||
## Desired and applied state
|
||||
`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`.
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Subscription import and refresh
|
||||
## Profile operations
|
||||
|
||||
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.
|
||||
The canonical API is scoped by profile:
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
## Ordered routing rules
|
||||
|
||||
`route.localRules` is the desired ordered list. Every rule has an explicit `outbound: "vpn" | "direct"`; the first enabled matcher wins and disabled rules retain their position without entering the generated config. `route.activeLocalRules` is the exact canonical list used to generate the running rules-enabled config, not a second desired owner.
|
||||
|
||||
The route-rules mutation uses the whole-array `PUT /api/route-rules/v2` with `rulesContractVersion: 2` and `expectedRulesRevision`. Contract v2 requires an explicit outbound on every rule. The versioned path prevents a stale v2 tab from writing to a rolled-back v1 backend; the legacy path on a v2 backend rejects its payload without changing state, config or runtime. A client that receives a snapshot without capability version 2 can read legacy rules as direct but keeps the editor read-only.
|
||||
|
||||
In Connect `gateway-direct`, local user rules are intentionally omitted and the snapshot reports no active or pending local rules. Gateway device policy `Напрямую` bypasses sing-box before these rules; policy `VPN` and an ordinary local/Gateway VPN pipeline evaluate them.
|
||||
|
||||
## Gateway failover and activity journal
|
||||
|
||||
`failoverPolicy` is the desired Gateway-only policy: master enable, primary/reserve profile and server, service checks with individual timeouts, health windows, active-traffic guard and flap protection. `failoverRuntimeState` stores switch history, hold and quarantine deadlines separately, so a runtime decision is not mistaken for a desired configuration change. `appliedFailoverPolicy` stores only the two loaded targets and safe configuration fingerprints.
|
||||
|
||||
Enabling failover while VPN is stopped validates a temporary dual-channel candidate but does not start VPN. The dual config is loaded only by the next explicit power-on. Enabling it over a running single-channel config remains pending until a later stop and power-on. Disabling automation stops its timer, probes and activity collector immediately, but does not restart sing-box or change the selected route; the already loaded dual config is reported as `passive-loaded` until the ordinary stop lifecycle clears it.
|
||||
|
||||
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
||||
|
||||
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are stored separately in `activity-journal.json` and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and prunes entries after 30 days.
|
||||
|
||||
## 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.
|
||||
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
||||
|
||||
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.
|
||||
Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config.
|
||||
|
||||
The old HTTP projection remains bounded for one release. Schema v8 persistence is not downgrade-compatible: stop Harbor and restore the `state.json.backup-v<fromVersion>-*` matching the rollback binary instead of deploying old code over v8 data. Rolling back before profiles still also requires the matching legacy subscription-cache backup.
|
||||
|
||||
@@ -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,64 @@
|
||||
# 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: 8`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, v5 embeds the canonical `profiles[]` collection with desired/applied profile identity, v6 adds an explicit `vpn` or `direct` outbound to every route rule, v7 stores connectivity-diagnostics settings, and v8 adds Gateway failover state.
|
||||
|
||||
## 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.
|
||||
|
||||
## Migration to ordered VPN/Direct rules
|
||||
|
||||
When schemas v0-v5 are read, Harbor preserves the order of `routeRules` and `appliedRouteRules` and adds `outbound: "direct"` to legacy entries before atomically committing schema v6. The original file is preserved using its actual source version, for example:
|
||||
|
||||
```text
|
||||
state.json.backup-v5-2026-08-17T12-00-00-000Z
|
||||
```
|
||||
|
||||
After migration, malformed schema-v6 rules are rejected; Harbor does not reinterpret a missing or unknown outbound as direct.
|
||||
|
||||
## Migration to failover
|
||||
|
||||
Schemas v0-v7 migrate to v8 with failover disabled, empty switch history and no applied dual config. Migration does not start probes, enable traffic accounting or change the single-channel runtime. The original state is preserved as `state.json.backup-v<fromVersion>-*` before the atomic replacement.
|
||||
|
||||
The separate `activity-journal.json` is created on the first important event. It uses the same atomic write and corrupt-file isolation mechanism as state, retains at most 30 days, and can be removed while Harbor is stopped without affecting subscriptions, routing or VPN startup.
|
||||
|
||||
## 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-v8 binary cannot interpret failover state. Restore `state.json.backup-v<fromVersion>-*` matching the rollback binary; deploying old code over schema v8 is not safe. A rollback to pre-v5 additionally requires the matching state and subscription-cache backups because that binary cannot interpret canonical profiles.
|
||||
|
||||
+76
-6
@@ -11,6 +11,8 @@ GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
||||
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
||||
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
|
||||
TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}"
|
||||
DIRECT_TRAFFIC_CHAIN="${DIRECT_TRAFFIC_CHAIN:-VPN_PROXY_DIRECT}"
|
||||
DIRECT_TRAFFIC_MARK="${DIRECT_TRAFFIC_MARK:-0x40000000}"
|
||||
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||
@@ -18,7 +20,9 @@ PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||
export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS
|
||||
export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN DIRECT_TRAFFIC_CHAIN DIRECT_TRAFFIC_MARK GATEWAY_CLIENT_CIDRS BYPASS_CIDRS
|
||||
DEVICE_TRAFFIC_CONFIG_VALID=false
|
||||
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=false
|
||||
|
||||
log() {
|
||||
printf '[gateway-entrypoint] %s\n' "$*"
|
||||
@@ -28,6 +32,53 @@ if [[ "$APP_COMPONENT" == "control" ]]; then
|
||||
exec node /app/dist/server/main.js
|
||||
fi
|
||||
|
||||
validate_device_traffic_config() {
|
||||
if [[ -z "$DIRECT_TRAFFIC_CHAIN" || ${#DIRECT_TRAFFIC_CHAIN} -gt 24
|
||||
|| "$DIRECT_TRAFFIC_CHAIN" =~ [^a-zA-Z0-9_] ]]; then
|
||||
log "device traffic counters unavailable: invalid DIRECT_TRAFFIC_CHAIN"
|
||||
return 1
|
||||
fi
|
||||
local direct_names=("$DIRECT_TRAFFIC_CHAIN" "${DIRECT_TRAFFIC_CHAIN}_A" "${DIRECT_TRAFFIC_CHAIN}_B")
|
||||
local reserved_names=(
|
||||
PREROUTING INPUT FORWARD OUTPUT POSTROUTING
|
||||
"$TPROXY_CHAIN"
|
||||
"$DEVICE_POLICY_CHAIN" "${DEVICE_POLICY_CHAIN}_A" "${DEVICE_POLICY_CHAIN}_B"
|
||||
"$TRAFFIC_DOWNLOAD_CHAIN" "${TRAFFIC_DOWNLOAD_CHAIN}_A" "${TRAFFIC_DOWNLOAD_CHAIN}_B"
|
||||
"${TRAFFIC_DOWNLOAD_CHAIN}_A_P" "${TRAFFIC_DOWNLOAD_CHAIN}_B_P"
|
||||
)
|
||||
for direct_name in "${direct_names[@]}"; do
|
||||
for reserved_name in "${reserved_names[@]}"; do
|
||||
if [[ "$direct_name" == "$reserved_name" ]]; then
|
||||
log "device traffic counters unavailable: DIRECT_TRAFFIC_CHAIN conflicts with ${reserved_name}"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
if ! [[ "$DIRECT_TRAFFIC_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$
|
||||
&& "$TPROXY_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$ ]]; then
|
||||
log "device traffic counters unavailable: invalid traffic mark"
|
||||
return 1
|
||||
fi
|
||||
local direct_mark_value tproxy_mark_value
|
||||
if [[ "$DIRECT_TRAFFIC_MARK" =~ ^0[xX] ]]; then
|
||||
direct_mark_value=$((16#${DIRECT_TRAFFIC_MARK:2}))
|
||||
else
|
||||
direct_mark_value=$((10#$DIRECT_TRAFFIC_MARK))
|
||||
fi
|
||||
if [[ "$TPROXY_MARK" =~ ^0[xX] ]]; then
|
||||
tproxy_mark_value=$((16#${TPROXY_MARK:2}))
|
||||
else
|
||||
tproxy_mark_value=$((10#$TPROXY_MARK))
|
||||
fi
|
||||
if (( direct_mark_value == 0 || direct_mark_value > 0xffffffff
|
||||
|| (direct_mark_value & (direct_mark_value - 1)) != 0
|
||||
|| (direct_mark_value & tproxy_mark_value) != 0 )); then
|
||||
log "device traffic counters unavailable: DIRECT_TRAFFIC_MARK must be one bit outside TPROXY_MARK"
|
||||
return 1
|
||||
fi
|
||||
DEVICE_TRAFFIC_CONFIG_VALID=true
|
||||
}
|
||||
|
||||
ipt() {
|
||||
iptables -w "$@"
|
||||
}
|
||||
@@ -80,37 +131,53 @@ cleanup_gateway_forwarding() {
|
||||
}
|
||||
|
||||
cleanup_device_traffic() {
|
||||
[[ "$DEVICE_TRAFFIC_CONFIG_VALID" == "true" ]] || return 0
|
||||
ipt_traffic -t mangle -D "$TPROXY_CHAIN" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" 2>/dev/null || true
|
||||
ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -D PREROUTING -j "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -D POSTROUTING -j "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t raw -F "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -F "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||
for slot in A B; do
|
||||
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -F "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -X "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||
done
|
||||
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -X "$DIRECT_TRAFFIC_CHAIN" 2>/dev/null || true
|
||||
ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||
}
|
||||
|
||||
setup_device_traffic() {
|
||||
log "setup raw device traffic counters"
|
||||
log "setup device traffic counters"
|
||||
cleanup_device_traffic
|
||||
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||
ipt_traffic -t mangle -N "$DIRECT_TRAFFIC_CHAIN" || return 1
|
||||
ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||
for slot in A B; do
|
||||
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1
|
||||
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || return 1
|
||||
ipt_traffic -t mangle -N "${DIRECT_TRAFFIC_CHAIN}_${slot}" || return 1
|
||||
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1
|
||||
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
|
||||
done
|
||||
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||
# sing-box inserts TPROXY at position 1 later; this jump then sees only packets not intercepted by it.
|
||||
ipt_traffic -t mangle -I PREROUTING 1 -j "$DIRECT_TRAFFIC_CHAIN" || return 1
|
||||
ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||
local policy_rule=4
|
||||
for _cidr in $BYPASS_CIDRS; do
|
||||
policy_rule=$((policy_rule + 1))
|
||||
done
|
||||
ipt_traffic -t mangle -I "$TPROXY_CHAIN" "$policy_rule" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" || return 1
|
||||
}
|
||||
|
||||
enable_ip_forwarding() {
|
||||
@@ -172,7 +239,6 @@ setup_tproxy() {
|
||||
for cidr in $BYPASS_CIDRS; do
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||
done
|
||||
|
||||
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
|
||||
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
|
||||
else
|
||||
@@ -183,9 +249,13 @@ setup_tproxy() {
|
||||
|
||||
setup_gateway_forwarding
|
||||
setup_tproxy
|
||||
if ! setup_device_traffic; then
|
||||
log "device traffic counters unavailable; VPN routing remains active"
|
||||
cleanup_device_traffic
|
||||
if validate_device_traffic_config; then
|
||||
if ! setup_device_traffic; then
|
||||
log "device traffic counters unavailable; VPN routing remains active"
|
||||
cleanup_device_traffic
|
||||
else
|
||||
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true
|
||||
fi
|
||||
fi
|
||||
setup_proxy_firewall
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+241
@@ -8,6 +8,9 @@
|
||||
"name": "vpn-proxy-gateway",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0",
|
||||
"@connectrpc/connect": "2.0.3",
|
||||
"@connectrpc/connect-node": "2.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -15,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.29.3",
|
||||
"@bufbuild/buf": "1.47.2",
|
||||
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||
"@csstools/selector-specificity": "6.0.0",
|
||||
"@types/node": "22.19.17",
|
||||
"@types/node18": "npm:@types/node@18.19.130",
|
||||
@@ -288,6 +293,229 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.47.2.tgz",
|
||||
"integrity": "sha512-glY5kCAoO4+a7HvDb+BLOdoHSdCk4mdXdkp53H8JFz7maOnkxCiHHXgRX+taFyEu25N8ybn7NjZFrZSdRwq2sA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"buf": "bin/buf",
|
||||
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
|
||||
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@bufbuild/buf-darwin-arm64": "1.47.2",
|
||||
"@bufbuild/buf-darwin-x64": "1.47.2",
|
||||
"@bufbuild/buf-linux-aarch64": "1.47.2",
|
||||
"@bufbuild/buf-linux-armv7": "1.47.2",
|
||||
"@bufbuild/buf-linux-x64": "1.47.2",
|
||||
"@bufbuild/buf-win32-arm64": "1.47.2",
|
||||
"@bufbuild/buf-win32-x64": "1.47.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-darwin-arm64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.47.2.tgz",
|
||||
"integrity": "sha512-74WerFn06y+azgVfsnzhfbI5wla/OLPDnIvaNJBWHaqya/3bfascJkDylW2GVNHmwG1K/cscpmcc/RJPaO7ntQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-darwin-x64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.47.2.tgz",
|
||||
"integrity": "sha512-adAiOacOQe8Ym/YXPCEiq9mrPeKRmDtF2TgqPWTcDy6mF7TqR7hMJINkEEuMd1EeACmXnzMOnXlm9ICtvdYgPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-aarch64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.47.2.tgz",
|
||||
"integrity": "sha512-52vY+Owffr5diw2PyfQJqH+Fld6zW6NhNZak4zojvc2MjZKubWM0TfNyM9jXz2YrwyB+cyxkabE60nBI80m37w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-armv7": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.47.2.tgz",
|
||||
"integrity": "sha512-g9KtpObDeHZ/VG/0b5ZCieOao7L/WYZ0fPqFSs4N07D3APgEDhJG6vLyUcDgJMDgyLcgkNjNz0+XdYQb/tXyQw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-x64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.47.2.tgz",
|
||||
"integrity": "sha512-MODCK2BzD1Mgoyr+5Sp8xA8qMNdytj8hYheyhA5NnCGTkQf8sfqAjpBSAAmKk6Zar8HOlVXML6tzE/ioDFFGwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-win32-arm64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.47.2.tgz",
|
||||
"integrity": "sha512-563YKYWJl3LrCY3G3+zuhb8HwOs6DzWslwGPFkKV2hwHyWyvd1DR1JjiLvw9zX64IKNctQ0HempSqc3kcboaqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-win32-x64": {
|
||||
"version": "1.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.47.2.tgz",
|
||||
"integrity": "sha512-Sqcdv7La2xBDh3bTdEYb2f4UTMMqCcYe/D0RELhvQ5wDn6I35V3/2YT1OF5fRuf0BZLCo0OdO37S9L47uHSz2g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.6.0.tgz",
|
||||
"integrity": "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bufbuild/protoc-gen-es": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.6.0.tgz",
|
||||
"integrity": "sha512-sKvgGndyw1stawiDKMLZyilj1BzMuUTlvyrBiDnzxGIjCMK4hoE0DsVBiqCuTFqENnLmEGdy+huOZ5KgQAGlFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.6.0",
|
||||
"@bufbuild/protoplugin": "2.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"protoc-gen-es": "bin/protoc-gen-es"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@bufbuild/protobuf": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protoplugin": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.6.0.tgz",
|
||||
"integrity": "sha512-mfAwI+4GqUtbw/ddfyolEHaAL86ozRIVlOg2A+SVRbjx1CjsMc1YJO+hBSkt/pqfpR+PmWBbZLstHbXP8KGtMQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0",
|
||||
"@typescript/vfs": "^1.5.2",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@connectrpc/connect": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz",
|
||||
"integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@connectrpc/connect-node": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz",
|
||||
"integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.2.0",
|
||||
"@connectrpc/connect": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/selector-specificity": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
|
||||
@@ -1538,6 +1766,19 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/vfs": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
|
||||
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
|
||||
"build": "vite build",
|
||||
"build:production": "npm run build && npm run build:server",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
@@ -18,6 +19,9 @@
|
||||
"start": "node dist/server/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.6.0",
|
||||
"@connectrpc/connect": "2.0.3",
|
||||
"@connectrpc/connect-node": "2.0.3",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -25,6 +29,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "7.29.3",
|
||||
"@bufbuild/buf": "1.47.2",
|
||||
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||
"@csstools/selector-specificity": "6.0.0",
|
||||
"@types/node": "22.19.17",
|
||||
"@types/node18": "npm:@types/node@18.19.130",
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package daemon;
|
||||
option go_package = "github.com/sagernet/sing-box/daemon";
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
service StartedService {
|
||||
rpc GetVersion(google.protobuf.Empty) returns(Version) {}
|
||||
rpc SubscribeServiceStatus(google.protobuf.Empty) returns(stream ServiceStatus) {}
|
||||
rpc SubscribeLog(google.protobuf.Empty) returns(stream Log) {}
|
||||
rpc GetDefaultLogLevel(google.protobuf.Empty) returns(DefaultLogLevel) {}
|
||||
rpc ClearLogs(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||
rpc SubscribeStatus(SubscribeStatusRequest) returns(stream Status) {}
|
||||
rpc SubscribeGroups(google.protobuf.Empty) returns(stream Groups) {}
|
||||
|
||||
rpc GetClashModeStatus(google.protobuf.Empty) returns(ClashModeStatus) {}
|
||||
rpc SubscribeClashMode(google.protobuf.Empty) returns(stream ClashMode) {}
|
||||
rpc SetClashMode(ClashMode) returns(google.protobuf.Empty) {}
|
||||
|
||||
rpc URLTest(URLTestRequest) returns(google.protobuf.Empty) {}
|
||||
rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {}
|
||||
rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {}
|
||||
rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {}
|
||||
rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||
rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {}
|
||||
rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {}
|
||||
|
||||
rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {}
|
||||
rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {}
|
||||
rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {}
|
||||
rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {}
|
||||
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
|
||||
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
|
||||
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
|
||||
rpc GetTailscaleCertificate(TailscaleCertificateRequest) returns (TailscaleCertificate) {}
|
||||
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
|
||||
rpc SubscribeTaildropInbox(SubscribeTaildropInboxRequest) returns (stream TaildropInbox) {}
|
||||
rpc MarkTaildropInboxRead(MarkTaildropInboxReadRequest) returns (google.protobuf.Empty) {}
|
||||
rpc SendTaildropFiles(stream TaildropSendClientMessage) returns (stream TaildropSendServerMessage) {}
|
||||
rpc DownloadTaildropFile(DownloadTaildropFileRequest) returns (stream DownloadTaildropFileChunk) {}
|
||||
rpc DeleteTaildropFile(DeleteTaildropFileRequest) returns (google.protobuf.Empty) {}
|
||||
rpc CancelTaildropReceiving(CancelTaildropReceivingRequest) returns (google.protobuf.Empty) {}
|
||||
rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {}
|
||||
rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {}
|
||||
rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {}
|
||||
rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {}
|
||||
rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {}
|
||||
rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {}
|
||||
rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {}
|
||||
rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {}
|
||||
rpc SubscribeNotifications(google.protobuf.Empty) returns (stream NotificationEvent) {}
|
||||
}
|
||||
|
||||
message Version {
|
||||
string version = 1;
|
||||
int32 apiVersion = 2;
|
||||
}
|
||||
|
||||
message ServiceStatus {
|
||||
enum Type {
|
||||
IDLE = 0;
|
||||
STARTING = 1;
|
||||
STARTED = 2;
|
||||
STOPPING = 3;
|
||||
FATAL = 4;
|
||||
}
|
||||
Type status = 1;
|
||||
string errorMessage = 2;
|
||||
}
|
||||
|
||||
message SubscribeStatusRequest {
|
||||
int64 interval = 1;
|
||||
}
|
||||
|
||||
enum LogLevel {
|
||||
PANIC = 0;
|
||||
FATAL = 1;
|
||||
ERROR = 2;
|
||||
WARN = 3;
|
||||
INFO = 4;
|
||||
DEBUG = 5;
|
||||
TRACE = 6;
|
||||
}
|
||||
|
||||
message Log {
|
||||
repeated Message messages = 1;
|
||||
bool reset = 2;
|
||||
message Message {
|
||||
LogLevel level = 1;
|
||||
string message = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message DefaultLogLevel {
|
||||
LogLevel level = 1;
|
||||
}
|
||||
|
||||
message Status {
|
||||
uint64 memory = 1;
|
||||
int32 goroutines = 2;
|
||||
int32 connectionsIn = 3;
|
||||
int32 connectionsOut = 4;
|
||||
bool trafficAvailable = 5;
|
||||
int64 uplink = 6;
|
||||
int64 downlink = 7;
|
||||
int64 uplinkTotal = 8;
|
||||
int64 downlinkTotal = 9;
|
||||
}
|
||||
|
||||
message Groups {
|
||||
repeated Group group = 1;
|
||||
}
|
||||
|
||||
message Group {
|
||||
string tag = 1;
|
||||
string type = 2;
|
||||
bool selectable = 3;
|
||||
string selected = 4;
|
||||
bool isExpand = 5;
|
||||
repeated GroupItem items = 6;
|
||||
}
|
||||
|
||||
message GroupItem {
|
||||
string tag = 1;
|
||||
string type = 2;
|
||||
int64 urlTestTime = 3;
|
||||
int32 urlTestDelay = 4;
|
||||
}
|
||||
|
||||
message URLTestRequest {
|
||||
string outboundTag = 1;
|
||||
}
|
||||
|
||||
message SelectOutboundRequest {
|
||||
string groupTag = 1;
|
||||
string outboundTag = 2;
|
||||
}
|
||||
|
||||
message SetGroupExpandRequest {
|
||||
string groupTag = 1;
|
||||
bool isExpand = 2;
|
||||
}
|
||||
|
||||
message ClashMode {
|
||||
string mode = 3;
|
||||
}
|
||||
|
||||
message ClashModeStatus {
|
||||
repeated string modeList = 1;
|
||||
string currentMode = 2;
|
||||
}
|
||||
|
||||
message SubscribeConnectionsRequest {
|
||||
int64 interval = 1;
|
||||
}
|
||||
|
||||
enum ConnectionEventType {
|
||||
CONNECTION_EVENT_NEW = 0;
|
||||
CONNECTION_EVENT_UPDATE = 1;
|
||||
CONNECTION_EVENT_CLOSED = 2;
|
||||
}
|
||||
|
||||
message ConnectionEvent {
|
||||
ConnectionEventType type = 1;
|
||||
string id = 2;
|
||||
Connection connection = 3;
|
||||
int64 uplinkDelta = 4;
|
||||
int64 downlinkDelta = 5;
|
||||
int64 closedAt = 6;
|
||||
}
|
||||
|
||||
message ConnectionEvents {
|
||||
repeated ConnectionEvent events = 1;
|
||||
bool reset = 2;
|
||||
}
|
||||
|
||||
message Connection {
|
||||
string id = 1;
|
||||
string inbound = 2;
|
||||
string inboundType = 3;
|
||||
int32 ipVersion = 4;
|
||||
string network = 5;
|
||||
string source = 6;
|
||||
string destination = 7;
|
||||
string domain = 8;
|
||||
string protocol = 9;
|
||||
string user = 10;
|
||||
string fromOutbound = 11;
|
||||
int64 createdAt = 12;
|
||||
int64 closedAt = 13;
|
||||
int64 uplink = 14;
|
||||
int64 downlink = 15;
|
||||
int64 uplinkTotal = 16;
|
||||
int64 downlinkTotal = 17;
|
||||
string rule = 18;
|
||||
string outbound = 19;
|
||||
string outboundType = 20;
|
||||
repeated string chainList = 21;
|
||||
ProcessInfo processInfo = 22;
|
||||
}
|
||||
|
||||
message ProcessInfo {
|
||||
uint32 processId = 1;
|
||||
int32 userId = 2;
|
||||
string userName = 3;
|
||||
string processPath = 4;
|
||||
repeated string packageNames = 5;
|
||||
}
|
||||
|
||||
message CloseConnectionRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeprecatedWarnings {
|
||||
repeated DeprecatedWarning warnings = 1;
|
||||
}
|
||||
|
||||
message DeprecatedWarning {
|
||||
string message = 1;
|
||||
bool impending = 2;
|
||||
string migrationLink = 3;
|
||||
string description = 4;
|
||||
string deprecatedVersion = 5;
|
||||
string scheduledVersion = 6;
|
||||
}
|
||||
|
||||
message StartedAt {
|
||||
int64 startedAt = 1;
|
||||
}
|
||||
|
||||
message OutboundList {
|
||||
repeated GroupItem outbounds = 1;
|
||||
}
|
||||
|
||||
message NetworkQualityTestRequest {
|
||||
string configURL = 1;
|
||||
string outboundTag = 2;
|
||||
bool serial = 3;
|
||||
int32 maxRuntimeSeconds = 4;
|
||||
bool http3 = 5;
|
||||
}
|
||||
|
||||
message NetworkQualityTestProgress {
|
||||
int32 phase = 1;
|
||||
int64 downloadCapacity = 2;
|
||||
int64 uploadCapacity = 3;
|
||||
int32 downloadRPM = 4;
|
||||
int32 uploadRPM = 5;
|
||||
int32 idleLatencyMs = 6;
|
||||
int64 elapsedMs = 7;
|
||||
bool isFinal = 8;
|
||||
string error = 9;
|
||||
int32 downloadCapacityAccuracy = 10;
|
||||
int32 uploadCapacityAccuracy = 11;
|
||||
int32 downloadRPMAccuracy = 12;
|
||||
int32 uploadRPMAccuracy = 13;
|
||||
}
|
||||
|
||||
message STUNTestRequest {
|
||||
string server = 1;
|
||||
string outboundTag = 2;
|
||||
}
|
||||
|
||||
message STUNTestProgress {
|
||||
int32 phase = 1;
|
||||
string externalAddr = 2;
|
||||
int32 latencyMs = 3;
|
||||
int32 natMapping = 4;
|
||||
int32 natFiltering = 5;
|
||||
bool isFinal = 6;
|
||||
string error = 7;
|
||||
bool natTypeSupported = 8;
|
||||
}
|
||||
|
||||
message TailscaleStatusUpdate {
|
||||
repeated TailscaleEndpointStatus endpoints = 1;
|
||||
}
|
||||
|
||||
message TailscaleEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string backendState = 2;
|
||||
string stateText = 3;
|
||||
string authURL = 4;
|
||||
string networkName = 5;
|
||||
string magicDNSSuffix = 6;
|
||||
TailscalePeer self = 7;
|
||||
repeated TailscaleUserGroup userGroups = 8;
|
||||
TailscalePeer exitNode = 9;
|
||||
bool keyAuth = 10;
|
||||
bool canShareFiles = 11;
|
||||
int32 waitingFileCount = 12;
|
||||
int32 receivingFileCount = 13;
|
||||
int32 unreadFileCount = 14;
|
||||
repeated string certDomains = 15;
|
||||
}
|
||||
|
||||
message TailscaleUserGroup {
|
||||
int64 userID = 1;
|
||||
string loginName = 2;
|
||||
string displayName = 3;
|
||||
string profilePicURL = 4;
|
||||
repeated TailscalePeer peers = 5;
|
||||
}
|
||||
|
||||
message TailscalePeer {
|
||||
string hostName = 1;
|
||||
string dnsName = 2;
|
||||
string os = 3;
|
||||
repeated string tailscaleIPs = 4;
|
||||
bool online = 5;
|
||||
bool exitNode = 6;
|
||||
bool exitNodeOption = 7;
|
||||
bool active = 8;
|
||||
int64 rxBytes = 9;
|
||||
int64 txBytes = 10;
|
||||
int64 keyExpiry = 11;
|
||||
string stableID = 12;
|
||||
bool expired = 13;
|
||||
repeated string sshHostKeys = 14;
|
||||
bool shareeNode = 15;
|
||||
int64 lastSeen = 16;
|
||||
bool canReceiveFiles = 17;
|
||||
}
|
||||
|
||||
message TailscalePingRequest {
|
||||
string endpointTag = 1;
|
||||
string peerIP = 2;
|
||||
}
|
||||
|
||||
message TailscalePingResponse {
|
||||
double latencyMs = 1;
|
||||
bool isDirect = 2;
|
||||
string endpoint = 3;
|
||||
int32 derpRegionID = 4;
|
||||
string derpRegionCode = 5;
|
||||
string error = 6;
|
||||
string peerRelay = 7;
|
||||
}
|
||||
|
||||
message SetTailscaleExitNodeRequest {
|
||||
string endpointTag = 1;
|
||||
string stableID = 2;
|
||||
}
|
||||
|
||||
message TailscaleLogoutRequest {
|
||||
string endpointTag = 1;
|
||||
}
|
||||
|
||||
message TailscaleCertificateRequest {
|
||||
string endpointTag = 1;
|
||||
string domain = 2;
|
||||
int64 minValiditySeconds = 3;
|
||||
}
|
||||
|
||||
message TailscaleCertificate {
|
||||
bytes certificatePEM = 1;
|
||||
bytes privateKeyPEM = 2;
|
||||
}
|
||||
|
||||
message TailscaleSSHClientMessage {
|
||||
oneof message {
|
||||
TailscaleSSHStart start = 1;
|
||||
TailscaleSSHInput input = 2;
|
||||
TailscaleSSHResize resize = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message TailscaleSSHStart {
|
||||
string endpointTag = 1;
|
||||
string peerAddress = 2;
|
||||
string username = 3;
|
||||
string terminalType = 4;
|
||||
int32 columns = 5;
|
||||
int32 rows = 6;
|
||||
int32 widthPixels = 7;
|
||||
int32 heightPixels = 8;
|
||||
repeated string hostKeys = 9;
|
||||
bool forward_agent = 10;
|
||||
}
|
||||
|
||||
message TailscaleSSHInput {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message TailscaleSSHResize {
|
||||
int32 columns = 1;
|
||||
int32 rows = 2;
|
||||
int32 widthPixels = 3;
|
||||
int32 heightPixels = 4;
|
||||
}
|
||||
|
||||
message TailscaleSSHServerMessage {
|
||||
oneof message {
|
||||
TailscaleSSHAuthBanner authBanner = 1;
|
||||
TailscaleSSHReady ready = 2;
|
||||
TailscaleSSHOutput output = 3;
|
||||
TailscaleSSHExit exit = 4;
|
||||
TailscaleSSHError error = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message TailscaleSSHAuthBanner {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message TailscaleSSHReady {
|
||||
}
|
||||
|
||||
message TailscaleSSHOutput {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message TailscaleSSHExit {
|
||||
int32 exitCode = 1;
|
||||
string signal = 2;
|
||||
string errorMessage = 3;
|
||||
}
|
||||
|
||||
message TailscaleSSHError {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message SubscribeTaildropInboxRequest {
|
||||
string endpointTag = 1;
|
||||
}
|
||||
|
||||
message MarkTaildropInboxReadRequest {
|
||||
string endpointTag = 1;
|
||||
}
|
||||
|
||||
message TaildropInbox {
|
||||
string endpointTag = 1;
|
||||
repeated TaildropFile files = 2;
|
||||
repeated TaildropReceivingFile receiving = 3;
|
||||
}
|
||||
|
||||
message TaildropFile {
|
||||
string name = 1;
|
||||
int64 size = 2;
|
||||
string senderName = 3;
|
||||
int64 modifiedAt = 4;
|
||||
}
|
||||
|
||||
message TaildropReceivingFile {
|
||||
string name = 1;
|
||||
int64 size = 2;
|
||||
int64 receivedBytes = 3;
|
||||
string senderID = 4;
|
||||
string senderName = 5;
|
||||
}
|
||||
|
||||
message TaildropSendClientMessage {
|
||||
oneof message {
|
||||
TaildropSendStart start = 1;
|
||||
TaildropFileChunk chunk = 2;
|
||||
TaildropFileDone fileDone = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message TaildropSendStart {
|
||||
string endpointTag = 1;
|
||||
string peerStableID = 2;
|
||||
repeated TaildropOutgoingFile files = 3;
|
||||
}
|
||||
|
||||
message TaildropOutgoingFile {
|
||||
string name = 1;
|
||||
int64 size = 2;
|
||||
}
|
||||
|
||||
message TaildropFileChunk {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message TaildropFileDone {}
|
||||
|
||||
message TaildropSendServerMessage {
|
||||
oneof message {
|
||||
TaildropSendProgress progress = 1;
|
||||
int64 receivedBytes = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message TaildropSendProgress {
|
||||
int32 fileIndex = 1;
|
||||
int64 sentBytes = 2;
|
||||
bool fileCompleted = 3;
|
||||
}
|
||||
|
||||
message DownloadTaildropFileRequest {
|
||||
string endpointTag = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message DownloadTaildropFileChunk {
|
||||
int64 size = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message DeleteTaildropFileRequest {
|
||||
string endpointTag = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message CancelTaildropReceivingRequest {
|
||||
string endpointTag = 1;
|
||||
string senderID = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
message USBProviderMessage {
|
||||
oneof message {
|
||||
USBDeviceAttach attach = 1;
|
||||
USBDeviceDetach detach = 2;
|
||||
USBURBResponse urbResponse = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message USBServerMessage {
|
||||
oneof message {
|
||||
USBDeviceReady ready = 1;
|
||||
USBURBRequest urbRequest = 2;
|
||||
USBEndpointAbort abort = 3;
|
||||
USBError error = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message USBDeviceDescriptor {
|
||||
string deviceId = 1;
|
||||
uint32 busNum = 2;
|
||||
uint32 devNum = 3;
|
||||
uint32 speed = 4;
|
||||
uint32 vendorId = 5;
|
||||
uint32 productId = 6;
|
||||
uint32 bcdDevice = 7;
|
||||
uint32 deviceClass = 8;
|
||||
uint32 deviceSubClass = 9;
|
||||
uint32 deviceProtocol = 10;
|
||||
uint32 configurationValue = 11;
|
||||
uint32 numConfigurations = 12;
|
||||
repeated USBInterface interfaces = 13;
|
||||
string serial = 14;
|
||||
string product = 15;
|
||||
}
|
||||
|
||||
message USBDeviceAttach {
|
||||
string serverTag = 1;
|
||||
USBDeviceDescriptor descriptor = 2;
|
||||
}
|
||||
|
||||
message USBInterface {
|
||||
uint32 interfaceClass = 1;
|
||||
uint32 interfaceSubClass = 2;
|
||||
uint32 interfaceProtocol = 3;
|
||||
}
|
||||
|
||||
message USBDeviceDetach {
|
||||
string deviceId = 1;
|
||||
}
|
||||
|
||||
message USBDeviceReady {
|
||||
string deviceId = 1;
|
||||
string busId = 2;
|
||||
}
|
||||
|
||||
message USBURBRequest {
|
||||
string deviceId = 1;
|
||||
uint64 seq = 2;
|
||||
uint32 endpoint = 3;
|
||||
bool directionIn = 4;
|
||||
uint32 transferFlags = 5;
|
||||
bytes setup = 6;
|
||||
uint32 transferBufferLength = 7;
|
||||
bytes outData = 8;
|
||||
int32 numberOfPackets = 9;
|
||||
int32 startFrame = 10;
|
||||
int32 interval = 11;
|
||||
repeated USBIsoPacket isoPackets = 12;
|
||||
}
|
||||
|
||||
message USBURBResponse {
|
||||
string deviceId = 1;
|
||||
uint64 seq = 2;
|
||||
int32 status = 3;
|
||||
int32 actualLength = 4;
|
||||
bytes inData = 5;
|
||||
repeated USBIsoPacket isoPackets = 6;
|
||||
}
|
||||
|
||||
message USBIsoPacket {
|
||||
int32 offset = 1;
|
||||
int32 length = 2;
|
||||
int32 actualLength = 3;
|
||||
int32 status = 4;
|
||||
}
|
||||
|
||||
message USBEndpointAbort {
|
||||
string deviceId = 1;
|
||||
uint32 endpoint = 2;
|
||||
}
|
||||
|
||||
message USBError {
|
||||
string deviceId = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message USBIPServerStatusUpdate {
|
||||
repeated USBIPServerStatus servers = 1;
|
||||
}
|
||||
|
||||
message USBIPServerStatus {
|
||||
string serverTag = 1;
|
||||
repeated USBSharedDevice devices = 2;
|
||||
}
|
||||
|
||||
message USBSharedDevice {
|
||||
USBDeviceDescriptor descriptor = 1;
|
||||
string busId = 2;
|
||||
string stableId = 3;
|
||||
USBBackend backend = 4;
|
||||
USBDeviceState state = 5;
|
||||
}
|
||||
|
||||
enum USBDeviceState {
|
||||
USB_DEVICE_STATE_IDLE = 0;
|
||||
USB_DEVICE_STATE_ATTACHED = 1;
|
||||
USB_DEVICE_STATE_UNAVAILABLE = 2;
|
||||
}
|
||||
|
||||
enum USBBackend {
|
||||
USB_BACKEND_UNSPECIFIED = 0;
|
||||
USB_BACKEND_LINUX_SYSFS = 1;
|
||||
USB_BACKEND_DYNAMIC = 2;
|
||||
USB_BACKEND_DARWIN_IOKIT = 3;
|
||||
USB_BACKEND_WINDOWS_VBOXUSB = 4;
|
||||
}
|
||||
|
||||
message OpenConnectStatusUpdate {
|
||||
repeated OpenConnectEndpointStatus endpoints = 1;
|
||||
}
|
||||
|
||||
message OpenConnectEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string state = 2;
|
||||
string stateText = 3;
|
||||
OpenConnectAuthChallenge authChallenge = 4;
|
||||
string error = 5;
|
||||
OpenConnectTunnelInfo tunnelInfo = 6;
|
||||
}
|
||||
|
||||
message OpenConnectTunnelInfo {
|
||||
string server = 1;
|
||||
string flavor = 2;
|
||||
string transport = 3;
|
||||
repeated string ipv4 = 4;
|
||||
repeated string ipv6 = 5;
|
||||
repeated string dns = 6;
|
||||
uint32 mtu = 7;
|
||||
int64 connectedSince = 8;
|
||||
}
|
||||
|
||||
message OpenConnectAuthChallenge {
|
||||
string id = 1;
|
||||
string banner = 2;
|
||||
string message = 3;
|
||||
string error = 4;
|
||||
oneof challenge {
|
||||
OpenConnectAuthForm form = 5;
|
||||
OpenConnectBrowserRequest browser = 6;
|
||||
}
|
||||
}
|
||||
|
||||
message OpenConnectAuthForm {
|
||||
repeated OpenConnectAuthFormField fields = 1;
|
||||
}
|
||||
|
||||
message OpenConnectAuthFormField {
|
||||
string submissionKey = 1;
|
||||
string name = 2;
|
||||
string label = 3;
|
||||
string kind = 4;
|
||||
string value = 5;
|
||||
repeated OpenConnectAuthFormChoice options = 6;
|
||||
}
|
||||
|
||||
message OpenConnectAuthFormChoice {
|
||||
string value = 1;
|
||||
string label = 2;
|
||||
}
|
||||
|
||||
message OpenConnectBrowserRequest {
|
||||
string url = 1;
|
||||
string finalURL = 2;
|
||||
repeated string cookieNames = 3;
|
||||
repeated string headerNames = 4;
|
||||
repeated string callbackURLPrefixes = 5;
|
||||
repeated string earlyCookieNames = 6;
|
||||
string cacheID = 7;
|
||||
}
|
||||
|
||||
message OpenConnectBrowserCookie {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message OpenConnectBrowserHeader {
|
||||
string name = 1;
|
||||
repeated string values = 2;
|
||||
}
|
||||
|
||||
message OpenConnectAuthFormResponse {
|
||||
map<string, string> values = 1;
|
||||
}
|
||||
|
||||
message OpenConnectBrowserResult {
|
||||
string finalURL = 1;
|
||||
repeated OpenConnectBrowserCookie cookies = 2;
|
||||
repeated OpenConnectBrowserHeader headers = 3;
|
||||
}
|
||||
|
||||
message OpenConnectAuthResponseSubmission {
|
||||
string endpointTag = 1;
|
||||
string challengeID = 2;
|
||||
oneof response {
|
||||
OpenConnectAuthFormResponse form = 3;
|
||||
OpenConnectBrowserResult browser = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message OpenConnectAuthChallengeCancel {
|
||||
string endpointTag = 1;
|
||||
string challengeID = 2;
|
||||
}
|
||||
|
||||
message OpenVPNStatusUpdate {
|
||||
repeated OpenVPNEndpointStatus endpoints = 1;
|
||||
}
|
||||
|
||||
message OpenVPNEndpointStatus {
|
||||
string endpointTag = 1;
|
||||
string state = 2;
|
||||
string stateText = 3;
|
||||
OpenVPNChallenge challenge = 4;
|
||||
string error = 5;
|
||||
OpenVPNTunnelInfo tunnelInfo = 6;
|
||||
}
|
||||
|
||||
message OpenVPNTunnelInfo {
|
||||
string server = 1;
|
||||
reserved 2;
|
||||
string network = 3;
|
||||
repeated string ipv4 = 4;
|
||||
repeated string ipv6 = 5;
|
||||
repeated string dns = 6;
|
||||
uint32 mtu = 7;
|
||||
int64 connectedSince = 8;
|
||||
string cipher = 9;
|
||||
}
|
||||
|
||||
message OpenVPNChallenge {
|
||||
string id = 1;
|
||||
string kind = 2;
|
||||
string username = 3;
|
||||
string message = 4;
|
||||
string url = 5;
|
||||
string secretMessage = 6;
|
||||
bool echo = 7;
|
||||
string previousError = 8;
|
||||
int64 deadline = 9;
|
||||
}
|
||||
|
||||
message OpenVPNChallengeSubmission {
|
||||
string endpointTag = 1;
|
||||
string challengeID = 2;
|
||||
string username = 3;
|
||||
string password = 4;
|
||||
string secret = 5;
|
||||
}
|
||||
|
||||
message OpenVPNChallengeCancel {
|
||||
string endpointTag = 1;
|
||||
string challengeID = 2;
|
||||
}
|
||||
|
||||
message NotificationEvent {
|
||||
oneof event {
|
||||
Notification send = 1;
|
||||
NotificationCancel cancel = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message Notification {
|
||||
string identifier = 1;
|
||||
string typeName = 2;
|
||||
int32 typeID = 3;
|
||||
string title = 4;
|
||||
string subtitle = 5;
|
||||
string body = 6;
|
||||
string openURL = 7;
|
||||
}
|
||||
|
||||
message NotificationCancel {
|
||||
string identifier = 1;
|
||||
int32 typeID = 2;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
|
||||
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||
@@ -63,7 +63,7 @@ else
|
||||
fi
|
||||
|
||||
echo "Building image on ${BUILD_HOST}"
|
||||
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
||||
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
|
||||
if [ "${BUILD_HOST}" = "local" ]; then
|
||||
bash -lc "${BUILD_COMMAND}"
|
||||
else
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
|
||||
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||
|
||||
@@ -53,7 +53,7 @@ export function affectedComponents(files) {
|
||||
for (const file of files) {
|
||||
if (file === VERSION_FILE) continue;
|
||||
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||
else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||
add('macClient', 'gatewayClient');
|
||||
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
|
||||
|
||||
@@ -9,6 +9,8 @@ COMPOSE_FILE="docker-compose.client.yml"
|
||||
DEFAULT_PROXY_PORT="8082"
|
||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||
TARGET_TRAFFIC_SOURCE="${SING_BOX_TRAFFIC_SOURCE:-native}"
|
||||
CLIENT_CONTAINER_NAME="harbor-connect"
|
||||
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||
@@ -260,7 +262,11 @@ copy_source() {
|
||||
[ -f "$source_dir/docker-compose.client.yml" ] || die "invalid Harbor source archive"
|
||||
log "installing files to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp -R "$source_dir/." "$INSTALL_DIR/"
|
||||
rsync -a --delete \
|
||||
--exclude='.env' \
|
||||
--exclude='.runtime' \
|
||||
--exclude='.git' \
|
||||
"$source_dir/" "$INSTALL_DIR/"
|
||||
}
|
||||
|
||||
download_source() {
|
||||
@@ -282,8 +288,14 @@ fi
|
||||
|
||||
need docker
|
||||
need curl
|
||||
need rsync
|
||||
need tar
|
||||
|
||||
case "$TARGET_TRAFFIC_SOURCE" in
|
||||
native|disabled) ;;
|
||||
*) die "SING_BOX_TRAFFIC_SOURCE must be native or disabled" ;;
|
||||
esac
|
||||
|
||||
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||
|
||||
@@ -314,6 +326,8 @@ UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||
assert_ui_outside_proxy_range
|
||||
|
||||
set_env_value APP_MODE client
|
||||
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION"
|
||||
set_env_value SING_BOX_TRAFFIC_SOURCE "$TARGET_TRAFFIC_SOURCE"
|
||||
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||
|
||||
@@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url';
|
||||
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
|
||||
const noRuntimeImpact = [
|
||||
/^\.codex\//,
|
||||
/^\.tmp-tests\//,
|
||||
/^docs\//,
|
||||
/^test\//,
|
||||
/^workpack\//,
|
||||
/^(?:AGENTS|PRODUCT|README)\.md$/,
|
||||
/^context\.md$/,
|
||||
/^\.env\.example$/,
|
||||
/^\.gitignore$/,
|
||||
/^Dockerfile\.client$/,
|
||||
@@ -17,6 +19,7 @@ const noRuntimeImpact = [
|
||||
/^entrypoint\.client\.sh$/,
|
||||
/^install\.sh$/,
|
||||
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
||||
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
|
||||
];
|
||||
const foundation = [
|
||||
/^\.dockerignore$/,
|
||||
@@ -30,11 +33,14 @@ const foundation = [
|
||||
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||
];
|
||||
const controlAndDataplane = [
|
||||
/^buf\.gen\.yaml$/,
|
||||
/^proto\//,
|
||||
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/server/(?:config|gatewayNativeRuntime|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||
/^src\/server\/generated\//,
|
||||
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService)${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
|
||||
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
|
||||
/^src\/server\/infrastructure\/dataplane\//,
|
||||
];
|
||||
const dataplane = [
|
||||
|
||||
+33
-2
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
const appMode = process.env.APP_MODE === "client" ? "client" : "gateway";
|
||||
const appComponent = process.env.APP_COMPONENT || "";
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
const parsePort = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
@@ -7,21 +9,45 @@ const parsePort = (value: string | undefined, fallback: number) => {
|
||||
};
|
||||
const proxyPort = parsePort(
|
||||
process.env.PROXY_PORT,
|
||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||
appMode === "client" ? 8082 : 8080,
|
||||
);
|
||||
const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE
|
||||
|| (appMode === "client" ? "native" : "snapshot");
|
||||
if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") {
|
||||
throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode");
|
||||
}
|
||||
if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) {
|
||||
throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode");
|
||||
}
|
||||
if (appMode === "gateway" && trafficSource !== "snapshot"
|
||||
&& ((appComponent !== "control" && appComponent !== "dataplane")
|
||||
|| !process.env.DATAPLANE_SOCKET?.trim())) {
|
||||
throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology");
|
||||
}
|
||||
|
||||
export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
appMode,
|
||||
appComponent,
|
||||
port: parsePort(process.env.PORT, 3456),
|
||||
proxyPort,
|
||||
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
||||
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
|
||||
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
|
||||
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
||||
singboxNativeApiPort: 19091,
|
||||
singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow",
|
||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
|
||||
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
|
||||
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
||||
deviceTrafficAccountingEnabled: process.env.DEVICE_TRAFFIC_ACCOUNTING_ENABLED !== "false",
|
||||
directTrafficChain: process.env.DIRECT_TRAFFIC_CHAIN || "VPN_PROXY_DIRECT",
|
||||
directTrafficMark: process.env.DIRECT_TRAFFIC_MARK || "0x40000000",
|
||||
gatewayClientCidrs: (process.env.GATEWAY_CLIENT_CIDRS
|
||||
|| "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16")
|
||||
.trim().split(/\s+/).filter(Boolean),
|
||||
bypassCidrs: (process.env.BYPASS_CIDRS
|
||||
|| "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4")
|
||||
.trim().split(/\s+/).filter(Boolean),
|
||||
@@ -32,8 +58,13 @@ export const settings = {
|
||||
configPath:
|
||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||
gatewayNativeApiSecretPath:
|
||||
process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret",
|
||||
gatewayRuntimeConfigPath:
|
||||
process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json",
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
deviceStatePath: path.join(dataDir, "devices.json"),
|
||||
activityJournalPath: path.join(dataDir, "activity-journal.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hostNetworkStatePath:
|
||||
|
||||
+240
-20
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
|
||||
import { settings } from './config.js';
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { buildVersionInfo } from './version.js';
|
||||
@@ -13,18 +15,37 @@ import {
|
||||
createDomainTrafficService,
|
||||
readSingboxConnections,
|
||||
} from './services/domainTrafficService.js';
|
||||
import { deviceId } from './services/deviceInventoryService.js';
|
||||
import {
|
||||
createLiveTrafficService,
|
||||
} from './services/liveTrafficService.js';
|
||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||
|
||||
const socketPath = settings.dataplaneSocket;
|
||||
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
||||
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
|
||||
const runtime = createSingboxRuntime({
|
||||
configPath: settings.configPath,
|
||||
gateway: true,
|
||||
tproxyChain: settings.tproxyChain,
|
||||
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||
...(nativeTrafficEnabled ? {
|
||||
nativeApi: {
|
||||
apiPort: settings.singboxNativeApiPort,
|
||||
secretPath: settings.gatewayNativeApiSecretPath,
|
||||
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
const versionInfo = buildVersionInfo('gateway');
|
||||
const traffic = createDeviceTrafficService({
|
||||
observe: () => readNeighborSnapshot(),
|
||||
uploadChain: settings.trafficUploadChain,
|
||||
downloadChain: settings.trafficDownloadChain,
|
||||
directChain: settings.directTrafficChain,
|
||||
directMark: settings.directTrafficMark,
|
||||
tproxyMark: settings.tproxyMark,
|
||||
gatewayClientCidrs: settings.gatewayClientCidrs,
|
||||
bypassCidrs: settings.bypassCidrs,
|
||||
proxyPort: settings.proxyPort,
|
||||
});
|
||||
@@ -36,10 +57,28 @@ const devicePolicy = createDevicePolicyService({
|
||||
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||
proxyPort: settings.diagnosticsProxyPort,
|
||||
});
|
||||
const domainTraffic = createDomainTrafficService({
|
||||
const failoverDiagnostics = {
|
||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||
};
|
||||
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
|
||||
const snapshotDomainTraffic = createDomainTrafficService({
|
||||
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||
devices: () => traffic.snapshot().devices,
|
||||
});
|
||||
const nativeDomainTraffic = createDomainTrafficService({
|
||||
observe: () => ({ connections: [] }),
|
||||
devices: () => traffic.snapshot().devices,
|
||||
});
|
||||
const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic;
|
||||
let originsByIp = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||
let liveTraffic = createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: false,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => false,
|
||||
resolveOrigin,
|
||||
});
|
||||
let ready = false;
|
||||
let trafficTimer: NodeJS.Timeout | null = null;
|
||||
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||
@@ -55,6 +94,133 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function updateOrigins(devices: unknown) {
|
||||
const next = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||
for (const value of Array.isArray(devices) ? devices : []) {
|
||||
const device = record(value);
|
||||
const ip = String(device.ip || '');
|
||||
const mac = String(device.mac || '').toLowerCase();
|
||||
if (!net.isIPv4(ip) || !/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(mac)) continue;
|
||||
const origin: LiveTrafficConnection['origin'] = {
|
||||
kind: 'device',
|
||||
id: deviceId(mac),
|
||||
label: ip,
|
||||
provenance: 'source-ip',
|
||||
};
|
||||
next.set(ip, next.has(ip) ? null : origin);
|
||||
}
|
||||
originsByIp = next;
|
||||
}
|
||||
|
||||
function resolveOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||
return originsByIp.get(sourceIp) || {
|
||||
kind: 'unknown',
|
||||
id: null,
|
||||
label: 'Неизвестное устройство',
|
||||
provenance: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshDeviceTraffic() {
|
||||
try {
|
||||
return await traffic.refresh();
|
||||
} finally {
|
||||
refreshOrigins();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOrigins() {
|
||||
updateOrigins(readNeighborSnapshot().observations);
|
||||
}
|
||||
|
||||
function decimal(value: unknown) {
|
||||
return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : 0n;
|
||||
}
|
||||
|
||||
function trackedTotals(snapshot: unknown) {
|
||||
let upload = 0n;
|
||||
let download = 0n;
|
||||
for (const value of Array.isArray(record(snapshot).tracked) ? record(snapshot).tracked as unknown[] : []) {
|
||||
const entry = record(value);
|
||||
upload += decimal(entry.uploadBytes);
|
||||
download += decimal(entry.downloadBytes);
|
||||
}
|
||||
return { upload, download };
|
||||
}
|
||||
|
||||
function mismatchCount(left: unknown, right: unknown, fields: string[]) {
|
||||
const entries = (value: unknown) => {
|
||||
const values = Array.isArray(value) ? value : [];
|
||||
return new Map(values.map((item) => {
|
||||
const entry = record(item);
|
||||
const key = fields.map((field) => String(entry[field] || '')).join('\0');
|
||||
return [key, `${entry.uploadBytes || '0'}\0${entry.downloadBytes || '0'}`];
|
||||
}));
|
||||
};
|
||||
const leftEntries = entries(left);
|
||||
const rightEntries = entries(right);
|
||||
const keys = new Set([...leftEntries.keys(), ...rightEntries.keys()]);
|
||||
let mismatches = 0;
|
||||
for (const key of keys) if (leftEntries.get(key) !== rightEntries.get(key)) mismatches += 1;
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function liveTrafficSnapshot(): LiveTrafficSnapshot {
|
||||
const snapshot = liveTraffic.snapshot();
|
||||
return nativeTrafficEnabled && runtime.nativeApiWarning ? {
|
||||
...snapshot,
|
||||
source: {
|
||||
...snapshot.source,
|
||||
state: 'incompatible',
|
||||
error: runtime.nativeApiWarning,
|
||||
},
|
||||
} : snapshot;
|
||||
}
|
||||
|
||||
function trafficCollectorSource() {
|
||||
const canonical = domainTraffic.snapshot();
|
||||
const canonicalSource = record(canonical.source);
|
||||
const nativeLive = nativeTrafficEnabled ? liveTrafficSnapshot() : null;
|
||||
const nativeProjection = nativeDomainTraffic.snapshot();
|
||||
const legacyProjection = snapshotDomainTraffic.snapshot();
|
||||
let shadow = null;
|
||||
if (trafficMode === 'shadow') {
|
||||
const nativeTotals = trackedTotals(nativeProjection);
|
||||
const legacyTotals = trackedTotals(legacyProjection);
|
||||
shadow = {
|
||||
activeDifference: Number(record(nativeProjection.source).activeConnections || 0)
|
||||
- Number(record(legacyProjection.source).activeConnections || 0),
|
||||
uploadDifferenceBytes: (nativeTotals.upload - legacyTotals.upload).toString(),
|
||||
downloadDifferenceBytes: (nativeTotals.download - legacyTotals.download).toString(),
|
||||
routeMismatches: mismatchCount(nativeProjection.tracked, legacyProjection.tracked, ['source', 'outbound']),
|
||||
deviceMismatches: mismatchCount(nativeProjection.routes, legacyProjection.routes, ['deviceId', 'source', 'outbound']),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: runtime.nativeApiWarning
|
||||
|| (trafficMode === 'native' ? nativeLive?.source.error : canonicalSource.error)
|
||||
|| null,
|
||||
mode: trafficMode,
|
||||
writer: trafficMode === 'native' ? 'native' as const : 'snapshot' as const,
|
||||
activeConnections: Number(canonicalSource.activeConnections || 0),
|
||||
native: nativeLive ? {
|
||||
state: nativeLive.source.state,
|
||||
epoch: nativeLive.epoch,
|
||||
sequence: nativeLive.sequence,
|
||||
observedAt: nativeLive.observedAt,
|
||||
active: nativeLive.summary.active,
|
||||
unattributedUploadBytes: nativeLive.source.unattributedUploadBytes,
|
||||
unattributedDownloadBytes: nativeLive.source.unattributedDownloadBytes,
|
||||
} : null,
|
||||
shadow,
|
||||
};
|
||||
}
|
||||
|
||||
function domainTrafficSnapshot() {
|
||||
const snapshot = domainTraffic.snapshot();
|
||||
return { ...snapshot, source: trafficCollectorSource() };
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -95,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||
singBoxVersion: versionInfo.runtime.singBox,
|
||||
devicePolicy: devicePolicy.snapshot(),
|
||||
trafficCollector: trafficCollectorSource(),
|
||||
ready,
|
||||
});
|
||||
}
|
||||
@@ -105,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
return sendJson(res, 200, traffic.snapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
||||
return sendJson(res, 200, domainTraffic.snapshot());
|
||||
return sendJson(res, 200, domainTrafficSnapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/traffic/live') {
|
||||
return sendJson(res, 200, liveTrafficSnapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||
return sendJson(res, 200, devicePolicy.snapshot());
|
||||
@@ -122,6 +292,36 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
target,
|
||||
}));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/failover/probe') {
|
||||
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
||||
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||
return sendJson(res, 200, await failoverDiagnostics[role].runVpn({ services, target, timeoutMs: Number(timeoutMs) }));
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/failover/selector') {
|
||||
return sendJson(res, 200, await selector.read());
|
||||
}
|
||||
if (req.method === 'PUT' && req.url === '/failover/selector') {
|
||||
const { role } = record(await readJson(req));
|
||||
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||
return sendJson(res, 200, await selector.select(role));
|
||||
}
|
||||
if (req.method === 'PUT' && req.url === '/failover/activity') {
|
||||
const { enabled } = record(await readJson(req));
|
||||
if (enabled === true) domainTraffic.enableActivity();
|
||||
else domainTraffic.disableActivity();
|
||||
return sendJson(res, 200, { enabled: enabled === true });
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/failover/activity/read') {
|
||||
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
|
||||
const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live';
|
||||
return sendJson(res, 200, {
|
||||
activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null,
|
||||
});
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/config/check') {
|
||||
const { config } = record(await readJson(req));
|
||||
return sendJson(res, 200, runtime.checkConfig(config));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/apply') {
|
||||
return sendJson(res, 200, await runtime.apply());
|
||||
}
|
||||
@@ -146,25 +346,44 @@ server.listen(socketPath, async () => {
|
||||
} catch (error) {
|
||||
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
refreshOrigins();
|
||||
liveTraffic = createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: nativeTrafficEnabled,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning,
|
||||
resolveOrigin,
|
||||
authorization: () => runtime.nativeApiSecret,
|
||||
onProjection: (batch) => {
|
||||
nativeDomainTraffic.ingestNative(batch);
|
||||
},
|
||||
});
|
||||
liveTraffic.start();
|
||||
ready = true;
|
||||
setImmediate(() => {
|
||||
traffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||
});
|
||||
trafficTimer = setInterval(() => {
|
||||
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||
}, 15_000);
|
||||
trafficTimer.unref();
|
||||
setImmediate(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||
});
|
||||
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
|
||||
domainTrafficTimer = setInterval(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||
}, 2_000);
|
||||
domainTrafficTimer.unref();
|
||||
if (settings.deviceTrafficAccountingEnabled) {
|
||||
setImmediate(() => {
|
||||
refreshDeviceTraffic()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||
});
|
||||
trafficTimer = setInterval(() => {
|
||||
refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||
}, 15_000);
|
||||
trafficTimer.unref();
|
||||
} else {
|
||||
trafficTimer = setInterval(refreshOrigins, 15_000);
|
||||
trafficTimer.unref();
|
||||
}
|
||||
if (trafficMode !== 'native') {
|
||||
setImmediate(() => {
|
||||
snapshotDomainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||
});
|
||||
domainTrafficTimer = setInterval(() => {
|
||||
snapshotDomainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||
}, 2_000);
|
||||
domainTrafficTimer.unref();
|
||||
}
|
||||
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||
}
|
||||
});
|
||||
@@ -176,6 +395,7 @@ async function shutdown() {
|
||||
ready = false;
|
||||
if (trafficTimer) clearInterval(trafficTimer);
|
||||
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||
await liveTraffic.stop();
|
||||
await runtime.shutdown();
|
||||
server.close(() => {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
|
||||
@@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
||||
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||
@@ -82,6 +83,16 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||
}
|
||||
},
|
||||
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
||||
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
||||
),
|
||||
readFailoverSelector: () => send(socketPath, '/failover/selector', 'GET'),
|
||||
selectFailoverRole: (role: 'primary' | 'reserve') => send(socketPath, '/failover/selector', 'PUT', { role }),
|
||||
setFailoverActivityEnabled: (enabled: boolean) => send(socketPath, '/failover/activity', 'PUT', { enabled }),
|
||||
readFailoverActivity: (thresholdBytesPerSecond: number) => (
|
||||
send(socketPath, '/failover/activity/read', 'POST', { thresholdBytesPerSecond })
|
||||
),
|
||||
apply: () => update('/apply', 'POST'),
|
||||
restart: () => update('/restart', 'POST'),
|
||||
stop: () => update('/stop', 'POST'),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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';
|
||||
import { finishRollback, type RollbackStep } from '../../services/rollback.js';
|
||||
import type { AppliedFailoverPolicy } from '../../../shared/failover.js';
|
||||
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||
|
||||
interface ConnectionServiceDependencies {
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: {
|
||||
readConfig(): unknown | null;
|
||||
};
|
||||
config: {
|
||||
exists(): boolean;
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
write(value: unknown): void;
|
||||
@@ -25,6 +27,19 @@ interface ConnectionServiceDependencies {
|
||||
stopCommand(): Promise<RuntimeCommandResult>;
|
||||
restartCommand(): Promise<RuntimeCommandResult>;
|
||||
};
|
||||
route?: { isGatewayDirect(): boolean };
|
||||
failover?: {
|
||||
build(state: StoredState, source?: 'desired' | 'applied'): {
|
||||
config: unknown;
|
||||
applied: AppliedFailoverPolicy;
|
||||
primaryProfile: StoredProfile;
|
||||
primaryServer: StoredProfile['servers'][number];
|
||||
};
|
||||
prepareActivation(role: 'primary' | 'reserve'): Promise<unknown>;
|
||||
restoreAppliedActivation(state: StoredState): Promise<unknown>;
|
||||
reconcile(): Promise<unknown>;
|
||||
};
|
||||
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
now(): Date;
|
||||
}
|
||||
@@ -46,65 +61,185 @@ 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 prepareFailoverActivation = async (role: 'primary' | 'reserve') => {
|
||||
try {
|
||||
await dependencies.failover?.prepareActivation(role);
|
||||
} catch (cause) {
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause });
|
||||
}
|
||||
};
|
||||
const activationTarget = (
|
||||
state: StoredState,
|
||||
applied: AppliedFailoverPolicy,
|
||||
role: 'primary' | 'reserve',
|
||||
) => {
|
||||
const target = applied[role];
|
||||
const profile = profileById(state, target.profileId);
|
||||
const server = profile?.servers.find(({ id }) => id === target.serverId);
|
||||
if (!profile || !server) throw new HarborError('SERVER_NOT_FOUND');
|
||||
return { profile, server };
|
||||
};
|
||||
const finishConnectionRollback = async (error: unknown, steps: RollbackStep[], message: string) => {
|
||||
try {
|
||||
await finishRollback(error, steps, message);
|
||||
} catch (cause) {
|
||||
const code = cause && typeof cause === 'object' && 'code' in cause
|
||||
&& /^[A-Z0-9_]{1,50}$/.test(String(cause.code)) ? String(cause.code) : 'UNKNOWN';
|
||||
dependencies.onEvent?.({
|
||||
type: 'connection.failed',
|
||||
severity: 'error',
|
||||
source: 'connection',
|
||||
dedupeKey: `connection.failed:${dependencies.state.read().revision}:${code}`,
|
||||
data: { errorCode: code },
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
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');
|
||||
|
||||
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = dependencies.config.build(
|
||||
subscriptionConfig,
|
||||
const wasRunning = await dependencies.runtime.isRunning();
|
||||
if (wasRunning && previousState.failoverPolicy?.enabled) {
|
||||
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
|
||||
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
}
|
||||
|
||||
if (dependencies.route?.isGatewayDirect()) {
|
||||
dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
|
||||
return { profileId: profile.id, serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
}
|
||||
|
||||
const failoverCandidate = previousState.failoverPolicy?.enabled
|
||||
? dependencies.failover?.build(previousState, wasRunning ? 'applied' : 'desired')
|
||||
: null;
|
||||
const nextConfig = failoverCandidate?.config || dependencies.config.build(
|
||||
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();
|
||||
if (failoverCandidate) await prepareFailoverActivation('primary');
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedServerId: selectedServer.id,
|
||||
...withDesiredServer(state, profile, selectedServer.id),
|
||||
connectionDesired: 'running',
|
||||
appliedProfileId: failoverCandidate?.primaryProfile.id || profile.id,
|
||||
appliedServerId: failoverCandidate?.primaryServer.id || selectedServer.id,
|
||||
appliedServerSnapshot: failoverCandidate?.primaryServer || selectedServer,
|
||||
appliedFailoverPolicy: failoverCandidate?.applied || null,
|
||||
appliedAt: dependencies.now().toISOString(),
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
await finishConnectionRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(configMutationStarted ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: async () => {
|
||||
if (!wasRunning) return dependencies.runtime.stop();
|
||||
await dependencies.runtime.start();
|
||||
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
|
||||
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||
await dependencies.failover?.reconcile();
|
||||
dependencies.onEvent?.({
|
||||
type: 'connection.started',
|
||||
severity: 'info',
|
||||
source: 'connection',
|
||||
dedupeKey: `connection.started:${dependencies.state.read().revision}`,
|
||||
data: {
|
||||
profileLabel: failoverCandidate?.primaryProfile.label || profile.label,
|
||||
serverLabel: failoverCandidate?.primaryServer.label || 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,51 +254,123 @@ 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,
|
||||
appliedFailoverPolicy: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
await finishConnectionRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
run: async () => {
|
||||
if (!wasRunning) return dependencies.runtime.stop();
|
||||
await dependencies.runtime.start();
|
||||
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
await dependencies.failover?.reconcile();
|
||||
dependencies.onEvent?.({
|
||||
type: 'connection.stopped',
|
||||
severity: 'info',
|
||||
source: 'connection',
|
||||
dedupeKey: `connection.stopped:${dependencies.state.read().revision}`,
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
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 failoverCandidate = previousState.failoverPolicy?.enabled
|
||||
? dependencies.failover?.build(previousState, wasRunning ? 'applied' : 'desired')
|
||||
: null;
|
||||
const activationRole = failoverCandidate && wasRunning
|
||||
&& previousState.appliedProfileId === failoverCandidate.applied.reserve.profileId
|
||||
&& previousState.appliedServerId === failoverCandidate.applied.reserve.serverId
|
||||
? 'reserve' as const
|
||||
: 'primary' as const;
|
||||
const failoverTarget = failoverCandidate
|
||||
? activationTarget(previousState, failoverCandidate.applied, activationRole)
|
||||
: null;
|
||||
const candidateConfig = failoverCandidate?.config || 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;
|
||||
if (failoverCandidate) await prepareFailoverActivation(activationRole);
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedServerId: state.selectedServerId,
|
||||
desiredProfileId: wasRunning ? state.desiredProfileId : profile.id,
|
||||
appliedProfileId: failoverTarget?.profile.id || profile.id,
|
||||
appliedServerId: failoverTarget?.server.id || server.id,
|
||||
appliedServerSnapshot: failoverTarget?.server || server,
|
||||
appliedFailoverPolicy: failoverCandidate?.applied || null,
|
||||
connectionDesired: 'running',
|
||||
appliedRouteRules: state.routeRules,
|
||||
appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||
await finishConnectionRollback(error, [
|
||||
...(configMutationStarted ? [{
|
||||
run: () => previousConfig === null
|
||||
? dependencies.config.remove()
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: async () => {
|
||||
if (!wasRunning) return dependencies.runtime.stop();
|
||||
await dependencies.runtime.start();
|
||||
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
], 'Connection rollback failed');
|
||||
}
|
||||
await dependencies.failover?.reconcile();
|
||||
dependencies.onEvent?.({
|
||||
type: 'connection.started',
|
||||
severity: 'info',
|
||||
source: 'connection',
|
||||
dedupeKey: `connection.started:${dependencies.state.read().revision}`,
|
||||
data: {
|
||||
profileLabel: failoverTarget?.profile.label || profile.label,
|
||||
serverLabel: failoverTarget?.server.label || server.label,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { apply, stop, restart };
|
||||
return { apply, activate, stop, restart };
|
||||
}
|
||||
|
||||
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
interface DiagnosticServer {
|
||||
id: unknown;
|
||||
label: unknown;
|
||||
}
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import {
|
||||
normalizeDiagnosticSettings,
|
||||
type DiagnosticSettings,
|
||||
} from '../../../shared/connectivityDiagnostics.js';
|
||||
import type { HarborServer, StoredProfile, StoredState } from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
|
||||
interface DiagnosticState {
|
||||
desiredProfileId?: unknown;
|
||||
appliedProfileId?: unknown;
|
||||
appliedServerId?: unknown;
|
||||
selectedServerId?: unknown;
|
||||
servers?: DiagnosticServer[];
|
||||
appliedServerSnapshot?: HarborServer | null;
|
||||
profiles?: StoredProfile[];
|
||||
revision?: number;
|
||||
diagnostics?: DiagnosticSettings;
|
||||
}
|
||||
|
||||
interface DiagnosticsResult extends Record<string, unknown> {
|
||||
@@ -14,7 +22,10 @@ interface DiagnosticsResult extends Record<string, unknown> {
|
||||
}
|
||||
|
||||
interface ConnectivityDiagnosticsDependencies {
|
||||
readState(): DiagnosticState;
|
||||
state: {
|
||||
read(): DiagnosticState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -25,16 +36,27 @@ 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);
|
||||
async run(target: unknown) {
|
||||
const state = dependencies.state.read();
|
||||
const selected = selectedServer(state);
|
||||
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||
const services = normalizeDiagnosticSettings(state.diagnostics).customServices;
|
||||
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||
return {
|
||||
...result,
|
||||
@@ -44,6 +66,24 @@ export function createConnectivityDiagnosticsUseCase(
|
||||
},
|
||||
};
|
||||
},
|
||||
updateSettings(settings: unknown, expectedRevision: unknown) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
let diagnostics: DiagnosticSettings;
|
||||
try {
|
||||
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
||||
? settings as Record<string, unknown>
|
||||
: {};
|
||||
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
const current = dependencies.state.read();
|
||||
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
||||
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
createIdleFailoverSnapshot,
|
||||
isFailoverConfigured,
|
||||
nextFailoverDecision,
|
||||
normalizeFailoverPolicy,
|
||||
type AppliedFailoverPolicy,
|
||||
type FailoverDecisionMemory,
|
||||
type FailoverHealth,
|
||||
type FailoverPolicy,
|
||||
type FailoverRole,
|
||||
type FailoverSnapshot,
|
||||
} from '../../../shared/failover.js';
|
||||
import type { HarborServer, StoredState } from '../../../shared/contracts/state.js';
|
||||
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
|
||||
interface Candidate {
|
||||
config: unknown;
|
||||
applied: AppliedFailoverPolicy;
|
||||
}
|
||||
|
||||
interface FailoverServiceDependencies {
|
||||
state: {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
runtime: { isRunning(): Promise<boolean> };
|
||||
dataplane: {
|
||||
checkConfig(config: unknown): Promise<unknown>;
|
||||
runFailoverProbe(role: FailoverRole, services: unknown, target: string, timeoutMs: number): Promise<unknown>;
|
||||
readFailoverSelector(): Promise<unknown>;
|
||||
selectFailoverRole(role: FailoverRole): Promise<unknown>;
|
||||
setFailoverActivityEnabled(enabled: boolean): Promise<unknown>;
|
||||
readFailoverActivity(thresholdBytesPerSecond: number): Promise<unknown>;
|
||||
};
|
||||
buildCandidate(state: StoredState): Candidate;
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
scheduler?: {
|
||||
setTimeout(callback: () => void, intervalMs: number): NodeJS.Timeout;
|
||||
clearTimeout(timer: NodeJS.Timeout): void;
|
||||
};
|
||||
now?: () => Date;
|
||||
onWarning?: (error: unknown) => void;
|
||||
onSwitch?: (from: FailoverRole, to: FailoverRole, reason: string) => void;
|
||||
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> => (
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
);
|
||||
|
||||
function targetServer(state: StoredState, role: FailoverRole): HarborServer | null {
|
||||
const target = state.appliedFailoverPolicy?.[role] || state.failoverPolicy[role];
|
||||
return state.profiles.find(({ id }) => id === target.profileId)
|
||||
?.servers.find(({ id }) => id === target.serverId) || null;
|
||||
}
|
||||
|
||||
function currentRole(state: StoredState): FailoverRole | null {
|
||||
const applied = state.appliedFailoverPolicy;
|
||||
if (!applied) return null;
|
||||
for (const role of ['primary', 'reserve'] as const) {
|
||||
if (
|
||||
state.appliedProfileId === applied[role].profileId
|
||||
&& state.appliedServerId === applied[role].serverId
|
||||
) return role;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function probeHealth(value: unknown): boolean {
|
||||
const vpn = record(record(value).vpn);
|
||||
const sites = Array.isArray(vpn.sites) ? vpn.sites.map(record) : [];
|
||||
return sites.length === 1 && sites[0].status === 'available';
|
||||
}
|
||||
|
||||
function safeErrorCode(error: unknown) {
|
||||
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||
return /^[A-Z0-9_]{1,50}$/.test(code) ? code : 'UNKNOWN';
|
||||
}
|
||||
|
||||
export function createFailoverService(dependencies: FailoverServiceDependencies) {
|
||||
const scheduler = dependencies.scheduler || {
|
||||
setTimeout: (callback: () => void, intervalMs: number) => setTimeout(callback, intervalMs),
|
||||
clearTimeout: (timer: NodeJS.Timeout) => clearTimeout(timer),
|
||||
};
|
||||
const now = dependencies.now || (() => new Date());
|
||||
const epoch = crypto.randomUUID();
|
||||
let sequence = 0;
|
||||
let generation = 0;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let collectorEnabled: boolean | null = null;
|
||||
let roundPromise: Promise<void> | null = null;
|
||||
let roundGeneration: number | null = null;
|
||||
let decisionMemory: FailoverDecisionMemory | undefined;
|
||||
const healthMemory: Record<FailoverRole, 'healthy' | 'unhealthy' | undefined> = {
|
||||
primary: undefined,
|
||||
reserve: undefined,
|
||||
};
|
||||
function clearHealthMemory() {
|
||||
healthMemory.primary = undefined;
|
||||
healthMemory.reserve = undefined;
|
||||
}
|
||||
function recordHealthTransition(
|
||||
role: FailoverRole,
|
||||
health: FailoverHealth,
|
||||
capturedGeneration?: number,
|
||||
) {
|
||||
if (capturedGeneration !== undefined && capturedGeneration !== generation) return;
|
||||
if (health !== 'healthy' && health !== 'unhealthy') return;
|
||||
const previous = healthMemory[role];
|
||||
healthMemory[role] = health;
|
||||
if (previous === health) return;
|
||||
if (previous === undefined && health === 'healthy') return;
|
||||
const type = health === 'unhealthy'
|
||||
? role === 'primary' ? 'failover.primary_unavailable' : 'failover.reserve_unavailable'
|
||||
: role === 'primary' ? 'failover.primary_recovered' : 'failover.reserve_recovered';
|
||||
dependencies.onEvent?.({
|
||||
type,
|
||||
severity: health === 'unhealthy' ? 'warning' : 'info',
|
||||
source: 'failover',
|
||||
dedupeKey: null,
|
||||
data: {
|
||||
role,
|
||||
reason: health === 'unhealthy' ? 'probe-failed' : 'probe-recovered',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let snapshot = createIdleFailoverSnapshot(dependencies.state.read().failoverPolicy, epoch, sequence);
|
||||
|
||||
function appliedMatchesDesired(state: StoredState) {
|
||||
if (!state.appliedFailoverPolicy) return false;
|
||||
try {
|
||||
return JSON.stringify(dependencies.buildCandidate(state).applied)
|
||||
=== JSON.stringify(state.appliedFailoverPolicy);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publish(next: FailoverSnapshot) {
|
||||
sequence += 1;
|
||||
snapshot = { ...next, observationEpoch: epoch, observationSequence: sequence };
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) scheduler.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule(delay: number) {
|
||||
clearTimer();
|
||||
timer = scheduler.setTimeout(() => {
|
||||
timer = null;
|
||||
void runRound().catch(dependencies.onWarning);
|
||||
}, delay);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function disableCollector() {
|
||||
if (collectorEnabled === false) return;
|
||||
await dependencies.dataplane.setFailoverActivityEnabled(false);
|
||||
collectorEnabled = false;
|
||||
}
|
||||
|
||||
async function deactivate(policy: FailoverPolicy, passiveRole: FailoverRole | null = null) {
|
||||
generation += 1;
|
||||
clearTimer();
|
||||
decisionMemory = undefined;
|
||||
clearHealthMemory();
|
||||
await disableCollector();
|
||||
const idle = createIdleFailoverSnapshot(policy, epoch, sequence);
|
||||
if (passiveRole) {
|
||||
idle.activation = 'passive-loaded';
|
||||
idle.currentRole = passiveRole;
|
||||
idle.reason = 'disabled';
|
||||
}
|
||||
publish(idle);
|
||||
}
|
||||
|
||||
function activeSnapshot(state: StoredState, status: FailoverSnapshot['status'] = 'observing'): FailoverSnapshot {
|
||||
const role = state.failoverRuntimeState.reasonCode === 'selector-unknown' ? null : currentRole(state);
|
||||
const channel = (target: typeof state.failoverPolicy.primary) => ({
|
||||
target,
|
||||
health: 'unknown' as FailoverHealth,
|
||||
failingServiceIds: [],
|
||||
checkedAt: null,
|
||||
stateSince: null,
|
||||
});
|
||||
return {
|
||||
observationEpoch: epoch,
|
||||
observationSequence: sequence,
|
||||
configured: isFailoverConfigured(state.failoverPolicy),
|
||||
enabled: state.failoverPolicy.enabled,
|
||||
paused: state.failoverPolicy.paused,
|
||||
activation: appliedMatchesDesired(state) ? 'active' : 'pending',
|
||||
currentRole: role || 'other',
|
||||
status,
|
||||
primary: channel(state.appliedFailoverPolicy?.primary || state.failoverPolicy.primary),
|
||||
reserve: channel(state.appliedFailoverPolicy?.reserve || state.failoverPolicy.reserve),
|
||||
nextDecisionAt: null,
|
||||
reason: null,
|
||||
trafficActivity: null,
|
||||
policy: state.failoverPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcile() {
|
||||
let state = dependencies.state.read();
|
||||
const policy = state.failoverPolicy;
|
||||
if (!policy.enabled) {
|
||||
const role = currentRole(state);
|
||||
const passiveRole = role && await dependencies.runtime.isRunning() ? role : null;
|
||||
return deactivate(policy, passiveRole);
|
||||
}
|
||||
const running = await dependencies.runtime.isRunning();
|
||||
if (!running || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||
generation += 1;
|
||||
clearHealthMemory();
|
||||
clearTimer();
|
||||
await disableCollector();
|
||||
const pending = activeSnapshot(state, 'idle');
|
||||
pending.activation = running ? 'pending' : 'inactive';
|
||||
pending.reason = running ? 'pending-activation' : 'vpn-stopped';
|
||||
publish(pending);
|
||||
return;
|
||||
}
|
||||
const role = currentRole(state)!;
|
||||
const selected = record(await dependencies.dataplane.readFailoverSelector());
|
||||
if (selected.role !== role) await dependencies.dataplane.selectFailoverRole(role);
|
||||
if (state.failoverRuntimeState.reasonCode === 'selector-unknown') {
|
||||
state = dependencies.state.update((current) => ({
|
||||
...current,
|
||||
failoverRuntimeState: { ...current.failoverRuntimeState, reasonCode: null },
|
||||
}));
|
||||
}
|
||||
if (collectorEnabled !== true) {
|
||||
await dependencies.dataplane.setFailoverActivityEnabled(true);
|
||||
collectorEnabled = true;
|
||||
}
|
||||
generation += 1;
|
||||
const active = activeSnapshot(state);
|
||||
if (active.activation === 'pending') active.reason = 'pending-activation';
|
||||
publish(active);
|
||||
schedule(0);
|
||||
}
|
||||
|
||||
async function reconcileAfterCommit() {
|
||||
try {
|
||||
await reconcile();
|
||||
} catch (error) {
|
||||
dependencies.onWarning?.(error);
|
||||
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||
failed.reason = 'reconcile-failed';
|
||||
publish(failed);
|
||||
}
|
||||
}
|
||||
|
||||
async function assessRole(role: FailoverRole, policy: FailoverPolicy) {
|
||||
const custom = dependencies.state.read().diagnostics.customServices;
|
||||
const results = await Promise.all(policy.checks.map(async (check) => {
|
||||
try {
|
||||
return {
|
||||
id: check.serviceId,
|
||||
ok: probeHealth(await dependencies.dataplane.runFailoverProbe(
|
||||
role,
|
||||
custom,
|
||||
`site:${check.serviceId}`,
|
||||
check.timeoutMs,
|
||||
)),
|
||||
};
|
||||
} catch {
|
||||
return { id: check.serviceId, ok: null };
|
||||
}
|
||||
}));
|
||||
return {
|
||||
health: results.some(({ ok }) => ok === null)
|
||||
? 'unknown' as const
|
||||
: results.every(({ ok }) => ok) ? 'healthy' as const : 'unhealthy' as const,
|
||||
failingServiceIds: results.filter(({ ok }) => ok === false).map(({ id }) => id),
|
||||
};
|
||||
}
|
||||
|
||||
async function switchWithinQueue(role: FailoverRole, reason: string) {
|
||||
const before = dependencies.state.read();
|
||||
const from = currentRole(before);
|
||||
if (!from || from === role) return;
|
||||
try {
|
||||
await dependencies.dataplane.selectFailoverRole(role);
|
||||
const server = targetServer(before, role);
|
||||
if (!server) throw new HarborError('SERVER_NOT_FOUND');
|
||||
const target = before.appliedFailoverPolicy![role];
|
||||
const switchedAt = now().toISOString();
|
||||
const cutoff = now().getTime() - before.failoverPolicy.flapProtection.windowMs;
|
||||
const history = role === 'reserve'
|
||||
? [...before.failoverRuntimeState.failoverHistory.filter((value) => Date.parse(value) >= cutoff), switchedAt]
|
||||
: before.failoverRuntimeState.failoverHistory.filter((value) => Date.parse(value) >= cutoff);
|
||||
const quarantine = history.length >= before.failoverPolicy.flapProtection.count
|
||||
? new Date(now().getTime() + before.failoverPolicy.flapProtection.quarantineMs).toISOString()
|
||||
: before.failoverRuntimeState.primaryQuarantineUntil;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
appliedProfileId: target.profileId,
|
||||
appliedServerId: target.serverId,
|
||||
appliedServerSnapshot: server,
|
||||
failoverPolicy: reason === 'manual'
|
||||
? state.failoverPolicy
|
||||
: role === 'primary' ? { ...state.failoverPolicy, paused: false } : state.failoverPolicy,
|
||||
failoverRuntimeState: {
|
||||
...state.failoverRuntimeState,
|
||||
lastSwitchAt: switchedAt,
|
||||
holdUntil: role === 'reserve'
|
||||
? new Date(now().getTime() + state.failoverPolicy.minimumReserveMs).toISOString()
|
||||
: null,
|
||||
primaryQuarantineUntil: quarantine,
|
||||
failoverHistory: history,
|
||||
reasonCode: reason,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await dependencies.dataplane.selectFailoverRole(from);
|
||||
} catch (rollback) {
|
||||
generation += 1;
|
||||
clearTimer();
|
||||
decisionMemory = undefined;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
failoverPolicy: { ...state.failoverPolicy, paused: true },
|
||||
failoverRuntimeState: { ...state.failoverRuntimeState, reasonCode: 'selector-unknown' },
|
||||
}));
|
||||
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||
failed.reason = 'selector-unknown';
|
||||
publish(failed);
|
||||
throw new AggregateError([error, rollback], 'Failover selector rollback failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
dependencies.onSwitch?.(from, role, reason);
|
||||
dependencies.onEvent?.({
|
||||
type: 'failover.switched',
|
||||
severity: 'info',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.switched:${dependencies.state.read().revision}`,
|
||||
data: {
|
||||
fromRole: from,
|
||||
toRole: role,
|
||||
primaryLabel: targetServer(dependencies.state.read(), 'primary')?.label || 'Primary',
|
||||
reserveLabel: targetServer(dependencies.state.read(), 'reserve')?.label || 'Reserve',
|
||||
reason,
|
||||
manual: reason === 'manual',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function performRound(capturedGeneration: number) {
|
||||
const prepared = await dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
const role = currentRole(state);
|
||||
if (
|
||||
capturedGeneration !== generation
|
||||
|| !state.failoverPolicy.enabled
|
||||
|| !state.appliedFailoverPolicy
|
||||
|| !role
|
||||
) return null;
|
||||
const selected = record(await dependencies.dataplane.readFailoverSelector());
|
||||
if (selected.role !== role) await dependencies.dataplane.selectFailoverRole(role);
|
||||
await dependencies.dataplane.setFailoverActivityEnabled(true);
|
||||
collectorEnabled = true;
|
||||
const latest = dependencies.state.read();
|
||||
return capturedGeneration === generation
|
||||
&& latest.failoverPolicy.enabled
|
||||
&& currentRole(latest) === role
|
||||
? { state: latest, policy: latest.failoverPolicy, role }
|
||||
: null;
|
||||
});
|
||||
if (!prepared) return;
|
||||
const { state, policy, role } = prepared;
|
||||
const checkedAt = now().toISOString();
|
||||
const previousSnapshot = snapshot;
|
||||
publish({ ...snapshot, reason: 'checking-channels' });
|
||||
let primary;
|
||||
let reserve;
|
||||
try {
|
||||
[primary, reserve] = await Promise.all([
|
||||
assessRole('primary', policy),
|
||||
assessRole('reserve', policy),
|
||||
]);
|
||||
} catch {
|
||||
primary = { health: 'unknown' as const, failingServiceIds: [] };
|
||||
reserve = { health: 'unknown' as const, failingServiceIds: [] };
|
||||
}
|
||||
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||
recordHealthTransition('primary', primary.health, capturedGeneration);
|
||||
recordHealthTransition('reserve', reserve.health, capturedGeneration);
|
||||
const activityResponse = record(await dependencies.dataplane.readFailoverActivity(
|
||||
policy.trafficGuard.thresholdBytesPerSecond,
|
||||
));
|
||||
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||
const activity = record(activityResponse.activity);
|
||||
const observedAt = typeof activity.observedAt === 'string' ? Date.parse(activity.observedAt) : NaN;
|
||||
const activityState = Number.isFinite(observedAt) && now().getTime() - observedAt <= 4_000
|
||||
&& (activity.state === 'active' || activity.state === 'quiet')
|
||||
? activity.state
|
||||
: 'unknown';
|
||||
const decision = nextFailoverDecision({
|
||||
now: now().getTime(),
|
||||
policy,
|
||||
currentRole: role,
|
||||
primaryHealth: primary.health,
|
||||
reserveHealth: reserve.health,
|
||||
activity: activityState,
|
||||
holdUntil: Date.parse(state.failoverRuntimeState.holdUntil || '') || null,
|
||||
primaryQuarantineUntil: Date.parse(state.failoverRuntimeState.primaryQuarantineUntil || '') || null,
|
||||
memory: decisionMemory,
|
||||
});
|
||||
decisionMemory = decision.memory;
|
||||
const next = activeSnapshot(state, decision.status);
|
||||
next.currentRole = role;
|
||||
next.primary = {
|
||||
...next.primary,
|
||||
...primary,
|
||||
checkedAt,
|
||||
stateSince: previousSnapshot.primary.health === primary.health
|
||||
? previousSnapshot.primary.stateSince || checkedAt
|
||||
: checkedAt,
|
||||
};
|
||||
next.reserve = {
|
||||
...next.reserve,
|
||||
...reserve,
|
||||
checkedAt,
|
||||
stateSince: previousSnapshot.reserve.health === reserve.health
|
||||
? previousSnapshot.reserve.stateSince || checkedAt
|
||||
: checkedAt,
|
||||
};
|
||||
next.reason = decision.reason;
|
||||
next.nextDecisionAt = decision.nextDecisionAt ? new Date(decision.nextDecisionAt).toISOString() : null;
|
||||
next.trafficActivity = activityState === 'unknown' ? {
|
||||
state: 'unknown',
|
||||
observedAt: Number.isFinite(observedAt) ? new Date(observedAt).toISOString() : checkedAt,
|
||||
windowMs: 10_000,
|
||||
thresholdBytesPerSecond: policy.trafficGuard.thresholdBytesPerSecond,
|
||||
totalBytesPerSecond: 0,
|
||||
transmittingConnections: 0,
|
||||
quietSince: null,
|
||||
switchTarget: decision.switchTo,
|
||||
blockers: [],
|
||||
} : {
|
||||
state: activityState,
|
||||
observedAt: String(activity.observedAt),
|
||||
windowMs: Number(activity.windowMs) || 10_000,
|
||||
thresholdBytesPerSecond: policy.trafficGuard.thresholdBytesPerSecond,
|
||||
totalBytesPerSecond: Number(activity.totalBytesPerSecond) || 0,
|
||||
transmittingConnections: Number(activity.transmittingConnections) || 0,
|
||||
quietSince: typeof activity.quietSince === 'string' ? activity.quietSince : null,
|
||||
switchTarget: decision.switchTo,
|
||||
blockers: (Array.isArray(activity.blockers) ? activity.blockers : []).slice(0, 3) as FailoverSnapshot['trafficActivity'] extends infer T ? T extends { blockers: infer B } ? B : never : never,
|
||||
};
|
||||
publish(next);
|
||||
if (decision.status === 'waiting-for-idle' && previousSnapshot.status !== 'waiting-for-idle') {
|
||||
dependencies.onEvent?.({
|
||||
type: 'failover.waiting_for_idle',
|
||||
severity: 'info',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.waiting_for_idle:${state.revision}:${role}:${decision.reason}`,
|
||||
data: { fromRole: role, toRole: decision.switchTo || (role === 'primary' ? 'reserve' : 'primary'), reason: decision.reason },
|
||||
});
|
||||
}
|
||||
if (decision.reason === 'both-unhealthy' && previousSnapshot.reason !== 'both-unhealthy') {
|
||||
dependencies.onEvent?.({
|
||||
type: 'failover.both_unhealthy',
|
||||
severity: 'warning',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.both_unhealthy:${state.revision}`,
|
||||
data: { reason: decision.reason },
|
||||
});
|
||||
}
|
||||
if (decision.switchTo) {
|
||||
try {
|
||||
const switched = await dependencies.serialize(async () => {
|
||||
const current = dependencies.state.read();
|
||||
if (
|
||||
capturedGeneration !== generation
|
||||
|| !current.failoverPolicy.enabled
|
||||
|| current.failoverPolicy.paused
|
||||
|| currentRole(current) !== role
|
||||
|| !current.appliedFailoverPolicy
|
||||
) return false;
|
||||
let freshPrimary;
|
||||
let freshReserve;
|
||||
try {
|
||||
[freshPrimary, freshReserve] = await Promise.all([
|
||||
assessRole('primary', current.failoverPolicy),
|
||||
assessRole('reserve', current.failoverPolicy),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const healthStillAllowsSwitch = decision.switchTo === 'reserve'
|
||||
? freshPrimary.health === 'unhealthy' && freshReserve.health === 'healthy'
|
||||
: freshPrimary.health === 'healthy';
|
||||
if (!healthStillAllowsSwitch || capturedGeneration !== generation) return false;
|
||||
if (current.failoverPolicy.trafficGuard.enabled) {
|
||||
const freshResponse = record(await dependencies.dataplane.readFailoverActivity(
|
||||
current.failoverPolicy.trafficGuard.thresholdBytesPerSecond,
|
||||
));
|
||||
const freshActivity = record(freshResponse.activity);
|
||||
const freshObservedAt = typeof freshActivity.observedAt === 'string'
|
||||
? Date.parse(freshActivity.observedAt)
|
||||
: NaN;
|
||||
const freshQuietSince = typeof freshActivity.quietSince === 'string'
|
||||
? Date.parse(freshActivity.quietSince)
|
||||
: NaN;
|
||||
if (
|
||||
capturedGeneration !== generation
|
||||
|| freshActivity.state !== 'quiet'
|
||||
|| !Number.isFinite(freshObservedAt)
|
||||
|| now().getTime() - freshObservedAt > 4_000
|
||||
|| !Number.isFinite(freshQuietSince)
|
||||
|| now().getTime() - freshQuietSince < current.failoverPolicy.trafficGuard.quietWindowMs
|
||||
) return false;
|
||||
}
|
||||
await switchWithinQueue(decision.switchTo!, decision.reason);
|
||||
return true;
|
||||
});
|
||||
if (!switched) {
|
||||
const cancelled = activeSnapshot(dependencies.state.read(), 'observing');
|
||||
cancelled.reason = 'revalidation-required';
|
||||
publish(cancelled);
|
||||
decisionMemory = undefined;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||
failed.reason = dependencies.state.read().failoverRuntimeState.reasonCode === 'selector-unknown'
|
||||
? 'selector-unknown'
|
||||
: 'switch-failed';
|
||||
publish(failed);
|
||||
dependencies.onEvent?.({
|
||||
type: 'failover.switch_failed',
|
||||
severity: 'error',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.switch_failed:${state.revision}:${role}:${decision.switchTo}`,
|
||||
data: { fromRole: role, toRole: decision.switchTo, reason: decision.reason, errorCode: safeErrorCode(error) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (capturedGeneration !== generation) return;
|
||||
publish(activeSnapshot(dependencies.state.read(), decision.switchTo === 'reserve' ? 'reserve' : 'primary'));
|
||||
decisionMemory = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runRound(): Promise<void> {
|
||||
if (roundPromise) {
|
||||
const pending = roundPromise;
|
||||
if (roundGeneration === generation) return pending;
|
||||
try {
|
||||
await pending;
|
||||
} catch {
|
||||
// The original caller owns the stale round error; continue with current-generation work.
|
||||
}
|
||||
if (roundPromise && roundPromise !== pending) return roundPromise;
|
||||
return runRound();
|
||||
}
|
||||
const capturedGeneration = generation;
|
||||
let trackedPromise: Promise<void>;
|
||||
trackedPromise = performRound(capturedGeneration).finally(() => {
|
||||
if (roundPromise === trackedPromise) {
|
||||
roundPromise = null;
|
||||
roundGeneration = null;
|
||||
}
|
||||
if (capturedGeneration === generation && snapshot.reason === 'checking-channels') {
|
||||
const failed = activeSnapshot(dependencies.state.read(), 'error');
|
||||
failed.reason = 'health-unknown';
|
||||
publish(failed);
|
||||
}
|
||||
const policy = dependencies.state.read().failoverPolicy;
|
||||
if (capturedGeneration === generation && policy.enabled && dependencies.state.read().appliedFailoverPolicy) {
|
||||
const decisionAt = snapshot.nextDecisionAt ? Date.parse(snapshot.nextDecisionAt) : NaN;
|
||||
const decisionDelay = Number.isFinite(decisionAt)
|
||||
? Math.max(250, decisionAt - now().getTime())
|
||||
: policy.intervalMs;
|
||||
schedule(Math.min(policy.intervalMs, decisionDelay));
|
||||
}
|
||||
});
|
||||
roundPromise = trackedPromise;
|
||||
roundGeneration = capturedGeneration;
|
||||
return trackedPromise;
|
||||
}
|
||||
|
||||
function save(value: unknown) {
|
||||
return dependencies.serialize(async () => {
|
||||
let policy: FailoverPolicy;
|
||||
try {
|
||||
policy = normalizeFailoverPolicy(value, { strict: true });
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
if (policy.enabled && !isFailoverConfigured(policy)) throw new HarborError('REQUEST_INVALID');
|
||||
const before = dependencies.state.read();
|
||||
const candidateState = { ...before, failoverPolicy: policy };
|
||||
if (policy.enabled) {
|
||||
const candidate = dependencies.buildCandidate(candidateState);
|
||||
await dependencies.dataplane.checkConfig(candidate.config);
|
||||
}
|
||||
dependencies.state.update((state) => {
|
||||
const role = before.failoverPolicy.enabled && !policy.enabled ? currentRole(state) : null;
|
||||
const target = role ? state.appliedFailoverPolicy?.[role] : null;
|
||||
return {
|
||||
...state,
|
||||
failoverPolicy: policy,
|
||||
...(target ? {
|
||||
desiredProfileId: target.profileId,
|
||||
profiles: state.profiles.map((profile) => profile.id === target.profileId
|
||||
? { ...profile, desiredServerId: target.serverId }
|
||||
: profile),
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
decisionMemory = undefined;
|
||||
if (before.failoverPolicy.enabled !== policy.enabled) clearHealthMemory();
|
||||
if (before.failoverPolicy.enabled !== policy.enabled) {
|
||||
dependencies.onEvent?.({
|
||||
type: policy.enabled ? 'failover.enabled' : 'failover.disabled',
|
||||
severity: 'info',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.${policy.enabled ? 'enabled' : 'disabled'}:${dependencies.state.read().revision}`,
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
await reconcileAfterCommit();
|
||||
});
|
||||
}
|
||||
|
||||
function pause(paused: boolean) {
|
||||
return dependencies.serialize(async () => {
|
||||
const before = dependencies.state.read();
|
||||
if (!paused && (
|
||||
!before.appliedFailoverPolicy
|
||||
|| !targetServer(before, 'primary')
|
||||
|| !targetServer(before, 'reserve')
|
||||
)) throw new HarborError('REQUEST_INVALID');
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
failoverPolicy: { ...state.failoverPolicy, paused },
|
||||
}));
|
||||
decisionMemory = undefined;
|
||||
dependencies.onEvent?.({
|
||||
type: paused ? 'failover.paused' : 'failover.resumed',
|
||||
severity: 'info',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.${paused ? 'paused' : 'resumed'}:${dependencies.state.read().revision}`,
|
||||
data: {},
|
||||
});
|
||||
await reconcileAfterCommit();
|
||||
});
|
||||
}
|
||||
|
||||
async function manualSwitch(role: FailoverRole) {
|
||||
await dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
if (!state.failoverPolicy.enabled || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
await switchWithinQueue(role, 'manual');
|
||||
});
|
||||
await reconcileAfterCommit();
|
||||
}
|
||||
|
||||
const prepareActivation = (role: FailoverRole) => dependencies.dataplane.selectFailoverRole(role);
|
||||
|
||||
async function restoreAppliedActivation(state: StoredState) {
|
||||
if (!state.appliedFailoverPolicy) return;
|
||||
const role = currentRole(state);
|
||||
if (!role) throw new HarborError('CONFIG_INVALID');
|
||||
await prepareActivation(role);
|
||||
}
|
||||
|
||||
function checkNow() {
|
||||
return dependencies.serialize(async () => {
|
||||
const state = dependencies.state.read();
|
||||
if (!state.failoverPolicy.enabled || !state.appliedFailoverPolicy || !currentRole(state)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const capturedGeneration = ++generation;
|
||||
clearTimer();
|
||||
const checkedAt = now().toISOString();
|
||||
publish({ ...snapshot, reason: 'checking-channels' });
|
||||
let primary;
|
||||
let reserve;
|
||||
try {
|
||||
[primary, reserve] = await Promise.all([
|
||||
assessRole('primary', state.failoverPolicy),
|
||||
assessRole('reserve', state.failoverPolicy),
|
||||
]);
|
||||
} catch {
|
||||
primary = { health: 'unknown' as const, failingServiceIds: [] };
|
||||
reserve = { health: 'unknown' as const, failingServiceIds: [] };
|
||||
}
|
||||
if (capturedGeneration !== generation || !dependencies.state.read().failoverPolicy.enabled) return;
|
||||
recordHealthTransition('primary', primary.health, capturedGeneration);
|
||||
recordHealthTransition('reserve', reserve.health, capturedGeneration);
|
||||
const next = activeSnapshot(dependencies.state.read(), 'observing');
|
||||
next.primary = { ...next.primary, ...primary, checkedAt, stateSince: checkedAt };
|
||||
next.reserve = { ...next.reserve, ...reserve, checkedAt, stateSince: checkedAt };
|
||||
next.reason = 'manual-check';
|
||||
publish(next);
|
||||
schedule(state.failoverPolicy.intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: () => snapshot,
|
||||
save,
|
||||
pause,
|
||||
manualSwitch,
|
||||
checkNow,
|
||||
prepareActivation,
|
||||
restoreAppliedActivation,
|
||||
reconcile,
|
||||
runRound,
|
||||
shutdown: () => deactivate(dependencies.state.read().failoverPolicy),
|
||||
};
|
||||
}
|
||||
|
||||
export type FailoverService = ReturnType<typeof createFailoverService>;
|
||||
@@ -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<string, unknown>): 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<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
stopCommand(): Promise<RuntimeCommandResult>;
|
||||
};
|
||||
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;
|
||||
@@ -144,7 +205,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
|
||||
current = candidate;
|
||||
gatewayAutoPublished = true;
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => state);
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
...(modeChanged && wasRunning && reconfigure ? {
|
||||
appliedRouteRules: candidate.mode === 'gateway-direct' ? [] : state.routeRules,
|
||||
} : {}),
|
||||
}));
|
||||
}
|
||||
if (persistEnabled !== undefined) {
|
||||
stateCommitStarted = true;
|
||||
@@ -171,7 +237,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 +250,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 +272,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 +301,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 });
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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 {
|
||||
normalizeRouteRules,
|
||||
ROUTE_RULES_CONTRACT_VERSION,
|
||||
} from '../../../shared/routingRules.js';
|
||||
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
@@ -11,7 +19,7 @@ interface RouteRulesDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
subscription: { readConfig(): unknown | null };
|
||||
subscription: { readConfig(profileId: string): unknown | null };
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||
read(): string | null;
|
||||
@@ -24,20 +32,34 @@ interface RouteRulesDependencies {
|
||||
applyCommand(): Promise<RuntimeCommandResult>;
|
||||
restoreRunning(): Promise<unknown>;
|
||||
};
|
||||
route?: { isGatewayDirect(): boolean };
|
||||
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
||||
afterApply?: () => Promise<unknown>;
|
||||
restoreAppliedActivation?: (state: StoredState) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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 bypassed = dependencies.route?.isGatewayDirect() === true;
|
||||
const targetProfile = wasRunning
|
||||
? appliedProfile(previousState)
|
||||
: desiredProfile(previousState);
|
||||
const targetServerId = wasRunning
|
||||
? previousState.appliedServerId
|
||||
: targetProfile?.desiredServerId || '';
|
||||
const subscriptionConfig = targetProfile
|
||||
? dependencies.subscription.readConfig(targetProfile.id)
|
||||
: null;
|
||||
if (bypassed || !targetServerId || !subscriptionConfig) {
|
||||
let stateCommitStarted = false;
|
||||
try {
|
||||
stateCommitStarted = true;
|
||||
dependencies.state.update((state) => ({
|
||||
...state,
|
||||
routeRules,
|
||||
...(bypassed ? { appliedRouteRules: [] } : {}),
|
||||
routeRulesRevision: state.routeRulesRevision + 1,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -50,19 +72,21 @@ 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();
|
||||
const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
|
||||
let configMutationStarted = false;
|
||||
let runtimeMutationStarted = false;
|
||||
let stateCommitStarted = false;
|
||||
|
||||
try {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
if (wasRunning) {
|
||||
if (configChanged) {
|
||||
configMutationStarted = true;
|
||||
dependencies.config.write(candidateConfig);
|
||||
}
|
||||
if (wasRunning && configChanged) {
|
||||
const command = await dependencies.runtime.applyCommand();
|
||||
runtimeMutationStarted = command.mutationStarted;
|
||||
if (!command.ok) throw command.error;
|
||||
@@ -74,6 +98,7 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||
routeRulesRevision: state.routeRulesRevision + 1,
|
||||
}));
|
||||
await dependencies.afterApply?.();
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(configMutationStarted ? [{
|
||||
@@ -82,7 +107,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
: dependencies.config.restore(previousConfig),
|
||||
}] : []),
|
||||
...(wasRunning && runtimeMutationStarted ? [{
|
||||
run: () => dependencies.runtime.restoreRunning(),
|
||||
run: async () => {
|
||||
await dependencies.runtime.restoreRunning();
|
||||
await dependencies.restoreAppliedActivation?.(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
@@ -90,24 +118,27 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||
}
|
||||
};
|
||||
|
||||
const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => {
|
||||
const update = (
|
||||
rules: unknown,
|
||||
expectedRulesRevision: unknown,
|
||||
rulesContractVersion: unknown,
|
||||
) => {
|
||||
if (rulesContractVersion !== ROUTE_RULES_CONTRACT_VERSION) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
let routeRules: RouteRule[];
|
||||
try {
|
||||
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
|
||||
if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
|
||||
return dependencies.serialize(async () => {
|
||||
const current = dependencies.state.read();
|
||||
const currentRevision = expectedRulesRevision == null
|
||||
? current.revision
|
||||
: current.routeRulesRevision;
|
||||
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||
await dependencies.runOperation(() => applyRules(current, routeRules));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type StateSnapshot,
|
||||
type StoredState,
|
||||
} from '../../../shared/contracts/state.js';
|
||||
import type { FailoverSnapshot } from '../../../shared/failover.js';
|
||||
|
||||
interface RuntimeState {
|
||||
running?: boolean;
|
||||
@@ -26,6 +27,7 @@ interface StateServiceDependencies {
|
||||
getGatewayAutoState: () => GatewayAutoState;
|
||||
getOperationState: () => OperationState;
|
||||
configExists: () => boolean;
|
||||
getFailoverSnapshot?: () => FailoverSnapshot;
|
||||
}
|
||||
|
||||
function subscriptionHost(value: unknown) {
|
||||
@@ -51,6 +53,7 @@ export function createStateService(dependencies: StateServiceDependencies) {
|
||||
configExists,
|
||||
subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
|
||||
operation: dependencies.getOperationState(),
|
||||
failoverSnapshot: dependencies.getFailoverSnapshot?.(),
|
||||
});
|
||||
return { snapshot, storedState, gatewayAuto, configExists };
|
||||
},
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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 type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
|
||||
import { finishRollback } from '../../services/rollback.js';
|
||||
|
||||
interface ParsedSubscription {
|
||||
config: unknown;
|
||||
sourceConfig?: unknown;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
fetchedAt: string;
|
||||
@@ -29,11 +33,6 @@ interface SubscriptionServiceDependencies {
|
||||
read(): StoredState;
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||
};
|
||||
cache: {
|
||||
read(): unknown;
|
||||
write(value: unknown): void;
|
||||
remove(): void;
|
||||
};
|
||||
config: {
|
||||
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||
read(): string | null;
|
||||
@@ -57,216 +56,526 @@ interface SubscriptionServiceDependencies {
|
||||
clearInterval(handle: TimerHandle): void;
|
||||
};
|
||||
onRefreshError(error: unknown): void;
|
||||
}
|
||||
|
||||
interface ResetOptions {
|
||||
stopRuntime?: boolean;
|
||||
expectedSubscription?: {
|
||||
url: string;
|
||||
generation: number;
|
||||
onEvent?: (event: ActivityJournalEventInput) => void;
|
||||
failover?: {
|
||||
reconcile(): Promise<unknown>;
|
||||
restoreAppliedActivation(state: StoredState): Promise<unknown>;
|
||||
};
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface SubscriptionMutationResult extends Record<string, unknown> {
|
||||
export interface ProfileMutationResult extends Record<string, unknown> {
|
||||
success: true;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
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<StoredProfile, 'id' | 'label'>): ProfileMutationResult {
|
||||
return { success: true, profileId: profile.id, label: profile.label };
|
||||
}
|
||||
|
||||
function publicHost(url: string) {
|
||||
try { return new URL(url).hostname; } catch { return ''; }
|
||||
}
|
||||
|
||||
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
|
||||
const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
|
||||
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,
|
||||
}));
|
||||
dependencies.onEvent?.({
|
||||
type: 'subscription.added',
|
||||
severity: 'info',
|
||||
source: 'subscription',
|
||||
dedupeKey: `subscription.added:${dependencies.state.read().revision}`,
|
||||
data: { profileId: profile.id, profileLabel: profile.label, host: publicHost(profile.subscriptionUrl), serverCount: profile.servers.length },
|
||||
});
|
||||
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 && state.desiredProfileId === profile.id) {
|
||||
return mutationResult(profile);
|
||||
}
|
||||
const selected = profile.desiredServerId === serverId
|
||||
? profile
|
||||
: { ...profile, desiredServerId: serverId };
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, selected),
|
||||
desiredProfileId: profile.id,
|
||||
}));
|
||||
return mutationResult(selected);
|
||||
});
|
||||
|
||||
const recordRefreshError = async (
|
||||
profileId: string,
|
||||
subscriptionUrl: string,
|
||||
error: unknown,
|
||||
origin: 'manual' | 'scheduled',
|
||||
) => 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),
|
||||
}));
|
||||
dependencies.onEvent?.({
|
||||
type: 'subscription.refresh_failed',
|
||||
severity: 'warning',
|
||||
source: 'subscription',
|
||||
dedupeKey: origin === 'scheduled'
|
||||
? `subscription.refresh_failed:${failed.id}:${failed.fetchedAt || 'never'}:${failed.lastRefreshErrorCode}`
|
||||
: `subscription.refresh_failed:${dependencies.state.read().revision}`,
|
||||
data: {
|
||||
profileId: failed.id,
|
||||
profileLabel: failed.label,
|
||||
host: publicHost(failed.subscriptionUrl),
|
||||
errorCode: failed.lastRefreshErrorCode || 'UNKNOWN',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const commitRefresh = (
|
||||
profileId: string,
|
||||
subscriptionUrl: string,
|
||||
parsed: ParsedSubscription,
|
||||
{ resetSelection = false, expectedGeneration }: {
|
||||
resetSelection?: boolean;
|
||||
expectedGeneration?: number;
|
||||
} = {},
|
||||
origin: 'manual' | 'scheduled',
|
||||
) => 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 contentChanged = JSON.stringify({
|
||||
subscriptionConfig: previousProfile.subscriptionConfig,
|
||||
servers: previousProfile.servers,
|
||||
userInfo: previousProfile.userInfo,
|
||||
}) !== JSON.stringify({
|
||||
subscriptionConfig: refreshedProfile.subscriptionConfig,
|
||||
servers: refreshedProfile.servers,
|
||||
userInfo: refreshedProfile.userInfo,
|
||||
});
|
||||
const appendRefreshEvent = () => {
|
||||
if (origin === 'scheduled' && !contentChanged && !previousProfile.lastRefreshErrorCode) return;
|
||||
dependencies.onEvent?.({
|
||||
type: 'subscription.refreshed',
|
||||
severity: 'info',
|
||||
source: 'subscription',
|
||||
dedupeKey: `subscription.refreshed:${dependencies.state.read().revision}`,
|
||||
data: {
|
||||
profileId: refreshedProfile.id,
|
||||
profileLabel: refreshedProfile.label,
|
||||
host: publicHost(refreshedProfile.subscriptionUrl),
|
||||
serverCount: refreshedProfile.servers.length,
|
||||
added: refreshedProfile.servers.filter(({ id }) => !previousProfile.servers.some((server) => server.id === id)).length,
|
||||
removed: previousProfile.servers.filter(({ id }) => !refreshedProfile.servers.some((server) => server.id === id)).length,
|
||||
},
|
||||
});
|
||||
};
|
||||
const loadedTargets = previousState.appliedFailoverPolicy
|
||||
? [previousState.appliedFailoverPolicy.primary, previousState.appliedFailoverPolicy.reserve]
|
||||
: [];
|
||||
const missingLoadedTarget = loadedTargets.some((target) => (
|
||||
target.profileId === profileId
|
||||
&& !refreshedProfile.servers.some(({ id }) => id === target.serverId)
|
||||
));
|
||||
const pauseFailover = previousState.failoverPolicy?.enabled
|
||||
&& !previousState.failoverPolicy.paused
|
||||
&& missingLoadedTarget;
|
||||
const running = await dependencies.runtime.isRunning();
|
||||
const refreshesApplied = running
|
||||
&& previousState.appliedProfileId === profileId
|
||||
&& !previousState.appliedFailoverPolicy;
|
||||
const nextAppliedServerId = refreshesApplied
|
||||
? dependencies.provider.selectRefreshedServer(
|
||||
previousState.appliedServerId,
|
||||
previousProfile.servers,
|
||||
parsed.servers,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (!refreshesApplied || !nextAppliedServerId) {
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, refreshedProfile),
|
||||
...(pauseFailover ? { failoverPolicy: { ...current.failoverPolicy, paused: true } } : {}),
|
||||
}));
|
||||
if (pauseFailover) dependencies.onEvent?.({
|
||||
type: 'failover.paused',
|
||||
severity: 'warning',
|
||||
source: 'failover',
|
||||
dedupeKey: `failover.paused:missing-target:${profileId}:${dependencies.state.read().revision}`,
|
||||
data: {},
|
||||
});
|
||||
await dependencies.failover?.reconcile();
|
||||
appendRefreshEvent();
|
||||
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,
|
||||
appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct'
|
||||
? []
|
||||
: current.routeRules,
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: async () => {
|
||||
await dependencies.runtime.start();
|
||||
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
], 'Subscription refresh rollback failed');
|
||||
}
|
||||
await dependencies.failover?.reconcile();
|
||||
appendRefreshEvent();
|
||||
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,
|
||||
origin: 'manual' | 'scheduled' = 'manual',
|
||||
) => {
|
||||
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,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return commitRefresh(
|
||||
profileId,
|
||||
initialProfile.subscriptionUrl,
|
||||
parsed,
|
||||
origin,
|
||||
);
|
||||
})().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 failoverReferencesProfile = Boolean(previousState.appliedFailoverPolicy && (
|
||||
previousState.appliedFailoverPolicy.primary.profileId === profile.id
|
||||
|| previousState.appliedFailoverPolicy.reserve.profileId === profile.id
|
||||
));
|
||||
const desiredFailoverReferencesProfile = previousState.failoverPolicy?.primary.profileId === profile.id
|
||||
|| previousState.failoverPolicy?.reserve.profileId === profile.id;
|
||||
const applied = running && (previousState.appliedProfileId === profile.id || failoverReferencesProfile);
|
||||
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 || failoverReferencesProfile;
|
||||
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,
|
||||
...(removesAppliedTarget ? {
|
||||
connectionDesired: 'stopped',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
appliedFailoverPolicy: null,
|
||||
} : {}),
|
||||
...(failoverReferencesProfile || desiredFailoverReferencesProfile ? {
|
||||
failoverPolicy: {
|
||||
...current.failoverPolicy,
|
||||
enabled: false,
|
||||
paused: false,
|
||||
primary: current.failoverPolicy.primary.profileId === profile.id
|
||||
? { profileId: '', serverId: '' }
|
||||
: current.failoverPolicy.primary,
|
||||
reserve: current.failoverPolicy.reserve.profileId === profile.id
|
||||
? { profileId: '', serverId: '' }
|
||||
: current.failoverPolicy.reserve,
|
||||
},
|
||||
} : {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
await finishRollback(error, [
|
||||
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||
...(gatewayMutationStarted ? [{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }] : []),
|
||||
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||
...(runtimeMutationStarted ? [{
|
||||
run: async () => {
|
||||
await dependencies.runtime.start();
|
||||
await dependencies.failover?.restoreAppliedActivation(previousState);
|
||||
},
|
||||
runtime: true,
|
||||
}] : []),
|
||||
], 'Subscription delete rollback failed');
|
||||
}
|
||||
await dependencies.failover?.reconcile();
|
||||
dependencies.onEvent?.({
|
||||
type: 'subscription.deleted',
|
||||
severity: 'info',
|
||||
source: 'subscription',
|
||||
dedupeKey: `subscription.deleted:${dependencies.state.read().revision}`,
|
||||
data: { profileId: profile.id, profileLabel: profile.label },
|
||||
});
|
||||
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, undefined, 'scheduled');
|
||||
} catch (error) {
|
||||
dependencies.onRefreshError(error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, intervalMs);
|
||||
refreshTimer.unref();
|
||||
};
|
||||
@@ -278,6 +587,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
|
||||
};
|
||||
|
||||
return {
|
||||
preflightAddProfile,
|
||||
preflightRenameProfile,
|
||||
addProfile,
|
||||
renameProfile,
|
||||
selectProfileServer,
|
||||
refreshProfile,
|
||||
deleteProfile,
|
||||
importSubscription,
|
||||
refreshSavedSubscription,
|
||||
resetSavedSubscription,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface MaterializeOptions {
|
||||
apiPort: number;
|
||||
secretPath: string;
|
||||
runtimeConfigPath: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function privateWrite(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
||||
let descriptor: number | null = null;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, JSON.stringify(value));
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
const status = fs.lstatSync(filePath);
|
||||
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
|
||||
throw new Error('private runtime config is not a regular 0600 file');
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== null) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openSecret(secretPath: string) {
|
||||
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
|
||||
try {
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
descriptor: fs.openSync(
|
||||
secretPath,
|
||||
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||
0o600,
|
||||
),
|
||||
created: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureGatewayNativeApiSecret(secretPath: string) {
|
||||
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||
const { descriptor, created } = openSecret(secretPath);
|
||||
try {
|
||||
const secret = created
|
||||
? crypto.randomBytes(32).toString('hex')
|
||||
: fs.readFileSync(descriptor, 'utf8');
|
||||
if (created) {
|
||||
fs.writeFileSync(descriptor, secret);
|
||||
fs.fsyncSync(descriptor);
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(secret)) {
|
||||
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
|
||||
}
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
const linked = fs.lstatSync(secretPath);
|
||||
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|
||||
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|
||||
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
|
||||
throw new Error('native API secret is not a regular 0600 file');
|
||||
}
|
||||
return secret;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function withoutApiServices(config: unknown) {
|
||||
const safe = structuredClone(record(config));
|
||||
const services = Array.isArray(safe.services)
|
||||
? safe.services.filter((service) => record(service).type !== 'api')
|
||||
: [];
|
||||
if (services.length) safe.services = services;
|
||||
else delete safe.services;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
|
||||
privateWrite(runtimeConfigPath, withoutApiServices(config));
|
||||
return { configPath: runtimeConfigPath, secret: null, warning: null };
|
||||
}
|
||||
|
||||
function withAuthenticatedApi(config: unknown, secret: string) {
|
||||
const materialized = structuredClone(record(config));
|
||||
const services = Array.isArray(materialized.services) ? materialized.services : [];
|
||||
materialized.services = services.map((service) => (
|
||||
record(service).type === 'api'
|
||||
? { ...record(service), secret }
|
||||
: service
|
||||
));
|
||||
return materialized;
|
||||
}
|
||||
|
||||
function validateApiService(config: unknown, apiPort: number) {
|
||||
const configuredServices = record(config).services;
|
||||
const services = Array.isArray(configuredServices) ? configuredServices : [];
|
||||
const apiServices = services.map(record).filter(({ type }) => type === 'api');
|
||||
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
|
||||
const [service] = apiServices;
|
||||
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|
||||
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
|
||||
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeGatewayNativeConfig(
|
||||
config: unknown,
|
||||
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
|
||||
) {
|
||||
let warning: string | null = null;
|
||||
try {
|
||||
validateApiService(config, apiPort);
|
||||
const secret = ensureGatewayNativeApiSecret(secretPath);
|
||||
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
|
||||
return { configPath: runtimeConfigPath, secret, warning };
|
||||
} catch (error) {
|
||||
warning = `Native traffic API disabled: ${message(error)}`;
|
||||
}
|
||||
|
||||
const safeConfig = withoutApiServices(config);
|
||||
try {
|
||||
privateWrite(runtimeConfigPath, safeConfig);
|
||||
return { configPath: runtimeConfigPath, secret: null, warning };
|
||||
} catch (error) {
|
||||
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
|
||||
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
|
||||
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
|
||||
privateWrite(fallbackPath, safeConfig);
|
||||
return { configPath: fallbackPath, secret: null, warning };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const options = { encoding: 'utf8' as const };
|
||||
const CHAIN_PATTERN = /^[a-z0-9_-]{1,28}$/i;
|
||||
|
||||
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) {
|
||||
if (!CHAIN_PATTERN.test(chain)) throw new Error('Некорректная TProxy chain');
|
||||
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
|
||||
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
|
||||
|
||||
@@ -10,7 +12,15 @@ export function setGatewayInterception(enabled: boolean, chain: string, run: typ
|
||||
if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
|
||||
return;
|
||||
}
|
||||
if (exists) return;
|
||||
if (exists) {
|
||||
const input = `*mangle\n-D PREROUTING -j ${chain}\n-I PREROUTING 1 -j ${chain}\nCOMMIT\n`;
|
||||
const result = run('iptables-restore', ['-w', '--noflush'], { ...options, input });
|
||||
if (result.status !== 0) {
|
||||
// The transaction keeps the already-working jump intact; leave routing up.
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = run(
|
||||
'iptables',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { ActivityJournalService } from '../../services/activityJournalService.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
export function createActivityJournalRoute({ journal }: { journal: ActivityJournalService }) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
if (url.pathname !== '/api/activity-journal' || req.method !== 'GET') return false;
|
||||
sendJson(res, 200, journal.page(
|
||||
Number(url.searchParams.get('limit')) || 50,
|
||||
url.searchParams.get('cursor'),
|
||||
));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface ConnectivityDiagnosticsRouteDependencies {
|
||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
|
||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
sendState(res: ServerResponse): Promise<void>;
|
||||
}
|
||||
|
||||
export function createConnectivityDiagnosticsRoute(
|
||||
@@ -13,11 +14,19 @@ export function createConnectivityDiagnosticsRoute(
|
||||
) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
|
||||
const { services = [], target = null } = await dependencies.readBody(req);
|
||||
const result = await dependencies.diagnostics.run(services, target);
|
||||
sendJson(res, 200, result);
|
||||
return true;
|
||||
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||
const { target = null } = await dependencies.readBody(req);
|
||||
const result = await dependencies.diagnostics.run(target);
|
||||
sendJson(res, 200, result);
|
||||
return true;
|
||||
}
|
||||
if (req.url === '/api/diagnostics/settings' && req.method === 'PUT') {
|
||||
const { settings, expectedRevision } = await dependencies.readBody(req);
|
||||
dependencies.diagnostics.updateSettings(settings, expectedRevision);
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ interface DeviceInventoryPort {
|
||||
snapshot(): unknown;
|
||||
refresh(): Promise<unknown>;
|
||||
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||
createTag(name: unknown, expectedRevision: unknown): unknown;
|
||||
renameTag(tagId: string, name: unknown, expectedRevision: unknown): unknown;
|
||||
deleteTag(tagId: string, expectedRevision: unknown): unknown;
|
||||
resetTraffic(expectedRevision: unknown): Promise<unknown>;
|
||||
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -17,6 +21,7 @@ interface DeviceInventoryRouteDependencies {
|
||||
|
||||
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||
const DEVICE_TAG_PATH = /^\/api\/device-tags\/(tag_[a-f0-9]{16})$/;
|
||||
|
||||
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||
return {
|
||||
@@ -39,6 +44,44 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/devices/traffic') {
|
||||
if (!dependencies.deviceInventory || req.method !== 'DELETE') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const body = await dependencies.readBody(req);
|
||||
sendJson(res, 200, await dependencies.deviceInventory.resetTraffic(body.expectedRevision));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/api/device-tags') {
|
||||
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const body = await dependencies.readBody(req);
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
dependencies.deviceInventory.createTag(body.name, body.expectedRevision),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const tagMatch = pathname.match(DEVICE_TAG_PATH);
|
||||
if (tagMatch) {
|
||||
if (!dependencies.deviceInventory || !['PUT', 'DELETE'].includes(req.method || '')) {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const body = await dependencies.readBody(req);
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
req.method === 'PUT'
|
||||
? dependencies.deviceInventory.renameTag(tagMatch[1], body.name, body.expectedRevision)
|
||||
: dependencies.deviceInventory.deleteTag(tagMatch[1], body.expectedRevision),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||
if (deviceMatch) {
|
||||
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { FailoverService } from '../../features/failover/failoverService.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
|
||||
interface FailoverRouteDependencies {
|
||||
appMode: string;
|
||||
failover: Pick<FailoverService, 'save' | 'pause' | 'manualSwitch' | 'checkNow'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>, options?: { expectedRevision?: unknown }): Promise<T>;
|
||||
sendState(res: ServerResponse): Promise<void>;
|
||||
}
|
||||
|
||||
export function createFailoverRoute(dependencies: FailoverRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (!pathname.startsWith('/api/failover')) return false;
|
||||
if (dependencies.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const body = await dependencies.readBody(req);
|
||||
if (pathname === '/api/failover' && req.method === 'PUT') {
|
||||
await dependencies.withOperation(
|
||||
'failover-save',
|
||||
() => dependencies.failover.save(body.policy),
|
||||
{ expectedRevision: body.expectedRevision },
|
||||
);
|
||||
} else if (pathname === '/api/failover/pause' && req.method === 'POST') {
|
||||
if (typeof body.paused !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||
await dependencies.withOperation(
|
||||
body.paused ? 'failover-pause' : 'failover-resume',
|
||||
() => dependencies.failover.pause(body.paused as boolean),
|
||||
{ expectedRevision: body.expectedRevision },
|
||||
);
|
||||
} else if (pathname === '/api/failover/switch' && req.method === 'POST') {
|
||||
if (body.role !== 'primary' && body.role !== 'reserve') throw new HarborError('REQUEST_INVALID');
|
||||
await dependencies.withOperation(
|
||||
'failover-switch',
|
||||
() => dependencies.failover.manualSwitch(body.role as 'primary' | 'reserve'),
|
||||
{ expectedRevision: body.expectedRevision },
|
||||
);
|
||||
} else if (pathname === '/api/failover/check' && req.method === 'POST') {
|
||||
await dependencies.failover.checkNow();
|
||||
} else {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface LiveTrafficReader {
|
||||
snapshot(): unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DeviceInventoryReader {
|
||||
snapshot(): unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function enrichLiveTrafficDeviceLabels(
|
||||
snapshot: LiveTrafficSnapshot,
|
||||
inventory: unknown,
|
||||
): LiveTrafficSnapshot {
|
||||
const devices = Array.isArray(record(inventory).devices)
|
||||
? (record(inventory).devices as unknown[]).map(record)
|
||||
: [];
|
||||
const labels = new Map(devices.flatMap((device) => {
|
||||
const id = String(device.id || '');
|
||||
if (!/^dev_[a-f0-9]{16}$/.test(id)) return [];
|
||||
const label = [device.alias, device.hostname, device.ip]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
return label ? [[id, String(label).trim()] as const] : [];
|
||||
}));
|
||||
if (!labels.size) return snapshot;
|
||||
return {
|
||||
...snapshot,
|
||||
connections: snapshot.connections.map((connection) => {
|
||||
const label = connection.origin.kind === 'device' && connection.origin.id
|
||||
? labels.get(connection.origin.id)
|
||||
: null;
|
||||
return label ? {
|
||||
...connection,
|
||||
origin: { ...connection.origin, label },
|
||||
} : connection;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveTrafficRoute({
|
||||
traffic,
|
||||
deviceInventory = null,
|
||||
}: {
|
||||
traffic: LiveTrafficReader | null;
|
||||
deviceInventory?: DeviceInventoryReader | null;
|
||||
}) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (pathname !== '/api/traffic/live') return false;
|
||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||
const enriched = deviceInventory
|
||||
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||
: snapshot;
|
||||
sendJson(res, 200, enriched);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,9 +11,9 @@ interface RouteRulesRouteDependencies {
|
||||
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false;
|
||||
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req);
|
||||
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision);
|
||||
if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false;
|
||||
const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req);
|
||||
await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion);
|
||||
await dependencies.sendState(res);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -5,18 +5,33 @@ import type { ConnectionService } from '../../features/connection/index.js';
|
||||
interface ServerApplyRouteDependencies {
|
||||
connection: Pick<ConnectionService, 'apply'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
|
||||
withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
options?: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown },
|
||||
): Promise<T>;
|
||||
sendState(res: ServerResponse, extra: { profileId: string; serverId: string; selectedTag: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
|
||||
const {
|
||||
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;
|
||||
|
||||
@@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js';
|
||||
interface ServerHealthRouteDependencies {
|
||||
serverHealth: ServerHealthService;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
|
||||
sendState(res: ServerResponse, extra: {
|
||||
profileId: string;
|
||||
results: Array<Record<string, unknown>>;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
|
||||
const 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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ConnectionService, 'activate'>;
|
||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||
withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
options?: OperationOptions,
|
||||
): Promise<T>;
|
||||
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const 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;
|
||||
|
||||
+533
-73
@@ -16,7 +16,10 @@ import {
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { tcpPing } from './ping.js';
|
||||
import {
|
||||
buildDualChannelGatewayConfig,
|
||||
buildGatewayConfig,
|
||||
dualChannelConfigMatchesApplied,
|
||||
fingerprintSelectedOutbound,
|
||||
removeSingboxConfig,
|
||||
restoreSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
@@ -28,13 +31,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,
|
||||
@@ -75,6 +84,14 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi
|
||||
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
||||
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
|
||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||
import { createFailoverService } from './features/failover/failoverService.js';
|
||||
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
||||
import { createActivityJournalService } from './services/activityJournalService.js';
|
||||
import { createActivityJournalRoute } from './http/routes/activityJournalRoute.js';
|
||||
import { createDomainTrafficService, readSingboxConnections } from './services/domainTrafficService.js';
|
||||
import type { ActivityJournalEventInput } from '../shared/activityJournal.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
@@ -93,11 +110,57 @@ 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<string, unknown> = {};
|
||||
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 activityJournal = createActivityJournalService({ filePath: settings.activityJournalPath });
|
||||
const appendJournal = (event: ActivityJournalEventInput) => {
|
||||
try {
|
||||
activityJournal.append(event);
|
||||
} catch (error) {
|
||||
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
|
||||
}
|
||||
};
|
||||
const deviceStore = createJsonStore<InventoryState>({
|
||||
filePath: settings.deviceStatePath,
|
||||
defaultValue: migrateDeviceInventoryState({}),
|
||||
@@ -112,32 +175,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);
|
||||
@@ -153,6 +261,16 @@ function selectRuntime() {
|
||||
throw new Error('Harbor runtime is not configured');
|
||||
}
|
||||
const singboxRuntime = selectRuntime();
|
||||
const clientLiveTraffic = settings.appMode === 'client'
|
||||
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: settings.singboxTrafficSource === 'native',
|
||||
isRuntimeRunning: () => Boolean(localRuntime?.running),
|
||||
})
|
||||
: null;
|
||||
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
|
||||
snapshot: () => requireRemoteRuntime().observeLiveTraffic(),
|
||||
} : null);
|
||||
|
||||
function requireRemoteRuntime() {
|
||||
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
||||
@@ -194,6 +312,37 @@ const deviceInventory = settings.appMode === 'gateway'
|
||||
const localConnectivityDiagnostics = !remoteDataplane
|
||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||
: null;
|
||||
const localFailoverDiagnostics = !remoteDataplane ? {
|
||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||
} : null;
|
||||
const localSelector = !remoteDataplane && settings.appMode === 'gateway'
|
||||
? createSingboxSelectorService({ port: settings.singboxApiPort })
|
||||
: null;
|
||||
const localFailoverTraffic = !remoteDataplane && settings.appMode === 'gateway'
|
||||
? createDomainTrafficService({
|
||||
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||
devices: () => record(deviceInventory?.snapshot()).devices,
|
||||
})
|
||||
: null;
|
||||
let localFailoverTrafficTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function setLocalFailoverActivityEnabled(enabled: boolean) {
|
||||
if (!localFailoverTraffic) throw new Error('Failover activity недоступна');
|
||||
if (!enabled) {
|
||||
if (localFailoverTrafficTimer) clearInterval(localFailoverTrafficTimer);
|
||||
localFailoverTrafficTimer = null;
|
||||
localFailoverTraffic.disableActivity();
|
||||
return;
|
||||
}
|
||||
localFailoverTraffic.enableActivity();
|
||||
if (localFailoverTrafficTimer) return;
|
||||
const refresh = () => localFailoverTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[control] failover activity: ${errorMessage(error)}`));
|
||||
void refresh();
|
||||
localFailoverTrafficTimer = setInterval(refresh, 2_000);
|
||||
localFailoverTrafficTimer.unref();
|
||||
}
|
||||
|
||||
function requireLocalConnectivityDiagnostics() {
|
||||
if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured');
|
||||
@@ -215,7 +364,7 @@ const gatewayAutoService = createGatewayAutoService({
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
readConfig: (profileId) => readProfileConfig(profileId),
|
||||
},
|
||||
config: {
|
||||
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
|
||||
@@ -238,6 +387,7 @@ const gatewayAutoService = createGatewayAutoService({
|
||||
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||
),
|
||||
restoreRunning: () => startSingbox(),
|
||||
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
|
||||
},
|
||||
discovery: {
|
||||
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
|
||||
@@ -265,6 +415,48 @@ const gatewayAutoService = createGatewayAutoService({
|
||||
onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`),
|
||||
onTimerError: (error) => console.warn(`[control] Gateway detection failed: ${errorMessage(error)}`),
|
||||
});
|
||||
const failoverDataplane = remoteDataplane ? {
|
||||
checkConfig: (config: unknown) => requireRemoteRuntime().checkConfig(config),
|
||||
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||
requireRemoteRuntime().runFailoverProbe(role, services, target, timeoutMs)
|
||||
),
|
||||
readFailoverSelector: () => requireRemoteRuntime().readFailoverSelector(),
|
||||
selectFailoverRole: (role: 'primary' | 'reserve') => requireRemoteRuntime().selectFailoverRole(role),
|
||||
setFailoverActivityEnabled: (enabled: boolean) => requireRemoteRuntime().setFailoverActivityEnabled(enabled),
|
||||
readFailoverActivity: (threshold: number) => requireRemoteRuntime().readFailoverActivity(threshold),
|
||||
} : {
|
||||
checkConfig: async (config: unknown) => singboxRuntime.checkConfig(config),
|
||||
runFailoverProbe: async (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => {
|
||||
if (!localFailoverDiagnostics) throw new Error('Failover diagnostics недоступна');
|
||||
return localFailoverDiagnostics[role].runVpn({ services, target, timeoutMs });
|
||||
},
|
||||
readFailoverSelector: async () => {
|
||||
if (!localSelector) throw new Error('Failover selector недоступен');
|
||||
return localSelector.read();
|
||||
},
|
||||
selectFailoverRole: async (role: 'primary' | 'reserve') => {
|
||||
if (!localSelector) throw new Error('Failover selector недоступен');
|
||||
return localSelector.select(role);
|
||||
},
|
||||
setFailoverActivityEnabled: async (enabled: boolean) => setLocalFailoverActivityEnabled(enabled),
|
||||
readFailoverActivity: async (threshold: number) => ({
|
||||
activity: localFailoverTraffic?.activitySnapshot(threshold) || null,
|
||||
}),
|
||||
};
|
||||
const failoverService = createFailoverService({
|
||||
state: {
|
||||
read: () => normalizeStoredState(stateStore.read()),
|
||||
update: updateStoredState,
|
||||
},
|
||||
runtime: { isRunning: async () => Boolean((await singboxRuntime.refresh()).running) },
|
||||
dataplane: failoverDataplane,
|
||||
buildCandidate: buildFailoverCandidate,
|
||||
serialize: serializeControl,
|
||||
onWarning: (error) => console.warn(`[control] failover: ${errorMessage(error)}`),
|
||||
onSwitch: (from, to, reason) => console.log(`[control] failover ${from} -> ${to}: ${reason}`),
|
||||
onEvent: appendJournal,
|
||||
});
|
||||
const gatewayFailover = settings.appMode === 'gateway' ? failoverService : null;
|
||||
const stateService = createStateService({
|
||||
appMode: settings.appMode,
|
||||
readStoredState: () => stateStore.read(),
|
||||
@@ -272,6 +464,7 @@ const stateService = createStateService({
|
||||
getGatewayAutoState: gatewayAutoService.read,
|
||||
getOperationState: () => operationState,
|
||||
configExists: () => fs.existsSync(settings.configPath),
|
||||
getFailoverSnapshot: failoverService.snapshot,
|
||||
});
|
||||
const stateRoute = createStateRoute({
|
||||
stateService,
|
||||
@@ -285,13 +478,24 @@ const gatewayAutoRoute = createGatewayAutoRoute({
|
||||
withOperation,
|
||||
readStatePayload: stateRoute.readPayload,
|
||||
});
|
||||
const failoverRoute = createFailoverRoute({
|
||||
appMode: settings.appMode,
|
||||
failover: failoverService,
|
||||
readBody,
|
||||
withOperation,
|
||||
sendState: (res) => stateRoute.send(res),
|
||||
});
|
||||
const activityJournalRoute = createActivityJournalRoute({ journal: activityJournal });
|
||||
const deviceInventoryRoute = createDeviceInventoryRoute({
|
||||
deviceInventory,
|
||||
readBody,
|
||||
});
|
||||
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => stateStore.read(),
|
||||
state: {
|
||||
read: () => normalizeStoredState(stateStore.read()),
|
||||
update: updateStoredState,
|
||||
},
|
||||
runDiagnostics: async (services, target) => remoteDataplane
|
||||
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
||||
: requireLocalConnectivityDiagnostics().run({
|
||||
@@ -303,10 +507,11 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||
diagnostics: connectivityDiagnostics,
|
||||
readBody,
|
||||
sendState: (res) => stateRoute.send(res),
|
||||
});
|
||||
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||
appMode: settings.appMode,
|
||||
readState: () => stateStore.read(),
|
||||
readState: () => normalizeStoredState(stateStore.read()),
|
||||
getHwid,
|
||||
});
|
||||
const sharedProxyRoute = createSharedProxyRoute({
|
||||
@@ -321,6 +526,10 @@ const versionRoute = createVersionRoute({
|
||||
? () => requireRemoteRuntime().refresh()
|
||||
: null,
|
||||
});
|
||||
const liveTrafficRoute = createLiveTrafficRoute({
|
||||
traffic: liveTraffic,
|
||||
deviceInventory: remoteDataplane ? deviceInventory : null,
|
||||
});
|
||||
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||
readBody,
|
||||
@@ -332,15 +541,13 @@ 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)
|
||||
),
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
return state.appliedFailoverPolicy
|
||||
? buildFailoverCandidate({ ...state, routeRules }, 'applied').config
|
||||
: buildActiveConfig(subscriptionConfig, selectedServerId, routeRules);
|
||||
},
|
||||
read: () => fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null,
|
||||
@@ -364,16 +571,18 @@ 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),
|
||||
onEvent: appendJournal,
|
||||
failover: gatewayFailover ? {
|
||||
reconcile: () => gatewayFailover.reconcile()
|
||||
.catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)),
|
||||
restoreAppliedActivation: gatewayFailover.restoreAppliedActivation,
|
||||
} : undefined,
|
||||
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 +593,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 +604,18 @@ const connectionService = createConnectionService({
|
||||
restore: restoreSingboxConfig,
|
||||
remove: removeSingboxConfig,
|
||||
},
|
||||
route: {
|
||||
isGatewayDirect: () => settings.appMode === 'client'
|
||||
&& gatewayAutoService.read().mode === 'gateway-direct',
|
||||
},
|
||||
failover: gatewayFailover ? {
|
||||
build: buildFailoverCandidate,
|
||||
prepareActivation: gatewayFailover.prepareActivation,
|
||||
restoreAppliedActivation: gatewayFailover.restoreAppliedActivation,
|
||||
reconcile: () => gatewayFailover.reconcile()
|
||||
.catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)),
|
||||
} : undefined,
|
||||
onEvent: appendJournal,
|
||||
runtime: {
|
||||
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||
start: () => startSingbox(),
|
||||
@@ -412,6 +629,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,12 +653,15 @@ const routeRulesService = createRouteRulesService({
|
||||
update: updateStoredState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => readSubscriptionCache()?.config || null,
|
||||
readConfig: (profileId) => readProfileConfig(profileId),
|
||||
},
|
||||
config: {
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||
),
|
||||
build: (subscriptionConfig, selectedServerId, routeRules) => {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
return state.appliedFailoverPolicy
|
||||
? buildFailoverCandidate({ ...state, routeRules }, 'applied').config
|
||||
: buildActiveConfig(subscriptionConfig, selectedServerId, routeRules);
|
||||
},
|
||||
read: () => fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null,
|
||||
@@ -450,8 +677,15 @@ const routeRulesService = createRouteRulesService({
|
||||
),
|
||||
restoreRunning: () => startSingbox(),
|
||||
},
|
||||
route: {
|
||||
isGatewayDirect: () => settings.appMode === 'client'
|
||||
&& gatewayAutoService.read().mode === 'gateway-direct',
|
||||
},
|
||||
serialize: serializeControl,
|
||||
runOperation: (operation) => withOperation('route-rules', operation),
|
||||
afterApply: gatewayFailover ? () => gatewayFailover.reconcile()
|
||||
.catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)) : undefined,
|
||||
restoreAppliedActivation: gatewayFailover?.restoreAppliedActivation,
|
||||
});
|
||||
const routeRulesRoute = createRouteRulesRoute({
|
||||
routeRules: routeRulesService,
|
||||
@@ -460,27 +694,64 @@ const routeRulesRoute = createRouteRulesRoute({
|
||||
});
|
||||
|
||||
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
|
||||
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<T>(kind: string, operation: () => Promise<T>): Promise<T> {
|
||||
async function withOperation<T>(
|
||||
kind: string,
|
||||
operation: (operationRevision: number) => Promise<T>,
|
||||
{
|
||||
expectedRevision,
|
||||
profileId = null,
|
||||
serverId = null,
|
||||
}: { expectedRevision?: unknown; profileId?: unknown; serverId?: unknown } = {},
|
||||
): Promise<T> {
|
||||
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,21 +807,165 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
function buildFailoverCandidate(state: StoredState, source: 'desired' | 'applied' = 'desired') {
|
||||
const policy = source === 'applied' ? state.appliedFailoverPolicy : state.failoverPolicy;
|
||||
if (!policy) throw new HarborError('CONFIG_INVALID');
|
||||
if (
|
||||
policy.primary.profileId === policy.reserve.profileId
|
||||
&& policy.primary.serverId === policy.reserve.serverId
|
||||
) throw new HarborError('REQUEST_INVALID');
|
||||
const channel = (role: 'primary' | 'reserve') => {
|
||||
const target = policy[role];
|
||||
const profile = state.profiles.find(({ id }) => id === target.profileId);
|
||||
const server = profile?.servers.find(({ id }) => id === target.serverId);
|
||||
if (!profile || !server || !profile.subscriptionConfig) throw new HarborError('SERVER_NOT_FOUND');
|
||||
return { profile, server };
|
||||
};
|
||||
const primary = channel('primary');
|
||||
const reserve = channel('reserve');
|
||||
const applied = {
|
||||
primary: policy.primary,
|
||||
reserve: policy.reserve,
|
||||
primaryConfigFingerprint: fingerprintSelectedOutbound(primary.profile.subscriptionConfig, primary.server.id),
|
||||
reserveConfigFingerprint: fingerprintSelectedOutbound(reserve.profile.subscriptionConfig, reserve.server.id),
|
||||
};
|
||||
if (source === 'applied' && JSON.stringify(applied) !== JSON.stringify(state.appliedFailoverPolicy)) {
|
||||
throw new HarborError('CONFIG_INVALID');
|
||||
}
|
||||
const defaultRole = source === 'applied'
|
||||
&& state.appliedProfileId === policy.reserve.profileId
|
||||
&& state.appliedServerId === policy.reserve.serverId
|
||||
? 'reserve'
|
||||
: 'primary';
|
||||
return {
|
||||
config: buildDualChannelGatewayConfig({
|
||||
primary: { subscriptionConfig: primary.profile.subscriptionConfig, selectedServerId: primary.server.id },
|
||||
reserve: { subscriptionConfig: reserve.profile.subscriptionConfig, selectedServerId: reserve.server.id },
|
||||
}, { routeRules: state.routeRules, defaultRole }),
|
||||
applied,
|
||||
primaryProfile: primary.profile,
|
||||
primaryServer: primary.server,
|
||||
};
|
||||
}
|
||||
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
function writeCurrentConfig() {
|
||||
const state = stateStore.read();
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedServerId || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||
return true;
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
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;
|
||||
let activeConfig: unknown;
|
||||
if (state.appliedFailoverPolicy) {
|
||||
const candidate = buildFailoverCandidate(state, 'applied');
|
||||
activeConfig = candidate.config;
|
||||
} else {
|
||||
activeConfig = buildActiveConfig(subscriptionConfig, server.id);
|
||||
}
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
try {
|
||||
writeSingboxConfig(activeConfig);
|
||||
} catch (error) {
|
||||
try {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([error, rollbackError], 'Current config rollback failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { profile, server, failoverApplied: state.appliedFailoverPolicy };
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
try {
|
||||
config = record(JSON.parse(fs.readFileSync(settings.configPath, 'utf8')));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (settings.appMode === 'client' || settings.appMode === 'gateway') {
|
||||
const apiServices = (Array.isArray(config.services) ? config.services : [])
|
||||
.map(record)
|
||||
.filter(({ type }) => type === 'api');
|
||||
const nativeApiMatches = apiServices.length === 1
|
||||
&& apiServices[0].listen === '127.0.0.1'
|
||||
&& apiServices[0].listen_port === settings.singboxNativeApiPort
|
||||
&& apiServices[0].dashboard === false
|
||||
&& !Object.hasOwn(apiServices[0], 'secret');
|
||||
const nativeApiExpected = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
if (nativeApiExpected ? !nativeApiMatches : apiServices.length > 0) return false;
|
||||
}
|
||||
if (state.appliedFailoverPolicy) {
|
||||
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
|
||||
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId
|
||||
? 'reserve'
|
||||
: 'primary';
|
||||
return dualChannelConfigMatchesApplied(config, state.appliedFailoverPolicy, expectedRole);
|
||||
}
|
||||
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
|
||||
|| state.appliedFailoverPolicy
|
||||
) {
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
connectionDesired: 'stopped',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
appliedFailoverPolicy: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||
@@ -562,10 +977,14 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||
if (await connectionRuntimeRoute.handle(req, res)) return;
|
||||
if (await routeRulesRoute.handle(req, res)) return;
|
||||
if (await gatewayAutoRoute.handle(req, res)) return;
|
||||
if (await failoverRoute.handle(req, res)) return;
|
||||
if (await activityJournalRoute.handle(req, res)) return;
|
||||
if (await connectivityDiagnosticsRoute.handle(req, res)) return;
|
||||
|
||||
if (await versionRoute.handle(req, res)) return;
|
||||
|
||||
if (await liveTrafficRoute.handle(req, res)) return;
|
||||
|
||||
if (await sharedProxyRoute.handle(req, res)) return;
|
||||
|
||||
if (await deviceInventoryRoute.handle(req, res)) return;
|
||||
@@ -614,6 +1033,8 @@ async function shutdown() {
|
||||
subscriptionService.stopAutoRefresh();
|
||||
gatewayAutoService.stopDiscovery();
|
||||
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
||||
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
|
||||
await serializeControl(() => singboxRuntime.shutdown());
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -623,29 +1044,68 @@ process.on('SIGINT', shutdown);
|
||||
|
||||
await gatewayAutoService.refresh({ reconfigure: false })
|
||||
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
const bootState = normalizeStoredState(stateStore.read());
|
||||
const bootWantsRunning = bootState.connectionDesired === 'running'
|
||||
|| (bootState.connectionDesired === undefined && fs.existsSync(settings.configPath));
|
||||
if (bootWantsRunning) {
|
||||
let target: ReturnType<typeof writeCurrentConfig> = null;
|
||||
try {
|
||||
writeCurrentConfig();
|
||||
target = writeCurrentConfig();
|
||||
} catch (error) {
|
||||
const candidate = record(error);
|
||||
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||
console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`);
|
||||
}
|
||||
const canReuseCurrentConfig = target === null
|
||||
&& fs.existsSync(settings.configPath)
|
||||
&& currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read()));
|
||||
if (target || canReuseCurrentConfig) {
|
||||
await startSingbox()
|
||||
.then(async () => {
|
||||
const current = normalizeStoredState(stateStore.read());
|
||||
const bootRole = current.appliedFailoverPolicy
|
||||
&& current.appliedProfileId === current.appliedFailoverPolicy.reserve.profileId
|
||||
&& current.appliedServerId === current.appliedFailoverPolicy.reserve.serverId
|
||||
? 'reserve'
|
||||
: current.appliedFailoverPolicy ? 'primary' : null;
|
||||
if (bootRole) await failoverDataplane.selectFailoverRole(bootRole);
|
||||
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,
|
||||
...(settings.appMode === 'client'
|
||||
&& gatewayAutoService.read().mode === 'gateway-direct'
|
||||
? { appliedRouteRules: [] }
|
||||
: target ? { appliedRouteRules: state.routeRules } : {}),
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`);
|
||||
await reconcileStoppedBoot();
|
||||
});
|
||||
} 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)}`));
|
||||
|
||||
await gatewayFailover?.reconcile()
|
||||
.catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`));
|
||||
|
||||
if (deviceInventory) {
|
||||
await deviceInventory.reconcilePolicies()
|
||||
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
||||
}
|
||||
|
||||
clientLiveTraffic?.start();
|
||||
|
||||
server.listen(settings.port, '0.0.0.0', () => {
|
||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
const COUNTER_PATTERN = /^\d+$/;
|
||||
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
||||
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
||||
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
||||
const COLLECTOR_STATES = new Set([
|
||||
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||
]);
|
||||
|
||||
const labelValue = (value: unknown) => String(value ?? '')
|
||||
.replaceAll('\\', '\\\\')
|
||||
@@ -23,6 +29,19 @@ function counter(value: unknown) {
|
||||
return decimal;
|
||||
}
|
||||
|
||||
function signedGauge(value: unknown) {
|
||||
const decimal = String(value ?? '');
|
||||
if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`);
|
||||
return decimal;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, { signed = false } = {}) {
|
||||
if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) {
|
||||
throw new Error(`Invalid Prometheus gauge: ${String(value)}`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown) {
|
||||
const milliseconds = Date.parse(String(value ?? ''));
|
||||
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||
@@ -114,7 +133,142 @@ export function renderPrometheusMetrics(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const directTraffic = record(snapshot.directTraffic);
|
||||
const directSeries = Array.isArray(directTraffic.series) ? directTraffic.series.map(record) : [];
|
||||
const directObservedAt = timestamp(directTraffic.observedAt);
|
||||
if (directObservedAt) {
|
||||
lines.push(
|
||||
'# HELP harbor_direct_ipv4_packet_bytes_total IPv4 L3 packet bytes forwarded directly instead of entering sing-box; includes IP headers and retransmissions.',
|
||||
'# TYPE harbor_direct_ipv4_packet_bytes_total counter',
|
||||
);
|
||||
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'download' }, counter(directTraffic.downloadBytes));
|
||||
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'upload' }, counter(directTraffic.uploadBytes));
|
||||
}
|
||||
if (directSeries.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_device_direct_ipv4_packet_bytes_total Attributed IPv4 L3 packet bytes forwarded directly instead of entering sing-box.',
|
||||
'# TYPE harbor_device_direct_ipv4_packet_bytes_total counter',
|
||||
);
|
||||
for (const series of directSeries) {
|
||||
for (const [direction, amount] of [
|
||||
['download', series.downloadBytes],
|
||||
['upload', series.uploadBytes],
|
||||
]) {
|
||||
metric(lines, 'harbor_device_direct_ipv4_packet_bytes_total', {
|
||||
device_id: series.deviceId,
|
||||
direction,
|
||||
}, counter(amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directObservedAt) {
|
||||
lines.push(
|
||||
'# HELP harbor_direct_ipv4_packet_last_observed_timestamp_seconds Unix timestamp of the last successful direct IPv4 packet observation.',
|
||||
'# TYPE harbor_direct_ipv4_packet_last_observed_timestamp_seconds gauge',
|
||||
);
|
||||
lines.push(`harbor_direct_ipv4_packet_last_observed_timestamp_seconds ${directObservedAt}`);
|
||||
}
|
||||
|
||||
const domainTraffic = record(snapshot.domainTraffic);
|
||||
const collectorSource = record(domainTraffic.source);
|
||||
const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow']
|
||||
.some((field) => Object.hasOwn(collectorSource, field));
|
||||
if (hasCollectorDiagnostics) {
|
||||
const source = collectorSource;
|
||||
const mode = String(source.mode || '');
|
||||
const writer = String(source.writer || '');
|
||||
if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) {
|
||||
throw new Error('Invalid traffic collector labels');
|
||||
}
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.',
|
||||
'# TYPE harbor_traffic_collector_info gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1');
|
||||
|
||||
if (source.native !== null) {
|
||||
const native = record(source.native);
|
||||
const state = String(native.state || '');
|
||||
if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state');
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_state Current native traffic collector state.',
|
||||
'# TYPE harbor_traffic_collector_state gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_state', { state }, '1');
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.',
|
||||
'# TYPE harbor_traffic_collector_unattributed_bytes gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes));
|
||||
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes));
|
||||
}
|
||||
|
||||
if (source.shadow !== null) {
|
||||
const shadow = record(source.shadow);
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.',
|
||||
'# TYPE harbor_traffic_shadow_active_difference gauge',
|
||||
`harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`,
|
||||
'# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.',
|
||||
'# TYPE harbor_traffic_shadow_difference_bytes gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes));
|
||||
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes));
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.',
|
||||
'# TYPE harbor_traffic_shadow_route_mismatches gauge',
|
||||
`harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`,
|
||||
'# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.',
|
||||
'# TYPE harbor_traffic_shadow_device_mismatches gauge',
|
||||
`harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
|
||||
if (trackedSeries.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.',
|
||||
'# TYPE harbor_singbox_tracked_bytes_total counter',
|
||||
);
|
||||
for (const series of trackedSeries) {
|
||||
const source = String(series.source || '');
|
||||
const outbound = String(series.outbound || '');
|
||||
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
|
||||
throw new Error('Invalid sing-box outbound labels');
|
||||
}
|
||||
for (const [direction, amount] of [
|
||||
['download', series.downloadBytes],
|
||||
['upload', series.uploadBytes],
|
||||
]) {
|
||||
metric(lines, 'harbor_singbox_tracked_bytes_total', { source, outbound, direction }, counter(amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
const routeSeries = Array.isArray(domainTraffic.routes) ? domainTraffic.routes.map(record) : [];
|
||||
if (routeSeries.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_device_singbox_tracked_bytes_total Attributed bytes observed by the sing-box TCP/UDP tracker for a selected outbound.',
|
||||
'# TYPE harbor_device_singbox_tracked_bytes_total counter',
|
||||
);
|
||||
for (const series of routeSeries) {
|
||||
const source = String(series.source || '');
|
||||
const outbound = String(series.outbound || '');
|
||||
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
|
||||
throw new Error('Invalid sing-box outbound labels');
|
||||
}
|
||||
for (const [direction, amount] of [
|
||||
['download', series.downloadBytes],
|
||||
['upload', series.uploadBytes],
|
||||
]) {
|
||||
metric(lines, 'harbor_device_singbox_tracked_bytes_total', {
|
||||
device_id: series.deviceId,
|
||||
source,
|
||||
outbound,
|
||||
direction,
|
||||
}, counter(amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
|
||||
if (domainSeries.length) {
|
||||
lines.push(
|
||||
@@ -143,6 +297,11 @@ export function renderPrometheusMetrics(value: unknown) {
|
||||
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
|
||||
);
|
||||
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
||||
lines.push(
|
||||
'# HELP harbor_singbox_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box traffic observation.',
|
||||
'# TYPE harbor_singbox_traffic_last_observed_timestamp_seconds gauge',
|
||||
`harbor_singbox_traffic_last_observed_timestamp_seconds ${domainObservedAt}`,
|
||||
);
|
||||
}
|
||||
if (domainTraffic.overflowConnections != null) {
|
||||
lines.push(
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ACTIVITY_JOURNAL_MAX_EVENTS,
|
||||
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
||||
normalizeActivityEventInput,
|
||||
normalizeStoredActivityEvent,
|
||||
type ActivityJournalEvent,
|
||||
type ActivityJournalEventInput,
|
||||
type ActivityJournalPage,
|
||||
} from '../../shared/activityJournal.js';
|
||||
import { createJsonStore } from './stateStore.js';
|
||||
|
||||
interface JournalState {
|
||||
schemaVersion: 1;
|
||||
events: ActivityJournalEvent[];
|
||||
}
|
||||
|
||||
const migrateJournal = (value: unknown): JournalState => {
|
||||
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
||||
.map(normalizeStoredActivityEvent)
|
||||
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
||||
};
|
||||
};
|
||||
|
||||
export function createActivityJournalService({
|
||||
filePath,
|
||||
now = () => new Date(),
|
||||
}: {
|
||||
filePath: string;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
const store = createJsonStore<JournalState>({
|
||||
filePath,
|
||||
defaultValue: { schemaVersion: 1, events: [] },
|
||||
migrate: migrateJournal,
|
||||
});
|
||||
let recoveryRecorded = false;
|
||||
let writeFailed = false;
|
||||
|
||||
function retained(events: ActivityJournalEvent[]) {
|
||||
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
||||
return events
|
||||
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
||||
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
||||
}
|
||||
|
||||
function append(value: ActivityJournalEventInput) {
|
||||
const input = normalizeActivityEventInput(value);
|
||||
const storedInput = input.dedupeKey ? {
|
||||
...input,
|
||||
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
||||
} : input;
|
||||
let appended: ActivityJournalEvent | null = null;
|
||||
try {
|
||||
store.update((state) => {
|
||||
const events = retained(state.events);
|
||||
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
||||
return { schemaVersion: 1, events };
|
||||
}
|
||||
appended = {
|
||||
id: crypto.randomUUID(),
|
||||
occurredAt: now().toISOString(),
|
||||
...storedInput,
|
||||
};
|
||||
return { schemaVersion: 1, events: retained([...events, appended]) };
|
||||
});
|
||||
writeFailed = false;
|
||||
} catch (error) {
|
||||
writeFailed = true;
|
||||
throw error;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
function ensureRecoveryEvent() {
|
||||
if (!store.recovery || recoveryRecorded) return;
|
||||
append({
|
||||
type: 'journal.recovered',
|
||||
severity: 'warning',
|
||||
source: 'storage',
|
||||
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
||||
data: {},
|
||||
});
|
||||
recoveryRecorded = true;
|
||||
}
|
||||
|
||||
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
||||
try {
|
||||
let state = store.read();
|
||||
ensureRecoveryEvent();
|
||||
if (store.recovery) state = store.read();
|
||||
const retainedEvents = retained(state.events);
|
||||
if (retainedEvents.length !== state.events.length) {
|
||||
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||
}
|
||||
const events = [...state.events].reverse();
|
||||
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
||||
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
||||
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
||||
if (cursor && cursorIndex < 0) return {
|
||||
events: [],
|
||||
nextCursor: null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: writeFailed
|
||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||
: { status: 'ready', errorCode: null },
|
||||
};
|
||||
const safeStart = cursorIndex + 1;
|
||||
const selected = events.slice(safeStart, safeStart + limit);
|
||||
return {
|
||||
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
||||
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: writeFailed
|
||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||
: { status: 'ready', errorCode: null },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
events: [],
|
||||
nextCursor: null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const state = store.read();
|
||||
const retainedEvents = retained(state.events);
|
||||
if (retainedEvents.length !== state.events.length) {
|
||||
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||
}
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { append, page };
|
||||
}
|
||||
|
||||
export type ActivityJournalService = ReturnType<typeof createActivityJournalService>;
|
||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
||||
import {
|
||||
assessConnectivity,
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_NETWORK_SOURCE,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
} from '../../shared/connectivityDiagnostics.js';
|
||||
@@ -43,6 +44,7 @@ interface RequestOptions {
|
||||
ipv4?: boolean;
|
||||
follow?: boolean;
|
||||
resolve?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface RequestResult {
|
||||
@@ -66,6 +68,16 @@ interface IpProbeResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface NetworkProbeResult {
|
||||
address: string | null;
|
||||
asn: string | null;
|
||||
provider: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SiteProbeResult {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -80,6 +92,7 @@ interface SiteProbeResult {
|
||||
}
|
||||
|
||||
type DiagnosticTarget =
|
||||
| { kind: 'network' }
|
||||
| { kind: 'ip'; probe: IpProbe }
|
||||
| { kind: 'site'; probe: SiteProbe };
|
||||
|
||||
@@ -167,7 +180,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
|
||||
ipv4 = false,
|
||||
follow = true,
|
||||
resolve = null,
|
||||
timeoutMs = 6_000,
|
||||
}: RequestOptions = {}): Promise<RequestResult> {
|
||||
const boundedTimeoutMs = Math.min(30_000, Math.max(1_000, Math.round(timeoutMs)));
|
||||
const args = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
@@ -177,9 +192,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'3',
|
||||
String(Math.min(3_000, boundedTimeoutMs) / 1_000),
|
||||
'--max-time',
|
||||
'6',
|
||||
String(boundedTimeoutMs / 1_000),
|
||||
'--user-agent',
|
||||
'Harbor-Diagnostics/1',
|
||||
'--output',
|
||||
@@ -261,6 +276,55 @@ async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecuto
|
||||
};
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseNetwork(body: string): Omit<NetworkProbeResult, 'attempts' | 'error'> | null {
|
||||
try {
|
||||
const value = record(JSON.parse(body));
|
||||
const connection = record(value.connection);
|
||||
const address = text(value.ip);
|
||||
if (value.success === false || !address || net.isIP(address) === 0) return null;
|
||||
const number = Number(connection.asn);
|
||||
return {
|
||||
address,
|
||||
asn: Number.isSafeInteger(number) && number > 0 ? `AS${number}` : null,
|
||||
provider: text(connection.isp) || text(connection.org),
|
||||
city: text(value.city),
|
||||
country: text(value.country_code) || text(value.country),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkProbe(
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sampleCount = 1,
|
||||
): Promise<NetworkProbeResult> {
|
||||
const samples: Array<RequestResult & { network: ReturnType<typeof parseNetwork> }> = [];
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
const result = await request(CONNECTIVITY_NETWORK_SOURCE, path, proxyPort, execute, { body: true, ipv4: true });
|
||||
samples.push({ ...result, network: result.ok ? parseNetwork(result.body) : null });
|
||||
}
|
||||
const selected = mostCommon(samples
|
||||
.map(({ network }) => network && JSON.stringify(network))
|
||||
.filter((value): value is string => Boolean(value)));
|
||||
const network = selected ? JSON.parse(selected) as ReturnType<typeof parseNetwork> : null;
|
||||
return {
|
||||
address: network?.address || null,
|
||||
asn: network?.asn || null,
|
||||
provider: network?.provider || null,
|
||||
city: network?.city || null,
|
||||
country: network?.country || null,
|
||||
attempts: samples.length,
|
||||
error: network ? null : samples.at(-1)?.error || 'invalid network response',
|
||||
};
|
||||
}
|
||||
|
||||
function isPublicAddress(address: string, family: number) {
|
||||
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||
@@ -318,6 +382,8 @@ async function siteProbe(
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sampleCount = 1,
|
||||
timeoutMs = 6_000,
|
||||
retryFailure = true,
|
||||
): Promise<SiteProbeResult> {
|
||||
if (probe.validationError) return {
|
||||
id: probe.id,
|
||||
@@ -330,12 +396,12 @@ async function siteProbe(
|
||||
stage: 'validation',
|
||||
error: probe.validationError,
|
||||
};
|
||||
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
||||
const options = { follow: probe.follow !== false, resolve: probe.resolve, timeoutMs };
|
||||
const samples = [];
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||
if (retryFailure && sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||
@@ -361,7 +427,8 @@ async function probePath(
|
||||
execute: CurlExecutor,
|
||||
sites: SiteProbe[],
|
||||
): Promise<ConnectivityPathResult> {
|
||||
const [ip, siteResults] = await Promise.all([
|
||||
const [network, ip, siteResults] = await Promise.all([
|
||||
networkProbe(path, proxyPort, execute),
|
||||
publicIps(path, proxyPort, execute),
|
||||
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||
]);
|
||||
@@ -370,6 +437,7 @@ async function probePath(
|
||||
internetAvailable: Boolean(
|
||||
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
|
||||
),
|
||||
network,
|
||||
...ip,
|
||||
sites: siteResults,
|
||||
};
|
||||
@@ -389,6 +457,7 @@ function unavailablePath(): ConnectivityPathResult {
|
||||
|
||||
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
|
||||
if (typeof targetId !== 'string') return null;
|
||||
if (targetId === CONNECTIVITY_NETWORK_SOURCE.id) return { kind: 'network' };
|
||||
if (targetId.startsWith('ip:')) {
|
||||
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||
return probe ? { kind: 'ip', probe } : null;
|
||||
@@ -405,19 +474,26 @@ async function probeTarget(
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
timeoutMs = 6_000,
|
||||
sampleCount = TARGET_SAMPLE_COUNT,
|
||||
retryFailure = true,
|
||||
): Promise<ConnectivityPathResult> {
|
||||
const network = target.kind === 'network'
|
||||
? await networkProbe(path, proxyPort, execute, sampleCount)
|
||||
: null;
|
||||
const ip = target.kind === 'ip'
|
||||
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
? await ipProbe(target.probe, path, proxyPort, execute, sampleCount)
|
||||
: null;
|
||||
const site = target.kind === 'site'
|
||||
? await siteProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
? await siteProbe(target.probe, path, proxyPort, execute, sampleCount, timeoutMs, retryFailure)
|
||||
: null;
|
||||
const ipv4Sources = ip?.family === 4 ? [ip] : [];
|
||||
const ipv6Source = ip?.family === 6 ? ip : null;
|
||||
const sites = site ? [site] : [];
|
||||
return {
|
||||
available: true,
|
||||
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
||||
internetAvailable: Boolean(network?.address || ip?.address || (site && site.status !== 'unavailable')),
|
||||
...(network ? { network } : {}),
|
||||
ipv4: {
|
||||
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
|
||||
sources: ipv4Sources,
|
||||
@@ -469,7 +545,25 @@ export function createConnectivityDiagnosticsService({
|
||||
assessment: assessConnectivity(direct, vpn),
|
||||
};
|
||||
}
|
||||
async function runVpn({ services = [], target: targetId = null, timeoutMs = 6_000 }: {
|
||||
services?: unknown;
|
||||
target?: unknown;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||
: [];
|
||||
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||
const target = resolveTarget(targetId, siteProbes);
|
||||
if (!target || target.kind !== 'site') throw new Error('Failover check ожидает site target');
|
||||
return {
|
||||
checkedAt: now(),
|
||||
vpn: await probeTarget(target, 'vpn', proxyPort, execute, timeoutMs, TARGET_SAMPLE_COUNT, false),
|
||||
};
|
||||
}
|
||||
return {
|
||||
run: runOnce,
|
||||
runVpn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { lookupService } from 'node:dns/promises';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { HarborError } from '../../shared/errors.js';
|
||||
@@ -63,10 +64,22 @@ interface DevicePolicyState {
|
||||
byMac: Record<string, DevicePolicyEntry>;
|
||||
}
|
||||
|
||||
interface DeviceTag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DeviceTagState {
|
||||
schemaVersion: number;
|
||||
items: DeviceTag[];
|
||||
byMac: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface InventoryDevice {
|
||||
id: string;
|
||||
alias: string;
|
||||
pinned: boolean;
|
||||
deprioritized: boolean;
|
||||
hostname: string | null;
|
||||
manufacturer: string | null;
|
||||
mac: string;
|
||||
@@ -87,6 +100,7 @@ interface InventoryTrafficState {
|
||||
baselinesByMac: Record<string, CounterBaseline>;
|
||||
totalsByMac: Record<string, TrafficTotal>;
|
||||
rebaselineMacs: string[];
|
||||
outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline>;
|
||||
proxy: ProxyTrafficState;
|
||||
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
|
||||
[key: string]: unknown;
|
||||
@@ -98,6 +112,7 @@ export interface InventoryState {
|
||||
lastObservedAt: string | null;
|
||||
lastError: string | null;
|
||||
policy: DevicePolicyState;
|
||||
tags: DeviceTagState;
|
||||
traffic: InventoryTrafficState;
|
||||
devices: InventoryDevice[];
|
||||
[key: string]: unknown;
|
||||
@@ -127,12 +142,48 @@ interface TrafficSample {
|
||||
observedAt: string | null | undefined;
|
||||
gatewayBytes: string;
|
||||
proxyBytes: string;
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}
|
||||
|
||||
interface TrafficCursor {
|
||||
signature: string;
|
||||
gateway: bigint;
|
||||
proxy: bigint;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
interface OutboundTrafficSample {
|
||||
observedAt: string;
|
||||
vpnBytes: string;
|
||||
directTrackedBytes: string;
|
||||
directIpv4Bytes: string;
|
||||
unknownBytes: string;
|
||||
}
|
||||
|
||||
interface OutboundTrafficTotal extends OutboundTrafficSample {
|
||||
singboxObservedAt: string | null;
|
||||
directIpv4ObservedAt: string | null;
|
||||
}
|
||||
|
||||
interface OutboundTrafficCursor {
|
||||
signature: string;
|
||||
routeEpoch: string;
|
||||
directEpoch: string;
|
||||
vpn: bigint;
|
||||
directTracked: bigint;
|
||||
directIpv4: bigint;
|
||||
unknown: bigint;
|
||||
}
|
||||
|
||||
interface OutboundTrafficBaseline {
|
||||
routeEpoch: string;
|
||||
directEpoch: string;
|
||||
vpnBytes: string;
|
||||
directTrackedBytes: string;
|
||||
directIpv4Bytes: string;
|
||||
unknownBytes: string;
|
||||
}
|
||||
|
||||
interface DeviceObservation {
|
||||
@@ -158,10 +209,17 @@ const ONLINE_MS = 2 * 60 * 1000;
|
||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TRAFFIC_HISTORY_LIMIT = 120;
|
||||
const HOSTNAME_LOOKUP_LIMIT = 8;
|
||||
const HOSTNAME_LOOKUP_TIMEOUT_MS = 800;
|
||||
const HOSTNAME_RETRY_MS = 5 * 60 * 1000;
|
||||
const COUNTER_PATTERN = /^\d+$/;
|
||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const TAG_ID_PATTERN = /^tag_[a-f0-9]{16}$/;
|
||||
const TAG_NAME_MAX_LENGTH = 24;
|
||||
const TAG_CATALOG_LIMIT = 32;
|
||||
const DEVICE_TAG_LIMIT = 8;
|
||||
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
|
||||
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
||||
@@ -186,6 +244,12 @@ const DEFAULT_POLICY_STATE: DevicePolicyState = {
|
||||
byMac: {},
|
||||
};
|
||||
|
||||
const DEFAULT_TAG_STATE: DeviceTagState = {
|
||||
schemaVersion: 1,
|
||||
items: [],
|
||||
byMac: {},
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
|
||||
schemaVersion: 1,
|
||||
lastObservedAt: null,
|
||||
@@ -210,6 +274,7 @@ const DEFAULT_STATE: InventoryState = {
|
||||
lastObservedAt: null,
|
||||
lastError: null,
|
||||
policy: DEFAULT_POLICY_STATE,
|
||||
tags: DEFAULT_TAG_STATE,
|
||||
traffic: {
|
||||
epoch: null,
|
||||
generation: null,
|
||||
@@ -218,6 +283,7 @@ const DEFAULT_STATE: InventoryState = {
|
||||
baselinesByMac: {},
|
||||
totalsByMac: {},
|
||||
rebaselineMacs: [],
|
||||
outboundBaselinesByDeviceId: {},
|
||||
proxy: DEFAULT_PROXY_TRAFFIC,
|
||||
global: {
|
||||
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
|
||||
@@ -238,10 +304,46 @@ const parseStoredCounter = (value: unknown) => {
|
||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||
};
|
||||
|
||||
const normalizeTagName = (value: unknown) => typeof value === 'string' ? value.trim() : '';
|
||||
const tagNameKey = (value: string) => value.toLocaleLowerCase('ru-RU');
|
||||
|
||||
function validTagName(value: string) {
|
||||
return value.length > 0 && value.length <= TAG_NAME_MAX_LENGTH;
|
||||
}
|
||||
|
||||
const sameStringList = (left: string[], right: string[]) => (
|
||||
left.length === right.length && left.every((value, index) => value === right[index])
|
||||
);
|
||||
|
||||
const validTimestamp = (value: unknown): value is string => (
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value))
|
||||
);
|
||||
|
||||
function normalizeHostname(value: unknown, ip: string) {
|
||||
const hostname = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
|
||||
return hostname && hostname !== ip && /^[a-z0-9_](?:[a-z0-9_.-]{0,251}[a-z0-9_])?$/i.test(hostname)
|
||||
? hostname
|
||||
: null;
|
||||
}
|
||||
|
||||
async function resolveDeviceHostname(ip: string) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
lookupService(ip, 0),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), HOSTNAME_LOOKUP_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
}),
|
||||
]);
|
||||
return normalizeHostname(result?.hostname, ip);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||
const device = record(value);
|
||||
const mac = normalizeMac(device.mac);
|
||||
@@ -261,7 +363,8 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||
: deviceId(mac),
|
||||
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||
pinned: device.pinned === true,
|
||||
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
||||
deprioritized: device.deprioritized === true && device.pinned !== true,
|
||||
hostname: normalizeHostname(device.hostname, ip),
|
||||
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
||||
mac,
|
||||
ip,
|
||||
@@ -457,6 +560,38 @@ function normalizePolicyState(value: unknown): DevicePolicyState {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTagState(value: unknown, devices: InventoryDevice[]): DeviceTagState {
|
||||
const tags = record(value);
|
||||
if (typeof tags.schemaVersion === 'number' && Number.isSafeInteger(tags.schemaVersion)
|
||||
&& tags.schemaVersion > 1) {
|
||||
throw new Error(`Unsupported device tags schemaVersion: ${tags.schemaVersion}`);
|
||||
}
|
||||
const items: DeviceTag[] = [];
|
||||
const ids = new Set<string>();
|
||||
const names = new Set<string>();
|
||||
for (const itemValue of Array.isArray(tags.items) ? tags.items : []) {
|
||||
if (items.length >= TAG_CATALOG_LIMIT) break;
|
||||
const item = record(itemValue);
|
||||
const id = typeof item.id === 'string' ? item.id : '';
|
||||
const name = normalizeTagName(item.name);
|
||||
const nameKey = tagNameKey(name);
|
||||
if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || ids.has(id) || names.has(nameKey)) continue;
|
||||
items.push({ id, name });
|
||||
ids.add(id);
|
||||
names.add(nameKey);
|
||||
}
|
||||
const knownMacs = new Set(devices.map(({ mac }) => mac));
|
||||
const byMac: Record<string, string[]> = {};
|
||||
for (const [rawMac, rawIds] of Object.entries(record(tags.byMac))) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
if (!knownMacs.has(mac) || !Array.isArray(rawIds)) continue;
|
||||
const selected = new Set(rawIds.filter((id): id is string => typeof id === 'string' && ids.has(id)));
|
||||
const ordered = items.map(({ id }) => id).filter((id) => selected.has(id)).slice(0, DEVICE_TAG_LIMIT);
|
||||
if (ordered.length) byMac[mac] = ordered;
|
||||
}
|
||||
return { schemaVersion: 1, items, byMac };
|
||||
}
|
||||
|
||||
export function parseOuiVendors(text: unknown) {
|
||||
const vendors = new Map<string, string>();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
@@ -495,6 +630,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
.map(normalizeInventoryDevice)
|
||||
.filter((device): device is InventoryDevice => device !== null)
|
||||
: [];
|
||||
const tags = normalizeTagState(state.tags, devices);
|
||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
@@ -541,6 +677,32 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
recoveredTraffic = true;
|
||||
}
|
||||
}
|
||||
const knownDeviceIds = new Set(devices.map(({ id }) => id));
|
||||
const outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline> = {};
|
||||
if (traffic.outboundBaselinesByDeviceId !== undefined
|
||||
&& record(traffic.outboundBaselinesByDeviceId) !== traffic.outboundBaselinesByDeviceId) {
|
||||
recoveredTraffic = true;
|
||||
}
|
||||
for (const [id, baseline] of recordEntries(traffic.outboundBaselinesByDeviceId)) {
|
||||
const vpnBytes = parseStoredCounter(baseline.vpnBytes);
|
||||
const directTrackedBytes = parseStoredCounter(baseline.directTrackedBytes);
|
||||
const directIpv4Bytes = parseStoredCounter(baseline.directIpv4Bytes);
|
||||
const unknownBytes = parseStoredCounter(baseline.unknownBytes);
|
||||
if (!DEVICE_ID_PATTERN.test(id) || !knownDeviceIds.has(id)
|
||||
|| typeof baseline.routeEpoch !== 'string' || typeof baseline.directEpoch !== 'string'
|
||||
|| vpnBytes == null || directTrackedBytes == null || directIpv4Bytes == null || unknownBytes == null) {
|
||||
recoveredTraffic = true;
|
||||
continue;
|
||||
}
|
||||
outboundBaselinesByDeviceId[id] = {
|
||||
routeEpoch: baseline.routeEpoch,
|
||||
directEpoch: baseline.directEpoch,
|
||||
vpnBytes,
|
||||
directTrackedBytes,
|
||||
directIpv4Bytes,
|
||||
unknownBytes,
|
||||
};
|
||||
}
|
||||
const global = {
|
||||
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
|
||||
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
|
||||
@@ -563,6 +725,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||
policy: normalizePolicyState(state.policy),
|
||||
tags,
|
||||
traffic: {
|
||||
...DEFAULT_STATE.traffic,
|
||||
...traffic,
|
||||
@@ -572,6 +735,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||
outboundBaselinesByDeviceId,
|
||||
proxy: proxyTraffic,
|
||||
global,
|
||||
},
|
||||
@@ -637,6 +801,7 @@ export function createDeviceInventoryService({
|
||||
observePolicy = null,
|
||||
applyPolicies = null,
|
||||
vendor = () => null,
|
||||
resolveHostname = resolveDeviceHostname,
|
||||
now = () => new Date(),
|
||||
}: {
|
||||
store: InventoryStore;
|
||||
@@ -646,14 +811,19 @@ export function createDeviceInventoryService({
|
||||
observePolicy?: (() => unknown | Promise<unknown>) | null;
|
||||
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
|
||||
vendor?: (mac: string) => string | null;
|
||||
resolveHostname?: (ip: string) => unknown | Promise<unknown>;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
let refreshPromise: Promise<unknown> | null = null;
|
||||
let policyQueue: Promise<unknown> = Promise.resolve();
|
||||
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
|
||||
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
|
||||
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
|
||||
const outboundTrafficByDeviceId = new Map<string, OutboundTrafficTotal>();
|
||||
let globalTrafficHistory: TrafficSample[] = [];
|
||||
let globalTrafficCursor: TrafficCursor | null = null;
|
||||
const hostnameAttempts = new Map<string, number>();
|
||||
let domainTrafficSnapshot: Record<string, unknown> = {
|
||||
epoch: null,
|
||||
observedAt: null,
|
||||
@@ -666,6 +836,14 @@ export function createDeviceInventoryService({
|
||||
},
|
||||
series: [],
|
||||
};
|
||||
let directTrafficSnapshot: Record<string, unknown> = {
|
||||
epoch: null,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
uploadBytes: '0',
|
||||
downloadBytes: '0',
|
||||
series: [],
|
||||
};
|
||||
|
||||
function captureTrafficHistory(state: InventoryState) {
|
||||
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
|
||||
@@ -674,10 +852,12 @@ export function createDeviceInventoryService({
|
||||
const proxy = state.traffic.proxy.totalsByMac[device.mac];
|
||||
const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`;
|
||||
if (signature === '|') continue;
|
||||
const upload = BigInt(traffic?.uploadBytes || '0') + BigInt(proxy?.uploadBytes || '0');
|
||||
const download = BigInt(traffic?.downloadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
|
||||
const gateway = BigInt(traffic?.uploadBytes || '0') + BigInt(traffic?.downloadBytes || '0');
|
||||
const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
|
||||
const previous = trafficCursorByMac.get(device.mac);
|
||||
trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal });
|
||||
trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal, upload, download });
|
||||
if (!previous || previous.signature === signature) continue;
|
||||
const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1);
|
||||
const samples = trafficHistoryByMac.get(device.mac) || [];
|
||||
@@ -685,6 +865,8 @@ export function createDeviceInventoryService({
|
||||
observedAt,
|
||||
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
|
||||
proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).toString() : '0',
|
||||
uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0',
|
||||
downloadBytes: download > previous.download ? (download - previous.download).toString() : '0',
|
||||
}].slice(-TRAFFIC_HISTORY_LIMIT));
|
||||
}
|
||||
for (const mac of trafficCursorByMac.keys()) {
|
||||
@@ -695,20 +877,120 @@ export function createDeviceInventoryService({
|
||||
}
|
||||
const gatewaySource = state.traffic.global.gateway;
|
||||
const proxySource = state.traffic.global.proxy;
|
||||
const upload = BigInt(gatewaySource.uploadBytes) + BigInt(proxySource.uploadBytes);
|
||||
const download = BigInt(gatewaySource.downloadBytes) + BigInt(proxySource.downloadBytes);
|
||||
const gateway = BigInt(gatewaySource.uploadBytes) + BigInt(gatewaySource.downloadBytes);
|
||||
const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes);
|
||||
const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`;
|
||||
const previous = globalTrafficCursor;
|
||||
globalTrafficCursor = { signature, gateway, proxy };
|
||||
globalTrafficCursor = { signature, gateway, proxy, upload, download };
|
||||
if (previous && previous.signature !== signature) {
|
||||
globalTrafficHistory = [...globalTrafficHistory, {
|
||||
observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1),
|
||||
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
|
||||
proxyBytes: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
|
||||
uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0',
|
||||
downloadBytes: download > previous.download ? (download - previous.download).toString() : '0',
|
||||
}].slice(-TRAFFIC_HISTORY_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
function captureOutboundTrafficHistory(state: InventoryState) {
|
||||
const routeEpoch = typeof domainTrafficSnapshot.epoch === 'string' ? domainTrafficSnapshot.epoch : '';
|
||||
const directEpoch = typeof directTrafficSnapshot.epoch === 'string' ? directTrafficSnapshot.epoch : '';
|
||||
const routeObservedAt = validTimestamp(domainTrafficSnapshot.observedAt)
|
||||
? String(domainTrafficSnapshot.observedAt)
|
||||
: '';
|
||||
const directObservedAt = validTimestamp(directTrafficSnapshot.observedAt)
|
||||
? String(directTrafficSnapshot.observedAt)
|
||||
: '';
|
||||
const signature = `${routeEpoch}|${routeObservedAt}|${directEpoch}|${directObservedAt}`;
|
||||
if (signature === '|||') return;
|
||||
|
||||
const totals = new Map<string, Omit<OutboundTrafficCursor, 'signature' | 'routeEpoch' | 'directEpoch'>>();
|
||||
const totalFor = (deviceId: string) => {
|
||||
const existing = totals.get(deviceId) || { vpn: 0n, directTracked: 0n, directIpv4: 0n, unknown: 0n };
|
||||
totals.set(deviceId, existing);
|
||||
return existing;
|
||||
};
|
||||
for (const value of Array.isArray(domainTrafficSnapshot.routes) ? domainTrafficSnapshot.routes : []) {
|
||||
const row = record(value);
|
||||
const deviceId = String(row.deviceId || '');
|
||||
const outbound = String(row.outbound || '');
|
||||
const uploadBytes = String(row.uploadBytes || '');
|
||||
const downloadBytes = String(row.downloadBytes || '');
|
||||
if (!DEVICE_ID_PATTERN.test(deviceId) || !['vpn', 'direct', 'unknown'].includes(outbound)
|
||||
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
|
||||
const amount = BigInt(uploadBytes) + BigInt(downloadBytes);
|
||||
const total = totalFor(deviceId);
|
||||
if (outbound === 'vpn') total.vpn += amount;
|
||||
else if (outbound === 'direct') total.directTracked += amount;
|
||||
else total.unknown += amount;
|
||||
}
|
||||
for (const value of Array.isArray(directTrafficSnapshot.series) ? directTrafficSnapshot.series : []) {
|
||||
const row = record(value);
|
||||
const deviceId = String(row.deviceId || '');
|
||||
const uploadBytes = String(row.uploadBytes || '');
|
||||
const downloadBytes = String(row.downloadBytes || '');
|
||||
if (!DEVICE_ID_PATTERN.test(deviceId)
|
||||
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
|
||||
totalFor(deviceId).directIpv4 += BigInt(uploadBytes) + BigInt(downloadBytes);
|
||||
}
|
||||
|
||||
const knownIds = new Set(state.devices.map(({ id }) => id));
|
||||
const observedAt = [routeObservedAt, directObservedAt].filter(Boolean).sort().at(-1) || '';
|
||||
for (const device of state.devices) {
|
||||
const total = totalFor(device.id);
|
||||
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
||||
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
||||
const baseline = state.traffic.outboundBaselinesByDeviceId[device.id];
|
||||
const routeBaseline = baseline?.routeEpoch === routeEpoch ? baseline : null;
|
||||
const directBaseline = baseline?.directEpoch === directEpoch ? baseline : null;
|
||||
const routeVpn = BigInt(routeBaseline?.vpnBytes || '0');
|
||||
const routeDirect = BigInt(routeBaseline?.directTrackedBytes || '0');
|
||||
const routeUnknown = BigInt(routeBaseline?.unknownBytes || '0');
|
||||
const directIpv4Baseline = BigInt(directBaseline?.directIpv4Bytes || '0');
|
||||
const visible = {
|
||||
vpn: total.vpn >= routeVpn ? total.vpn - routeVpn : 0n,
|
||||
directTracked: total.directTracked >= routeDirect ? total.directTracked - routeDirect : 0n,
|
||||
directIpv4: total.directIpv4 >= directIpv4Baseline ? total.directIpv4 - directIpv4Baseline : 0n,
|
||||
unknown: total.unknown >= routeUnknown ? total.unknown - routeUnknown : 0n,
|
||||
};
|
||||
outboundTrafficCursorByDeviceId.set(device.id, current);
|
||||
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
|
||||
observedAt,
|
||||
singboxObservedAt: routeObservedAt || null,
|
||||
directIpv4ObservedAt: directObservedAt || null,
|
||||
vpnBytes: visible.vpn.toString(),
|
||||
directTrackedBytes: visible.directTracked.toString(),
|
||||
directIpv4Bytes: visible.directIpv4.toString(),
|
||||
unknownBytes: visible.unknown.toString(),
|
||||
});
|
||||
if (!previous || previous.signature === signature || !observedAt) continue;
|
||||
const routeDelta = (value: bigint, before: bigint) => (
|
||||
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
|
||||
);
|
||||
const directDelta = directEpoch && directEpoch === previous.directEpoch && total.directIpv4 > previous.directIpv4
|
||||
? total.directIpv4 - previous.directIpv4
|
||||
: 0n;
|
||||
const samples = outboundTrafficHistoryByDeviceId.get(device.id) || [];
|
||||
outboundTrafficHistoryByDeviceId.set(device.id, [...samples, {
|
||||
observedAt,
|
||||
vpnBytes: routeDelta(total.vpn, previous.vpn).toString(),
|
||||
directTrackedBytes: routeDelta(total.directTracked, previous.directTracked).toString(),
|
||||
directIpv4Bytes: directDelta.toString(),
|
||||
unknownBytes: routeDelta(total.unknown, previous.unknown).toString(),
|
||||
}].slice(-TRAFFIC_HISTORY_LIMIT));
|
||||
}
|
||||
for (const deviceId of outboundTrafficCursorByDeviceId.keys()) {
|
||||
if (!knownIds.has(deviceId)) {
|
||||
outboundTrafficCursorByDeviceId.delete(deviceId);
|
||||
outboundTrafficHistoryByDeviceId.delete(deviceId);
|
||||
outboundTrafficByDeviceId.delete(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
|
||||
const result = policyQueue.then(() => action(), () => action());
|
||||
policyQueue = result.catch(() => {});
|
||||
@@ -764,6 +1046,7 @@ export function createDeviceInventoryService({
|
||||
const policy = policyFor(state, device.mac);
|
||||
return {
|
||||
...device,
|
||||
tagIds: state.tags.byMac[device.mac] || [],
|
||||
status: deviceStatus(device.lastSeenAt, current),
|
||||
uploadBytes: traffic?.uploadBytes || '0',
|
||||
downloadBytes: traffic?.downloadBytes || '0',
|
||||
@@ -772,6 +1055,8 @@ export function createDeviceInventoryService({
|
||||
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
|
||||
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
|
||||
trafficHistory: trafficHistoryByMac.get(device.mac) || [],
|
||||
outboundTraffic: outboundTrafficByDeviceId.get(device.id) || null,
|
||||
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
|
||||
desiredPolicy: policy.desired,
|
||||
appliedPolicy: policy.applied,
|
||||
policyStatus: policy.status,
|
||||
@@ -780,6 +1065,7 @@ export function createDeviceInventoryService({
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
Number(right.pinned) - Number(left.pinned)
|
||||
|| Number(left.deprioritized) - Number(right.deprioritized)
|
||||
|| rank[left.status] - rank[right.status]
|
||||
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
||||
));
|
||||
@@ -796,10 +1082,13 @@ export function createDeviceInventoryService({
|
||||
: [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
|
||||
return {
|
||||
revision: state.revision,
|
||||
tags: state.tags.items,
|
||||
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
|
||||
traffic: {
|
||||
gatewayBytes: gatewayBytes.toString(),
|
||||
proxyBytes: proxyBytes.toString(),
|
||||
uploadBytes: (BigInt(gatewayTraffic.uploadBytes) + BigInt(proxyTraffic.uploadBytes)).toString(),
|
||||
downloadBytes: (BigInt(gatewayTraffic.downloadBytes) + BigInt(proxyTraffic.downloadBytes)).toString(),
|
||||
totalBytes: (gatewayBytes + proxyBytes).toString(),
|
||||
gatewayObservedAt: gatewayTraffic.lastObservedAt,
|
||||
proxyObservedAt: proxyTraffic.lastObservedAt,
|
||||
@@ -828,7 +1117,7 @@ export function createDeviceInventoryService({
|
||||
}
|
||||
|
||||
function metricsSnapshot() {
|
||||
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
|
||||
return { ...snapshot(), domainTraffic: domainTrafficSnapshot, directTraffic: directTrafficSnapshot };
|
||||
}
|
||||
|
||||
function markPolicyEpoch(observed: unknown) {
|
||||
@@ -978,6 +1267,34 @@ export function createDeviceInventoryService({
|
||||
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
|
||||
identitiesByMac.set(mac, identities);
|
||||
}
|
||||
const previousByMac = new Map(migrateDeviceInventoryState(store.read()).devices
|
||||
.map((device) => [device.mac, device]));
|
||||
const hostnameCandidates = new Map<string, DeviceObservation>();
|
||||
const hostnameKeys = new Set<string>();
|
||||
const refreshTime = new Date(observedAt).getTime();
|
||||
for (const observation of observations) {
|
||||
const key = `${observation.mac}|${observation.ip}`;
|
||||
hostnameKeys.add(key);
|
||||
const previous = previousByMac.get(observation.mac);
|
||||
if (!observation.active || (identitiesByMac.get(observation.mac)?.size || 0) !== 1
|
||||
|| (previous?.hostname && previous.ip === observation.ip)
|
||||
|| refreshTime - (hostnameAttempts.get(key) || 0) < HOSTNAME_RETRY_MS) continue;
|
||||
hostnameCandidates.set(observation.mac, observation);
|
||||
}
|
||||
for (const key of hostnameAttempts.keys()) {
|
||||
if (!hostnameKeys.has(key)) hostnameAttempts.delete(key);
|
||||
}
|
||||
// ponytail: resolve eight names per poll; add a queue only if large LANs need faster first-pass naming.
|
||||
const hostnameByMac = new Map<string, string>();
|
||||
await Promise.all([...hostnameCandidates].slice(0, HOSTNAME_LOOKUP_LIMIT).map(async ([mac, observation]) => {
|
||||
hostnameAttempts.set(`${mac}|${observation.ip}`, refreshTime);
|
||||
try {
|
||||
const hostname = normalizeHostname(await resolveHostname(observation.ip), observation.ip);
|
||||
if (hostname) hostnameByMac.set(mac, hostname);
|
||||
} catch {
|
||||
// Reverse lookup is best-effort and must not make inventory refresh stale.
|
||||
}
|
||||
}));
|
||||
return serializePolicy(async () => {
|
||||
if (typeof domainTrafficResult?.transportError === 'string') {
|
||||
domainTrafficSnapshot = {
|
||||
@@ -987,26 +1304,94 @@ export function createDeviceInventoryService({
|
||||
} else if (domainTrafficResult) {
|
||||
domainTrafficSnapshot = record(domainTrafficResult);
|
||||
}
|
||||
if (typeof trafficResult?.transportError === 'string') {
|
||||
directTrafficSnapshot = {
|
||||
...directTrafficSnapshot,
|
||||
source: { error: trafficResult.transportError },
|
||||
};
|
||||
} else if (trafficResult) {
|
||||
try {
|
||||
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : [];
|
||||
const hasDirect = rows.some((row) => Object.hasOwn(row, 'directUploadBytes') || Object.hasOwn(row, 'directDownloadBytes'));
|
||||
const direct = record(trafficResult.direct);
|
||||
const hasDirectTotal = Object.hasOwn(direct, 'uploadBytes') || Object.hasOwn(direct, 'downloadBytes');
|
||||
if ((hasDirectTotal && rows.some((row) => !Object.hasOwn(row, 'directUploadBytes') || !Object.hasOwn(row, 'directDownloadBytes')))
|
||||
|| (!hasDirectTotal && hasDirect)
|
||||
|| (hasDirectTotal && (!Object.hasOwn(direct, 'uploadBytes') || !Object.hasOwn(direct, 'downloadBytes')))) {
|
||||
throw new Error('Dataplane вернул неполный direct traffic counter');
|
||||
}
|
||||
const uploadBytes = String(direct.uploadBytes ?? '0');
|
||||
const downloadBytes = String(direct.downloadBytes ?? '0');
|
||||
if (hasDirectTotal && (!COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes))) {
|
||||
throw new Error('Dataplane вернул невалидный global direct traffic counter');
|
||||
}
|
||||
if (hasDirectTotal && (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch
|
||||
|| !validTimestamp(trafficResult.observedAt))) {
|
||||
throw new Error('Dataplane вернул невалидную direct traffic identity');
|
||||
}
|
||||
const series = hasDirectTotal ? rows.map((row) => {
|
||||
const mac = normalizeMac(row.mac);
|
||||
const uploadBytes = String(row.directUploadBytes ?? '');
|
||||
const downloadBytes = String(row.directDownloadBytes ?? '');
|
||||
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) {
|
||||
throw new Error('Dataplane вернул невалидный direct traffic counter');
|
||||
}
|
||||
return { deviceId: deviceId(mac), uploadBytes, downloadBytes };
|
||||
}) : [];
|
||||
directTrafficSnapshot = {
|
||||
epoch: typeof trafficResult.epoch === 'string' ? trafficResult.epoch : null,
|
||||
observedAt: hasDirectTotal && typeof trafficResult.observedAt === 'string' ? trafficResult.observedAt : null,
|
||||
source: {
|
||||
error: typeof record(trafficResult.source).error === 'string'
|
||||
? String(record(trafficResult.source).error)
|
||||
: null,
|
||||
},
|
||||
uploadBytes,
|
||||
downloadBytes,
|
||||
series,
|
||||
};
|
||||
} catch (error) {
|
||||
directTrafficSnapshot = {
|
||||
...directTrafficSnapshot,
|
||||
source: { error: errorMessage(error) },
|
||||
};
|
||||
}
|
||||
}
|
||||
const nextState = store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
let policyByMac = state.policy.byMac;
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac) continue;
|
||||
const previous = byMac.get(mac);
|
||||
if (!previous) {
|
||||
const retained = policyByMac[mac];
|
||||
if (policyByMac === state.policy.byMac) policyByMac = { ...policyByMac };
|
||||
policyByMac[mac] = {
|
||||
desired: 'direct',
|
||||
applied: retained?.applied || 'vpn',
|
||||
status: 'applying',
|
||||
appliedAt: retained?.appliedAt || null,
|
||||
error: null,
|
||||
operationId: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
|
||||
const lastSeenAt = observation.active || !previous
|
||||
? observationTime
|
||||
: previous.lastSeenAt;
|
||||
const replaceAddress = observation.active || !previous;
|
||||
byMac.set(mac, {
|
||||
id: previous?.id || deviceId(mac),
|
||||
alias: previous?.alias || '',
|
||||
pinned: previous?.pinned === true,
|
||||
hostname: previous?.hostname || null,
|
||||
deprioritized: previous?.deprioritized === true && previous?.pinned !== true,
|
||||
hostname: hostnameByMac.get(mac) || previous?.hostname || null,
|
||||
manufacturer: previous?.manufacturer || vendor(mac),
|
||||
mac,
|
||||
ip: String(observation.ip || previous?.ip || ''),
|
||||
interface: String(observation.interface || previous?.interface || ''),
|
||||
ip: replaceAddress ? observation.ip : previous.ip,
|
||||
interface: replaceAddress ? observation.interface : previous.interface,
|
||||
firstSeenAt: previous?.firstSeenAt || observationTime,
|
||||
lastSeenAt,
|
||||
source: 'neighbor',
|
||||
@@ -1016,8 +1401,9 @@ export function createDeviceInventoryService({
|
||||
});
|
||||
}
|
||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||
const sourceUnavailable = typeof result.error === 'string';
|
||||
const devices = [...byMac.values()].filter((device) => (
|
||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
sourceUnavailable || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
));
|
||||
let traffic = state.traffic;
|
||||
if (trafficResult) {
|
||||
@@ -1229,11 +1615,15 @@ export function createDeviceInventoryService({
|
||||
revision: state.revision + 1,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: typeof result.error === 'string' ? result.error : null,
|
||||
policy: policyByMac === state.policy.byMac
|
||||
? state.policy
|
||||
: { ...state.policy, lastError: null, byMac: policyByMac },
|
||||
traffic,
|
||||
devices,
|
||||
};
|
||||
});
|
||||
captureTrafficHistory(nextState);
|
||||
captureOutboundTrafficHistory(nextState);
|
||||
if (typeof policyResult?.transportError === 'string') {
|
||||
commitPolicyFailure(new Error(policyResult.transportError));
|
||||
}
|
||||
@@ -1257,31 +1647,209 @@ export function createDeviceInventoryService({
|
||||
const value = record(patch);
|
||||
const aliasProvided = Object.hasOwn(value, 'alias');
|
||||
const pinProvided = Object.hasOwn(value, 'pinned');
|
||||
const deprioritizedProvided = Object.hasOwn(value, 'deprioritized');
|
||||
const tagIdsProvided = Object.hasOwn(value, 'tagIds');
|
||||
const requestedTagIds = Array.isArray(value.tagIds)
|
||||
? value.tagIds.filter((tagId): tagId is string => typeof tagId === 'string')
|
||||
: [];
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||
|| (!aliasProvided && !pinProvided)
|
||||
|| (!aliasProvided && !pinProvided && !deprioritizedProvided && !tagIdsProvided)
|
||||
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
||||
|| (pinProvided && typeof value.pinned !== 'boolean')) {
|
||||
|| (pinProvided && typeof value.pinned !== 'boolean')
|
||||
|| (deprioritizedProvided && typeof value.deprioritized !== 'boolean')
|
||||
|| (tagIdsProvided && (!Array.isArray(value.tagIds)
|
||||
|| requestedTagIds.length !== value.tagIds.length
|
||||
|| requestedTagIds.length > DEVICE_TAG_LIMIT
|
||||
|| new Set(requestedTagIds).size !== requestedTagIds.length
|
||||
|| requestedTagIds.some((tagId) => !TAG_ID_PATTERN.test(tagId))))
|
||||
|| (value.pinned === true && value.deprioritized === true)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const revision = expectedRevision;
|
||||
const alias = typeof value.alias === 'string' ? value.alias : '';
|
||||
const pinned = value.pinned === true;
|
||||
const deprioritized = value.deprioritized === true;
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||
const index = state.devices.findIndex((device) => device.id === id);
|
||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||
const knownTagIds = new Set(state.tags.items.map(({ id: tagId }) => tagId));
|
||||
if (tagIdsProvided && requestedTagIds.some((tagId) => !knownTagIds.has(tagId))) {
|
||||
throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||
}
|
||||
const tagIds = state.tags.items
|
||||
.map(({ id: tagId }) => tagId)
|
||||
.filter((tagId) => requestedTagIds.includes(tagId));
|
||||
const currentTagIds = state.tags.byMac[state.devices[index].mac] || [];
|
||||
const nextAlias = aliasProvided ? alias.trim() : state.devices[index].alias;
|
||||
const nextPinned = pinProvided ? pinned : state.devices[index].pinned;
|
||||
const nextDeprioritized = deprioritizedProvided
|
||||
? deprioritized
|
||||
: pinProvided && pinned ? false : state.devices[index].deprioritized;
|
||||
if (nextAlias === state.devices[index].alias
|
||||
&& nextPinned === state.devices[index].pinned
|
||||
&& nextDeprioritized === state.devices[index].deprioritized
|
||||
&& (!tagIdsProvided || sameStringList(tagIds, currentTagIds))) return state;
|
||||
const devices = [...state.devices];
|
||||
devices[index] = {
|
||||
...devices[index],
|
||||
...(aliasProvided ? { alias: alias.trim() } : {}),
|
||||
...(pinProvided ? { pinned } : {}),
|
||||
...(aliasProvided ? { alias: nextAlias } : {}),
|
||||
...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}),
|
||||
...(deprioritizedProvided
|
||||
? { deprioritized, ...(deprioritized ? { pinned: false } : {}) }
|
||||
: {}),
|
||||
};
|
||||
return { ...state, revision: state.revision + 1, devices };
|
||||
let tags = state.tags;
|
||||
if (tagIdsProvided) {
|
||||
const byMac = { ...state.tags.byMac };
|
||||
if (tagIds.length) byMac[devices[index].mac] = tagIds;
|
||||
else delete byMac[devices[index].mac];
|
||||
tags = { ...state.tags, byMac };
|
||||
}
|
||||
return { ...state, revision: state.revision + 1, devices, tags };
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function createTag(nameValue: unknown, expectedRevision: unknown) {
|
||||
const name = normalizeTagName(nameValue);
|
||||
if (!validTagName(name) || typeof expectedRevision !== 'number'
|
||||
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (state.tags.items.length >= TAG_CATALOG_LIMIT) throw new HarborError('REQUEST_INVALID');
|
||||
if (state.tags.items.some((tag) => tagNameKey(tag.name) === tagNameKey(name))) {
|
||||
throw new HarborError('DEVICE_TAG_NAME_CONFLICT');
|
||||
}
|
||||
let id = '';
|
||||
const ids = new Set(state.tags.items.map((tag) => tag.id));
|
||||
do id = `tag_${crypto.randomBytes(8).toString('hex')}`; while (ids.has(id));
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
tags: { ...state.tags, items: [...state.tags.items, { id, name }] },
|
||||
};
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function renameTag(id: string, nameValue: unknown, expectedRevision: unknown) {
|
||||
const name = normalizeTagName(nameValue);
|
||||
if (!TAG_ID_PATTERN.test(id) || !validTagName(name) || typeof expectedRevision !== 'number'
|
||||
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
const index = state.tags.items.findIndex((tag) => tag.id === id);
|
||||
if (index < 0) throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||
if (state.tags.items[index].name === name) return state;
|
||||
if (state.tags.items.some((tag) => tag.id !== id && tagNameKey(tag.name) === tagNameKey(name))) {
|
||||
throw new HarborError('DEVICE_TAG_NAME_CONFLICT');
|
||||
}
|
||||
const items = [...state.tags.items];
|
||||
items[index] = { ...items[index], name };
|
||||
return { ...state, revision: state.revision + 1, tags: { ...state.tags, items } };
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function deleteTag(id: string, expectedRevision: unknown) {
|
||||
if (!TAG_ID_PATTERN.test(id) || typeof expectedRevision !== 'number'
|
||||
|| !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (!state.tags.items.some((tag) => tag.id === id)) throw new HarborError('DEVICE_TAG_NOT_FOUND');
|
||||
const byMac: Record<string, string[]> = {};
|
||||
for (const [mac, tagIds] of Object.entries(state.tags.byMac)) {
|
||||
const remaining = tagIds.filter((tagId) => tagId !== id);
|
||||
if (remaining.length) byMac[mac] = remaining;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
tags: {
|
||||
...state.tags,
|
||||
items: state.tags.items.filter((tag) => tag.id !== id),
|
||||
byMac,
|
||||
},
|
||||
};
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function resetTraffic(expectedRevision: unknown) {
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||
|| expectedRevision < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
if (refreshPromise) await refreshPromise;
|
||||
const nextState = store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
const totalsByMac: Record<string, TrafficTotal> = {};
|
||||
const proxyTotalsByMac: Record<string, TrafficTotal> = {};
|
||||
const outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline> = {};
|
||||
const rebaselineMacs = new Set(state.traffic.rebaselineMacs);
|
||||
const proxyRebaselineMacs = new Set(state.traffic.proxy.rebaselineMacs);
|
||||
for (const device of state.devices) {
|
||||
const traffic = state.traffic.totalsByMac[device.mac];
|
||||
const proxy = state.traffic.proxy.totalsByMac[device.mac];
|
||||
totalsByMac[device.mac] = {
|
||||
uploadBytes: '0',
|
||||
downloadBytes: '0',
|
||||
observedAt: traffic?.observedAt || state.traffic.lastObservedAt,
|
||||
};
|
||||
proxyTotalsByMac[device.mac] = {
|
||||
uploadBytes: '0',
|
||||
downloadBytes: '0',
|
||||
observedAt: proxy?.observedAt || state.traffic.proxy.lastObservedAt,
|
||||
};
|
||||
if (!state.traffic.baselinesByMac[device.mac]) rebaselineMacs.add(device.mac);
|
||||
if (!state.traffic.proxy.baselinesByMac[device.mac]) proxyRebaselineMacs.add(device.mac);
|
||||
const outbound = outboundTrafficCursorByDeviceId.get(device.id);
|
||||
if (outbound) outboundBaselinesByDeviceId[device.id] = {
|
||||
routeEpoch: outbound.routeEpoch,
|
||||
directEpoch: outbound.directEpoch,
|
||||
vpnBytes: outbound.vpn.toString(),
|
||||
directTrackedBytes: outbound.directTracked.toString(),
|
||||
directIpv4Bytes: outbound.directIpv4.toString(),
|
||||
unknownBytes: outbound.unknown.toString(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
traffic: {
|
||||
...state.traffic,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs],
|
||||
outboundBaselinesByDeviceId,
|
||||
proxy: {
|
||||
...state.traffic.proxy,
|
||||
totalsByMac: proxyTotalsByMac,
|
||||
rebaselineMacs: [...proxyRebaselineMacs],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
trafficHistoryByMac.clear();
|
||||
trafficCursorByMac.clear();
|
||||
captureTrafficHistory(nextState);
|
||||
captureOutboundTrafficHistory(nextState);
|
||||
outboundTrafficHistoryByDeviceId.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|
||||
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||
@@ -1325,5 +1893,16 @@ export function createDeviceInventoryService({
|
||||
return serializePolicy(() => reconcileLocked(observed, true));
|
||||
}
|
||||
|
||||
return { snapshot, metricsSnapshot, refresh, update, setPolicy, reconcilePolicies };
|
||||
return {
|
||||
snapshot,
|
||||
metricsSnapshot,
|
||||
refresh,
|
||||
update,
|
||||
createTag,
|
||||
renameTag,
|
||||
deleteTag,
|
||||
resetTraffic,
|
||||
setPolicy,
|
||||
reconcilePolicies,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ interface TrafficDevice {
|
||||
key: string;
|
||||
}
|
||||
|
||||
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
|
||||
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
|
||||
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
|
||||
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download' | 'direct-upload' | 'direct-download';
|
||||
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload' | 'directUpload' | 'directDownload';
|
||||
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes' | 'directUploadBytes' | 'directDownloadBytes';
|
||||
type CounterValues = Record<CounterField, bigint>;
|
||||
|
||||
interface RetiredCounters {
|
||||
@@ -40,6 +40,7 @@ interface TrafficSnapshot {
|
||||
generation: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
direct: { uploadBytes: string; downloadBytes: string };
|
||||
devices: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
@@ -53,6 +54,8 @@ const COUNTERS = [
|
||||
['download', 'download', 'downloadBytes'],
|
||||
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
||||
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
||||
['direct-upload', 'directUpload', 'directUploadBytes'],
|
||||
['direct-download', 'directDownload', 'directDownloadBytes'],
|
||||
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
|
||||
|
||||
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||
@@ -117,7 +120,37 @@ function isIpv4Cidr(value: unknown) {
|
||||
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
||||
}
|
||||
|
||||
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||
function cidrRange(cidr: string) {
|
||||
const [address, prefix] = cidr.split('/');
|
||||
const value = address.split('.').reduce((result, octet) => result * 256n + BigInt(octet), 0n);
|
||||
const bits = BigInt(Number(prefix));
|
||||
const mask = bits === 0n ? 0n : (0xffff_ffffn << (32n - bits)) & 0xffff_ffffn;
|
||||
const first = value & mask;
|
||||
return [first, first | (0xffff_ffffn ^ mask)] as const;
|
||||
}
|
||||
|
||||
function hasOverlappingCidrs(cidrs: readonly string[]) {
|
||||
const ranges = cidrs.map(cidrRange).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
return ranges.some(([start], index) => index > 0 && start <= ranges[index - 1][1]);
|
||||
}
|
||||
|
||||
const zeroCounters = (): CounterValues => ({
|
||||
upload: 0n,
|
||||
download: 0n,
|
||||
proxyUpload: 0n,
|
||||
proxyDownload: 0n,
|
||||
directUpload: 0n,
|
||||
directDownload: 0n,
|
||||
});
|
||||
|
||||
function markValue(value: unknown) {
|
||||
try {
|
||||
const parsed = BigInt(String(value));
|
||||
return parsed > 0n && parsed <= 0xffff_ffffn ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -160,6 +193,10 @@ export function buildTrafficRestore({
|
||||
bypassCidrs,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
directChain,
|
||||
directMark,
|
||||
tproxyMark,
|
||||
gatewayClientCidrs,
|
||||
slot,
|
||||
proxyPort,
|
||||
}: {
|
||||
@@ -167,17 +204,28 @@ export function buildTrafficRestore({
|
||||
bypassCidrs: readonly string[];
|
||||
uploadChain: string;
|
||||
downloadChain: string;
|
||||
directChain: string;
|
||||
directMark: string;
|
||||
tproxyMark: string;
|
||||
gatewayClientCidrs: readonly string[];
|
||||
slot: string;
|
||||
proxyPort: number;
|
||||
}) {
|
||||
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
||||
const parsedDirectMark = markValue(directMark);
|
||||
const parsedTproxyMark = markValue(tproxyMark);
|
||||
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain) || !CHAIN_PATTERN.test(directChain)
|
||||
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
||||
|| proxyPort < 1 || proxyPort > 65_535
|
||||
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) {
|
||||
|| parsedDirectMark == null || (parsedDirectMark & (parsedDirectMark - 1n)) !== 0n
|
||||
|| parsedTproxyMark == null || (parsedDirectMark & parsedTproxyMark) !== 0n
|
||||
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))
|
||||
|| !Array.isArray(gatewayClientCidrs) || gatewayClientCidrs.some((cidr) => !isIpv4Cidr(cidr))
|
||||
|| hasOverlappingCidrs(gatewayClientCidrs)) {
|
||||
throw new Error('Некорректная конфигурация traffic accounting');
|
||||
}
|
||||
const uploadChild = childChain(uploadChain, slot);
|
||||
const downloadChild = childChain(downloadChain, slot);
|
||||
const directChild = childChain(directChain, slot);
|
||||
const proxyUploadChild = proxyChildChain(uploadChain, slot);
|
||||
const proxyDownloadChild = proxyChildChain(downloadChain, slot);
|
||||
const raw = [
|
||||
@@ -192,8 +240,12 @@ export function buildTrafficRestore({
|
||||
];
|
||||
const mangle = [
|
||||
'*mangle',
|
||||
`-F ${directChild}`,
|
||||
`-F ${downloadChild}`,
|
||||
`-F ${proxyDownloadChild}`,
|
||||
`-A ${directChild} -m addrtype --dst-type LOCAL -j RETURN`,
|
||||
`-A ${directChild} -m mark --mark ${tproxyMark}/${tproxyMark} -j RETURN`,
|
||||
`-A ${directChild} -i br-+ -j RETURN`,
|
||||
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
|
||||
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||
@@ -208,9 +260,16 @@ export function buildTrafficRestore({
|
||||
}
|
||||
for (const cidr of bypassCidrs) {
|
||||
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
|
||||
mangle.push(`-A ${directChild} -d ${cidr} -j RETURN`);
|
||||
mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`);
|
||||
}
|
||||
for (const cidr of gatewayClientCidrs) {
|
||||
mangle.push(`-A ${directChild} -s ${cidr} -m comment --comment harbor-traffic:global:direct-upload -j CONNMARK --set-xmark ${directMark}/${directMark}`);
|
||||
mangle.push(`-A ${downloadChild} -d ${cidr} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:global:direct-download`);
|
||||
}
|
||||
for (const device of devices) {
|
||||
mangle.push(`-A ${directChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-upload`);
|
||||
mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-download`);
|
||||
raw.push(`-A ${uploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-traffic:${device.key}:upload -j RETURN`);
|
||||
mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m comment --comment harbor-traffic:${device.key}:download -j RETURN`);
|
||||
}
|
||||
@@ -220,7 +279,7 @@ export function buildTrafficRestore({
|
||||
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
|
||||
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const linePattern = new RegExp(
|
||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}|global):(upload|download|proxy-upload|proxy-download|direct-upload|direct-download)"?`,
|
||||
);
|
||||
const counters = new Map<string, string>();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
@@ -236,6 +295,10 @@ export function createDeviceTrafficService({
|
||||
observe,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
directChain,
|
||||
directMark,
|
||||
tproxyMark,
|
||||
gatewayClientCidrs,
|
||||
bypassCidrs,
|
||||
proxyPort,
|
||||
run = runCommand,
|
||||
@@ -244,6 +307,10 @@ export function createDeviceTrafficService({
|
||||
observe: () => Promise<unknown> | unknown;
|
||||
uploadChain: string;
|
||||
downloadChain: string;
|
||||
directChain: string;
|
||||
directMark: string;
|
||||
tproxyMark: string;
|
||||
gatewayClientCidrs: string[];
|
||||
bypassCidrs: string[];
|
||||
proxyPort: number;
|
||||
run?: RunCommand;
|
||||
@@ -257,12 +324,14 @@ export function createDeviceTrafficService({
|
||||
let pendingRetired: RetiredCounters | null = null;
|
||||
let refreshPromise: Promise<TrafficSnapshot> | null = null;
|
||||
const finalized = new Map<string, CounterValues>();
|
||||
const finalizedDirect = { upload: 0n, download: 0n };
|
||||
const devicesByKey = new Map<string, TrafficDevice>();
|
||||
let current: TrafficSnapshot = {
|
||||
epoch,
|
||||
generation: epoch,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
direct: { uploadBytes: '0', downloadBytes: '0' },
|
||||
devices: [],
|
||||
};
|
||||
|
||||
@@ -278,6 +347,10 @@ export function createDeviceTrafficService({
|
||||
bypassCidrs,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
directChain,
|
||||
directMark,
|
||||
tproxyMark,
|
||||
gatewayClientCidrs,
|
||||
slot,
|
||||
proxyPort,
|
||||
});
|
||||
@@ -287,22 +360,37 @@ export function createDeviceTrafficService({
|
||||
async function switchTo(slot: 'A' | 'B') {
|
||||
const uploadChild = childChain(uploadChain, slot);
|
||||
const downloadChild = childChain(downloadChain, slot);
|
||||
const directChild = childChain(directChain, slot);
|
||||
const replace = activeSlot ? '-R' : '-A';
|
||||
const uploadArgs = activeSlot
|
||||
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
|
||||
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
|
||||
const downloadArgs = activeSlot
|
||||
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
|
||||
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
|
||||
const mangleInput = [
|
||||
'*mangle',
|
||||
activeSlot
|
||||
? `-R ${downloadChain} 1 -j ${downloadChild}`
|
||||
: `-A ${downloadChain} -j ${downloadChild}`,
|
||||
activeSlot
|
||||
? `-R ${directChain} 1 -j ${directChild}`
|
||||
: `-A ${directChain} -j ${directChild}`,
|
||||
'COMMIT',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await execute('iptables', uploadArgs);
|
||||
try {
|
||||
await execute('iptables', downloadArgs);
|
||||
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input: mangleInput });
|
||||
} catch (error) {
|
||||
const rollbackArgs = activeSlot
|
||||
const uploadRollback = activeSlot
|
||||
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
|
||||
: ['-w', '1', '-t', 'raw', '-F', uploadChain];
|
||||
await execute('iptables', rollbackArgs);
|
||||
try {
|
||||
await execute('iptables', uploadRollback);
|
||||
} catch (rollbackError) {
|
||||
const original = error instanceof Error ? error.message : String(error);
|
||||
const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
||||
throw new Error(`${original}; rollback: ${rollback}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -318,6 +406,8 @@ export function createDeviceTrafficService({
|
||||
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
|
||||
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
||||
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
||||
directUpload: parseTrafficCounters(mangle, childChain(directChain, slot)),
|
||||
directDownload: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
|
||||
};
|
||||
const counters = new Map<string, string>();
|
||||
for (const { key } of devices) {
|
||||
@@ -325,6 +415,8 @@ export function createDeviceTrafficService({
|
||||
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
||||
}
|
||||
}
|
||||
counters.set('global:direct-upload', parsed.directUpload.get('global:direct-upload') || '0');
|
||||
counters.set('global:direct-download', parsed.directDownload.get('global:direct-download') || '0');
|
||||
return counters;
|
||||
}
|
||||
|
||||
@@ -345,6 +437,8 @@ export function createDeviceTrafficService({
|
||||
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
||||
finalized.set(key, next);
|
||||
}
|
||||
finalizedDirect.upload += counter(counters, 'global', 'direct-upload');
|
||||
finalizedDirect.download += counter(counters, 'global', 'direct-download');
|
||||
pendingRetired = null;
|
||||
return true;
|
||||
}
|
||||
@@ -368,18 +462,41 @@ export function createDeviceTrafficService({
|
||||
}
|
||||
return [...totalsByMac.values()]
|
||||
.map((total) => {
|
||||
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
|
||||
const {
|
||||
key: _key,
|
||||
upload,
|
||||
download,
|
||||
proxyUpload,
|
||||
proxyDownload,
|
||||
directUpload,
|
||||
directDownload,
|
||||
...device
|
||||
} = total;
|
||||
return {
|
||||
...device,
|
||||
uploadBytes: upload.toString(),
|
||||
downloadBytes: download.toString(),
|
||||
proxyUploadBytes: proxyUpload.toString(),
|
||||
proxyDownloadBytes: proxyDownload.toString(),
|
||||
directUploadBytes: directUpload.toString(),
|
||||
directDownloadBytes: directDownload.toString(),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||
}
|
||||
|
||||
function directTotals() {
|
||||
const pending = pendingRetired?.counters || new Map();
|
||||
return {
|
||||
uploadBytes: (finalizedDirect.upload
|
||||
+ counter(pending, 'global', 'direct-upload')
|
||||
+ counter(activeCounters, 'global', 'direct-upload')).toString(),
|
||||
downloadBytes: (finalizedDirect.download
|
||||
+ counter(pending, 'global', 'direct-download')
|
||||
+ counter(activeCounters, 'global', 'direct-download')).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
let observed: Record<string, unknown>;
|
||||
try {
|
||||
@@ -426,11 +543,13 @@ export function createDeviceTrafficService({
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||
countersRead = true;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||
if (activeSlot) {
|
||||
try {
|
||||
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||
countersRead = true;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
current = {
|
||||
epoch,
|
||||
@@ -439,6 +558,7 @@ export function createDeviceTrafficService({
|
||||
? observed.observedAt
|
||||
: current.observedAt,
|
||||
source: { error: sourceError },
|
||||
direct: countersRead ? directTotals() : current.direct,
|
||||
devices: countersRead ? processTotals() : current.devices,
|
||||
};
|
||||
return structuredClone(current);
|
||||
|
||||
@@ -3,12 +3,14 @@ import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { domainToASCII } from 'node:url';
|
||||
import { deviceId } from './deviceInventoryService.js';
|
||||
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SERIES = 4096;
|
||||
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
||||
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
|
||||
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
|
||||
type TrafficRoute = 'vpn' | 'direct' | 'unknown';
|
||||
const SERVICE_DOMAINS = [
|
||||
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
||||
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
||||
@@ -16,26 +18,36 @@ const SERVICE_DOMAINS = [
|
||||
|
||||
interface ParsedBaseConnection {
|
||||
id: string;
|
||||
startedAt?: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
type ParsedConnection =
|
||||
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
|
||||
| (ParsedBaseConnection & { outcome: 'unsupported_source' })
|
||||
| (ParsedBaseConnection & {
|
||||
outcome: 'unknown_device';
|
||||
source: 'gateway' | 'proxy';
|
||||
outbound: TrafficRoute;
|
||||
})
|
||||
| (ParsedBaseConnection & {
|
||||
outcome: 'classified' | 'unresolved_host';
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source: 'gateway' | 'proxy';
|
||||
outbound: TrafficRoute;
|
||||
});
|
||||
|
||||
interface PreviousConnection {
|
||||
startedAt?: string;
|
||||
outcome: AttributionOutcome | 'classified';
|
||||
key?: string;
|
||||
requestedKey?: string;
|
||||
countedUpload: bigint | null;
|
||||
countedDownload: bigint | null;
|
||||
trackedUpload: bigint | null;
|
||||
trackedDownload: bigint | null;
|
||||
}
|
||||
|
||||
interface DomainSeriesTotal {
|
||||
@@ -47,24 +59,61 @@ interface DomainSeriesTotal {
|
||||
downloadBytes: bigint;
|
||||
}
|
||||
|
||||
interface RouteSeriesTotal {
|
||||
deviceId?: string;
|
||||
source: 'gateway' | 'proxy';
|
||||
outbound: TrafficRoute;
|
||||
uploadBytes: bigint;
|
||||
downloadBytes: bigint;
|
||||
}
|
||||
|
||||
interface DomainTrafficSnapshot {
|
||||
epoch: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
source: { error: string | null; activeConnections: number };
|
||||
overflowConnections: string;
|
||||
attributionEvents: Record<AttributionOutcome, string>;
|
||||
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}>;
|
||||
routes: Array<Omit<RouteSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}>;
|
||||
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
device: string;
|
||||
service: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
interface ActivitySample {
|
||||
at: number;
|
||||
entries: ActivityEntry[];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function publicDeviceLabel(value: unknown) {
|
||||
const device = record(value);
|
||||
for (const candidate of [device.alias, device.hostname]) {
|
||||
const label = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
if (label && label.length <= 64 && !/[\/?#@\\]/.test(label) && !net.isIP(label)) return label;
|
||||
}
|
||||
return 'Устройство';
|
||||
}
|
||||
|
||||
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||
|
||||
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||
@@ -86,6 +135,12 @@ function sourceFor(type: string): 'gateway' | 'proxy' | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function routeFor(value: unknown): TrafficRoute {
|
||||
if (!Array.isArray(value) || !value.length
|
||||
|| value.some((entry) => typeof entry !== 'string' || !entry.trim())) return 'unknown';
|
||||
return value[0].trim() === 'direct' ? 'direct' : 'vpn';
|
||||
}
|
||||
|
||||
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
|
||||
const connection = record(value);
|
||||
const id = String(connection.id || '');
|
||||
@@ -103,8 +158,9 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
|
||||
};
|
||||
const source = sourceFor(String(metadata.type || ''));
|
||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||
const outbound = routeFor(connection.chains);
|
||||
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
|
||||
const classifiedDomain = classifyDomain(metadata.host);
|
||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||
return {
|
||||
@@ -113,6 +169,47 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
|
||||
deviceId: currentDeviceId,
|
||||
...domain,
|
||||
source,
|
||||
outbound,
|
||||
};
|
||||
}
|
||||
|
||||
function decimalCounter(value: unknown) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
||||
throw new Error('Sing-box вернул невалидный native traffic counter');
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
function parseNativeConnection(value: unknown): ParsedConnection {
|
||||
const connection = record(value);
|
||||
const inbound = record(connection.inbound);
|
||||
const origin = record(connection.origin);
|
||||
const destination = record(connection.destination);
|
||||
const route = record(connection.route);
|
||||
const traffic = record(connection.traffic);
|
||||
const parsed = {
|
||||
id: String(connection.id || ''),
|
||||
startedAt: typeof connection.startedAt === 'string' ? connection.startedAt : undefined,
|
||||
upload: decimalCounter(traffic.uploadBytes),
|
||||
download: decimalCounter(traffic.downloadBytes),
|
||||
};
|
||||
if (!parsed.id) throw new Error('Sing-box вернул native traffic без id');
|
||||
const source = sourceFor(`${String(inbound.type || '')}/${String(inbound.tag || '')}`);
|
||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||
const outbound: TrafficRoute = route.kind === 'vpn' || route.kind === 'direct' ? route.kind : 'unknown';
|
||||
const currentDeviceId = origin.kind === 'device' && typeof origin.id === 'string' && origin.id
|
||||
? origin.id
|
||||
: null;
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
|
||||
const classifiedDomain = classifyDomain(destination.domain);
|
||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||
return {
|
||||
...parsed,
|
||||
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||
deviceId: currentDeviceId,
|
||||
...domain,
|
||||
source,
|
||||
outbound,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,9 +261,14 @@ export function createDomainTrafficService({
|
||||
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
|
||||
const epoch = crypto.randomUUID();
|
||||
const totals = new Map<string, DomainSeriesTotal>();
|
||||
const routeTotals = new Map<string, RouteSeriesTotal>();
|
||||
const trackedTotals = new Map<string, RouteSeriesTotal>();
|
||||
const normalSeriesLimit = maxSeries - 2;
|
||||
let normalSeries = 0;
|
||||
let previousConnections = new Map<string, PreviousConnection>();
|
||||
const settledNativeConnections = new Map<string, PreviousConnection>();
|
||||
let nativeEpoch: string | null = null;
|
||||
let activeConnections = 0;
|
||||
let overflowConnections = 0n;
|
||||
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||
unresolved_host: 0n,
|
||||
@@ -174,12 +276,18 @@ export function createDomainTrafficService({
|
||||
unsupported_source: 0n,
|
||||
};
|
||||
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||
let activityEnabled = false;
|
||||
let activityStartedAt = 0;
|
||||
let activitySamples: ActivitySample[] = [];
|
||||
let quietSince: string | null = null;
|
||||
let current: DomainTrafficSnapshot = {
|
||||
epoch,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
source: { error: null, activeConnections: 0 },
|
||||
overflowConnections: '0',
|
||||
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||
tracked: [],
|
||||
routes: [],
|
||||
series: [],
|
||||
};
|
||||
|
||||
@@ -187,11 +295,32 @@ export function createDomainTrafficService({
|
||||
return {
|
||||
epoch,
|
||||
observedAt: current.observedAt,
|
||||
source: { error },
|
||||
source: { error, activeConnections },
|
||||
overflowConnections: overflowConnections.toString(),
|
||||
attributionEvents: Object.fromEntries(
|
||||
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||
) as Record<AttributionOutcome, string>,
|
||||
tracked: [...trackedTotals.values()]
|
||||
.map((entry) => ({
|
||||
source: entry.source,
|
||||
outbound: entry.outbound,
|
||||
uploadBytes: entry.uploadBytes.toString(),
|
||||
downloadBytes: entry.downloadBytes.toString(),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.source.localeCompare(right.source) || left.outbound.localeCompare(right.outbound)
|
||||
)),
|
||||
routes: [...routeTotals.values()]
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
uploadBytes: entry.uploadBytes.toString(),
|
||||
downloadBytes: entry.downloadBytes.toString(),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
String(left.deviceId).localeCompare(String(right.deviceId))
|
||||
|| left.source.localeCompare(right.source)
|
||||
|| left.outbound.localeCompare(right.outbound)
|
||||
)),
|
||||
series: [...totals.values()]
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
@@ -207,11 +336,159 @@ export function createDomainTrafficService({
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedConnections({
|
||||
connections,
|
||||
reset,
|
||||
closedIds = [],
|
||||
observed,
|
||||
deviceLabels,
|
||||
sourceActiveConnections,
|
||||
}: {
|
||||
connections: ParsedConnection[];
|
||||
reset: boolean;
|
||||
closedIds?: string[];
|
||||
observed: Date;
|
||||
deviceLabels: Map<string, string>;
|
||||
sourceActiveConnections?: number;
|
||||
}) {
|
||||
const nextConnections = reset
|
||||
? new Map<string, PreviousConnection>()
|
||||
: new Map(previousConnections);
|
||||
const activityEntries: ActivityEntry[] = [];
|
||||
for (const connection of connections) {
|
||||
const settled = settledNativeConnections.get(connection.id);
|
||||
const previous = previousConnections.get(connection.id)
|
||||
?? (connection.startedAt && settled?.startedAt === connection.startedAt ? settled : undefined);
|
||||
if (previous === settled) settledNativeConnections.delete(connection.id);
|
||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||
attributionEvents[connection.outcome] += 1n;
|
||||
}
|
||||
if (connection.outcome !== 'unsupported_source') {
|
||||
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
|
||||
? connection.upload - previous.trackedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
|
||||
? connection.download - previous.trackedDownload
|
||||
: connection.download;
|
||||
const trackedKey = `${connection.source}\0${connection.outbound}`;
|
||||
const tracked = trackedTotals.get(trackedKey) || {
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
tracked.uploadBytes += uploadDelta;
|
||||
tracked.downloadBytes += downloadDelta;
|
||||
trackedTotals.set(trackedKey, tracked);
|
||||
if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) {
|
||||
activityEntries.push({
|
||||
device: 'deviceId' in connection
|
||||
? deviceLabels.get(connection.deviceId) || 'Устройство'
|
||||
: 'Неизвестное устройство',
|
||||
service: 'service' in connection ? connection.service : 'Не распознано',
|
||||
upload: uploadDelta,
|
||||
download: downloadDelta,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||
nextConnections.set(connection.id, {
|
||||
startedAt: connection.startedAt,
|
||||
outcome: connection.outcome,
|
||||
countedUpload: previous?.countedUpload ?? null,
|
||||
countedDownload: previous?.countedDownload ?? null,
|
||||
trackedUpload: connection.outcome === 'unknown_device'
|
||||
? connection.upload
|
||||
: previous?.trackedUpload ?? null,
|
||||
trackedDownload: connection.outcome === 'unknown_device'
|
||||
? connection.download
|
||||
: previous?.trackedDownload ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||
overflowConnections += 1n;
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
key = `_other\0${domain}\0${connection.source}`;
|
||||
} else if (!totals.has(key)) {
|
||||
normalSeries += 1;
|
||||
}
|
||||
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||
? connection.upload - previous.countedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||
? connection.download - previous.countedDownload
|
||||
: connection.download;
|
||||
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
|
||||
const routeTotal = routeTotals.get(routeKey) || {
|
||||
deviceId: connection.deviceId,
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
routeTotal.uploadBytes += uploadDelta;
|
||||
routeTotal.downloadBytes += downloadDelta;
|
||||
routeTotals.set(routeKey, routeTotal);
|
||||
const total = totals.get(key) || {
|
||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||
domain,
|
||||
service,
|
||||
source: connection.source,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
total.uploadBytes += uploadDelta;
|
||||
total.downloadBytes += downloadDelta;
|
||||
totals.set(key, total);
|
||||
nextConnections.set(connection.id, {
|
||||
startedAt: connection.startedAt,
|
||||
outcome: connection.outcome,
|
||||
key,
|
||||
requestedKey,
|
||||
countedUpload: connection.upload,
|
||||
countedDownload: connection.download,
|
||||
trackedUpload: connection.upload,
|
||||
trackedDownload: connection.download,
|
||||
});
|
||||
}
|
||||
for (const id of closedIds) {
|
||||
const baseline = nextConnections.get(id);
|
||||
if (baseline?.startedAt) {
|
||||
settledNativeConnections.delete(id);
|
||||
settledNativeConnections.set(id, baseline);
|
||||
while (settledNativeConnections.size > 2_048) {
|
||||
settledNativeConnections.delete(settledNativeConnections.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
nextConnections.delete(id);
|
||||
}
|
||||
previousConnections = nextConnections;
|
||||
activeConnections = sourceActiveConnections ?? nextConnections.size;
|
||||
if (activityEnabled) {
|
||||
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
|
||||
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
|
||||
}
|
||||
current = { ...current, observedAt: observed.toISOString() };
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
try {
|
||||
const response = record(await observe());
|
||||
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||
const devicesByIp = new Map<string, string | null>();
|
||||
const deviceLabels = new Map<string, string>();
|
||||
const observedDevices = devices();
|
||||
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||
const device = record(value);
|
||||
@@ -219,67 +496,49 @@ export function createDomainTrafficService({
|
||||
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||
if (!net.isIPv4(ip) || !id) continue;
|
||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||
deviceLabels.set(id, publicDeviceLabel(device));
|
||||
}
|
||||
const activeConnections = new Map<string, PreviousConnection>();
|
||||
for (const rawConnection of response.connections) {
|
||||
const connection = parseConnection(rawConnection, devicesByIp);
|
||||
const previous = previousConnections.get(connection.id);
|
||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||
attributionEvents[connection.outcome] += 1n;
|
||||
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
|
||||
nativeEpoch = null;
|
||||
return applyParsedConnections({
|
||||
connections,
|
||||
reset: true,
|
||||
observed: now(),
|
||||
deviceLabels,
|
||||
sourceActiveConnections: response.connections.length,
|
||||
});
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ingestNative(batch: NativeTrafficProjectionBatch) {
|
||||
try {
|
||||
const observed = new Date(batch.observedAt);
|
||||
if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch');
|
||||
const deviceLabels = new Map<string, string>();
|
||||
for (const value of batch.connections) {
|
||||
const connection = record(value);
|
||||
const origin = record(connection.origin);
|
||||
if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) {
|
||||
deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label }));
|
||||
}
|
||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
countedUpload: previous?.countedUpload ?? null,
|
||||
countedDownload: previous?.countedDownload ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||
overflowConnections += 1n;
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
key = `_other\0${domain}\0${connection.source}`;
|
||||
} else if (!totals.has(key)) {
|
||||
normalSeries += 1;
|
||||
}
|
||||
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||
? connection.upload - previous.countedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||
? connection.download - previous.countedDownload
|
||||
: connection.download;
|
||||
const total = totals.get(key) || {
|
||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||
domain,
|
||||
service,
|
||||
source: connection.source,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
total.uploadBytes += uploadDelta;
|
||||
total.downloadBytes += downloadDelta;
|
||||
totals.set(key, total);
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
key,
|
||||
requestedKey,
|
||||
countedUpload: connection.upload,
|
||||
countedDownload: connection.download,
|
||||
});
|
||||
}
|
||||
previousConnections = activeConnections;
|
||||
current = { ...current, observedAt: now().toISOString() };
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
if (nativeEpoch !== batch.epoch) {
|
||||
nativeEpoch = batch.epoch;
|
||||
previousConnections = new Map();
|
||||
settledNativeConnections.clear();
|
||||
}
|
||||
const connections = batch.connections.map(parseNativeConnection);
|
||||
const result = applyParsedConnections({
|
||||
connections,
|
||||
reset: batch.reset,
|
||||
closedIds: batch.closedIds,
|
||||
observed,
|
||||
deviceLabels,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
@@ -295,5 +554,64 @@ export function createDomainTrafficService({
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh };
|
||||
function enableActivity() {
|
||||
if (activityEnabled) return;
|
||||
activityEnabled = true;
|
||||
activityStartedAt = now().getTime();
|
||||
activitySamples = [];
|
||||
quietSince = null;
|
||||
}
|
||||
|
||||
function disableActivity() {
|
||||
activityEnabled = false;
|
||||
activityStartedAt = 0;
|
||||
activitySamples = [];
|
||||
quietSince = null;
|
||||
}
|
||||
|
||||
function activitySnapshot(thresholdBytesPerSecond: unknown = 0) {
|
||||
if (!activityEnabled || !current.observedAt) return null;
|
||||
const observedAt = Date.parse(current.observedAt);
|
||||
const threshold = Math.max(0, Number(thresholdBytesPerSecond) || 0);
|
||||
const divisorMs = Math.max(1_000, Math.min(10_000, observedAt - activityStartedAt || 1_000));
|
||||
const totals = new Map<string, ActivityEntry>();
|
||||
let bytes = 0n;
|
||||
for (const sample of activitySamples) {
|
||||
for (const entry of sample.entries) {
|
||||
bytes += entry.upload + entry.download;
|
||||
const key = `${entry.device}\0${entry.service}`;
|
||||
const total = totals.get(key) || { ...entry, upload: 0n, download: 0n };
|
||||
total.upload += entry.upload;
|
||||
total.download += entry.download;
|
||||
totals.set(key, total);
|
||||
}
|
||||
}
|
||||
const totalBytesPerSecond = Number(bytes * 1_000n / BigInt(divisorMs));
|
||||
const active = totalBytesPerSecond > threshold;
|
||||
quietSince = active ? null : quietSince || current.observedAt;
|
||||
const latest = activitySamples.at(-1);
|
||||
return {
|
||||
state: active ? 'active' : 'quiet',
|
||||
observedAt: current.observedAt,
|
||||
windowMs: 10_000,
|
||||
thresholdBytesPerSecond: threshold,
|
||||
totalBytesPerSecond,
|
||||
transmittingConnections: latest?.entries.length || 0,
|
||||
quietSince,
|
||||
blockers: [...totals.values()]
|
||||
.map((entry) => ({
|
||||
device: entry.device,
|
||||
service: entry.service,
|
||||
uploadBytesPerSecond: Number(entry.upload * 1_000n / BigInt(divisorMs)),
|
||||
downloadBytesPerSecond: Number(entry.download * 1_000n / BigInt(divisorMs)),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
right.uploadBytesPerSecond + right.downloadBytesPerSecond
|
||||
- left.uploadBytesPerSecond - left.downloadBytesPerSecond
|
||||
))
|
||||
.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh, ingestNative, enableActivity, disableActivity, activitySnapshot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { createClient } from '@connectrpc/connect';
|
||||
import { createGrpcTransport } from '@connectrpc/connect-node';
|
||||
|
||||
import type { LiveTrafficConnection, LiveTrafficSnapshot, LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||
import {
|
||||
ConnectionEventType,
|
||||
StartedService,
|
||||
type Connection,
|
||||
type ConnectionEvents,
|
||||
type Status,
|
||||
} from '../generated/daemon/started_service_pb.js';
|
||||
|
||||
const CONNECTION_INTERVAL = 1_000_000_000n;
|
||||
const SUPPORTED_SINGBOX_VERSION = '1.14.0-rc.5';
|
||||
const SUPPORTED_SINGBOX_API_VERSION = 4;
|
||||
const MAX_VISIBLE = 256;
|
||||
const MAX_SETTLED_IDS = 2048;
|
||||
const MAX_RECENT_CONNECTIONS = 2048;
|
||||
const RECENT_CONNECTION_MS = 30_000;
|
||||
const RETRY_MS = 500;
|
||||
const STALE_MS = 3_000;
|
||||
const VPN_OUTBOUND_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
interface ActiveConnection {
|
||||
value: Omit<LiveTrafficConnection, 'traffic'>;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
uploadRate: bigint;
|
||||
downloadRate: bigint;
|
||||
}
|
||||
|
||||
interface LiveTrafficLedgerOptions {
|
||||
enabled?: boolean;
|
||||
now?: () => Date;
|
||||
gateway?: boolean;
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||
}
|
||||
|
||||
export interface NativeTrafficProjectionBatch {
|
||||
epoch: string;
|
||||
observedAt: string;
|
||||
reset: boolean;
|
||||
connections: LiveTrafficConnection[];
|
||||
closedIds: string[];
|
||||
}
|
||||
|
||||
interface NativeTrafficClient {
|
||||
getVersion(
|
||||
input: Record<string, never>,
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): Promise<{ version: string; apiVersion: number }>;
|
||||
getStartedAt(
|
||||
input: Record<string, never>,
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): Promise<{ startedAt: bigint }>;
|
||||
subscribeConnections(
|
||||
input: { interval: bigint },
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): AsyncIterable<ConnectionEvents>;
|
||||
subscribeStatus(
|
||||
input: { interval: bigint },
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): AsyncIterable<Status>;
|
||||
}
|
||||
|
||||
interface LiveTrafficServiceOptions {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
isRuntimeRunning: () => boolean;
|
||||
gateway?: boolean;
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||
authorization?: () => string | null;
|
||||
unavailableError?: string | null;
|
||||
onProjection?: (batch: NativeTrafficProjectionBatch) => Promise<void> | void;
|
||||
clientFactory?: (port: number) => NativeTrafficClient;
|
||||
}
|
||||
|
||||
function positive(value: bigint) {
|
||||
return value > 0n ? value : 0n;
|
||||
}
|
||||
|
||||
function safeError(error: unknown) {
|
||||
return (error instanceof Error ? error.message : String(error || 'Native traffic stream unavailable'))
|
||||
.replace(/https?:\/\/\S+/gi, '[endpoint]')
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
function parseEndpoint(value: string) {
|
||||
const text = value.trim();
|
||||
const bracketed = /^\[(.+)]:(\d+)$/.exec(text);
|
||||
if (bracketed) return { ip: bracketed[1], port: Number(bracketed[2]) };
|
||||
const separator = text.lastIndexOf(':');
|
||||
if (separator > 0 && !text.slice(0, separator).includes(':') && /^\d+$/.test(text.slice(separator + 1))) {
|
||||
return { ip: text.slice(0, separator), port: Number(text.slice(separator + 1)) };
|
||||
}
|
||||
return { ip: text, port: null };
|
||||
}
|
||||
|
||||
function isoFromMilliseconds(value: bigint, fallback: Date) {
|
||||
const milliseconds = Number(value);
|
||||
return Number.isSafeInteger(milliseconds) && milliseconds > 0
|
||||
? new Date(milliseconds).toISOString()
|
||||
: fallback.toISOString();
|
||||
}
|
||||
|
||||
function routeKindFromValues(
|
||||
outbound: string | null,
|
||||
outboundType: string | null,
|
||||
chain: string[] = [],
|
||||
gateway = false,
|
||||
): 'vpn' | 'direct' | 'other' {
|
||||
if (outbound === 'direct' || outboundType === 'direct') return 'direct';
|
||||
if (gateway) {
|
||||
if (chain[0] === 'direct') return 'direct';
|
||||
return chain.length > 0 ? 'vpn' : 'other';
|
||||
}
|
||||
return outboundType && VPN_OUTBOUND_TYPES.has(outboundType) ? 'vpn' : 'other';
|
||||
}
|
||||
|
||||
function routeKind(connection: Connection, gateway: boolean): 'vpn' | 'direct' | 'other' {
|
||||
return routeKindFromValues(
|
||||
connection.outbound || null,
|
||||
connection.outboundType || null,
|
||||
connection.chainList,
|
||||
gateway,
|
||||
);
|
||||
}
|
||||
|
||||
function macOrigin(): LiveTrafficConnection['origin'] {
|
||||
return {
|
||||
kind: 'this-mac',
|
||||
id: null,
|
||||
label: 'Этот Mac',
|
||||
provenance: 'client-runtime',
|
||||
};
|
||||
}
|
||||
|
||||
function unknownOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
id: null,
|
||||
label: sourceIp || 'Неизвестное устройство',
|
||||
provenance: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function mapConnection(
|
||||
connection: Connection,
|
||||
now: Date,
|
||||
gateway: boolean,
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'],
|
||||
): Omit<LiveTrafficConnection, 'traffic'> {
|
||||
const source = parseEndpoint(connection.source);
|
||||
const destination = parseEndpoint(connection.destination);
|
||||
const destinationHost = destination.ip.trim();
|
||||
const domain = connection.domain.trim().toLowerCase()
|
||||
|| (destinationHost && !isIP(destinationHost) ? destinationHost.toLowerCase() : null);
|
||||
const destinationIp = isIP(destinationHost) ? destinationHost : null;
|
||||
return {
|
||||
id: connection.id,
|
||||
startedAt: isoFromMilliseconds(connection.createdAt, now),
|
||||
closedAt: null,
|
||||
inbound: { tag: connection.inbound, type: connection.inboundType },
|
||||
network: connection.network === 'tcp' || connection.network === 'udp' ? connection.network : 'unknown',
|
||||
protocol: connection.protocol || null,
|
||||
source,
|
||||
destination: {
|
||||
domain,
|
||||
ip: destinationIp,
|
||||
port: destination.port,
|
||||
provenance: domain || destinationIp ? 'sing-box' : 'unknown',
|
||||
},
|
||||
origin: resolveOrigin?.(source.ip) ?? (gateway ? unknownOrigin(source.ip) : macOrigin()),
|
||||
route: {
|
||||
kind: routeKind(connection, gateway),
|
||||
scope: 'local-sing-box',
|
||||
outbound: connection.outbound || null,
|
||||
outboundType: connection.outboundType || null,
|
||||
chain: [...connection.chainList],
|
||||
rule: connection.rule || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFinalMetadata(
|
||||
current: Omit<LiveTrafficConnection, 'traffic'> | undefined,
|
||||
final: Omit<LiveTrafficConnection, 'traffic'> | null,
|
||||
gateway: boolean,
|
||||
) {
|
||||
if (!current) return final;
|
||||
if (!final) return current;
|
||||
const domain = final.destination.domain ?? current.destination.domain;
|
||||
const ip = final.destination.ip ?? current.destination.ip;
|
||||
const outbound = final.route.outbound ?? current.route.outbound;
|
||||
const outboundType = final.route.outboundType ?? current.route.outboundType;
|
||||
return {
|
||||
...current,
|
||||
inbound: {
|
||||
tag: final.inbound.tag || current.inbound.tag,
|
||||
type: final.inbound.type || current.inbound.type,
|
||||
},
|
||||
network: final.network === 'unknown' ? current.network : final.network,
|
||||
protocol: final.protocol ?? current.protocol,
|
||||
source: {
|
||||
ip: final.source.ip || current.source.ip,
|
||||
port: final.source.port ?? current.source.port,
|
||||
},
|
||||
destination: {
|
||||
domain,
|
||||
ip,
|
||||
port: final.destination.port ?? current.destination.port,
|
||||
provenance: domain || ip ? 'sing-box' as const : 'unknown' as const,
|
||||
},
|
||||
route: {
|
||||
...current.route,
|
||||
kind: routeKindFromValues(
|
||||
outbound,
|
||||
outboundType,
|
||||
final.route.chain.length ? final.route.chain : current.route.chain,
|
||||
gateway,
|
||||
),
|
||||
outbound,
|
||||
outboundType,
|
||||
chain: final.route.chain.length ? final.route.chain : current.route.chain,
|
||||
rule: final.route.rule ?? current.route.rule,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveTrafficLedger({
|
||||
enabled = true,
|
||||
now = () => new Date(),
|
||||
gateway = false,
|
||||
resolveOrigin,
|
||||
}: LiveTrafficLedgerOptions = {}) {
|
||||
let epoch: string | null = null;
|
||||
let sequence = 0;
|
||||
let observedAt: string | null = null;
|
||||
let state: LiveTrafficSourceState = enabled ? 'connecting' : 'disabled';
|
||||
let singBoxVersion: string | null = null;
|
||||
let singBoxApiVersion: number | null = null;
|
||||
let error: string | null = null;
|
||||
let accountedUpload = 0n;
|
||||
let accountedDownload = 0n;
|
||||
let explicitGapUpload = 0n;
|
||||
let explicitGapDownload = 0n;
|
||||
let statusGapUpload = 0n;
|
||||
let statusGapDownload = 0n;
|
||||
let mismatchCount = 0;
|
||||
let resetSeen = false;
|
||||
let statusSeen = false;
|
||||
let projectionError = false;
|
||||
let lastStatus: Status | null = null;
|
||||
const active = new Map<string, ActiveConnection>();
|
||||
const recent = new Map<string, ActiveConnection>();
|
||||
const settled = new Map<string, true>();
|
||||
|
||||
const changed = (updateObservedAt = true) => {
|
||||
sequence += 1;
|
||||
if (updateObservedAt) observedAt = now().toISOString();
|
||||
};
|
||||
const settle = (id: string) => {
|
||||
if (!id) return;
|
||||
settled.delete(id);
|
||||
settled.set(id, true);
|
||||
while (settled.size > MAX_SETTLED_IDS) settled.delete(settled.keys().next().value as string);
|
||||
};
|
||||
const rememberRecent = (connection: ActiveConnection) => {
|
||||
const id = connection.value.id;
|
||||
recent.delete(id);
|
||||
recent.set(id, connection);
|
||||
while (recent.size > MAX_RECENT_CONNECTIONS) recent.delete(recent.keys().next().value as string);
|
||||
};
|
||||
const pruneRecent = (timestamp: Date) => {
|
||||
const cutoff = timestamp.getTime() - RECENT_CONNECTION_MS;
|
||||
for (const [id, connection] of recent) {
|
||||
const closedAt = connection.value.closedAt;
|
||||
if (closedAt !== null && Date.parse(closedAt) <= cutoff) recent.delete(id);
|
||||
}
|
||||
};
|
||||
const clearEpoch = () => {
|
||||
active.clear();
|
||||
recent.clear();
|
||||
settled.clear();
|
||||
accountedUpload = 0n;
|
||||
accountedDownload = 0n;
|
||||
explicitGapUpload = 0n;
|
||||
explicitGapDownload = 0n;
|
||||
statusGapUpload = 0n;
|
||||
statusGapDownload = 0n;
|
||||
mismatchCount = 0;
|
||||
resetSeen = false;
|
||||
statusSeen = false;
|
||||
projectionError = false;
|
||||
lastStatus = null;
|
||||
};
|
||||
const reconcileStatus = (countMismatch = false) => {
|
||||
if (!lastStatus) return;
|
||||
const statusUpload = positive(lastStatus.uplinkTotal);
|
||||
const statusDownload = positive(lastStatus.downlinkTotal);
|
||||
statusGapUpload = statusUpload > accountedUpload ? statusUpload - accountedUpload : 0n;
|
||||
statusGapDownload = statusDownload > accountedDownload ? statusDownload - accountedDownload : 0n;
|
||||
const mismatch = active.size !== lastStatus.connectionsIn
|
||||
|| statusGapUpload > 0n
|
||||
|| statusGapDownload > 0n
|
||||
|| accountedUpload > statusUpload
|
||||
|| accountedDownload > statusDownload;
|
||||
if (countMismatch) mismatchCount = mismatch ? mismatchCount + 1 : 0;
|
||||
if (resetSeen && statusSeen) state = mismatchCount >= 3 || projectionError ? 'degraded' : 'live';
|
||||
};
|
||||
const addUnattributed = (upload: bigint, download: bigint) => {
|
||||
const safeUpload = positive(upload);
|
||||
const safeDownload = positive(download);
|
||||
explicitGapUpload += safeUpload;
|
||||
explicitGapDownload += safeDownload;
|
||||
accountedUpload += safeUpload;
|
||||
accountedDownload += safeDownload;
|
||||
};
|
||||
const project = (connection: ActiveConnection): LiveTrafficConnection => ({
|
||||
...connection.value,
|
||||
origin: resolveOrigin?.(connection.value.source.ip) ?? connection.value.origin,
|
||||
traffic: {
|
||||
uploadBytes: connection.upload.toString(),
|
||||
downloadBytes: connection.download.toString(),
|
||||
uploadBytesPerSecond: connection.uploadRate.toString(),
|
||||
downloadBytesPerSecond: connection.downloadRate.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
beginEpoch(startedAt: bigint, version: string, apiVersion: number) {
|
||||
const nextEpoch = `sing-box-${startedAt}`;
|
||||
const epochChanged = nextEpoch !== epoch;
|
||||
if (epochChanged) clearEpoch();
|
||||
epoch = nextEpoch;
|
||||
singBoxVersion = version;
|
||||
singBoxApiVersion = apiVersion;
|
||||
error = null;
|
||||
state = 'connecting';
|
||||
changed(epochChanged || observedAt === null);
|
||||
},
|
||||
|
||||
applyConnections(batch: ConnectionEvents) {
|
||||
const timestamp = now();
|
||||
const touched = new Set<string>();
|
||||
const closedIds = new Set<string>();
|
||||
const closedConnections = new Map<string, LiveTrafficConnection>();
|
||||
pruneRecent(timestamp);
|
||||
if (batch.reset || batch.events.some(({ type }) => (
|
||||
type === ConnectionEventType.CONNECTION_EVENT_UPDATE
|
||||
))) {
|
||||
for (const connection of active.values()) {
|
||||
connection.uploadRate = 0n;
|
||||
connection.downloadRate = 0n;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.reset) {
|
||||
const next = new Map<string, ActiveConnection>();
|
||||
for (const event of batch.events) {
|
||||
const connection = event.connection;
|
||||
if (!connection?.id) continue;
|
||||
touched.add(connection.id);
|
||||
const upload = positive(connection.uplinkTotal);
|
||||
const download = positive(connection.downlinkTotal);
|
||||
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||
const recentPrevious = recent.get(connection.id);
|
||||
const previous = active.get(connection.id)
|
||||
?? (recentPrevious?.value.startedAt === mapped.startedAt ? recentPrevious : undefined);
|
||||
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||
const alreadySettled = settled.has(connection.id);
|
||||
if (!alreadySettled) {
|
||||
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||
const closedAt = event.closedAt > 0n
|
||||
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||
: isoFromMilliseconds(connection.closedAt, timestamp);
|
||||
const settledConnection = {
|
||||
value: { ...mapped, closedAt },
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
};
|
||||
closedConnections.set(connection.id, project(settledConnection));
|
||||
rememberRecent(settledConnection);
|
||||
}
|
||||
closedIds.add(connection.id);
|
||||
settle(connection.id);
|
||||
continue;
|
||||
}
|
||||
recent.delete(connection.id);
|
||||
settled.delete(connection.id);
|
||||
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||
next.set(connection.id, {
|
||||
value: mapped,
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
});
|
||||
}
|
||||
for (const [id] of active) {
|
||||
if (next.has(id)) continue;
|
||||
closedIds.add(id);
|
||||
settle(id);
|
||||
}
|
||||
active.clear();
|
||||
for (const [id, connection] of next) active.set(id, connection);
|
||||
resetSeen = true;
|
||||
} else {
|
||||
for (const event of batch.events) {
|
||||
const id = event.id || event.connection?.id || '';
|
||||
if (!id) continue;
|
||||
touched.add(id);
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW) {
|
||||
const connection = event.connection;
|
||||
if (!connection || active.has(id)) continue;
|
||||
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||
settle(id);
|
||||
continue;
|
||||
}
|
||||
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||
const previous = recent.get(id);
|
||||
if (!previous && settled.has(id)) continue;
|
||||
if (previous && mapped.startedAt <= previous.value.startedAt) continue;
|
||||
recent.delete(id);
|
||||
settled.delete(id);
|
||||
const upload = positive(connection.uplinkTotal);
|
||||
const download = positive(connection.downlinkTotal);
|
||||
active.set(id, {
|
||||
value: mapped,
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
});
|
||||
accountedUpload += upload;
|
||||
accountedDownload += download;
|
||||
continue;
|
||||
}
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE) {
|
||||
const upload = positive(event.uplinkDelta);
|
||||
const download = positive(event.downlinkDelta);
|
||||
const connection = active.get(id);
|
||||
if (!connection) continue;
|
||||
connection.upload += upload;
|
||||
connection.download += download;
|
||||
connection.uploadRate += upload;
|
||||
connection.downloadRate += download;
|
||||
accountedUpload += upload;
|
||||
accountedDownload += download;
|
||||
continue;
|
||||
}
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED
|
||||
&& !settled.has(id) && !recent.has(id)) {
|
||||
const current = active.get(id);
|
||||
const finalUpload = event.connection ? positive(event.connection.uplinkTotal) : 0n;
|
||||
const finalDownload = event.connection ? positive(event.connection.downlinkTotal) : 0n;
|
||||
const tailUpload = event.connection
|
||||
? (current && finalUpload > current.upload ? finalUpload - current.upload : current ? 0n : finalUpload)
|
||||
: positive(event.uplinkDelta);
|
||||
const tailDownload = event.connection
|
||||
? (current && finalDownload > current.download ? finalDownload - current.download : current ? 0n : finalDownload)
|
||||
: positive(event.downlinkDelta);
|
||||
if (current) {
|
||||
accountedUpload += tailUpload;
|
||||
accountedDownload += tailDownload;
|
||||
} else if (event.connection) {
|
||||
addUnattributed(tailUpload, tailDownload);
|
||||
}
|
||||
const metadata = mergeFinalMetadata(
|
||||
current?.value,
|
||||
event.connection ? mapConnection(event.connection, timestamp, gateway, resolveOrigin) : null,
|
||||
gateway,
|
||||
);
|
||||
if (metadata) {
|
||||
const closedAt = event.closedAt > 0n
|
||||
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||
: event.connection && event.connection.closedAt > 0n
|
||||
? isoFromMilliseconds(event.connection.closedAt, timestamp)
|
||||
: timestamp.toISOString();
|
||||
const settledConnection = {
|
||||
value: { ...metadata, id, closedAt },
|
||||
upload: current ? current.upload + tailUpload : finalUpload,
|
||||
download: current ? current.download + tailDownload : finalDownload,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
};
|
||||
closedConnections.set(id, project(settledConnection));
|
||||
rememberRecent(settledConnection);
|
||||
}
|
||||
active.delete(id);
|
||||
closedIds.add(id);
|
||||
settle(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
reconcileStatus();
|
||||
changed();
|
||||
if (!epoch) return null;
|
||||
const connections: LiveTrafficConnection[] = [];
|
||||
for (const id of touched) {
|
||||
const settledConnection = closedConnections.get(id);
|
||||
if (settledConnection) connections.push(settledConnection);
|
||||
else {
|
||||
const connection = active.get(id);
|
||||
if (connection) connections.push(project(connection));
|
||||
}
|
||||
}
|
||||
return {
|
||||
epoch,
|
||||
observedAt: timestamp.toISOString(),
|
||||
reset: batch.reset,
|
||||
connections,
|
||||
closedIds: [...closedIds],
|
||||
} satisfies NativeTrafficProjectionBatch;
|
||||
},
|
||||
|
||||
applyStatus(status: Status) {
|
||||
const timestamp = now();
|
||||
pruneRecent(timestamp);
|
||||
lastStatus = status;
|
||||
statusSeen = true;
|
||||
reconcileStatus(true);
|
||||
changed();
|
||||
return epoch && state === 'live' ? {
|
||||
epoch,
|
||||
observedAt: timestamp.toISOString(),
|
||||
reset: false,
|
||||
connections: [],
|
||||
closedIds: [],
|
||||
} satisfies NativeTrafficProjectionBatch : null;
|
||||
},
|
||||
|
||||
markStopped() {
|
||||
if (state === 'stopped' && active.size === 0) return;
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
state = 'stopped';
|
||||
error = null;
|
||||
changed();
|
||||
},
|
||||
|
||||
markTransportError(reason: unknown) {
|
||||
error = safeError(reason);
|
||||
state = epoch ? 'stale' : 'connecting';
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markProjectionError(reason: unknown) {
|
||||
projectionError = true;
|
||||
error = safeError(reason);
|
||||
state = 'degraded';
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markProjectionHealthy() {
|
||||
if (!projectionError) return;
|
||||
projectionError = false;
|
||||
error = null;
|
||||
reconcileStatus();
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markUnavailable(reason: unknown) {
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
state = 'incompatible';
|
||||
error = safeError(reason);
|
||||
changed();
|
||||
},
|
||||
|
||||
markIncompatible(version: string, apiVersion: number) {
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
singBoxVersion = version;
|
||||
singBoxApiVersion = apiVersion;
|
||||
state = 'incompatible';
|
||||
error = `sing-box ${version} API ${apiVersion} is incompatible`;
|
||||
changed();
|
||||
},
|
||||
|
||||
snapshot(): LiveTrafficSnapshot {
|
||||
const recentCutoff = now().getTime() - RECENT_CONNECTION_MS;
|
||||
const all = [...active.values()];
|
||||
all.sort((left, right) => right.value.startedAt.localeCompare(left.value.startedAt)
|
||||
|| left.value.id.localeCompare(right.value.id));
|
||||
const allRecent = [...recent.values()].filter(({ value }) => (
|
||||
value.closedAt !== null && Date.parse(value.closedAt) > recentCutoff
|
||||
));
|
||||
allRecent.sort((left, right) => (right.value.closedAt ?? '').localeCompare(left.value.closedAt ?? '')
|
||||
|| left.value.id.localeCompare(right.value.id));
|
||||
const visible = all.slice(0, MAX_VISIBLE);
|
||||
if (visible.length < MAX_VISIBLE) visible.push(...allRecent.slice(0, MAX_VISIBLE - visible.length));
|
||||
const connections = visible.map(project);
|
||||
const recognized = all.filter(({ value }) => value.destination.domain !== null).length;
|
||||
return {
|
||||
apiVersion: 1,
|
||||
epoch,
|
||||
sequence,
|
||||
observedAt,
|
||||
capabilities: {
|
||||
lifecycle: true,
|
||||
deviceAttribution: Boolean(resolveOrigin),
|
||||
applicationAttribution: false,
|
||||
},
|
||||
source: {
|
||||
transport: 'native',
|
||||
state,
|
||||
completeness: 'lifecycle',
|
||||
singBoxVersion,
|
||||
singBoxApiVersion,
|
||||
error,
|
||||
unattributedUploadBytes: (explicitGapUpload + statusGapUpload).toString(),
|
||||
unattributedDownloadBytes: (explicitGapDownload + statusGapDownload).toString(),
|
||||
},
|
||||
summary: {
|
||||
active: all.length,
|
||||
recent: allRecent.length,
|
||||
visible: connections.length,
|
||||
recognized,
|
||||
unresolved: all.length - recognized,
|
||||
unresolvedOrigin: all.filter((connection) => project(connection).origin.kind === 'unknown').length,
|
||||
truncated: all.length + allRecent.length > MAX_VISIBLE,
|
||||
},
|
||||
connections,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultClientFactory(port: number): NativeTrafficClient {
|
||||
return createClient(StartedService, createGrpcTransport({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
|
||||
export function createLiveTrafficService({
|
||||
port,
|
||||
enabled,
|
||||
isRuntimeRunning,
|
||||
gateway = false,
|
||||
resolveOrigin,
|
||||
authorization,
|
||||
unavailableError = null,
|
||||
onProjection,
|
||||
clientFactory = defaultClientFactory,
|
||||
}: LiveTrafficServiceOptions) {
|
||||
const ledger = createLiveTrafficLedger({ enabled, gateway, resolveOrigin });
|
||||
if (!enabled && unavailableError) ledger.markUnavailable(unavailableError);
|
||||
let stopped = false;
|
||||
let controller: AbortController | null = null;
|
||||
let running: Promise<void> | null = null;
|
||||
let failedProjection: NativeTrafficProjectionBatch | null = null;
|
||||
let projectionQueue = Promise.resolve();
|
||||
|
||||
const project = (batch: NativeTrafficProjectionBatch) => {
|
||||
if (!onProjection) return Promise.resolve();
|
||||
const run = async () => {
|
||||
if (failedProjection) {
|
||||
const retry = failedProjection;
|
||||
try {
|
||||
await onProjection(retry);
|
||||
failedProjection = null;
|
||||
} catch (reason) {
|
||||
ledger.markProjectionError(reason);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onProjection(batch);
|
||||
ledger.markProjectionHealthy();
|
||||
} catch (reason) {
|
||||
failedProjection = batch;
|
||||
ledger.markProjectionError(reason);
|
||||
throw reason;
|
||||
}
|
||||
};
|
||||
const result = projectionQueue.then(run, run);
|
||||
projectionQueue = result.catch(() => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const attach = async () => {
|
||||
const client = clientFactory(port);
|
||||
const signal = controller?.signal;
|
||||
if (!signal) return;
|
||||
const secret = authorization?.();
|
||||
const options = secret ? { signal, headers: { authorization: `Bearer ${secret}` } } : { signal };
|
||||
const version = await client.getVersion({}, options);
|
||||
if (version.version !== SUPPORTED_SINGBOX_VERSION
|
||||
|| version.apiVersion !== SUPPORTED_SINGBOX_API_VERSION) {
|
||||
ledger.markIncompatible(version.version, version.apiVersion);
|
||||
return;
|
||||
}
|
||||
const started = await client.getStartedAt({}, options);
|
||||
ledger.beginEpoch(started.startedAt, version.version, version.apiVersion);
|
||||
let lastStatusAt = Date.now();
|
||||
const watchdog = setInterval(() => {
|
||||
if (!isRuntimeRunning() || Date.now() - lastStatusAt > STALE_MS) {
|
||||
controller?.abort(new Error(isRuntimeRunning() ? 'Native traffic status is stale' : 'sing-box stopped'));
|
||||
}
|
||||
}, RETRY_MS);
|
||||
watchdog.unref();
|
||||
const streams = [
|
||||
(async () => {
|
||||
for await (const batch of client.subscribeConnections({ interval: CONNECTION_INTERVAL }, options)) {
|
||||
const projection = ledger.applyConnections(batch);
|
||||
if (projection) await project(projection);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
for await (const status of client.subscribeStatus({ interval: CONNECTION_INTERVAL }, options)) {
|
||||
lastStatusAt = Date.now();
|
||||
const projection = ledger.applyStatus(status);
|
||||
if (projection) await project(projection);
|
||||
}
|
||||
})(),
|
||||
];
|
||||
try {
|
||||
await Promise.race(streams);
|
||||
throw new Error('Native traffic stream ended');
|
||||
} finally {
|
||||
controller?.abort(new Error('Native traffic stream ended'));
|
||||
await Promise.allSettled(streams);
|
||||
clearInterval(watchdog);
|
||||
}
|
||||
};
|
||||
|
||||
const loop = async () => {
|
||||
while (!stopped) {
|
||||
if (!isRuntimeRunning()) {
|
||||
ledger.markStopped();
|
||||
await delay(RETRY_MS);
|
||||
continue;
|
||||
}
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await attach();
|
||||
} catch (reason) {
|
||||
if (!stopped) {
|
||||
if (isRuntimeRunning()) ledger.markTransportError(reason);
|
||||
else ledger.markStopped();
|
||||
}
|
||||
} finally {
|
||||
controller = null;
|
||||
}
|
||||
if (!stopped) await delay(RETRY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (!enabled || running) return;
|
||||
running = loop();
|
||||
},
|
||||
async stop() {
|
||||
stopped = true;
|
||||
controller?.abort();
|
||||
await running;
|
||||
},
|
||||
snapshot: ledger.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export type LiveTrafficService = ReturnType<typeof createLiveTrafficService>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import http from 'node:http';
|
||||
import {
|
||||
FAILOVER_PRIMARY_TAG,
|
||||
FAILOVER_RESERVE_TAG,
|
||||
FAILOVER_SELECTOR_TAG,
|
||||
} from '../singbox.js';
|
||||
|
||||
type Role = 'primary' | 'reserve';
|
||||
const SELECTOR_READY_ATTEMPTS = 20;
|
||||
const SELECTOR_READY_DELAY_MS = 100;
|
||||
|
||||
const transientStartupError = (error: unknown) => (
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? ['ECONNREFUSED', 'ECONNRESET'].includes(String(error.code))
|
||||
: false
|
||||
);
|
||||
|
||||
async function whenReady<T>(operation: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (!transientStartupError(error) || attempt === SELECTOR_READY_ATTEMPTS) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, SELECTOR_READY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(port: number, method: string, body?: unknown): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const encoded = body === undefined ? null : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/proxies/${encodeURIComponent(FAILOVER_SELECTOR_TAG)}`,
|
||||
method,
|
||||
headers: encoded ? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(encoded),
|
||||
} : {},
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if ((res.statusCode || 500) >= 400) return reject(new Error(`Sing-box selector HTTP ${res.statusCode}`));
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch (cause) {
|
||||
reject(new Error('Sing-box selector вернул невалидный JSON', { cause }));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.setTimeout(2_000, () => req.destroy(new Error('Sing-box selector timeout')));
|
||||
req.on('error', reject);
|
||||
req.end(encoded);
|
||||
});
|
||||
}
|
||||
|
||||
const tagFor = (role: Role) => role === 'primary' ? FAILOVER_PRIMARY_TAG : FAILOVER_RESERVE_TAG;
|
||||
const roleFor = (tag: unknown): Role | null => (
|
||||
tag === FAILOVER_PRIMARY_TAG ? 'primary' : tag === FAILOVER_RESERVE_TAG ? 'reserve' : null
|
||||
);
|
||||
|
||||
export function createSingboxSelectorService({
|
||||
port,
|
||||
send = (method: string, body?: unknown) => request(port, method, body),
|
||||
}: {
|
||||
port: number;
|
||||
send?: (method: string, body?: unknown) => Promise<unknown>;
|
||||
}) {
|
||||
async function read() {
|
||||
const value = await whenReady(() => send('GET')) as Record<string, unknown>;
|
||||
const role = roleFor(value.now);
|
||||
if (!role) throw new Error('Sing-box selector вернул неизвестный outbound');
|
||||
return { role };
|
||||
}
|
||||
async function select(role: Role) {
|
||||
await whenReady(() => send('PUT', { name: tagFor(role) }));
|
||||
const selected = await read();
|
||||
if (selected.role !== role) throw new Error('Sing-box selector не подтвердил переключение');
|
||||
return selected;
|
||||
}
|
||||
return { read, select };
|
||||
}
|
||||
@@ -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 { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
import {
|
||||
normalizeStoredState,
|
||||
type PersistedState,
|
||||
} from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES, normalizeRouteRules } 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 = 8;
|
||||
|
||||
export interface AtomicWriteOptions {
|
||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||
@@ -59,6 +68,23 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
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,63 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio
|
||||
const routeRules = version < 3
|
||||
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
|
||||
: stored.routeRules;
|
||||
const migratedRouteRules = normalizeRouteRules(routeRules, { strict: version >= 6 });
|
||||
const migratedAppliedRouteRules = normalizeRouteRules(stored.appliedRouteRules, { strict: version >= 6 });
|
||||
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: migratedRouteRules,
|
||||
appliedRouteRules: migratedAppliedRouteRules,
|
||||
...(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<string, unknown>;
|
||||
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<T>(options: JsonStoreOptions<T>): JsonStore<T>;
|
||||
@@ -204,14 +286,17 @@ export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStor
|
||||
|
||||
export function createStateStore(
|
||||
filePath: string,
|
||||
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
|
||||
options: Partial<Omit<JsonStoreOptions<PersistedState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> & {
|
||||
legacySubscriptionCache?: unknown;
|
||||
} = {},
|
||||
) {
|
||||
return createJsonStore<StoredState & { schemaVersion: number }>({
|
||||
const { legacySubscriptionCache = null, ...storeOptions } = options;
|
||||
return createJsonStore<PersistedState & { schemaVersion: number }>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
+159
-12
@@ -1,13 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import { normalizeRouteRules } from '../shared/routingRules.js';
|
||||
import type { AppliedFailoverPolicy } from '../shared/failover.js';
|
||||
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const MIXED_INBOUND = 'mixed-in';
|
||||
const TPROXY_INBOUND = 'tproxy-in';
|
||||
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
|
||||
const DIAGNOSTICS_PRIMARY_INBOUND = 'diagnostics-primary-in';
|
||||
const DIAGNOSTICS_RESERVE_INBOUND = 'diagnostics-reserve-in';
|
||||
export const FAILOVER_SELECTOR_TAG = 'channel-selector';
|
||||
export const FAILOVER_PRIMARY_TAG = 'channel-primary';
|
||||
export const FAILOVER_RESERVE_TAG = 'channel-reserve';
|
||||
const SNIFF_TIMEOUT = '1s';
|
||||
const SNIFFERS = ['http', 'tls', 'quic'];
|
||||
|
||||
@@ -34,18 +41,68 @@ function findOutbound(subscriptionConfig: unknown, selectedTag: unknown): ProxyO
|
||||
));
|
||||
}
|
||||
|
||||
function selectedOutbound(subscriptionConfig: unknown, selectedTag: unknown, tag?: string) {
|
||||
const outbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!outbound) throw new HarborError('SERVER_NOT_FOUND');
|
||||
if (tag) outbound.tag = tag;
|
||||
else if (!outbound.tag) outbound.tag = 'vpn-out';
|
||||
if (outbound.type === 'vless' && !outbound.packet_encoding) outbound.packet_encoding = 'xudp';
|
||||
return outbound;
|
||||
}
|
||||
|
||||
export interface DualChannelConfig {
|
||||
primary: { subscriptionConfig: unknown; selectedServerId: string };
|
||||
reserve: { subscriptionConfig: unknown; selectedServerId: string };
|
||||
}
|
||||
|
||||
export function fingerprintConfiguredOutbound(value: unknown, selectedServerId: string) {
|
||||
const outbound = structuredClone(record(value)) as ProxyOutbound;
|
||||
if (!PROXY_TYPES.has(String(outbound.type || ''))) throw new HarborError('CONFIG_INVALID');
|
||||
outbound.tag = selectedServerId;
|
||||
if (outbound.type === 'vless' && !outbound.packet_encoding) outbound.packet_encoding = 'xudp';
|
||||
return crypto.createHash('sha256').update(JSON.stringify(outbound)).digest('hex');
|
||||
}
|
||||
|
||||
export function fingerprintSelectedOutbound(subscriptionConfig: unknown, selectedServerId: string) {
|
||||
const outbound = findOutbound(subscriptionConfig, selectedServerId);
|
||||
if (!outbound) throw new HarborError('SERVER_NOT_FOUND');
|
||||
return fingerprintConfiguredOutbound(outbound, selectedServerId);
|
||||
}
|
||||
|
||||
export function dualChannelConfigMatchesApplied(
|
||||
configValue: unknown,
|
||||
applied: AppliedFailoverPolicy,
|
||||
expectedRole: 'primary' | 'reserve',
|
||||
) {
|
||||
const config = record(configValue);
|
||||
const outbounds = (Array.isArray(config.outbounds) ? config.outbounds : []).map(record);
|
||||
const primary = outbounds.filter(({ tag }) => tag === FAILOVER_PRIMARY_TAG);
|
||||
const reserve = outbounds.filter(({ tag }) => tag === FAILOVER_RESERVE_TAG);
|
||||
const selector = outbounds.find(({ tag }) => tag === FAILOVER_SELECTOR_TAG);
|
||||
try {
|
||||
return primary.length === 1
|
||||
&& reserve.length === 1
|
||||
&& fingerprintConfiguredOutbound(primary[0], applied.primary.serverId) === applied.primaryConfigFingerprint
|
||||
&& fingerprintConfiguredOutbound(reserve[0], applied.reserve.serverId) === applied.reserveConfigFingerprint
|
||||
&& selector?.type === 'selector'
|
||||
&& JSON.stringify(selector.outbounds) === JSON.stringify([FAILOVER_PRIMARY_TAG, FAILOVER_RESERVE_TAG])
|
||||
&& selector.default === (expectedRole === 'reserve' ? FAILOVER_RESERVE_TAG : FAILOVER_PRIMARY_TAG)
|
||||
&& selector.interrupt_exist_connections === false
|
||||
&& record(config.route).final === FAILOVER_SELECTOR_TAG;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unknown, {
|
||||
clientDirect = false,
|
||||
routeRules = [],
|
||||
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
||||
const clientMode = settings.appMode === 'client';
|
||||
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
const directClient = clientMode && clientDirect;
|
||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
|
||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = 'xudp';
|
||||
}
|
||||
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
|
||||
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
||||
|
||||
const inbounds = [
|
||||
@@ -70,9 +127,12 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
set_system_proxy: false,
|
||||
},
|
||||
];
|
||||
const directRules = normalizeRouteRules(routeRules)
|
||||
const userRules = (directClient ? [] : normalizeRouteRules(routeRules))
|
||||
.filter((rule) => rule.enabled)
|
||||
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
|
||||
.map((rule) => ({
|
||||
[rule.type]: [rule.value],
|
||||
outbound: rule.outbound === 'vpn' ? vpnOutbound.tag : 'direct',
|
||||
}));
|
||||
const rules = clientMode
|
||||
? [
|
||||
{
|
||||
@@ -82,7 +142,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
timeout: SNIFF_TIMEOUT,
|
||||
},
|
||||
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
|
||||
...directRules,
|
||||
...userRules,
|
||||
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
||||
]
|
||||
: [
|
||||
@@ -93,25 +153,32 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
timeout: SNIFF_TIMEOUT,
|
||||
},
|
||||
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
|
||||
...directRules,
|
||||
...userRules,
|
||||
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
|
||||
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
||||
];
|
||||
|
||||
return {
|
||||
log: { level: settings.logLevel, timestamp: true },
|
||||
...(nativeTraffic ? {
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: settings.singboxNativeApiPort,
|
||||
dashboard: false,
|
||||
}],
|
||||
} : {}),
|
||||
experimental: {
|
||||
cache_file: { enabled: true, path: settings.cachePath },
|
||||
...(!clientMode ? {
|
||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||
} : {}),
|
||||
},
|
||||
dns: { independent_cache: true },
|
||||
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||
inbounds,
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
{ type: 'block', tag: 'block' },
|
||||
],
|
||||
route: {
|
||||
rule_set: [],
|
||||
@@ -122,6 +189,86 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function buildDualChannelGatewayConfig(
|
||||
channels: DualChannelConfig,
|
||||
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
|
||||
) {
|
||||
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
|
||||
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
const primary = selectedOutbound(
|
||||
channels.primary.subscriptionConfig,
|
||||
channels.primary.selectedServerId,
|
||||
FAILOVER_PRIMARY_TAG,
|
||||
);
|
||||
const reserve = selectedOutbound(
|
||||
channels.reserve.subscriptionConfig,
|
||||
channels.reserve.selectedServerId,
|
||||
FAILOVER_RESERVE_TAG,
|
||||
);
|
||||
const userRules = normalizeRouteRules(routeRules)
|
||||
.filter((rule) => rule.enabled)
|
||||
.map((rule) => ({
|
||||
[rule.type]: [rule.value],
|
||||
outbound: rule.outbound === 'vpn' ? FAILOVER_SELECTOR_TAG : 'direct',
|
||||
}));
|
||||
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
|
||||
return {
|
||||
log: { level: settings.logLevel, timestamp: true },
|
||||
...(nativeTraffic ? {
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: settings.singboxNativeApiPort,
|
||||
dashboard: false,
|
||||
}],
|
||||
} : {}),
|
||||
experimental: {
|
||||
cache_file: { enabled: true, path: settings.cachePath },
|
||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||
},
|
||||
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||
inbounds: [
|
||||
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort },
|
||||
{ type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false },
|
||||
{ type: 'mixed', tag: DIAGNOSTICS_INBOUND, listen: '127.0.0.1', listen_port: settings.diagnosticsProxyPort, set_system_proxy: false },
|
||||
{ type: 'mixed', tag: DIAGNOSTICS_PRIMARY_INBOUND, listen: '127.0.0.1', listen_port: settings.failoverPrimaryProxyPort, set_system_proxy: false },
|
||||
{ type: 'mixed', tag: DIAGNOSTICS_RESERVE_INBOUND, listen: '127.0.0.1', listen_port: settings.failoverReserveProxyPort, set_system_proxy: false },
|
||||
],
|
||||
outbounds: [
|
||||
primary,
|
||||
reserve,
|
||||
{
|
||||
type: 'selector',
|
||||
tag: FAILOVER_SELECTOR_TAG,
|
||||
outbounds: [FAILOVER_PRIMARY_TAG, FAILOVER_RESERVE_TAG],
|
||||
default: defaultRole === 'reserve' ? FAILOVER_RESERVE_TAG : FAILOVER_PRIMARY_TAG,
|
||||
interrupt_exist_connections: false,
|
||||
},
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
],
|
||||
route: {
|
||||
rule_set: [],
|
||||
rules: [
|
||||
{
|
||||
inbound: [TPROXY_INBOUND, MIXED_INBOUND, DIAGNOSTICS_INBOUND, DIAGNOSTICS_PRIMARY_INBOUND, DIAGNOSTICS_RESERVE_INBOUND],
|
||||
action: 'sniff',
|
||||
sniffer: SNIFFERS,
|
||||
timeout: SNIFF_TIMEOUT,
|
||||
},
|
||||
{ inbound: [DIAGNOSTICS_PRIMARY_INBOUND], outbound: FAILOVER_PRIMARY_TAG },
|
||||
{ inbound: [DIAGNOSTICS_RESERVE_INBOUND], outbound: FAILOVER_RESERVE_TAG },
|
||||
{ inbound: [DIAGNOSTICS_INBOUND], outbound: FAILOVER_SELECTOR_TAG },
|
||||
...userRules,
|
||||
{ inbound: userInbounds, outbound: FAILOVER_SELECTOR_TAG },
|
||||
],
|
||||
final: FAILOVER_SELECTOR_TAG,
|
||||
auto_detect_interface: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSingboxConfig(config: unknown) {
|
||||
atomicWriteJson(settings.configPath, config);
|
||||
}
|
||||
|
||||
@@ -3,27 +3,76 @@ import fs from 'node:fs';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import { setGatewayInterception } from './gatewayRouting.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import {
|
||||
materializeGatewayNativeConfig,
|
||||
materializeGatewaySnapshotConfig,
|
||||
} from './gatewayNativeRuntime.js';
|
||||
|
||||
export function createSingboxRuntime({
|
||||
configPath,
|
||||
gateway = false,
|
||||
tproxyChain = '',
|
||||
gatewayRuntimeConfigPath,
|
||||
nativeApi,
|
||||
}: {
|
||||
configPath: string;
|
||||
gateway?: boolean;
|
||||
tproxyChain?: string;
|
||||
gatewayRuntimeConfigPath?: string;
|
||||
nativeApi?: {
|
||||
apiPort: number;
|
||||
secretPath: string;
|
||||
runtimeConfigPath: string;
|
||||
};
|
||||
}) {
|
||||
let child: ChildProcess | null = null;
|
||||
let configHash = '';
|
||||
let startedAt: string | null = null;
|
||||
let nativeApiSecret: string | null = null;
|
||||
let nativeApiWarning: string | null = null;
|
||||
|
||||
const state = () => ({ running: Boolean(child), startedAt });
|
||||
const state = () => ({ running: Boolean(child), startedAt, nativeApiWarning });
|
||||
|
||||
function checked(configFile: string) {
|
||||
const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfig(config: unknown) {
|
||||
if (nativeApi) {
|
||||
const materialized = materializeGatewayNativeConfig(config, nativeApi);
|
||||
checked(materialized.configPath);
|
||||
return {
|
||||
valid: true,
|
||||
...(materialized.warning ? { warning: materialized.warning } : {}),
|
||||
};
|
||||
}
|
||||
if (gatewayRuntimeConfigPath) {
|
||||
const materialized = materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath);
|
||||
checked(materialized.configPath);
|
||||
return { valid: true };
|
||||
}
|
||||
const directory = fs.mkdtempSync(`${configPath}.check-`);
|
||||
const candidatePath = `${directory}/config.json`;
|
||||
try {
|
||||
fs.writeFileSync(candidatePath, JSON.stringify(config));
|
||||
checked(candidatePath);
|
||||
return { valid: true };
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (gateway) setGatewayInterception(false, tproxyChain);
|
||||
if (!child) {
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -31,6 +80,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
@@ -48,23 +98,37 @@ export function createSingboxRuntime({
|
||||
async function apply({ force = false } = {}) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
await stop();
|
||||
nativeApiWarning = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
let materialized = { configPath, secret: null as string | null, warning: null as string | null };
|
||||
if (nativeApi || gatewayRuntimeConfigPath) {
|
||||
let config: unknown;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch (cause) {
|
||||
throw new HarborError('CONFIG_INVALID', { cause });
|
||||
}
|
||||
materialized = nativeApi
|
||||
? materializeGatewayNativeConfig(config, nativeApi)
|
||||
: materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath!);
|
||||
}
|
||||
checked(materialized.configPath);
|
||||
|
||||
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
|
||||
if (!force && child && nextHash === configHash) return state();
|
||||
const nextHash = crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(materialized.configPath))
|
||||
.digest('hex');
|
||||
if (!force && child && nextHash === configHash) {
|
||||
nativeApiSecret = materialized.secret;
|
||||
nativeApiWarning = materialized.warning;
|
||||
return state();
|
||||
}
|
||||
|
||||
await stop();
|
||||
let current: ChildProcess;
|
||||
try {
|
||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
||||
current = spawn('sing-box', ['run', '-c', materialized.configPath], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -77,6 +141,8 @@ export function createSingboxRuntime({
|
||||
child = current;
|
||||
configHash = nextHash;
|
||||
startedAt = new Date().toISOString();
|
||||
nativeApiSecret = materialized.secret;
|
||||
nativeApiWarning = materialized.warning;
|
||||
try {
|
||||
if (gateway) setGatewayInterception(true, tproxyChain);
|
||||
} catch (error) {
|
||||
@@ -84,6 +150,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause: error });
|
||||
}
|
||||
current.once('exit', () => {
|
||||
@@ -91,6 +158,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
if (gateway) setGatewayInterception(false, tproxyChain);
|
||||
});
|
||||
return state();
|
||||
@@ -99,7 +167,10 @@ export function createSingboxRuntime({
|
||||
return {
|
||||
get running() { return Boolean(child); },
|
||||
get startedAt() { return startedAt; },
|
||||
get nativeApiSecret() { return nativeApiSecret; },
|
||||
get nativeApiWarning() { return nativeApiWarning; },
|
||||
refresh: async () => state(),
|
||||
checkConfig,
|
||||
apply,
|
||||
restart: () => apply({ force: true }),
|
||||
stop,
|
||||
|
||||
@@ -36,6 +36,16 @@ function outboundRecord(value: unknown): SubscriptionOutbound {
|
||||
return record(value) as SubscriptionOutbound;
|
||||
}
|
||||
|
||||
function connectionVariantKey(value: unknown) {
|
||||
const outbound = record(value);
|
||||
const transport = record(outbound.transport);
|
||||
if (transport.type !== 'ws') return '';
|
||||
const headers = record(transport.headers);
|
||||
const tls = record(outbound.tls);
|
||||
// ponytail: WS routing distinguishes current same-endpoint variants; extend when another real transport needs it.
|
||||
return ['ws', transport.path || '/', headers.Host || headers.host || '', tls.server_name || ''].join('\u0000');
|
||||
}
|
||||
|
||||
function usableProxyOutbound(value: unknown) {
|
||||
const outbound = outboundRecord(value);
|
||||
const host = String(outbound.server || '').trim().toLowerCase();
|
||||
@@ -107,11 +117,52 @@ export function parseVlessUrl(rawUrl: string) {
|
||||
const serverName = parsed.searchParams.get('sni') || server;
|
||||
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
|
||||
const flow = parsed.searchParams.get('flow') || '';
|
||||
const security = parsed.searchParams.get('security') || '';
|
||||
const transportType = parsed.searchParams.get('type') || 'tcp';
|
||||
const encryption = parsed.searchParams.get('encryption') || '';
|
||||
|
||||
if (!uuid || !server || !serverPort) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
if (security === 'tls' && transportType === 'ws') {
|
||||
if (encryption && encryption !== 'none') {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
const websocketHost = parsed.searchParams.get('host') || '';
|
||||
const alpn = (parsed.searchParams.get('alpn') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
type: 'vless',
|
||||
tag,
|
||||
server,
|
||||
server_port: serverPort,
|
||||
uuid,
|
||||
flow,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: serverName,
|
||||
...(alpn.length ? { alpn } : {}),
|
||||
utls: {
|
||||
enabled: true,
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
transport: {
|
||||
type: 'ws',
|
||||
path: parsed.searchParams.get('path') || '/',
|
||||
...(websocketHost ? { headers: { Host: websocketHost } } : {}),
|
||||
},
|
||||
packet_encoding: 'xudp',
|
||||
};
|
||||
}
|
||||
|
||||
if ((security && security !== 'reality') || !['tcp', 'raw'].includes(transportType)) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
if (!publicKey || !shortId) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
@@ -169,8 +220,12 @@ export function normalizeSubscriptionConfig(value: unknown) {
|
||||
rejectedOutbounds.push(outbound);
|
||||
return [];
|
||||
}
|
||||
const id = createServerId(outbound);
|
||||
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
|
||||
const endpointId = createServerId(outbound);
|
||||
const variant = connectionVariantKey(outbound);
|
||||
const id = variant
|
||||
? `srv_${crypto.createHash('sha256').update(`${endpointId}\u0000${variant}`).digest('hex').slice(0, 16)}`
|
||||
: endpointId;
|
||||
// ponytail: endpoint plus WS route deduplicates indistinguishable entries; include provider IDs if a real feed needs more.
|
||||
if (seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
servers.push(normalizeServer({ ...outbound, id }));
|
||||
@@ -244,11 +299,17 @@ export function selectRefreshedServer(
|
||||
nextServers: readonly HarborServer[],
|
||||
) {
|
||||
if (!currentServerId) return '';
|
||||
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
||||
const previous = currentServers.find((server) => server.id === currentServerId);
|
||||
if (!previous) return '';
|
||||
const identity = serverIdentityKey(previous);
|
||||
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
|
||||
if (matches.some((server) => server.id === currentServerId)) {
|
||||
return currentServerId === createServerId(previous) && matches.length > 1 ? '' : currentServerId;
|
||||
}
|
||||
if (
|
||||
currentServerId !== createServerId(previous) ||
|
||||
currentServers.filter((server) => serverIdentityKey(server) === identity).length !== 1
|
||||
) return '';
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
export const ACTIVITY_JOURNAL_RETENTION_DAYS = 30;
|
||||
export const ACTIVITY_JOURNAL_MAX_EVENTS = 10_000;
|
||||
|
||||
export const ACTIVITY_EVENT_TYPES = [
|
||||
'connection.started', 'connection.stopped', 'connection.failed',
|
||||
'subscription.added', 'subscription.refreshed', 'subscription.refresh_failed', 'subscription.deleted',
|
||||
'failover.enabled', 'failover.disabled', 'failover.paused', 'failover.resumed',
|
||||
'failover.waiting_for_idle', 'failover.switched', 'failover.switch_failed',
|
||||
'failover.both_unhealthy',
|
||||
'failover.primary_unavailable', 'failover.primary_recovered',
|
||||
'failover.reserve_unavailable', 'failover.reserve_recovered',
|
||||
'failover.recovered', 'journal.recovered',
|
||||
] as const;
|
||||
|
||||
export type ActivityEventType = typeof ACTIVITY_EVENT_TYPES[number];
|
||||
export type ActivityEventSeverity = 'info' | 'warning' | 'error';
|
||||
export type ActivityEventSource = 'connection' | 'subscription' | 'failover' | 'storage';
|
||||
|
||||
export interface ActivityJournalEvent {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
type: ActivityEventType | 'unknown';
|
||||
severity: ActivityEventSeverity;
|
||||
source: ActivityEventSource;
|
||||
dedupeKey: string | null;
|
||||
data: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
export type ActivityJournalEventInput = Omit<ActivityJournalEvent, 'id' | 'occurredAt'>;
|
||||
|
||||
export interface ActivityJournalPage {
|
||||
events: ActivityJournalEvent[];
|
||||
nextCursor: string | null;
|
||||
retentionDays: 30;
|
||||
generatedAt: string;
|
||||
storage: { status: 'ready' | 'error'; errorCode: string | null };
|
||||
}
|
||||
|
||||
const ALLOWED_DATA_KEYS: Record<ActivityEventType, readonly string[]> = {
|
||||
'connection.started': ['profileLabel', 'serverLabel'],
|
||||
'connection.stopped': [],
|
||||
'connection.failed': ['errorCode'],
|
||||
'subscription.added': ['profileId', 'profileLabel', 'host', 'serverCount'],
|
||||
'subscription.refreshed': ['profileId', 'profileLabel', 'host', 'serverCount', 'added', 'removed'],
|
||||
'subscription.refresh_failed': ['profileId', 'profileLabel', 'host', 'errorCode'],
|
||||
'subscription.deleted': ['profileId', 'profileLabel'],
|
||||
'failover.enabled': ['primaryLabel', 'reserveLabel'],
|
||||
'failover.disabled': [],
|
||||
'failover.paused': [],
|
||||
'failover.resumed': [],
|
||||
'failover.waiting_for_idle': ['fromRole', 'toRole', 'reason'],
|
||||
'failover.switched': ['fromRole', 'toRole', 'primaryLabel', 'reserveLabel', 'reason', 'manual'],
|
||||
'failover.switch_failed': ['fromRole', 'toRole', 'reason', 'errorCode'],
|
||||
'failover.both_unhealthy': ['reason'],
|
||||
'failover.primary_unavailable': ['role', 'reason'],
|
||||
'failover.primary_recovered': ['role', 'reason'],
|
||||
'failover.reserve_unavailable': ['role', 'reason'],
|
||||
'failover.reserve_recovered': ['role', 'reason'],
|
||||
'failover.recovered': ['role', 'reason'],
|
||||
'journal.recovered': [],
|
||||
};
|
||||
|
||||
const typeSet = new Set<string>(ACTIVITY_EVENT_TYPES);
|
||||
const severitySet = new Set(['info', 'warning', 'error']);
|
||||
const sourceSet = new Set(['connection', 'subscription', 'failover', 'storage']);
|
||||
const record = (value: unknown): Record<string, unknown> => (
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
);
|
||||
|
||||
const LABEL_KEYS = new Set(['profileLabel', 'serverLabel', 'primaryLabel', 'reserveLabel']);
|
||||
const ROLE_KEYS = new Set(['fromRole', 'toRole', 'role']);
|
||||
const SAFE_LABEL_FALLBACKS: Record<string, string> = {
|
||||
profileLabel: 'Подписка',
|
||||
serverLabel: 'Сервер',
|
||||
primaryLabel: 'Основной канал',
|
||||
reserveLabel: 'Резервный канал',
|
||||
};
|
||||
|
||||
function safeScalar(key: string, value: unknown) {
|
||||
if (value === null || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
|
||||
if (typeof value !== 'string' || value.length > 120 || /[\r\n]/.test(value)) {
|
||||
throw new TypeError('Unsafe journal value');
|
||||
}
|
||||
if (LABEL_KEYS.has(key) && (
|
||||
!value.trim()
|
||||
|| /(?:[a-z][a-z0-9+.-]*:\/\/)|[\/?#@\\]/i.test(value)
|
||||
|| /(?:^|\D)(?:\d{1,3}\.){3}\d{1,3}(?:\D|$)/.test(value)
|
||||
|| /(?:^|[^0-9a-f])(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}(?:[^0-9a-f]|$)/i.test(value)
|
||||
)) return SAFE_LABEL_FALLBACKS[key];
|
||||
if (ROLE_KEYS.has(key) && !['primary', 'reserve'].includes(value)) throw new TypeError('Unsafe journal role');
|
||||
if (key === 'errorCode' && !/^[A-Z0-9_]{1,50}$/.test(value)) throw new TypeError('Unsafe journal error code');
|
||||
if (key === 'reason' && !/^[a-z0-9-]{1,80}$/.test(value)) throw new TypeError('Unsafe journal reason');
|
||||
if (key === 'profileId' && !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) throw new TypeError('Unsafe journal profile id');
|
||||
if (key === 'host' && (
|
||||
!/^[a-z0-9.-]{1,120}$/i.test(value)
|
||||
|| /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)
|
||||
|| value.includes('..')
|
||||
)) return 'Провайдер';
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeActivityEventInput(value: unknown): ActivityJournalEventInput {
|
||||
const candidate = record(value);
|
||||
const type = String(candidate.type || '') as ActivityEventType;
|
||||
const severity = String(candidate.severity || '') as ActivityEventSeverity;
|
||||
const source = String(candidate.source || '') as ActivityEventSource;
|
||||
if (!typeSet.has(type) || !severitySet.has(severity) || !sourceSet.has(source)) {
|
||||
throw new TypeError('Unknown journal event');
|
||||
}
|
||||
const inputData = record(candidate.data);
|
||||
const allowed = new Set(ALLOWED_DATA_KEYS[type]);
|
||||
if (Object.keys(inputData).some((key) => !allowed.has(key))) throw new TypeError('Unsafe journal data key');
|
||||
const data = Object.fromEntries(Object.entries(inputData).map(([key, item]) => [key, safeScalar(key, item)]));
|
||||
const dedupeKey = candidate.dedupeKey == null ? null : String(candidate.dedupeKey).trim();
|
||||
if (dedupeKey !== null && (
|
||||
!dedupeKey.startsWith(`${type}:`)
|
||||
|| !/^[a-zA-Z0-9_.:-]{1,160}$/.test(dedupeKey)
|
||||
)) {
|
||||
throw new TypeError('Invalid journal dedupe key');
|
||||
}
|
||||
return { type, severity, source, dedupeKey, data };
|
||||
}
|
||||
|
||||
export function normalizeStoredActivityEvent(value: unknown): ActivityJournalEvent | null {
|
||||
const candidate = record(value);
|
||||
const id = typeof candidate.id === 'string' && /^[a-f0-9-]{20,50}$/i.test(candidate.id) ? candidate.id : '';
|
||||
const occurredAt = typeof candidate.occurredAt === 'string' && Number.isFinite(Date.parse(candidate.occurredAt))
|
||||
? candidate.occurredAt
|
||||
: '';
|
||||
if (!id || !occurredAt) return null;
|
||||
try {
|
||||
return { id, occurredAt, ...normalizeActivityEventInput(candidate) };
|
||||
} catch {
|
||||
const severity = String(candidate.severity || '') as ActivityEventSeverity;
|
||||
const source = String(candidate.source || '') as ActivityEventSource;
|
||||
return severitySet.has(severity) && sourceSet.has(source) && typeof candidate.type === 'string'
|
||||
&& /^[a-z][a-z0-9_.-]{0,79}$/.test(candidate.type)
|
||||
? { id, occurredAt, type: 'unknown', severity, source, dedupeKey: null, data: {} }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertActivityJournalPage(value: unknown): ActivityJournalPage {
|
||||
const candidate = record(value);
|
||||
const events = Array.isArray(candidate.events) ? candidate.events.map(normalizeStoredActivityEvent) : [];
|
||||
const storage = record(candidate.storage);
|
||||
if (
|
||||
!Array.isArray(candidate.events) || events.some((event) => event === null)
|
||||
|| !(candidate.nextCursor === null || typeof candidate.nextCursor === 'string')
|
||||
|| candidate.retentionDays !== ACTIVITY_JOURNAL_RETENTION_DAYS
|
||||
|| typeof candidate.generatedAt !== 'string' || !Number.isFinite(Date.parse(candidate.generatedAt))
|
||||
|| !['ready', 'error'].includes(String(storage.status || ''))
|
||||
|| !(storage.errorCode === null || typeof storage.errorCode === 'string')
|
||||
) throw new TypeError('Invalid activity journal page');
|
||||
return {
|
||||
events: events as ActivityJournalEvent[],
|
||||
nextCursor: candidate.nextCursor as string | null,
|
||||
retentionDays: 30,
|
||||
generatedAt: candidate.generatedAt,
|
||||
storage: { status: storage.status as 'ready' | 'error', errorCode: storage.errorCode as string | null },
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,12 @@ export const CONNECTIVITY_IP_SOURCES = Object.freeze([
|
||||
{ id: 'ipify-v6', label: 'ipify IPv6', family: 6, url: 'https://api6.ipify.org' },
|
||||
]);
|
||||
|
||||
export const CONNECTIVITY_NETWORK_SOURCE = Object.freeze({
|
||||
id: 'network',
|
||||
label: 'Сеть',
|
||||
url: 'https://ipwho.is/',
|
||||
});
|
||||
|
||||
export const CONNECTIVITY_SITES = Object.freeze([
|
||||
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
|
||||
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' },
|
||||
@@ -19,6 +25,73 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
||||
|
||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||
|
||||
export interface DiagnosticService {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticSettings {
|
||||
configured: boolean;
|
||||
customServices: DiagnosticService[];
|
||||
hiddenServiceIds: string[];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function diagnosticService(value: unknown): DiagnosticService | null {
|
||||
const candidate = record(value);
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||
if (!/^custom-[a-z0-9-]{1,80}$/i.test(id) || !label || label.length > 40) return null;
|
||||
try {
|
||||
const url = new URL(typeof candidate.url === 'string' ? candidate.url.trim() : '');
|
||||
if (
|
||||
url.protocol !== 'https:'
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| (url.port && url.port !== '443')
|
||||
) return null;
|
||||
return { id, label, url: url.href };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDiagnosticSettings(
|
||||
value: unknown,
|
||||
{ strict = false }: { strict?: boolean } = {},
|
||||
): DiagnosticSettings {
|
||||
const candidate = record(value);
|
||||
const requestedServices = Array.isArray(candidate.customServices) ? candidate.customServices : [];
|
||||
const customServices = requestedServices
|
||||
.map(diagnosticService)
|
||||
.filter((service): service is DiagnosticService => Boolean(service))
|
||||
.filter((service, index, services) => services.findIndex(({ id }) => id === service.id) === index)
|
||||
.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES);
|
||||
const builtInIds = new Set(CONNECTIVITY_SITES.map(({ id }) => id));
|
||||
const requestedHiddenIds = Array.isArray(candidate.hiddenServiceIds) ? candidate.hiddenServiceIds : [];
|
||||
const hiddenServiceIds = requestedHiddenIds
|
||||
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
||||
.filter((id, index, ids) => ids.indexOf(id) === index);
|
||||
if (strict && (
|
||||
typeof candidate.configured !== 'boolean'
|
||||
|| !Array.isArray(candidate.customServices)
|
||||
|| !Array.isArray(candidate.hiddenServiceIds)
|
||||
|| customServices.length !== requestedServices.length
|
||||
|| hiddenServiceIds.length !== requestedHiddenIds.length
|
||||
)) throw new TypeError('Invalid diagnostic settings');
|
||||
return {
|
||||
configured: candidate.configured === true,
|
||||
customServices,
|
||||
hiddenServiceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectivitySiteResult {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -32,6 +105,7 @@ export interface ConnectivityPathResult {
|
||||
internetAvailable: boolean;
|
||||
ipv4: { addresses: string[]; [key: string]: unknown };
|
||||
ipv6: string | null;
|
||||
network?: unknown;
|
||||
sites: ConnectivitySiteResult[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
+360
-40
@@ -1,5 +1,23 @@
|
||||
import { normalizeRouteRules } from '../routingRules.js';
|
||||
import {
|
||||
normalizeRouteRules,
|
||||
ROUTE_RULES_CONTRACT_VERSION,
|
||||
type RouteRuleOutbound,
|
||||
} from '../routingRules.js';
|
||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||
import {
|
||||
normalizeDiagnosticSettings,
|
||||
type DiagnosticSettings,
|
||||
} from '../connectivityDiagnostics.js';
|
||||
import {
|
||||
createIdleFailoverSnapshot,
|
||||
normalizeAppliedFailoverPolicy,
|
||||
normalizeFailoverPolicy,
|
||||
normalizeFailoverRuntimeState,
|
||||
type AppliedFailoverPolicy,
|
||||
type FailoverPolicy,
|
||||
type FailoverRuntimeState,
|
||||
type FailoverSnapshot,
|
||||
} from '../failover.js';
|
||||
|
||||
export type HarborMode = 'client' | 'gateway';
|
||||
export type ConnectionState = 'running' | 'stopped';
|
||||
@@ -18,6 +36,35 @@ export interface RouteRule {
|
||||
type: 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
outbound: RouteRuleOutbound;
|
||||
}
|
||||
|
||||
export interface StoredProfile {
|
||||
id: string;
|
||||
label: string;
|
||||
subscriptionUrl: string;
|
||||
subscriptionConfig: unknown;
|
||||
servers: HarborServer[];
|
||||
userInfo: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
lastRefreshAttemptAt: string | null;
|
||||
errorCode: string | null;
|
||||
};
|
||||
desiredServerId: string;
|
||||
servers: HarborServer[];
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
@@ -25,20 +72,30 @@ export interface StateSnapshot {
|
||||
revision: number;
|
||||
generatedAt: string;
|
||||
mode: HarborMode;
|
||||
profiles: ProfileSnapshot[];
|
||||
subscription: {
|
||||
status: 'missing' | 'ready';
|
||||
status: 'missing' | 'ready' | 'stale';
|
||||
host: string;
|
||||
fetchedAt: string | null;
|
||||
userInfo: Record<string, unknown>;
|
||||
};
|
||||
selection: { desiredServerId: string; appliedServerId: string };
|
||||
selection: {
|
||||
desiredProfileId: string;
|
||||
desiredServerId: string;
|
||||
appliedProfileId: string;
|
||||
appliedServerId: string;
|
||||
appliedServerSnapshot: HarborServer | null;
|
||||
};
|
||||
connection: {
|
||||
desired: ConnectionState;
|
||||
process: ConnectionState;
|
||||
startedAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
diagnostics: DiagnosticSettings;
|
||||
failover: FailoverSnapshot;
|
||||
route: {
|
||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||
mode: string;
|
||||
gatewayAddress: string | null;
|
||||
gatewayUiOrigin: string | null;
|
||||
@@ -56,24 +113,40 @@ 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<string, unknown> {
|
||||
export interface PersistedState extends Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
diagnostics: DiagnosticSettings;
|
||||
failoverPolicy: FailoverPolicy;
|
||||
failoverRuntimeState: FailoverRuntimeState;
|
||||
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
||||
}
|
||||
|
||||
// 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<string, unknown>;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
@@ -97,6 +170,8 @@ export interface OperationState {
|
||||
status: OperationStatus;
|
||||
startedAt: string | null;
|
||||
error: string | null;
|
||||
profileId?: string | null;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
const MODES = new Set<HarborMode>(['client', 'gateway']);
|
||||
@@ -109,37 +184,156 @@ const dateOrNull = (value: unknown) => (
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
|
||||
);
|
||||
|
||||
export function normalizeStoredState(value: unknown): StoredState {
|
||||
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value)
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
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<PersistedState, 'profiles'>, profileId: unknown) {
|
||||
const id = identityText(profileId);
|
||||
return state.profiles.find((profile) => profile.id === id) || null;
|
||||
}
|
||||
|
||||
export function desiredProfile(state: Pick<PersistedState, 'profiles' | 'desiredProfileId'>) {
|
||||
return profileById(state, state.desiredProfileId);
|
||||
}
|
||||
|
||||
export function appliedProfile(state: Pick<PersistedState, 'profiles' | 'appliedProfileId'>) {
|
||||
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,
|
||||
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
||||
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
||||
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
||||
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,8 +343,8 @@ export function createStateSnapshot({
|
||||
gatewayAuto,
|
||||
appMode,
|
||||
configExists,
|
||||
subscriptionHost,
|
||||
operation = { kind: null, status: 'idle', startedAt: null, error: null },
|
||||
failoverSnapshot,
|
||||
now = new Date(),
|
||||
}: {
|
||||
storedState: unknown;
|
||||
@@ -158,17 +352,20 @@ export function createStateSnapshot({
|
||||
gatewayAuto?: GatewayAutoState | null;
|
||||
appMode?: string;
|
||||
configExists: boolean;
|
||||
subscriptionHost: string;
|
||||
subscriptionHost?: string;
|
||||
operation?: OperationState;
|
||||
failoverSnapshot?: FailoverSnapshot | null;
|
||||
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,30 +375,44 @@ export function createStateSnapshot({
|
||||
: routeMode === 'gateway-direct'
|
||||
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
||||
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||
const localRulesBypassed = mode === 'client' && routeMode === 'gateway-direct';
|
||||
const activeLocalRules = running && !localRulesBypassed ? 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,
|
||||
},
|
||||
diagnostics: stored.diagnostics,
|
||||
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
||||
route: {
|
||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||
mode: routeMode,
|
||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
||||
@@ -212,20 +423,50 @@ export function createStateSnapshot({
|
||||
localRules: stored.routeRules,
|
||||
activeLocalRules,
|
||||
localRulesRevision: stored.routeRulesRevision,
|
||||
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
|
||||
localRulesPendingRestart: localRulesBypassed
|
||||
? false
|
||||
: !isSameRules(stored.routeRules, activeLocalRules),
|
||||
},
|
||||
operation: {
|
||||
kind: nullableText(operation.kind),
|
||||
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 || [],
|
||||
});
|
||||
}
|
||||
|
||||
export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
const candidate = snapshot as StateSnapshot;
|
||||
const rawCandidate = snapshot as StateSnapshot;
|
||||
const legacyRule = (rule: RouteRule): RouteRule => (
|
||||
rule && !Object.hasOwn(rule, 'outbound')
|
||||
? { ...rule, outbound: 'direct' }
|
||||
: rule
|
||||
);
|
||||
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||
: rawCandidate;
|
||||
const candidateWithFailover = candidateWithDiagnostics && candidateWithDiagnostics.failover === undefined
|
||||
? {
|
||||
...candidateWithDiagnostics,
|
||||
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
||||
}
|
||||
: candidateWithDiagnostics;
|
||||
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
||||
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
||||
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
||||
? {
|
||||
...candidateWithFailover,
|
||||
route: {
|
||||
...candidateWithFailover.route,
|
||||
localRules: candidateWithFailover.route.localRules.map(legacyRule),
|
||||
activeLocalRules: candidateWithFailover.route.activeLocalRules.map(legacyRule),
|
||||
},
|
||||
}
|
||||
: candidateWithFailover;
|
||||
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const nullableDate = (value: unknown) => value === null || validDate(value);
|
||||
const nullableString = (value: unknown) => value === null || typeof value === 'string';
|
||||
@@ -243,8 +484,65 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
||||
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
|
||||
typeof rule.value === 'string' &&
|
||||
Boolean(rule.value) &&
|
||||
typeof rule.enabled === 'boolean'
|
||||
typeof rule.enabled === 'boolean' &&
|
||||
['vpn', 'direct'].includes(rule.outbound)
|
||||
);
|
||||
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)
|
||||
);
|
||||
const validDiagnostics = (diagnostics: DiagnosticSettings) => {
|
||||
try {
|
||||
normalizeDiagnosticSettings(diagnostics, { strict: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validFailover = (value: FailoverSnapshot) => {
|
||||
const channel = (item: FailoverSnapshot['primary']) => (
|
||||
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
||||
&& ['healthy', 'unhealthy', 'unknown', 'not-monitoring'].includes(item.health)
|
||||
&& Array.isArray(item.failingServiceIds) && item.failingServiceIds.every((id) => typeof id === 'string')
|
||||
&& nullableDate(item.checkedAt) && nullableDate(item.stateSince)
|
||||
);
|
||||
const activity = value.trafficActivity;
|
||||
try {
|
||||
normalizeFailoverPolicy(value.policy, { strict: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return channel(value.primary) && channel(value.reserve)
|
||||
&& nullableDate(value.nextDecisionAt) && nullableString(value.reason)
|
||||
&& (activity === null || (
|
||||
['active', 'quiet', 'unknown'].includes(activity.state)
|
||||
&& validDate(activity.observedAt)
|
||||
&& [activity.windowMs, activity.thresholdBytesPerSecond, activity.totalBytesPerSecond, activity.transmittingConnections]
|
||||
.every((number) => Number.isFinite(number) && number >= 0)
|
||||
&& nullableDate(activity.quietSince)
|
||||
&& (activity.switchTarget === null || ['primary', 'reserve'].includes(activity.switchTarget))
|
||||
&& Array.isArray(activity.blockers)
|
||||
&& activity.blockers.length <= 3
|
||||
&& activity.blockers.every((blocker) => (
|
||||
typeof blocker.device === 'string' && typeof blocker.service === 'string'
|
||||
&& Number.isFinite(blocker.uploadBytesPerSecond) && blocker.uploadBytesPerSecond >= 0
|
||||
&& Number.isFinite(blocker.downloadBytesPerSecond) && blocker.downloadBytesPerSecond >= 0
|
||||
))
|
||||
));
|
||||
};
|
||||
|
||||
if (
|
||||
!snapshot ||
|
||||
@@ -253,22 +551,42 @@ 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) ||
|
||||
!nullableDate(candidate.connection.startedAt) ||
|
||||
!nullableString(candidate.connection.lastError) ||
|
||||
!validDiagnostics(candidate.diagnostics) ||
|
||||
!candidate.failover ||
|
||||
typeof candidate.failover.observationEpoch !== 'string' ||
|
||||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
||||
candidate.failover.observationSequence < 0 ||
|
||||
typeof candidate.failover.enabled !== 'boolean' ||
|
||||
typeof candidate.failover.paused !== 'boolean' ||
|
||||
typeof candidate.failover.configured !== 'boolean' ||
|
||||
!candidate.failover.policy ||
|
||||
!['inactive', 'active', 'pending', 'passive-loaded'].includes(candidate.failover.activation) ||
|
||||
!['primary', 'reserve', 'other', 'none'].includes(candidate.failover.currentRole) ||
|
||||
!['idle', 'observing', 'primary', 'reserve', 'waiting-for-idle', 'blocked', 'switching', 'error'].includes(candidate.failover.status) ||
|
||||
!validFailover(candidate.failover) ||
|
||||
!candidate.route ||
|
||||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
|
||||
typeof candidate.route.mode !== 'string' ||
|
||||
!nullableString(candidate.route.gatewayAddress) ||
|
||||
!nullableString(candidate.route.gatewayUiOrigin) ||
|
||||
@@ -288,6 +606,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)
|
||||
) {
|
||||
|
||||
@@ -14,9 +14,14 @@ 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 },
|
||||
DEVICE_TAG_NOT_FOUND: { status: 404, message: 'Тег больше недоступен.', retryable: false },
|
||||
DEVICE_TAG_NAME_CONFLICT: { status: 409, message: 'Тег с таким именем уже существует.', retryable: false },
|
||||
DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
|
||||
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
|
||||
DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true },
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
export type FailoverRole = 'primary' | 'reserve';
|
||||
export type FailoverHealth = 'healthy' | 'unhealthy' | 'unknown' | 'not-monitoring';
|
||||
|
||||
export interface FailoverTarget {
|
||||
profileId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface FailoverCheck {
|
||||
serviceId: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface FailoverPolicy {
|
||||
version: 1;
|
||||
enabled: boolean;
|
||||
paused: boolean;
|
||||
primary: FailoverTarget;
|
||||
reserve: FailoverTarget;
|
||||
checks: FailoverCheck[];
|
||||
intervalMs: number;
|
||||
failureWindowMs: number;
|
||||
recoveryWindowMs: number;
|
||||
trafficGuard: {
|
||||
enabled: boolean;
|
||||
thresholdBytesPerSecond: number;
|
||||
quietWindowMs: number;
|
||||
};
|
||||
minimumReserveMs: number;
|
||||
flapProtection: {
|
||||
count: number;
|
||||
windowMs: number;
|
||||
quarantineMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FailoverRuntimeState {
|
||||
lastSwitchAt: string | null;
|
||||
holdUntil: string | null;
|
||||
primaryQuarantineUntil: string | null;
|
||||
failoverHistory: string[];
|
||||
reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedFailoverPolicy {
|
||||
primary: FailoverTarget;
|
||||
reserve: FailoverTarget;
|
||||
primaryConfigFingerprint: string;
|
||||
reserveConfigFingerprint: string;
|
||||
}
|
||||
|
||||
export interface FailoverActivity {
|
||||
state: 'active' | 'quiet' | 'unknown';
|
||||
observedAt: string;
|
||||
windowMs: number;
|
||||
thresholdBytesPerSecond: number;
|
||||
totalBytesPerSecond: number;
|
||||
transmittingConnections: number;
|
||||
quietSince: string | null;
|
||||
switchTarget: FailoverRole | null;
|
||||
blockers: Array<{
|
||||
device: string;
|
||||
service: string;
|
||||
uploadBytesPerSecond: number;
|
||||
downloadBytesPerSecond: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FailoverSnapshot {
|
||||
observationEpoch: string;
|
||||
observationSequence: number;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
paused: boolean;
|
||||
activation: 'inactive' | 'active' | 'pending' | 'passive-loaded';
|
||||
currentRole: FailoverRole | 'other' | 'none';
|
||||
status: 'idle' | 'observing' | 'primary' | 'reserve' | 'waiting-for-idle' | 'blocked' | 'switching' | 'error';
|
||||
primary: { target: FailoverTarget; health: FailoverHealth; failingServiceIds: string[]; checkedAt: string | null; stateSince: string | null };
|
||||
reserve: { target: FailoverTarget; health: FailoverHealth; failingServiceIds: string[]; checkedAt: string | null; stateSince: string | null };
|
||||
nextDecisionAt: string | null;
|
||||
reason: string | null;
|
||||
trafficActivity: FailoverActivity | null;
|
||||
policy: FailoverPolicy;
|
||||
}
|
||||
|
||||
export interface FailoverDecisionMemory {
|
||||
primaryFailedSince: number | null;
|
||||
primaryRecoveredSince: number | null;
|
||||
quietSince: number | null;
|
||||
}
|
||||
|
||||
export interface FailoverDecisionInput {
|
||||
now: number;
|
||||
policy: FailoverPolicy;
|
||||
currentRole: FailoverRole;
|
||||
primaryHealth: Exclude<FailoverHealth, 'not-monitoring'>;
|
||||
reserveHealth: Exclude<FailoverHealth, 'not-monitoring'>;
|
||||
activity: 'active' | 'quiet' | 'unknown';
|
||||
holdUntil?: number | null;
|
||||
primaryQuarantineUntil?: number | null;
|
||||
memory?: FailoverDecisionMemory;
|
||||
}
|
||||
|
||||
export interface FailoverDecision {
|
||||
status: FailoverSnapshot['status'];
|
||||
reason: string;
|
||||
switchTo: FailoverRole | null;
|
||||
memory: FailoverDecisionMemory;
|
||||
nextDecisionAt: number | null;
|
||||
}
|
||||
|
||||
const text = (value: unknown) => typeof value === 'string' ? value.trim() : '';
|
||||
const record = (value: unknown): Record<string, unknown> => (
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
);
|
||||
const bounded = (value: unknown, fallback: number, minimum: number, maximum: number) => {
|
||||
const candidate = Number(value);
|
||||
return Number.isFinite(candidate) ? Math.min(maximum, Math.max(minimum, Math.round(candidate))) : fallback;
|
||||
};
|
||||
const equivalent = (left: unknown, right: unknown): boolean => {
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => equivalent(value, right[index]));
|
||||
}
|
||||
if (left && right && typeof left === 'object' && typeof right === 'object') {
|
||||
const leftRecord = left as Record<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
const leftKeys = Object.keys(leftRecord).sort();
|
||||
const rightKeys = Object.keys(rightRecord).sort();
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) => key === rightKeys[index] && equivalent(leftRecord[key], rightRecord[key]));
|
||||
}
|
||||
return Object.is(left, right);
|
||||
};
|
||||
|
||||
export const DEFAULT_FAILOVER_POLICY: FailoverPolicy = Object.freeze({
|
||||
version: 1,
|
||||
enabled: false,
|
||||
paused: false,
|
||||
primary: { profileId: '', serverId: '' },
|
||||
reserve: { profileId: '', serverId: '' },
|
||||
checks: [{ serviceId: 'youtube', timeoutMs: 6_000 }, { serviceId: 'google', timeoutMs: 6_000 }],
|
||||
intervalMs: 60_000,
|
||||
failureWindowMs: 120_000,
|
||||
recoveryWindowMs: 900_000,
|
||||
trafficGuard: { enabled: true, thresholdBytesPerSecond: 32 * 1024, quietWindowMs: 30_000 },
|
||||
minimumReserveMs: 600_000,
|
||||
flapProtection: { count: 3, windowMs: 86_400_000, quarantineMs: 21_600_000 },
|
||||
});
|
||||
|
||||
export const DEFAULT_FAILOVER_RUNTIME_STATE: FailoverRuntimeState = Object.freeze({
|
||||
lastSwitchAt: null,
|
||||
holdUntil: null,
|
||||
primaryQuarantineUntil: null,
|
||||
failoverHistory: [],
|
||||
reasonCode: null,
|
||||
});
|
||||
|
||||
function target(value: unknown): FailoverTarget {
|
||||
const candidate = record(value);
|
||||
return { profileId: text(candidate.profileId), serverId: text(candidate.serverId) };
|
||||
}
|
||||
|
||||
export function normalizeFailoverPolicy(value: unknown, { strict = false } = {}): FailoverPolicy {
|
||||
const candidate = record(value);
|
||||
const requestedChecks = Array.isArray(candidate.checks) ? candidate.checks : DEFAULT_FAILOVER_POLICY.checks;
|
||||
const checks = requestedChecks.map((value) => {
|
||||
const check = record(value);
|
||||
return {
|
||||
serviceId: text(check.serviceId).slice(0, 100),
|
||||
timeoutMs: bounded(check.timeoutMs, 6_000, 2_000, 30_000),
|
||||
};
|
||||
}).filter(({ serviceId }, index, all) => serviceId && all.findIndex((item) => item.serviceId === serviceId) === index).slice(0, 10);
|
||||
const trafficGuard = record(candidate.trafficGuard);
|
||||
const flapProtection = record(candidate.flapProtection);
|
||||
const policy: FailoverPolicy = {
|
||||
version: 1,
|
||||
enabled: candidate.enabled === true,
|
||||
paused: candidate.paused === true,
|
||||
primary: target(candidate.primary),
|
||||
reserve: target(candidate.reserve),
|
||||
checks,
|
||||
intervalMs: bounded(candidate.intervalMs, 60_000, 15_000, 900_000),
|
||||
failureWindowMs: 0,
|
||||
recoveryWindowMs: bounded(candidate.recoveryWindowMs, 900_000, 60_000, 86_400_000),
|
||||
trafficGuard: {
|
||||
enabled: trafficGuard.enabled !== false,
|
||||
thresholdBytesPerSecond: bounded(trafficGuard.thresholdBytesPerSecond, 32 * 1024, 1024, 100 * 1024 * 1024),
|
||||
quietWindowMs: bounded(trafficGuard.quietWindowMs, 30_000, 5_000, 600_000),
|
||||
},
|
||||
minimumReserveMs: bounded(candidate.minimumReserveMs, 600_000, 60_000, 86_400_000),
|
||||
flapProtection: {
|
||||
count: bounded(flapProtection.count, 3, 2, 10),
|
||||
windowMs: bounded(flapProtection.windowMs, 86_400_000, 3_600_000, 72 * 3_600_000),
|
||||
quarantineMs: bounded(flapProtection.quarantineMs, 21_600_000, 600_000, 7 * 86_400_000),
|
||||
},
|
||||
};
|
||||
policy.failureWindowMs = bounded(
|
||||
candidate.failureWindowMs,
|
||||
120_000,
|
||||
policy.intervalMs * 2,
|
||||
1_800_000,
|
||||
);
|
||||
if (strict && !equivalent(policy, value)) {
|
||||
throw new TypeError('Invalid failover policy');
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
export function normalizeFailoverRuntimeState(value: unknown): FailoverRuntimeState {
|
||||
const candidate = record(value);
|
||||
const date = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null;
|
||||
return {
|
||||
lastSwitchAt: date(candidate.lastSwitchAt),
|
||||
holdUntil: date(candidate.holdUntil),
|
||||
primaryQuarantineUntil: date(candidate.primaryQuarantineUntil),
|
||||
failoverHistory: (Array.isArray(candidate.failoverHistory) ? candidate.failoverHistory : [])
|
||||
.map(date).filter((item): item is string => Boolean(item)).slice(-20),
|
||||
reasonCode: text(candidate.reasonCode) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAppliedFailoverPolicy(value: unknown): AppliedFailoverPolicy | null {
|
||||
if (!value) return null;
|
||||
const candidate = record(value);
|
||||
const primary = target(candidate.primary);
|
||||
const reserve = target(candidate.reserve);
|
||||
const primaryConfigFingerprint = text(candidate.primaryConfigFingerprint);
|
||||
const reserveConfigFingerprint = text(candidate.reserveConfigFingerprint);
|
||||
return primary.profileId && primary.serverId && reserve.profileId && reserve.serverId
|
||||
&& /^[a-f0-9]{64}$/.test(primaryConfigFingerprint)
|
||||
&& /^[a-f0-9]{64}$/.test(reserveConfigFingerprint)
|
||||
? { primary, reserve, primaryConfigFingerprint, reserveConfigFingerprint }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isFailoverConfigured(policy: FailoverPolicy) {
|
||||
return Boolean(
|
||||
policy.primary.profileId && policy.primary.serverId
|
||||
&& policy.reserve.profileId && policy.reserve.serverId
|
||||
&& policy.checks.length
|
||||
&& (policy.primary.profileId !== policy.reserve.profileId
|
||||
|| policy.primary.serverId !== policy.reserve.serverId)
|
||||
);
|
||||
}
|
||||
|
||||
export function createIdleFailoverSnapshot(
|
||||
policy: FailoverPolicy,
|
||||
observationEpoch = '',
|
||||
observationSequence = 0,
|
||||
): FailoverSnapshot {
|
||||
const channel = (target: FailoverTarget) => ({
|
||||
target,
|
||||
health: 'not-monitoring' as const,
|
||||
failingServiceIds: [],
|
||||
checkedAt: null,
|
||||
stateSince: null,
|
||||
});
|
||||
return {
|
||||
observationEpoch,
|
||||
observationSequence,
|
||||
configured: isFailoverConfigured(policy),
|
||||
enabled: policy.enabled,
|
||||
paused: policy.paused,
|
||||
activation: 'inactive',
|
||||
currentRole: 'none',
|
||||
status: 'idle',
|
||||
primary: channel(policy.primary),
|
||||
reserve: channel(policy.reserve),
|
||||
nextDecisionAt: null,
|
||||
reason: null,
|
||||
trafficActivity: null,
|
||||
policy,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextFailoverDecision(input: FailoverDecisionInput): FailoverDecision {
|
||||
const memory = input.memory || {
|
||||
primaryFailedSince: null,
|
||||
primaryRecoveredSince: null,
|
||||
quietSince: null,
|
||||
};
|
||||
const next = { ...memory };
|
||||
if (!input.policy.enabled || input.policy.paused) {
|
||||
return { status: 'idle', reason: input.policy.paused ? 'paused' : 'disabled', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if (input.primaryHealth === 'unhealthy' && input.reserveHealth === 'unhealthy') {
|
||||
next.quietSince = null;
|
||||
return { status: 'blocked', reason: 'both-unhealthy', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
|
||||
let target: FailoverRole | null = null;
|
||||
let readyAt: number | null = null;
|
||||
if (input.currentRole === 'primary') {
|
||||
next.primaryRecoveredSince = null;
|
||||
if (input.primaryHealth !== 'unhealthy') {
|
||||
next.primaryFailedSince = null;
|
||||
next.quietSince = null;
|
||||
return { status: 'primary', reason: input.primaryHealth === 'healthy' ? 'primary-healthy' : 'health-unknown', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
next.primaryFailedSince ??= input.now;
|
||||
readyAt = next.primaryFailedSince + input.policy.failureWindowMs;
|
||||
if (input.now < readyAt || input.reserveHealth !== 'healthy') {
|
||||
next.quietSince = null;
|
||||
return { status: 'observing', reason: input.reserveHealth === 'healthy' ? 'failure-window' : 'reserve-not-healthy', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
target = 'reserve';
|
||||
} else {
|
||||
next.primaryFailedSince = null;
|
||||
if (input.primaryHealth !== 'healthy') {
|
||||
next.primaryRecoveredSince = null;
|
||||
next.quietSince = null;
|
||||
return { status: 'reserve', reason: 'primary-not-recovered', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if ((input.primaryQuarantineUntil || 0) > input.now) {
|
||||
next.primaryRecoveredSince = null;
|
||||
next.quietSince = null;
|
||||
return {
|
||||
status: 'reserve',
|
||||
reason: 'recovery-hold',
|
||||
switchTo: null,
|
||||
memory: next,
|
||||
nextDecisionAt: input.primaryQuarantineUntil || null,
|
||||
};
|
||||
}
|
||||
next.primaryRecoveredSince ??= input.now;
|
||||
const recoveredAt = next.primaryRecoveredSince + input.policy.recoveryWindowMs;
|
||||
readyAt = input.reserveHealth === 'unhealthy'
|
||||
? recoveredAt
|
||||
: Math.max(recoveredAt, input.holdUntil || 0, input.primaryQuarantineUntil || 0);
|
||||
if (input.now < readyAt) {
|
||||
next.quietSince = null;
|
||||
return { status: 'reserve', reason: 'recovery-hold', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
target = 'primary';
|
||||
}
|
||||
|
||||
if (input.policy.trafficGuard.enabled) {
|
||||
if (input.activity === 'unknown') {
|
||||
next.quietSince = null;
|
||||
return { status: 'blocked', reason: 'activity-unknown', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
if (input.activity === 'active') {
|
||||
next.quietSince = null;
|
||||
return { status: 'waiting-for-idle', reason: 'active-traffic', switchTo: null, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
next.quietSince ??= input.now;
|
||||
readyAt = next.quietSince + input.policy.trafficGuard.quietWindowMs;
|
||||
if (input.now < readyAt) {
|
||||
return { status: 'waiting-for-idle', reason: 'quiet-window', switchTo: null, memory: next, nextDecisionAt: readyAt };
|
||||
}
|
||||
}
|
||||
return { status: 'switching', reason: target === 'reserve' ? 'primary-failed' : 'primary-recovered', switchTo: target, memory: next, nextDecisionAt: null };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
export type LiveTrafficSourceState =
|
||||
| 'connecting'
|
||||
| 'live'
|
||||
| 'degraded'
|
||||
| 'stale'
|
||||
| 'stopped'
|
||||
| 'incompatible'
|
||||
| 'disabled';
|
||||
|
||||
export interface LiveTrafficConnection {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
closedAt: string | null;
|
||||
inbound: { tag: string; type: string };
|
||||
network: 'tcp' | 'udp' | 'unknown';
|
||||
protocol: string | null;
|
||||
source: { ip: string; port: number | null };
|
||||
destination: {
|
||||
domain: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
provenance: 'sing-box' | 'unknown';
|
||||
};
|
||||
origin: {
|
||||
kind: 'this-mac' | 'device' | 'unknown';
|
||||
id: string | null;
|
||||
label: string;
|
||||
provenance: 'client-runtime' | 'source-ip' | 'unknown';
|
||||
};
|
||||
route: {
|
||||
kind: 'vpn' | 'direct' | 'other';
|
||||
scope: 'local-sing-box';
|
||||
outbound: string | null;
|
||||
outboundType: string | null;
|
||||
chain: string[];
|
||||
rule: string | null;
|
||||
};
|
||||
traffic: {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
uploadBytesPerSecond: string;
|
||||
downloadBytesPerSecond: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LiveTrafficSnapshot {
|
||||
apiVersion: 1;
|
||||
epoch: string | null;
|
||||
sequence: number;
|
||||
observedAt: string | null;
|
||||
capabilities: {
|
||||
lifecycle: true;
|
||||
deviceAttribution: boolean;
|
||||
applicationAttribution: false;
|
||||
};
|
||||
source: {
|
||||
transport: 'native';
|
||||
state: LiveTrafficSourceState;
|
||||
completeness: 'lifecycle';
|
||||
singBoxVersion: string | null;
|
||||
singBoxApiVersion: number | null;
|
||||
error: string | null;
|
||||
unattributedUploadBytes: string;
|
||||
unattributedDownloadBytes: string;
|
||||
};
|
||||
summary: {
|
||||
active: number;
|
||||
recent: number;
|
||||
visible: number;
|
||||
recognized: number;
|
||||
unresolved: number;
|
||||
unresolvedOrigin: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
connections: LiveTrafficConnection[];
|
||||
}
|
||||
|
||||
const sourceStates = new Set<LiveTrafficSourceState>([
|
||||
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||
]);
|
||||
const decimal = /^\d+$/;
|
||||
|
||||
function isoTimestamp(value: unknown) {
|
||||
return typeof value === 'string'
|
||||
&& !Number.isNaN(Date.parse(value))
|
||||
&& new Date(value).toISOString() === value;
|
||||
}
|
||||
|
||||
function decimalString(value: unknown) {
|
||||
return typeof value === 'string' && decimal.test(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
if (value !== null && typeof value !== 'string') throw new Error('Expected nullable string');
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error('Expected non-negative integer');
|
||||
}
|
||||
|
||||
function nullablePort(value: unknown) {
|
||||
if (value !== null && (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 65_535)) {
|
||||
throw new Error('Expected nullable port');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLiveTrafficSnapshot(value: unknown): LiveTrafficSnapshot {
|
||||
const snapshot = record(value);
|
||||
if (snapshot.apiVersion !== 1) throw new Error('Expected live traffic apiVersion 1');
|
||||
nullableString(snapshot.epoch);
|
||||
nullableString(snapshot.observedAt);
|
||||
nonNegativeInteger(snapshot.sequence);
|
||||
|
||||
const capabilities = record(snapshot.capabilities);
|
||||
if (capabilities.lifecycle !== true || typeof capabilities.deviceAttribution !== 'boolean'
|
||||
|| capabilities.applicationAttribution !== false) throw new Error('Invalid traffic capabilities');
|
||||
|
||||
const source = record(snapshot.source);
|
||||
if (source.transport !== 'native' || source.completeness !== 'lifecycle'
|
||||
|| !sourceStates.has(source.state as LiveTrafficSourceState)) throw new Error('Invalid traffic source');
|
||||
nullableString(source.singBoxVersion);
|
||||
nullableString(source.error);
|
||||
if (source.singBoxApiVersion !== null) nonNegativeInteger(source.singBoxApiVersion);
|
||||
if (!decimalString(source.unattributedUploadBytes)
|
||||
|| !decimalString(source.unattributedDownloadBytes)) throw new Error('Invalid traffic gap');
|
||||
|
||||
const summary = record(snapshot.summary);
|
||||
for (const field of ['active', 'recent', 'visible', 'recognized', 'unresolved', 'unresolvedOrigin']) {
|
||||
nonNegativeInteger(summary[field]);
|
||||
}
|
||||
if (typeof summary.truncated !== 'boolean') throw new Error('Invalid traffic summary');
|
||||
if (!Array.isArray(snapshot.connections) || snapshot.connections.length > 256) {
|
||||
throw new Error('Invalid traffic connection list');
|
||||
}
|
||||
const activeTotal = Number(summary.active);
|
||||
const recentTotal = Number(summary.recent);
|
||||
const visibleTotal = Number(summary.visible);
|
||||
const expectedVisible = Math.min(256, activeTotal + recentTotal);
|
||||
if (visibleTotal !== snapshot.connections.length
|
||||
|| visibleTotal !== expectedVisible
|
||||
|| Number(summary.recognized) + Number(summary.unresolved) !== Number(summary.active)
|
||||
|| Number(summary.unresolvedOrigin) > Number(summary.active)
|
||||
|| summary.truncated !== (activeTotal + recentTotal > visibleTotal)) {
|
||||
throw new Error('Inconsistent traffic summary');
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
let visibleActive = 0;
|
||||
let visibleRecent = 0;
|
||||
let recentSeen = false;
|
||||
for (const rawConnection of snapshot.connections) {
|
||||
const connection = record(rawConnection);
|
||||
if (typeof connection.id !== 'string' || !connection.id
|
||||
|| ids.has(connection.id)
|
||||
|| !isoTimestamp(connection.startedAt)
|
||||
|| (connection.closedAt !== null && !isoTimestamp(connection.closedAt))) {
|
||||
throw new Error('Invalid traffic connection identity');
|
||||
}
|
||||
ids.add(connection.id);
|
||||
if (connection.closedAt === null) {
|
||||
if (recentSeen) throw new Error('Inconsistent traffic summary');
|
||||
visibleActive += 1;
|
||||
} else {
|
||||
recentSeen = true;
|
||||
visibleRecent += 1;
|
||||
}
|
||||
const inbound = record(connection.inbound);
|
||||
const sourceAddress = record(connection.source);
|
||||
const destination = record(connection.destination);
|
||||
const origin = record(connection.origin);
|
||||
const route = record(connection.route);
|
||||
const traffic = record(connection.traffic);
|
||||
if (typeof inbound.tag !== 'string' || typeof inbound.type !== 'string'
|
||||
|| !['tcp', 'udp', 'unknown'].includes(String(connection.network))
|
||||
|| (connection.protocol !== null && typeof connection.protocol !== 'string')
|
||||
|| typeof sourceAddress.ip !== 'string'
|
||||
|| (destination.domain !== null && typeof destination.domain !== 'string')
|
||||
|| (destination.ip !== null && typeof destination.ip !== 'string')
|
||||
|| !['sing-box', 'unknown'].includes(String(destination.provenance))
|
||||
|| !['this-mac', 'device', 'unknown'].includes(String(origin.kind))
|
||||
|| (origin.id !== null && typeof origin.id !== 'string')
|
||||
|| typeof origin.label !== 'string'
|
||||
|| !['client-runtime', 'source-ip', 'unknown'].includes(String(origin.provenance))
|
||||
|| !['vpn', 'direct', 'other'].includes(String(route.kind))
|
||||
|| route.scope !== 'local-sing-box'
|
||||
|| (route.outbound !== null && typeof route.outbound !== 'string')
|
||||
|| (route.outboundType !== null && typeof route.outboundType !== 'string')
|
||||
|| (route.rule !== null && typeof route.rule !== 'string')
|
||||
|| !Array.isArray(route.chain) || !route.chain.every((item) => typeof item === 'string')) {
|
||||
throw new Error('Invalid traffic connection');
|
||||
}
|
||||
nullablePort(sourceAddress.port);
|
||||
nullablePort(destination.port);
|
||||
for (const field of ['uploadBytes', 'downloadBytes', 'uploadBytesPerSecond', 'downloadBytesPerSecond']) {
|
||||
if (!decimalString(traffic[field])) throw new Error('Invalid traffic byte value');
|
||||
}
|
||||
if (connection.closedAt !== null
|
||||
&& (traffic.uploadBytesPerSecond !== '0' || traffic.downloadBytesPerSecond !== '0')) {
|
||||
throw new Error('Invalid closed traffic rate');
|
||||
}
|
||||
}
|
||||
if (visibleActive !== Math.min(activeTotal, visibleTotal)
|
||||
|| visibleRecent !== visibleTotal - visibleActive
|
||||
|| visibleRecent > recentTotal) {
|
||||
throw new Error('Inconsistent traffic summary');
|
||||
}
|
||||
return value as LiveTrafficSnapshot;
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
export const INITIAL_ROUTE_RULES = Object.freeze([
|
||||
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }),
|
||||
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }),
|
||||
]);
|
||||
|
||||
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
|
||||
const RULE_OUTBOUNDS = new Set(['vpn', 'direct']);
|
||||
export const ROUTE_RULES_CONTRACT_VERSION = 2;
|
||||
export const MAX_ROUTE_RULES = 200;
|
||||
|
||||
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
|
||||
export type RouteRuleOutbound = 'vpn' | 'direct';
|
||||
|
||||
export interface NormalizedRouteRule {
|
||||
type: RouteRuleType;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
outbound: RouteRuleOutbound;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -28,20 +32,32 @@ function hostname(value: unknown) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRule(input: unknown): NormalizedRouteRule {
|
||||
function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
|
||||
const rule = record(input);
|
||||
const type = String(rule.type || '').trim();
|
||||
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
|
||||
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
|
||||
throw new TypeError('Invalid domain rule enabled state');
|
||||
}
|
||||
if (strict && !Object.hasOwn(rule, 'outbound')) {
|
||||
throw new TypeError('Route rule outbound is required');
|
||||
}
|
||||
const outbound = Object.hasOwn(rule, 'outbound')
|
||||
? String(rule.outbound || '').trim()
|
||||
: 'direct';
|
||||
if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound');
|
||||
const value = type === 'domain_keyword'
|
||||
? String(rule.value || '').trim().toLowerCase()
|
||||
: hostname(rule.value);
|
||||
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
|
||||
throw new TypeError('Invalid domain rule value');
|
||||
}
|
||||
return { type: type as RouteRuleType, value, enabled: rule.enabled !== false };
|
||||
return {
|
||||
type: type as RouteRuleType,
|
||||
value,
|
||||
enabled: rule.enabled !== false,
|
||||
outbound: outbound as RouteRuleOutbound,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRouteRules(
|
||||
@@ -60,7 +76,7 @@ export function normalizeRouteRules(
|
||||
const normalized: NormalizedRouteRule[] = [];
|
||||
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
|
||||
try {
|
||||
const rule = normalizeRule(candidate);
|
||||
const rule = normalizeRule(candidate, strict);
|
||||
const key = `${rule.type}:${rule.value}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.20.36',
|
||||
gatewayClient: '0.21.21',
|
||||
gatewayBackend: '0.21.20',
|
||||
macClient: '0.34.0',
|
||||
gatewayClient: '0.36.0',
|
||||
gatewayBackend: '0.36.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
+185
-55
@@ -18,34 +18,70 @@ import {
|
||||
} from './state/operations.js';
|
||||
|
||||
const componentActions = {
|
||||
validateSubscription: api.subscription.validate,
|
||||
listDevices: api.devices.list,
|
||||
refreshDevices: api.devices.refresh,
|
||||
resetDeviceTraffic: api.devices.resetTraffic,
|
||||
updateDevice: api.devices.update,
|
||||
createDeviceTag: api.devices.createTag,
|
||||
renameDeviceTag: api.devices.renameTag,
|
||||
deleteDeviceTag: api.devices.deleteTag,
|
||||
setDevicePolicy: api.devices.setPolicy,
|
||||
pingServers: api.servers.ping,
|
||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||
loadActivityJournal: api.activityJournal.page,
|
||||
loadLiveTraffic: api.traffic.live,
|
||||
};
|
||||
|
||||
interface UiError {
|
||||
context: string;
|
||||
profileId: string;
|
||||
message: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
retry: (() => unknown) | null;
|
||||
}
|
||||
|
||||
const operationErrorContext: Record<string, string> = {
|
||||
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',
|
||||
'failover-save': 'failover',
|
||||
'failover-pause': 'failover',
|
||||
'failover-resume': 'failover',
|
||||
'failover-switch': 'failover',
|
||||
};
|
||||
|
||||
function asHarborApiError(error: unknown) {
|
||||
const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {};
|
||||
return error instanceof HarborApiError
|
||||
? error
|
||||
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
|
||||
}
|
||||
|
||||
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<OperationRegistrySnapshot>({});
|
||||
const [error, setError] = useState<UiError | null>(null);
|
||||
const [dismissedCanonicalError, setDismissedCanonicalError] = useState('');
|
||||
const [versionInfo, setVersionInfo] = useState<unknown>(null);
|
||||
const pollGeneration = useRef(0);
|
||||
const revisionRef = useRef(0);
|
||||
const hasAcceptedSnapshotRef = useRef(false);
|
||||
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
|
||||
if (!operationRegistry.current) {
|
||||
operationRegistry.current = createOperationRegistry((next) => {
|
||||
@@ -53,16 +89,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 +134,50 @@ 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<unknown>, 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<unknown>,
|
||||
context: string,
|
||||
target = '',
|
||||
profileId = '',
|
||||
) {
|
||||
setError(null);
|
||||
return operationRegistry.current!.run(key, async () => {
|
||||
try {
|
||||
return await applyMutation(action);
|
||||
} catch (err) {
|
||||
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
|
||||
const safeError = err instanceof HarborApiError
|
||||
? err
|
||||
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
|
||||
let safeError = asHarborApiError(err);
|
||||
if (safeError.code === 'STATE_CONFLICT' && context !== 'routing') {
|
||||
await loadState();
|
||||
try {
|
||||
return await applyMutation(action);
|
||||
} catch (retryError) {
|
||||
safeError = asHarborApiError(retryError);
|
||||
}
|
||||
}
|
||||
await loadState();
|
||||
setError({
|
||||
context,
|
||||
profileId,
|
||||
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
|
||||
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
|
||||
: safeError.message,
|
||||
: safeError.code === 'STATE_CONFLICT'
|
||||
? 'Harbor снова получил новые данные во время действия. Повторите ещё раз.'
|
||||
: safeError.message,
|
||||
code: safeError.code,
|
||||
correlationId: safeError.correlationId,
|
||||
correlationId: safeError.code === 'STATE_CONFLICT' ? '' : 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<unknown>) {
|
||||
@@ -133,6 +189,10 @@ export function App() {
|
||||
const result = response as Record<string, unknown>;
|
||||
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 +201,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 <BootStatePage transport={transport} onRetry={() => 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 (
|
||||
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
|
||||
@@ -182,22 +265,41 @@ 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,
|
||||
)}
|
||||
onSelectProfileServer={(profileId: string, serverId: string) => run(
|
||||
'profileSelect',
|
||||
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
|
||||
'subscription',
|
||||
`${profileId}:${serverId}`,
|
||||
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 +308,35 @@ export function App() {
|
||||
() => api.routeRules.update(rules, expectedRevision),
|
||||
'routing',
|
||||
)}
|
||||
onDismissError={() => setError(null)}
|
||||
onUpdateDiagnosticsSettings={(settings: unknown) => run(
|
||||
'diagnosticsSettings',
|
||||
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||
'diagnostics',
|
||||
)}
|
||||
onSaveFailover={(policy: unknown) => run(
|
||||
'failover',
|
||||
() => api.failover.save(policy, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onPauseFailover={(paused: boolean) => run(
|
||||
'failover',
|
||||
() => api.failover.pause(paused, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onSwitchFailover={(role: 'primary' | 'reserve') => run(
|
||||
'failover',
|
||||
() => api.failover.switch(role, revisionRef.current),
|
||||
'failover',
|
||||
)}
|
||||
onCheckFailover={() => run(
|
||||
'failover',
|
||||
() => api.failover.check(),
|
||||
'failover',
|
||||
)}
|
||||
onDismissError={() => {
|
||||
setError(null);
|
||||
setDismissedCanonicalError(canonicalErrorId);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+108
-9
@@ -1,5 +1,6 @@
|
||||
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
||||
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
||||
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
||||
headers?: Record<string, string>;
|
||||
@@ -96,10 +97,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', {
|
||||
@@ -108,14 +149,18 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
routeRules: {
|
||||
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', {
|
||||
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules/v2', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ rules, expectedRulesRevision }),
|
||||
body: JSON.stringify({ rules, expectedRulesRevision, rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION }),
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
list: () => request('/api/devices'),
|
||||
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
||||
resetTraffic: (expectedRevision: unknown) => request('/api/devices/traffic', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
}),
|
||||
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
|
||||
`/api/devices/${id}`,
|
||||
{
|
||||
@@ -123,6 +168,24 @@ export const api = {
|
||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||
},
|
||||
),
|
||||
createTag: (name: string, expectedRevision: unknown) => request('/api/device-tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, expectedRevision }),
|
||||
}),
|
||||
renameTag: (id: string, name: string, expectedRevision: unknown) => request(
|
||||
`/api/device-tags/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, expectedRevision }),
|
||||
},
|
||||
),
|
||||
deleteTag: (id: string, expectedRevision: unknown) => request(
|
||||
`/api/device-tags/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
},
|
||||
),
|
||||
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
|
||||
`/api/devices/${id}/policy`,
|
||||
{
|
||||
@@ -132,23 +195,56 @@ export const api = {
|
||||
),
|
||||
},
|
||||
diagnostics: {
|
||||
connectivity: (services: unknown[] = [], target: unknown = null) => request(
|
||||
connectivity: (target: unknown = null) => request(
|
||||
'/api/diagnostics/connectivity',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ services, target }),
|
||||
body: JSON.stringify({ target }),
|
||||
},
|
||||
),
|
||||
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||
'/api/diagnostics/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ settings, expectedRevision }),
|
||||
},
|
||||
),
|
||||
},
|
||||
failover: {
|
||||
save: (policy: unknown, expectedRevision: number) => request('/api/failover', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ policy, expectedRevision }),
|
||||
}),
|
||||
pause: (paused: boolean, expectedRevision: number) => request('/api/failover/pause', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paused, expectedRevision }),
|
||||
}),
|
||||
switch: (role: 'primary' | 'reserve', expectedRevision: number) => request('/api/failover/switch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ role, expectedRevision }),
|
||||
}),
|
||||
check: () => request('/api/failover/check', {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
activityJournal: {
|
||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||
},
|
||||
traffic: {
|
||||
live: () => request('/api/traffic/live'),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
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,9 +273,12 @@ 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,
|
||||
diagnostics: snapshot.diagnostics,
|
||||
failover: snapshot.failover,
|
||||
route: snapshot.route,
|
||||
operation: snapshot.operation,
|
||||
servers: snapshot.servers,
|
||||
|
||||
@@ -5,9 +5,11 @@ import React, {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
copyText,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
} from '../utils/clientControls.js';
|
||||
import {
|
||||
operationBlocked,
|
||||
@@ -45,13 +47,25 @@ import {
|
||||
InstructionsToggle,
|
||||
useInstructionsFeature,
|
||||
} from '../features/instructions/index.js';
|
||||
import {
|
||||
TrafficPanel,
|
||||
TrafficToggle,
|
||||
useTrafficFeature,
|
||||
} from '../features/traffic/index.js';
|
||||
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
||||
import {
|
||||
ActivityJournalPanel,
|
||||
ActivityJournalToggle,
|
||||
useActivityJournalFeature,
|
||||
} from '../features/activity-journal/index.js';
|
||||
import type { FailoverPolicy } from '../../shared/failover.js';
|
||||
import {
|
||||
HARBOR_VERSIONS,
|
||||
parseVersion,
|
||||
versionCompatibility,
|
||||
} from '../../shared/versions.js';
|
||||
import type {
|
||||
HarborServer,
|
||||
ProfileSnapshot,
|
||||
RouteRule,
|
||||
StateSnapshot,
|
||||
} from '../../shared/contracts/state.js';
|
||||
@@ -62,8 +76,37 @@ const VERSION_PARTS = [
|
||||
['hotfix', 'Hotfix'],
|
||||
] as const;
|
||||
|
||||
const DRAWER_SWITCH_MS = 620;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
|
||||
type DrawerKey = typeof DRAWER_ORDER[number];
|
||||
|
||||
const failoverReasonLabel = (reason: string | null) => ({
|
||||
'primary-healthy': 'основной работает',
|
||||
'health-unknown': 'ожидаем проверку',
|
||||
'failure-window': 'подтверждаем сбой',
|
||||
'reserve-not-healthy': 'резерв не подтверждён',
|
||||
'both-unhealthy': 'оба канала недоступны',
|
||||
'primary-not-recovered': 'основной восстанавливается',
|
||||
'recovery-hold': 'проверяем стабильность',
|
||||
'activity-unknown': 'активность неизвестна',
|
||||
'active-traffic': 'ждём завершения работы',
|
||||
'quiet-window': 'проверяем тишину',
|
||||
'primary-failed': 'основной недоступен',
|
||||
'primary-recovered': 'основной восстановился',
|
||||
'pending-activation': 'изменения ожидают запуска',
|
||||
'vpn-stopped': 'VPN выключен',
|
||||
paused: 'автоматика на паузе',
|
||||
disabled: 'резерв выключен',
|
||||
'switch-failed': 'не удалось переключить',
|
||||
'selector-unknown': 'текущий канал неизвестен',
|
||||
'reconcile-failed': 'мониторинг временно недоступен',
|
||||
'revalidation-required': 'условия проверяются заново',
|
||||
'manual-check': 'оба канала проверены',
|
||||
}[reason || ''] || 'наблюдение');
|
||||
|
||||
interface UiError {
|
||||
context?: string;
|
||||
profileId?: string;
|
||||
message?: string;
|
||||
correlationId?: string;
|
||||
retry?: (() => unknown) | null;
|
||||
@@ -79,13 +122,18 @@ interface VersionBadgeProps {
|
||||
}
|
||||
|
||||
interface ComponentActions {
|
||||
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
|
||||
listDevices: () => Promise<unknown>;
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||||
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||||
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
|
||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||
pingServers: (ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClientViewState extends StateSnapshot {
|
||||
@@ -102,23 +150,27 @@ 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<unknown>;
|
||||
onRefreshSubscription: () => Promise<unknown>;
|
||||
onForgetSubscription: () => Promise<unknown>;
|
||||
onApply: (serverId: string) => Promise<unknown>;
|
||||
onAddProfile: (label: string, url: string) => Promise<unknown>;
|
||||
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
|
||||
onRefreshProfile: (profileId: string) => Promise<unknown>;
|
||||
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||||
onApply: (profileId: string, serverId: string) => Promise<unknown>;
|
||||
onRestart: () => Promise<unknown>;
|
||||
onStop: () => Promise<unknown>;
|
||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
||||
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
||||
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||
onCheckFailover: () => Promise<unknown>;
|
||||
onDismissError: () => void;
|
||||
}
|
||||
|
||||
type CopyKind = 'gateway' | 'socks5' | 'http';
|
||||
type CopyFeedback = { failed: boolean; cycle: number };
|
||||
type CopyFeedbackMap = Partial<Record<CopyKind, CopyFeedback>>;
|
||||
type CopyAnnouncement = { text: string; cycle: number };
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
@@ -240,9 +292,33 @@ function InlineError({ error, context }: { error?: UiError | null; context: stri
|
||||
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
|
||||
connection: ['connection', 'Меняем состояние подключения…'],
|
||||
serverApply: ['connection', 'Применяем сервер…'],
|
||||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||
profileActivate: ['connection', 'Переключаем подписку…'],
|
||||
profileAdd: ['subscription', 'Добавляем подписку…'],
|
||||
profileRefresh: ['subscription', 'Обновляем подписку…'],
|
||||
profileDelete: ['subscription', 'Удаляем подписку…'],
|
||||
routeRules: ['routing', 'Применяем локальные правила…'],
|
||||
failover: ['failover', 'Применяем настройки резерва…'],
|
||||
};
|
||||
|
||||
const canonicalOperationKeys: Record<string, OperationKey> = {
|
||||
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',
|
||||
'failover-save': 'failover',
|
||||
'failover-pause': 'failover',
|
||||
'failover-resume': 'failover',
|
||||
'failover-switch': 'failover',
|
||||
'subscription-import': 'profileAdd',
|
||||
'subscription-refresh': 'profileRefresh',
|
||||
'subscription-forget': 'profileDelete',
|
||||
};
|
||||
|
||||
function InlineProgress({ operations, context }: {
|
||||
@@ -263,8 +339,32 @@ function InlineProgress({ operations, context }: {
|
||||
);
|
||||
}
|
||||
|
||||
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
|
||||
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 <div className="client-applied-identity" aria-label={identity}>
|
||||
<span className="client-applied-value" aria-hidden="true">
|
||||
{previous && <strong className="is-leaving">{previous}</strong>}
|
||||
<strong key={current} className="is-active">{current}</strong>
|
||||
</span>
|
||||
<div className="client-applied-operation">
|
||||
{operation && <span>{operation}</span>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
|
||||
isGateway: boolean;
|
||||
connected: boolean;
|
||||
gatewayAvailable: boolean;
|
||||
gatewayDirect: boolean;
|
||||
blocked: boolean;
|
||||
@@ -333,7 +433,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||||
<div className={`harbor-brand is-${product.toLowerCase()}${connected ? ' is-connected' : ''}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||||
{switchable ? <button
|
||||
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
|
||||
type="button"
|
||||
@@ -365,64 +465,110 @@ export function ClientOverviewPage({
|
||||
versionInfo,
|
||||
operations = {},
|
||||
error,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingServerId,
|
||||
setPendingServerId,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscription,
|
||||
onForgetSubscription,
|
||||
onAddProfile,
|
||||
onSelectProfileServer,
|
||||
onRefreshProfile,
|
||||
onForgetProfile,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
onSetGatewayAuto,
|
||||
onSaveRouteRules,
|
||||
onUpdateDiagnosticsSettings,
|
||||
onSaveFailover,
|
||||
onPauseFailover,
|
||||
onSwitchFailover,
|
||||
onCheckFailover,
|
||||
onDismissError,
|
||||
}: ClientOverviewPageProps) {
|
||||
const isGateway = state?.mode === 'gateway';
|
||||
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
|
||||
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
|
||||
const connected = state?.connection?.process === 'running';
|
||||
const hasSubscription = state?.subscription?.status === 'ready';
|
||||
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||
const 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<{ kind: CopyKind; failed: boolean } | null>(null);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
|
||||
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
|
||||
const [drawerSwitchTarget, setDrawerSwitchTarget] = useState<DrawerKey | null>(null);
|
||||
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
|
||||
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
|
||||
const drawerSwitchRef = useRef<{
|
||||
target: DrawerKey;
|
||||
finish: () => void;
|
||||
} | null>(null);
|
||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const controlHost = window.location.host || `${gatewayAddress}:3456`;
|
||||
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
|
||||
const connectionBlocked = operationBlocked(operations, 'connection');
|
||||
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||
const switchingServer = Boolean(
|
||||
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
|
||||
);
|
||||
const 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 subscriptionError = error?.context === 'subscription'
|
||||
&& profiles.some((profile) => profile.id === error.profileId
|
||||
&& profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED')
|
||||
? null
|
||||
: error;
|
||||
const subscriptionFeature = useSubscriptionFeature({
|
||||
subscription: state?.subscription,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
operations,
|
||||
error,
|
||||
serverCount: servers.length,
|
||||
profiles,
|
||||
selection: state.selection,
|
||||
connected,
|
||||
operations: visibleOperations,
|
||||
error: subscriptionError,
|
||||
isGateway,
|
||||
gatewayDirect,
|
||||
validateSubscription: actions.validateSubscription,
|
||||
onImport: onFetchSubscription,
|
||||
onRefresh: onRefreshSubscription,
|
||||
onForget: onForgetSubscription,
|
||||
onAdd: onAddProfile,
|
||||
onRefresh: onRefreshProfile,
|
||||
onForget: onForgetProfile,
|
||||
onDismissError,
|
||||
});
|
||||
const subscriptionContentReady = subscriptionFeature.contentReady;
|
||||
const routingFeature = useRoutingFeature({
|
||||
route: state?.route,
|
||||
connected,
|
||||
operations,
|
||||
operations: visibleOperations,
|
||||
onSave: onSaveRouteRules,
|
||||
onDismissError,
|
||||
});
|
||||
@@ -430,7 +576,11 @@ export function ClientOverviewPage({
|
||||
isGateway,
|
||||
listDevices: actions.listDevices,
|
||||
refreshDevices: actions.refreshDevices,
|
||||
resetDeviceTraffic: actions.resetDeviceTraffic,
|
||||
updateDevice: actions.updateDevice,
|
||||
createDeviceTag: actions.createDeviceTag,
|
||||
renameDeviceTag: actions.renameDeviceTag,
|
||||
deleteDeviceTag: actions.deleteDeviceTag,
|
||||
setDevicePolicy: actions.setDevicePolicy,
|
||||
});
|
||||
const diagnosticsFeature = useDiagnosticsFeature();
|
||||
@@ -440,7 +590,70 @@ export function ClientOverviewPage({
|
||||
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
|
||||
controlHost,
|
||||
});
|
||||
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
|
||||
const failoverFeature = useFailoverFeature();
|
||||
const activityJournalFeature = useActivityJournalFeature();
|
||||
const trafficFeature = useTrafficFeature({
|
||||
enabled: true,
|
||||
isGateway,
|
||||
loadLiveTraffic: actions.loadLiveTraffic,
|
||||
});
|
||||
const diagnosticsAvailable = hasSubscription;
|
||||
const drawerControls = {
|
||||
subscription: {
|
||||
isOpen: subscriptionFeature.open,
|
||||
panelRef: subscriptionFeature.panelRef,
|
||||
show: subscriptionFeature.toggle,
|
||||
close: subscriptionFeature.close,
|
||||
},
|
||||
failover: {
|
||||
isOpen: failoverFeature.isOpen,
|
||||
panelRef: failoverFeature.panelRef,
|
||||
show: failoverFeature.toggle,
|
||||
close: failoverFeature.close,
|
||||
},
|
||||
instructions: {
|
||||
isOpen: instructionsFeature.isOpen,
|
||||
panelRef: instructionsFeature.panelRef,
|
||||
show: instructionsFeature.toggle,
|
||||
close: instructionsFeature.close,
|
||||
},
|
||||
devices: {
|
||||
isOpen: devicesFeature.isOpen,
|
||||
panelRef: devicesFeature.panelRef,
|
||||
show: devicesFeature.toggle,
|
||||
close: devicesFeature.close,
|
||||
},
|
||||
traffic: {
|
||||
isOpen: trafficFeature.isOpen,
|
||||
panelRef: trafficFeature.panelRef,
|
||||
show: trafficFeature.toggle,
|
||||
close: trafficFeature.close,
|
||||
},
|
||||
diagnostics: {
|
||||
isOpen: diagnosticsFeature.isOpen,
|
||||
panelRef: diagnosticsFeature.panelRef,
|
||||
show: diagnosticsFeature.toggle,
|
||||
close: diagnosticsFeature.close,
|
||||
},
|
||||
routing: {
|
||||
isOpen: routingFeature.isOpen,
|
||||
panelRef: routingFeature.panelRef,
|
||||
show: routingFeature.open,
|
||||
close: routingFeature.forceClose,
|
||||
},
|
||||
journal: {
|
||||
isOpen: activityJournalFeature.isOpen,
|
||||
panelRef: activityJournalFeature.panelRef,
|
||||
show: activityJournalFeature.toggle,
|
||||
close: activityJournalFeature.close,
|
||||
},
|
||||
};
|
||||
const drawerOrder = isGateway
|
||||
? DRAWER_ORDER
|
||||
: DRAWER_ORDER.filter((drawer) => !['devices', 'failover', 'journal'].includes(drawer));
|
||||
const activeRailDrawer = drawerSwitchTarget && drawerControls[drawerSwitchTarget].isOpen
|
||||
? drawerSwitchTarget
|
||||
: drawerOrder.find((drawer) => drawerControls[drawer].isOpen) || null;
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -450,128 +663,243 @@ export function ClientOverviewPage({
|
||||
return () => clearInterval(timer);
|
||||
}, [isGateway, connected, state?.connection?.startedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showIntro) return undefined;
|
||||
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [showIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) {
|
||||
routingFeature.forceClose();
|
||||
if (!isGateway) {
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
}
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
failoverFeature.close();
|
||||
activityJournalFeature.close();
|
||||
trafficFeature.close();
|
||||
}
|
||||
}, [hasSubscription, isGateway]);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
||||
}, [diagnosticsAvailable]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
for (const timer of Object.values(copyTimersRef.current)) clearTimeout(timer);
|
||||
copyAttemptsRef.current = {};
|
||||
}, []);
|
||||
|
||||
function selectServer(serverId: string) {
|
||||
setPendingServerId(serverId);
|
||||
if (connected && serverId) onApply(serverId);
|
||||
function selectServer(profile: ProfileSnapshot, serverId: string) {
|
||||
if (connected && !gatewayDirect) {
|
||||
onApply(profile.id, serverId);
|
||||
return;
|
||||
}
|
||||
onSelectProfileServer(profile.id, serverId);
|
||||
}
|
||||
|
||||
async function copyProxy(kind: CopyKind) {
|
||||
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
const activeTimer = copyTimersRef.current[kind];
|
||||
if (activeTimer) clearTimeout(activeTimer);
|
||||
const attempt = {};
|
||||
copyAttemptsRef.current[kind] = attempt;
|
||||
let failed = false;
|
||||
try {
|
||||
await copyText(value);
|
||||
setCopyFeedback({ kind, failed: false });
|
||||
} catch {
|
||||
setCopyFeedback({ kind, failed: true });
|
||||
failed = true;
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||
if (copyAttemptsRef.current[kind] !== attempt) return;
|
||||
const pendingTimer = copyTimersRef.current[kind];
|
||||
if (pendingTimer) clearTimeout(pendingTimer);
|
||||
setCopyFeedback((current) => ({
|
||||
...current,
|
||||
[kind]: { failed, cycle: (current[kind]?.cycle || 0) + 1 },
|
||||
}));
|
||||
setCopyAnnouncement((current) => ({
|
||||
text: failed ? 'Не удалось скопировать' : 'Скопировано',
|
||||
cycle: current.cycle + 1,
|
||||
}));
|
||||
copyTimersRef.current[kind] = setTimeout(() => {
|
||||
setCopyFeedback((current) => {
|
||||
const next = { ...current };
|
||||
delete next[kind];
|
||||
return next;
|
||||
});
|
||||
delete copyTimersRef.current[kind];
|
||||
delete copyAttemptsRef.current[kind];
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function openRouting() {
|
||||
subscriptionFeature.close();
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
routingFeature.open();
|
||||
function switchDrawer(target: DrawerKey) {
|
||||
const activeSwitch = drawerSwitchRef.current;
|
||||
const current = activeSwitch?.target
|
||||
|| drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
|
||||
|| null;
|
||||
activeSwitch?.finish();
|
||||
|
||||
if (current === target) {
|
||||
if (target === 'failover') failoverFeature.pendingTargetRef.current = null;
|
||||
drawerControls[target].close();
|
||||
return;
|
||||
}
|
||||
if (current === 'routing' && routingFeature.dirty) {
|
||||
routingFeature.requestClose();
|
||||
return;
|
||||
}
|
||||
if (current === 'failover') {
|
||||
if (!failoverFeature.beforeCloseRef.current()) {
|
||||
failoverFeature.pendingTargetRef.current = () => switchDrawer(target);
|
||||
return;
|
||||
}
|
||||
failoverFeature.pendingTargetRef.current = null;
|
||||
}
|
||||
if (!current) {
|
||||
drawerControls[target].show();
|
||||
return;
|
||||
}
|
||||
|
||||
const fromControl = drawerControls[current];
|
||||
const toControl = drawerControls[target];
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reducedMotion) {
|
||||
flushSync(() => {
|
||||
fromControl.close();
|
||||
toControl.show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawerSwitchTarget(target);
|
||||
flushSync(() => toControl.show());
|
||||
const from = fromControl.panelRef.current;
|
||||
const to = toControl.panelRef.current;
|
||||
if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') {
|
||||
fromControl.close();
|
||||
setDrawerSwitchTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
from.inert = true;
|
||||
from.setAttribute('aria-hidden', 'true');
|
||||
const direction = DRAWER_ORDER.indexOf(target) > DRAWER_ORDER.indexOf(current) ? -1 : 1;
|
||||
const options: KeyframeAnimationOptions = {
|
||||
duration: DRAWER_SWITCH_MS,
|
||||
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
fill: 'both',
|
||||
};
|
||||
const outgoing = from.animate([
|
||||
{ transform: 'translateY(0)', opacity: 1 },
|
||||
{ transform: `translateY(${direction * 100}%)`, opacity: 1 },
|
||||
], options);
|
||||
const incoming = to.animate([
|
||||
{ transform: `translateY(${-direction * 100}%)`, opacity: 1 },
|
||||
{ transform: 'translateY(0)', opacity: 1 },
|
||||
], options);
|
||||
let finished = false;
|
||||
const cancel = () => {
|
||||
from.inert = false;
|
||||
from.removeAttribute('aria-hidden');
|
||||
outgoing.cancel();
|
||||
incoming.cancel();
|
||||
};
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
flushSync(() => {
|
||||
fromControl.close();
|
||||
setDrawerSwitchTarget(null);
|
||||
});
|
||||
cancel();
|
||||
if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null;
|
||||
};
|
||||
incoming.addEventListener('finish', finish, { once: true });
|
||||
drawerSwitchRef.current = { target, finish };
|
||||
}
|
||||
|
||||
const mainIdentity = gatewayDirect
|
||||
? 'Gateway · сервер не определён'
|
||||
: connected
|
||||
? appliedProfile && appliedServer
|
||||
? `${subscriptionDomain(appliedProfile.subscription.host)} · ${appliedServer.label}`
|
||||
: 'VPN · сервер не определён'
|
||||
: desiredProfile && desiredServer
|
||||
? `Выбран: ${subscriptionDomain(desiredProfile.subscription.host)} · ${desiredServer.label}`
|
||||
: 'Сервер не выбран';
|
||||
const switchIdentity = gatewayDirect
|
||||
? 'Данные применённого сервера Gateway недоступны'
|
||||
: switchingServer && operationProfile && operationServer
|
||||
? `Переключаем на ${subscriptionDomain(operationProfile.subscription.host)} · ${operationServer.label}`
|
||||
: '';
|
||||
const failoverIdentity = isGateway && state.failover.enabled
|
||||
? state.failover.currentRole === 'other'
|
||||
? state.failover.reason === 'vpn-stopped'
|
||||
? 'Резерв включится при запуске VPN'
|
||||
: 'Резерв применится после перезапуска VPN'
|
||||
: `${state.failover.currentRole === 'reserve'
|
||||
? 'Резервный канал'
|
||||
: 'Основной канал'} · ${failoverReasonLabel(state.failover.reason)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||||
className={`client-shell${!hasSubscription ? ' is-first-run' : ''}`}
|
||||
>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
|
||||
</div>
|
||||
<HarborBrand
|
||||
isGateway={isGateway}
|
||||
gatewayAvailable={gatewayAvailable}
|
||||
gatewayDirect={gatewayDirect}
|
||||
blocked={gatewayAutoBlocked}
|
||||
onSetGatewayAuto={onSetGatewayAuto}
|
||||
/>
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||
{isGateway && <SubscriptionToggle
|
||||
{hasSubscription && <nav
|
||||
className="client-secondary-menu"
|
||||
aria-label="Дополнительные меню"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<SubscriptionToggle
|
||||
feature={subscriptionFeature}
|
||||
onToggle={() => {
|
||||
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
subscriptionFeature.toggle();
|
||||
}}
|
||||
open={activeRailDrawer === 'subscription'}
|
||||
onToggle={() => switchDrawer('subscription')}
|
||||
/>
|
||||
{isGateway && <FailoverToggle
|
||||
feature={failoverFeature}
|
||||
open={activeRailDrawer === 'failover'}
|
||||
onToggle={() => switchDrawer('failover')}
|
||||
/>}
|
||||
<InstructionsToggle
|
||||
feature={instructionsFeature}
|
||||
onToggle={() => {
|
||||
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||
subscriptionFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
instructionsFeature.toggle();
|
||||
}}
|
||||
open={activeRailDrawer === 'instructions'}
|
||||
onToggle={() => switchDrawer('instructions')}
|
||||
/>
|
||||
{isGateway && <DevicesToggle
|
||||
feature={devicesFeature}
|
||||
onToggle={() => {
|
||||
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||
subscriptionFeature.close();
|
||||
instructionsFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
devicesFeature.toggle();
|
||||
}}
|
||||
open={activeRailDrawer === 'devices'}
|
||||
onToggle={() => switchDrawer('devices')}
|
||||
/>}
|
||||
<TrafficToggle
|
||||
feature={trafficFeature}
|
||||
open={activeRailDrawer === 'traffic'}
|
||||
onToggle={() => switchDrawer('traffic')}
|
||||
/>
|
||||
<DiagnosticsToggle
|
||||
feature={diagnosticsFeature}
|
||||
onToggle={() => {
|
||||
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||
subscriptionFeature.close();
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.toggle();
|
||||
}}
|
||||
open={activeRailDrawer === 'diagnostics'}
|
||||
onToggle={() => switchDrawer('diagnostics')}
|
||||
/>
|
||||
<RoutingToggle
|
||||
feature={routingFeature}
|
||||
open={activeRailDrawer === 'routing'}
|
||||
gatewayDirect={gatewayDirect}
|
||||
isGateway={isGateway}
|
||||
hasSubscription={hasSubscription}
|
||||
onOpen={openRouting}
|
||||
onOpen={() => switchDrawer('routing')}
|
||||
/>
|
||||
{isGateway && <ActivityJournalToggle
|
||||
feature={activityJournalFeature}
|
||||
open={activeRailDrawer === 'journal'}
|
||||
onToggle={() => switchDrawer('journal')}
|
||||
/>}
|
||||
</nav>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
|
||||
<ConnectionPanel
|
||||
visible={showPower}
|
||||
isGateway={isGateway}
|
||||
connected={connected}
|
||||
gatewayDirect={gatewayDirect}
|
||||
selectedServerId={selectedServerId}
|
||||
configured={Boolean(state?.clientRuntime?.configured)}
|
||||
startedAt={state?.connection?.startedAt}
|
||||
gatewayAddress={gatewayAddress}
|
||||
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
|
||||
@@ -579,72 +907,91 @@ export function ClientOverviewPage({
|
||||
proxyPort={state?.clientRuntime?.proxyPort}
|
||||
now={now}
|
||||
blocked={connectionBlocked}
|
||||
brandSlot={<HarborBrand
|
||||
isGateway={isGateway}
|
||||
connected={connected || gatewayDirect}
|
||||
gatewayAvailable={gatewayAvailable}
|
||||
gatewayDirect={gatewayDirect}
|
||||
blocked={gatewayAutoBlocked}
|
||||
onSetGatewayAuto={onSetGatewayAuto}
|
||||
/>}
|
||||
copyFeedback={copyFeedback}
|
||||
onCopyProxy={copyProxy}
|
||||
onApply={onApply}
|
||||
onRestart={onRestart}
|
||||
onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
|
||||
onStop={onStop}
|
||||
routingSlot={<RoutingPendingStatus
|
||||
feature={routingFeature}
|
||||
blocked={connectionBlocked}
|
||||
onRestart={onRestart}
|
||||
/>}
|
||||
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
|
||||
<span className="client-gateway-summary-kicker">Сейчас</span>
|
||||
<strong id="gateway-summary-title">
|
||||
{appliedServer?.label || 'VPN-сервер не используется'}
|
||||
</strong>
|
||||
<div className="client-gateway-route-slot">
|
||||
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
|
||||
</div>
|
||||
</div>}
|
||||
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity || failoverIdentity} />}
|
||||
statusSlot={<>
|
||||
<InlineError error={error} context="connection" />
|
||||
<InlineProgress operations={operations} context="connection" />
|
||||
<InlineProgress operations={visibleOperations} context="connection" />
|
||||
</>}
|
||||
/>
|
||||
|
||||
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
|
||||
{isGateway && hasSubscription && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
|
||||
|
||||
<SubscriptionPanel
|
||||
feature={subscriptionFeature}
|
||||
statusSlot={<>
|
||||
<InlineError error={subscriptionFeature.error || error} context="subscription" />
|
||||
<InlineProgress operations={operations} context="subscription" />
|
||||
<InlineError error={subscriptionFeature.error} context="subscription" />
|
||||
</>}
|
||||
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
|
||||
renderServerPicker={(profile, pickerState) => <ServerPicker
|
||||
profileId={profile.id}
|
||||
pingServers={actions.pingServers}
|
||||
servers={servers}
|
||||
selectedServerId={selectedServerId}
|
||||
disabled={serverApplyBlocked}
|
||||
prompt={!showPower}
|
||||
leaving={subscriptionFeature.serversLeaving}
|
||||
revealVersion={subscriptionFeature.serverRevealVersion}
|
||||
onSelect={selectServer}
|
||||
servers={profile.servers}
|
||||
selectedServerId={pickerState.selectedServerId}
|
||||
disabled={serverApplyBlocked || pickerState.disabled}
|
||||
leaving={pickerState.leaving}
|
||||
revealVersion={pickerState.revealVersion}
|
||||
anchorServerId={pickerState.anchorServerId}
|
||||
onSelect={(serverId) => selectServer(profile, serverId)}
|
||||
/>}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
|
||||
{hasSubscription && <InstructionsPanel
|
||||
feature={instructionsFeature}
|
||||
isGateway={isGateway}
|
||||
/>}
|
||||
|
||||
{isGateway && <DevicesPanel feature={devicesFeature} />}
|
||||
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
||||
|
||||
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
|
||||
|
||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||
feature={diagnosticsFeature}
|
||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||
settings={state.diagnostics}
|
||||
updateSettings={onUpdateDiagnosticsSettings}
|
||||
isGateway={isGateway}
|
||||
/>}
|
||||
|
||||
{hasSubscription && subscriptionContentReady && <RoutingPanel
|
||||
{hasSubscription && <RoutingPanel
|
||||
feature={routingFeature}
|
||||
statusSlot={<>
|
||||
<InlineError error={error} context="routing" />
|
||||
<InlineProgress operations={operations} context="routing" />
|
||||
<InlineProgress operations={visibleOperations} context="routing" />
|
||||
</>}
|
||||
/>}
|
||||
{isGateway && hasSubscription && <FailoverPanel
|
||||
feature={failoverFeature}
|
||||
snapshot={state.failover}
|
||||
profiles={profiles}
|
||||
diagnostics={state.diagnostics}
|
||||
blocked={operationBlocked(visibleOperations, 'failover')}
|
||||
onSave={onSaveFailover}
|
||||
onPause={onPauseFailover}
|
||||
onSwitch={onSwitchFailover}
|
||||
onCheck={onCheckFailover}
|
||||
onUpdateDiagnostics={onUpdateDiagnosticsSettings}
|
||||
/>}
|
||||
{isGateway && hasSubscription && <ActivityJournalPanel
|
||||
feature={activityJournalFeature}
|
||||
loadPage={actions.loadActivityJournal}
|
||||
/>}
|
||||
<RoutingDiscardDialog feature={routingFeature} />
|
||||
<SubscriptionDeleteDialog feature={subscriptionFeature} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { assertActivityJournalPage, type ActivityJournalEvent } from '../../../shared/activityJournal.js';
|
||||
import { compactActivityJournalEvents, activityJournalEventCopy, refreshStreakCopy, type ActivityJournalDisplayItem } from './activityJournalModel.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
|
||||
export function useActivityJournalFeature() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const close = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
document.addEventListener('keydown', close);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', close);
|
||||
document.removeEventListener('keydown', close);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
return { isOpen, panelRef, toggleRef, closeRef, close: () => setIsOpen(false), toggle: () => setIsOpen((value) => !value) };
|
||||
}
|
||||
|
||||
export type ActivityJournalFeature = ReturnType<typeof useActivityJournalFeature>;
|
||||
|
||||
export function ActivityJournalToggle({ feature, open, onToggle }: {
|
||||
feature: ActivityJournalFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-journal-toggle"
|
||||
open={open}
|
||||
controls="client-activity-journal"
|
||||
ariaLabel={open ? 'Закрыть журнал событий' : 'Открыть журнал событий'}
|
||||
label="Журнал"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 8V4m0 4h4M5.6 7.1A8 8 0 1 1 4 12M12 7.5V12l3 2" />
|
||||
<circle cx="12" cy="12" r=".8" />
|
||||
</svg>
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
function eventTargetRole(event: ActivityJournalEvent) {
|
||||
if (event.type === 'failover.switched') return event.data.toRole;
|
||||
if (
|
||||
event.type === 'failover.reserve_unavailable'
|
||||
|| event.type === 'failover.reserve_recovered'
|
||||
|| (event.type === 'failover.recovered' && event.data.role === 'reserve')
|
||||
) return 'reserve';
|
||||
if (
|
||||
event.type === 'failover.primary_unavailable'
|
||||
|| event.type === 'failover.primary_recovered'
|
||||
|| (event.type === 'failover.recovered' && event.data.role === 'primary')
|
||||
) return 'primary';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function dayLabel(value: string) {
|
||||
const date = new Date(value);
|
||||
const today = new Date();
|
||||
const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const start = startDate.getTime();
|
||||
const yesterday = new Date(startDate);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
if (day === start) return 'Сегодня';
|
||||
if (day === yesterday.getTime()) return 'Вчера';
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long' }).format(date);
|
||||
}
|
||||
|
||||
export function ActivityJournalPanel({ feature, loadPage }: {
|
||||
feature: ActivityJournalFeature;
|
||||
loadPage: (cursor?: string | null) => Promise<unknown>;
|
||||
}) {
|
||||
const [events, setEvents] = useState<ActivityJournalEvent[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'refreshing' | 'older' | 'error'>('idle');
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
|
||||
async function load(cursor: string | null = null, refresh = false) {
|
||||
setStatus(cursor ? 'older' : refresh ? 'refreshing' : 'loading');
|
||||
try {
|
||||
const page = assertActivityJournalPage(await loadPage(cursor));
|
||||
if (page.storage.status === 'error') throw new Error('journal unavailable');
|
||||
if (refresh) {
|
||||
const known = new Set(events.map(({ id }) => id));
|
||||
setAnnouncement(`Журнал обновлён, новых событий: ${page.events.filter(({ id }) => !known.has(id)).length}`);
|
||||
}
|
||||
setEvents((current) => cursor ? [...current, ...page.events] : page.events);
|
||||
setNextCursor(page.nextCursor);
|
||||
setStatus('ready');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (feature.isOpen) void load(null, status !== 'idle');
|
||||
}, [feature.isOpen]);
|
||||
|
||||
const groups = compactActivityJournalEvents(events).reduce<Array<{ label: string; items: ActivityJournalDisplayItem[] }>>((result, item) => {
|
||||
const label = dayLabel(item.event.occurredAt);
|
||||
const group = result.at(-1);
|
||||
if (group?.label === label) group.items.push(item);
|
||||
else result.push({ label, items: [item] });
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-activity-journal"
|
||||
open={feature.isOpen}
|
||||
label="Журнал Harbor"
|
||||
closeLabel="Закрыть журнал"
|
||||
onClose={feature.close}
|
||||
className="client-journal-drawer"
|
||||
>
|
||||
<header className="client-journal-header">
|
||||
<span>Важные события хранятся 30 дней</span>
|
||||
<div><h2>Журнал</h2><button type="button" aria-label="Обновить журнал" disabled={status === 'refreshing'} onClick={() => void load(null, true)}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" /></svg>
|
||||
</button></div>
|
||||
</header>
|
||||
<span className="client-live-region" role="status" aria-live="polite">{announcement}</span>
|
||||
{status === 'error' && <div className="client-journal-error" role="status">Журнал временно недоступен <button type="button" onClick={() => void load()}>Повторить</button></div>}
|
||||
{status === 'loading' && !events.length ? <div className="client-journal-skeleton" aria-label="Загружаем журнал">{[0, 1, 2, 3].map((value) => <span key={value} />)}</div>
|
||||
: !events.length && status === 'ready' ? <p className="client-journal-empty">За последние 30 дней важных событий пока нет</p>
|
||||
: <div className="client-journal-groups">{groups.map((group) => <section key={group.label}>
|
||||
<h3>{group.label}</h3>
|
||||
<ol>{group.items.map((item) => {
|
||||
const { event } = item;
|
||||
const [title, details] = refreshStreakCopy(item) || activityJournalEventCopy(event);
|
||||
return <li
|
||||
key={event.id}
|
||||
className="client-journal-event"
|
||||
data-event-type={event.type}
|
||||
data-severity={event.severity}
|
||||
data-target-role={eventTargetRole(event)}
|
||||
>
|
||||
<div className="client-journal-time"><time dateTime={event.occurredAt}>{new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(event.occurredAt))}</time><span>{event.source}</span></div>
|
||||
<div><strong>{title}</strong>{details && <span>{details}</span>}{event.severity !== 'info' && <em>{event.severity === 'error' ? 'Ошибка' : 'Внимание'}</em>}</div>
|
||||
</li>;
|
||||
})}</ol>
|
||||
</section>)}</div>}
|
||||
<footer className="client-journal-footer">
|
||||
{nextCursor ? <button type="button" disabled={status === 'older'} onClick={() => void load(nextCursor)}>{status === 'older' ? 'Загружаем…' : 'Показать ещё'}</button> : <span>{events.length ? 'Это вся история за последние 30 дней' : 'Храним события 30 дней'}</span>}
|
||||
</footer>
|
||||
</Drawer>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ActivityJournalEvent } from '../../../shared/activityJournal.js';
|
||||
|
||||
export interface ActivityJournalRefreshStreak {
|
||||
count: number;
|
||||
firstOccurredAt: string;
|
||||
profileLabels: string[];
|
||||
}
|
||||
|
||||
export interface ActivityJournalDisplayItem {
|
||||
event: ActivityJournalEvent;
|
||||
refreshStreak: ActivityJournalRefreshStreak | null;
|
||||
}
|
||||
|
||||
function localDay(value: string) {
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
}
|
||||
|
||||
function isRoutineRefresh(event: ActivityJournalEvent) {
|
||||
return event.type === 'subscription.refreshed'
|
||||
&& event.severity === 'info'
|
||||
&& Number(event.data.added || 0) === 0
|
||||
&& Number(event.data.removed || 0) === 0;
|
||||
}
|
||||
|
||||
export function compactActivityJournalEvents(events: ActivityJournalEvent[]) {
|
||||
const items: ActivityJournalDisplayItem[] = [];
|
||||
for (let index = 0; index < events.length;) {
|
||||
const event = events[index];
|
||||
if (!isRoutineRefresh(event)) {
|
||||
items.push({ event, refreshStreak: null });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const day = localDay(event.occurredAt);
|
||||
let end = index + 1;
|
||||
while (end < events.length && isRoutineRefresh(events[end]) && localDay(events[end].occurredAt) === day) {
|
||||
end += 1;
|
||||
}
|
||||
const streakEvents = events.slice(index, end);
|
||||
if (streakEvents.length === 1) {
|
||||
items.push({ event, refreshStreak: null });
|
||||
} else {
|
||||
const profileLabels = [...new Set(streakEvents.map(({ data }) => String(data.profileLabel || 'Подписка')))];
|
||||
items.push({
|
||||
event,
|
||||
refreshStreak: {
|
||||
count: streakEvents.length,
|
||||
firstOccurredAt: streakEvents.at(-1)?.occurredAt || event.occurredAt,
|
||||
profileLabels,
|
||||
},
|
||||
});
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function plural(value: number, one: string, few: string, many: string) {
|
||||
const tens = value % 100;
|
||||
const units = value % 10;
|
||||
if (tens < 11 || tens > 14) {
|
||||
if (units === 1) return one;
|
||||
if (units >= 2 && units <= 4) return few;
|
||||
}
|
||||
return many;
|
||||
}
|
||||
|
||||
function formatObservedDuration(firstOccurredAt: string, lastOccurredAt: string) {
|
||||
const minutes = Math.max(0, Math.round((Date.parse(lastOccurredAt) - Date.parse(firstOccurredAt)) / 60_000));
|
||||
if (minutes < 1) return 'меньше минуты';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return [hours ? `${hours} ч` : '', remainingMinutes ? `${remainingMinutes} мин` : ''].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function refreshStreakCopy(item: ActivityJournalDisplayItem): [string, string] | null {
|
||||
const streak = item.refreshStreak;
|
||||
if (!streak) return null;
|
||||
const profile = streak.profileLabels.length === 1
|
||||
? streak.profileLabels[0]
|
||||
: `подписок: ${streak.profileLabels.length}`;
|
||||
const updates = `${streak.count} ${plural(streak.count, 'обновление', 'обновления', 'обновлений')}`;
|
||||
return [
|
||||
'Подписки обновлялись без ошибок',
|
||||
`${formatObservedDuration(streak.firstOccurredAt, item.event.occurredAt)} всё хорошо · ${updates} · ${profile}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function activityJournalEventCopy(event: ActivityJournalEvent): [string, string] {
|
||||
const value = event.data;
|
||||
const copies: Record<string, [string, string]> = {
|
||||
'connection.started': ['VPN включён', [value.profileLabel, value.serverLabel].filter(Boolean).join(' · ')],
|
||||
'connection.stopped': ['VPN выключен', 'Остановлен пользователем'],
|
||||
'connection.failed': ['VPN не запущен', String(value.errorCode || '')],
|
||||
'subscription.added': ['Подписка добавлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0}`],
|
||||
'subscription.refreshed': ['Подписка обновлена', `${value.profileLabel || ''} · серверов: ${value.serverCount || 0} · +${value.added || 0} / −${value.removed || 0}`],
|
||||
'subscription.refresh_failed': ['Подписка не обновлена', `${value.profileLabel || ''} · ${value.errorCode || ''}`],
|
||||
'subscription.deleted': ['Подписка удалена', String(value.profileLabel || '')],
|
||||
'failover.enabled': ['Резервный канал включён', 'Мониторинг начнётся после активации dual-config'],
|
||||
'failover.disabled': ['Резервный канал выключен', 'Автоматика полностью остановлена'],
|
||||
'failover.paused': ['Автопереключение на паузе', 'Проверки продолжаются'],
|
||||
'failover.resumed': ['Автопереключение возобновлено', ''],
|
||||
'failover.waiting_for_idle': ['Переключение отложено', 'Обнаружен активный трафик'],
|
||||
'failover.switched': ['Новые соединения переключены', `${value.fromRole || ''} → ${value.toRole || ''}`],
|
||||
'failover.switch_failed': ['Переключение не выполнено', String(value.errorCode || '')],
|
||||
'failover.both_unhealthy': ['Оба канала недоступны', 'Текущий маршрут сохранён'],
|
||||
'failover.primary_unavailable': ['Основной канал недоступен', 'Проверки канала не пройдены'],
|
||||
'failover.reserve_unavailable': ['Резервный канал недоступен', 'Проверки канала не пройдены'],
|
||||
'failover.primary_recovered': ['Основной канал восстановлен', 'Проверки канала снова проходят успешно'],
|
||||
'failover.reserve_recovered': ['Резервный канал восстановлен', 'Проверки канала снова проходят успешно'],
|
||||
'journal.recovered': ['Журнал восстановлен', 'Повреждённый файл сохранён отдельно'],
|
||||
};
|
||||
if (event.type === 'failover.recovered') {
|
||||
return value.role === 'reserve'
|
||||
? ['Резервный канал восстановлен', 'Проверки канала снова проходят успешно']
|
||||
: ['Основной канал восстановлен', 'Проверки канала снова проходят успешно'];
|
||||
}
|
||||
return copies[event.type] || ['Системное событие', ''];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ActivityJournalPanel, ActivityJournalToggle, useActivityJournalFeature } from './ActivityJournalFeature.js';
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { CopyButton } from '../../ui/CopyButton.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import {
|
||||
connectionAction,
|
||||
connectionDurationParts,
|
||||
@@ -11,8 +13,8 @@ const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||
type CopyKind = 'gateway' | 'socks5' | 'http';
|
||||
|
||||
interface CopyFeedback {
|
||||
kind: CopyKind;
|
||||
failed: boolean;
|
||||
cycle: number;
|
||||
}
|
||||
|
||||
interface DurationUnit {
|
||||
@@ -26,7 +28,6 @@ interface ConnectionPanelProps {
|
||||
connected: boolean;
|
||||
gatewayDirect: boolean;
|
||||
selectedServerId: string;
|
||||
configured: boolean;
|
||||
startedAt?: string | null;
|
||||
gatewayAddress: string;
|
||||
gatewayUiOrigin?: string | null;
|
||||
@@ -34,13 +35,13 @@ interface ConnectionPanelProps {
|
||||
proxyPort?: number;
|
||||
now: number;
|
||||
blocked: boolean;
|
||||
copyFeedback?: CopyFeedback | null;
|
||||
brandSlot?: ReactNode;
|
||||
copyFeedback?: Partial<Record<CopyKind, CopyFeedback>>;
|
||||
routingSlot?: ReactNode;
|
||||
serverSlot?: ReactNode;
|
||||
statusSlot?: ReactNode;
|
||||
onCopyProxy: (kind: CopyKind) => unknown;
|
||||
onApply: (serverId: string) => unknown;
|
||||
onRestart: () => unknown;
|
||||
onStop: () => unknown;
|
||||
}
|
||||
|
||||
@@ -66,7 +67,6 @@ export function ConnectionPanel({
|
||||
connected,
|
||||
gatewayDirect,
|
||||
selectedServerId,
|
||||
configured,
|
||||
startedAt,
|
||||
gatewayAddress,
|
||||
gatewayUiOrigin,
|
||||
@@ -74,13 +74,13 @@ export function ConnectionPanel({
|
||||
proxyPort,
|
||||
now,
|
||||
blocked,
|
||||
brandSlot,
|
||||
copyFeedback,
|
||||
routingSlot,
|
||||
serverSlot,
|
||||
statusSlot,
|
||||
onCopyProxy,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
}: ConnectionPanelProps) {
|
||||
const [durationMode, setDurationMode] = useState(() => {
|
||||
@@ -91,8 +91,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]> = [
|
||||
@@ -102,9 +103,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'],
|
||||
@@ -117,13 +118,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() {
|
||||
@@ -147,12 +147,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}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -161,78 +163,81 @@ export function ConnectionPanel({
|
||||
</button>;
|
||||
|
||||
return <>
|
||||
{visible && <section className="client-power-section" aria-labelledby="connection-title">
|
||||
{isGateway ? <span
|
||||
className="client-power-control client-tooltip-anchor"
|
||||
{visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
|
||||
<div
|
||||
className={`client-power-control${powerUnavailable ? ' client-tooltip-anchor' : ''}`}
|
||||
tabIndex={powerUnavailable ? 0 : undefined}
|
||||
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
|
||||
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||
>
|
||||
{brandSlot}
|
||||
{powerButton}
|
||||
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
|
||||
Сначала добавьте подписку и выберите сервер
|
||||
</span>}
|
||||
</span> : powerButton}
|
||||
{powerUnavailable && <Tooltip id="gateway-power-unavailable">
|
||||
{remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
|
||||
</Tooltip>}
|
||||
</div>
|
||||
<div className="client-state-detail">
|
||||
{connected && !remoteOwned ? (
|
||||
<button
|
||||
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||||
type="button"
|
||||
key="duration"
|
||||
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
onClick={toggleDurationMode}
|
||||
>
|
||||
<span className="client-duration-stack">
|
||||
<time
|
||||
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
|
||||
aria-hidden={durationMode !== 'digital'}
|
||||
>
|
||||
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||||
</time>
|
||||
<time
|
||||
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
|
||||
aria-hidden={durationMode !== 'words'}
|
||||
>
|
||||
{duration.days.value > 0 && (
|
||||
<span className="client-duration-word-row is-calendar">
|
||||
<span className="client-duration-unit" data-unit="days">
|
||||
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
|
||||
<DurationPart name="days-label">{duration.days.label}</DurationPart>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="client-duration-word-row is-clock">
|
||||
{wordClockDuration.map(([name, part]) => (
|
||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||||
? <AnimatedSeconds value={part.value} padded={false} />
|
||||
: part.value}</DurationPart>{' '}
|
||||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</time>
|
||||
</span>
|
||||
<Tooltip>
|
||||
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
</Tooltip>
|
||||
</button>
|
||||
) : (
|
||||
<p key="hint">
|
||||
{remoteOwned
|
||||
? 'Управляется Harbor Gateway'
|
||||
: canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{routingSlot}
|
||||
<div className="client-state-copy" aria-live="polite">
|
||||
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||
<span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||
<span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||
<span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||
</h2>
|
||||
{serverSlot}
|
||||
<div className="client-state-detail">
|
||||
{connected ? (
|
||||
<button
|
||||
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||||
type="button"
|
||||
key="duration"
|
||||
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
onClick={toggleDurationMode}
|
||||
>
|
||||
<span className="client-duration-stack">
|
||||
<time
|
||||
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
|
||||
aria-hidden={durationMode !== 'digital'}
|
||||
>
|
||||
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||||
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||||
</time>
|
||||
<time
|
||||
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
|
||||
aria-hidden={durationMode !== 'words'}
|
||||
>
|
||||
{duration.days.value > 0 && (
|
||||
<span className="client-duration-word-row is-calendar">
|
||||
<span className="client-duration-unit" data-unit="days">
|
||||
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
|
||||
<DurationPart name="days-label">{duration.days.label}</DurationPart>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="client-duration-word-row is-clock">
|
||||
{wordClockDuration.map(([name, part]) => (
|
||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||||
? <AnimatedSeconds value={part.value} padded={false} />
|
||||
: part.value}</DurationPart>{' '}
|
||||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</time>
|
||||
</span>
|
||||
<span className="client-tooltip" role="tooltip">
|
||||
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<p key="hint">
|
||||
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||
@@ -249,23 +254,21 @@ export function ConnectionPanel({
|
||||
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||
</strong>
|
||||
<div className="client-proxy-actions">
|
||||
{proxyKinds.map(([kind, label]) => (
|
||||
<button
|
||||
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
{proxyKinds.map(([kind, label]) => {
|
||||
const feedback = copyFeedback?.[kind];
|
||||
return <CopyButton
|
||||
label={label}
|
||||
feedback={feedback}
|
||||
key={kind}
|
||||
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||
ariaLabel={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||
onClick={() => onCopyProxy(kind)}
|
||||
>
|
||||
<span className="client-copy-label">{label}</span>
|
||||
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
|
||||
</button>
|
||||
))}
|
||||
/>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{statusSlot}
|
||||
</section>}
|
||||
</section> : brandSlot}
|
||||
|
||||
<ConfirmationDialog
|
||||
open={confirmingStop}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatByteString, formatLastSeen } from '../../utils/format.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import {
|
||||
formatByteString,
|
||||
formatLastSeen,
|
||||
trafficBytesPerSecond,
|
||||
} from '../../utils/format.js';
|
||||
import { TrafficChart } from './TrafficChart.js';
|
||||
import {
|
||||
parseDeviceSnapshot,
|
||||
type Device,
|
||||
type DevicePolicy,
|
||||
type DeviceSnapshot,
|
||||
type DeviceTag,
|
||||
} from './deviceSnapshot.js';
|
||||
|
||||
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||
@@ -14,7 +20,11 @@ interface DevicesFeatureOptions {
|
||||
isGateway: boolean;
|
||||
listDevices: () => Promise<unknown>;
|
||||
refreshDevices: () => Promise<unknown>;
|
||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
|
||||
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
|
||||
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
|
||||
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
|
||||
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -31,11 +41,21 @@ function requestError(value: unknown): RequestError {
|
||||
return { code: typeof value.code === 'string' ? value.code : undefined };
|
||||
}
|
||||
|
||||
const sameStringList = (left: string[], right: string[]) => (
|
||||
left.length === right.length && left.every((value, index) => value === right[index])
|
||||
);
|
||||
|
||||
const tagNameKey = (value: string) => value.trim().toLocaleLowerCase('ru-RU');
|
||||
|
||||
export function useDevicesFeature({
|
||||
isGateway,
|
||||
listDevices,
|
||||
refreshDevices,
|
||||
resetDeviceTraffic,
|
||||
updateDevice: requestDeviceUpdate,
|
||||
createDeviceTag,
|
||||
renameDeviceTag,
|
||||
deleteDeviceTag,
|
||||
setDevicePolicy,
|
||||
}: DevicesFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -45,6 +65,10 @@ export function useDevicesFeature({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||
const [savingId, setSavingId] = useState('');
|
||||
const [tagSavingId, setTagSavingId] = useState('');
|
||||
const [tagError, setTagError] = useState<unknown>(null);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -131,6 +155,143 @@ export function useDevicesFeature({
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceTags(device: Device, tagIds: string[], baselineTagIds: string[]) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId(device.id);
|
||||
setTagError(null);
|
||||
const currentDevice = snapshot.devices.find(({ id }) => id === device.id);
|
||||
if (!currentDevice || !sameStringList(currentDevice.tagIds, baselineTagIds)) {
|
||||
setTagError(new Error('Device tags changed'));
|
||||
setTagSavingId('');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
const knownTagIds = new Set(latest.tags.map(({ id }) => id));
|
||||
if (!latestDevice || !sameStringList(latestDevice.tagIds, baselineTagIds)
|
||||
|| tagIds.some((tagId) => !knownTagIds.has(tagId))) throw caught;
|
||||
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, { tagIds }, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function createTag(name: string) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId('create');
|
||||
setTagError(null);
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await createDeviceTag(name, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const nameKey = tagNameKey(name);
|
||||
if (latest.tags.length >= 32 || latest.tags.some((tag) => tagNameKey(tag.name) === nameKey)) throw caught;
|
||||
next = parseDeviceSnapshot(await createDeviceTag(name, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTag(tag: DeviceTag, name: string, baselineName: string) {
|
||||
if (!snapshot) return false;
|
||||
setTagSavingId(tag.id);
|
||||
setTagError(null);
|
||||
if (snapshot.tags.find(({ id }) => id === tag.id)?.name !== baselineName) {
|
||||
setTagError(new Error('Device tag changed'));
|
||||
setTagSavingId('');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
const latestTag = latest.tags.find(({ id }) => id === tag.id);
|
||||
if (!latestTag || latestTag.name !== baselineName) throw caught;
|
||||
next = parseDeviceSnapshot(await renameDeviceTag(tag.id, name, latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
return false;
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag(tag: DeviceTag): Promise<'saved' | 'conflict' | 'failed'> {
|
||||
if (!snapshot) return 'failed';
|
||||
setTagSavingId(tag.id);
|
||||
setTagError(null);
|
||||
try {
|
||||
publish(parseDeviceSnapshot(await deleteDeviceTag(tag.id, snapshot.revision)));
|
||||
return 'saved';
|
||||
} catch (caught) {
|
||||
setTagError(caught);
|
||||
if (requestError(caught).code === 'STATE_CONFLICT') {
|
||||
try {
|
||||
publish(parseDeviceSnapshot(await listDevices()));
|
||||
} catch {
|
||||
// Preserve the conflict as the actionable error.
|
||||
}
|
||||
return 'conflict';
|
||||
}
|
||||
return 'failed';
|
||||
} finally {
|
||||
setTagSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResetTraffic() {
|
||||
if (!snapshot) return;
|
||||
setResetting(true);
|
||||
try {
|
||||
let next: DeviceSnapshot;
|
||||
try {
|
||||
next = parseDeviceSnapshot(await resetDeviceTraffic(snapshot.revision));
|
||||
} catch (caught) {
|
||||
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
|
||||
const latest = parseDeviceSnapshot(await listDevices());
|
||||
publish(latest);
|
||||
next = parseDeviceSnapshot(await resetDeviceTraffic(latest.revision));
|
||||
}
|
||||
publish(next);
|
||||
setError(null);
|
||||
setResetOpen(false);
|
||||
} catch (caught) {
|
||||
setError(caught);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGateway) return undefined;
|
||||
load();
|
||||
@@ -147,6 +308,8 @@ export function useDevicesFeature({
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (resetOpen) return;
|
||||
if (document.querySelector('.client-devices-rail.is-open, .client-device-tag-popover, .client-confirmation-popup.is-open')) return;
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
@@ -163,7 +326,7 @@ export function useDevicesFeature({
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, resetOpen]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
@@ -173,12 +336,24 @@ export function useDevicesFeature({
|
||||
refreshing,
|
||||
refreshCycle,
|
||||
savingId,
|
||||
tagSavingId,
|
||||
tagError,
|
||||
resetOpen,
|
||||
resetting,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
load,
|
||||
updateDevice,
|
||||
updateDeviceTags,
|
||||
createTag,
|
||||
renameTag,
|
||||
deleteTag,
|
||||
clearTagError: () => setTagError(null),
|
||||
updatePolicy,
|
||||
requestTrafficReset: () => setResetOpen(true),
|
||||
cancelTrafficReset: () => setResetOpen(false),
|
||||
confirmResetTraffic,
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen((open) => !open),
|
||||
};
|
||||
@@ -186,27 +361,49 @@ export function useDevicesFeature({
|
||||
|
||||
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
|
||||
|
||||
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-devices"
|
||||
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
export function DevicesToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: DevicesFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle client-devices-toggle"
|
||||
open={open}
|
||||
controls="client-devices"
|
||||
ariaLabel={open ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
label="Устройства"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
|
||||
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
|
||||
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
|
||||
<g className="client-rail-device-monitor">
|
||||
<rect x="2.5" y="4.5" width="9" height="10.5" rx="1.5" />
|
||||
<path d="M5 19h5.5M7 15v4" />
|
||||
</g>
|
||||
<rect className="client-rail-device-phone" x="16.5" y="7" width="5" height="9" rx="1.3" />
|
||||
<path className="client-rail-device-link" d="M19 16v3h-5.5" />
|
||||
</svg>
|
||||
<span>Устройства</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
|
||||
const globalTraffic = feature.snapshot?.traffic;
|
||||
const history = globalTraffic?.history || [];
|
||||
const latest = history.at(-1);
|
||||
const previous = history.at(-2);
|
||||
const hasDirectionalRate = latest && previous
|
||||
&& typeof latest.downloadBytes === 'string'
|
||||
&& typeof latest.uploadBytes === 'string';
|
||||
const downloadRate = hasDirectionalRate
|
||||
? trafficBytesPerSecond(latest.downloadBytes, previous.observedAt, latest.observedAt)
|
||||
: null;
|
||||
const uploadRate = hasDirectionalRate
|
||||
? trafficBytesPerSecond(latest.uploadBytes, previous.observedAt, latest.observedAt)
|
||||
: null;
|
||||
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|
||||
|| feature.snapshot?.source?.traffic?.proxy?.error
|
||||
|| (feature.status === 'error' ? feature.error : null);
|
||||
@@ -216,14 +413,21 @@ export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeatur
|
||||
|
||||
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
|
||||
<div className="client-gateway-traffic-heading">
|
||||
<span>Учтено Harbor</span>
|
||||
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||
<span className="client-gateway-traffic-total">
|
||||
<small>Учтено Harbor</small>
|
||||
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||
</span>
|
||||
<span className="client-gateway-traffic-speed" aria-label="Текущая средняя скорость">
|
||||
<span className="is-download">↓ {downloadRate === null ? '—' : `${formatByteString(downloadRate)}/с`}</span>
|
||||
<span className="is-upload">↑ {uploadRate === null ? '—' : `${formatByteString(uploadRate)}/с`}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="client-gateway-traffic-chart">
|
||||
<TrafficChart
|
||||
samples={globalTraffic?.history || []}
|
||||
samples={history}
|
||||
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
|
||||
routeLabel="Gateway"
|
||||
series="speed"
|
||||
/>
|
||||
</div>
|
||||
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,20 @@
|
||||
import React, { useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
|
||||
import React, {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type AnimationEvent,
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
byteString,
|
||||
formatByteString,
|
||||
trafficAxisMid,
|
||||
trafficBytesPerSecond,
|
||||
trafficScaleRatio,
|
||||
} from '../../utils/format.js';
|
||||
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||
import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
@@ -17,13 +25,20 @@ function chartTime(value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type ChartSample = TrafficSample | OutboundTrafficSample;
|
||||
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
|
||||
type ChartYKey = 'gatewayY' | 'proxyY' | 'unknownY';
|
||||
|
||||
interface ChartPoint {
|
||||
sample: TrafficSample;
|
||||
sample: ChartSample;
|
||||
x: number;
|
||||
gateway: bigint;
|
||||
proxy: bigint;
|
||||
directIpv4: bigint;
|
||||
unknown: bigint;
|
||||
gatewayY: number;
|
||||
proxyY: number;
|
||||
unknownY: number;
|
||||
}
|
||||
|
||||
interface HoveredPoint extends ChartPoint {
|
||||
@@ -31,7 +46,7 @@ interface HoveredPoint extends ChartPoint {
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') {
|
||||
function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
|
||||
if (!points.length) return '';
|
||||
return points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
@@ -40,14 +55,36 @@ function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY'
|
||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||
}
|
||||
|
||||
function trafficSeriesMax(samples: TrafficSample[]) {
|
||||
return samples.reduce((largest, sample) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
return gateway > largest
|
||||
? (proxy > gateway ? proxy : gateway)
|
||||
: (proxy > largest ? proxy : largest);
|
||||
}, 0n);
|
||||
function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartPoint[]) {
|
||||
const previousByTime = new Map(previousPoints.map((point) => [point.sample.observedAt, point]));
|
||||
let anchor: ChartPoint | undefined;
|
||||
return points.map((point) => {
|
||||
const exact = previousByTime.get(point.sample.observedAt);
|
||||
if (exact) anchor = exact;
|
||||
const source = exact || anchor;
|
||||
return source ? {
|
||||
...point,
|
||||
x: source.x,
|
||||
gatewayY: source.gatewayY,
|
||||
proxyY: source.proxyY,
|
||||
unknownY: source.unknownY,
|
||||
} : {
|
||||
...point,
|
||||
gatewayY: 100,
|
||||
proxyY: 100,
|
||||
unknownY: 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function trafficSeriesMax(values: Array<Record<ChartValueKey, bigint>>) {
|
||||
return values.reduce((largest, value) => (
|
||||
Object.values(value).reduce((current, item) => item > current ? item : current, largest)
|
||||
), 0n);
|
||||
}
|
||||
|
||||
function formatRate(value: bigint) {
|
||||
return `${formatByteString(value)}/с`;
|
||||
}
|
||||
|
||||
export function TrafficChart({
|
||||
@@ -56,44 +93,109 @@ export function TrafficChart({
|
||||
capacity,
|
||||
routeLabel,
|
||||
pinned = true,
|
||||
collapsing = false,
|
||||
onCollapseEnd,
|
||||
series = 'inbound',
|
||||
}: {
|
||||
samples: TrafficSample[];
|
||||
samples: ChartSample[];
|
||||
scale?: TrafficScale;
|
||||
capacity: number;
|
||||
routeLabel: string;
|
||||
pinned?: boolean;
|
||||
collapsing?: boolean;
|
||||
onCollapseEnd?: () => void;
|
||||
series?: 'inbound' | 'outbound' | 'speed';
|
||||
}) {
|
||||
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
|
||||
const previousPoints = useRef<ChartPoint[]>([]);
|
||||
const previousScale = useRef<TrafficScale>(scale);
|
||||
const max = trafficSeriesMax(samples);
|
||||
const previousSeries = useRef(series);
|
||||
const speedAvailable = samples.length > 1 && samples.every((sample) => (
|
||||
'downloadBytes' in sample && typeof sample.downloadBytes === 'string'
|
||||
&& 'uploadBytes' in sample && typeof sample.uploadBytes === 'string'
|
||||
));
|
||||
const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples;
|
||||
const values = visibleSamples.map((sample, index) => {
|
||||
if (series === 'inbound') {
|
||||
const traffic = sample as TrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: byteString(traffic.gatewayBytes),
|
||||
proxy: byteString(traffic.proxyBytes),
|
||||
directIpv4: 0n,
|
||||
unknown: 0n,
|
||||
};
|
||||
}
|
||||
if (series === 'outbound') {
|
||||
const outbound = sample as OutboundTrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: byteString(outbound.vpnBytes),
|
||||
proxy: byteString(outbound.directTrackedBytes) + byteString(outbound.directIpv4Bytes),
|
||||
directIpv4: byteString(outbound.directIpv4Bytes),
|
||||
unknown: byteString(outbound.unknownBytes),
|
||||
};
|
||||
}
|
||||
const traffic = sample as TrafficSample;
|
||||
const previous = samples[index] as TrafficSample;
|
||||
return {
|
||||
sample,
|
||||
gateway: trafficBytesPerSecond(traffic.downloadBytes, previous.observedAt, traffic.observedAt),
|
||||
proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt),
|
||||
directIpv4: 0n,
|
||||
unknown: 0n,
|
||||
};
|
||||
});
|
||||
const max = trafficSeriesMax(values.map(({ gateway, proxy, unknown }) => ({
|
||||
gateway, proxy, directIpv4: 0n, unknown,
|
||||
})));
|
||||
const mid = trafficAxisMid(max, scale);
|
||||
const firstSlot = capacity - samples.length;
|
||||
const points = samples.map((sample, index) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
const firstSlot = capacity - visibleSamples.length;
|
||||
const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => {
|
||||
return {
|
||||
sample,
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
directIpv4,
|
||||
unknown,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
const penultimate = points.at(-2);
|
||||
const newest = points.at(-1);
|
||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||
const scaleFrom = previousPoints.current;
|
||||
const animateScale = previousScale.current !== scale
|
||||
&& scaleFrom.length === points.length
|
||||
const lineDefinitions: Array<{
|
||||
valueKey: ChartValueKey;
|
||||
yKey: ChartYKey;
|
||||
}> = series === 'outbound'
|
||||
? [
|
||||
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
||||
{ valueKey: 'proxy', yKey: 'proxyY' },
|
||||
{ valueKey: 'unknown', yKey: 'unknownY' },
|
||||
]
|
||||
: [
|
||||
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
||||
{ valueKey: 'proxy', yKey: 'proxyY' },
|
||||
];
|
||||
const visibleLines = lineDefinitions.filter(({ valueKey }) => points.some((point) => point[valueKey] > 0n));
|
||||
const motionFrom = previousSeries.current === series ? previousPoints.current : [];
|
||||
const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom);
|
||||
const newestMotionFrom = trafficPathAnimationSource(
|
||||
penultimate && newest ? [penultimate, newest] : [],
|
||||
motionFrom,
|
||||
);
|
||||
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => (
|
||||
`${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}`
|
||||
)).join('|')}`;
|
||||
const animatePaths = points.length > 0
|
||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
previousPoints.current = points;
|
||||
previousScale.current = scale;
|
||||
}, [points, scale]);
|
||||
previousSeries.current = series;
|
||||
}, [points, series]);
|
||||
|
||||
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
@@ -116,25 +218,46 @@ export function TrafficChart({
|
||||
}}
|
||||
>
|
||||
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
|
||||
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
||||
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
||||
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
||||
{series === 'speed' ? <>
|
||||
<strong className="is-download">↓ Download {formatRate(hovered.gateway)}</strong>
|
||||
<span className="is-upload">↑ Upload {formatRate(hovered.proxy)}</span>
|
||||
<span className="is-interval">За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
|
||||
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
|
||||
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
|
||||
</> : series === 'outbound' ? <>
|
||||
<strong className="is-total">Примерно {formatByteString(hovered.gateway + hovered.proxy + hovered.unknown)}</strong>
|
||||
{hovered.gateway > 0n && <span className="is-vpn">VPN {formatByteString(hovered.gateway)}</span>}
|
||||
{hovered.proxy > 0n && <span className="is-direct-total">Direct {formatByteString(hovered.proxy)}</span>}
|
||||
{hovered.proxy - hovered.directIpv4 > 0n && hovered.directIpv4 > 0n && <>
|
||||
<span className="is-direct-detail">через sing-box {formatByteString(hovered.proxy - hovered.directIpv4)}</span>
|
||||
<span className="is-direct-detail">мимо sing-box · IPv4 {formatByteString(hovered.directIpv4)}</span>
|
||||
</>}
|
||||
{hovered.unknown > 0n && <span className="is-unknown">Маршрут не определён · sing-box {formatByteString(hovered.unknown)}</span>}
|
||||
<span className="is-interval">Оценка за 15-секундный интервал</span>
|
||||
</> : <>
|
||||
<strong className="is-total">Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
||||
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
||||
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
||||
</>}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return <span
|
||||
className="client-device-traffic-chart"
|
||||
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
|
||||
role="img"
|
||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||
aria-label={`${series === 'speed' ? 'История скорости' : series === 'outbound' ? 'Фактический выход трафика' : 'Источник входящего трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
|
||||
style={{
|
||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||
} as CSSProperties}
|
||||
onAnimationEnd={(event: AnimationEvent<HTMLSpanElement>) => {
|
||||
if (collapsing && event.animationName === 'client-device-traffic-plot-collapse') onCollapseEnd?.();
|
||||
}}
|
||||
>
|
||||
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
|
||||
<span className="is-max">{formatByteString(max)}</span>
|
||||
<span className="is-mid">{formatByteString(mid)}</span>
|
||||
<span className="is-max">{series === 'speed' ? formatRate(max) : formatByteString(max)}</span>
|
||||
<span className="is-mid">{series === 'speed' ? formatRate(mid) : formatByteString(mid)}</span>
|
||||
<span className="is-zero">0</span>
|
||||
</span>}
|
||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||
@@ -144,32 +267,31 @@ export function TrafficChart({
|
||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
|
||||
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
|
||||
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
|
||||
{animateScale && <animate key={`proxy-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
|
||||
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
|
||||
</path>}
|
||||
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
|
||||
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
|
||||
</path>}
|
||||
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
|
||||
<g className="client-device-traffic-lines">
|
||||
{visibleLines.map((line) => previous.length > 0 && <path key={`old-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, line.yKey)} to={smoothTrafficPath(previous, line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>)}
|
||||
{visibleLines.map((line) => penultimate && newest && <path key={`new-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, line.yKey)} to={smoothTrafficPath([penultimate, newest], line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>)}
|
||||
{visibleLines.map((line) => !penultimate && newest && <line key={`point-${line.valueKey}`} className={`${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
|
||||
{animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
|
||||
</line>)}
|
||||
</g>
|
||||
{hovered && <g className="client-device-traffic-cursor">
|
||||
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
||||
<line className="is-point is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
|
||||
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
|
||||
{visibleLines.map((line) => hovered[line.valueKey] > 0n && <line key={line.valueKey} className={`is-point ${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
|
||||
</g>}
|
||||
</svg>
|
||||
</span>
|
||||
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
|
||||
<span>15 с</span>
|
||||
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time>
|
||||
{visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
|
||||
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
|
||||
{series === 'outbound' ? <span className="client-device-traffic-legend">
|
||||
{visibleLines.some(({ valueKey }) => valueKey === 'gateway') && <span className="is-vpn">VPN</span>}
|
||||
{visibleLines.some(({ valueKey }) => valueKey === 'proxy') && <span className="is-direct-total">Direct</span>}
|
||||
{visibleLines.some(({ valueKey }) => valueKey === 'unknown') && <span className="is-unknown">Другое</span>}
|
||||
</span> : <span>{series === 'speed' ? '↓ / ↑' : 'Вход'}</span>}
|
||||
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
|
||||
</span>}
|
||||
{tooltip}
|
||||
</span>;
|
||||
|
||||
@@ -5,20 +5,44 @@ type DeviceStatus = 'online' | 'recent' | 'offline';
|
||||
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
|
||||
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
|
||||
|
||||
export interface DeviceTag extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TrafficSample extends Record<string, unknown> {
|
||||
observedAt: string;
|
||||
gatewayBytes: ByteValue;
|
||||
proxyBytes: ByteValue;
|
||||
uploadBytes?: ByteValue;
|
||||
downloadBytes?: ByteValue;
|
||||
}
|
||||
|
||||
export interface OutboundTrafficSample extends Record<string, unknown> {
|
||||
observedAt: string;
|
||||
vpnBytes: ByteValue;
|
||||
directTrackedBytes: ByteValue;
|
||||
directIpv4Bytes: ByteValue;
|
||||
unknownBytes: ByteValue;
|
||||
}
|
||||
|
||||
export interface OutboundTrafficTotal extends OutboundTrafficSample {
|
||||
singboxObservedAt: string | null;
|
||||
directIpv4ObservedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Device extends Record<string, unknown> {
|
||||
id: string;
|
||||
alias: string | null;
|
||||
hostname: string | null;
|
||||
mac: string;
|
||||
ip: string | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string | null;
|
||||
status: DeviceStatus;
|
||||
pinned: boolean;
|
||||
deprioritized?: boolean;
|
||||
tagIds: string[];
|
||||
downloadBytes: ByteValue;
|
||||
uploadBytes: ByteValue;
|
||||
proxyDownloadBytes: ByteValue;
|
||||
@@ -29,6 +53,8 @@ export interface Device extends Record<string, unknown> {
|
||||
appliedPolicy: DevicePolicy;
|
||||
confidence: DeviceConfidence;
|
||||
trafficHistory: TrafficSample[];
|
||||
outboundTraffic?: OutboundTrafficTotal | null;
|
||||
outboundTrafficHistory?: OutboundTrafficSample[];
|
||||
}
|
||||
|
||||
interface SnapshotSource extends Record<string, unknown> {
|
||||
@@ -54,12 +80,16 @@ interface SnapshotSource extends Record<string, unknown> {
|
||||
|
||||
export interface DeviceSnapshot extends Record<string, unknown> {
|
||||
revision: number;
|
||||
tags: DeviceTag[];
|
||||
taggingSupported: boolean;
|
||||
devices: Device[];
|
||||
trafficHistoryCapacity: number;
|
||||
traffic: {
|
||||
gatewayBytes: ByteValue;
|
||||
proxyBytes: ByteValue;
|
||||
totalBytes: ByteValue;
|
||||
uploadBytes?: ByteValue;
|
||||
downloadBytes?: ByteValue;
|
||||
gatewayObservedAt: string | null;
|
||||
proxyObservedAt: string | null;
|
||||
observedAt: string | null;
|
||||
@@ -93,23 +123,53 @@ function validTrafficSample(value: unknown): value is TrafficSample {
|
||||
return record(value)
|
||||
&& timestamp(value.observedAt)
|
||||
&& bytes(value.gatewayBytes)
|
||||
&& bytes(value.proxyBytes);
|
||||
&& bytes(value.proxyBytes)
|
||||
&& (value.uploadBytes === undefined || bytes(value.uploadBytes))
|
||||
&& (value.downloadBytes === undefined || bytes(value.downloadBytes));
|
||||
}
|
||||
|
||||
function validHistory(value: unknown): value is TrafficSample[] {
|
||||
return Array.isArray(value) && value.every(validTrafficSample);
|
||||
}
|
||||
|
||||
function validOutboundTrafficSample(value: unknown): value is OutboundTrafficSample {
|
||||
return record(value)
|
||||
&& timestamp(value.observedAt)
|
||||
&& bytes(value.vpnBytes)
|
||||
&& bytes(value.directTrackedBytes)
|
||||
&& bytes(value.directIpv4Bytes)
|
||||
&& bytes(value.unknownBytes);
|
||||
}
|
||||
|
||||
function validOutboundHistory(value: unknown): value is OutboundTrafficSample[] {
|
||||
return Array.isArray(value) && value.every(validOutboundTrafficSample);
|
||||
}
|
||||
|
||||
function validOutboundTraffic(value: unknown): value is OutboundTrafficTotal | null {
|
||||
return value === null || (validOutboundTrafficSample(value)
|
||||
&& nullableTimestamp(value.singboxObservedAt)
|
||||
&& nullableTimestamp(value.directIpv4ObservedAt));
|
||||
}
|
||||
|
||||
function validDevice(value: unknown): value is Device {
|
||||
return record(value)
|
||||
&& typeof value.id === 'string'
|
||||
&& /^dev_[a-f0-9]{16}$/.test(value.id)
|
||||
&& nullableString(value.alias)
|
||||
&& nullableString(value.hostname)
|
||||
&& typeof value.mac === 'string'
|
||||
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
|
||||
&& nullableString(value.ip)
|
||||
&& timestamp(value.firstSeenAt)
|
||||
&& nullableTimestamp(value.lastSeenAt)
|
||||
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
|
||||
&& typeof value.pinned === 'boolean'
|
||||
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
|
||||
&& (value.tagIds === undefined || (Array.isArray(value.tagIds)
|
||||
&& value.tagIds.length <= 8
|
||||
&& value.tagIds.every((tagId) => typeof tagId === 'string' && /^tag_[a-f0-9]{16}$/.test(tagId))
|
||||
&& new Set(value.tagIds).size === value.tagIds.length))
|
||||
&& !(value.pinned === true && value.deprioritized === true)
|
||||
&& bytes(value.downloadBytes)
|
||||
&& bytes(value.uploadBytes)
|
||||
&& bytes(value.proxyDownloadBytes)
|
||||
@@ -120,7 +180,23 @@ function validDevice(value: unknown): value is Device {
|
||||
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
|
||||
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
|
||||
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
|
||||
&& validHistory(value.trafficHistory);
|
||||
&& validHistory(value.trafficHistory)
|
||||
&& (value.outboundTraffic === undefined || validOutboundTraffic(value.outboundTraffic))
|
||||
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
||||
}
|
||||
|
||||
function validTags(value: unknown): value is DeviceTag[] {
|
||||
return Array.isArray(value)
|
||||
&& value.length <= 32
|
||||
&& value.every((tag) => record(tag)
|
||||
&& typeof tag.id === 'string'
|
||||
&& /^tag_[a-f0-9]{16}$/.test(tag.id)
|
||||
&& typeof tag.name === 'string'
|
||||
&& tag.name.trim() === tag.name
|
||||
&& tag.name.length > 0
|
||||
&& tag.name.length <= 24)
|
||||
&& new Set(value.map((tag) => tag.id)).size === value.length
|
||||
&& new Set(value.map((tag) => tag.name.toLocaleLowerCase('ru-RU'))).size === value.length;
|
||||
}
|
||||
|
||||
function validSource(value: unknown): value is SnapshotSource {
|
||||
@@ -144,6 +220,8 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
|
||||
&& bytes(value.gatewayBytes)
|
||||
&& bytes(value.proxyBytes)
|
||||
&& bytes(value.totalBytes)
|
||||
&& (value.uploadBytes === undefined || bytes(value.uploadBytes))
|
||||
&& (value.downloadBytes === undefined || bytes(value.downloadBytes))
|
||||
&& nullableTimestamp(value.gatewayObservedAt)
|
||||
&& nullableTimestamp(value.proxyObservedAt)
|
||||
&& nullableTimestamp(value.observedAt)
|
||||
@@ -151,12 +229,19 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
|
||||
}
|
||||
|
||||
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
||||
const taggingSupported = record(value) && Object.hasOwn(value, 'tags');
|
||||
const tags = taggingSupported && record(value) && validTags(value.tags) ? value.tags : [];
|
||||
const knownTagIds = new Set(tags.map(({ id }) => id));
|
||||
if (!record(value)
|
||||
|| !Number.isSafeInteger(value.revision)
|
||||
|| typeof value.revision !== 'number'
|
||||
|| value.revision < 0
|
||||
|| !Array.isArray(value.devices)
|
||||
|| !value.devices.every(validDevice)
|
||||
|| (taggingSupported && !validTags(value.tags))
|
||||
|| (taggingSupported && value.devices.some((device) => (
|
||||
!Array.isArray(device.tagIds) || device.tagIds.some((tagId) => !knownTagIds.has(tagId))
|
||||
)))
|
||||
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|
||||
|| typeof value.trafficHistoryCapacity !== 'number'
|
||||
|| value.trafficHistoryCapacity <= 0
|
||||
@@ -168,5 +253,14 @@ function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
|
||||
|
||||
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
|
||||
assertDeviceSnapshot(value);
|
||||
return value;
|
||||
const taggingSupported = Object.hasOwn(value, 'tags');
|
||||
return {
|
||||
...value,
|
||||
taggingSupported,
|
||||
tags: taggingSupported ? value.tags : [],
|
||||
devices: value.devices.map((device) => ({
|
||||
...device,
|
||||
tagIds: taggingSupported && Array.isArray(device.tagIds) ? device.tagIds : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@ import {
|
||||
type FormEvent,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import {
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_NETWORK_SOURCE,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
type DiagnosticService,
|
||||
type DiagnosticSettings,
|
||||
} from '../../../shared/connectivityDiagnostics.js';
|
||||
import {
|
||||
parseConnectivityResult,
|
||||
@@ -18,16 +23,11 @@ import {
|
||||
type DiagnosticSiteResult,
|
||||
} from './connectivityResult.js';
|
||||
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
||||
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
||||
|
||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||
|
||||
interface DiagnosticService extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IpSourceDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -35,10 +35,8 @@ interface IpSourceDefinition {
|
||||
}
|
||||
|
||||
type StatusValue = [className: string, label: string];
|
||||
type RunConnectivityDiagnostics = (
|
||||
services: DiagnosticService[],
|
||||
target: string,
|
||||
) => Promise<unknown>;
|
||||
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
||||
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -85,6 +83,15 @@ function readHiddenServices(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyServices() {
|
||||
try {
|
||||
localStorage.removeItem(CUSTOM_SERVICES_KEY);
|
||||
localStorage.removeItem(HIDDEN_SERVICES_KEY);
|
||||
} catch {
|
||||
// Browser storage is migration-only; canonical settings already live on the backend.
|
||||
}
|
||||
}
|
||||
|
||||
function resultStatus(
|
||||
site: DiagnosticSiteResult | undefined,
|
||||
pending: boolean,
|
||||
@@ -106,6 +113,34 @@ function Status({ value, route }: { value: StatusValue; route: string }) {
|
||||
</span>;
|
||||
}
|
||||
|
||||
function RowRefresh({
|
||||
label,
|
||||
running,
|
||||
disabled,
|
||||
onRun,
|
||||
}: {
|
||||
label: string;
|
||||
running: boolean;
|
||||
disabled: boolean;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return <span className="client-diagnostics-row-refresh-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-diagnostics-refresh client-diagnostics-row-refresh${running ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Проверить: ${label}`}
|
||||
aria-busy={running}
|
||||
disabled={disabled}
|
||||
onClick={onRun}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>Проверить только эту строку</Tooltip>
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) {
|
||||
if (!path?.available) return null;
|
||||
return source.family === 6
|
||||
@@ -128,10 +163,38 @@ function IpCell({
|
||||
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
|
||||
if (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||
}
|
||||
|
||||
function NetworkCell({
|
||||
path,
|
||||
pending,
|
||||
route,
|
||||
}: {
|
||||
path: DiagnosticPath | undefined;
|
||||
pending: boolean;
|
||||
route: string;
|
||||
}) {
|
||||
let status: StatusValue | null = null;
|
||||
if (path?.available === false) status = ['is-muted', '—'];
|
||||
else if (pending) status = ['is-running', 'Тестируем'];
|
||||
else if (!path?.available) status = ['is-muted', '—'];
|
||||
else if (path.network == null) status = ['is-muted', '—'];
|
||||
const identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · ');
|
||||
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
|
||||
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
|
||||
if (status) return <>
|
||||
<Status value={status} route={route} /><br />
|
||||
<span className="client-diagnostics-status is-muted" aria-hidden="true"> </span>
|
||||
</>;
|
||||
return <span aria-label={`${route}: ${[identity, location].filter(Boolean).join(', ')}`}>
|
||||
<span className="client-diagnostics-status">{identity || location}</span><br />
|
||||
<span className="client-diagnostics-status is-muted">{identity && location ? location : <> </>}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
|
||||
const merged = [...previous];
|
||||
for (const item of incoming) {
|
||||
@@ -159,6 +222,7 @@ function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPat
|
||||
ipv4: { addresses, sources },
|
||||
ipv6,
|
||||
ipv6Source,
|
||||
network: incoming.available === false ? null : incoming.network ?? previous?.network ?? null,
|
||||
sites,
|
||||
};
|
||||
}
|
||||
@@ -172,43 +236,49 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
||||
export function ConnectivityDiagnosticsPanel({
|
||||
feature,
|
||||
runConnectivityDiagnostics,
|
||||
settings,
|
||||
updateSettings,
|
||||
isGateway,
|
||||
}: {
|
||||
feature: DiagnosticsFeature;
|
||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||
settings: DiagnosticSettings;
|
||||
updateSettings: UpdateSettings;
|
||||
isGateway: boolean;
|
||||
}) {
|
||||
const [result, setResult] = useState<ConnectivityResult | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
|
||||
const [activeTarget, setActiveTarget] = useState<string | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removingServiceId, setRemovingServiceId] = useState('');
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [serviceName, setServiceName] = useState('');
|
||||
const [serviceUrl, setServiceUrl] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||
const previousTargetRef = useRef<string | null>(null);
|
||||
const retryTargetRef = useRef<string | undefined>(undefined);
|
||||
const migrationAttemptedRef = useRef(false);
|
||||
const requestError = requestDetails(error);
|
||||
const { customServices, hiddenServiceIds } = settings;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
||||
} catch {
|
||||
// The service still works for this session when browser storage is unavailable.
|
||||
if (settings.configured) {
|
||||
clearLegacyServices();
|
||||
return;
|
||||
}
|
||||
}, [customServices]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds));
|
||||
} catch {
|
||||
// The service list still works for this session when browser storage is unavailable.
|
||||
}
|
||||
}, [hiddenServiceIds]);
|
||||
if (migrationAttemptedRef.current) return;
|
||||
migrationAttemptedRef.current = true;
|
||||
void updateSettings({
|
||||
customServices: readCustomServices(),
|
||||
hiddenServiceIds: readHiddenServices(),
|
||||
}).then((saved) => {
|
||||
if (saved === false) return;
|
||||
clearLegacyServices();
|
||||
});
|
||||
}, [settings.configured, updateSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sheet = sheetRef.current;
|
||||
@@ -234,18 +304,20 @@ export function ConnectivityDiagnosticsPanel({
|
||||
previousTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
async function run() {
|
||||
async function run(onlyTarget?: string) {
|
||||
retryTargetRef.current = onlyTarget;
|
||||
setStatus('running');
|
||||
setError(null);
|
||||
try {
|
||||
let next = result;
|
||||
const targets = [
|
||||
const targets = onlyTarget ? [onlyTarget] : [
|
||||
CONNECTIVITY_NETWORK_SOURCE.id,
|
||||
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
||||
...sites.map(({ id }) => `site:${id}`),
|
||||
];
|
||||
for (const target of targets) {
|
||||
setActiveTarget(target);
|
||||
const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target));
|
||||
const partial = parseConnectivityResult(await runConnectivityDiagnostics(target));
|
||||
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
|
||||
next = legacyFullResult ? partial : mergeResult(next, partial);
|
||||
setResult(next);
|
||||
@@ -260,17 +332,18 @@ export function ConnectivityDiagnosticsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function addService(event: FormEvent<HTMLFormElement>) {
|
||||
async function addService(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||
const parsed = new URL(serviceUrl.trim());
|
||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
||||
setCustomServices((services) => [...services, {
|
||||
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||
label: serviceName.trim() || parsed.hostname,
|
||||
url: parsed.href,
|
||||
}]);
|
||||
setSettingsSaving(true);
|
||||
const saved = await saveCustomDiagnosticService({
|
||||
name: serviceName,
|
||||
url: serviceUrl,
|
||||
customServices,
|
||||
hiddenServiceIds,
|
||||
updateSettings,
|
||||
});
|
||||
if (!saved) return;
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
@@ -281,37 +354,52 @@ export function ConnectivityDiagnosticsPanel({
|
||||
? Reflect.get(validationError, 'message')
|
||||
: undefined;
|
||||
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
||||
} finally {
|
||||
setSettingsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removeService(serviceId: string) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
finishRemoveService(serviceId);
|
||||
void finishRemoveService(serviceId);
|
||||
return;
|
||||
}
|
||||
setRemovingServiceId(serviceId);
|
||||
}
|
||||
|
||||
function finishRemoveService(serviceId: string) {
|
||||
const update = () => flushSync(() => {
|
||||
async function finishRemoveService(serviceId: string) {
|
||||
const update = async () => {
|
||||
if (serviceId === 'draft') {
|
||||
setAdding(false);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
} else if (serviceId.startsWith('custom-')) {
|
||||
setCustomServices((services) => services.filter((service) => service.id !== serviceId));
|
||||
} else {
|
||||
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]);
|
||||
flushSync(() => {
|
||||
setAdding(false);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
setRemovingServiceId('');
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRemovingServiceId('');
|
||||
setResult(null);
|
||||
});
|
||||
setSettingsSaving(true);
|
||||
const saved = await updateSettings({
|
||||
customServices: serviceId.startsWith('custom-')
|
||||
? customServices.filter((service) => service.id !== serviceId)
|
||||
: customServices,
|
||||
hiddenServiceIds: serviceId.startsWith('custom-')
|
||||
? hiddenServiceIds
|
||||
: [...new Set([...hiddenServiceIds, serviceId])],
|
||||
});
|
||||
flushSync(() => {
|
||||
setRemovingServiceId('');
|
||||
setSettingsSaving(false);
|
||||
if (saved === false) setFormError('Не удалось сохранить список сервисов.');
|
||||
else setResult(null);
|
||||
});
|
||||
};
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
await update();
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(update);
|
||||
await document.startViewTransition(update).finished;
|
||||
}
|
||||
|
||||
const pending = status === 'running';
|
||||
@@ -319,28 +407,24 @@ export function ConnectivityDiagnosticsPanel({
|
||||
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
||||
...customServices,
|
||||
];
|
||||
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
||||
const serviceEditorBlocked = pending || settingsSaving || Boolean(removingServiceId);
|
||||
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
<Drawer
|
||||
panelRef={panelRef}
|
||||
closeRef={closeRef}
|
||||
sheetRef={sheetRef}
|
||||
id="client-diagnostics"
|
||||
className={`client-drawer client-instructions client-diagnostics${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="client-diagnostics-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
className="client-instructions client-diagnostics"
|
||||
sheetClassName="client-instructions-sheet client-diagnostics-sheet"
|
||||
open={open}
|
||||
labelledBy="client-diagnostics-title"
|
||||
closeLabel="Закрыть диагностику"
|
||||
onClose={onClose}
|
||||
leading={<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />}
|
||||
>
|
||||
<div ref={sheetRef} className="client-drawer-sheet client-instructions-sheet client-diagnostics-sheet">
|
||||
<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть диагностику"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-diagnostics-header">
|
||||
<span>{isGateway ? 'Gateway' : 'Connect'} · Direct ↔ VPN</span>
|
||||
<div className="client-diagnostics-title-row">
|
||||
@@ -352,13 +436,13 @@ export function ConnectivityDiagnosticsPanel({
|
||||
aria-label="Проверить маршруты"
|
||||
aria-busy={pending}
|
||||
disabled={pending}
|
||||
onClick={run}
|
||||
onClick={() => run()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="client-tooltip" role="tooltip">Проверить маршруты</span>
|
||||
<Tooltip>Проверить маршруты</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -366,7 +450,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
{Boolean(error) && <div className="client-diagnostics-feedback">
|
||||
<div className="client-diagnostics-error" role="alert">
|
||||
<span>{requestError.message}</span>
|
||||
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||
{requestError.retryable && <button type="button" onClick={() => run(retryTargetRef.current)}>Повторить</button>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -380,11 +464,44 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<th>Напрямую</th>
|
||||
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
||||
</tr></thead>
|
||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
|
||||
<tbody>
|
||||
<tr
|
||||
data-diagnostic-target={CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
className={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id ? 'is-running' : undefined}
|
||||
>
|
||||
<th scope="row" aria-label={CONNECTIVITY_NETWORK_SOURCE.label}><span className="client-diagnostics-row-name">
|
||||
<span>{CONNECTIVITY_NETWORK_SOURCE.label}</span>
|
||||
<RowRefresh
|
||||
label={CONNECTIVITY_NETWORK_SOURCE.label}
|
||||
running={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
disabled={pending}
|
||||
onRun={() => run(CONNECTIVITY_NETWORK_SOURCE.id)}
|
||||
/>
|
||||
</span></th>
|
||||
<td><NetworkCell
|
||||
path={result?.direct}
|
||||
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
route="Напрямую, сеть"
|
||||
/></td>
|
||||
<td><NetworkCell
|
||||
path={result?.vpn}
|
||||
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
|
||||
route="VPN, сеть"
|
||||
/></td>
|
||||
</tr>
|
||||
{CONNECTIVITY_IP_SOURCES.map((source) => {
|
||||
const target = `ip:${source.id}`;
|
||||
const running = activeTarget === target;
|
||||
return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}>
|
||||
<th scope="row">{source.label}</th>
|
||||
<th scope="row" aria-label={source.label}><span className="client-diagnostics-row-name">
|
||||
<span>{source.label}</span>
|
||||
<RowRefresh
|
||||
label={source.label}
|
||||
running={running}
|
||||
disabled={pending}
|
||||
onRun={() => run(target)}
|
||||
/>
|
||||
</span></th>
|
||||
<td><IpCell path={result?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td>
|
||||
<td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td>
|
||||
</tr>})}</tbody>
|
||||
@@ -416,11 +533,19 @@ export function ConnectivityDiagnosticsPanel({
|
||||
style={{ viewTransitionName: removing ? 'none' : `diagnostic-service-${site.id}` }}
|
||||
inert={removing ? true : undefined}
|
||||
>
|
||||
<span role="rowheader" className="client-diagnostics-service-name">{site.label}</span>
|
||||
<span role="rowheader" aria-label={site.label} className="client-diagnostics-service-name client-diagnostics-row-name">
|
||||
<span>{site.label}</span>
|
||||
<RowRefresh
|
||||
label={site.label}
|
||||
running={running}
|
||||
disabled={pending}
|
||||
onRun={() => run(`site:${site.id}`)}
|
||||
/>
|
||||
</span>
|
||||
<span role="cell"><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></span>
|
||||
<span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить сервис ${site.label}`}
|
||||
disabled={serviceEditorBlocked}
|
||||
@@ -429,7 +554,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService(site.id)}
|
||||
onAnimationEnd={() => void finishRemoveService(site.id)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
@@ -466,7 +591,7 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<button type="submit" disabled={serviceEditorBlocked}>Добавить</button>
|
||||
</span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label="Отменить добавление сервиса"
|
||||
onClick={() => removeService('draft')}
|
||||
@@ -474,15 +599,15 @@ export function ConnectivityDiagnosticsPanel({
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService('draft')}
|
||||
onAnimationEnd={() => void finishRemoveService('draft')}
|
||||
/>
|
||||
</form>}
|
||||
|
||||
{!sites.length && !adding && <p className="client-diagnostics-services-empty">Сервисов пока нет.</p>}
|
||||
|
||||
<div className="client-local-rule-add-slot client-diagnostics-add-slot">
|
||||
<div className="client-row-add-slot client-diagnostics-add-slot">
|
||||
<button
|
||||
className="client-local-rule-add"
|
||||
className="client-row-add"
|
||||
type="button"
|
||||
disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
onClick={() => setAdding(true)}
|
||||
@@ -493,7 +618,6 @@ export function ConnectivityDiagnosticsPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
|
||||
export function useDiagnosticsFeature() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -42,24 +43,30 @@ export type DiagnosticsFeature = ReturnType<typeof useDiagnosticsFeature>;
|
||||
|
||||
export function DiagnosticsToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: DiagnosticsFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-diagnostics-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-diagnostics"
|
||||
aria-label={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle client-diagnostics-toggle"
|
||||
open={open}
|
||||
controls="client-diagnostics"
|
||||
ariaLabel={open ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||
label="Диагностика"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-rail-diagnostics-base" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
|
||||
<path className="client-rail-diagnostics-trace" d="M4.5 10h2l1.2-2.4 2.2 4.8 1.2-2.4h3.4" />
|
||||
<g className="client-rail-diagnostics-position">
|
||||
<g className="client-rail-diagnostics-glass">
|
||||
<circle cx="9.5" cy="9.5" r="6.5" />
|
||||
<path d="m14.2 14.2 6.3 6.3" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<span>Диагностика</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ export interface DiagnosticSiteResult extends Record<string, unknown> {
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticNetworkResult extends Record<string, unknown> {
|
||||
address: string | null;
|
||||
asn: string | null;
|
||||
provider: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticServer extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -26,6 +34,7 @@ export interface DiagnosticPath extends Record<string, unknown> {
|
||||
};
|
||||
ipv6: string | null;
|
||||
ipv6Source: DiagnosticIpResult | null;
|
||||
network?: DiagnosticNetworkResult | null;
|
||||
sites: DiagnosticSiteResult[];
|
||||
server?: DiagnosticServer | null;
|
||||
}
|
||||
@@ -59,6 +68,19 @@ function validSiteResult(value: unknown): value is DiagnosticSiteResult {
|
||||
&& nullableNonnegativeNumber(value.latencyMs);
|
||||
}
|
||||
|
||||
function nullableText(value: unknown): value is string | null {
|
||||
return value === null || (typeof value === 'string' && value.length > 0);
|
||||
}
|
||||
|
||||
function validNetworkResult(value: unknown): value is DiagnosticNetworkResult {
|
||||
return record(value)
|
||||
&& nullableText(value.address)
|
||||
&& nullableText(value.asn)
|
||||
&& nullableText(value.provider)
|
||||
&& nullableText(value.city)
|
||||
&& nullableText(value.country);
|
||||
}
|
||||
|
||||
function validServer(value: unknown): value is DiagnosticServer | null {
|
||||
return value === null || (record(value)
|
||||
&& typeof value.id === 'string'
|
||||
@@ -76,6 +98,7 @@ function validPath(value: unknown): value is DiagnosticPath {
|
||||
&& value.ipv4.sources.every(validIpResult)
|
||||
&& (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0))
|
||||
&& (value.ipv6Source === null || validIpResult(value.ipv6Source))
|
||||
&& (!Object.hasOwn(value, 'network') || value.network === null || validNetworkResult(value.network))
|
||||
&& Array.isArray(value.sites)
|
||||
&& value.sites.every(validSiteResult);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
type DiagnosticService,
|
||||
type DiagnosticSettings,
|
||||
} from '../../../shared/connectivityDiagnostics.js';
|
||||
|
||||
export async function saveCustomDiagnosticService({
|
||||
name,
|
||||
url,
|
||||
customServices,
|
||||
hiddenServiceIds,
|
||||
updateSettings,
|
||||
}: {
|
||||
name: string;
|
||||
url: string;
|
||||
customServices: DiagnosticService[];
|
||||
hiddenServiceIds: string[];
|
||||
updateSettings: (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
||||
}) {
|
||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return false;
|
||||
const parsed = new URL(url.trim());
|
||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
||||
const saved = await updateSettings({
|
||||
customServices: [...customServices, {
|
||||
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||
label: name.trim() || parsed.hostname,
|
||||
url: parsed.href,
|
||||
}],
|
||||
hiddenServiceIds,
|
||||
});
|
||||
if (saved === false) throw new Error('Не удалось сохранить сервис.');
|
||||
return true;
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export {
|
||||
useDiagnosticsFeature,
|
||||
type DiagnosticsFeature,
|
||||
} from './DiagnosticsFeature.js';
|
||||
export { saveCustomDiagnosticService } from './customServiceAction.js';
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, type DiagnosticSettings } from '../../../shared/connectivityDiagnostics.js';
|
||||
import { normalizeFailoverPolicy, type FailoverPolicy, type FailoverSnapshot } from '../../../shared/failover.js';
|
||||
import type { ProfileSnapshot } from '../../../shared/contracts/state.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import { saveCustomDiagnosticService } from '../diagnostics/index.js';
|
||||
export function useFailoverFeature() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const beforeCloseRef = useRef<() => boolean>(() => true);
|
||||
const pendingTargetRef = useRef<(() => void) | null>(null);
|
||||
const close = () => {
|
||||
if (beforeCloseRef.current()) {
|
||||
pendingTargetRef.current = null;
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const handleClose = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest('.client-confirmation-popup')) return;
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener('pointerdown', handleClose);
|
||||
document.addEventListener('keydown', handleClose);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', handleClose);
|
||||
document.removeEventListener('keydown', handleClose);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
return {
|
||||
isOpen, panelRef, toggleRef, closeRef, beforeCloseRef, pendingTargetRef, close,
|
||||
toggle: () => isOpen ? close() : setIsOpen(true),
|
||||
};
|
||||
}
|
||||
|
||||
export type FailoverFeature = ReturnType<typeof useFailoverFeature>;
|
||||
|
||||
export function FailoverToggle({ feature, open, onToggle }: {
|
||||
feature: FailoverFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-failover-toggle"
|
||||
open={open}
|
||||
controls="client-failover"
|
||||
ariaLabel={open ? 'Закрыть резервный канал' : 'Настроить резервный канал'}
|
||||
label="Резерв"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 7h11m-3-3 3 3-3 3M19 17H8m3-3-3 3 3 3" />
|
||||
<circle cx="4" cy="7" r="1" /><circle cx="20" cy="17" r="1" />
|
||||
</svg>
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
const seconds = (milliseconds: number) => Math.round(milliseconds / 1000);
|
||||
const normalizeFailoverDraft = (policy: FailoverPolicy) => {
|
||||
const normalized = normalizeFailoverPolicy(policy);
|
||||
return {
|
||||
...normalized,
|
||||
minimumReserveMs: Math.round(normalized.minimumReserveMs / 60_000) * 60_000,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
function pulseNumber(event: FormEvent<HTMLInputElement>) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
event.currentTarget.animate([
|
||||
{ color: 'var(--client-accent)', textShadow: '0 0 10px color-mix(in oklch, var(--client-accent) 54%, transparent)' },
|
||||
{ color: 'var(--client-text)', textShadow: '0 0 0 transparent' },
|
||||
], { duration: 320, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
|
||||
}
|
||||
|
||||
function FailoverNumberSetting({ before, after, value, min, max, step = 10, disabled, ariaLabel, onChange }: {
|
||||
before: string;
|
||||
after: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const set = (next: number) => onChange(Math.min(max, Math.max(min, next)));
|
||||
return <div className="client-failover-number-setting">
|
||||
<span>{before}</span>
|
||||
<span className="client-failover-number-control">
|
||||
<input
|
||||
className="client-failover-number-input"
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
style={{ width: `${Math.max(2, String(value).length + 0.6)}ch` }}
|
||||
disabled={disabled}
|
||||
onInput={pulseNumber}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
if (Number.isFinite(next)) onChange(next);
|
||||
}}
|
||||
/>
|
||||
<span className="client-failover-number-steps">
|
||||
<button type="button" aria-label={`${ariaLabel}: уменьшить на ${step}`} disabled={disabled || value <= min} onClick={() => set(value - step)}>−</button>
|
||||
<button type="button" aria-label={`${ariaLabel}: увеличить на ${step}`} disabled={disabled || value >= max} onClick={() => set(value + step)}>+</button>
|
||||
</span>
|
||||
</span>
|
||||
<span>{after}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const channelLabel = (profile: string, server: string) => profile.trim() === server.trim()
|
||||
? server
|
||||
: `${profile} · ${server}`;
|
||||
|
||||
function HarborSwitch({ checked, label, disabled, onChange }: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <button
|
||||
className="client-failover-switch"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
><span aria-hidden="true" /></button>;
|
||||
}
|
||||
|
||||
interface PickerOption {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FailoverPicker({ id, label, value, options, open, disabled, onOpenChange, onChange }: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: PickerOption[];
|
||||
open: boolean;
|
||||
disabled?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const enabledIndexes = useMemo(
|
||||
() => options.flatMap((option, index) => option.disabled ? [] : [index]),
|
||||
[options],
|
||||
);
|
||||
const selectedIndex = options.findIndex((option) => option.id === value && !option.disabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
optionRefs.current[selectedIndex >= 0 ? selectedIndex : enabledIndexes[0]]?.focus();
|
||||
});
|
||||
const close = (event: PointerEvent | globalThis.KeyboardEvent) => {
|
||||
if (event.type === 'keydown') {
|
||||
if ((event as globalThis.KeyboardEvent).key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
onOpenChangeRef.current(false);
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (rootRef.current?.contains(event.target as Node)) return;
|
||||
onOpenChangeRef.current(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
document.addEventListener('keydown', close);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', close);
|
||||
document.removeEventListener('keydown', close);
|
||||
};
|
||||
}, [enabledIndexes, open, selectedIndex]);
|
||||
|
||||
const move = (event: ReactKeyboardEvent<HTMLButtonElement>, offset: number) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onOpenChangeRef.current(false);
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const current = optionRefs.current.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const position = Math.max(0, enabledIndexes.indexOf(current));
|
||||
const next = event.key === 'Home'
|
||||
? enabledIndexes[0]
|
||||
: event.key === 'End'
|
||||
? enabledIndexes.at(-1)
|
||||
: enabledIndexes[(position + offset + enabledIndexes.length) % enabledIndexes.length];
|
||||
if (next !== undefined) optionRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
return <div className={`client-failover-picker${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="client-failover-picker-trigger"
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
onOpenChange(true);
|
||||
}}
|
||||
>
|
||||
<span>{value ? options.find((option) => option.id === value)?.label || label : label}</span>
|
||||
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
|
||||
</button>
|
||||
<div className="client-failover-picker-list" id={id} role="listbox" aria-hidden={!open} inert={!open ? true : undefined}>
|
||||
{options.map((option, index) => <button
|
||||
ref={(node) => { optionRefs.current[index] = node; }}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.id === value}
|
||||
aria-disabled={option.disabled || undefined}
|
||||
tabIndex={open && !option.disabled ? 0 : -1}
|
||||
key={option.id}
|
||||
style={{ '--picker-index': Math.min(index, 5) } as React.CSSProperties}
|
||||
onClick={() => {
|
||||
if (option.disabled) return;
|
||||
onChange(option.id);
|
||||
onOpenChange(false);
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
onKeyDown={(event) => move(event, event.key === 'ArrowUp' ? -1 : 1)}
|
||||
>{option.label}</button>)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function FailoverPanel({
|
||||
feature,
|
||||
snapshot,
|
||||
profiles,
|
||||
diagnostics,
|
||||
blocked,
|
||||
onSave,
|
||||
onPause,
|
||||
onSwitch,
|
||||
onCheck,
|
||||
onUpdateDiagnostics,
|
||||
}: {
|
||||
feature: FailoverFeature;
|
||||
snapshot: FailoverSnapshot;
|
||||
profiles: ProfileSnapshot[];
|
||||
diagnostics: DiagnosticSettings;
|
||||
blocked: boolean;
|
||||
onSave: (policy: FailoverPolicy) => Promise<unknown>;
|
||||
onPause: (paused: boolean) => Promise<unknown>;
|
||||
onSwitch: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||
onCheck: () => Promise<unknown>;
|
||||
onUpdateDiagnostics: (settings: unknown) => Promise<unknown>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => normalizeFailoverDraft(snapshot.policy));
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
const [addingService, setAddingService] = useState(false);
|
||||
const [serviceName, setServiceName] = useState('');
|
||||
const [serviceUrl, setServiceUrl] = useState('');
|
||||
const [serviceError, setServiceError] = useState('');
|
||||
const [openPicker, setOpenPicker] = useState('');
|
||||
const [removingCheckId, setRemovingCheckId] = useState('');
|
||||
const [manualChecking, setManualChecking] = useState(false);
|
||||
const discardContinuationFrameRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dirty) setDraft(normalizeFailoverDraft(snapshot.policy));
|
||||
}, [snapshot.policy, dirty]);
|
||||
useEffect(() => {
|
||||
feature.beforeCloseRef.current = () => {
|
||||
if (!dirty) {
|
||||
feature.pendingTargetRef.current = null;
|
||||
return true;
|
||||
}
|
||||
setConfirmDiscard(true);
|
||||
return false;
|
||||
};
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
return () => {
|
||||
feature.beforeCloseRef.current = () => true;
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
};
|
||||
}, [dirty, feature.beforeCloseRef, feature.pendingTargetRef]);
|
||||
useEffect(() => () => {
|
||||
if (discardContinuationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(discardContinuationFrameRef.current);
|
||||
}
|
||||
}, []);
|
||||
const services = useMemo(() => [
|
||||
...CONNECTIVITY_SITES,
|
||||
...diagnostics.customServices,
|
||||
], [diagnostics.customServices]);
|
||||
const channelOptions = useMemo(() => profiles.flatMap((profile) => profile.servers.map((server) => ({
|
||||
id: `${profile.id}\u001f${server.id}`,
|
||||
label: channelLabel(profile.label, server.label),
|
||||
target: { profileId: profile.id, serverId: server.id },
|
||||
}))), [profiles]);
|
||||
const channelPickerOptions = useMemo(() => ({
|
||||
primary: channelOptions.map((option) => {
|
||||
const duplicate = option.target.profileId === draft.reserve.profileId && option.target.serverId === draft.reserve.serverId;
|
||||
return { id: option.id, label: `${option.label}${duplicate ? ' · уже выбран' : ''}`, disabled: duplicate };
|
||||
}),
|
||||
reserve: channelOptions.map((option) => {
|
||||
const duplicate = option.target.profileId === draft.primary.profileId && option.target.serverId === draft.primary.serverId;
|
||||
return { id: option.id, label: `${option.label}${duplicate ? ' · уже выбран' : ''}`, disabled: duplicate };
|
||||
}),
|
||||
}), [
|
||||
channelOptions,
|
||||
draft.primary.profileId,
|
||||
draft.primary.serverId,
|
||||
draft.reserve.profileId,
|
||||
draft.reserve.serverId,
|
||||
]);
|
||||
const servicePickerOptions = useMemo(() => [
|
||||
...services.filter((service) => !draft.checks.some(({ serviceId }) => serviceId === service.id)).map((service) => ({ id: service.id, label: service.label })),
|
||||
...(diagnostics.customServices.length < MAX_CUSTOM_DIAGNOSTIC_SERVICES ? [{ id: '__new-service', label: 'Новый HTTPS-сервис…' }] : []),
|
||||
], [diagnostics.customServices.length, draft.checks, services]);
|
||||
const update = (value: Partial<FailoverPolicy>) => {
|
||||
setDraft((current) => normalizeFailoverDraft({ ...current, ...value }));
|
||||
setDirty(true);
|
||||
};
|
||||
const updateChecks = (change: (checks: FailoverPolicy['checks']) => FailoverPolicy['checks']) => {
|
||||
setDraft((current) => normalizeFailoverDraft({ ...current, checks: change(current.checks) }));
|
||||
setDirty(true);
|
||||
};
|
||||
const updateTarget = (channel: 'primary' | 'reserve', optionId: string) => {
|
||||
const target = channelOptions.find(({ id }) => id === optionId)?.target;
|
||||
if (target) update({ [channel]: target } as Partial<FailoverPolicy>);
|
||||
};
|
||||
const role = snapshot.currentRole === 'primary' || snapshot.currentRole === 'reserve'
|
||||
? snapshot.currentRole
|
||||
: null;
|
||||
const switchRole = role === 'primary' ? 'reserve' : role === 'reserve' ? 'primary' : null;
|
||||
const switchActionLabel = role && switchRole
|
||||
? `Сейчас ${role === 'primary' ? 'основной' : 'резервный'} канал. Переключить на ${switchRole === 'primary' ? 'основной' : 'резервный'} канал`
|
||||
: 'Переключение недоступно';
|
||||
const checking = manualChecking || snapshot.reason === 'checking-channels';
|
||||
const targetExists = (target: FailoverPolicy['primary']) => profiles
|
||||
.find(({ id }) => id === target.profileId)?.servers.some(({ id }) => id === target.serverId);
|
||||
const targetsValid = targetExists(draft.primary) && targetExists(draft.reserve)
|
||||
&& (draft.primary.profileId !== draft.reserve.profileId || draft.primary.serverId !== draft.reserve.serverId);
|
||||
const draftValid = !draft.enabled || Boolean(targetsValid && draft.checks.length);
|
||||
const addService = async () => {
|
||||
try {
|
||||
const saved = await saveCustomDiagnosticService({
|
||||
name: serviceName,
|
||||
url: serviceUrl,
|
||||
customServices: diagnostics.customServices,
|
||||
hiddenServiceIds: diagnostics.hiddenServiceIds,
|
||||
updateSettings: onUpdateDiagnostics,
|
||||
});
|
||||
if (!saved) return;
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setServiceError('');
|
||||
setAddingService(false);
|
||||
} catch (error) {
|
||||
setServiceError(error instanceof Error ? error.message : 'Проверьте адрес.');
|
||||
}
|
||||
};
|
||||
const removeCheck = (serviceId: string) => {
|
||||
const finish = () => updateChecks((checks) => checks.filter((check) => check.serviceId !== serviceId));
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) finish();
|
||||
else setRemovingCheckId(serviceId);
|
||||
};
|
||||
const finishRemoveCheck = (serviceId: string) => {
|
||||
const finish = () => flushSync(() => {
|
||||
setRemovingCheckId('');
|
||||
updateChecks((checks) => checks.filter((check) => check.serviceId !== serviceId));
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) finish();
|
||||
else document.startViewTransition(finish);
|
||||
};
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-failover"
|
||||
open={feature.isOpen}
|
||||
label="Резервный канал"
|
||||
closeLabel="Закрыть резервный канал"
|
||||
onClose={feature.close}
|
||||
className="client-failover-drawer"
|
||||
>
|
||||
<header className="client-failover-header">
|
||||
<span>Gateway</span>
|
||||
<div className="client-failover-heading">
|
||||
<h2>Резервный канал</h2>
|
||||
<div className="client-failover-header-actions">
|
||||
<div className="client-failover-switch-setting">
|
||||
<span>Канал</span>
|
||||
<HarborSwitch
|
||||
checked={draft.enabled}
|
||||
label={draft.enabled ? 'Выключить резервный канал' : 'Включить резервный канал'}
|
||||
disabled={blocked}
|
||||
onChange={(enabled) => {
|
||||
setOpenPicker('');
|
||||
if (!enabled) setAddingService(false);
|
||||
update({ enabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="client-failover-switch-setting">
|
||||
<span>Автоматика</span>
|
||||
<HarborSwitch
|
||||
checked={!snapshot.paused}
|
||||
label={snapshot.paused ? 'Включить автоматическое переключение' : 'Остановить автоматическое переключение'}
|
||||
disabled={blocked || !snapshot.enabled}
|
||||
onChange={(automatic) => void onPause(!automatic)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="client-failover-save"
|
||||
type="submit"
|
||||
form="client-failover-form"
|
||||
disabled={blocked || !dirty || !draftValid}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="client-failover-intro">
|
||||
<p>Если основной канал перестанет работать, Harbor направит новые подключения через резервный канал. Уже открытые подключения продолжат работать через прежний канал.</p>
|
||||
{snapshot.enabled && <small className="client-failover-pause-hint">{snapshot.paused ? 'Новые подключения продолжают идти через текущий канал.' : 'Harbor проверяет оба канала и при необходимости переключает новые подключения.'}</small>}
|
||||
</div>
|
||||
|
||||
<form id="client-failover-form" className="client-failover-form" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSave(normalizeFailoverDraft(draft)).then((result) => { if (result !== false) setDirty(false); });
|
||||
}}>
|
||||
<section className="client-failover-channels" aria-label="Основной и резервный каналы">
|
||||
{(['primary', 'reserve'] as const).map((channel) => {
|
||||
const health = snapshot[channel].health;
|
||||
const missing = Boolean(snapshot[channel].target.profileId) && !targetExists(snapshot[channel].target);
|
||||
const selectedId = channelOptions.find(({ target }) => (
|
||||
target.profileId === draft[channel].profileId && target.serverId === draft[channel].serverId
|
||||
))?.id || '';
|
||||
const healthLabel = missing
|
||||
? 'Сервер не найден'
|
||||
: !draft.enabled
|
||||
? 'Канал выключен'
|
||||
: snapshot.reason === 'vpn-stopped' || snapshot.activation === 'inactive'
|
||||
? 'VPN выключен'
|
||||
: snapshot.activation === 'pending'
|
||||
? 'Проверим после запуска VPN'
|
||||
: channel === 'primary' && snapshot.reason === 'failure-window'
|
||||
? `Проверяем основной канал · ${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} из ${seconds(draft.failureWindowMs)} с`
|
||||
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
|
||||
? 'Проверяем восстановление'
|
||||
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Проверка выключена' : 'Ждёт проверки';
|
||||
return <div className="client-failover-channel-slot" key={channel}><article className="client-failover-channel" data-channel={channel}>
|
||||
<div className="client-failover-channel-title">
|
||||
<h3>{channel === 'primary' ? 'Основной' : 'Резервный'}</h3>
|
||||
<strong className={health === 'healthy' ? 'is-healthy' : health === 'unhealthy' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
|
||||
</div>
|
||||
<FailoverPicker
|
||||
id={`client-failover-${channel}-options`}
|
||||
label={draft[channel].profileId ? 'Сервер не найден' : 'Выбрать канал'}
|
||||
value={selectedId}
|
||||
options={channelPickerOptions[channel]}
|
||||
open={openPicker === channel && !blocked && draft.enabled}
|
||||
disabled={blocked || !draft.enabled || channelOptions.length === 0}
|
||||
onOpenChange={(open) => setOpenPicker(open ? channel : '')}
|
||||
onChange={(optionId) => updateTarget(channel, optionId)}
|
||||
/>
|
||||
<span className={`client-failover-channel-checking${checking ? ' is-visible' : ''}`} aria-hidden="true">
|
||||
Проверяем {channel === 'primary' ? 'основной' : 'резервный'} канал
|
||||
</span>
|
||||
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
|
||||
<button
|
||||
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-label="Проверить оба канала"
|
||||
aria-busy={checking}
|
||||
disabled={blocked || checking || !snapshot.enabled || snapshot.activation !== 'active'}
|
||||
onClick={() => {
|
||||
setManualChecking(true);
|
||||
void onCheck().finally(() => setManualChecking(false));
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
<Tooltip>Проверить оба канала</Tooltip>
|
||||
</button>
|
||||
<button
|
||||
className={`client-failover-direction-action client-tooltip-anchor${role === 'primary' ? ' is-primary' : role === 'reserve' ? ' is-reserve' : ''}`}
|
||||
type="button"
|
||||
aria-label={switchActionLabel}
|
||||
disabled={blocked || !snapshot.enabled || snapshot.activation !== 'active' || !switchRole}
|
||||
onClick={() => { if (switchRole) void onSwitch(switchRole); }}
|
||||
>
|
||||
<svg className={role === 'primary' ? 'is-primary' : role === 'reserve' ? 'is-reserve' : undefined} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 12h15M14 7l5 5-5 5" />
|
||||
</svg>
|
||||
<Tooltip>{switchActionLabel}</Tooltip>
|
||||
</button>
|
||||
</div>}</div>;
|
||||
})}
|
||||
<span className="client-live-region" role="status" aria-live="polite">{checking ? 'Проверяем основной и резервный каналы' : ''}</span>
|
||||
</section>
|
||||
|
||||
<fieldset className="client-failover-services">
|
||||
<legend><span className="client-failover-section-header"><strong>Что проверять</strong><small>Harbor проверяет эти сайты через оба канала, чтобы понять, какой канал работает.</small></span></legend>
|
||||
{draft.checks.map((check) => {
|
||||
const service = services.find(({ id }) => check.serviceId === id);
|
||||
const removing = removingCheckId === check.serviceId;
|
||||
return <div
|
||||
className={`client-failover-service${removing ? ' is-removing' : ''}`}
|
||||
key={check.serviceId}
|
||||
inert={removing ? true : undefined}
|
||||
style={{ viewTransitionName: removing ? 'none' : `failover-check-${check.serviceId}` }}
|
||||
onAnimationEnd={(event) => {
|
||||
if (removing && event.target === event.currentTarget) finishRemoveCheck(check.serviceId);
|
||||
}}
|
||||
>
|
||||
<span className="client-failover-service-name">{service?.label || 'Сайт не найден'}</span>
|
||||
<FailoverNumberSetting
|
||||
before="Ждать ответа не дольше"
|
||||
after="секунд"
|
||||
min={2}
|
||||
max={30}
|
||||
step={1}
|
||||
ariaLabel={`Время ожидания ответа: ${service?.label || 'сайт не найден'}`}
|
||||
value={seconds(check.timeoutMs)}
|
||||
disabled={blocked || !draft.enabled}
|
||||
onChange={(value) => updateChecks((checks) => checks.map((item) => item.serviceId === check.serviceId
|
||||
? { ...item, timeoutMs: value * 1_000 }
|
||||
: item))}
|
||||
/>
|
||||
<button
|
||||
className="client-failover-remove-service"
|
||||
type="button"
|
||||
aria-label={`Убрать проверку: ${service?.label || 'сайт не найден'}`}
|
||||
disabled={blocked || !draft.enabled || draft.checks.length === 1 || Boolean(removingCheckId)}
|
||||
onClick={() => removeCheck(check.serviceId)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-failover-remove-lid" d="M8 7V5h8v2m-11 0h14" />
|
||||
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>;
|
||||
})}
|
||||
{!addingService && <FailoverPicker
|
||||
id="client-failover-service-options"
|
||||
label="Добавить проверку"
|
||||
value=""
|
||||
options={servicePickerOptions}
|
||||
open={openPicker === 'services' && !blocked && draft.enabled}
|
||||
disabled={blocked || !draft.enabled || (
|
||||
services.every((service) => draft.checks.some(({ serviceId }) => serviceId === service.id))
|
||||
&& diagnostics.customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES
|
||||
)}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'services' : '')}
|
||||
onChange={(serviceId) => {
|
||||
if (serviceId === '__new-service') setAddingService(true);
|
||||
else updateChecks((checks) => [...checks, { serviceId, timeoutMs: 6_000 }]);
|
||||
}}
|
||||
/>}
|
||||
{addingService && <div className="client-failover-service-editor">
|
||||
<input aria-label="Название HTTPS-сервиса" placeholder="Название" disabled={blocked || !draft.enabled} value={serviceName} onChange={(event) => setServiceName(event.target.value)} />
|
||||
<input aria-label="HTTPS-адрес сервиса" placeholder="https://example.com/health" disabled={blocked || !draft.enabled} value={serviceUrl} onChange={(event) => setServiceUrl(event.target.value)} />
|
||||
{serviceError && <span role="alert">{serviceError}</span>}
|
||||
<button type="button" disabled={blocked || !draft.enabled || !serviceUrl.trim()} onClick={() => void addService()}>Добавить</button>
|
||||
<button type="button" disabled={blocked || !draft.enabled} onClick={() => { setAddingService(false); setServiceError(''); }}>Отмена</button>
|
||||
</div>}
|
||||
</fieldset>
|
||||
|
||||
<section className="client-failover-timing" aria-label="Пороги переключения">
|
||||
<header className="client-failover-section-header">
|
||||
<h3>Когда переключаться на резервный канал</h3>
|
||||
<p>Harbor направит новые подключения через резервный канал, если основной канал не отвечает, а резервный канал работает.</p>
|
||||
</header>
|
||||
<FailoverNumberSetting before="Проверять оба канала каждые" after="секунд" min={15} max={900} ariaLabel="Интервал проверки каналов" value={seconds(draft.intervalMs)} disabled={blocked || !draft.enabled} onChange={(value) => update({ intervalMs: value * 1_000 })} />
|
||||
<FailoverNumberSetting before="Считать основной канал недоступным, если он не отвечает" after="секунд" min={seconds(draft.intervalMs * 2)} max={1800} ariaLabel="Длительность сбоя основного канала" value={seconds(draft.failureWindowMs)} disabled={blocked || !draft.enabled} onChange={(value) => update({ failureWindowMs: value * 1_000 })} />
|
||||
<FailoverNumberSetting before="Вернуться на основной канал после" after="секунд без сбоев" min={60} max={86400} ariaLabel="Время стабильной работы основного канала" value={seconds(draft.recoveryWindowMs)} disabled={blocked || !draft.enabled} onChange={(value) => update({ recoveryWindowMs: value * 1_000 })} />
|
||||
<div className="client-failover-traffic-switch">
|
||||
<span><strong>Дождаться паузы в передаче данных</strong><small>{draft.trafficGuard.enabled ? 'Harbor подождёт, пока передача данных почти остановится, и только потом переключит новые подключения.' : 'Harbor переключит новые подключения сразу после того, как убедится, что основной канал не работает.'}</small></span>
|
||||
<HarborSwitch
|
||||
checked={draft.trafficGuard.enabled}
|
||||
label={draft.trafficGuard.enabled ? 'Переключать без ожидания паузы' : 'Ждать паузу перед переключением'}
|
||||
disabled={blocked || !draft.enabled}
|
||||
onChange={(enabled) => update({ trafficGuard: { ...draft.trafficGuard, enabled } })}
|
||||
/>
|
||||
</div>
|
||||
<FailoverNumberSetting before="Переключить после" after="секунд без передачи данных" min={5} max={600} ariaLabel="Время без активной передачи данных" value={seconds(draft.trafficGuard.quietWindowMs)} disabled={blocked || !draft.enabled || !draft.trafficGuard.enabled} onChange={(value) => update({ trafficGuard: { ...draft.trafficGuard, quietWindowMs: value * 1_000 } })} />
|
||||
<FailoverNumberSetting before="Считать передачу активной при скорости выше" after="КБ/с" min={1} max={102400} ariaLabel="Скорость активной работы" value={Math.round(draft.trafficGuard.thresholdBytesPerSecond / 1024)} disabled={blocked || !draft.enabled || !draft.trafficGuard.enabled} onChange={(value) => update({ trafficGuard: { ...draft.trafficGuard, thresholdBytesPerSecond: value * 1024 } })} />
|
||||
</section>
|
||||
|
||||
<section className="client-failover-protection" aria-labelledby="client-failover-protection-title">
|
||||
<header className="client-failover-section-header">
|
||||
<h3 id="client-failover-protection-title">Повторные сбои основного канала</h3>
|
||||
<p>Если основной канал снова перестаёт работать, Harbor дольше оставляет новые подключения на резервном канале.</p>
|
||||
</header>
|
||||
<div className="client-failover-protection-options">
|
||||
<FailoverNumberSetting before="Оставаться на резервном канале не меньше" after="минут" min={1} max={1440} step={1} ariaLabel="Минимальное время на резервном канале" value={draft.minimumReserveMs / 60_000} disabled={blocked || !draft.enabled} onChange={(value) => update({ minimumReserveMs: Math.round(value) * 60_000 })} />
|
||||
<FailoverNumberSetting before="Считать основной канал нестабильным после" after="повторных сбоев" min={2} max={10} step={1} ariaLabel="Количество повторных сбоев" value={draft.flapProtection.count} disabled={blocked || !draft.enabled} onChange={(value) => update({ flapProtection: { ...draft.flapProtection, count: value } })} />
|
||||
<FailoverNumberSetting before="Учитывать сбои, случившиеся не более" after="ч назад" min={1} max={72} step={1} ariaLabel="Период повторных сбоев" value={Math.round(draft.flapProtection.windowMs / 3_600_000)} disabled={blocked || !draft.enabled} onChange={(value) => update({ flapProtection: { ...draft.flapProtection, windowMs: value * 3_600_000 } })} />
|
||||
<FailoverNumberSetting before="После повторных сбоев не возвращаться на основной канал" after="минут" min={10} max={10080} ariaLabel="Длительность карантина основного канала" value={Math.round(draft.flapProtection.quarantineMs / 60_000)} disabled={blocked || !draft.enabled} onChange={(value) => update({ flapProtection: { ...draft.flapProtection, quarantineMs: value * 60_000 } })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!draftValid && <p className="client-failover-validation">Выберите разные серверы для основного и резервного каналов и хотя бы один сайт для проверки.</p>}
|
||||
</form>
|
||||
<ConfirmationDialog
|
||||
open={confirmDiscard}
|
||||
id="discard-failover"
|
||||
title="Закрыть без сохранения?"
|
||||
description="Несохранённые настройки резервного канала будут потеряны."
|
||||
cancelLabel="Остаться"
|
||||
confirmLabel="Закрыть без сохранения"
|
||||
onCancel={() => {
|
||||
feature.pendingTargetRef.current = null;
|
||||
setConfirmDiscard(false);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const continueNavigation = feature.pendingTargetRef.current;
|
||||
feature.pendingTargetRef.current = null;
|
||||
feature.beforeCloseRef.current = () => true;
|
||||
flushSync(() => {
|
||||
setDraft(normalizeFailoverDraft(snapshot.policy));
|
||||
setDirty(false);
|
||||
setConfirmDiscard(false);
|
||||
});
|
||||
discardContinuationFrameRef.current = requestAnimationFrame(() => {
|
||||
discardContinuationFrameRef.current = null;
|
||||
if (continueNavigation) continueNavigation();
|
||||
else feature.close();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Drawer>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FailoverPanel, FailoverToggle, useFailoverFeature } from './FailoverFeature.js';
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { CopyButton } from '../../ui/CopyButton.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { copyText } from '../../utils/clientControls.js';
|
||||
import { instructionBlocks } from './instructionBlocks.js';
|
||||
|
||||
@@ -55,22 +58,48 @@ function InstructionBlock({
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
|
||||
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
|
||||
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
|
||||
const copyAttempts = useRef(new Map<string, object>());
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
for (const timer of copyTimers.current.values()) clearTimeout(timer);
|
||||
copyTimers.current.clear();
|
||||
copyAttempts.current.clear();
|
||||
}, []);
|
||||
|
||||
async function copyInstruction(action: InstructionCopyAction) {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
const activeTimer = copyTimers.current.get(action.id);
|
||||
if (activeTimer) clearTimeout(activeTimer);
|
||||
const attempt = {};
|
||||
copyAttempts.current.set(action.id, attempt);
|
||||
let feedback: { failed: boolean };
|
||||
try {
|
||||
await copyText(action.text);
|
||||
setCopyFeedback({ id: action.id, failed: false });
|
||||
feedback = { failed: false };
|
||||
} catch {
|
||||
setCopyFeedback({ id: action.id, failed: true });
|
||||
feedback = { failed: true };
|
||||
}
|
||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||
if (copyAttempts.current.get(action.id) !== attempt) return;
|
||||
const announcement = {
|
||||
id: action.id,
|
||||
message: feedback.failed ? `Не удалось скопировать ${action.label}` : `${action.label} скопировано`,
|
||||
};
|
||||
const pendingTimer = copyTimers.current.get(action.id);
|
||||
if (pendingTimer) clearTimeout(pendingTimer);
|
||||
setCopyFeedback((current) => ({ ...current, [action.id]: feedback }));
|
||||
setCopyAnnouncement(announcement);
|
||||
copyTimers.current.set(action.id, setTimeout(() => {
|
||||
setCopyFeedback((current) => {
|
||||
const next = { ...current };
|
||||
delete next[action.id];
|
||||
return next;
|
||||
});
|
||||
setCopyAnnouncement((current) => current?.id === action.id ? null : current);
|
||||
copyTimers.current.delete(action.id);
|
||||
copyAttempts.current.delete(action.id);
|
||||
}, 800));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -106,23 +135,19 @@ function InstructionBlock({
|
||||
: <code>{block.code}</code>)}
|
||||
{block.copies && <div className="client-instruction-copies">
|
||||
{block.copies.map((action) => {
|
||||
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
|
||||
const feedback = copyFeedback[action.id];
|
||||
return <div className="client-instruction-copy" key={action.id}>
|
||||
<span>{action.label}</span>
|
||||
<button
|
||||
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
<CopyButton
|
||||
className="client-instruction-copy-button"
|
||||
label="Скопировать"
|
||||
feedback={feedback}
|
||||
onClick={() => copyInstruction(action)}
|
||||
>
|
||||
<span className="client-copy-label">Скопировать</span>
|
||||
{feedback && <span className="client-copy-feedback" aria-hidden="true">
|
||||
{feedback.failed ? 'Ошибка' : 'Скопировано'}
|
||||
</span>}
|
||||
</button>
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<span className="client-live-region" role="status" aria-live="polite">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||
{copyAnnouncement?.message || ''}
|
||||
</span>
|
||||
</div>}
|
||||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||
@@ -207,26 +232,32 @@ export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
|
||||
|
||||
export function InstructionsToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: InstructionsFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-instructions"
|
||||
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-instructions-toggle"
|
||||
open={open}
|
||||
controls="client-instructions"
|
||||
ariaLabel={open ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
label="Как использовать"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
<g className="client-rail-book-page-position is-left">
|
||||
<path className="client-rail-book-page is-left" d="M12 7.5C10.2 6.1 7.7 5.7 5 6v11c2.8-.3 5.2.3 7 1.7z" />
|
||||
</g>
|
||||
<g className="client-rail-book-page-position is-right">
|
||||
<path className="client-rail-book-page is-right" d="M12 7.5c1.8-1.4 4.3-1.8 7-1.5v11c-2.8-.3-5.2.3-7 1.7z" />
|
||||
</g>
|
||||
<path className="client-rail-book-spine" d="M12 7.5v11.2" />
|
||||
</svg>
|
||||
<span>Как использовать</span>
|
||||
</button>;
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
export function InstructionsPanel({
|
||||
@@ -236,40 +267,34 @@ export function InstructionsPanel({
|
||||
feature: InstructionsFeature;
|
||||
isGateway: boolean;
|
||||
}) {
|
||||
return <aside
|
||||
ref={feature.panelRef}
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-instructions"
|
||||
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="instructions-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
className="client-instructions"
|
||||
sheetClassName="client-instructions-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="instructions-title"
|
||||
closeLabel="Закрыть инструкции"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<div className="client-drawer-sheet client-instructions-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть инструкции"
|
||||
onClick={feature.close}
|
||||
>×</button>
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{feature.guides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === feature.openInstructionId}
|
||||
onToggle={() => feature.toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{feature.guides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === feature.openInstructionId}
|
||||
onToggle={() => feature.toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>;
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
||||
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||
],
|
||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.',
|
||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.',
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,15 +2,39 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FocusEvent as ReactFocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { canAppendRouteRule } from '../../../shared/routingRules.js';
|
||||
import {
|
||||
canAppendRouteRule,
|
||||
normalizeRouteRules,
|
||||
ROUTE_RULES_CONTRACT_VERSION,
|
||||
} from '../../../shared/routingRules.js';
|
||||
import type { RouteRule } from '../../../shared/contracts/state.js';
|
||||
import { operationBlocked } from '../../state/operations.js';
|
||||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import {
|
||||
beginRuleReorder,
|
||||
crossedRuleIndex,
|
||||
edgeScrollDelta,
|
||||
endRuleReorder,
|
||||
keyboardRuleIndex,
|
||||
moveRule,
|
||||
RULE_DROP_DURATION_MS,
|
||||
RULE_REORDER_DURATION_MS,
|
||||
restoreRuleOrder,
|
||||
shouldLiftRule,
|
||||
type RuleReorderEndReason,
|
||||
type RuleReorderLifecycle,
|
||||
} from './ruleReorderModel.js';
|
||||
|
||||
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
||||
['domain', 'Точный домен'],
|
||||
@@ -30,6 +54,8 @@ interface DraftRule extends RouteRule {
|
||||
}
|
||||
|
||||
interface RoutingState {
|
||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||
mode?: string;
|
||||
localRules?: RouteRule[];
|
||||
activeLocalRules?: RouteRule[];
|
||||
localRulesRevision?: number;
|
||||
@@ -49,39 +75,66 @@ interface RoutingSaveState {
|
||||
localRulesPendingRestart: boolean;
|
||||
}
|
||||
|
||||
interface RuleDragSession extends RuleReorderLifecycle {
|
||||
originRules: DraftRule[];
|
||||
pointerId: number | null;
|
||||
startY: number;
|
||||
latestY: number;
|
||||
pointerOffsetY: number;
|
||||
translateY: number;
|
||||
raf: number | null;
|
||||
capture: HTMLElement;
|
||||
handle: HTMLButtonElement;
|
||||
}
|
||||
|
||||
let localRuleDraftId = 0;
|
||||
|
||||
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
|
||||
...rule,
|
||||
enabled: rule?.enabled !== false,
|
||||
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
|
||||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||||
});
|
||||
|
||||
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound }));
|
||||
|
||||
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
|
||||
rules
|
||||
.filter((rule) => !rule.removing)
|
||||
.map(({ type, value, enabled }) => ({ type, value, enabled })),
|
||||
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })),
|
||||
);
|
||||
|
||||
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
|
||||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
|
||||
const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
|
||||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}`
|
||||
);
|
||||
|
||||
const sameRule = (left?: RouteRule, right?: RouteRule) => (
|
||||
Boolean(left && right && localRuleKey(left) === localRuleKey(right))
|
||||
);
|
||||
|
||||
const normalizeDraftRuleValue = (rule: DraftRule, value: string) => {
|
||||
try {
|
||||
return normalizeRouteRules([{ ...rule, value }], { strict: true })[0]?.value || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
function localRuleStatus(
|
||||
rule: DraftRule,
|
||||
index: number,
|
||||
savedRules: RouteRule[],
|
||||
activeRules: RouteRule[],
|
||||
runtimeActive: boolean,
|
||||
bypassed: boolean,
|
||||
) {
|
||||
const key = localRuleKey(rule);
|
||||
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
|
||||
if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
|
||||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||||
if (bypassed) return ['bypassed', 'Обходится'];
|
||||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||||
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
|
||||
if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
|
||||
return ['pending', 'Ждёт перезапуска'];
|
||||
}
|
||||
|
||||
@@ -120,19 +173,34 @@ export function useRoutingFeature({
|
||||
const [rules, setRules] = useState<DraftRule[]>([]);
|
||||
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
|
||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||
const [liftedKey, setLiftedKey] = useState<string | null>(null);
|
||||
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const baselineRef = useRef('[]');
|
||||
const rulesRef = useRef<DraftRule[]>(rules);
|
||||
const dragRef = useRef<RuleDragSession | null>(null);
|
||||
const suppressHandleClickRef = useRef(false);
|
||||
rulesRef.current = rules;
|
||||
const savedRules = route?.localRules || [];
|
||||
const activeRules = route?.activeLocalRules || [];
|
||||
const editable = route?.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION;
|
||||
const bypassed = route?.mode === 'gateway-direct';
|
||||
const dirty = localRulesSignature(rules) !== baselineRef.current;
|
||||
const pendingRestart = connected && route?.localRulesPendingRestart === true;
|
||||
const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true;
|
||||
const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true;
|
||||
const pendingCount = pendingRestart
|
||||
? savedRules.filter((rule) => (
|
||||
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length
|
||||
? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
|
||||
: 0;
|
||||
const reorderedOnly = pendingRestart
|
||||
&& savedRules.length === activeRules.length
|
||||
&& JSON.stringify(savedRules.map(localRuleKey).sort()) === JSON.stringify(activeRules.map(localRuleKey).sort());
|
||||
const pendingMessage = !pendingRestart
|
||||
? ''
|
||||
: reorderedOnly || pendingCount === 0
|
||||
? 'Изменения правил не применены'
|
||||
: `${pendingCount} ${pendingCount === 1 ? 'правило не применено' : 'правила не применены'}`;
|
||||
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -152,6 +220,11 @@ export function useRoutingFeature({
|
||||
if (event.type === 'keydown') {
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
|
||||
if (dragRef.current?.lifted) {
|
||||
keyboardEvent.preventDefault();
|
||||
cancelReorder();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (panelRef.current?.contains(event.target as Node)) return;
|
||||
if (toggleRef.current?.contains(event.target as Node)) return;
|
||||
@@ -166,6 +239,14 @@ export function useRoutingFeature({
|
||||
};
|
||||
}, [isOpen, dirty]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const session = dragRef.current;
|
||||
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
|
||||
cancelAnimationFrame(session.raf);
|
||||
}
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !dirty) return undefined;
|
||||
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
@@ -177,8 +258,10 @@ export function useRoutingFeature({
|
||||
}, [isOpen, dirty]);
|
||||
|
||||
function open() {
|
||||
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
|
||||
setRules(savedRules.map(createLocalRuleDraft));
|
||||
const nextRules = savedRules.map(createLocalRuleDraft);
|
||||
baselineRef.current = localRulesSignature(savedRules);
|
||||
rulesRef.current = nextRules;
|
||||
setRules(nextRules);
|
||||
setRevision(route?.localRulesRevision || 0);
|
||||
setConfirmingClose(false);
|
||||
onDismissError();
|
||||
@@ -186,11 +269,13 @@ export function useRoutingFeature({
|
||||
}
|
||||
|
||||
function forceClose() {
|
||||
cancelReorder(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (dirty) {
|
||||
const currentRules = cancelReorder(false);
|
||||
if (localRulesSignature(currentRules) !== baselineRef.current) {
|
||||
setConfirmingClose(true);
|
||||
return false;
|
||||
}
|
||||
@@ -199,36 +284,52 @@ export function useRoutingFeature({
|
||||
}
|
||||
|
||||
function discard() {
|
||||
cancelReorder(false);
|
||||
setConfirmingClose(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
|
||||
setRules((current) => current.map((rule, ruleIndex) => (
|
||||
function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
|
||||
setRules((current) => {
|
||||
const next = updater(current);
|
||||
rulesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function change(
|
||||
index: number,
|
||||
field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled' | 'outbound'>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (!editable || blocked) return;
|
||||
updateRules((current) => current.map((rule, ruleIndex) => (
|
||||
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
|
||||
)));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setRules((current) => [
|
||||
if (!editable || blocked) return;
|
||||
updateRules((current) => [
|
||||
...current,
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
|
||||
]);
|
||||
}
|
||||
|
||||
function remove(ruleKey: string) {
|
||||
if (!editable || blocked) return;
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
return;
|
||||
}
|
||||
setRules((current) => current.map((rule) => (
|
||||
updateRules((current) => current.map((rule) => (
|
||||
rule._key === ruleKey ? { ...rule, removing: true } : rule
|
||||
)));
|
||||
}
|
||||
|
||||
function finishRemove(ruleKey: string) {
|
||||
const update = () => flushSync(() => {
|
||||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
@@ -239,25 +340,302 @@ export function useRoutingFeature({
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = localRuleValues(rules);
|
||||
if (!editable || blocked) return;
|
||||
if (dragRef.current) dropReorder(false);
|
||||
const values = localRuleValues(rulesRef.current);
|
||||
const result = routingSaveState(await onSave(values, revision));
|
||||
if (!result) return;
|
||||
baselineRef.current = JSON.stringify(values);
|
||||
baselineRef.current = localRulesSignature(values);
|
||||
setRevision(result.localRulesRevision);
|
||||
setConfirmingClose(false);
|
||||
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
|
||||
}
|
||||
|
||||
function reducedMotion() {
|
||||
return matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
function ruleRows() {
|
||||
return new Map(Array.from(
|
||||
panelRef.current?.querySelectorAll<HTMLElement>('[data-rule-key]') || [],
|
||||
(row) => [row.dataset.ruleKey || '', row],
|
||||
));
|
||||
}
|
||||
|
||||
function replaceRuleOrder(next: DraftRule[], draggedRuleKey: string) {
|
||||
const rows = ruleRows();
|
||||
const previousTops = new Map(Array.from(rows, ([key, row]) => [key, row.getBoundingClientRect().top]));
|
||||
rulesRef.current = next;
|
||||
flushSync(() => setRules(next));
|
||||
if (reducedMotion()) return;
|
||||
for (const [key, row] of ruleRows()) {
|
||||
if (key === draggedRuleKey) continue;
|
||||
const previousTop = previousTops.get(key);
|
||||
if (previousTop === undefined) continue;
|
||||
const delta = previousTop - row.getBoundingClientRect().top;
|
||||
if (!delta) continue;
|
||||
row.animate(
|
||||
[{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
|
||||
{ duration: RULE_REORDER_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function moveRuleTo(ruleKey: string, target: number) {
|
||||
const current = rulesRef.current;
|
||||
const from = current.findIndex((rule) => rule._key === ruleKey);
|
||||
if (from < 0 || target === from) return from;
|
||||
replaceRuleOrder(moveRule(current, from, target), ruleKey);
|
||||
return target;
|
||||
}
|
||||
|
||||
function positionPointerRule(pointerY: number) {
|
||||
const session = dragRef.current;
|
||||
if (!session?.lifted) return;
|
||||
const list = panelRef.current?.querySelector<HTMLElement>('.client-local-rules-list');
|
||||
if (!list) return;
|
||||
const rows = ruleRows();
|
||||
let row = rows.get(session.key);
|
||||
if (!row) return;
|
||||
const desiredCenter = pointerY - session.pointerOffsetY;
|
||||
const listTop = list.getBoundingClientRect().top;
|
||||
const slotCenter = (element: HTMLElement) => listTop + element.offsetTop + element.offsetHeight / 2;
|
||||
session.translateY = desiredCenter - slotCenter(row);
|
||||
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
|
||||
|
||||
const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key);
|
||||
const centers = rulesRef.current.map((rule) => {
|
||||
const ruleRow = rows.get(rule._key);
|
||||
return ruleRow ? slotCenter(ruleRow) : desiredCenter;
|
||||
});
|
||||
const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
|
||||
if (target === currentIndex) return;
|
||||
moveRuleTo(session.key, target);
|
||||
row = ruleRows().get(session.key);
|
||||
if (!row) return;
|
||||
session.translateY = desiredCenter - slotCenter(row);
|
||||
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
|
||||
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
|
||||
}
|
||||
|
||||
function stopAutoScroll(session: RuleDragSession) {
|
||||
if (session.raf !== null) cancelAnimationFrame(session.raf);
|
||||
session.raf = null;
|
||||
}
|
||||
|
||||
function continueAutoScroll(session: RuleDragSession) {
|
||||
if (session.raf !== null) return;
|
||||
const tick = () => {
|
||||
session.raf = null;
|
||||
if (dragRef.current !== session || !session.lifted) return;
|
||||
const container = panelRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const delta = edgeScrollDelta(session.latestY, rect.top, rect.bottom);
|
||||
if (!delta) return;
|
||||
const previousTop = container.scrollTop;
|
||||
container.scrollTop += delta;
|
||||
if (container.scrollTop === previousTop) return;
|
||||
positionPointerRule(session.latestY);
|
||||
session.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
session.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function startPointerReorder(event: ReactPointerEvent<HTMLButtonElement>, ruleKey: string) {
|
||||
if (event.button !== 0 || !event.isPrimary || !editable || blocked || rulesRef.current.length < 2) return;
|
||||
if (dragRef.current?.input === 'keyboard') finishReorder('focus-leave');
|
||||
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer');
|
||||
if (!lifecycle) return;
|
||||
const row = event.currentTarget.closest<HTMLElement>('[data-rule-key]');
|
||||
const capture = event.currentTarget.closest<HTMLElement>('.client-local-rules-list');
|
||||
if (!row || !capture) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
capture.setPointerCapture(event.pointerId);
|
||||
dragRef.current = {
|
||||
...lifecycle,
|
||||
originRules: [...rulesRef.current],
|
||||
pointerId: event.pointerId,
|
||||
startY: event.clientY,
|
||||
latestY: event.clientY,
|
||||
pointerOffsetY: event.clientY - (rect.top + rect.height / 2),
|
||||
translateY: 0,
|
||||
raf: null,
|
||||
capture,
|
||||
handle: event.currentTarget,
|
||||
};
|
||||
}
|
||||
|
||||
function movePointerReorder(event: ReactPointerEvent<HTMLElement>) {
|
||||
const session = dragRef.current;
|
||||
if (!session || session.pointerId !== event.pointerId) return;
|
||||
session.latestY = event.clientY;
|
||||
if (!session.lifted) {
|
||||
if (!shouldLiftRule(session.startY, event.clientY)) return;
|
||||
event.preventDefault();
|
||||
session.lifted = true;
|
||||
flushSync(() => setLiftedKey(session.key));
|
||||
setReorderAnnouncement('Правило поднято');
|
||||
}
|
||||
event.preventDefault();
|
||||
positionPointerRule(event.clientY);
|
||||
const container = panelRef.current;
|
||||
const bounds = container?.getBoundingClientRect();
|
||||
if (!bounds || !edgeScrollDelta(session.latestY, bounds.top, bounds.bottom)) {
|
||||
stopAutoScroll(session);
|
||||
} else {
|
||||
continueAutoScroll(session);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraggedStyle(session: RuleDragSession, settle: boolean) {
|
||||
const row = ruleRows().get(session.key);
|
||||
if (!row) return;
|
||||
const translateY = session.translateY;
|
||||
row.style.removeProperty('--client-rule-drag-y');
|
||||
if (settle && translateY && !reducedMotion()) {
|
||||
row.animate(
|
||||
[{ transform: `translateY(${translateY}px)` }, { transform: 'translateY(0)' }],
|
||||
{ duration: RULE_DROP_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function finishReorder(reason: RuleReorderEndReason, announce = true) {
|
||||
const session = dragRef.current;
|
||||
if (!session) return rulesRef.current;
|
||||
const outcome = endRuleReorder(session, reason, reducedMotion());
|
||||
if (outcome.stopAutoScroll) stopAutoScroll(session);
|
||||
dragRef.current = null;
|
||||
let nextRules = rulesRef.current;
|
||||
if (outcome.restoreOrder) {
|
||||
nextRules = restoreRuleOrder(session.originRules, rulesRef.current, (rule) => rule._key);
|
||||
replaceRuleOrder(nextRules, session.key);
|
||||
}
|
||||
clearDraggedStyle(session, outcome.animateDrop);
|
||||
setLiftedKey(null);
|
||||
if (
|
||||
outcome.releasePointerCapture
|
||||
&& session.pointerId !== null
|
||||
&& session.capture.hasPointerCapture(session.pointerId)
|
||||
) {
|
||||
session.capture.releasePointerCapture(session.pointerId);
|
||||
}
|
||||
if (session.lifted) {
|
||||
if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true;
|
||||
if (announce && reason !== 'unmount') {
|
||||
setReorderAnnouncement(reason === 'drop' ? 'Правило размещено' : 'Перемещение отменено');
|
||||
}
|
||||
}
|
||||
if (outcome.restoreFocus) session.handle.focus();
|
||||
return nextRules;
|
||||
}
|
||||
|
||||
function dropReorder(announce = true) {
|
||||
return finishReorder('drop', announce);
|
||||
}
|
||||
|
||||
function cancelReorder(announce = true) {
|
||||
return finishReorder('cancel', announce);
|
||||
}
|
||||
|
||||
function finishPointerReorder(event: ReactPointerEvent<HTMLElement>) {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||||
dropReorder();
|
||||
}
|
||||
|
||||
function cancelPointerReorder(event: ReactPointerEvent<HTMLElement>) {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||||
cancelReorder();
|
||||
}
|
||||
|
||||
function losePointerReorder(event: ReactPointerEvent<HTMLElement>) {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||||
finishReorder('lost-capture');
|
||||
}
|
||||
|
||||
function toggleKeyboardReorder(handle: HTMLButtonElement, ruleKey: string) {
|
||||
const active = dragRef.current;
|
||||
if (active?.key === ruleKey && active.input === 'keyboard') {
|
||||
dropReorder();
|
||||
return;
|
||||
}
|
||||
if (active?.input === 'keyboard') finishReorder('focus-leave');
|
||||
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'keyboard');
|
||||
if (!lifecycle || !editable || blocked || rulesRef.current.length < 2) return;
|
||||
dragRef.current = {
|
||||
...lifecycle,
|
||||
originRules: [...rulesRef.current],
|
||||
pointerId: null,
|
||||
startY: 0,
|
||||
latestY: 0,
|
||||
pointerOffsetY: 0,
|
||||
translateY: 0,
|
||||
raf: null,
|
||||
capture: handle,
|
||||
handle,
|
||||
};
|
||||
setLiftedKey(ruleKey);
|
||||
setReorderAnnouncement('Правило поднято');
|
||||
}
|
||||
|
||||
function handleReorderKey(event: ReactKeyboardEvent<HTMLButtonElement>, ruleKey: string) {
|
||||
const session = dragRef.current;
|
||||
const ownsSession = session?.key === ruleKey && session.input === 'keyboard';
|
||||
if (!ownsSession) return;
|
||||
if (event.key === 'Tab') {
|
||||
finishReorder('focus-leave');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancelReorder();
|
||||
return;
|
||||
}
|
||||
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const current = rulesRef.current.findIndex((rule) => rule._key === ruleKey);
|
||||
const target = keyboardRuleIndex(current, event.key === 'ArrowUp' ? -1 : 1, rulesRef.current.length);
|
||||
if (target === current) return;
|
||||
moveRuleTo(ruleKey, target);
|
||||
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
|
||||
function handleReorderClick(event: ReactMouseEvent<HTMLButtonElement>, ruleKey: string) {
|
||||
if (suppressHandleClickRef.current) {
|
||||
suppressHandleClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.detail === 0) toggleKeyboardReorder(event.currentTarget, ruleKey);
|
||||
}
|
||||
|
||||
function handleReorderBlur(event: ReactFocusEvent<HTMLButtonElement>, ruleKey: string) {
|
||||
const session = dragRef.current;
|
||||
if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) {
|
||||
finishReorder('focus-leave');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
rules,
|
||||
savedRules,
|
||||
activeRules,
|
||||
connected,
|
||||
editable,
|
||||
bypassed,
|
||||
dirty,
|
||||
pendingRestart,
|
||||
savedUntilStart,
|
||||
pendingCount,
|
||||
pendingMessage,
|
||||
blocked,
|
||||
liftedKey,
|
||||
reorderAnnouncement,
|
||||
confirmingClose,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
@@ -271,6 +649,14 @@ export function useRoutingFeature({
|
||||
add,
|
||||
remove,
|
||||
finishRemove,
|
||||
startPointerReorder,
|
||||
movePointerReorder,
|
||||
finishPointerReorder,
|
||||
cancelPointerReorder,
|
||||
losePointerReorder,
|
||||
handleReorderKey,
|
||||
handleReorderClick,
|
||||
handleReorderBlur,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -378,46 +764,82 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypeP
|
||||
);
|
||||
}
|
||||
|
||||
function RuleOutboundPicker({
|
||||
value,
|
||||
index,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: RouteRule['outbound'];
|
||||
index: number;
|
||||
disabled?: boolean;
|
||||
onChange: (value: RouteRule['outbound']) => void;
|
||||
}) {
|
||||
const direct = value === 'direct';
|
||||
const label = direct ? 'Напрямую' : 'VPN';
|
||||
return <span className="client-rule-outbound client-tooltip-anchor">
|
||||
<button
|
||||
className={direct ? 'is-direct' : 'is-vpn'}
|
||||
type="button"
|
||||
aria-label={`Маршрут правила ${index + 1}: ${label}. Переключить на ${direct ? 'VPN' : 'Напрямую'}`}
|
||||
aria-pressed={direct}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(direct ? 'vpn' : 'direct')}
|
||||
>
|
||||
{direct ? <svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 12h15M14 7l5 5-5 5" />
|
||||
</svg> : <svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 19 6v5c0 4.4-2.8 8-7 10-4.2-2-7-5.6-7-10V6l7-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>}
|
||||
</button>
|
||||
<Tooltip>{label}</Tooltip>
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function RoutingToggle({
|
||||
feature,
|
||||
open,
|
||||
gatewayDirect,
|
||||
isGateway,
|
||||
hasSubscription,
|
||||
onOpen,
|
||||
}: {
|
||||
feature: RoutingFeature;
|
||||
open: boolean;
|
||||
gatewayDirect: boolean;
|
||||
isGateway: boolean;
|
||||
hasSubscription: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const disabled = gatewayDirect || (isGateway && !hasSubscription);
|
||||
const disabled = isGateway && !hasSubscription;
|
||||
return (
|
||||
<button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
<RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
|
||||
open={open}
|
||||
controls="client-local-rules"
|
||||
disabled={disabled}
|
||||
aria-expanded={feature.isOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
|
||||
ariaLabel={disabled
|
||||
? 'Правила маршрутизации недоступны: сначала добавьте подписку'
|
||||
: gatewayDirect
|
||||
? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
|
||||
: open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
|
||||
label={disabled
|
||||
? 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
|
||||
onClick={() => open ? feature.requestClose() : onOpen()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||
<path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
|
||||
<g className="client-rail-rule-knob-position is-top">
|
||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||
</g>
|
||||
<g className="client-rail-rule-knob-position is-bottom">
|
||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
<span>{disabled
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>
|
||||
</RailAction>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,10 +852,10 @@ export function RoutingPendingStatus({
|
||||
blocked: boolean;
|
||||
onRestart: () => unknown;
|
||||
}) {
|
||||
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{feature.pendingCount > 0 && (
|
||||
return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{feature.pendingMessage && (
|
||||
<>
|
||||
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
|
||||
<span>{feature.pendingMessage}</span>
|
||||
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
|
||||
</>
|
||||
)}
|
||||
@@ -441,82 +863,164 @@ export function RoutingPendingStatus({
|
||||
}
|
||||
|
||||
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const draftRules = feature.rules.filter((rule) => !rule.removing);
|
||||
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
|
||||
const editorDisabled = feature.blocked || !feature.editable;
|
||||
const canAdd = canAppendRouteRule(draftRules) && !editorDisabled;
|
||||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={feature.panelRef as RefObject<HTMLElement>}
|
||||
<Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-local-rules"
|
||||
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="local-rules-title"
|
||||
aria-hidden={!feature.isOpen}
|
||||
inert={!feature.isOpen ? true : undefined}
|
||||
className="client-local-rules"
|
||||
sheetClassName="client-local-rules-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="local-rules-title"
|
||||
closeLabel="Закрыть правила маршрутизации"
|
||||
onClose={feature.requestClose}
|
||||
>
|
||||
<div className="client-drawer-sheet client-local-rules-sheet">
|
||||
<button
|
||||
ref={feature.closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть локальные правила"
|
||||
onClick={feature.requestClose}
|
||||
>×</button>
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<button
|
||||
className="client-local-rules-save"
|
||||
type="submit"
|
||||
form="client-local-rules-form"
|
||||
disabled={feature.blocked || !feature.dirty}
|
||||
disabled={editorDisabled || !feature.dirty}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<h2 id="local-rules-title">Локальные правила</h2>
|
||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||
{feature.pendingRestart && (
|
||||
<h2 id="local-rules-title">Правила маршрутизации</h2>
|
||||
{!feature.editable && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Обновите Harbor, чтобы редактировать правила.
|
||||
</p>
|
||||
)}
|
||||
{feature.bypassed && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Локальный список сейчас обходится Harbor Gateway.
|
||||
</p>
|
||||
)}
|
||||
{!feature.bypassed && feature.pendingRestart && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||||
</p>
|
||||
)}
|
||||
{feature.savedUntilStart && (
|
||||
<p className="client-local-rules-runtime" role="status">
|
||||
Правила сохранены и начнут работать после запуска Harbor.
|
||||
</p>
|
||||
)}
|
||||
<div className={`client-local-rules-help${helpOpen ? ' is-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={helpOpen}
|
||||
aria-controls="client-local-rules-help-content"
|
||||
onClick={() => setHelpOpen((current) => !current)}
|
||||
>
|
||||
Как работают правила
|
||||
</button>
|
||||
<div className="client-local-rules-help-reveal" aria-hidden={!helpOpen}>
|
||||
<div id="client-local-rules-help-content" className="client-local-rules-help-content">
|
||||
<div className="client-local-rules-help-copy">
|
||||
<p>Правила проверяются сверху вниз. Первое совпадение выбирает маршрут: щит означает VPN, стрелка — прямое подключение.</p>
|
||||
<p>Правила применяются только к трафику внутри маршрутизации Harbor. Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список.</p>
|
||||
<p>Можно вставить полный URL: Harbor сразу оставит только домен. Путь и параметры HTTPS для маршрутизации недоступны.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
|
||||
<span id="client-rule-reorder-instructions" className="client-rule-reorder-instructions">
|
||||
Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет.
|
||||
</span>
|
||||
<span className="client-rule-reorder-live" role="status" aria-live="polite" aria-atomic="true">
|
||||
{feature.reorderAnnouncement}
|
||||
</span>
|
||||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||||
<span id="local-rules-list-title">Правила</span>
|
||||
<div className="client-local-rules-list">
|
||||
<div
|
||||
className={`client-local-rules-list${draftRules.length > 1 ? ' has-order-flow' : ''}`}
|
||||
role="list"
|
||||
aria-label="Правила применяются сверху вниз"
|
||||
onPointerMove={feature.movePointerReorder}
|
||||
onPointerUp={feature.finishPointerReorder}
|
||||
onPointerCancel={feature.cancelPointerReorder}
|
||||
onLostPointerCapture={feature.losePointerReorder}
|
||||
>
|
||||
{feature.rules.map((rule, index) => {
|
||||
const [status, statusLabel] = localRuleStatus(
|
||||
rule,
|
||||
index,
|
||||
feature.savedRules,
|
||||
feature.activeRules,
|
||||
feature.connected,
|
||||
feature.bypassed,
|
||||
);
|
||||
const lifted = feature.liftedKey === rule._key;
|
||||
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
|
||||
return (
|
||||
<div
|
||||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
|
||||
key={rule._key}
|
||||
data-rule-key={rule._key}
|
||||
data-route={rule.outbound}
|
||||
role="listitem"
|
||||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||||
inert={rule.removing ? true : undefined}
|
||||
>
|
||||
<button
|
||||
className="client-local-rule-enabled"
|
||||
className="client-rule-handle"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={rule.enabled}
|
||||
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
|
||||
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
|
||||
aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
|
||||
aria-describedby="client-rule-reorder-instructions"
|
||||
aria-pressed={lifted}
|
||||
disabled={handleDisabled}
|
||||
onPointerDown={(event) => feature.startPointerReorder(event, rule._key)}
|
||||
onKeyDown={(event) => feature.handleReorderKey(event, rule._key)}
|
||||
onClick={(event) => feature.handleReorderClick(event, rule._key)}
|
||||
onBlur={(event) => feature.handleReorderBlur(event, rule._key)}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
|
||||
<svg viewBox="0 0 16 28" aria-hidden="true">
|
||||
<circle cx="5" cy="5" r="1.25" />
|
||||
<circle cx="11" cy="5" r="1.25" />
|
||||
<circle cx="5" cy="11" r="1.25" />
|
||||
<circle cx="11" cy="11" r="1.25" />
|
||||
<circle cx="5" cy="17" r="1.25" />
|
||||
<circle cx="11" cy="17" r="1.25" />
|
||||
<circle cx="5" cy="23" r="1.25" />
|
||||
<circle cx="11" cy="23" r="1.25" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="client-local-rule-enabled-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-local-rule-enabled"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={rule.enabled}
|
||||
aria-label={`${rule.enabled ? 'Отключить' : 'Включить'} правило ${index + 1}`}
|
||||
disabled={editorDisabled}
|
||||
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>
|
||||
{rule.enabled
|
||||
? 'Отключить: правило перестанет участвовать в маршрутизации'
|
||||
: 'Включить: правило снова будет участвовать в маршрутизации'}
|
||||
</Tooltip>
|
||||
</span>
|
||||
<RuleTypePicker
|
||||
value={rule.type}
|
||||
ruleKey={rule._key}
|
||||
index={index}
|
||||
disabled={rule.removing}
|
||||
disabled={editorDisabled || rule.removing}
|
||||
onChange={(type) => feature.change(index, 'type', type)}
|
||||
/>
|
||||
<input
|
||||
@@ -525,19 +1029,42 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
disabled={editorDisabled}
|
||||
aria-label={`Значение правила ${index + 1}`}
|
||||
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
|
||||
value={rule.value}
|
||||
onChange={(event) => feature.change(index, 'value', event.target.value)}
|
||||
onPaste={(event) => {
|
||||
event.preventDefault();
|
||||
feature.change(index, 'value', normalizeDraftRuleValue(rule, event.clipboardData.getData('text')));
|
||||
}}
|
||||
onBlur={() => feature.change(index, 'value', normalizeDraftRuleValue(rule, rule.value))}
|
||||
/>
|
||||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||||
<RuleOutboundPicker
|
||||
value={rule.outbound}
|
||||
index={index}
|
||||
disabled={editorDisabled}
|
||||
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
|
||||
/>
|
||||
<span
|
||||
className="client-local-rule-status client-tooltip-anchor"
|
||||
role="status"
|
||||
aria-label={`Состояние правила: ${statusLabel}`}
|
||||
>
|
||||
<span className="client-rule-status-dot" aria-hidden="true" />
|
||||
<Tooltip>{statusLabel}</Tooltip>
|
||||
</span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
className="client-row-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить правило ${index + 1}`}
|
||||
disabled={editorDisabled}
|
||||
onClick={() => feature.remove(rule._key)}
|
||||
>
|
||||
×
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-row-delete-lid" d="M8 7V5h8v2m-11 0h14" />
|
||||
<path d="M7 7l1 13h8l1-13M10 10v7m4-7v7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
@@ -547,26 +1074,22 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Harbor использует выбранный режим соединения.</p>}
|
||||
</div>
|
||||
<div className="client-local-rule-add-slot">
|
||||
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||||
<div className="client-row-add-slot">
|
||||
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||||
+ Добавить правило
|
||||
</button>
|
||||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="client-local-rules-note">
|
||||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||||
</p>
|
||||
{statusSlot}
|
||||
<div className="client-local-rules-actions">
|
||||
<button type="button" onClick={feature.requestClose}>Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export const RULE_DRAG_THRESHOLD_PX = 4;
|
||||
export const RULE_EDGE_ZONE_PX = 32;
|
||||
export const RULE_REORDER_DURATION_MS = 220;
|
||||
export const RULE_DROP_DURATION_MS = 260;
|
||||
|
||||
export type RuleReorderInput = 'pointer' | 'keyboard';
|
||||
export type RuleReorderEndReason = 'drop' | 'cancel' | 'focus-leave' | 'lost-capture' | 'unmount';
|
||||
|
||||
export interface RuleReorderLifecycle {
|
||||
key: string;
|
||||
input: RuleReorderInput;
|
||||
lifted: boolean;
|
||||
}
|
||||
|
||||
export function beginRuleReorder(
|
||||
current: RuleReorderLifecycle | null,
|
||||
key: string,
|
||||
input: RuleReorderInput,
|
||||
): RuleReorderLifecycle | null {
|
||||
if (current) return null;
|
||||
return { key, input, lifted: input === 'keyboard' };
|
||||
}
|
||||
|
||||
export function endRuleReorder(
|
||||
session: RuleReorderLifecycle,
|
||||
reason: RuleReorderEndReason,
|
||||
reducedMotion = false,
|
||||
) {
|
||||
return {
|
||||
restoreOrder: reason !== 'drop' && session.lifted,
|
||||
stopAutoScroll: true as const,
|
||||
restoreFocus: !['focus-leave', 'unmount'].includes(reason),
|
||||
releasePointerCapture: session.input === 'pointer' && !['lost-capture', 'unmount'].includes(reason),
|
||||
animateDrop: reason === 'drop' && session.lifted && !reducedMotion,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldLiftRule(startY: number, currentY: number) {
|
||||
return Math.abs(currentY - startY) >= RULE_DRAG_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
export function moveRule<T>(rules: readonly T[], from: number, to: number): T[] {
|
||||
if (from === to || from < 0 || to < 0 || from >= rules.length || to >= rules.length) {
|
||||
return [...rules];
|
||||
}
|
||||
const next = [...rules];
|
||||
const [rule] = next.splice(from, 1);
|
||||
next.splice(to, 0, rule);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function restoreRuleOrder<T>(
|
||||
origin: readonly T[],
|
||||
current: readonly T[],
|
||||
keyOf: (item: T) => string,
|
||||
): T[] {
|
||||
const currentByKey = new Map(current.map((item) => [keyOf(item), item]));
|
||||
const originKeys = new Set(origin.map(keyOf));
|
||||
return [
|
||||
...origin.map((item) => currentByKey.get(keyOf(item))).filter((item): item is T => item !== undefined),
|
||||
...current.filter((item) => !originKeys.has(keyOf(item))),
|
||||
];
|
||||
}
|
||||
|
||||
export function crossedRuleIndex(current: number, draggedCenterY: number, centers: readonly number[]) {
|
||||
let target = current;
|
||||
while (target > 0 && draggedCenterY < centers[target - 1]) target -= 1;
|
||||
while (target < centers.length - 1 && draggedCenterY > centers[target + 1]) target += 1;
|
||||
return target;
|
||||
}
|
||||
|
||||
export function keyboardRuleIndex(current: number, direction: -1 | 1, length: number) {
|
||||
return Math.max(0, Math.min(length - 1, current + direction));
|
||||
}
|
||||
|
||||
export function edgeScrollDelta(
|
||||
pointerY: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
zone = RULE_EDGE_ZONE_PX,
|
||||
) {
|
||||
const edgeDelta = (distance: number) => Math.min(12, Math.max(2, 12 - (distance / zone) * 10));
|
||||
if (pointerY >= top && pointerY < top + zone) return -edgeDelta(pointerY - top);
|
||||
if (pointerY <= bottom && pointerY > bottom - zone) return edgeDelta(bottom - pointerY);
|
||||
return 0;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type PickerServer = HarborServer & {
|
||||
const FAVORITES_KEY = 'harbor-server-favorites';
|
||||
const RECENT_KEY = 'harbor-server-recent';
|
||||
const AUTO_KEY = 'harbor-server-auto';
|
||||
const SIMPLE_SERVER_LIMIT = 5;
|
||||
const INLINE_SERVER_ROWS = 8;
|
||||
|
||||
interface PingResult {
|
||||
id?: string;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function ServerCheckButton({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return <button
|
||||
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
|
||||
className={`client-server-check${checking ? ' is-checking' : ''}`}
|
||||
type="button"
|
||||
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
|
||||
disabled={checking || disabled}
|
||||
@@ -102,7 +102,7 @@ function ServerCheckButton({
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||
</svg>
|
||||
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
||||
<span className="client-server-check-label">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
@@ -127,19 +127,25 @@ function ServerRow({
|
||||
}) {
|
||||
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping);
|
||||
|
||||
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
|
||||
return <div
|
||||
className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}
|
||||
style={{
|
||||
'--server-index': Math.min(index, 7),
|
||||
'--server-exit-delay': `${90 + Math.max(0, 4 - Math.min(index, 4)) * 45}ms`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<button
|
||||
className={`client-server${selected ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
|
||||
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
|
||||
onClick={() => onSelect(server.id)}
|
||||
>
|
||||
<strong>{server.label}</strong>
|
||||
{(server.city || server.country) && <small>{[server.city, server.country].filter(Boolean).join(' · ')}</small>}
|
||||
</button>
|
||||
{(ping || onFavorite) && <div className="client-server-meta">
|
||||
<div className="client-server-meta">
|
||||
<ServerHealth ping={ping} />
|
||||
{onFavorite && <button
|
||||
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
|
||||
@@ -148,38 +154,43 @@ function ServerRow({
|
||||
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
|
||||
onClick={() => onFavorite(server.id)}
|
||||
>★</button>}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ServerPickerProps {
|
||||
pingServers: (ids: string[]) => Promise<unknown>;
|
||||
profileId: string;
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
servers: PickerServer[];
|
||||
selectedServerId: string;
|
||||
disabled: boolean;
|
||||
prompt: boolean;
|
||||
leaving: boolean;
|
||||
revealVersion: number;
|
||||
anchorServerId?: string;
|
||||
onSelect: (id: string) => unknown;
|
||||
}
|
||||
|
||||
export function ServerPicker({
|
||||
profileId,
|
||||
pingServers,
|
||||
servers,
|
||||
selectedServerId,
|
||||
disabled,
|
||||
prompt,
|
||||
leaving,
|
||||
revealVersion,
|
||||
anchorServerId = '',
|
||||
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<string[]>([]);
|
||||
const [pings, setPings] = useState<PingState>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
@@ -196,7 +207,7 @@ export function ServerPicker({
|
||||
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
|
||||
return found;
|
||||
}, [servers, query, view, favorites, recent]);
|
||||
const results = filtered.filter(({ id }) => id !== selectedServerId);
|
||||
const results = filtered.filter(({ id }) => id !== selectedServerId && id !== anchorServerId);
|
||||
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
|
||||
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
|
||||
const grouped = servers.length >= 10;
|
||||
@@ -208,18 +219,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 +247,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 +262,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 }])),
|
||||
@@ -262,13 +276,12 @@ export function ServerPicker({
|
||||
|
||||
if (servers.length === 1) {
|
||||
return <section className="client-servers" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
<div className="client-server-toolbar is-single">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} onClick={checkVisible} />
|
||||
</div>
|
||||
<div className="client-server-grid">
|
||||
<ServerRow
|
||||
{servers[0].id !== anchorServerId && <ServerRow
|
||||
server={servers[0]}
|
||||
selected={servers[0].id === selectedServerId}
|
||||
favorite={false}
|
||||
@@ -276,7 +289,7 @@ export function ServerPicker({
|
||||
disabled={disabled}
|
||||
index={0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -296,12 +309,11 @@ export function ServerPicker({
|
||||
));
|
||||
|
||||
const simpleServers = [
|
||||
...(selected ? [selected] : []),
|
||||
...servers.filter(({ id }) => id !== selectedServerId),
|
||||
].slice(0, SIMPLE_SERVER_LIMIT);
|
||||
...(selected && selected.id !== anchorServerId ? [selected] : []),
|
||||
...servers.filter(({ id }) => id !== selectedServerId && id !== anchorServerId),
|
||||
];
|
||||
|
||||
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
return <section className={`client-servers is-scalable${simpleServers.length <= INLINE_SERVER_ROWS ? ' is-short' : ''}`} aria-label="Выберите сервер">
|
||||
<div className="client-server-toolbar">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} />
|
||||
@@ -324,7 +336,7 @@ export function ServerPicker({
|
||||
inert={advanced ? true : undefined}
|
||||
>
|
||||
<div className="client-server-mode-panel-inner">
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${revealVersion}`}>
|
||||
<div className="client-server-grid">
|
||||
{simpleServers.map((server, index) => <ServerRow
|
||||
key={server.id}
|
||||
@@ -336,9 +348,6 @@ export function ServerPicker({
|
||||
onSelect={select}
|
||||
/>)}
|
||||
</div>
|
||||
{servers.length > simpleServers.length && <p className="client-server-overflow-note">
|
||||
Ещё {servers.length - simpleServers.length} — доступны через поиск
|
||||
</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -389,7 +398,7 @@ export function ServerPicker({
|
||||
fallback={autoActive ? undefined : 'Первый стабильный сервер'}
|
||||
/>
|
||||
</button>
|
||||
{selected && <ServerRow
|
||||
{selected && selected.id !== anchorServerId && <ServerRow
|
||||
server={selected}
|
||||
selected
|
||||
favorite={favorites.includes(selected.id)}
|
||||
@@ -401,7 +410,7 @@ export function ServerPicker({
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${revealVersion}`}>
|
||||
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
|
||||
{grouped ? groupServers(visible).map(([group, items]) => {
|
||||
const isCollapsed = collapsed.includes(group);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,503 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficConnection,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { formatByteString } from '../../utils/format.js';
|
||||
import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
trafficGroupMatches,
|
||||
type DisplayedTrafficGroup,
|
||||
type TrafficConnectionGroup,
|
||||
type TrafficQualityFilter,
|
||||
type TrafficRouteFilter,
|
||||
} from './trafficRows.js';
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
|
||||
const RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||
|
||||
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
||||
|
||||
interface TrafficFeatureOptions {
|
||||
enabled: boolean;
|
||||
isGateway: boolean;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||
vpn: 'VPN',
|
||||
direct: 'Direct',
|
||||
other: 'Другое',
|
||||
};
|
||||
|
||||
function storedRetentionSeconds(): RetentionSeconds {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
|
||||
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function address(ip: string | null, port: number | null) {
|
||||
if (!ip) return '—';
|
||||
return port === null ? ip : `${ip}:${port}`;
|
||||
}
|
||||
|
||||
function updatedAt(value: string | null | undefined) {
|
||||
if (!value) return 'обновлений ещё нет';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? 'время неизвестно'
|
||||
: `обновлено ${new Intl.DateTimeFormat('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOpen || paused) return undefined;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
|
||||
if (!cancelled) {
|
||||
setSnapshot(next);
|
||||
setRequestState('ready');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setRequestState('error');
|
||||
} finally {
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, isOpen, paused, loadLiveTraffic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeTraffic = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeTraffic);
|
||||
document.addEventListener('keydown', closeTraffic);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeTraffic);
|
||||
document.removeEventListener('keydown', closeTraffic);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
function close() {
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) close();
|
||||
else if (enabled) setIsOpen(true);
|
||||
}
|
||||
|
||||
return {
|
||||
isGateway,
|
||||
isOpen,
|
||||
paused,
|
||||
snapshot,
|
||||
requestState,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
close,
|
||||
toggle,
|
||||
togglePause: () => setPaused((current) => !current),
|
||||
};
|
||||
}
|
||||
|
||||
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
|
||||
|
||||
export function TrafficToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: TrafficFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-traffic-toggle"
|
||||
open={open}
|
||||
controls="client-traffic"
|
||||
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
|
||||
label="Трафик"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.5 19.5h17" />
|
||||
<path d="m5 16 4-4 3 2 6-7" />
|
||||
<circle cx="5" cy="16" r=".7" />
|
||||
<circle cx="9" cy="12" r=".7" />
|
||||
<circle cx="12" cy="14" r=".7" />
|
||||
<circle cx="18" cy="7" r=".7" />
|
||||
</svg>
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
function groupStatus(group: TrafficConnectionGroup) {
|
||||
if (group.connections.length === 1) {
|
||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
||||
}
|
||||
const states = [];
|
||||
if (group.activeCount > 0) states.push(`Активно: ${group.activeCount}`);
|
||||
if (group.recentCount > 0) {
|
||||
states.push(`${group.activeCount > 0 ? 'завершено' : 'Завершено'}: ${group.recentCount}`);
|
||||
}
|
||||
states.push(group.protocol);
|
||||
return states.join(' · ');
|
||||
}
|
||||
|
||||
function groupDestination(group: TrafficConnectionGroup) {
|
||||
const { domain, ip, port } = group.destination;
|
||||
if (!domain) return address(ip, port);
|
||||
if (group.destinationIps.length === 1) return `${domain} · ${address(group.destinationIps[0], port)}`;
|
||||
if (group.destinationIps.length > 1) {
|
||||
return `${domain} · IP: ${group.destinationIps.length}${port === null ? '' : ` · порт ${port}`}`;
|
||||
}
|
||||
return port === null ? domain : `${domain} · порт ${port}`;
|
||||
}
|
||||
|
||||
function TrafficGroupRow({
|
||||
group,
|
||||
expanded,
|
||||
exiting,
|
||||
onExited,
|
||||
onToggle,
|
||||
}: {
|
||||
group: TrafficConnectionGroup;
|
||||
expanded: boolean;
|
||||
exiting: boolean;
|
||||
onExited: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const detailsId = useId();
|
||||
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||
const source = onlyConnection
|
||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||
const chain = group.route.chain.length
|
||||
? group.route.chain.join(' → ')
|
||||
: group.route.outbound || '—';
|
||||
|
||||
return <div
|
||||
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
|
||||
role="listitem"
|
||||
inert={exiting || undefined}
|
||||
aria-hidden={exiting || undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
|
||||
onExited();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="client-traffic-connection-summary"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="client-traffic-identity">
|
||||
<strong aria-label={group.connections.length > 1
|
||||
? `${group.label}, соединений: ${group.connections.length}`
|
||||
: undefined}
|
||||
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
|
||||
<small>{groupStatus(group)}</small>
|
||||
</span>
|
||||
<span className="client-traffic-route" data-route={group.route.kind}>
|
||||
{routeLabels[group.route.kind]}
|
||||
</span>
|
||||
<span className="client-traffic-values">
|
||||
{group.activeCount > 0 && <strong>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
|
||||
</strong>}
|
||||
<small>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytes)}</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytes)}</span>
|
||||
</small>
|
||||
</span>
|
||||
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="m5 6 3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||
</dl>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TrafficState({ feature }: { feature: TrafficFeature }) {
|
||||
const { snapshot, requestState } = feature;
|
||||
const sourceState = snapshot?.source.state;
|
||||
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
|
||||
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
|
||||
{[0, 1, 2, 3].map((item) => <span key={item} />)}
|
||||
</div>;
|
||||
}
|
||||
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
|
||||
if (sourceState === 'disabled') {
|
||||
return <p className="client-traffic-state" role="status">{feature.isGateway
|
||||
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
|
||||
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
|
||||
}
|
||||
if (sourceState === 'incompatible') {
|
||||
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
|
||||
}
|
||||
if (sourceState === 'stopped') {
|
||||
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
|
||||
}
|
||||
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
|
||||
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика…</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
|
||||
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
));
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const snapshot = feature.snapshot;
|
||||
const sourceState = snapshot?.source.state;
|
||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
||||
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
|
||||
const groups = useMemo(() => trafficGroups.filter((group) => (
|
||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
||||
)), [trafficGroups, query, routeFilter, qualityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReducedMotion(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const immediate = reducedMotion
|
||||
|| !snapshot
|
||||
|| ['disabled', 'incompatible', 'stopped'].includes(sourceState || '');
|
||||
if (immediate) {
|
||||
const desiredIds = new Set(groups.map((group) => group.id));
|
||||
setExpandedId((current) => desiredIds.has(current) ? current : '');
|
||||
}
|
||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
||||
|
||||
function selectRetention(seconds: RetentionSeconds) {
|
||||
setRetentionSeconds(seconds);
|
||||
try {
|
||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
||||
} catch {
|
||||
// The setting remains available for this session when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function finishExit(id: string) {
|
||||
setDisplayedGroups((current) => current.filter((row) => (
|
||||
row.group.id !== id || !row.exiting
|
||||
)));
|
||||
setExpandedId((current) => current === id ? '' : current);
|
||||
}
|
||||
|
||||
const canShowList = snapshot
|
||||
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
|
||||
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
|
||||
const stale = feature.requestState === 'error' || sourceState === 'stale';
|
||||
const degraded = sourceState === 'degraded';
|
||||
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-traffic"
|
||||
className="client-traffic"
|
||||
sheetClassName="client-traffic-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="client-traffic-title"
|
||||
closeLabel="Закрыть трафик"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<header className="client-traffic-header">
|
||||
<div className="client-traffic-meta">
|
||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
|
||||
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={feature.paused}
|
||||
onClick={feature.togglePause}
|
||||
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||
</div>
|
||||
<h2 id="client-traffic-title">Трафик</h2>
|
||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
||||
</header>
|
||||
|
||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
||||
</div>}
|
||||
|
||||
<div className="client-traffic-tools">
|
||||
<label className="client-traffic-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="10.5" cy="10.5" r="6" />
|
||||
<path d="m15 15 5 5" />
|
||||
</svg>
|
||||
<span className="client-live-region">Поиск трафика</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти домен, сервис или IP"
|
||||
placeholder="Домен, сервис или IP"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['vpn', 'VPN'],
|
||||
['direct', 'Direct'],
|
||||
['other', 'Другое'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={routeFilter === value}
|
||||
onClick={() => setRouteFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['recognized', 'Распознано'],
|
||||
['attention', 'Требует внимания'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={qualityFilter === value}
|
||||
onClick={() => setQualityFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Показывать завершённые</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||
{RETENTION_OPTIONS.map((seconds) => <button
|
||||
type="button"
|
||||
key={seconds}
|
||||
aria-pressed={retentionSeconds === seconds}
|
||||
onClick={() => selectRetention(seconds)}
|
||||
>{seconds} с</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||
</p>}
|
||||
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Данные временно не обновляются. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||
className="client-traffic-notice"
|
||||
role="status"
|
||||
>
|
||||
Инспектор переподключается. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}.
|
||||
</p>}
|
||||
|
||||
<TrafficState feature={feature} />
|
||||
|
||||
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
Активных соединений пока нет.
|
||||
</p>}
|
||||
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
По выбранным фильтрам ничего не найдено.
|
||||
</p>}
|
||||
{canShowList && displayedGroups.length > 0 && <div
|
||||
className="client-traffic-list"
|
||||
role="list"
|
||||
aria-label="Группы активных и недавно завершённых соединений"
|
||||
aria-busy={feature.requestState === 'loading'}
|
||||
>
|
||||
{displayedGroups.map((row) => <TrafficGroupRow
|
||||
key={row.group.id}
|
||||
group={row.group}
|
||||
expanded={expandedId === row.group.id}
|
||||
exiting={row.exiting}
|
||||
onExited={() => finishExit(row.group.id)}
|
||||
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
|
||||
/>)}
|
||||
</div>}
|
||||
|
||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
||||
</p>}
|
||||
<p className="client-traffic-honesty">
|
||||
{feature.isGateway
|
||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
|
||||
</p>
|
||||
</Drawer>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user