Author SHA1 Message Date
dokril bdf3f22b12 Merge branch 'codex/task-057-059'
Build and Deploy Gateway / build-and-push (push) Failing after 17s
Build and Deploy Gateway / deploy (push) Has been skipped
# Conflicts:
#	src/shared/versions.ts
#	test/web/style-boundaries.test.js
2026-08-31 05:20:57 +03:00
dokril 4d066cb879 Add native traffic inspection to Harbor Connect and Gateway 2026-08-31 05:19:15 +03:00
dokril f4882c53c2 Improve device tag editing and version clients
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-31 02:26:24 +03:00
dokril 116686a138 Implement Harbor gateway device ecosystem support
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-31 02:06:22 +03:00
dokril 7e15cc199f Default new devices to Direct routing
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-31 01:07:59 +03:00
dokril f977874da6 Refine failover channel status and switch controls
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-28 21:13:09 +03:00
dokril 14b3c2afac Exclude test artifacts from runtime impact analysis
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-28 19:47:58 +03:00
dokril a84cca0668 Improve failover controls and preserve manual switching state
Build and Deploy Gateway / build-and-push (push) Failing after 1s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-08-28 19:40:53 +03:00
dokril 8f2f418569 Add failover channel events to activity journal
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-27 14:36:08 +03:00
Dmitriy Petrov 4a566e082a Expand activity journal color cues
Build and Deploy Gateway / build-and-push (push) Successful in 37s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-25 16:42:05 +03:00
Dmitriy Petrov f451c65b2f Improve activity journal readability
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 15s
2026-08-25 15:52:42 +03:00
dokril 64462d3639 Clarify failover messaging and bump Harbor versions
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-21 13:00:54 +03:00
dokril 12f2f30212 Clarify failover UI messaging and controls
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-21 01:01:34 +03:00
dokril 1ee453b3b6 Add failover setting step controls
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-20 01:55:50 +03:00
dokril c27c898ad5 Simplify failover settings UI
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-20 01:47:27 +03:00
dokril 74227ae38c Improve failover status feedback and controls
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-20 01:32:03 +03:00
dokril 9055934e92 Fix failover channel layout and runtime impact coverage
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-19 20:54:28 +03:00
dokril 3b515ee355 Improve failover startup handling and status UI
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-19 20:24:04 +03:00
dokril cedd31cc16 Refine failover controls and bump Harbor versions
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-19 19:23:16 +03:00
dokril d4f228284e Add hard deploy option for Gateway workflow
Build and Deploy Gateway / build-and-push (push) Successful in 24s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-19 18:40:01 +03:00
dokril b843970ec2 Make journal write failure test deterministic
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-08-19 18:20:25 +03:00
dokril daec12e013 Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-08-19 18:16:10 +03:00
dokril 416b2b294a Persist configurable connectivity diagnostics
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-19 13:38:19 +03:00
dokril df865fbe3d Add per-row connectivity diagnostic refresh controls
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-19 12:59:21 +03:00
dokril 7cb25d0633 Retry state conflicts and always render server picker
Build and Deploy Gateway / build-and-push (push) Successful in 25s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-19 00:36:39 +03:00
dokril dc1fd76c44 Simplify rail motion and add routing help toggle
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-17 17:26:57 +03:00
dokril 0a0a932057 Refine secondary rail icon animations
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-17 17:07:25 +03:00
dokril 286a89051a Improve routing rule drag handling and controls
Build and Deploy Gateway / build-and-push (push) Successful in 34s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-17 16:48:46 +03:00
dokril f3be0b2fd0 Polish routing rules UI and normalize pasted values
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-17 16:27:25 +03:00
dokril bc86741397 Refine routing rule controls and responsive layout
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-17 16:04:06 +03:00
dokril 7c255192b0 Update Harbor client and gateway functionality
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-17 15:23:16 +03:00
dokril 0b39211fbd Refactor Harbor client connection flow
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-17 12:19:26 +03:00
dokril 8eccdd4050 Remove startup reveal animation
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-15 10:34:19 +03:00
dokril 804e08727e Refine client rail icons and motion
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-15 02:26:02 +03:00
dokril 978fe71628 Refine secondary rail icons and animations
Build and Deploy Gateway / build-and-push (push) Successful in 23s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-14 23:40:05 +03:00
dokril 5a21a09b82 Refine rail drawer switching and toggle animations
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-14 15:52:56 +03:00
dokril a9eca0e9d4 Refine Harbor device ecosystem guidance and device panel layout
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-14 15:38:17 +03:00
dokril 32217f4d17 Animate vertical transitions between client drawers
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-14 15:23:00 +03:00
dokril 019930924d Handle expired subscriptions in client profile UI
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-13 15:06:13 +03:00
dokril 0ea2f9d548 Track outbound device traffic deltas
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-13 14:12:12 +03:00
dokril 0290784526 Refine device traffic reset control
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-13 00:05:04 +03:00
dokril b86812d02b Add device traffic reset with outbound baselines
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-12 23:55:34 +03:00
dokril 08cc013def Clarify device traffic route labels and update Harbor versions
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-12 23:20:25 +03:00
dokril 72c085a5b8 Expose outbound traffic totals and simplify Direct chart breakdown
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-12 22:43:40 +03:00
dokril 9d43e74d97 Add outbound traffic breakdown to device charts
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-12 22:18:08 +03:00
dokril 9e52ccc24d Improve VPN client connection management
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-12 21:48:37 +03:00
dokril 068a7f9890 Accept IPv6 addresses in connectivity diagnostics
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-11 17:23:20 +03:00
dokril 6381760b27 Add network identity diagnostics
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-11 16:19:23 +03:00
dokril 3566f4bc0b Show device identity details and resolve hostnames
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-11 12:39:27 +03:00
dokril 501c498edf Add device identity tooltips and MAC validation
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 11:45:11 +03:00
dokril 7e4da4bdcf Fix profile server selection activation state
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 10:11:13 +03:00
dokril 17849ffd73 Polish subscription profile controls and status styling
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 10:04:13 +03:00
dokril 021cdb28d0 Preserve drawer during startup animation
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 09:02:19 +03:00
dokril ded7b740dc Use top offset for subscribed panel positioning
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 08:56:59 +03:00
dokril 79ffff194f Update VPN client and gateway behavior
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 08:50:44 +03:00
dokril 396c5d1917 Improve server picker scrolling and subscription input layout
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 08:10:15 +03:00
dokril 444f26c401 Prevent duplicate server display during profile transitions
Build and Deploy Gateway / build-and-push (push) Successful in 26s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 08:02:17 +03:00
dokril 9ad0307333 Polish subscription and server picker animations
Build and Deploy Gateway / build-and-push (push) Successful in 28s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 07:55:41 +03:00
dokril 9a8191dc91 Refine subscription panel layout and feedback
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 07:47:28 +03:00
dokril 5cad3e9061 Simplify subscription profile management UI
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 07:36:44 +03:00
dokril af1c45e424 Refine subscription profile layout and focus handling
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-11 07:23:40 +03:00
dokril 9432112fe2 Improve server picker layout and profile activation flow
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-11 01:40:50 +03:00
dokril aa9c959368 Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-11 01:27:46 +03:00
dokril c89e56942a Preserve device inventory across unavailable observations
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-10 10:49:02 +03:00
dokril f233660dc3 Add deprioritized device group
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-10 09:25:29 +03:00
dokril ec68ba6a7b Refine device traffic chart collapse animation
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-10 00:17:13 +03:00
dokril 3a4173db43 Animate device pin collapse and traffic chart updates
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-09 23:58:02 +03:00
dokril d349ca5e29 Improve gateway traffic dashboard and connection branding
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-09 14:37:11 +03:00
dokril 3204ecf4a5 Retry sing-box downloads in runtime builds
Build and Deploy Gateway / build-and-push (push) Successful in 36s
Build and Deploy Gateway / deploy (push) Successful in 18s
2026-08-09 13:33:28 +03:00
dokril d612e227fa Pin and verify sing-box version across Harbor runtimes
Build and Deploy Gateway / build-and-push (push) Failing after 1m15s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-08-09 13:25:07 +03:00
dokril 03b2ed5fb0 Preserve local macOS client state during installs
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-09 13:18:53 +03:00
dokril 5da9686c27 Update sing-box to 1.13.18
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-09 13:05:51 +03:00
dokril f40221969b Preserve VLESS WebSocket variants during subscription refresh
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-09 12:42:05 +03:00
dokril 90433d7cd8 Update VPN proxy client behavior
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-09 11:55:02 +03:00
dokril 71ede44be0 Use Debian Node image for gateway builds
Build and Deploy Gateway / build-and-push (push) Successful in 48s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-09 00:51:46 +03:00
194 changed files with 31699 additions and 4761 deletions
@@ -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. 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. 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 ## Non-negotiable decisions
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis. - 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. - 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. - 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. - 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. - Preserve a generous invisible hit area around icon-only controls.
- Center proxy address and protocol actions with the power column. - Center proxy address and protocol actions with the power column.
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels. - 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. - Show expiry as both date and remaining days, with correct Russian forms.
- If there is no total, say `без лимита` and omit the progress bar. - 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. - 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
View File
@@ -4,16 +4,19 @@ CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT=8082 CLIENT_PROXY_PORT=8082
HARBOR_GATEWAY_CONTROL_PORT=3456 HARBOR_GATEWAY_CONTROL_PORT=3456
BASE_IMAGE=debian:bookworm-slim BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.12.13 SINGBOX_VERSION=1.14.0-rc.5
INSTALL_RUNTIME_DEPS=true INSTALL_RUNTIME_DEPS=true
INSTALL_SINGBOX=true INSTALL_SINGBOX=true
PROXY_PORT=8080 PROXY_PORT=8080
PROXY_BIND_IP=0.0.0.0 PROXY_BIND_IP=0.0.0.0
SING_BOX_API_PORT=19090 SING_BOX_API_PORT=19090
SING_BOX_TRAFFIC_SOURCE=snapshot
TPROXY_PORT=7895 TPROXY_PORT=7895
TPROXY_MARK=1 TPROXY_MARK=1
TPROXY_TABLE=100 TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY TPROXY_CHAIN=VPN_PROXY_TPROXY
DIRECT_TRAFFIC_CHAIN=VPN_PROXY_DIRECT
DIRECT_TRAFFIC_MARK=0x40000000
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
+46 -7
View File
@@ -4,15 +4,21 @@ on:
push: push:
branches: [master] branches: [master]
workflow_dispatch: workflow_dispatch:
inputs:
hard_deploy:
description: Always rebuild and deploy both Gateway images
required: false
default: false
type: boolean
env: env:
DEPLOY_PATH: /opt/vpn-proxy DEPLOY_PATH: /opt/vpn-proxy
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
NODE_BUILD_IMAGE: mirror.gcr.io/library/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 RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
APT_MIRROR: http://mirror.yandex.ru/debian APT_MIRROR: http://mirror.yandex.ru/debian
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
SINGBOX_VERSION: 1.12.13 SINGBOX_VERSION: 1.14.0-rc.5
jobs: jobs:
build-and-push: build-and-push:
@@ -34,6 +40,8 @@ jobs:
- name: Build and push gateway image - name: Build and push gateway image
id: gateway-build id: gateway-build
env:
HARD_DEPLOY_INPUT: ${{ inputs.hard_deploy }}
run: | run: |
set -euo pipefail set -euo pipefail
cd repo cd repo
@@ -44,6 +52,11 @@ jobs:
DATAPLANE_IMAGE="${IMAGE}-dataplane" DATAPLANE_IMAGE="${IMAGE}-dataplane"
EVENT_NAME="${{ gitea.event_name }}" 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 }}" BEFORE_SHA="${{ gitea.event.before }}"
ZERO_SHA="0000000000000000000000000000000000000000" ZERO_SHA="0000000000000000000000000000000000000000"
if [ "$EVENT_NAME" = "push" ] \ if [ "$EVENT_NAME" = "push" ] \
@@ -76,6 +89,13 @@ jobs:
none:none|control:control|dataplane:both|control+dataplane:both) ;; none:none|control:control|dataplane:both|control+dataplane:both) ;;
*) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;; *) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;;
esac 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 "Affected components: ${AFFECTED_COMPONENTS}"
echo "Restart scope: ${RESTART_SCOPE}" echo "Restart scope: ${RESTART_SCOPE}"
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT" echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
@@ -87,8 +107,8 @@ jobs:
npm test npm test
npm run build:production npm run build:production
else else
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then 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 image are unavailable." >&2 echo "Cannot validate change: host npm and the Node 20.19 build toolchain are unavailable." >&2
exit 1 exit 1
fi fi
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}" echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
@@ -97,7 +117,16 @@ jobs:
-v "$PWD:/work" \ -v "$PWD:/work" \
-w /work \ -w /work \
"${{ env.NODE_BUILD_IMAGE }}" \ "${{ 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 fi
if [ "$RESTART_SCOPE" = "none" ]; then if [ "$RESTART_SCOPE" = "none" ]; then
echo "Image build and push skipped: no Gateway runtime impact." echo "Image build and push skipped: no Gateway runtime impact."
@@ -110,8 +139,9 @@ jobs:
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p' 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 \ 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 || ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc \
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now." '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 }}" \ BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \ RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
APT_MIRROR="${{ env.APT_MIRROR }}" \ APT_MIRROR="${{ env.APT_MIRROR }}" \
@@ -122,6 +152,7 @@ jobs:
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
DOCKER_BUILDKIT=1 docker build \ DOCKER_BUILDKIT=1 docker build \
"${DOCKER_BUILD_OPTIONS[@]}" \
--network host \ --network host \
--pull=false \ --pull=false \
--build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \ --build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
@@ -134,6 +165,10 @@ jobs:
-t "${DATAPLANE_IMAGE}:latest" \ -t "${DATAPLANE_IMAGE}:latest" \
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \ -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}:latest"
docker push "${CONTROL_IMAGE}:${{ gitea.sha }}" docker push "${CONTROL_IMAGE}:${{ gitea.sha }}"
docker push "${DATAPLANE_IMAGE}:latest" docker push "${DATAPLANE_IMAGE}:latest"
@@ -188,3 +223,7 @@ jobs:
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \ DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \ UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
bash scripts/deploy-gateway.sh 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 }}\""
+1
View File
@@ -13,6 +13,7 @@ data/
node_modules/ node_modules/
dist/ dist/
.test-dist/ .test-dist/
.tmp-tests/
coverage/ coverage/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
+2
View File
@@ -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. 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. 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
View File
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
RUN npm run build:production RUN npm run build:production
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.12.13 ARG SINGBOX_VERSION=1.14.0-rc.5
ARG INSTALL_RUNTIME_DEPS=true ARG INSTALL_RUNTIME_DEPS=true
ARG INSTALL_SINGBOX=true ARG INSTALL_SINGBOX=true
@@ -35,7 +35,7 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
arm64) sb_arch="arm64" ;; \ arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \ *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \ 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; \ tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \ mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \ chmod +x /usr/local/bin/sing-box; \
@@ -46,6 +46,9 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
WORKDIR /app WORKDIR /app
COPY --from=build /src/dist /app/dist 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 package.json /app/package.json
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -59,6 +62,7 @@ ENV PORT=3456 \
TPROXY_PORT=7895 \ TPROXY_PORT=7895 \
DATA_DIR=/var/lib/vpn-proxy \ DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \ 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"] ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
+6 -2
View File
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
RUN npm run build:production RUN npm run build:production
FROM ${RUNTIME_IMAGE} FROM ${RUNTIME_IMAGE}
ARG SINGBOX_VERSION=1.12.13 ARG SINGBOX_VERSION=1.14.0-rc.5
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \ && 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" ;; \ arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \ *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \ 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; \ tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \ mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \ chmod +x /usr/local/bin/sing-box; \
@@ -34,6 +34,9 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=build /src/dist /app/dist 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 package.json /app/package.json
COPY entrypoint.client.sh /entrypoint.client.sh COPY entrypoint.client.sh /entrypoint.client.sh
@@ -49,6 +52,7 @@ ENV APP_MODE=client \
SING_BOX_CACHE=/var/lib/sing-box/cache.db \ SING_BOX_CACHE=/var/lib/sing-box/cache.db \
RULE_SET_DOWNLOAD_DETOUR=vpn \ RULE_SET_DOWNLOAD_DETOUR=vpn \
ROUTING_RU_DIRECT=true \ ROUTING_RU_DIRECT=true \
SING_BOX_TRAFFIC_SOURCE=native \
LOG_LEVEL=info LOG_LEVEL=info
EXPOSE 3456 8082 EXPOSE 3456 8082
+2 -2
View File
@@ -1,6 +1,6 @@
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
FROM ${BASE_IMAGE} 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_MIRROR=http://mirror.yandex.ru/debian
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
ARG HTTP_PROXY ARG HTTP_PROXY
@@ -45,7 +45,7 @@ RUN set -eux; \
arm64) sb_arch="arm64" ;; \ arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \ *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \ 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; \ tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \ mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \ chmod +x /usr/local/bin/sing-box; \
+66 -20
View File
@@ -1,6 +1,6 @@
# Harbor # Harbor
Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`. Harbor помогает пользоваться несколькими VPN-подписками дома и на Mac без ручной настройки `sing-box`.
Проект работает в двух режимах: Проект работает в двух режимах:
@@ -9,7 +9,7 @@ Harbor помогает пользоваться одной VPN-подписко
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси | | **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный 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. При необходимости добавьте подписку ### 4. При необходимости добавьте подписку
1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway. 1. Нажмите «Подписки» — верхнюю кнопку в правой панели Gateway.
2. Вставьте ссылку VPN-подписки. 2. Нажмите «Добавить подписку», задайте понятное имя и вставьте ссылку VPN-провайдера.
3. Нажмите «Сохранить подписку». 3. Выберите сервер внутри добавленной группы.
4. Выберите сервер. 4. При нескольких группах выберите нужную действием «Сделать активной».
5. Включите VPN. 5. Включите VPN.
После подключения Harbor покажет два варианта использования: После подключения Harbor покажет два варианта использования:
@@ -75,15 +75,29 @@ http://АДРЕС-GATEWAY:3456
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей. Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
### Резервный канал Gateway
После добавления подписок откройте «Резерв» — вторую кнопку в правой панели. Выберите основной и резервный серверы (они могут быть из одной или разных подписок), сервисы для проверки и отдельный таймаут каждого сервиса. Там же настраиваются длительность сбоя и восстановления, порог активного трафика, период тишины и защита от частых переключений.
При включении Harbor заранее проверяет dual-конфигурацию. Если VPN остановлен, она начнёт работать только после следующего обычного нажатия питания; сохранение само VPN не включает. Переключение меняет маршрут только для новых соединений — уже открытые соединения не закрываются. Если через VPN идёт активный трафик или его активность нельзя надёжно определить, Harbor ждёт и показывает скорость, число передающих соединений и безопасные подписи основных блокирующих потоков.
Выключенный резерв полностью пассивен: Harbor не запускает проверки, таймер выбора и отдельный подсчёт активности. Если dual-конфигурация уже загружена, отключение не перезапускает VPN и не меняет текущий маршрут; обычный stop и следующий запуск вернут single-channel config. Последние важные события — включение VPN, обновления подписок, переключения и ошибки — доступны в последней кнопке «Журнал» и хранятся 30 дней без ссылок подписок и сырых диагностических ответов.
### Устройства Gateway ### Устройства 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 сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `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 ## Установка Harbor Connect на macOS
@@ -114,10 +128,12 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
### 3. Добавьте подписку ### 3. Добавьте подписку
Откройте `http://127.0.0.1:3456`, вставьте ссылку подписки, выберите сервер и включите VPN. Откройте `http://127.0.0.1:3456`, добавьте подписку с понятным именем, выберите сервер внутри её группы и включите VPN. Остальные подписки можно добавить через правую панель «Подписки»; у каждой сохраняются собственные серверы, лимит и выбор.
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS. Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `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`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси. Допустимы порты от `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 и подключаемые списки пока не поддерживаются. Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе. При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс применяет новую конфигурацию, только если она изменилась. Если sing-box остановлен, правила сохраняются с признаком «ждут запуска» и начнут работать при следующем запуске или restart; в Connect с активным Harbor Gateway они сохраняются как желаемые, но локально не применяются.
## Системный прокси macOS ## Системный прокси macOS
@@ -167,15 +185,15 @@ networksetup -setsocksfirewallproxystate Wi-Fi off
## Автоматическое использование домашнего Gateway ## Автоматическое использование домашнего 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`; 2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
3. оставьте автоматический режим включённым в Harbor Connect. 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_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy | | `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать | | `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` | Уровень подробности журнала | | `LOG_LEVEL` | `info` | Уровень подробности журнала |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно. После изменения `.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 ## Prometheus и Grafana
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд: 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. `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». Готовый 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 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 ```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`. Порты можно заменить через `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`, если хотите сохранить тестовые подписки и настройки.
## Служебные команды ## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется. Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
+9
View File
@@ -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
View File
@@ -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 аудита не запускались.
+2 -1
View File
@@ -4,7 +4,7 @@ services:
context: . context: .
dockerfile: Dockerfile.client dockerfile: Dockerfile.client
args: args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13} SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
container_name: harbor-connect container_name: harbor-connect
environment: environment:
APP_MODE: client APP_MODE: client
@@ -14,6 +14,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json 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: ${SING_BOX_TRAFFIC_SOURCE:-native}
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456} HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
+5 -1
View File
@@ -5,7 +5,7 @@ x-gateway-image: &gateway-image
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim} 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_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true} INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
@@ -25,6 +25,9 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CONFIG: /var/lib/vpn-proxy/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: ${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 DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
volumes: volumes:
- vpn-proxy-data:/var/lib/vpn-proxy - vpn-proxy-data:/var/lib/vpn-proxy
@@ -49,6 +52,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CONFIG: /var/lib/vpn-proxy/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: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports: ports:
- "${PORT:-3456}:${PORT:-3456}" - "${PORT:-3456}:${PORT:-3456}"
+84 -49
View File
@@ -1,85 +1,120 @@
# Harbor application state v1 # 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 ```json
{ {
"apiVersion": 1, "apiVersion": 1,
"revision": 42, "revision": 42,
"generatedAt": "2026-07-11T15:00:00.000Z",
"mode": "client", "mode": "client",
"subscription": { "profiles": [
"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": [
{ {
"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", "label": "Amsterdam",
"host": "nl.example.net", "host": "nl.example.net",
"port": 443, "port": 443,
"protocol": "vless" "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 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 ## 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.
+4 -4
View File
@@ -3,14 +3,14 @@
Harbor tracks active browser mutations by operation key instead of one global `busy` flag: Harbor tracks active browser mutations by operation key instead of one global `busy` flag:
- `connection`: start, stop and restart; - `connection`: start, stop and restart;
- `serverApply`: apply the selected server; - `serverApply`: apply a `(profileId, serverId)` pair;
- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`; - `profileAdd`, `profileRename`, `profileSelect`, `profileActivate`, `profileRefresh`, `profileDelete`;
- `gatewayAuto`: change the active route preference. - `gatewayAuto`: change the active route preference.
Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.ts`. Each entry is `{ status: "running", startedAt }`. A repeated operation key receives the same in-flight Promise, so a double click sends one request. A conflicting key resolves to `false` without starting its action. The symmetric conflict matrix lives in `src/web/state/operations.ts`.
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure. The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
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.
+41 -13
View File
@@ -1,36 +1,64 @@
# Harbor state recovery # 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 ## 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. 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 ```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 ## Corrupt JSON
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
```text ```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. Perform recovery while Harbor is stopped:
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.
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
View File
@@ -11,6 +11,8 @@ GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}" GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}" TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}" 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}" 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_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" 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_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}" 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}" 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() { log() {
printf '[gateway-entrypoint] %s\n' "$*" printf '[gateway-entrypoint] %s\n' "$*"
@@ -28,6 +32,53 @@ if [[ "$APP_COMPONENT" == "control" ]]; then
exec node /app/dist/server/main.js exec node /app/dist/server/main.js
fi 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() { ipt() {
iptables -w "$@" iptables -w "$@"
} }
@@ -80,37 +131,53 @@ cleanup_gateway_forwarding() {
} }
cleanup_device_traffic() { 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 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 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 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 ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
for slot in A B; do 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}" 2>/dev/null || true
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 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}_P" 2>/dev/null || true
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 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}" 2>/dev/null || true
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 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}_P" 2>/dev/null || true
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
done done
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true 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 ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
} }
setup_device_traffic() { setup_device_traffic() {
log "setup raw device traffic counters" log "setup device traffic counters"
cleanup_device_traffic cleanup_device_traffic
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1 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 ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
for slot in A B; do 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}" || return 1
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || 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}" || return 1
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1 ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
done done
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1 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 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() { enable_ip_forwarding() {
@@ -172,7 +239,6 @@ setup_tproxy() {
for cidr in $BYPASS_CIDRS; do for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
done done
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN" ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
else else
@@ -183,9 +249,13 @@ setup_tproxy() {
setup_gateway_forwarding setup_gateway_forwarding
setup_tproxy setup_tproxy
if ! setup_device_traffic; then if validate_device_traffic_config; then
log "device traffic counters unavailable; VPN routing remains active" if ! setup_device_traffic; then
cleanup_device_traffic log "device traffic counters unavailable; VPN routing remains active"
cleanup_device_traffic
else
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true
fi
fi fi
setup_proxy_firewall setup_proxy_firewall
File diff suppressed because it is too large Load Diff
+241
View File
@@ -8,6 +8,9 @@
"name": "vpn-proxy-gateway", "name": "vpn-proxy-gateway",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -15,6 +18,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/parser": "7.29.3", "@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0", "@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17", "@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130", "@types/node18": "npm:@types/node@18.19.130",
@@ -288,6 +293,229 @@
"node": ">=6.9.0" "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": { "node_modules/@csstools/selector-specificity": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
@@ -1538,6 +1766,19 @@
"node": ">=16.20.0" "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": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+6
View File
@@ -6,6 +6,7 @@
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.", "description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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": "vite build",
"build:production": "npm run build && npm run build:server", "build:production": "npm run build && npm run build:server",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json",
@@ -18,6 +19,9 @@
"start": "node dist/server/main.js" "start": "node dist/server/main.js"
}, },
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -25,6 +29,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/parser": "7.29.3", "@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0", "@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17", "@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130", "@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;
}
+2 -2
View File
@@ -12,7 +12,7 @@ GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}" BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}" NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}" SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}" DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}" INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}" INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
@@ -63,7 +63,7 @@ else
fi fi
echo "Building image on ${BUILD_HOST}" echo "Building image on ${BUILD_HOST}"
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build: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 if [ "${BUILD_HOST}" = "local" ]; then
bash -lc "${BUILD_COMMAND}" bash -lc "${BUILD_COMMAND}"
else else
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base: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_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}" 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}')}" HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
+1 -1
View File
@@ -53,7 +53,7 @@ export function affectedComponents(files) {
for (const file of files) { for (const file of files) {
if (file === VERSION_FILE) continue; if (file === VERSION_FILE) continue;
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS); if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
else if (/^(src\/web\/|public\/|index\.html$|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'); add('macClient', 'gatewayClient');
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend'); } else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) { else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
+15 -1
View File
@@ -9,6 +9,8 @@ COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8082" DEFAULT_PROXY_PORT="8082"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_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" CLIENT_CONTAINER_NAME="harbor-connect"
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client" LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network" 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" [ -f "$source_dir/docker-compose.client.yml" ] || die "invalid Harbor source archive"
log "installing files to $INSTALL_DIR" log "installing files to $INSTALL_DIR"
mkdir -p "$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() { download_source() {
@@ -282,8 +288,14 @@ fi
need docker need docker
need curl need curl
need rsync
need tar 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 compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
docker info >/dev/null 2>&1 || die "Docker Desktop is not running" 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 assert_ui_outside_proxy_range
set_env_value APP_MODE client 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_UI_PORT "$UI_PORT"
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT" set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
set_env_value PROXY_PORT "$PROXY_PORT" set_env_value PROXY_PORT "$PROXY_PORT"
+9 -3
View File
@@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url';
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`; const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
const noRuntimeImpact = [ const noRuntimeImpact = [
/^\.codex\//, /^\.codex\//,
/^\.tmp-tests\//,
/^docs\//, /^docs\//,
/^test\//, /^test\//,
/^workpack\//, /^workpack\//,
/^(?:AGENTS|PRODUCT|README)\.md$/, /^(?:AGENTS|PRODUCT|README)\.md$/,
/^context\.md$/,
/^\.env\.example$/, /^\.env\.example$/,
/^\.gitignore$/, /^\.gitignore$/,
/^Dockerfile\.client$/, /^Dockerfile\.client$/,
@@ -17,6 +19,7 @@ const noRuntimeImpact = [
/^entrypoint\.client\.sh$/, /^entrypoint\.client\.sh$/,
/^install\.sh$/, /^install\.sh$/,
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.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 = [ const foundation = [
/^\.dockerignore$/, /^\.dockerignore$/,
@@ -30,11 +33,14 @@ const foundation = [
/^tsconfig(?:\.[^.]+)?\.json$/, /^tsconfig(?:\.[^.]+)?\.json$/,
]; ];
const controlAndDataplane = [ const controlAndDataplane = [
/^buf\.gen\.yaml$/,
/^proto\//,
new RegExp(`^src/server/main${CODE_EXTENSION}`), 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/adapters/neighbors${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService)${CODE_EXTENSION}`), new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`), new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
/^src\/server\/infrastructure\/dataplane\//, /^src\/server\/infrastructure\/dataplane\//,
]; ];
const dataplane = [ const dataplane = [
+33 -2
View File
@@ -1,5 +1,7 @@
import path from "node:path"; 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 dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const parsePort = (value: string | undefined, fallback: number) => { const parsePort = (value: string | undefined, fallback: number) => {
const parsed = Number.parseInt(value || '', 10); const parsed = Number.parseInt(value || '', 10);
@@ -7,21 +9,45 @@ const parsePort = (value: string | undefined, fallback: number) => {
}; };
const proxyPort = parsePort( const proxyPort = parsePort(
process.env.PROXY_PORT, 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 = { export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway", appMode,
appComponent,
port: parsePort(process.env.PORT, 3456), port: parsePort(process.env.PORT, 3456),
proxyPort, proxyPort,
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080), 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), 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), tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyMark: process.env.TPROXY_MARK || "1", tproxyMark: process.env.TPROXY_MARK || "1",
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY", devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP", trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN", 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 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") || "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), .trim().split(/\s+/).filter(Boolean),
@@ -32,8 +58,13 @@ export const settings = {
configPath: configPath:
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", 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"), statePath: path.join(dataDir, "state.json"),
deviceStatePath: path.join(dataDir, "devices.json"), deviceStatePath: path.join(dataDir, "devices.json"),
activityJournalPath: path.join(dataDir, "activity-journal.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "", sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hostNetworkStatePath: hostNetworkStatePath:
+240 -20
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
import { settings } from './config.js'; import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js'; import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js'; import { buildVersionInfo } from './version.js';
@@ -13,18 +15,37 @@ import {
createDomainTrafficService, createDomainTrafficService,
readSingboxConnections, readSingboxConnections,
} from './services/domainTrafficService.js'; } 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 socketPath = settings.dataplaneSocket;
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
const runtime = createSingboxRuntime({ const runtime = createSingboxRuntime({
configPath: settings.configPath, configPath: settings.configPath,
gateway: true, gateway: true,
tproxyChain: settings.tproxyChain, tproxyChain: settings.tproxyChain,
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
...(nativeTrafficEnabled ? {
nativeApi: {
apiPort: settings.singboxNativeApiPort,
secretPath: settings.gatewayNativeApiSecretPath,
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
},
} : {}),
}); });
const versionInfo = buildVersionInfo('gateway'); const versionInfo = buildVersionInfo('gateway');
const traffic = createDeviceTrafficService({ const traffic = createDeviceTrafficService({
observe: () => readNeighborSnapshot(), observe: () => readNeighborSnapshot(),
uploadChain: settings.trafficUploadChain, uploadChain: settings.trafficUploadChain,
downloadChain: settings.trafficDownloadChain, downloadChain: settings.trafficDownloadChain,
directChain: settings.directTrafficChain,
directMark: settings.directTrafficMark,
tproxyMark: settings.tproxyMark,
gatewayClientCidrs: settings.gatewayClientCidrs,
bypassCidrs: settings.bypassCidrs, bypassCidrs: settings.bypassCidrs,
proxyPort: settings.proxyPort, proxyPort: settings.proxyPort,
}); });
@@ -36,10 +57,28 @@ const devicePolicy = createDevicePolicyService({
const connectivityDiagnostics = createConnectivityDiagnosticsService({ const connectivityDiagnostics = createConnectivityDiagnosticsService({
proxyPort: settings.diagnosticsProxyPort, 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), observe: () => readSingboxConnections(settings.singboxApiPort),
devices: () => traffic.snapshot().devices, 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 ready = false;
let trafficTimer: NodeJS.Timeout | null = null; let trafficTimer: NodeJS.Timeout | null = null;
let domainTrafficTimer: 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); 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> { function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -95,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
gatewayBackendVersion: versionInfo.components.gatewayBackend, gatewayBackendVersion: versionInfo.components.gatewayBackend,
singBoxVersion: versionInfo.runtime.singBox, singBoxVersion: versionInfo.runtime.singBox,
devicePolicy: devicePolicy.snapshot(), devicePolicy: devicePolicy.snapshot(),
trafficCollector: trafficCollectorSource(),
ready, ready,
}); });
} }
@@ -105,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
return sendJson(res, 200, traffic.snapshot()); return sendJson(res, 200, traffic.snapshot());
} }
if (req.method === 'GET' && req.url === '/domain-traffic') { 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') { if (req.method === 'GET' && req.url === '/device-policy') {
return sendJson(res, 200, devicePolicy.snapshot()); return sendJson(res, 200, devicePolicy.snapshot());
@@ -122,6 +292,36 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
target, 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') { if (req.method === 'POST' && req.url === '/apply') {
return sendJson(res, 200, await runtime.apply()); return sendJson(res, 200, await runtime.apply());
} }
@@ -146,25 +346,44 @@ server.listen(socketPath, async () => {
} catch (error) { } catch (error) {
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
} finally { } 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; ready = true;
setImmediate(() => { if (settings.deviceTrafficAccountingEnabled) {
traffic.refresh() setImmediate(() => {
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); refreshDeviceTraffic()
}); .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
trafficTimer = setInterval(() => { });
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); trafficTimer = setInterval(() => {
}, 15_000); refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
trafficTimer.unref(); }, 15_000);
setImmediate(() => { trafficTimer.unref();
domainTraffic.refresh() } else {
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); trafficTimer = setInterval(refreshOrigins, 15_000);
}); trafficTimer.unref();
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one. }
domainTrafficTimer = setInterval(() => { if (trafficMode !== 'native') {
domainTraffic.refresh() setImmediate(() => {
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); snapshotDomainTraffic.refresh()
}, 2_000); .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
domainTrafficTimer.unref(); });
domainTrafficTimer = setInterval(() => {
snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
}, 2_000);
domainTrafficTimer.unref();
}
console.log(`[dataplane] control socket: ${socketPath}`); console.log(`[dataplane] control socket: ${socketPath}`);
} }
}); });
@@ -176,6 +395,7 @@ async function shutdown() {
ready = false; ready = false;
if (trafficTimer) clearInterval(trafficTimer); if (trafficTimer) clearInterval(trafficTimer);
if (domainTrafficTimer) clearInterval(domainTrafficTimer); if (domainTrafficTimer) clearInterval(domainTrafficTimer);
await liveTraffic.stop();
await runtime.shutdown(); await runtime.shutdown();
server.close(() => { server.close(() => {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
+11
View File
@@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
observeDevices: () => send(socketPath, '/devices', 'GET'), observeDevices: () => send(socketPath, '/devices', 'GET'),
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'), observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }), applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => { runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
@@ -82,6 +83,16 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
throw new HarborError('DIAGNOSTICS_FAILED', { cause }); 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'), apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'), restart: () => update('/restart', 'POST'),
stop: () => update('/stop', '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 { 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 { interface ConnectionServiceDependencies {
state: { state: {
read(): StoredState; read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState; update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
}; };
subscription: {
readConfig(): unknown | null;
};
config: { config: {
exists(): boolean;
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown; build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null; read(): string | null;
write(value: unknown): void; write(value: unknown): void;
@@ -25,6 +27,19 @@ interface ConnectionServiceDependencies {
stopCommand(): Promise<RuntimeCommandResult>; stopCommand(): Promise<RuntimeCommandResult>;
restartCommand(): 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>; serialize<T>(operation: () => Promise<T>): Promise<T>;
now(): Date; 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) { export function createConnectionService(dependencies: ConnectionServiceDependencies) {
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => { const prepareFailoverActivation = async (role: 'primary' | 'reserve') => {
const previousState = dependencies.state.read(); try {
const requestedId = String(serverId).trim(); await dependencies.failover?.prepareActivation(role);
const requestedTag = String(selectedTag).trim(); } 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 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 : ''; 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 (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
if (!profile.subscriptionConfig) throw new HarborError('CONFIG_INVALID');
const subscriptionConfig = dependencies.subscription.readConfig(); const wasRunning = await dependencies.runtime.isRunning();
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID'); if (wasRunning && previousState.failoverPolicy?.enabled) {
const nextConfig = dependencies.config.build( dependencies.state.update((state) => withDesiredServer(state, profile, selectedServer.id));
subscriptionConfig, 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, selectedServer.id,
previousState.routeRules, previousState.routeRules,
); );
const previousConfig = dependencies.config.read(); const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning();
let desiredCommitStarted = false;
let configMutationStarted = false; let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try { try {
desiredCommitStarted = true;
dependencies.state.update((state) => ({
...state,
selectedServerId: selectedServer.id,
connectionDesired: 'running',
}));
configMutationStarted = true; configMutationStarted = true;
dependencies.config.write(nextConfig); dependencies.config.write(nextConfig);
runtimeMutationStarted = true;
await dependencies.runtime.start(); await dependencies.runtime.start();
if (failoverCandidate) await prepareFailoverActivation('primary');
stateCommitStarted = true;
dependencies.state.update((state) => ({ dependencies.state.update((state) => ({
...state, ...withDesiredServer(state, profile, selectedServer.id),
appliedServerId: 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(), appliedAt: dependencies.now().toISOString(),
appliedRouteRules: state.routeRules, appliedRouteRules: state.routeRules,
})); }));
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishConnectionRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
...(configMutationStarted ? [{ ...(configMutationStarted ? [{
run: () => previousConfig === null run: () => previousConfig === null
? dependencies.config.remove() ? dependencies.config.remove()
: dependencies.config.restore(previousConfig), : dependencies.config.restore(previousConfig),
}] : []), }] : []),
...(configMutationStarted ? [{ ...(runtimeMutationStarted ? [{
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, runtime: true,
}] : []), }] : []),
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Connection rollback failed'); ], '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 stop = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read(); const previousState = dependencies.state.read();
let wasRunning: boolean | null = null; let wasRunning: boolean | null = null;
@@ -119,51 +254,123 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
runtimeMutationStarted = command.mutationStarted; runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error; if (!command.ok) throw command.error;
stateCommitStarted = true; stateCommitStarted = true;
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' })); dependencies.state.update((state) => ({
...state,
connectionDesired: 'stopped',
appliedProfileId: '',
appliedServerId: '',
appliedServerSnapshot: null,
appliedFailoverPolicy: null,
}));
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishConnectionRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{ ...(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, runtime: true,
}] : []), }] : []),
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Connection rollback failed'); ], '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 restart = () => dependencies.serialize(async () => {
const previousState = dependencies.state.read(); const previousState = dependencies.state.read();
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID'); const wasRunning = await dependencies.runtime.isRunning();
let wasRunning: boolean | null = null; const targetProfileId = wasRunning
try { ? previousState.appliedProfileId
wasRunning = await dependencies.runtime.isRunning(); : previousState.desiredProfileId;
} catch {} 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 runtimeMutationStarted = false;
let stateCommitStarted = false; let stateCommitStarted = false;
try { try {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
const command = await dependencies.runtime.restartCommand(); const command = await dependencies.runtime.restartCommand();
runtimeMutationStarted = command.mutationStarted; runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error; if (!command.ok) throw command.error;
if (failoverCandidate) await prepareFailoverActivation(activationRole);
stateCommitStarted = true; stateCommitStarted = true;
dependencies.state.update((state) => ({ dependencies.state.update((state) => ({
...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', connectionDesired: 'running',
appliedRouteRules: state.routeRules, appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules,
})); }));
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishConnectionRollback(error, [
...(runtimeMutationStarted && wasRunning !== null ? [{ ...(configMutationStarted ? [{
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(), 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, runtime: true,
}] : []), }] : []),
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
], 'Connection rollback failed'); ], '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>; export type ConnectionService = ReturnType<typeof createConnectionService>;
@@ -1,12 +1,20 @@
interface DiagnosticServer { import { isDeepStrictEqual } from 'node:util';
id: unknown;
label: unknown; 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 { interface DiagnosticState {
desiredProfileId?: unknown;
appliedProfileId?: unknown;
appliedServerId?: unknown; appliedServerId?: unknown;
selectedServerId?: unknown; appliedServerSnapshot?: HarborServer | null;
servers?: DiagnosticServer[]; profiles?: StoredProfile[];
revision?: number;
diagnostics?: DiagnosticSettings;
} }
interface DiagnosticsResult extends Record<string, unknown> { interface DiagnosticsResult extends Record<string, unknown> {
@@ -14,7 +22,10 @@ interface DiagnosticsResult extends Record<string, unknown> {
} }
interface ConnectivityDiagnosticsDependencies { interface ConnectivityDiagnosticsDependencies {
readState(): DiagnosticState; state: {
read(): DiagnosticState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
};
runDiagnostics(services: unknown, target: unknown): Promise<unknown>; runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
} }
@@ -25,16 +36,27 @@ function diagnosticsResult(value: unknown): DiagnosticsResult {
return value as 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( export function createConnectivityDiagnosticsUseCase(
dependencies: ConnectivityDiagnosticsDependencies, dependencies: ConnectivityDiagnosticsDependencies,
) { ) {
return { return {
async run(services: unknown, target: unknown) { async run(target: unknown) {
const state = dependencies.readState(); const state = dependencies.state.read();
const appliedServerId = state.appliedServerId || state.selectedServerId; const selected = selectedServer(state);
const selected = (Array.isArray(state.servers) ? state.servers : [])
.find((server) => server.id === appliedServerId);
const server = selected ? { id: selected.id, label: selected.label } : null; const server = selected ? { id: selected.id, label: selected.label } : null;
const services = normalizeDiagnosticSettings(state.diagnostics).customServices;
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target)); const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
return { return {
...result, ...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 { 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 type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js'; import { finishRollback } from '../../services/rollback.js';
@@ -25,7 +31,7 @@ interface GatewayAutoServiceDependencies {
read(): StoredState; read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState; update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
}; };
subscription: { readConfig(): unknown | null }; subscription: { readConfig(profileId: string): unknown | null };
config: { config: {
build( build(
subscriptionConfig: unknown, subscriptionConfig: unknown,
@@ -42,6 +48,7 @@ interface GatewayAutoServiceDependencies {
isRunning(): boolean; isRunning(): boolean;
applyCommand(): Promise<RuntimeCommandResult>; applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>; restoreRunning(): Promise<unknown>;
stopCommand(): Promise<RuntimeCommandResult>;
}; };
discovery: { discovery: {
readHostNetwork(): HostNetworkState | null; readHostNetwork(): HostNetworkState | null;
@@ -108,22 +115,76 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const previousGatewayAuto = current; const previousGatewayAuto = current;
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate); const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
const modeChanged = previousGatewayAuto.mode !== candidate.mode; const modeChanged = previousGatewayAuto.mode !== candidate.mode;
const leavesGatewayDirect = previousGatewayAuto.mode === 'gateway-direct'
&& candidate.mode !== 'gateway-direct';
if (!stateChanged && persistEnabled === undefined) return current; if (!stateChanged && persistEnabled === undefined) return current;
const previousState = dependencies.state.read(); const previousState = dependencies.state.read();
const subscriptionConfig = modeChanged const wasRunning = modeChanged ? dependencies.runtime.isRunning() : false;
? dependencies.subscription.readConfig() const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
const targetServerId = wasRunning
? previousState.appliedServerId
: targetProfile?.desiredServerId || '';
const subscriptionConfig = modeChanged && targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null; : null;
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig const stopUnavailableTarget = async (cause: unknown) => {
? dependencies.config.build( 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, subscriptionConfig,
previousState.selectedServerId, targetServerId,
previousState.routeRules, previousState.routeRules,
candidate, 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 previousConfig = candidateConfig === null ? null : dependencies.config.read();
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
let configMutationStarted = false; let configMutationStarted = false;
let runtimeMutationStarted = false; let runtimeMutationStarted = false;
let gatewayAutoPublished = false; let gatewayAutoPublished = false;
@@ -144,7 +205,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
current = candidate; current = candidate;
gatewayAutoPublished = true; gatewayAutoPublished = true;
stateCommitStarted = 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) { if (persistEnabled !== undefined) {
stateCommitStarted = true; stateCommitStarted = true;
@@ -171,7 +237,9 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => { const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
const state = dependencies.state.read(); const state = dependencies.state.read();
const network = state.subscriptionUrl const profile = desiredProfile(state);
const subscriptionUrl = profile?.subscriptionUrl || '';
const network = subscriptionUrl
? dependencies.discovery.readHostNetwork() ? dependencies.discovery.readHostNetwork()
: null; : null;
@@ -182,7 +250,7 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
error: discoveryError, error: discoveryError,
}); });
const candidate = dependencies.transition.applyPreference( const candidate = dependencies.transition.applyPreference(
state.subscriptionUrl subscriptionUrl
? { ...discoveredState, lastError: discoveryError } ? { ...discoveredState, lastError: discoveryError }
: discoveredState, : discoveredState,
state.gatewayAutoEnabled !== false, state.gatewayAutoEnabled !== false,
@@ -204,16 +272,17 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
try { try {
verifiedGateway = await dependencies.discovery.probeGateway({ verifiedGateway = await dependencies.discovery.probeGateway({
gateway: network.gateway, gateway: network.gateway,
subscriptionUrl: String(state.subscriptionUrl), subscriptionUrl,
}); });
} catch (error) { } catch (error) {
const reason = errorMessage(error); const reason = errorMessage(error);
const latestState = dependencies.state.read(); const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork() ? dependencies.discovery.readHostNetwork()
: null; : null;
if ( if (
latestState.subscriptionUrl !== state.subscriptionUrl || latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork) !dependencies.transition.sameRoute(network, latestNetwork)
) { ) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure }); return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -232,11 +301,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
} }
const latestState = dependencies.state.read(); const latestState = dependencies.state.read();
const latestNetwork = latestState.subscriptionUrl const latestSubscriptionUrl = desiredProfile(latestState)?.subscriptionUrl || '';
const latestNetwork = latestSubscriptionUrl
? dependencies.discovery.readHostNetwork() ? dependencies.discovery.readHostNetwork()
: null; : null;
if ( if (
latestState.subscriptionUrl !== state.subscriptionUrl || latestSubscriptionUrl !== subscriptionUrl ||
!dependencies.transition.sameRoute(network, latestNetwork) !dependencies.transition.sameRoute(network, latestNetwork)
) { ) {
return commitCandidate(dependencies.transition.createInitial(), { reconfigure }); return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
@@ -1,8 +1,16 @@
import { isDeepStrictEqual } from 'node:util'; 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 { 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 type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js'; import { finishRollback } from '../../services/rollback.js';
@@ -11,7 +19,7 @@ interface RouteRulesDependencies {
read(): StoredState; read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState; update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
}; };
subscription: { readConfig(): unknown | null }; subscription: { readConfig(profileId: string): unknown | null };
config: { config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown; build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
read(): string | null; read(): string | null;
@@ -24,20 +32,34 @@ interface RouteRulesDependencies {
applyCommand(): Promise<RuntimeCommandResult>; applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>; restoreRunning(): Promise<unknown>;
}; };
route?: { isGatewayDirect(): boolean };
serialize<T>(operation: () => Promise<T>): Promise<T>; serialize<T>(operation: () => Promise<T>): Promise<T>;
runOperation<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) { export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => { const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
const subscriptionConfig = dependencies.subscription.readConfig(); const wasRunning = await dependencies.runtime.isRunning();
if (!previousState.selectedServerId || !subscriptionConfig) { 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; let stateCommitStarted = false;
try { try {
stateCommitStarted = true; stateCommitStarted = true;
dependencies.state.update((state) => ({ dependencies.state.update((state) => ({
...state, ...state,
routeRules, routeRules,
...(bypassed ? { appliedRouteRules: [] } : {}),
routeRulesRevision: state.routeRulesRevision + 1, routeRulesRevision: state.routeRulesRevision + 1,
})); }));
} catch (error) { } catch (error) {
@@ -50,19 +72,21 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const candidateConfig = dependencies.config.build( const candidateConfig = dependencies.config.build(
subscriptionConfig, subscriptionConfig,
previousState.selectedServerId, targetServerId,
routeRules, routeRules,
); );
const previousConfig = dependencies.config.read(); const previousConfig = dependencies.config.read();
const wasRunning = await dependencies.runtime.isRunning(); const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
let configMutationStarted = false; let configMutationStarted = false;
let runtimeMutationStarted = false; let runtimeMutationStarted = false;
let stateCommitStarted = false; let stateCommitStarted = false;
try { try {
configMutationStarted = true; if (configChanged) {
dependencies.config.write(candidateConfig); configMutationStarted = true;
if (wasRunning) { dependencies.config.write(candidateConfig);
}
if (wasRunning && configChanged) {
const command = await dependencies.runtime.applyCommand(); const command = await dependencies.runtime.applyCommand();
runtimeMutationStarted = command.mutationStarted; runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error; if (!command.ok) throw command.error;
@@ -74,6 +98,7 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
...(wasRunning ? { appliedRouteRules: routeRules } : {}), ...(wasRunning ? { appliedRouteRules: routeRules } : {}),
routeRulesRevision: state.routeRulesRevision + 1, routeRulesRevision: state.routeRulesRevision + 1,
})); }));
await dependencies.afterApply?.();
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishRollback(error, [
...(configMutationStarted ? [{ ...(configMutationStarted ? [{
@@ -82,7 +107,10 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
: dependencies.config.restore(previousConfig), : dependencies.config.restore(previousConfig),
}] : []), }] : []),
...(wasRunning && runtimeMutationStarted ? [{ ...(wasRunning && runtimeMutationStarted ? [{
run: () => dependencies.runtime.restoreRunning(), run: async () => {
await dependencies.runtime.restoreRunning();
await dependencies.restoreAppliedActivation?.(previousState);
},
runtime: true, runtime: true,
}] : []), }] : []),
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ...(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[]; let routeRules: RouteRule[];
try { try {
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[]; routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
} catch (cause) { } catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause }); throw new HarborError('REQUEST_INVALID', { cause });
} }
const rulesRevision = expectedRulesRevision ?? expectedRevision; if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
throw new HarborError('REQUEST_INVALID'); throw new HarborError('REQUEST_INVALID');
} }
return dependencies.serialize(async () => { return dependencies.serialize(async () => {
const current = dependencies.state.read(); const current = dependencies.state.read();
const currentRevision = expectedRulesRevision == null if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
? current.revision
: current.routeRulesRevision;
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return; if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await dependencies.runOperation(() => applyRules(current, routeRules)); await dependencies.runOperation(() => applyRules(current, routeRules));
}); });
+11 -6
View File
@@ -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_MAX_COUNT = 30;
export const SERVER_HEALTH_CONCURRENCY = 4; export const SERVER_HEALTH_CONCURRENCY = 4;
@@ -36,19 +37,23 @@ export async function checkServerHealth(
} }
interface ServerHealthDependencies { interface ServerHealthDependencies {
readServers(): HarborServer[]; readProfiles(): StoredProfile[];
readDesiredProfileId(): string;
ping: Ping; ping: Ping;
} }
export function createServerHealthService(dependencies: ServerHealthDependencies) { export function createServerHealthService(dependencies: ServerHealthDependencies) {
return { 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 requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
const servers = dependencies.readServers();
return checkServerHealth( return checkServerHealth(
requestedIds.size requestedIds.size
? servers.filter((server) => requestedIds.has(server.id)) ? profile.servers.filter((server) => requestedIds.has(server.id))
: servers, : profile.servers,
dependencies.ping, dependencies.ping,
); );
}, },
@@ -6,6 +6,7 @@ import {
type StateSnapshot, type StateSnapshot,
type StoredState, type StoredState,
} from '../../../shared/contracts/state.js'; } from '../../../shared/contracts/state.js';
import type { FailoverSnapshot } from '../../../shared/failover.js';
interface RuntimeState { interface RuntimeState {
running?: boolean; running?: boolean;
@@ -26,6 +27,7 @@ interface StateServiceDependencies {
getGatewayAutoState: () => GatewayAutoState; getGatewayAutoState: () => GatewayAutoState;
getOperationState: () => OperationState; getOperationState: () => OperationState;
configExists: () => boolean; configExists: () => boolean;
getFailoverSnapshot?: () => FailoverSnapshot;
} }
function subscriptionHost(value: unknown) { function subscriptionHost(value: unknown) {
@@ -51,6 +53,7 @@ export function createStateService(dependencies: StateServiceDependencies) {
configExists, configExists,
subscriptionHost: subscriptionHost(storedState.subscriptionUrl), subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
operation: dependencies.getOperationState(), operation: dependencies.getOperationState(),
failoverSnapshot: dependencies.getFailoverSnapshot?.(),
}); });
return { snapshot, storedState, gatewayAuto, configExists }; return { snapshot, storedState, gatewayAuto, configExists };
}, },
@@ -1,14 +1,18 @@
import type { import crypto from 'node:crypto';
GatewayAutoState,
HarborServer, import {
StoredState, profileById,
type GatewayAutoState,
type HarborServer,
type StoredProfile,
type StoredState,
} from '../../../shared/contracts/state.js'; } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js'; import { HarborError } from '../../../shared/errors.js';
import type { ActivityJournalEventInput } from '../../../shared/activityJournal.js';
import { finishRollback } from '../../services/rollback.js'; import { finishRollback } from '../../services/rollback.js';
interface ParsedSubscription { interface ParsedSubscription {
config: unknown; config: unknown;
sourceConfig?: unknown;
servers: HarborServer[]; servers: HarborServer[];
userInfo: Record<string, unknown>; userInfo: Record<string, unknown>;
fetchedAt: string; fetchedAt: string;
@@ -29,11 +33,6 @@ interface SubscriptionServiceDependencies {
read(): StoredState; read(): StoredState;
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState; update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
}; };
cache: {
read(): unknown;
write(value: unknown): void;
remove(): void;
};
config: { config: {
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown; build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
read(): string | null; read(): string | null;
@@ -57,216 +56,526 @@ interface SubscriptionServiceDependencies {
clearInterval(handle: TimerHandle): void; clearInterval(handle: TimerHandle): void;
}; };
onRefreshError(error: unknown): void; onRefreshError(error: unknown): void;
} onEvent?: (event: ActivityJournalEventInput) => void;
failover?: {
interface ResetOptions { reconcile(): Promise<unknown>;
stopRuntime?: boolean; restoreAppliedActivation(state: StoredState): Promise<unknown>;
expectedSubscription?: {
url: string;
generation: number;
}; };
now?: () => Date;
} }
export interface SubscriptionMutationResult extends Record<string, unknown> { export interface ProfileMutationResult extends Record<string, unknown> {
success: true; success: true;
servers: HarborServer[]; profileId: string;
userInfo: Record<string, unknown>; label: string;
fetchedAt: string;
selectedServerId: string;
selectedTag: string;
} }
const TERMINAL_SUBSCRIPTION_CODES = new Set([ const safeErrorCode = (error: unknown) => (
'SUBSCRIPTION_EXPIRED', error && typeof error === 'object' && 'code' in error
'SUBSCRIPTION_DISABLED', ? String(error.code)
'SUBSCRIPTION_REJECTED', : '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) { export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
let refreshPromise: Promise<SubscriptionMutationResult> | null = null; const refreshPromises = new Map<string, Promise<ProfileMutationResult>>();
let refreshTimer: TimerHandle | null = null; let refreshTimer: TimerHandle | null = null;
let subscriptionGeneration = 0; const now = dependencies.now || (() => new Date());
const restoreCache = (previous: unknown) => {
if (previous !== null) dependencies.cache.write(previous);
else dependencies.cache.remove();
};
const restoreConfig = (previous: string | null) => { const restoreConfig = (previous: string | null) => {
if (previous === null) dependencies.config.remove(); if (previous === null) dependencies.config.remove();
else dependencies.config.restore(previous); 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, subscriptionUrl: string,
parsed: ParsedSubscription, parsed: ParsedSubscription,
{ resetSelection = false, expectedGeneration }: { origin: 'manual' | 'scheduled',
resetSelection?: boolean;
expectedGeneration?: number;
} = {},
) => dependencies.serialize(async () => { ) => 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(); const previousState = dependencies.state.read();
if ( const previousProfile = requireProfile(previousState, profileId);
subscriptionGeneration !== expectedGeneration || if (previousProfile.subscriptionUrl !== subscriptionUrl) throw new HarborError('STATE_CONFLICT');
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
) {
throw new HarborError('STATE_CONFLICT');
}
const selectedServerId = resetSelection const desiredServerId = dependencies.provider.selectRefreshedServer(
? '' previousProfile.desiredServerId,
: dependencies.provider.selectRefreshedServer( previousProfile.servers,
previousState.selectedServerId, parsed.servers,
previousState.servers, );
parsed.servers, const refreshedProfile: StoredProfile = {
); ...previousProfile,
const candidateConfig = selectedServerId subscriptionConfig: parsed.config,
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = dependencies.cache.read();
const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = await dependencies.runtime.isRunning();
let restoreRuntime = false;
let stateCommitStarted = false;
try {
if ((resetSelection || !candidateConfig) && wasRunning) {
restoreRuntime = true;
await dependencies.runtime.stop();
}
if (candidateConfig) dependencies.config.write(candidateConfig);
else dependencies.config.remove();
dependencies.cache.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) {
restoreRuntime = true;
await dependencies.runtime.start();
}
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update((state) => ({
...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
}
return {
success: true as const,
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt, fetchedAt: parsed.fetchedAt,
selectedServerId, desiredServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '', 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 refreshProfile = (
const expectedGeneration = subscriptionGeneration; profileIdValue: unknown,
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl); expectedRevision?: unknown,
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration }); 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 = ({ const resetSavedSubscription = ({
stopRuntime = true, stopRuntime = true,
expectedSubscription, expectedRevision,
}: ResetOptions = {}) => ( }: { stopRuntime?: boolean; expectedRevision?: unknown } = {}) => {
dependencies.serialize(async () => { const state = dependencies.state.read();
const previousState = dependencies.state.read(); if (!state.profiles.length) return Promise.resolve(false);
if (expectedSubscription && ( if (state.profiles.length !== 1) throw new HarborError('STATE_CONFLICT');
previousState.subscriptionUrl !== expectedSubscription.url || return deleteProfile(
subscriptionGeneration !== expectedSubscription.generation state.profiles[0].id,
)) return false; stopRuntime ? 'stop-and-delete' : 'delete',
const previousCache = dependencies.cache.read(); expectedRevision,
const previousConfig = dependencies.config.read(); ).then(() => true);
const previousGatewayAuto = dependencies.gatewayAuto.read();
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
let restoreRuntime = false;
let stateCommitStarted = false;
try {
if (stopRuntime) {
restoreRuntime = wasRunning;
await dependencies.runtime.stop();
}
dependencies.config.remove();
dependencies.cache.remove();
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true;
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
subscriptionGeneration += 1;
} catch (error) {
await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
{ run: () => restoreCache(previousCache) },
{ run: () => restoreConfig(previousConfig) },
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
], 'Subscription rollback failed');
}
return true;
})
);
const refreshSavedSubscription = () => {
if (refreshPromise) return refreshPromise;
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
const expectedGeneration = subscriptionGeneration;
const operation = (async () => {
try {
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error
? String(error.code)
: '';
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
const reset = await resetSavedSubscription({
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
});
if (!reset) throw new HarborError('STATE_CONFLICT');
}
throw error;
}
})().finally(() => {
refreshPromise = null;
});
refreshPromise = operation;
return operation;
}; };
const startAutoRefresh = (intervalMs: number) => { const startAutoRefresh = (intervalMs: number) => {
if (refreshTimer) return; if (refreshTimer) return;
refreshTimer = dependencies.scheduler.setInterval(() => { refreshTimer = dependencies.scheduler.setInterval(() => {
if (!dependencies.state.read().subscriptionUrl) return; void (async () => {
void refreshSavedSubscription().catch(dependencies.onRefreshError); for (const { id } of dependencies.state.read().profiles) {
try {
await refreshProfile(id, undefined, 'scheduled');
} catch (error) {
dependencies.onRefreshError(error);
}
}
})();
}, intervalMs); }, intervalMs);
refreshTimer.unref(); refreshTimer.unref();
}; };
@@ -278,6 +587,13 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
}; };
return { return {
preflightAddProfile,
preflightRenameProfile,
addProfile,
renameProfile,
selectProfileServer,
refreshProfile,
deleteProfile,
importSubscription, importSubscription,
refreshSavedSubscription, refreshSavedSubscription,
resetSavedSubscription, resetSavedSubscription,
+163
View File
@@ -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 };
}
}
+11 -1
View File
@@ -1,8 +1,10 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const options = { encoding: 'utf8' as const }; 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) { 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 rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0; const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
@@ -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); if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
return; 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( const result = run(
'iptables', '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'; import { sendJson } from '../response.js';
interface ConnectivityDiagnosticsRouteDependencies { interface ConnectivityDiagnosticsRouteDependencies {
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>; diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>; readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
sendState(res: ServerResponse): Promise<void>;
} }
export function createConnectivityDiagnosticsRoute( export function createConnectivityDiagnosticsRoute(
@@ -13,11 +14,19 @@ export function createConnectivityDiagnosticsRoute(
) { ) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false; if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
const { services = [], target = null } = await dependencies.readBody(req); const { target = null } = await dependencies.readBody(req);
const result = await dependencies.diagnostics.run(services, target); const result = await dependencies.diagnostics.run(target);
sendJson(res, 200, result); sendJson(res, 200, result);
return true; 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; snapshot(): unknown;
refresh(): Promise<unknown>; refresh(): Promise<unknown>;
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): 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>; 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_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/; 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) { export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
return { return {
@@ -39,6 +44,44 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep
return true; 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); const deviceMatch = pathname.match(DEVICE_PATH);
if (deviceMatch) { if (deviceMatch) {
if (!dependencies.deviceInventory || req.method !== 'PUT') { if (!dependencies.deviceInventory || req.method !== 'PUT') {
+49
View File
@@ -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;
},
};
}
+3 -3
View File
@@ -11,9 +11,9 @@ interface RouteRulesRouteDependencies {
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) { export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false; if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false;
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req); const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req);
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision); await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion);
await dependencies.sendState(res); await dependencies.sendState(res);
return true; return true;
}, },
+19 -4
View File
@@ -5,18 +5,33 @@ import type { ConnectionService } from '../../features/connection/index.js';
interface ServerApplyRouteDependencies { interface ServerApplyRouteDependencies {
connection: Pick<ConnectionService, 'apply'>; connection: Pick<ConnectionService, 'apply'>;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>; readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>; withOperation<T>(
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>; 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) { export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'POST' || req.url !== '/api/apply') return false; 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( const result = await dependencies.withOperation(
'apply-server', 'apply-server',
() => dependencies.connection.apply(serverId, selectedTag), (operationRevision) => dependencies.connection.apply(
profileId,
serverId,
selectedTag,
operationRevision,
),
{ expectedRevision, profileId, serverId },
); );
await dependencies.sendState(res, result); await dependencies.sendState(res, result);
return true; return true;
+10 -4
View File
@@ -5,16 +5,22 @@ import type { ServerHealthService } from '../../features/servers/index.js';
interface ServerHealthRouteDependencies { interface ServerHealthRouteDependencies {
serverHealth: ServerHealthService; serverHealth: ServerHealthService;
readBody(req: IncomingMessage): Promise<Record<string, unknown>>; 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) { export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { 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 { serverIds = [] } = await dependencies.readBody(req);
const results = await dependencies.serverHealth.check(serverIds); const profileId = profileMatch ? decodeURIComponent(profileMatch[1]) : '';
await dependencies.sendState(res, { results }); const results = await dependencies.serverHealth.check(profileId, serverIds);
await dependencies.sendState(res, { profileId, results });
return true; return true;
}, },
}; };
+1 -1
View File
@@ -46,7 +46,7 @@ function withStateV0Compatibility(
singboxRunning: snapshot.connection.process === 'running', singboxRunning: snapshot.connection.process === 'running',
singboxStartedAt: snapshot.connection.startedAt, singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host, subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready', hasSubscription: snapshot.subscription.status !== 'missing',
selectedTag: stored.selectedTag, selectedTag: stored.selectedTag,
userInfo: snapshot.subscription.userInfo, userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt, fetchedAt: snapshot.subscription.fetchedAt,
@@ -1,43 +1,170 @@
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ConnectionService } from '../../features/connection/index.js';
import type { SubscriptionService } from '../../features/subscription/index.js'; import type { SubscriptionService } from '../../features/subscription/index.js';
interface OperationOptions {
expectedRevision?: unknown;
profileId?: unknown;
serverId?: unknown;
}
interface SubscriptionMutationRouteDependencies { interface SubscriptionMutationRouteDependencies {
subscriptionService: Pick< subscriptionService: Pick<
SubscriptionService, 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>>; 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>; sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
} }
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) { export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
return { return {
async handle(req: IncomingMessage, res: ServerResponse) { 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 { url = '' } = await dependencies.readBody(req);
const result = await dependencies.withOperation( const result = await dependencies.withOperation(
'subscription-import', 'subscription-import',
() => dependencies.subscriptionService.importSubscription(String(url).trim()), (operationRevision) => dependencies.subscriptionService.importSubscription(
String(url).trim(),
operationRevision,
),
); );
await dependencies.sendState(res, result); await dependencies.sendState(res, result);
return true; return true;
} }
if (req.method === 'POST' && req.url === '/api/subscription/refresh') { if (req.method === 'POST' && pathname === '/api/subscription/refresh') {
const { success: _success, ...result } = await dependencies.withOperation( const result = await dependencies.withOperation(
'subscription-refresh', 'subscription-refresh',
() => dependencies.subscriptionService.refreshSavedSubscription(), (operationRevision) => dependencies.subscriptionService.refreshSavedSubscription(
operationRevision,
),
); );
await dependencies.sendState(res, result); await dependencies.sendState(res, result);
return true; return true;
} }
if (req.method === 'DELETE' && req.url === '/api/subscription') { if (req.method === 'DELETE' && pathname === '/api/subscription') {
await dependencies.withOperation( await dependencies.withOperation(
'subscription-forget', 'subscription-forget',
() => dependencies.subscriptionService.resetSavedSubscription(), (operationRevision) => dependencies.subscriptionService.resetSavedSubscription({
expectedRevision: operationRevision,
}),
); );
await dependencies.sendState(res); await dependencies.sendState(res);
return true; return true;
+533 -73
View File
@@ -16,7 +16,10 @@ import {
import { createSingboxRuntime } from './singboxRuntime.js'; import { createSingboxRuntime } from './singboxRuntime.js';
import { tcpPing } from './ping.js'; import { tcpPing } from './ping.js';
import { import {
buildDualChannelGatewayConfig,
buildGatewayConfig, buildGatewayConfig,
dualChannelConfigMatchesApplied,
fingerprintSelectedOutbound,
removeSingboxConfig, removeSingboxConfig,
restoreSingboxConfig, restoreSingboxConfig,
writeSingboxConfig, writeSingboxConfig,
@@ -28,13 +31,19 @@ import {
selectRefreshedServer, selectRefreshedServer,
} from './subscription.js'; } from './subscription.js';
import { import {
desiredProfile,
normalizeStoredState, normalizeStoredState,
type OperationState, type OperationState,
type RouteRule, type RouteRule,
type StoredState, type StoredState,
} from '../shared/contracts/state.js'; } from '../shared/contracts/state.js';
import { serverIdentityKey } from '../shared/serverIdentity.js';
import { HarborError, normalizeHarborError } from '../shared/errors.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 { createDevicePolicyService } from './services/devicePolicyService.js';
import { import {
createDeviceInventoryService, createDeviceInventoryService,
@@ -75,6 +84,14 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js'; import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js'; import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
import { createVersionRoute } from './http/routes/versionRoute.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 MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -93,11 +110,57 @@ function errorMessage(error: unknown) {
fs.mkdirSync(settings.dataDir, { recursive: true }); 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({ const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath, filePath: settings.subscriptionCachePath,
defaultValue: null, 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>({ const deviceStore = createJsonStore<InventoryState>({
filePath: settings.deviceStatePath, filePath: settings.deviceStatePath,
defaultValue: migrateDeviceInventoryState({}), defaultValue: migrateDeviceInventoryState({}),
@@ -112,32 +175,77 @@ if (deviceStore.migration) {
if (deviceStore.recovery) { if (deviceStore.recovery) {
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`); console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
} }
let cacheRecoveryLogged = false; let initialStoredState = stateStore.read();
function readRawSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
}
function readSubscriptionCache() {
const raw = readRawSubscriptionCache();
const cached = record(raw);
return cached.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
}
const initialStoredState = stateStore.read();
if (stateStore.migration) { if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`); console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
} }
if (stateStore.recovery) { if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`); 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 remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const versionInfo = buildVersionInfo(settings.appMode); const versionInfo = buildVersionInfo(settings.appMode);
@@ -153,6 +261,16 @@ function selectRuntime() {
throw new Error('Harbor runtime is not configured'); throw new Error('Harbor runtime is not configured');
} }
const singboxRuntime = selectRuntime(); 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() { function requireRemoteRuntime() {
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured'); if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
@@ -194,6 +312,37 @@ const deviceInventory = settings.appMode === 'gateway'
const localConnectivityDiagnostics = !remoteDataplane const localConnectivityDiagnostics = !remoteDataplane
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
: null; : 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() { function requireLocalConnectivityDiagnostics() {
if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured'); if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured');
@@ -215,7 +364,7 @@ const gatewayAutoService = createGatewayAutoService({
update: updateStoredState, update: updateStoredState,
}, },
subscription: { subscription: {
readConfig: () => readSubscriptionCache()?.config || null, readConfig: (profileId) => readProfileConfig(profileId),
}, },
config: { config: {
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => ( build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
@@ -238,6 +387,7 @@ const gatewayAutoService = createGatewayAutoService({
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] }, { preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
), ),
restoreRunning: () => startSingbox(), restoreRunning: () => startSingbox(),
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
}, },
discovery: { discovery: {
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath), readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
@@ -265,6 +415,48 @@ const gatewayAutoService = createGatewayAutoService({
onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`), onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`),
onTimerError: (error) => console.warn(`[control] Gateway detection failed: ${errorMessage(error)}`), 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({ const stateService = createStateService({
appMode: settings.appMode, appMode: settings.appMode,
readStoredState: () => stateStore.read(), readStoredState: () => stateStore.read(),
@@ -272,6 +464,7 @@ const stateService = createStateService({
getGatewayAutoState: gatewayAutoService.read, getGatewayAutoState: gatewayAutoService.read,
getOperationState: () => operationState, getOperationState: () => operationState,
configExists: () => fs.existsSync(settings.configPath), configExists: () => fs.existsSync(settings.configPath),
getFailoverSnapshot: failoverService.snapshot,
}); });
const stateRoute = createStateRoute({ const stateRoute = createStateRoute({
stateService, stateService,
@@ -285,13 +478,24 @@ const gatewayAutoRoute = createGatewayAutoRoute({
withOperation, withOperation,
readStatePayload: stateRoute.readPayload, 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({ const deviceInventoryRoute = createDeviceInventoryRoute({
deviceInventory, deviceInventory,
readBody, readBody,
}); });
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory }); const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
readState: () => stateStore.read(), state: {
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
runDiagnostics: async (services, target) => remoteDataplane runDiagnostics: async (services, target) => remoteDataplane
? requireRemoteRuntime().runConnectivityDiagnostics(services, target) ? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
: requireLocalConnectivityDiagnostics().run({ : requireLocalConnectivityDiagnostics().run({
@@ -303,10 +507,11 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
diagnostics: connectivityDiagnostics, diagnostics: connectivityDiagnostics,
readBody, readBody,
sendState: (res) => stateRoute.send(res),
}); });
const gatewayPresenceRoute = createGatewayPresenceRoute({ const gatewayPresenceRoute = createGatewayPresenceRoute({
appMode: settings.appMode, appMode: settings.appMode,
readState: () => stateStore.read(), readState: () => normalizeStoredState(stateStore.read()),
getHwid, getHwid,
}); });
const sharedProxyRoute = createSharedProxyRoute({ const sharedProxyRoute = createSharedProxyRoute({
@@ -321,6 +526,10 @@ const versionRoute = createVersionRoute({
? () => requireRemoteRuntime().refresh() ? () => requireRemoteRuntime().refresh()
: null, : null,
}); });
const liveTrafficRoute = createLiveTrafficRoute({
traffic: liveTraffic,
deviceInventory: remoteDataplane ? deviceInventory : null,
});
const subscriptionValidationRoute = createSubscriptionValidationRoute({ const subscriptionValidationRoute = createSubscriptionValidationRoute({
validateSubscription: createValidateSubscription(fetchSubscription), validateSubscription: createValidateSubscription(fetchSubscription),
readBody, readBody,
@@ -332,15 +541,13 @@ const subscriptionService = createSubscriptionService({
read: () => normalizeStoredState(stateStore.read()), read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState, update: updateStoredState,
}, },
cache: {
read: readRawSubscriptionCache,
write: (value) => { subscriptionCacheStore.write(value); },
remove: () => subscriptionCacheStore.remove(),
},
config: { config: {
build: (subscriptionConfig, selectedServerId, routeRules) => ( build: (subscriptionConfig, selectedServerId, routeRules) => {
buildActiveConfig(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) read: () => fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8') ? fs.readFileSync(settings.configPath, 'utf8')
: null, : null,
@@ -364,16 +571,18 @@ const subscriptionService = createSubscriptionService({
clearInterval: (timer) => clearInterval(timer), clearInterval: (timer) => clearInterval(timer),
}, },
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`), onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
}); onEvent: appendJournal,
const subscriptionMutationRoute = createSubscriptionMutationRoute({ failover: gatewayFailover ? {
subscriptionService, reconcile: () => gatewayFailover.reconcile()
readBody, .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`)),
withOperation, restoreAppliedActivation: gatewayFailover.restoreAppliedActivation,
sendState: (res, extra) => stateRoute.send(res, extra), } : undefined,
now: () => new Date(),
}); });
const serverHealthRoute = createServerHealthRoute({ const serverHealthRoute = createServerHealthRoute({
serverHealth: createServerHealthService({ serverHealth: createServerHealthService({
readServers: () => normalizeStoredState(stateStore.read()).servers, readProfiles: () => normalizeStoredState(stateStore.read()).profiles,
readDesiredProfileId: () => normalizeStoredState(stateStore.read()).desiredProfileId,
ping: tcpPing, ping: tcpPing,
}), }),
readBody, readBody,
@@ -384,11 +593,7 @@ const connectionService = createConnectionService({
read: () => normalizeStoredState(stateStore.read()), read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState, update: updateStoredState,
}, },
subscription: {
readConfig: () => readSubscriptionCache()?.config || null,
},
config: { config: {
exists: () => fs.existsSync(settings.configPath),
build: (subscriptionConfig, selectedServerId, routeRules) => ( build: (subscriptionConfig, selectedServerId, routeRules) => (
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules) buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
), ),
@@ -399,6 +604,18 @@ const connectionService = createConnectionService({
restore: restoreSingboxConfig, restore: restoreSingboxConfig,
remove: removeSingboxConfig, 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: { runtime: {
isRunning: async () => Boolean((await singboxRuntime.refresh()).running), isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
start: () => startSingbox(), start: () => startSingbox(),
@@ -412,6 +629,13 @@ const connectionService = createConnectionService({
serialize: serializeControl, serialize: serializeControl,
now: () => new Date(), now: () => new Date(),
}); });
const subscriptionMutationRoute = createSubscriptionMutationRoute({
subscriptionService,
connection: connectionService,
readBody,
withOperation,
sendState: (res, extra) => stateRoute.send(res, extra),
});
const serverApplyRoute = createServerApplyRoute({ const serverApplyRoute = createServerApplyRoute({
connection: connectionService, connection: connectionService,
readBody, readBody,
@@ -429,12 +653,15 @@ const routeRulesService = createRouteRulesService({
update: updateStoredState, update: updateStoredState,
}, },
subscription: { subscription: {
readConfig: () => readSubscriptionCache()?.config || null, readConfig: (profileId) => readProfileConfig(profileId),
}, },
config: { config: {
build: (subscriptionConfig, selectedServerId, routeRules) => ( build: (subscriptionConfig, selectedServerId, routeRules) => {
buildActiveConfig(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) read: () => fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8') ? fs.readFileSync(settings.configPath, 'utf8')
: null, : null,
@@ -450,8 +677,15 @@ const routeRulesService = createRouteRulesService({
), ),
restoreRunning: () => startSingbox(), restoreRunning: () => startSingbox(),
}, },
route: {
isGatewayDirect: () => settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct',
},
serialize: serializeControl, serialize: serializeControl,
runOperation: (operation) => withOperation('route-rules', operation), 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({ const routeRulesRoute = createRouteRulesRoute({
routeRules: routeRulesService, routeRules: routeRulesService,
@@ -460,27 +694,64 @@ const routeRulesRoute = createRouteRulesRoute({
}); });
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) { function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
return stateStore.update((stored) => { return normalizeStoredState(stateStore.update((stored) => {
const current = normalizeStoredState(stored); const current = normalizeStoredState(stored);
const schemaVersion = stored.schemaVersion; const schemaVersion = stored.schemaVersion;
const next = normalizeStoredState({ schemaVersion, ...update(current) }); const next = normalizeStoredState({ schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1; revision = Math.max(revision, current.revision) + 1;
next.revision = revision; next.revision = revision;
return { ...next, schemaVersion }; 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 = { operationState = {
kind, kind,
status: 'running', status: 'running',
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
error: null, error: null,
profileId: profileId == null ? null : String(profileId),
serverId: serverId == null ? null : String(serverId),
}; };
updateStoredState((state) => state); let operationRevision: number;
try { try {
const result = await operation(); operationRevision = updateStoredState((state) => state).revision;
operationState = { kind: null, status: 'idle', startedAt: null, error: null }; } 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); updateStoredState((state) => state);
return result; return result;
} catch (error) { } catch (error) {
@@ -536,21 +807,165 @@ function buildActiveConfig(
selectedServerId: string, selectedServerId: string,
routeRules: RouteRule[] = stateStore.read().routeRules, 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', clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
routeRules, 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 stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply(); const startSingbox = () => singboxRuntime.apply();
function writeCurrentConfig() { function writeCurrentConfig() {
const state = stateStore.read(); const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache(); const hasAppliedTarget = Boolean(state.appliedProfileId && state.appliedServerId);
if (!state.selectedServerId || !cached?.config) return false; const profile = hasAppliedTarget
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId)); ? state.profiles.find((candidate) => candidate.id === state.appliedProfileId) || null
return true; : 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) { 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 connectionRuntimeRoute.handle(req, res)) return;
if (await routeRulesRoute.handle(req, res)) return; if (await routeRulesRoute.handle(req, res)) return;
if (await gatewayAutoRoute.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 connectivityDiagnosticsRoute.handle(req, res)) return;
if (await versionRoute.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 sharedProxyRoute.handle(req, res)) return;
if (await deviceInventoryRoute.handle(req, res)) return; if (await deviceInventoryRoute.handle(req, res)) return;
@@ -614,6 +1033,8 @@ async function shutdown() {
subscriptionService.stopAutoRefresh(); subscriptionService.stopAutoRefresh();
gatewayAutoService.stopDiscovery(); gatewayAutoService.stopDiscovery();
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer); 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()); await serializeControl(() => singboxRuntime.shutdown());
process.exit(0); process.exit(0);
} }
@@ -623,29 +1044,68 @@ process.on('SIGINT', shutdown);
await gatewayAutoService.refresh({ reconfigure: false }) await gatewayAutoService.refresh({ reconfigure: false })
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`)); .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 { try {
writeCurrentConfig(); target = writeCurrentConfig();
} catch (error) { } catch (error) {
const candidate = record(error); console.warn(`[storage] не удалось собрать сохранённую подписку: ${errorMessage(error)}`);
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
} }
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(() => { await gatewayFailover?.reconcile()
if (fs.existsSync(settings.configPath)) { .catch((error) => console.warn(`[control] failover reconcile: ${errorMessage(error)}`));
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
}
})
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
if (deviceInventory) { if (deviceInventory) {
await deviceInventory.reconcilePolicies() await deviceInventory.reconcilePolicies()
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
} }
clientLiveTraffic?.start();
server.listen(settings.port, '0.0.0.0', () => { server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
}); });
+159
View File
@@ -1,6 +1,12 @@
import type { ServerResponse } from 'node:http'; import type { ServerResponse } from 'node:http';
const COUNTER_PATTERN = /^\d+$/; 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 ?? '') const labelValue = (value: unknown) => String(value ?? '')
.replaceAll('\\', '\\\\') .replaceAll('\\', '\\\\')
@@ -23,6 +29,19 @@ function counter(value: unknown) {
return decimal; 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) { function timestamp(value: unknown) {
const milliseconds = Date.parse(String(value ?? '')); const milliseconds = Date.parse(String(value ?? ''));
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null; 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 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) : []; const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
if (domainSeries.length) { if (domainSeries.length) {
lines.push( lines.push(
@@ -143,6 +297,11 @@ export function renderPrometheusMetrics(value: unknown) {
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge', '# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
); );
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`); 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) { if (domainTraffic.overflowConnections != null) {
lines.push( 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 { import {
assessConnectivity, assessConnectivity,
CONNECTIVITY_IP_SOURCES, CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_NETWORK_SOURCE,
CONNECTIVITY_SITES, CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES, MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js'; } from '../../shared/connectivityDiagnostics.js';
@@ -43,6 +44,7 @@ interface RequestOptions {
ipv4?: boolean; ipv4?: boolean;
follow?: boolean; follow?: boolean;
resolve?: string | null; resolve?: string | null;
timeoutMs?: number;
} }
interface RequestResult { interface RequestResult {
@@ -66,6 +68,16 @@ interface IpProbeResult {
error: string | null; 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 { interface SiteProbeResult {
id: string; id: string;
label: string; label: string;
@@ -80,6 +92,7 @@ interface SiteProbeResult {
} }
type DiagnosticTarget = type DiagnosticTarget =
| { kind: 'network' }
| { kind: 'ip'; probe: IpProbe } | { kind: 'ip'; probe: IpProbe }
| { kind: 'site'; probe: SiteProbe }; | { kind: 'site'; probe: SiteProbe };
@@ -167,7 +180,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
ipv4 = false, ipv4 = false,
follow = true, follow = true,
resolve = null, resolve = null,
timeoutMs = 6_000,
}: RequestOptions = {}): Promise<RequestResult> { }: RequestOptions = {}): Promise<RequestResult> {
const boundedTimeoutMs = Math.min(30_000, Math.max(1_000, Math.round(timeoutMs)));
const args = [ const args = [
'--silent', '--silent',
'--show-error', '--show-error',
@@ -177,9 +192,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
'--proto-redir', '--proto-redir',
'=https', '=https',
'--connect-timeout', '--connect-timeout',
'3', String(Math.min(3_000, boundedTimeoutMs) / 1_000),
'--max-time', '--max-time',
'6', String(boundedTimeoutMs / 1_000),
'--user-agent', '--user-agent',
'Harbor-Diagnostics/1', 'Harbor-Diagnostics/1',
'--output', '--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) { function isPublicAddress(address: string, family: number) {
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : ''; const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES; const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
@@ -318,6 +382,8 @@ async function siteProbe(
proxyPort: number, proxyPort: number,
execute: CurlExecutor, execute: CurlExecutor,
sampleCount = 1, sampleCount = 1,
timeoutMs = 6_000,
retryFailure = true,
): Promise<SiteProbeResult> { ): Promise<SiteProbeResult> {
if (probe.validationError) return { if (probe.validationError) return {
id: probe.id, id: probe.id,
@@ -330,12 +396,12 @@ async function siteProbe(
stage: 'validation', stage: 'validation',
error: probe.validationError, error: probe.validationError,
}; };
const options = { follow: probe.follow !== false, resolve: probe.resolve }; const options = { follow: probe.follow !== false, resolve: probe.resolve, timeoutMs };
const samples = []; const samples = [];
for (let attempt = 0; attempt < sampleCount; attempt += 1) { for (let attempt = 0; attempt < sampleCount; attempt += 1) {
samples.push(await request(probe, path, proxyPort, execute, options)); samples.push(await request(probe, path, proxyPort, execute, options));
} }
if (sampleCount === 1 && samples[0] && !samples[0].ok) { if (retryFailure && sampleCount === 1 && samples[0] && !samples[0].ok) {
samples.push(await request(probe, path, proxyPort, execute, options)); samples.push(await request(probe, path, proxyPort, execute, options));
} }
const status = mostCommon(samples.map(siteStatus)) || 'unavailable'; const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
@@ -361,7 +427,8 @@ async function probePath(
execute: CurlExecutor, execute: CurlExecutor,
sites: SiteProbe[], sites: SiteProbe[],
): Promise<ConnectivityPathResult> { ): Promise<ConnectivityPathResult> {
const [ip, siteResults] = await Promise.all([ const [network, ip, siteResults] = await Promise.all([
networkProbe(path, proxyPort, execute),
publicIps(path, proxyPort, execute), publicIps(path, proxyPort, execute),
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))), Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
]); ]);
@@ -370,6 +437,7 @@ async function probePath(
internetAvailable: Boolean( internetAvailable: Boolean(
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'), ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
), ),
network,
...ip, ...ip,
sites: siteResults, sites: siteResults,
}; };
@@ -389,6 +457,7 @@ function unavailablePath(): ConnectivityPathResult {
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null { function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
if (typeof targetId !== 'string') return null; if (typeof targetId !== 'string') return null;
if (targetId === CONNECTIVITY_NETWORK_SOURCE.id) return { kind: 'network' };
if (targetId.startsWith('ip:')) { if (targetId.startsWith('ip:')) {
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3)); const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
return probe ? { kind: 'ip', probe } : null; return probe ? { kind: 'ip', probe } : null;
@@ -405,19 +474,26 @@ async function probeTarget(
path: PathKind, path: PathKind,
proxyPort: number, proxyPort: number,
execute: CurlExecutor, execute: CurlExecutor,
timeoutMs = 6_000,
sampleCount = TARGET_SAMPLE_COUNT,
retryFailure = true,
): Promise<ConnectivityPathResult> { ): Promise<ConnectivityPathResult> {
const network = target.kind === 'network'
? await networkProbe(path, proxyPort, execute, sampleCount)
: null;
const ip = target.kind === 'ip' const ip = target.kind === 'ip'
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT) ? await ipProbe(target.probe, path, proxyPort, execute, sampleCount)
: null; : null;
const site = target.kind === 'site' 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; : null;
const ipv4Sources = ip?.family === 4 ? [ip] : []; const ipv4Sources = ip?.family === 4 ? [ip] : [];
const ipv6Source = ip?.family === 6 ? ip : null; const ipv6Source = ip?.family === 6 ? ip : null;
const sites = site ? [site] : []; const sites = site ? [site] : [];
return { return {
available: true, available: true,
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')), internetAvailable: Boolean(network?.address || ip?.address || (site && site.status !== 'unavailable')),
...(network ? { network } : {}),
ipv4: { ipv4: {
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)), addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
sources: ipv4Sources, sources: ipv4Sources,
@@ -469,7 +545,25 @@ export function createConnectivityDiagnosticsService({
assessment: assessConnectivity(direct, vpn), 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 { return {
run: runOnce, run: runOnce,
runVpn,
}; };
} }
+593 -14
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { lookupService } from 'node:dns/promises';
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net'; import net from 'node:net';
import { HarborError } from '../../shared/errors.js'; import { HarborError } from '../../shared/errors.js';
@@ -63,10 +64,22 @@ interface DevicePolicyState {
byMac: Record<string, DevicePolicyEntry>; byMac: Record<string, DevicePolicyEntry>;
} }
interface DeviceTag {
id: string;
name: string;
}
interface DeviceTagState {
schemaVersion: number;
items: DeviceTag[];
byMac: Record<string, string[]>;
}
interface InventoryDevice { interface InventoryDevice {
id: string; id: string;
alias: string; alias: string;
pinned: boolean; pinned: boolean;
deprioritized: boolean;
hostname: string | null; hostname: string | null;
manufacturer: string | null; manufacturer: string | null;
mac: string; mac: string;
@@ -87,6 +100,7 @@ interface InventoryTrafficState {
baselinesByMac: Record<string, CounterBaseline>; baselinesByMac: Record<string, CounterBaseline>;
totalsByMac: Record<string, TrafficTotal>; totalsByMac: Record<string, TrafficTotal>;
rebaselineMacs: string[]; rebaselineMacs: string[];
outboundBaselinesByDeviceId: Record<string, OutboundTrafficBaseline>;
proxy: ProxyTrafficState; proxy: ProxyTrafficState;
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource }; global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
[key: string]: unknown; [key: string]: unknown;
@@ -98,6 +112,7 @@ export interface InventoryState {
lastObservedAt: string | null; lastObservedAt: string | null;
lastError: string | null; lastError: string | null;
policy: DevicePolicyState; policy: DevicePolicyState;
tags: DeviceTagState;
traffic: InventoryTrafficState; traffic: InventoryTrafficState;
devices: InventoryDevice[]; devices: InventoryDevice[];
[key: string]: unknown; [key: string]: unknown;
@@ -127,12 +142,48 @@ interface TrafficSample {
observedAt: string | null | undefined; observedAt: string | null | undefined;
gatewayBytes: string; gatewayBytes: string;
proxyBytes: string; proxyBytes: string;
uploadBytes: string;
downloadBytes: string;
} }
interface TrafficCursor { interface TrafficCursor {
signature: string; signature: string;
gateway: bigint; gateway: bigint;
proxy: 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 { interface DeviceObservation {
@@ -158,10 +209,17 @@ const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000; const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const TRAFFIC_HISTORY_LIMIT = 120; 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 COUNTER_PATTERN = /^\d+$/;
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/; const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
const 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_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']); const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных'; const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
@@ -186,6 +244,12 @@ const DEFAULT_POLICY_STATE: DevicePolicyState = {
byMac: {}, byMac: {},
}; };
const DEFAULT_TAG_STATE: DeviceTagState = {
schemaVersion: 1,
items: [],
byMac: {},
};
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = { const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
schemaVersion: 1, schemaVersion: 1,
lastObservedAt: null, lastObservedAt: null,
@@ -210,6 +274,7 @@ const DEFAULT_STATE: InventoryState = {
lastObservedAt: null, lastObservedAt: null,
lastError: null, lastError: null,
policy: DEFAULT_POLICY_STATE, policy: DEFAULT_POLICY_STATE,
tags: DEFAULT_TAG_STATE,
traffic: { traffic: {
epoch: null, epoch: null,
generation: null, generation: null,
@@ -218,6 +283,7 @@ const DEFAULT_STATE: InventoryState = {
baselinesByMac: {}, baselinesByMac: {},
totalsByMac: {}, totalsByMac: {},
rebaselineMacs: [], rebaselineMacs: [],
outboundBaselinesByDeviceId: {},
proxy: DEFAULT_PROXY_TRAFFIC, proxy: DEFAULT_PROXY_TRAFFIC,
global: { global: {
gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE, gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
@@ -238,10 +304,46 @@ const parseStoredCounter = (value: unknown) => {
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null; 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 => ( const validTimestamp = (value: unknown): value is string => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) 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 { function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
const device = record(value); const device = record(value);
const mac = normalizeMac(device.mac); const mac = normalizeMac(device.mac);
@@ -261,7 +363,8 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
: deviceId(mac), : deviceId(mac),
alias: typeof device.alias === 'string' ? device.alias : '', alias: typeof device.alias === 'string' ? device.alias : '',
pinned: device.pinned === true, 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, manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
mac, mac,
ip, 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) { export function parseOuiVendors(text: unknown) {
const vendors = new Map<string, string>(); const vendors = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) { for (const line of String(text || '').split(/\r?\n/)) {
@@ -495,6 +630,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
.map(normalizeInventoryDevice) .map(normalizeInventoryDevice)
.filter((device): device is InventoryDevice => device !== null) .filter((device): device is InventoryDevice => device !== null)
: []; : [];
const tags = normalizeTagState(state.tags, devices);
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices); const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
@@ -541,6 +677,32 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
recoveredTraffic = true; 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 = { const global = {
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, { gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null, epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
@@ -563,6 +725,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION, schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0, revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
policy: normalizePolicyState(state.policy), policy: normalizePolicyState(state.policy),
tags,
traffic: { traffic: {
...DEFAULT_STATE.traffic, ...DEFAULT_STATE.traffic,
...traffic, ...traffic,
@@ -572,6 +735,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState {
baselinesByMac, baselinesByMac,
totalsByMac, totalsByMac,
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)), rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
outboundBaselinesByDeviceId,
proxy: proxyTraffic, proxy: proxyTraffic,
global, global,
}, },
@@ -637,6 +801,7 @@ export function createDeviceInventoryService({
observePolicy = null, observePolicy = null,
applyPolicies = null, applyPolicies = null,
vendor = () => null, vendor = () => null,
resolveHostname = resolveDeviceHostname,
now = () => new Date(), now = () => new Date(),
}: { }: {
store: InventoryStore; store: InventoryStore;
@@ -646,14 +811,19 @@ export function createDeviceInventoryService({
observePolicy?: (() => unknown | Promise<unknown>) | null; observePolicy?: (() => unknown | Promise<unknown>) | null;
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null; applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
vendor?: (mac: string) => string | null; vendor?: (mac: string) => string | null;
resolveHostname?: (ip: string) => unknown | Promise<unknown>;
now?: () => Date; now?: () => Date;
}) { }) {
let refreshPromise: Promise<unknown> | null = null; let refreshPromise: Promise<unknown> | null = null;
let policyQueue: Promise<unknown> = Promise.resolve(); let policyQueue: Promise<unknown> = Promise.resolve();
const trafficHistoryByMac = new Map<string, TrafficSample[]>(); const trafficHistoryByMac = new Map<string, TrafficSample[]>();
const trafficCursorByMac = new Map<string, TrafficCursor>(); 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 globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null; let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>();
let domainTrafficSnapshot: Record<string, unknown> = { let domainTrafficSnapshot: Record<string, unknown> = {
epoch: null, epoch: null,
observedAt: null, observedAt: null,
@@ -666,6 +836,14 @@ export function createDeviceInventoryService({
}, },
series: [], series: [],
}; };
let directTrafficSnapshot: Record<string, unknown> = {
epoch: null,
observedAt: null,
source: { error: null },
uploadBytes: '0',
downloadBytes: '0',
series: [],
};
function captureTrafficHistory(state: InventoryState) { function captureTrafficHistory(state: InventoryState) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac)); 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 proxy = state.traffic.proxy.totalsByMac[device.mac];
const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`; const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`;
if (signature === '|') continue; 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 gateway = BigInt(traffic?.uploadBytes || '0') + BigInt(traffic?.downloadBytes || '0');
const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0'); const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
const previous = trafficCursorByMac.get(device.mac); 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; if (!previous || previous.signature === signature) continue;
const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1); const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1);
const samples = trafficHistoryByMac.get(device.mac) || []; const samples = trafficHistoryByMac.get(device.mac) || [];
@@ -685,6 +865,8 @@ export function createDeviceInventoryService({
observedAt, observedAt,
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0', gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).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)); }].slice(-TRAFFIC_HISTORY_LIMIT));
} }
for (const mac of trafficCursorByMac.keys()) { for (const mac of trafficCursorByMac.keys()) {
@@ -695,20 +877,120 @@ export function createDeviceInventoryService({
} }
const gatewaySource = state.traffic.global.gateway; const gatewaySource = state.traffic.global.gateway;
const proxySource = state.traffic.global.proxy; 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 gateway = BigInt(gatewaySource.uploadBytes) + BigInt(gatewaySource.downloadBytes);
const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes); const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes);
const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`; const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`;
const previous = globalTrafficCursor; const previous = globalTrafficCursor;
globalTrafficCursor = { signature, gateway, proxy }; globalTrafficCursor = { signature, gateway, proxy, upload, download };
if (previous && previous.signature !== signature) { if (previous && previous.signature !== signature) {
globalTrafficHistory = [...globalTrafficHistory, { globalTrafficHistory = [...globalTrafficHistory, {
observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1), observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1),
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0', gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxyBytes: proxy > previous.proxy ? (proxy - previous.proxy).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); }].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> { function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
const result = policyQueue.then(() => action(), () => action()); const result = policyQueue.then(() => action(), () => action());
policyQueue = result.catch(() => {}); policyQueue = result.catch(() => {});
@@ -764,6 +1046,7 @@ export function createDeviceInventoryService({
const policy = policyFor(state, device.mac); const policy = policyFor(state, device.mac);
return { return {
...device, ...device,
tagIds: state.tags.byMac[device.mac] || [],
status: deviceStatus(device.lastSeenAt, current), status: deviceStatus(device.lastSeenAt, current),
uploadBytes: traffic?.uploadBytes || '0', uploadBytes: traffic?.uploadBytes || '0',
downloadBytes: traffic?.downloadBytes || '0', downloadBytes: traffic?.downloadBytes || '0',
@@ -772,6 +1055,8 @@ export function createDeviceInventoryService({
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null, proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
trafficHistory: trafficHistoryByMac.get(device.mac) || [], trafficHistory: trafficHistoryByMac.get(device.mac) || [],
outboundTraffic: outboundTrafficByDeviceId.get(device.id) || null,
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
desiredPolicy: policy.desired, desiredPolicy: policy.desired,
appliedPolicy: policy.applied, appliedPolicy: policy.applied,
policyStatus: policy.status, policyStatus: policy.status,
@@ -780,6 +1065,7 @@ export function createDeviceInventoryService({
}; };
}).sort((left, right) => ( }).sort((left, right) => (
Number(right.pinned) - Number(left.pinned) Number(right.pinned) - Number(left.pinned)
|| Number(left.deprioritized) - Number(right.deprioritized)
|| rank[left.status] - rank[right.status] || rank[left.status] - rank[right.status]
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)) || String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
)); ));
@@ -796,10 +1082,13 @@ export function createDeviceInventoryService({
: [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean); : [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
return { return {
revision: state.revision, revision: state.revision,
tags: state.tags.items,
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT, trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
traffic: { traffic: {
gatewayBytes: gatewayBytes.toString(), gatewayBytes: gatewayBytes.toString(),
proxyBytes: proxyBytes.toString(), proxyBytes: proxyBytes.toString(),
uploadBytes: (BigInt(gatewayTraffic.uploadBytes) + BigInt(proxyTraffic.uploadBytes)).toString(),
downloadBytes: (BigInt(gatewayTraffic.downloadBytes) + BigInt(proxyTraffic.downloadBytes)).toString(),
totalBytes: (gatewayBytes + proxyBytes).toString(), totalBytes: (gatewayBytes + proxyBytes).toString(),
gatewayObservedAt: gatewayTraffic.lastObservedAt, gatewayObservedAt: gatewayTraffic.lastObservedAt,
proxyObservedAt: proxyTraffic.lastObservedAt, proxyObservedAt: proxyTraffic.lastObservedAt,
@@ -828,7 +1117,7 @@ export function createDeviceInventoryService({
} }
function metricsSnapshot() { function metricsSnapshot() {
return { ...snapshot(), domainTraffic: domainTrafficSnapshot }; return { ...snapshot(), domainTraffic: domainTrafficSnapshot, directTraffic: directTrafficSnapshot };
} }
function markPolicyEpoch(observed: unknown) { function markPolicyEpoch(observed: unknown) {
@@ -978,6 +1267,34 @@ export function createDeviceInventoryService({
identities.add(`${String(observation.ip)}|${observation.interface || ''}`); identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
identitiesByMac.set(mac, identities); 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 () => { return serializePolicy(async () => {
if (typeof domainTrafficResult?.transportError === 'string') { if (typeof domainTrafficResult?.transportError === 'string') {
domainTrafficSnapshot = { domainTrafficSnapshot = {
@@ -987,26 +1304,94 @@ export function createDeviceInventoryService({
} else if (domainTrafficResult) { } else if (domainTrafficResult) {
domainTrafficSnapshot = record(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 nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored); const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device])); const byMac = new Map(state.devices.map((device) => [device.mac, device]));
let policyByMac = state.policy.byMac;
for (const observation of observations) { for (const observation of observations) {
const mac = normalizeMac(observation.mac); const mac = normalizeMac(observation.mac);
if (!mac) continue; if (!mac) continue;
const previous = byMac.get(mac); const previous = byMac.get(mac);
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 observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
const lastSeenAt = observation.active || !previous const lastSeenAt = observation.active || !previous
? observationTime ? observationTime
: previous.lastSeenAt; : previous.lastSeenAt;
const replaceAddress = observation.active || !previous;
byMac.set(mac, { byMac.set(mac, {
id: previous?.id || deviceId(mac), id: previous?.id || deviceId(mac),
alias: previous?.alias || '', alias: previous?.alias || '',
pinned: previous?.pinned === true, 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), manufacturer: previous?.manufacturer || vendor(mac),
mac, mac,
ip: String(observation.ip || previous?.ip || ''), ip: replaceAddress ? observation.ip : previous.ip,
interface: String(observation.interface || previous?.interface || ''), interface: replaceAddress ? observation.interface : previous.interface,
firstSeenAt: previous?.firstSeenAt || observationTime, firstSeenAt: previous?.firstSeenAt || observationTime,
lastSeenAt, lastSeenAt,
source: 'neighbor', source: 'neighbor',
@@ -1016,8 +1401,9 @@ export function createDeviceInventoryService({
}); });
} }
const cutoff = new Date(observedAt).getTime() - RETENTION_MS; const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const sourceUnavailable = typeof result.error === 'string';
const devices = [...byMac.values()].filter((device) => ( 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; let traffic = state.traffic;
if (trafficResult) { if (trafficResult) {
@@ -1229,11 +1615,15 @@ export function createDeviceInventoryService({
revision: state.revision + 1, revision: state.revision + 1,
lastObservedAt: observedAt, lastObservedAt: observedAt,
lastError: typeof result.error === 'string' ? result.error : null, lastError: typeof result.error === 'string' ? result.error : null,
policy: policyByMac === state.policy.byMac
? state.policy
: { ...state.policy, lastError: null, byMac: policyByMac },
traffic, traffic,
devices, devices,
}; };
}); });
captureTrafficHistory(nextState); captureTrafficHistory(nextState);
captureOutboundTrafficHistory(nextState);
if (typeof policyResult?.transportError === 'string') { if (typeof policyResult?.transportError === 'string') {
commitPolicyFailure(new Error(policyResult.transportError)); commitPolicyFailure(new Error(policyResult.transportError));
} }
@@ -1257,31 +1647,209 @@ export function createDeviceInventoryService({
const value = record(patch); const value = record(patch);
const aliasProvided = Object.hasOwn(value, 'alias'); const aliasProvided = Object.hasOwn(value, 'alias');
const pinProvided = Object.hasOwn(value, 'pinned'); 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 if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|| (!aliasProvided && !pinProvided) || (!aliasProvided && !pinProvided && !deprioritizedProvided && !tagIdsProvided)
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64)) || (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'); throw new HarborError('REQUEST_INVALID');
} }
const revision = expectedRevision; const revision = expectedRevision;
const alias = typeof value.alias === 'string' ? value.alias : ''; const alias = typeof value.alias === 'string' ? value.alias : '';
const pinned = value.pinned === true; const pinned = value.pinned === true;
const deprioritized = value.deprioritized === true;
store.update((stored) => { store.update((stored) => {
const state = migrateDeviceInventoryState(stored); const state = migrateDeviceInventoryState(stored);
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT'); if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id); const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND'); if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const 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]; const devices = [...state.devices];
devices[index] = { devices[index] = {
...devices[index], ...devices[index],
...(aliasProvided ? { alias: alias.trim() } : {}), ...(aliasProvided ? { alias: nextAlias } : {}),
...(pinProvided ? { pinned } : {}), ...(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(); 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) { function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) { || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
@@ -1325,5 +1893,16 @@ export function createDeviceInventoryService({
return serializePolicy(() => reconcileLocked(observed, true)); return serializePolicy(() => reconcileLocked(observed, true));
} }
return { snapshot, metricsSnapshot, refresh, update, setPolicy, reconcilePolicies }; return {
snapshot,
metricsSnapshot,
refresh,
update,
createTag,
renameTag,
deleteTag,
resetTraffic,
setPolicy,
reconcilePolicies,
};
} }
+139 -19
View File
@@ -24,9 +24,9 @@ interface TrafficDevice {
key: string; key: string;
} }
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download'; type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download' | 'direct-upload' | 'direct-download';
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload'; type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload' | 'directUpload' | 'directDownload';
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes'; type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes' | 'directUploadBytes' | 'directDownloadBytes';
type CounterValues = Record<CounterField, bigint>; type CounterValues = Record<CounterField, bigint>;
interface RetiredCounters { interface RetiredCounters {
@@ -40,6 +40,7 @@ interface TrafficSnapshot {
generation: string; generation: string;
observedAt: string | null; observedAt: string | null;
source: { error: string | null }; source: { error: string | null };
direct: { uploadBytes: string; downloadBytes: string };
devices: Record<string, unknown>[]; devices: Record<string, unknown>[];
} }
@@ -53,6 +54,8 @@ const COUNTERS = [
['download', 'download', 'downloadBytes'], ['download', 'download', 'downloadBytes'],
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'], ['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'], ['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
['direct-upload', 'directUpload', 'directUploadBytes'],
['direct-download', 'directDownload', 'directDownloadBytes'],
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[]; ] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
const childChain = (chain: string, slot: string) => `${chain}_${slot}`; const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
@@ -117,7 +120,37 @@ function isIpv4Cidr(value: unknown) {
&& Number.isInteger(size) && size >= 0 && size <= 32; && 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> { function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
@@ -160,6 +193,10 @@ export function buildTrafficRestore({
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot, slot,
proxyPort, proxyPort,
}: { }: {
@@ -167,17 +204,28 @@ export function buildTrafficRestore({
bypassCidrs: readonly string[]; bypassCidrs: readonly string[];
uploadChain: string; uploadChain: string;
downloadChain: string; downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: readonly string[];
slot: string; slot: string;
proxyPort: number; 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) || !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|| proxyPort < 1 || proxyPort > 65_535 || 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'); throw new Error('Некорректная конфигурация traffic accounting');
} }
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const proxyUploadChild = proxyChildChain(uploadChain, slot); const proxyUploadChild = proxyChildChain(uploadChain, slot);
const proxyDownloadChild = proxyChildChain(downloadChain, slot); const proxyDownloadChild = proxyChildChain(downloadChain, slot);
const raw = [ const raw = [
@@ -192,8 +240,12 @@ export function buildTrafficRestore({
]; ];
const mangle = [ const mangle = [
'*mangle', '*mangle',
`-F ${directChild}`,
`-F ${downloadChild}`, `-F ${downloadChild}`,
`-F ${proxyDownloadChild}`, `-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 ${proxyDownloadChild}`,
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`, `-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}`, `-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) { for (const cidr of bypassCidrs) {
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`); raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${directChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${downloadChild} -s ${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) { 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`); 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`); 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> { export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linePattern = new RegExp( const linePattern = new RegExp(
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`, `^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}|global):(upload|download|proxy-upload|proxy-download|direct-upload|direct-download)"?`,
); );
const counters = new Map<string, string>(); const counters = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) { for (const line of String(text || '').split(/\r?\n/)) {
@@ -236,6 +295,10 @@ export function createDeviceTrafficService({
observe, observe,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
bypassCidrs, bypassCidrs,
proxyPort, proxyPort,
run = runCommand, run = runCommand,
@@ -244,6 +307,10 @@ export function createDeviceTrafficService({
observe: () => Promise<unknown> | unknown; observe: () => Promise<unknown> | unknown;
uploadChain: string; uploadChain: string;
downloadChain: string; downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: string[];
bypassCidrs: string[]; bypassCidrs: string[];
proxyPort: number; proxyPort: number;
run?: RunCommand; run?: RunCommand;
@@ -257,12 +324,14 @@ export function createDeviceTrafficService({
let pendingRetired: RetiredCounters | null = null; let pendingRetired: RetiredCounters | null = null;
let refreshPromise: Promise<TrafficSnapshot> | null = null; let refreshPromise: Promise<TrafficSnapshot> | null = null;
const finalized = new Map<string, CounterValues>(); const finalized = new Map<string, CounterValues>();
const finalizedDirect = { upload: 0n, download: 0n };
const devicesByKey = new Map<string, TrafficDevice>(); const devicesByKey = new Map<string, TrafficDevice>();
let current: TrafficSnapshot = { let current: TrafficSnapshot = {
epoch, epoch,
generation: epoch, generation: epoch,
observedAt: null, observedAt: null,
source: { error: null }, source: { error: null },
direct: { uploadBytes: '0', downloadBytes: '0' },
devices: [], devices: [],
}; };
@@ -278,6 +347,10 @@ export function createDeviceTrafficService({
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot, slot,
proxyPort, proxyPort,
}); });
@@ -287,22 +360,37 @@ export function createDeviceTrafficService({
async function switchTo(slot: 'A' | 'B') { async function switchTo(slot: 'A' | 'B') {
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const replace = activeSlot ? '-R' : '-A'; const replace = activeSlot ? '-R' : '-A';
const uploadArgs = activeSlot const uploadArgs = activeSlot
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild] ? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild]; : ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
const downloadArgs = activeSlot const mangleInput = [
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild] '*mangle',
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild]; 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); await execute('iptables', uploadArgs);
try { try {
await execute('iptables', downloadArgs); await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input: mangleInput });
} catch (error) { } catch (error) {
const rollbackArgs = activeSlot const uploadRollback = activeSlot
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)] ? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
: ['-w', '1', '-t', 'raw', '-F', uploadChain]; : ['-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; throw error;
} }
} }
@@ -318,6 +406,8 @@ export function createDeviceTrafficService({
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)), download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)), proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, 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>(); const counters = new Map<string, string>();
for (const { key } of devices) { for (const { key } of devices) {
@@ -325,6 +415,8 @@ export function createDeviceTrafficService({
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0'); 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; return counters;
} }
@@ -345,6 +437,8 @@ export function createDeviceTrafficService({
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind); for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
finalized.set(key, next); finalized.set(key, next);
} }
finalizedDirect.upload += counter(counters, 'global', 'direct-upload');
finalizedDirect.download += counter(counters, 'global', 'direct-download');
pendingRetired = null; pendingRetired = null;
return true; return true;
} }
@@ -368,18 +462,41 @@ export function createDeviceTrafficService({
} }
return [...totalsByMac.values()] return [...totalsByMac.values()]
.map((total) => { .map((total) => {
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total; const {
key: _key,
upload,
download,
proxyUpload,
proxyDownload,
directUpload,
directDownload,
...device
} = total;
return { return {
...device, ...device,
uploadBytes: upload.toString(), uploadBytes: upload.toString(),
downloadBytes: download.toString(), downloadBytes: download.toString(),
proxyUploadBytes: proxyUpload.toString(), proxyUploadBytes: proxyUpload.toString(),
proxyDownloadBytes: proxyDownload.toString(), proxyDownloadBytes: proxyDownload.toString(),
directUploadBytes: directUpload.toString(),
directDownloadBytes: directDownload.toString(),
}; };
}) })
.sort((left, right) => left.mac.localeCompare(right.mac)); .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() { async function performRefresh() {
let observed: Record<string, unknown>; let observed: Record<string, unknown>;
try { try {
@@ -426,11 +543,13 @@ export function createDeviceTrafficService({
} }
} }
try { if (activeSlot) {
activeCounters = await readCounters(activeDevices, activeSlot); try {
countersRead = true; activeCounters = await readCounters(activeDevices, activeSlot);
} catch (error) { countersRead = true;
sourceError = sourceError || (error instanceof Error ? error.message : String(error)); } catch (error) {
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
}
} }
current = { current = {
epoch, epoch,
@@ -439,6 +558,7 @@ export function createDeviceTrafficService({
? observed.observedAt ? observed.observedAt
: current.observedAt, : current.observedAt,
source: { error: sourceError }, source: { error: sourceError },
direct: countersRead ? directTotals() : current.direct,
devices: countersRead ? processTotals() : current.devices, devices: countersRead ? processTotals() : current.devices,
}; };
return structuredClone(current); return structuredClone(current);
+382 -64
View File
@@ -3,12 +3,14 @@ import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import { domainToASCII } from 'node:url'; import { domainToASCII } from 'node:url';
import { deviceId } from './deviceInventoryService.js'; import { deviceId } from './deviceInventoryService.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_SERIES = 4096; const DEFAULT_MAX_SERIES = 4096;
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' }; const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const; const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number]; type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
type TrafficRoute = 'vpn' | 'direct' | 'unknown';
const SERVICE_DOMAINS = [ const SERVICE_DOMAINS = [
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']], ['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']], ['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
@@ -16,26 +18,36 @@ const SERVICE_DOMAINS = [
interface ParsedBaseConnection { interface ParsedBaseConnection {
id: string; id: string;
startedAt?: string;
upload: bigint; upload: bigint;
download: bigint; download: bigint;
} }
type ParsedConnection = type ParsedConnection =
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' }) | (ParsedBaseConnection & { outcome: 'unsupported_source' })
| (ParsedBaseConnection & {
outcome: 'unknown_device';
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
})
| (ParsedBaseConnection & { | (ParsedBaseConnection & {
outcome: 'classified' | 'unresolved_host'; outcome: 'classified' | 'unresolved_host';
deviceId: string; deviceId: string;
domain: string; domain: string;
service: string; service: string;
source: 'gateway' | 'proxy'; source: 'gateway' | 'proxy';
outbound: TrafficRoute;
}); });
interface PreviousConnection { interface PreviousConnection {
startedAt?: string;
outcome: AttributionOutcome | 'classified'; outcome: AttributionOutcome | 'classified';
key?: string; key?: string;
requestedKey?: string; requestedKey?: string;
countedUpload: bigint | null; countedUpload: bigint | null;
countedDownload: bigint | null; countedDownload: bigint | null;
trackedUpload: bigint | null;
trackedDownload: bigint | null;
} }
interface DomainSeriesTotal { interface DomainSeriesTotal {
@@ -47,24 +59,61 @@ interface DomainSeriesTotal {
downloadBytes: bigint; downloadBytes: bigint;
} }
interface RouteSeriesTotal {
deviceId?: string;
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
uploadBytes: bigint;
downloadBytes: bigint;
}
interface DomainTrafficSnapshot { interface DomainTrafficSnapshot {
epoch: string; epoch: string;
observedAt: string | null; observedAt: string | null;
source: { error: string | null }; source: { error: string | null; activeConnections: number };
overflowConnections: string; overflowConnections: string;
attributionEvents: Record<AttributionOutcome, 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'> & { series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string; uploadBytes: string;
downloadBytes: 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> { function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> ? 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}`); const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
export function classifyDomain(value: unknown): { domain: string; service: string } | null { export function classifyDomain(value: unknown): { domain: string; service: string } | null {
@@ -86,6 +135,12 @@ function sourceFor(type: string): 'gateway' | 'proxy' | null {
return 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 { function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
const connection = record(value); const connection = record(value);
const id = String(connection.id || ''); const id = String(connection.id || '');
@@ -103,8 +158,9 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
}; };
const source = sourceFor(String(metadata.type || '')); const source = sourceFor(String(metadata.type || ''));
if (!source) return { ...parsed, outcome: 'unsupported_source' }; if (!source) return { ...parsed, outcome: 'unsupported_source' };
const outbound = routeFor(connection.chains);
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || '')); const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' }; if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
const classifiedDomain = classifyDomain(metadata.host); const classifiedDomain = classifyDomain(metadata.host);
const domain = classifiedDomain || UNKNOWN_DOMAIN; const domain = classifiedDomain || UNKNOWN_DOMAIN;
return { return {
@@ -113,6 +169,47 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
deviceId: currentDeviceId, deviceId: currentDeviceId,
...domain, ...domain,
source, 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'); if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
const epoch = crypto.randomUUID(); const epoch = crypto.randomUUID();
const totals = new Map<string, DomainSeriesTotal>(); const totals = new Map<string, DomainSeriesTotal>();
const routeTotals = new Map<string, RouteSeriesTotal>();
const trackedTotals = new Map<string, RouteSeriesTotal>();
const normalSeriesLimit = maxSeries - 2; const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0; let normalSeries = 0;
let previousConnections = new Map<string, PreviousConnection>(); let previousConnections = new Map<string, PreviousConnection>();
const settledNativeConnections = new Map<string, PreviousConnection>();
let nativeEpoch: string | null = null;
let activeConnections = 0;
let overflowConnections = 0n; let overflowConnections = 0n;
const attributionEvents: Record<AttributionOutcome, bigint> = { const attributionEvents: Record<AttributionOutcome, bigint> = {
unresolved_host: 0n, unresolved_host: 0n,
@@ -174,12 +276,18 @@ export function createDomainTrafficService({
unsupported_source: 0n, unsupported_source: 0n,
}; };
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null; let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
let activityEnabled = false;
let activityStartedAt = 0;
let activitySamples: ActivitySample[] = [];
let quietSince: string | null = null;
let current: DomainTrafficSnapshot = { let current: DomainTrafficSnapshot = {
epoch, epoch,
observedAt: null, observedAt: null,
source: { error: null }, source: { error: null, activeConnections: 0 },
overflowConnections: '0', overflowConnections: '0',
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
tracked: [],
routes: [],
series: [], series: [],
}; };
@@ -187,11 +295,32 @@ export function createDomainTrafficService({
return { return {
epoch, epoch,
observedAt: current.observedAt, observedAt: current.observedAt,
source: { error }, source: { error, activeConnections },
overflowConnections: overflowConnections.toString(), overflowConnections: overflowConnections.toString(),
attributionEvents: Object.fromEntries( attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
) as Record<AttributionOutcome, string>, ) 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()] series: [...totals.values()]
.map((entry) => ({ .map((entry) => ({
...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() { async function performRefresh() {
try { try {
const response = record(await observe()); const response = record(await observe());
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array'); if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
const devicesByIp = new Map<string, string | null>(); const devicesByIp = new Map<string, string | null>();
const deviceLabels = new Map<string, string>();
const observedDevices = devices(); const observedDevices = devices();
for (const value of Array.isArray(observedDevices) ? observedDevices : []) { for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
const device = record(value); const device = record(value);
@@ -219,67 +496,49 @@ export function createDomainTrafficService({
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null; const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
if (!net.isIPv4(ip) || !id) continue; if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
deviceLabels.set(id, publicDeviceLabel(device));
} }
const activeConnections = new Map<string, PreviousConnection>(); const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
for (const rawConnection of response.connections) { nativeEpoch = null;
const connection = parseConnection(rawConnection, devicesByIp); return applyParsedConnections({
const previous = previousConnections.get(connection.id); connections,
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { reset: true,
attributionEvents[connection.outcome] += 1n; 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; if (nativeEpoch !== batch.epoch) {
current = { ...current, observedAt: now().toISOString() }; nativeEpoch = batch.epoch;
current = buildSnapshot(); previousConnections = new Map();
return current; settledNativeConnections.clear();
}
const connections = batch.connections.map(parseNativeConnection);
const result = applyParsedConnections({
connections,
reset: batch.reset,
closedIds: batch.closedIds,
observed,
deviceLabels,
});
return result;
} catch (error) { } catch (error) {
current = buildSnapshot(error instanceof Error ? error.message : String(error)); current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw error; throw error;
@@ -295,5 +554,64 @@ export function createDomainTrafficService({
return refreshPromise; 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 };
} }
+777
View File
@@ -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 };
}
+96 -11
View File
@@ -1,10 +1,19 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js'; import {
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; 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 { export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void; 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) { function syncDirectory(directory: string) {
let descriptor: number | undefined; let descriptor: number | undefined;
try { try {
@@ -105,7 +131,10 @@ export function atomicWriteJson(filePath: string, value: unknown, options?: Atom
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options); 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 stored = record(value);
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0; const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) { if (version < 0 || version > STATE_SCHEMA_VERSION) {
@@ -114,10 +143,63 @@ export function migrateStoredState(value: unknown): StoredState & { schemaVersio
const routeRules = version < 3 const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])] ? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? 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 { return {
...normalizeStoredState({ ...stored, routeRules }), ...canonical,
schemaVersion: STATE_SCHEMA_VERSION, schemaVersion: STATE_SCHEMA_VERSION,
}; } as PersistedState & { schemaVersion: number };
} }
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>; export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
@@ -204,14 +286,17 @@ export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStor
export function createStateStore( export function createStateStore(
filePath: string, 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, filePath,
defaultValue: migrateStoredState({}), defaultValue: migrateStoredState({}, legacySubscriptionCache),
migrate: migrateStoredState, migrate: (value) => migrateStoredState(value, legacySubscriptionCache),
initializeMissing: true, initializeMissing: true,
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion, backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
...options, ...storeOptions,
}); });
} }
+159 -12
View File
@@ -1,13 +1,20 @@
import fs from 'node:fs'; import fs from 'node:fs';
import crypto from 'node:crypto';
import { settings } from './config.js'; import { settings } from './config.js';
import { HarborError } from '../shared/errors.js'; import { HarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js'; import { normalizeRouteRules } from '../shared/routingRules.js';
import type { AppliedFailoverPolicy } from '../shared/failover.js';
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js'; import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in'; const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in'; const TPROXY_INBOUND = 'tproxy-in';
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-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 SNIFF_TIMEOUT = '1s';
const SNIFFERS = ['http', 'tls', 'quic']; 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, { export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unknown, {
clientDirect = false, clientDirect = false,
routeRules = [], routeRules = [],
}: { clientDirect?: boolean; routeRules?: unknown } = {}) { }: { clientDirect?: boolean; routeRules?: unknown } = {}) {
const clientMode = settings.appMode === 'client'; const clientMode = settings.appMode === 'client';
const nativeTraffic = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
const directClient = clientMode && clientDirect; const directClient = clientMode && clientDirect;
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag)); const vpnOutbound = selectedOutbound(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 outboundTag = directClient ? 'direct' : vpnOutbound.tag; const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
const inbounds = [ const inbounds = [
@@ -70,9 +127,12 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
set_system_proxy: false, set_system_proxy: false,
}, },
]; ];
const directRules = normalizeRouteRules(routeRules) const userRules = (directClient ? [] : normalizeRouteRules(routeRules))
.filter((rule) => rule.enabled) .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 const rules = clientMode
? [ ? [
{ {
@@ -82,7 +142,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT, timeout: SNIFF_TIMEOUT,
}, },
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag }, { inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
...directRules, ...userRules,
{ inbound: [MIXED_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag },
] ]
: [ : [
@@ -93,25 +153,32 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT, timeout: SNIFF_TIMEOUT,
}, },
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules, ...userRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag },
]; ];
return { return {
log: { level: settings.logLevel, timestamp: true }, log: { level: settings.logLevel, timestamp: true },
...(nativeTraffic ? {
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: settings.singboxNativeApiPort,
dashboard: false,
}],
} : {}),
experimental: { experimental: {
cache_file: { enabled: true, path: settings.cachePath }, cache_file: { enabled: true, path: settings.cachePath },
...(!clientMode ? { ...(!clientMode ? {
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
} : {}), } : {}),
}, },
dns: { independent_cache: true }, dns: nativeTraffic ? {} : { independent_cache: true },
inbounds, inbounds,
outbounds: [ outbounds: [
vpnOutbound, vpnOutbound,
{ type: 'direct', tag: 'direct' }, { type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' },
], ],
route: { route: {
rule_set: [], 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) { export function writeSingboxConfig(config: unknown) {
atomicWriteJson(settings.configPath, config); atomicWriteJson(settings.configPath, config);
} }
+80 -9
View File
@@ -3,27 +3,76 @@ import fs from 'node:fs';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { setGatewayInterception } from './gatewayRouting.js'; import { setGatewayInterception } from './gatewayRouting.js';
import { HarborError } from '../shared/errors.js'; import { HarborError } from '../shared/errors.js';
import {
materializeGatewayNativeConfig,
materializeGatewaySnapshotConfig,
} from './gatewayNativeRuntime.js';
export function createSingboxRuntime({ export function createSingboxRuntime({
configPath, configPath,
gateway = false, gateway = false,
tproxyChain = '', tproxyChain = '',
gatewayRuntimeConfigPath,
nativeApi,
}: { }: {
configPath: string; configPath: string;
gateway?: boolean; gateway?: boolean;
tproxyChain?: string; tproxyChain?: string;
gatewayRuntimeConfigPath?: string;
nativeApi?: {
apiPort: number;
secretPath: string;
runtimeConfigPath: string;
};
}) { }) {
let child: ChildProcess | null = null; let child: ChildProcess | null = null;
let configHash = ''; let configHash = '';
let startedAt: string | null = null; 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() { async function stop() {
if (gateway) setGatewayInterception(false, tproxyChain); if (gateway) setGatewayInterception(false, tproxyChain);
if (!child) { if (!child) {
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
return state(); return state();
} }
@@ -31,6 +80,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
current.kill('SIGKILL'); current.kill('SIGKILL');
@@ -48,23 +98,37 @@ export function createSingboxRuntime({
async function apply({ force = false } = {}) { async function apply({ force = false } = {}) {
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
await stop(); await stop();
nativeApiWarning = null;
return state(); return state();
} }
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); let materialized = { configPath, secret: null as string | null, warning: null as string | null };
if (check.status !== 0) { if (nativeApi || gatewayRuntimeConfigPath) {
throw new HarborError('CONFIG_INVALID', { let config: unknown;
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), 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'); const nextHash = crypto.createHash('sha256')
if (!force && child && nextHash === configHash) return state(); .update(fs.readFileSync(materialized.configPath))
.digest('hex');
if (!force && child && nextHash === configHash) {
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
return state();
}
await stop(); await stop();
let current: ChildProcess; let current: ChildProcess;
try { try {
current = spawn('sing-box', ['run', '-c', configPath], { current = spawn('sing-box', ['run', '-c', materialized.configPath], {
stdio: ['ignore', 'inherit', 'inherit'], stdio: ['ignore', 'inherit', 'inherit'],
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -77,6 +141,8 @@ export function createSingboxRuntime({
child = current; child = current;
configHash = nextHash; configHash = nextHash;
startedAt = new Date().toISOString(); startedAt = new Date().toISOString();
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
try { try {
if (gateway) setGatewayInterception(true, tproxyChain); if (gateway) setGatewayInterception(true, tproxyChain);
} catch (error) { } catch (error) {
@@ -84,6 +150,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
throw new HarborError('PROCESS_START_FAILED', { cause: error }); throw new HarborError('PROCESS_START_FAILED', { cause: error });
} }
current.once('exit', () => { current.once('exit', () => {
@@ -91,6 +158,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
if (gateway) setGatewayInterception(false, tproxyChain); if (gateway) setGatewayInterception(false, tproxyChain);
}); });
return state(); return state();
@@ -99,7 +167,10 @@ export function createSingboxRuntime({
return { return {
get running() { return Boolean(child); }, get running() { return Boolean(child); },
get startedAt() { return startedAt; }, get startedAt() { return startedAt; },
get nativeApiSecret() { return nativeApiSecret; },
get nativeApiWarning() { return nativeApiWarning; },
refresh: async () => state(), refresh: async () => state(),
checkConfig,
apply, apply,
restart: () => apply({ force: true }), restart: () => apply({ force: true }),
stop, stop,
+64 -3
View File
@@ -36,6 +36,16 @@ function outboundRecord(value: unknown): SubscriptionOutbound {
return record(value) as 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) { function usableProxyOutbound(value: unknown) {
const outbound = outboundRecord(value); const outbound = outboundRecord(value);
const host = String(outbound.server || '').trim().toLowerCase(); const host = String(outbound.server || '').trim().toLowerCase();
@@ -107,11 +117,52 @@ export function parseVlessUrl(rawUrl: string) {
const serverName = parsed.searchParams.get('sni') || server; const serverName = parsed.searchParams.get('sni') || server;
const fingerprint = parsed.searchParams.get('fp') || 'chrome'; const fingerprint = parsed.searchParams.get('fp') || 'chrome';
const flow = parsed.searchParams.get('flow') || ''; 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) { if (!uuid || !server || !serverPort) {
throw new HarborError('SUBSCRIPTION_INVALID'); 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) { if (!publicKey || !shortId) {
throw new HarborError('SUBSCRIPTION_INVALID'); throw new HarborError('SUBSCRIPTION_INVALID');
} }
@@ -169,8 +220,12 @@ export function normalizeSubscriptionConfig(value: unknown) {
rejectedOutbounds.push(outbound); rejectedOutbounds.push(outbound);
return []; return [];
} }
const id = createServerId(outbound); const endpointId = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants. 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 []; if (seen.has(id)) return [];
seen.add(id); seen.add(id);
servers.push(normalizeServer({ ...outbound, id })); servers.push(normalizeServer({ ...outbound, id }));
@@ -244,11 +299,17 @@ export function selectRefreshedServer(
nextServers: readonly HarborServer[], nextServers: readonly HarborServer[],
) { ) {
if (!currentServerId) return ''; if (!currentServerId) return '';
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId); const previous = currentServers.find((server) => server.id === currentServerId);
if (!previous) return ''; if (!previous) return '';
const identity = serverIdentityKey(previous); const identity = serverIdentityKey(previous);
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity); 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 : ''; return matches.length === 1 ? matches[0].id : '';
} }
+163
View File
@@ -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 },
};
}
+74
View File
@@ -8,6 +8,12 @@ export const CONNECTIVITY_IP_SOURCES = Object.freeze([
{ id: 'ipify-v6', label: 'ipify IPv6', family: 6, url: 'https://api6.ipify.org' }, { 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([ export const CONNECTIVITY_SITES = Object.freeze([
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' }, { id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.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 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 { export interface ConnectivitySiteResult {
id: string; id: string;
label: string; label: string;
@@ -32,6 +105,7 @@ export interface ConnectivityPathResult {
internetAvailable: boolean; internetAvailable: boolean;
ipv4: { addresses: string[]; [key: string]: unknown }; ipv4: { addresses: string[]; [key: string]: unknown };
ipv6: string | null; ipv6: string | null;
network?: unknown;
sites: ConnectivitySiteResult[]; sites: ConnectivitySiteResult[];
[key: string]: unknown; [key: string]: unknown;
} }
+360 -40
View File
@@ -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 { 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 HarborMode = 'client' | 'gateway';
export type ConnectionState = 'running' | 'stopped'; export type ConnectionState = 'running' | 'stopped';
@@ -18,6 +36,35 @@ export interface RouteRule {
type: 'domain' | 'domain_suffix' | 'domain_keyword'; type: 'domain' | 'domain_suffix' | 'domain_keyword';
value: string; value: string;
enabled: boolean; 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 { export interface StateSnapshot {
@@ -25,20 +72,30 @@ export interface StateSnapshot {
revision: number; revision: number;
generatedAt: string; generatedAt: string;
mode: HarborMode; mode: HarborMode;
profiles: ProfileSnapshot[];
subscription: { subscription: {
status: 'missing' | 'ready'; status: 'missing' | 'ready' | 'stale';
host: string; host: string;
fetchedAt: string | null; fetchedAt: string | null;
userInfo: Record<string, unknown>; userInfo: Record<string, unknown>;
}; };
selection: { desiredServerId: string; appliedServerId: string }; selection: {
desiredProfileId: string;
desiredServerId: string;
appliedProfileId: string;
appliedServerId: string;
appliedServerSnapshot: HarborServer | null;
};
connection: { connection: {
desired: ConnectionState; desired: ConnectionState;
process: ConnectionState; process: ConnectionState;
startedAt: string | null; startedAt: string | null;
lastError: string | null; lastError: string | null;
}; };
diagnostics: DiagnosticSettings;
failover: FailoverSnapshot;
route: { route: {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode: string; mode: string;
gatewayAddress: string | null; gatewayAddress: string | null;
gatewayUiOrigin: string | null; gatewayUiOrigin: string | null;
@@ -56,24 +113,40 @@ export interface StateSnapshot {
status: OperationStatus; status: OperationStatus;
startedAt: string | null; startedAt: string | null;
error: string | null; error: string | null;
profileId: string | null;
serverId: string | null;
}; };
// One-release projection of the desired profile for older clients.
servers: HarborServer[]; servers: HarborServer[];
} }
export interface StoredState extends Record<string, unknown> { export interface PersistedState extends Record<string, unknown> {
revision: number; revision: number;
selectedServerId: string; profiles: StoredProfile[];
desiredProfileId: string;
appliedProfileId: string;
appliedServerId: string; appliedServerId: string;
selectedTag: string; appliedServerSnapshot: HarborServer | null;
appliedTag: string;
servers: HarborServer[];
routeRules: RouteRule[]; routeRules: RouteRule[];
appliedRouteRules: RouteRule[]; appliedRouteRules: RouteRule[];
routeRulesRevision: number; routeRulesRevision: number;
subscriptionUrl?: string;
connectionDesired?: ConnectionState; connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean; 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; fetchedAt?: string;
} }
@@ -97,6 +170,8 @@ export interface OperationState {
status: OperationStatus; status: OperationStatus;
startedAt: string | null; startedAt: string | null;
error: string | null; error: string | null;
profileId?: string | null;
serverId?: string | null;
} }
const MODES = new Set<HarborMode>(['client', 'gateway']); 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 typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
); );
export function normalizeStoredState(value: unknown): StoredState { function record(value: unknown): Record<string, unknown> {
const state: Record<string, unknown> = value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown> ? 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, 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.selectedServerId),
identityText(state.selectedTag), identityText(state.selectedTag),
); );
const appliedServerId = Object.hasOwn(state, 'appliedServerId') const legacyAppliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(servers, identityText(state.appliedServerId)) ? resolveServerId(legacyServers, identityText(state.appliedServerId))
: resolveServerId(servers, '', identityText(state.appliedTag) || identityText(state.selectedTag)); : resolveServerId(legacyServers, '', identityText(state.appliedTag) || identityText(state.selectedTag));
const selectedServer = servers.find((server: HarborServer) => server.id === selectedServerId); const suppliedProfiles = Array.isArray(state.profiles)
const appliedServer = servers.find((server: HarborServer) => server.id === appliedServerId); ? 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 { return {
...state, ...state,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0 revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) && state.revision >= 0
? state.revision ? state.revision
: 0, : 0,
selectedServerId, profiles,
desiredProfileId,
appliedProfileId,
appliedServerId, appliedServerId,
selectedTag: selectedServer?.label || '', appliedServerSnapshot,
appliedTag: appliedServer?.label || '',
servers,
routeRules: normalizeRouteRules(state.routeRules) as RouteRule[], routeRules: normalizeRouteRules(state.routeRules) as RouteRule[],
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[], appliedRouteRules: normalizeRouteRules(state.appliedRouteRules) as RouteRule[],
routeRulesRevision: typeof state.routeRulesRevision === 'number' routeRulesRevision: typeof state.routeRulesRevision === 'number'
&& Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0 && Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
? state.routeRulesRevision ? state.routeRulesRevision
: 0, : 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, gatewayAuto,
appMode, appMode,
configExists, configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null }, operation = { kind: null, status: 'idle', startedAt: null, error: null },
failoverSnapshot,
now = new Date(), now = new Date(),
}: { }: {
storedState: unknown; storedState: unknown;
@@ -158,17 +352,20 @@ export function createStateSnapshot({
gatewayAuto?: GatewayAutoState | null; gatewayAuto?: GatewayAutoState | null;
appMode?: string; appMode?: string;
configExists: boolean; configExists: boolean;
subscriptionHost: string; subscriptionHost?: string;
operation?: OperationState; operation?: OperationState;
failoverSnapshot?: FailoverSnapshot | null;
now?: Date; now?: Date;
}): StateSnapshot { }): StateSnapshot {
const stored = normalizeStoredState(storedState); const stored = normalizeStoredState(storedState);
const mode: HarborMode = appMode === 'client' || appMode === 'gateway' ? appMode : 'gateway'; 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) const desired: ConnectionState = stored.connectionDesired && CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired ? stored.connectionDesired
: configExists ? 'running' : 'stopped'; : configExists ? 'running' : 'stopped';
const servers = stored.servers; const running = Boolean(runtime?.running);
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false; const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client' const routeReason = mode !== 'client'
@@ -178,30 +375,44 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct' : routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found' ? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local'; : 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({ return assertStateSnapshot({
apiVersion: 1, apiVersion: 1,
revision: stored.revision, revision: stored.revision,
generatedAt: now.toISOString(), generatedAt: now.toISOString(),
mode, mode,
subscription: { profiles,
status: hasSubscription ? 'ready' : 'missing', subscription: selectedSnapshot ? {
host: hasSubscription ? subscriptionHost : '', status: selectedSnapshot.subscription.status,
fetchedAt: dateOrNull(stored.fetchedAt), host: selectedSnapshot.subscription.host,
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {}, fetchedAt: selectedSnapshot.subscription.fetchedAt,
userInfo: selectedSnapshot.subscription.userInfo,
} : {
status: 'missing',
host: '',
fetchedAt: null,
userInfo: {},
}, },
selection: { selection: {
desiredServerId: stored.selectedServerId, desiredProfileId: selectedProfile?.id || '',
desiredServerId: selectedProfile?.desiredServerId || '',
appliedProfileId: stored.appliedProfileId,
appliedServerId: stored.appliedServerId, appliedServerId: stored.appliedServerId,
appliedServerSnapshot,
}, },
connection: { connection: {
desired, desired,
process: runtime?.running ? 'running' : 'stopped', process: running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt), startedAt: dateOrNull(runtime?.startedAt),
lastError: null, lastError: null,
}, },
diagnostics: stored.diagnostics,
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
route: { route: {
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
mode: routeMode, mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null, gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
@@ -212,20 +423,50 @@ export function createStateSnapshot({
localRules: stored.routeRules, localRules: stored.routeRules,
activeLocalRules, activeLocalRules,
localRulesRevision: stored.routeRulesRevision, localRulesRevision: stored.routeRulesRevision,
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules), localRulesPendingRestart: localRulesBypassed
? false
: !isSameRules(stored.routeRules, activeLocalRules),
}, },
operation: { operation: {
kind: nullableText(operation.kind), kind: nullableText(operation.kind),
status: operation.status, status: operation.status,
startedAt: nullableText(operation.startedAt), startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error), 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 { 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 validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value: unknown) => value === null || validDate(value); const nullableDate = (value: unknown) => value === null || validDate(value);
const nullableString = (value: unknown) => value === null || typeof value === 'string'; 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) && ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' && typeof rule.value === 'string' &&
Boolean(rule.value) && 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 ( if (
!snapshot || !snapshot ||
@@ -253,22 +551,42 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
candidate.revision < 0 || candidate.revision < 0 ||
!validDate(candidate.generatedAt) || !validDate(candidate.generatedAt) ||
!MODES.has(candidate.mode) || !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 || !candidate.subscription ||
!['missing', 'ready'].includes(candidate.subscription.status) || !['missing', 'ready', 'stale'].includes(candidate.subscription.status) ||
typeof candidate.subscription.host !== 'string' || typeof candidate.subscription.host !== 'string' ||
Object.hasOwn(candidate.subscription, 'url') || Object.hasOwn(candidate.subscription, 'url') ||
!nullableDate(candidate.subscription.fetchedAt) || !nullableDate(candidate.subscription.fetchedAt) ||
!candidate.subscription.userInfo || !candidate.subscription.userInfo ||
typeof candidate.subscription.userInfo !== 'object' || typeof candidate.subscription.userInfo !== 'object' ||
!candidate.selection || !candidate.selection ||
typeof candidate.selection.desiredProfileId !== 'string' ||
typeof candidate.selection.desiredServerId !== 'string' || typeof candidate.selection.desiredServerId !== 'string' ||
typeof candidate.selection.appliedProfileId !== 'string' ||
typeof candidate.selection.appliedServerId !== 'string' || typeof candidate.selection.appliedServerId !== 'string' ||
!(candidate.selection.appliedServerSnapshot === null || validServer(candidate.selection.appliedServerSnapshot)) ||
!candidate.connection || !candidate.connection ||
!CONNECTION_STATES.has(candidate.connection.desired) || !CONNECTION_STATES.has(candidate.connection.desired) ||
!CONNECTION_STATES.has(candidate.connection.process) || !CONNECTION_STATES.has(candidate.connection.process) ||
!nullableDate(candidate.connection.startedAt) || !nullableDate(candidate.connection.startedAt) ||
!nullableString(candidate.connection.lastError) || !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 || !candidate.route ||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
typeof candidate.route.mode !== 'string' || typeof candidate.route.mode !== 'string' ||
!nullableString(candidate.route.gatewayAddress) || !nullableString(candidate.route.gatewayAddress) ||
!nullableString(candidate.route.gatewayUiOrigin) || !nullableString(candidate.route.gatewayUiOrigin) ||
@@ -288,6 +606,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!OPERATION_STATES.has(candidate.operation.status) || !OPERATION_STATES.has(candidate.operation.status) ||
!nullableDate(candidate.operation.startedAt) || !nullableDate(candidate.operation.startedAt) ||
!nullableString(candidate.operation.error) || !nullableString(candidate.operation.error) ||
!nullableString(candidate.operation.profileId) ||
!nullableString(candidate.operation.serverId) ||
!Array.isArray(candidate.servers) || !Array.isArray(candidate.servers) ||
!candidate.servers.every(validServer) !candidate.servers.every(validServer)
) { ) {
+5
View File
@@ -14,9 +14,14 @@ export const ERROR_DEFINITIONS = Object.freeze({
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false }, SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false }, SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true }, 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 }, STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_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_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true }, DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true }, DIAGNOSTICS_FAILED: { status: 503, message: 'Не удалось проверить маршруты. Попробуйте ещё раз.', retryable: true },
+355
View File
@@ -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 };
}
+213
View File
@@ -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;
}
+20 -4
View File
@@ -1,16 +1,20 @@
export const INITIAL_ROUTE_RULES = Object.freeze([ 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_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 const MAX_ROUTE_RULES = 200;
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword'; export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
export type RouteRuleOutbound = 'vpn' | 'direct';
export interface NormalizedRouteRule { export interface NormalizedRouteRule {
type: RouteRuleType; type: RouteRuleType;
value: string; value: string;
enabled: boolean; enabled: boolean;
outbound: RouteRuleOutbound;
} }
function record(value: unknown): Record<string, unknown> { function record(value: unknown): Record<string, unknown> {
@@ -28,20 +32,32 @@ function hostname(value: unknown) {
return normalized; return normalized;
} }
function normalizeRule(input: unknown): NormalizedRouteRule { function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
const rule = record(input); const rule = record(input);
const type = String(rule.type || '').trim(); const type = String(rule.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type'); if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') { if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state'); throw new TypeError('Invalid domain rule enabled state');
} }
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' const value = type === 'domain_keyword'
? String(rule.value || '').trim().toLowerCase() ? String(rule.value || '').trim().toLowerCase()
: hostname(rule.value); : hostname(rule.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) { if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule value'); throw new TypeError('Invalid domain rule value');
} }
return { type: 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( export function normalizeRouteRules(
@@ -60,7 +76,7 @@ export function normalizeRouteRules(
const normalized: NormalizedRouteRule[] = []; const normalized: NormalizedRouteRule[] = [];
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) { for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try { try {
const rule = normalizeRule(candidate); const rule = normalizeRule(candidate, strict);
const key = `${rule.type}:${rule.value}`; const key = `${rule.type}:${rule.value}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.20.36', macClient: '0.34.0',
gatewayClient: '0.21.21', gatewayClient: '0.36.0',
gatewayBackend: '0.21.21', gatewayBackend: '0.36.0',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+185 -55
View File
@@ -18,34 +18,70 @@ import {
} from './state/operations.js'; } from './state/operations.js';
const componentActions = { const componentActions = {
validateSubscription: api.subscription.validate,
listDevices: api.devices.list, listDevices: api.devices.list,
refreshDevices: api.devices.refresh, refreshDevices: api.devices.refresh,
resetDeviceTraffic: api.devices.resetTraffic,
updateDevice: api.devices.update, updateDevice: api.devices.update,
createDeviceTag: api.devices.createTag,
renameDeviceTag: api.devices.renameTag,
deleteDeviceTag: api.devices.deleteTag,
setDevicePolicy: api.devices.setPolicy, setDevicePolicy: api.devices.setPolicy,
pingServers: api.servers.ping, pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity, runConnectivityDiagnostics: api.diagnostics.connectivity,
loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
}; };
interface UiError { interface UiError {
context: string; context: string;
profileId: string;
message: string; message: string;
code: string; code: string;
correlationId: string; correlationId: string;
retry: (() => unknown) | null; 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() { export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer( const [{ snapshot: state, transport }, dispatch] = useReducer(
harborReducer, harborReducer,
initialHarborState, initialHarborState,
); );
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState<OperationRegistrySnapshot>({}); const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
const [error, setError] = useState<UiError | null>(null); const [error, setError] = useState<UiError | null>(null);
const [dismissedCanonicalError, setDismissedCanonicalError] = useState('');
const [versionInfo, setVersionInfo] = useState<unknown>(null); const [versionInfo, setVersionInfo] = useState<unknown>(null);
const pollGeneration = useRef(0); const pollGeneration = useRef(0);
const revisionRef = useRef(0);
const hasAcceptedSnapshotRef = useRef(false);
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null); const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
if (!operationRegistry.current) { if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry((next) => { 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 } = {}) { async function loadState({ retry = false }: { retry?: boolean } = {}) {
if (retry) dispatch({ type: 'retry-sync' }); if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current; const generation = pollGeneration.current;
try { try {
const snapshot = await harborClient.getState(); const snapshot = await harborClient.getState();
if (generation === pollGeneration.current) { 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() }); dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
} }
} catch (requestError) { } catch (requestError) {
@@ -98,30 +134,50 @@ export function App() {
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2'; if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
}, [state?.mode]); }, [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); setError(null);
return operationRegistry.current!.run(key, async () => { return operationRegistry.current!.run(key, async () => {
try { try {
return await applyMutation(action); return await applyMutation(action);
} catch (err) { } catch (err) {
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {}; let safeError = asHarborApiError(err);
const safeError = err instanceof HarborApiError if (safeError.code === 'STATE_CONFLICT' && context !== 'routing') {
? err await loadState();
: new HarborApiError({ code: candidate.code }, Number(candidate.status)); try {
return await applyMutation(action);
} catch (retryError) {
safeError = asHarborApiError(retryError);
}
}
await loadState();
setError({ setError({
context, context,
profileId,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT' message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.' ? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message, : safeError.code === 'STATE_CONFLICT'
? 'Harbor снова получил новые данные во время действия. Повторите ещё раз.'
: safeError.message,
code: safeError.code, code: safeError.code,
correlationId: safeError.correlationId, correlationId: safeError.code === 'STATE_CONFLICT' ? '' : safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT' retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context) ? () => run(key, action, context, target, profileId)
: null, : null,
}); });
return false; return false;
} }
}); }, target);
} }
async function applyMutation(action: () => Promise<unknown>) { async function applyMutation(action: () => Promise<unknown>) {
@@ -133,6 +189,10 @@ export function App() {
const result = response as Record<string, unknown>; const result = response as Record<string, unknown>;
if (!result.state) throw new Error('Harbor API не вернул state snapshot'); if (!result.state) throw new Error('Harbor API не вернул state snapshot');
const snapshot = parseHarborState(result.state); const snapshot = parseHarborState(result.state);
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({ dispatch({
type: 'sync-succeeded', type: 'sync-succeeded',
snapshot, snapshot,
@@ -141,36 +201,59 @@ export function App() {
return result; 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 })} />; 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 ? { const displayState = previewReady ? {
...state, ...state,
mode: 'client' as const, 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' }, 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 }, clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
} : state; } : 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 ( return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}> <div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
@@ -182,22 +265,41 @@ export function App() {
state={displayState} state={displayState}
versionInfo={versionInfo} versionInfo={versionInfo}
operations={operations} operations={operations}
error={error} error={visibleError}
subscriptionUrl={subscriptionUrl} onAddProfile={(label: string, url: string) => run(
setSubscriptionUrl={setSubscriptionUrl} 'profileAdd',
servers={previewReady ? [{ () => api.profiles.add(label, url, revisionRef.current),
id: 'preview-amsterdam', 'subscription',
label: 'Amsterdam', label,
host: '127.0.0.1', )}
port: 443, onSelectProfileServer={(profileId: string, serverId: string) => run(
protocol: 'vless', 'profileSelect',
}] : state.servers || []} () => api.profiles.selectServer(profileId, serverId, revisionRef.current),
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId} 'subscription',
setPendingServerId={setPendingServerId} `${profileId}:${serverId}`,
onFetchSubscription={fetchSubscription} profileId,
onRefreshSubscription={refreshSubscription} )}
onForgetSubscription={forgetSubscription} onRefreshProfile={(profileId: string) => run(
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')} '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')} onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')} onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
@@ -206,7 +308,35 @@ export function App() {
() => api.routeRules.update(rules, expectedRevision), () => api.routeRules.update(rules, expectedRevision),
'routing', '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> </main>
</div> </div>
+108 -9
View File
@@ -1,5 +1,6 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js'; import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js'; import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
type RequestOptions = Omit<RequestInit, 'headers'> & { type RequestOptions = Omit<RequestInit, 'headers'> & {
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -96,10 +97,50 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }), refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }), 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', method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends. body: JSON.stringify({ profileId, serverId, expectedRevision }),
body: JSON.stringify({ serverId, selectedTag: serverId }),
}), }),
gatewayAuto: { gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', { setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
@@ -108,14 +149,18 @@ export const api = {
}), }),
}, },
routeRules: { routeRules: {
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', { update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules/v2', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }), body: JSON.stringify({ rules, expectedRulesRevision, rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION }),
}), }),
}, },
devices: { devices: {
list: () => request('/api/devices'), list: () => request('/api/devices'),
refresh: () => request('/api/devices/refresh', { method: 'POST' }), 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( update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
`/api/devices/${id}`, `/api/devices/${id}`,
{ {
@@ -123,6 +168,24 @@ export const api = {
body: JSON.stringify({ ...patch, expectedRevision }), 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( setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
`/api/devices/${id}/policy`, `/api/devices/${id}/policy`,
{ {
@@ -132,23 +195,56 @@ export const api = {
), ),
}, },
diagnostics: { diagnostics: {
connectivity: (services: unknown[] = [], target: unknown = null) => request( connectivity: (target: unknown = null) => request(
'/api/diagnostics/connectivity', '/api/diagnostics/connectivity',
{ {
method: 'POST', 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: { singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }), stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }),
}, },
servers: { servers: {
ping: (serverIds: string[]) => request('/api/servers/ping-all', { ping: (profileId: string, serverIds: string[]) => request(
`/api/profiles/${encodeURIComponent(profileId)}/servers/ping`,
{
method: 'POST', method: 'POST',
body: JSON.stringify({ serverIds }), body: JSON.stringify({ serverIds }),
}), },
),
}, },
}; };
@@ -177,9 +273,12 @@ export function parseHarborState(value: unknown): HarborClientState {
revision: snapshot.revision, revision: snapshot.revision,
generatedAt: snapshot.generatedAt, generatedAt: snapshot.generatedAt,
mode: snapshot.mode, mode: snapshot.mode,
profiles: snapshot.profiles,
subscription: snapshot.subscription, subscription: snapshot.subscription,
selection: snapshot.selection, selection: snapshot.selection,
connection: snapshot.connection, connection: snapshot.connection,
diagnostics: snapshot.diagnostics,
failover: snapshot.failover,
route: snapshot.route, route: snapshot.route,
operation: snapshot.operation, operation: snapshot.operation,
servers: snapshot.servers, servers: snapshot.servers,
+494 -147
View File
@@ -5,9 +5,11 @@ import React, {
useState, useState,
type CSSProperties, type CSSProperties,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom';
import { import {
copyText, copyText,
localProxyUrls, localProxyUrls,
subscriptionDomain,
} from '../utils/clientControls.js'; } from '../utils/clientControls.js';
import { import {
operationBlocked, operationBlocked,
@@ -45,13 +47,25 @@ import {
InstructionsToggle, InstructionsToggle,
useInstructionsFeature, useInstructionsFeature,
} from '../features/instructions/index.js'; } 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 { import {
HARBOR_VERSIONS, HARBOR_VERSIONS,
parseVersion, parseVersion,
versionCompatibility, versionCompatibility,
} from '../../shared/versions.js'; } from '../../shared/versions.js';
import type { import type {
HarborServer, ProfileSnapshot,
RouteRule, RouteRule,
StateSnapshot, StateSnapshot,
} from '../../shared/contracts/state.js'; } from '../../shared/contracts/state.js';
@@ -62,8 +76,37 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'], ['hotfix', 'Hotfix'],
] as const; ] 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 { interface UiError {
context?: string; context?: string;
profileId?: string;
message?: string; message?: string;
correlationId?: string; correlationId?: string;
retry?: (() => unknown) | null; retry?: (() => unknown) | null;
@@ -79,13 +122,18 @@ interface VersionBadgeProps {
} }
interface ComponentActions { interface ComponentActions {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
listDevices: () => Promise<unknown>; listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>; refreshDevices: () => Promise<unknown>;
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, 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>; setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (ids: string[]) => Promise<unknown>; pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>; runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
} }
interface ClientViewState extends StateSnapshot { interface ClientViewState extends StateSnapshot {
@@ -102,23 +150,27 @@ interface ClientOverviewPageProps {
versionInfo: unknown; versionInfo: unknown;
operations?: OperationRegistrySnapshot; operations?: OperationRegistrySnapshot;
error: UiError | null; error: UiError | null;
subscriptionUrl: string; onAddProfile: (label: string, url: string) => Promise<unknown>;
setSubscriptionUrl: (value: string) => void; onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
servers: HarborServer[]; onRefreshProfile: (profileId: string) => Promise<unknown>;
pendingServerId: string; onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
setPendingServerId: (id: string) => void; onApply: (profileId: string, serverId: string) => Promise<unknown>;
onFetchSubscription: () => Promise<unknown>;
onRefreshSubscription: () => Promise<unknown>;
onForgetSubscription: () => Promise<unknown>;
onApply: (serverId: string) => Promise<unknown>;
onRestart: () => Promise<unknown>; onRestart: () => Promise<unknown>;
onStop: () => Promise<unknown>; onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>; onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => 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; onDismissError: () => void;
} }
type CopyKind = 'gateway' | 'socks5' | 'http'; 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> { function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) 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]>> = { const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
connection: ['connection', 'Меняем состояние подключения…'], connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'], serverApply: ['connection', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'], profileActivate: ['connection', 'Переключаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'], profileAdd: ['subscription', 'Добавляем подписку…'],
profileRefresh: ['subscription', 'Обновляем подписку…'],
profileDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'], 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 }: { 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; isGateway: boolean;
connected: boolean;
gatewayAvailable: boolean; gatewayAvailable: boolean;
gatewayDirect: boolean; gatewayDirect: boolean;
blocked: boolean; blocked: boolean;
@@ -333,7 +433,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe
</div>; </div>;
return ( 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 {switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`} className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button" type="button"
@@ -365,64 +465,110 @@ export function ClientOverviewPage({
versionInfo, versionInfo,
operations = {}, operations = {},
error, error,
subscriptionUrl, onAddProfile,
setSubscriptionUrl, onSelectProfileServer,
servers, onRefreshProfile,
pendingServerId, onForgetProfile,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onApply, onApply,
onRestart, onRestart,
onStop, onStop,
onSetGatewayAuto, onSetGatewayAuto,
onSaveRouteRules, onSaveRouteRules,
onUpdateDiagnosticsSettings,
onSaveFailover,
onPauseFailover,
onSwitchFailover,
onCheckFailover,
onDismissError, onDismissError,
}: ClientOverviewPageProps) { }: ClientOverviewPageProps) {
const isGateway = state?.mode === 'gateway'; const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct'; const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable); const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
const connected = state?.connection?.process === 'running'; const connected = state?.connection?.process === 'running';
const hasSubscription = state?.subscription?.status === 'ready'; const profiles = state?.profiles || [];
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || ''; 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 appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId); const appliedServer = appliedProfile?.servers.find(({ id }) => id === appliedServerId)
const desiredServer = servers.find(({ id }) => id === selectedServerId); || (state?.selection?.appliedServerSnapshot?.id === appliedServerId
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId)); ? state.selection.appliedServerSnapshot
: null);
const desiredServer = desiredProfile?.servers.find(({ id }) => id === selectedServerId);
const showPower = hasSubscription;
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true); const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
const [copyFeedback, setCopyFeedback] = useState<{ kind: CopyKind; failed: boolean } | null>(null); const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 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 gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`; const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress); const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
const connectionBlocked = operationBlocked(operations, 'connection'); const canonicalOperationKey = state.operation.status === 'running'
const serverApplyBlocked = operationBlocked(operations, 'serverApply'); ? canonicalOperationKeys[state.operation.kind || '']
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto'); : undefined;
const switchingServer = Boolean( const canonicalTarget = state.operation.profileId
selectedServerId && selectedServerId !== appliedServerId && desiredServer, ? `${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({ const subscriptionFeature = useSubscriptionFeature({
subscription: state?.subscription, profiles,
subscriptionUrl, selection: state.selection,
setSubscriptionUrl, connected,
operations, operations: visibleOperations,
error, error: subscriptionError,
serverCount: servers.length,
isGateway, isGateway,
gatewayDirect, gatewayDirect,
validateSubscription: actions.validateSubscription, onAdd: onAddProfile,
onImport: onFetchSubscription, onRefresh: onRefreshProfile,
onRefresh: onRefreshSubscription, onForget: onForgetProfile,
onForget: onForgetSubscription,
onDismissError, onDismissError,
}); });
const subscriptionContentReady = subscriptionFeature.contentReady;
const routingFeature = useRoutingFeature({ const routingFeature = useRoutingFeature({
route: state?.route, route: state?.route,
connected, connected,
operations, operations: visibleOperations,
onSave: onSaveRouteRules, onSave: onSaveRouteRules,
onDismissError, onDismissError,
}); });
@@ -430,7 +576,11 @@ export function ClientOverviewPage({
isGateway, isGateway,
listDevices: actions.listDevices, listDevices: actions.listDevices,
refreshDevices: actions.refreshDevices, refreshDevices: actions.refreshDevices,
resetDeviceTraffic: actions.resetDeviceTraffic,
updateDevice: actions.updateDevice, updateDevice: actions.updateDevice,
createDeviceTag: actions.createDeviceTag,
renameDeviceTag: actions.renameDeviceTag,
deleteDeviceTag: actions.deleteDeviceTag,
setDevicePolicy: actions.setDevicePolicy, setDevicePolicy: actions.setDevicePolicy,
}); });
const diagnosticsFeature = useDiagnosticsFeature(); const diagnosticsFeature = useDiagnosticsFeature();
@@ -440,7 +590,70 @@ export function ClientOverviewPage({
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082), port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
controlHost, 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(() => { useEffect(() => {
setNow(Date.now()); setNow(Date.now());
@@ -450,128 +663,243 @@ export function ClientOverviewPage({
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isGateway, connected, state?.connection?.startedAt]); }, [isGateway, connected, state?.connection?.startedAt]);
useEffect(() => {
if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200);
return () => clearTimeout(timer);
}, [showIntro]);
useEffect(() => { useEffect(() => {
if (!hasSubscription) { if (!hasSubscription) {
routingFeature.forceClose(); routingFeature.forceClose();
if (!isGateway) { instructionsFeature.close();
instructionsFeature.close(); devicesFeature.close();
devicesFeature.close(); diagnosticsFeature.close();
diagnosticsFeature.close(); failoverFeature.close();
} activityJournalFeature.close();
trafficFeature.close();
} }
}, [hasSubscription, isGateway]); }, [hasSubscription]);
useEffect(() => { useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close(); if (!diagnosticsAvailable) diagnosticsFeature.close();
}, [diagnosticsAvailable]); }, [diagnosticsAvailable]);
useEffect(() => () => { useEffect(() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current); for (const timer of Object.values(copyTimersRef.current)) clearTimeout(timer);
copyAttemptsRef.current = {};
}, []); }, []);
function selectServer(serverId: string) { function selectServer(profile: ProfileSnapshot, serverId: string) {
setPendingServerId(serverId); if (connected && !gatewayDirect) {
if (connected && serverId) onApply(serverId); onApply(profile.id, serverId);
return;
}
onSelectProfileServer(profile.id, serverId);
} }
async function copyProxy(kind: CopyKind) { async function copyProxy(kind: CopyKind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind]; 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 { try {
await copyText(value); await copyText(value);
setCopyFeedback({ kind, failed: false });
} catch { } 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() { function switchDrawer(target: DrawerKey) {
subscriptionFeature.close(); const activeSwitch = drawerSwitchRef.current;
instructionsFeature.close(); const current = activeSwitch?.target
devicesFeature.close(); || drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
diagnosticsFeature.close(); || null;
routingFeature.open(); 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 ( return (
<div <div
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`} className={`client-shell${!hasSubscription ? ' is-first-run' : ''}`}
> >
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} /> <VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true"> <div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''} <span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div> </div>
<HarborBrand {hasSubscription && <nav
isGateway={isGateway} className="client-secondary-menu"
gatewayAvailable={gatewayAvailable} aria-label="Дополнительные меню"
gatewayDirect={gatewayDirect} onPointerDown={(event) => event.stopPropagation()}
blocked={gatewayAutoBlocked} >
onSetGatewayAuto={onSetGatewayAuto} <SubscriptionToggle
/>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle
feature={subscriptionFeature} feature={subscriptionFeature}
onToggle={() => { open={activeRailDrawer === 'subscription'}
if (routingFeature.isOpen && !routingFeature.requestClose()) return; onToggle={() => switchDrawer('subscription')}
instructionsFeature.close(); />
devicesFeature.close(); {isGateway && <FailoverToggle
diagnosticsFeature.close(); feature={failoverFeature}
subscriptionFeature.toggle(); open={activeRailDrawer === 'failover'}
}} onToggle={() => switchDrawer('failover')}
/>} />}
<InstructionsToggle <InstructionsToggle
feature={instructionsFeature} feature={instructionsFeature}
onToggle={() => { open={activeRailDrawer === 'instructions'}
if (routingFeature.isOpen && !routingFeature.requestClose()) return; onToggle={() => switchDrawer('instructions')}
subscriptionFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
instructionsFeature.toggle();
}}
/> />
{isGateway && <DevicesToggle {isGateway && <DevicesToggle
feature={devicesFeature} feature={devicesFeature}
onToggle={() => { open={activeRailDrawer === 'devices'}
if (routingFeature.isOpen && !routingFeature.requestClose()) return; onToggle={() => switchDrawer('devices')}
subscriptionFeature.close();
instructionsFeature.close();
diagnosticsFeature.close();
devicesFeature.toggle();
}}
/>} />}
<TrafficToggle
feature={trafficFeature}
open={activeRailDrawer === 'traffic'}
onToggle={() => switchDrawer('traffic')}
/>
<DiagnosticsToggle <DiagnosticsToggle
feature={diagnosticsFeature} feature={diagnosticsFeature}
onToggle={() => { open={activeRailDrawer === 'diagnostics'}
if (routingFeature.isOpen && !routingFeature.requestClose()) return; onToggle={() => switchDrawer('diagnostics')}
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.toggle();
}}
/> />
<RoutingToggle <RoutingToggle
feature={routingFeature} feature={routingFeature}
open={activeRailDrawer === 'routing'}
gatewayDirect={gatewayDirect} gatewayDirect={gatewayDirect}
isGateway={isGateway} isGateway={isGateway}
hasSubscription={hasSubscription} hasSubscription={hasSubscription}
onOpen={openRouting} onOpen={() => switchDrawer('routing')}
/> />
{isGateway && <ActivityJournalToggle
feature={activityJournalFeature}
open={activeRailDrawer === 'journal'}
onToggle={() => switchDrawer('journal')}
/>}
</nav>} </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 <ConnectionPanel
visible={showPower} visible={showPower}
isGateway={isGateway} isGateway={isGateway}
connected={connected} connected={connected}
gatewayDirect={gatewayDirect} gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId} selectedServerId={selectedServerId}
configured={Boolean(state?.clientRuntime?.configured)}
startedAt={state?.connection?.startedAt} startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress} gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin} gatewayUiOrigin={state?.route?.gatewayUiOrigin}
@@ -579,72 +907,91 @@ export function ClientOverviewPage({
proxyPort={state?.clientRuntime?.proxyPort} proxyPort={state?.clientRuntime?.proxyPort}
now={now} now={now}
blocked={connectionBlocked} blocked={connectionBlocked}
brandSlot={<HarborBrand
isGateway={isGateway}
connected={connected || gatewayDirect}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>}
copyFeedback={copyFeedback} copyFeedback={copyFeedback}
onCopyProxy={copyProxy} onCopyProxy={copyProxy}
onApply={onApply} onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
onRestart={onRestart}
onStop={onStop} onStop={onStop}
routingSlot={<RoutingPendingStatus routingSlot={<RoutingPendingStatus
feature={routingFeature} feature={routingFeature}
blocked={connectionBlocked} blocked={connectionBlocked}
onRestart={onRestart} onRestart={onRestart}
/>} />}
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title"> serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity || failoverIdentity} />}
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
statusSlot={<> statusSlot={<>
<InlineError error={error} context="connection" /> <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 <SubscriptionPanel
feature={subscriptionFeature} feature={subscriptionFeature}
statusSlot={<> statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" /> <InlineError error={subscriptionFeature.error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</>} </>}
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker renderServerPicker={(profile, pickerState) => <ServerPicker
profileId={profile.id}
pingServers={actions.pingServers} pingServers={actions.pingServers}
servers={servers} servers={profile.servers}
selectedServerId={selectedServerId} selectedServerId={pickerState.selectedServerId}
disabled={serverApplyBlocked} disabled={serverApplyBlocked || pickerState.disabled}
prompt={!showPower} leaving={pickerState.leaving}
leaving={subscriptionFeature.serversLeaving} revealVersion={pickerState.revealVersion}
revealVersion={subscriptionFeature.serverRevealVersion} anchorServerId={pickerState.anchorServerId}
onSelect={selectServer} onSelect={(serverId) => selectServer(profile, serverId)}
/>} />}
/> />
</main> </main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel {hasSubscription && <InstructionsPanel
feature={instructionsFeature} feature={instructionsFeature}
isGateway={isGateway} isGateway={isGateway}
/>} />}
{isGateway && <DevicesPanel feature={devicesFeature} />} {isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel {diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature} feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics} runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
settings={state.diagnostics}
updateSettings={onUpdateDiagnosticsSettings}
isGateway={isGateway} isGateway={isGateway}
/>} />}
{hasSubscription && subscriptionContentReady && <RoutingPanel {hasSubscription && <RoutingPanel
feature={routingFeature} feature={routingFeature}
statusSlot={<> statusSlot={<>
<InlineError error={error} context="routing" /> <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} /> <RoutingDiscardDialog feature={routingFeature} />
<SubscriptionDeleteDialog feature={subscriptionFeature} /> <SubscriptionDeleteDialog feature={subscriptionFeature} />
</div> </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';
+92 -89
View File
@@ -1,5 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { CopyButton } from '../../ui/CopyButton.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { import {
connectionAction, connectionAction,
connectionDurationParts, connectionDurationParts,
@@ -11,8 +13,8 @@ const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
type CopyKind = 'gateway' | 'socks5' | 'http'; type CopyKind = 'gateway' | 'socks5' | 'http';
interface CopyFeedback { interface CopyFeedback {
kind: CopyKind;
failed: boolean; failed: boolean;
cycle: number;
} }
interface DurationUnit { interface DurationUnit {
@@ -26,7 +28,6 @@ interface ConnectionPanelProps {
connected: boolean; connected: boolean;
gatewayDirect: boolean; gatewayDirect: boolean;
selectedServerId: string; selectedServerId: string;
configured: boolean;
startedAt?: string | null; startedAt?: string | null;
gatewayAddress: string; gatewayAddress: string;
gatewayUiOrigin?: string | null; gatewayUiOrigin?: string | null;
@@ -34,13 +35,13 @@ interface ConnectionPanelProps {
proxyPort?: number; proxyPort?: number;
now: number; now: number;
blocked: boolean; blocked: boolean;
copyFeedback?: CopyFeedback | null; brandSlot?: ReactNode;
copyFeedback?: Partial<Record<CopyKind, CopyFeedback>>;
routingSlot?: ReactNode; routingSlot?: ReactNode;
serverSlot?: ReactNode; serverSlot?: ReactNode;
statusSlot?: ReactNode; statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown; onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown; onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown; onStop: () => unknown;
} }
@@ -66,7 +67,6 @@ export function ConnectionPanel({
connected, connected,
gatewayDirect, gatewayDirect,
selectedServerId, selectedServerId,
configured,
startedAt, startedAt,
gatewayAddress, gatewayAddress,
gatewayUiOrigin, gatewayUiOrigin,
@@ -74,13 +74,13 @@ export function ConnectionPanel({
proxyPort, proxyPort,
now, now,
blocked, blocked,
brandSlot,
copyFeedback, copyFeedback,
routingSlot, routingSlot,
serverSlot, serverSlot,
statusSlot, statusSlot,
onCopyProxy, onCopyProxy,
onApply, onApply,
onRestart,
onStop, onStop,
}: ConnectionPanelProps) { }: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => { const [durationMode, setDurationMode] = useState(() => {
@@ -91,8 +91,9 @@ export function ConnectionPanel({
} }
}); });
const [confirmingStop, setConfirmingStop] = useState(false); const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured); const remoteOwned = !isGateway && gatewayDirect;
const powerUnavailable = isGateway && !connected && !canStart; const canStart = Boolean(selectedServerId);
const powerUnavailable = remoteOwned || (!connected && !canStart);
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress); const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now); const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [ const clockUnits: Array<[string, DurationUnit]> = [
@@ -102,9 +103,9 @@ export function ConnectionPanel({
]; ];
const wordClockDuration = clockUnits const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds'); .filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected const connectionTitle = remoteOwned
? gatewayDirect ? 'Gateway подключён' : 'VPN включён' ? 'Gateway подключён'
: 'Подключение выключено'; : connected ? 'VPN включён' : 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway const proxyKinds: Array<[CopyKind, string]> = isGateway
? [ ? [
['gateway', 'GATEWAY'], ['gateway', 'GATEWAY'],
@@ -117,13 +118,12 @@ export function ConnectionPanel({
]; ];
function toggleConnection() { function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured }); const action = connectionAction({ connected, selectedServerId });
if (action?.type === 'stop') { if (action?.type === 'stop') {
setConfirmingStop(true); setConfirmingStop(true);
return; return;
} }
if (action?.type === 'apply') return onApply(action.serverId); if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
} }
async function stopConnection() { async function stopConnection() {
@@ -147,12 +147,14 @@ export function ConnectionPanel({
className="client-power" className="client-power"
type="button" type="button"
role="switch" role="switch"
aria-checked={connected} aria-checked={connected || remoteOwned}
aria-label={isGateway aria-label={remoteOwned
? 'Подключением управляет Harbor Gateway'
: isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN' ? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'} : connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined} aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)} disabled={blocked || powerUnavailable}
onClick={toggleConnection} onClick={toggleConnection}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -161,78 +163,81 @@ export function ConnectionPanel({
</button>; </button>;
return <> return <>
{visible && <section className="client-power-section" aria-labelledby="connection-title"> {visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
{isGateway ? <span <div
className="client-power-control client-tooltip-anchor" className={`client-power-control${powerUnavailable ? ' client-tooltip-anchor' : ''}`}
tabIndex={powerUnavailable ? 0 : undefined} tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined} aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined} aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
> >
{brandSlot}
{powerButton} {powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip"> {powerUnavailable && <Tooltip id="gateway-power-unavailable">
Сначала добавьте подписку и выберите сервер {remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
</span>} </Tooltip>}
</span> : powerButton} </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} {routingSlot}
<div className="client-state-copy" aria-live="polite"> <div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}> <h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span> <span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span> <span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span> <span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2> </h2>
{serverSlot} {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> </div>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}> <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?:\/\//, '')} {isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong> </strong>
<div className="client-proxy-actions"> <div className="client-proxy-actions">
{proxyKinds.map(([kind, label]) => ( {proxyKinds.map(([kind, label]) => {
<button const feedback = copyFeedback?.[kind];
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`} return <CopyButton
type="button" label={label}
feedback={feedback}
key={kind} key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`} ariaLabel={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => onCopyProxy(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>
</div> </div>
</section> </section>
{statusSlot} {statusSlot}
</section>} </section> : brandSlot}
<ConfirmationDialog <ConfirmationDialog
open={confirmingStop} open={confirmingStop}
+222 -18
View File
@@ -1,11 +1,17 @@
import { useEffect, useRef, useState } from 'react'; 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 { TrafficChart } from './TrafficChart.js';
import { import {
parseDeviceSnapshot, parseDeviceSnapshot,
type Device, type Device,
type DevicePolicy, type DevicePolicy,
type DeviceSnapshot, type DeviceSnapshot,
type DeviceTag,
} from './deviceSnapshot.js'; } from './deviceSnapshot.js';
const DEVICE_AUTO_REFRESH_MS = 15_000; const DEVICE_AUTO_REFRESH_MS = 15_000;
@@ -14,7 +20,11 @@ interface DevicesFeatureOptions {
isGateway: boolean; isGateway: boolean;
listDevices: () => Promise<unknown>; listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>; refreshDevices: () => Promise<unknown>;
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, 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>; 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 }; 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({ export function useDevicesFeature({
isGateway, isGateway,
listDevices, listDevices,
refreshDevices, refreshDevices,
resetDeviceTraffic,
updateDevice: requestDeviceUpdate, updateDevice: requestDeviceUpdate,
createDeviceTag,
renameDeviceTag,
deleteDeviceTag,
setDevicePolicy, setDevicePolicy,
}: DevicesFeatureOptions) { }: DevicesFeatureOptions) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -45,6 +65,10 @@ export function useDevicesFeature({
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0); const [refreshCycle, setRefreshCycle] = useState(0);
const [savingId, setSavingId] = useState(''); 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 panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null); const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = 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(() => { useEffect(() => {
if (!isGateway) return undefined; if (!isGateway) return undefined;
load(); load();
@@ -147,6 +308,8 @@ export function useDevicesFeature({
if (!isOpen) return undefined; if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus()); const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeDevices = (event: PointerEvent | KeyboardEvent) => { 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' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && ( if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node) 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(); if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
}); });
}; };
}, [isOpen]); }, [isOpen, resetOpen]);
return { return {
isOpen, isOpen,
@@ -173,12 +336,24 @@ export function useDevicesFeature({
refreshing, refreshing,
refreshCycle, refreshCycle,
savingId, savingId,
tagSavingId,
tagError,
resetOpen,
resetting,
panelRef, panelRef,
toggleRef, toggleRef,
closeRef, closeRef,
load, load,
updateDevice, updateDevice,
updateDeviceTags,
createTag,
renameTag,
deleteTag,
clearTagError: () => setTagError(null),
updatePolicy, updatePolicy,
requestTrafficReset: () => setResetOpen(true),
cancelTrafficReset: () => setResetOpen(false),
confirmResetTraffic,
close: () => setIsOpen(false), close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open), toggle: () => setIsOpen((open) => !open),
}; };
@@ -186,27 +361,49 @@ export function useDevicesFeature({
export type DevicesFeature = ReturnType<typeof useDevicesFeature>; export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) { export function DevicesToggle({
return <button feature,
ref={feature.toggleRef} open,
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`} onToggle,
type="button" }: {
aria-expanded={feature.isOpen} feature: DevicesFeature;
aria-controls="client-devices" open: boolean;
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'} 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} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <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" /> <g className="client-rail-device-monitor">
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" /> <rect x="2.5" y="4.5" width="9" height="10.5" rx="1.5" />
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" /> <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> </svg>
<span>Устройства</span> </RailAction>;
</button>;
} }
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) { export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
const globalTraffic = feature.snapshot?.traffic; 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 const trafficSourceError = feature.snapshot?.source?.traffic?.error
|| feature.snapshot?.source?.traffic?.proxy?.error || feature.snapshot?.source?.traffic?.proxy?.error
|| (feature.status === 'error' ? feature.error : null); || (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"> return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
<div className="client-gateway-traffic-heading"> <div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span> <span className="client-gateway-traffic-total">
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong> <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>
<div className="client-gateway-traffic-chart"> <div className="client-gateway-traffic-chart">
<TrafficChart <TrafficChart
samples={globalTraffic?.history || []} samples={history}
capacity={feature.snapshot?.trafficHistoryCapacity || 120} capacity={feature.snapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway" routeLabel="Gateway"
series="speed"
/> />
</div> </div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite"> <div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
File diff suppressed because it is too large Load Diff
+174 -52
View File
@@ -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 { createPortal } from 'react-dom';
import { import {
byteString, byteString,
formatByteString, formatByteString,
trafficAxisMid, trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio, trafficScaleRatio,
} from '../../utils/format.js'; } 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 TRAFFIC_CHART_HEADROOM = 10;
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM); 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 { interface ChartPoint {
sample: TrafficSample; sample: ChartSample;
x: number; x: number;
gateway: bigint; gateway: bigint;
proxy: bigint; proxy: bigint;
directIpv4: bigint;
unknown: bigint;
gatewayY: number; gatewayY: number;
proxyY: number; proxyY: number;
unknownY: number;
} }
interface HoveredPoint extends ChartPoint { interface HoveredPoint extends ChartPoint {
@@ -31,7 +46,7 @@ interface HoveredPoint extends ChartPoint {
clientY: number; clientY: number;
} }
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') { function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
if (!points.length) return ''; if (!points.length) return '';
return points.slice(1).reduce((path, point, index) => { return points.slice(1).reduce((path, point, index) => {
const previous = points[index]; const previous = points[index];
@@ -40,14 +55,36 @@ function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY'
}, `M ${points[0].x},${points[0][valueKey]}`); }, `M ${points[0].x},${points[0][valueKey]}`);
} }
function trafficSeriesMax(samples: TrafficSample[]) { function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartPoint[]) {
return samples.reduce((largest, sample) => { const previousByTime = new Map(previousPoints.map((point) => [point.sample.observedAt, point]));
const gateway = byteString(sample.gatewayBytes); let anchor: ChartPoint | undefined;
const proxy = byteString(sample.proxyBytes); return points.map((point) => {
return gateway > largest const exact = previousByTime.get(point.sample.observedAt);
? (proxy > gateway ? proxy : gateway) if (exact) anchor = exact;
: (proxy > largest ? proxy : largest); const source = exact || anchor;
}, 0n); 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({ export function TrafficChart({
@@ -56,44 +93,109 @@ export function TrafficChart({
capacity, capacity,
routeLabel, routeLabel,
pinned = true, pinned = true,
collapsing = false,
onCollapseEnd,
series = 'inbound',
}: { }: {
samples: TrafficSample[]; samples: ChartSample[];
scale?: TrafficScale; scale?: TrafficScale;
capacity: number; capacity: number;
routeLabel: string; routeLabel: string;
pinned?: boolean; pinned?: boolean;
collapsing?: boolean;
onCollapseEnd?: () => void;
series?: 'inbound' | 'outbound' | 'speed';
}) { }) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null); const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]); const previousPoints = useRef<ChartPoint[]>([]);
const previousScale = useRef<TrafficScale>(scale); const previousSeries = useRef(series);
const max = trafficSeriesMax(samples); 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 mid = trafficAxisMid(max, scale);
const firstSlot = capacity - samples.length; const firstSlot = capacity - visibleSamples.length;
const points = samples.map((sample, index) => { const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return { return {
sample, sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
gateway, gateway,
proxy, proxy,
directIpv4,
unknown,
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)), gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)), proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
}; };
}); });
const previous = points.slice(0, -1); const previous = points.slice(0, -1);
const penultimate = points.at(-2); const penultimate = points.at(-2);
const newest = points.at(-1); const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n); const lineDefinitions: Array<{
const scaleFrom = previousPoints.current; valueKey: ChartValueKey;
const animateScale = previousScale.current !== scale yKey: ChartYKey;
&& scaleFrom.length === points.length }> = 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); && !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => { useLayoutEffect(() => {
previousPoints.current = points; previousPoints.current = points;
previousScale.current = scale; previousSeries.current = series;
}, [points, scale]); }, [points, series]);
function trackPointer(event: PointerEvent<HTMLSpanElement>) { function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect(); const bounds = event.currentTarget.getBoundingClientRect();
@@ -116,25 +218,46 @@ export function TrafficChart({
}} }}
> >
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time> <time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong> {series === 'speed' ? <>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span> <strong className="is-download"> Download {formatRate(hovered.gateway)}</strong>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>} <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>, </span>,
document.body, document.body,
); );
return <span return <span
className="client-device-traffic-chart" className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
role="img" role="img"
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`} aria-label={`${series === 'speed' ? 'История скорости' : series === 'outbound' ? 'Фактический выход трафика' : 'Источник входящего трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
style={{ style={{
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`, '--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`, '--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
} as CSSProperties} } 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"> {pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{formatByteString(max)}</span> <span className="is-max">{series === 'speed' ? formatRate(max) : formatByteString(max)}</span>
<span className="is-mid">{formatByteString(mid)}</span> <span className="is-mid">{series === 'speed' ? formatRate(mid) : formatByteString(mid)}</span>
<span className="is-zero">0</span> <span className="is-zero">0</span>
</span>} </span>}
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}> <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 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
<line x1="0" x2="100" y1="100" y2="100" /> <line x1="0" x2="100" y1="100" y2="100" />
</g>} </g>}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}> <g className="client-device-traffic-lines">
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}> {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)}>
{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" />} {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>} </path>)}
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}> {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)}>
{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" />} {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>} </path>)}
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}> {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]}>
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />} {animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
</path>} </line>)}
{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> </g>
{hovered && <g className="client-device-traffic-cursor"> {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-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} /> {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]} />)}
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
</g>} </g>}
</svg> </svg>
</span> </span>
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true"> {visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time> <time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
<span>15 с</span> {series === 'outbound' ? <span className="client-device-traffic-legend">
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time> {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>} </span>}
{tooltip} {tooltip}
</span>; </span>;
+97 -3
View File
@@ -5,20 +5,44 @@ type DeviceStatus = 'online' | 'recent' | 'offline';
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed'; type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
type DeviceConfidence = 'high' | 'medium' | 'ambiguous'; type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
export interface DeviceTag extends Record<string, unknown> {
id: string;
name: string;
}
export interface TrafficSample extends Record<string, unknown> { export interface TrafficSample extends Record<string, unknown> {
observedAt: string; observedAt: string;
gatewayBytes: ByteValue; gatewayBytes: ByteValue;
proxyBytes: 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> { export interface Device extends Record<string, unknown> {
id: string; id: string;
alias: string | null; alias: string | null;
hostname: string | null; hostname: string | null;
mac: string;
ip: string | null; ip: string | null;
firstSeenAt: string;
lastSeenAt: string | null; lastSeenAt: string | null;
status: DeviceStatus; status: DeviceStatus;
pinned: boolean; pinned: boolean;
deprioritized?: boolean;
tagIds: string[];
downloadBytes: ByteValue; downloadBytes: ByteValue;
uploadBytes: ByteValue; uploadBytes: ByteValue;
proxyDownloadBytes: ByteValue; proxyDownloadBytes: ByteValue;
@@ -29,6 +53,8 @@ export interface Device extends Record<string, unknown> {
appliedPolicy: DevicePolicy; appliedPolicy: DevicePolicy;
confidence: DeviceConfidence; confidence: DeviceConfidence;
trafficHistory: TrafficSample[]; trafficHistory: TrafficSample[];
outboundTraffic?: OutboundTrafficTotal | null;
outboundTrafficHistory?: OutboundTrafficSample[];
} }
interface SnapshotSource extends Record<string, unknown> { interface SnapshotSource extends Record<string, unknown> {
@@ -54,12 +80,16 @@ interface SnapshotSource extends Record<string, unknown> {
export interface DeviceSnapshot extends Record<string, unknown> { export interface DeviceSnapshot extends Record<string, unknown> {
revision: number; revision: number;
tags: DeviceTag[];
taggingSupported: boolean;
devices: Device[]; devices: Device[];
trafficHistoryCapacity: number; trafficHistoryCapacity: number;
traffic: { traffic: {
gatewayBytes: ByteValue; gatewayBytes: ByteValue;
proxyBytes: ByteValue; proxyBytes: ByteValue;
totalBytes: ByteValue; totalBytes: ByteValue;
uploadBytes?: ByteValue;
downloadBytes?: ByteValue;
gatewayObservedAt: string | null; gatewayObservedAt: string | null;
proxyObservedAt: string | null; proxyObservedAt: string | null;
observedAt: string | null; observedAt: string | null;
@@ -93,23 +123,53 @@ function validTrafficSample(value: unknown): value is TrafficSample {
return record(value) return record(value)
&& timestamp(value.observedAt) && timestamp(value.observedAt)
&& bytes(value.gatewayBytes) && 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[] { function validHistory(value: unknown): value is TrafficSample[] {
return Array.isArray(value) && value.every(validTrafficSample); 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 { function validDevice(value: unknown): value is Device {
return record(value) return record(value)
&& typeof value.id === 'string' && typeof value.id === 'string'
&& /^dev_[a-f0-9]{16}$/.test(value.id) && /^dev_[a-f0-9]{16}$/.test(value.id)
&& nullableString(value.alias) && nullableString(value.alias)
&& nullableString(value.hostname) && nullableString(value.hostname)
&& typeof value.mac === 'string'
&& /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(value.mac)
&& nullableString(value.ip) && nullableString(value.ip)
&& timestamp(value.firstSeenAt)
&& nullableTimestamp(value.lastSeenAt) && nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline') && (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean' && 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.downloadBytes)
&& bytes(value.uploadBytes) && bytes(value.uploadBytes)
&& bytes(value.proxyDownloadBytes) && bytes(value.proxyDownloadBytes)
@@ -120,7 +180,23 @@ function validDevice(value: unknown): value is Device {
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct') && (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct') && (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous') && (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 { function validSource(value: unknown): value is SnapshotSource {
@@ -144,6 +220,8 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
&& bytes(value.gatewayBytes) && bytes(value.gatewayBytes)
&& bytes(value.proxyBytes) && bytes(value.proxyBytes)
&& bytes(value.totalBytes) && bytes(value.totalBytes)
&& (value.uploadBytes === undefined || bytes(value.uploadBytes))
&& (value.downloadBytes === undefined || bytes(value.downloadBytes))
&& nullableTimestamp(value.gatewayObservedAt) && nullableTimestamp(value.gatewayObservedAt)
&& nullableTimestamp(value.proxyObservedAt) && nullableTimestamp(value.proxyObservedAt)
&& nullableTimestamp(value.observedAt) && nullableTimestamp(value.observedAt)
@@ -151,12 +229,19 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
} }
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot { 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) if (!record(value)
|| !Number.isSafeInteger(value.revision) || !Number.isSafeInteger(value.revision)
|| typeof value.revision !== 'number' || typeof value.revision !== 'number'
|| value.revision < 0 || value.revision < 0
|| !Array.isArray(value.devices) || !Array.isArray(value.devices)
|| !value.devices.every(validDevice) || !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) || !Number.isSafeInteger(value.trafficHistoryCapacity)
|| typeof value.trafficHistoryCapacity !== 'number' || typeof value.trafficHistoryCapacity !== 'number'
|| value.trafficHistoryCapacity <= 0 || value.trafficHistoryCapacity <= 0
@@ -168,5 +253,14 @@ function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot { export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
assertDeviceSnapshot(value); 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, type FormEvent,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { Drawer } from '../../ui/Drawer.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { import {
CONNECTIVITY_IP_SOURCES, CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_NETWORK_SOURCE,
CONNECTIVITY_SITES, CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES, MAX_CUSTOM_DIAGNOSTIC_SERVICES,
type DiagnosticService,
type DiagnosticSettings,
} from '../../../shared/connectivityDiagnostics.js'; } from '../../../shared/connectivityDiagnostics.js';
import { import {
parseConnectivityResult, parseConnectivityResult,
@@ -18,16 +23,11 @@ import {
type DiagnosticSiteResult, type DiagnosticSiteResult,
} from './connectivityResult.js'; } from './connectivityResult.js';
import type { DiagnosticsFeature } from './DiagnosticsFeature.js'; import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
import { saveCustomDiagnosticService } from './customServiceAction.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'; const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'; const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
interface DiagnosticService extends Record<string, unknown> {
id: string;
label: string;
url: string;
}
interface IpSourceDefinition { interface IpSourceDefinition {
id: string; id: string;
label: string; label: string;
@@ -35,10 +35,8 @@ interface IpSourceDefinition {
} }
type StatusValue = [className: string, label: string]; type StatusValue = [className: string, label: string];
type RunConnectivityDiagnostics = ( type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
services: DiagnosticService[], type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
target: string,
) => Promise<unknown>;
function record(value: unknown): value is Record<string, unknown> { function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value); 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( function resultStatus(
site: DiagnosticSiteResult | undefined, site: DiagnosticSiteResult | undefined,
pending: boolean, pending: boolean,
@@ -106,6 +113,34 @@ function Status({ value, route }: { value: StatusValue; route: string }) {
</span>; </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) { function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) {
if (!path?.available) return null; if (!path?.available) return null;
return source.family === 6 return source.family === 6
@@ -128,10 +163,38 @@ function IpCell({
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />; if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />; if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
if (!path?.available) return <Status value={['is-muted', '—']} 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} />; if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>; 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">&nbsp;</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 : <>&nbsp;</>}</span>
</span>;
}
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) { function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
const merged = [...previous]; const merged = [...previous];
for (const item of incoming) { for (const item of incoming) {
@@ -159,6 +222,7 @@ function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPat
ipv4: { addresses, sources }, ipv4: { addresses, sources },
ipv6, ipv6,
ipv6Source, ipv6Source,
network: incoming.available === false ? null : incoming.network ?? previous?.network ?? null,
sites, sites,
}; };
} }
@@ -172,43 +236,49 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
export function ConnectivityDiagnosticsPanel({ export function ConnectivityDiagnosticsPanel({
feature, feature,
runConnectivityDiagnostics, runConnectivityDiagnostics,
settings,
updateSettings,
isGateway, isGateway,
}: { }: {
feature: DiagnosticsFeature; feature: DiagnosticsFeature;
runConnectivityDiagnostics: RunConnectivityDiagnostics; runConnectivityDiagnostics: RunConnectivityDiagnostics;
settings: DiagnosticSettings;
updateSettings: UpdateSettings;
isGateway: boolean; isGateway: boolean;
}) { }) {
const [result, setResult] = useState<ConnectivityResult | null>(null); const [result, setResult] = useState<ConnectivityResult | null>(null);
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
const [activeTarget, setActiveTarget] = useState<string | null>(null); const [activeTarget, setActiveTarget] = useState<string | null>(null);
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
const [customServices, setCustomServices] = useState(readCustomServices);
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [removingServiceId, setRemovingServiceId] = useState(''); const [removingServiceId, setRemovingServiceId] = useState('');
const [settingsSaving, setSettingsSaving] = useState(false);
const [serviceName, setServiceName] = useState(''); const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState(''); const [serviceUrl, setServiceUrl] = useState('');
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const sheetRef = useRef<HTMLDivElement>(null); const sheetRef = useRef<HTMLDivElement>(null);
const runnerRef = useRef<HTMLSpanElement>(null); const runnerRef = useRef<HTMLSpanElement>(null);
const previousTargetRef = useRef<string | null>(null); const previousTargetRef = useRef<string | null>(null);
const retryTargetRef = useRef<string | undefined>(undefined);
const migrationAttemptedRef = useRef(false);
const requestError = requestDetails(error); const requestError = requestDetails(error);
const { customServices, hiddenServiceIds } = settings;
useEffect(() => { useEffect(() => {
try { if (settings.configured) {
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices)); clearLegacyServices();
} catch { return;
// The service still works for this session when browser storage is unavailable.
} }
}, [customServices]); if (migrationAttemptedRef.current) return;
migrationAttemptedRef.current = true;
useEffect(() => { void updateSettings({
try { customServices: readCustomServices(),
localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds)); hiddenServiceIds: readHiddenServices(),
} catch { }).then((saved) => {
// The service list still works for this session when browser storage is unavailable. if (saved === false) return;
} clearLegacyServices();
}, [hiddenServiceIds]); });
}, [settings.configured, updateSettings]);
useLayoutEffect(() => { useLayoutEffect(() => {
const sheet = sheetRef.current; const sheet = sheetRef.current;
@@ -234,18 +304,20 @@ export function ConnectivityDiagnosticsPanel({
previousTargetRef.current = activeTarget; previousTargetRef.current = activeTarget;
}, [activeTarget]); }, [activeTarget]);
async function run() { async function run(onlyTarget?: string) {
retryTargetRef.current = onlyTarget;
setStatus('running'); setStatus('running');
setError(null); setError(null);
try { try {
let next = result; let next = result;
const targets = [ const targets = onlyTarget ? [onlyTarget] : [
CONNECTIVITY_NETWORK_SOURCE.id,
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`), ...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
...sites.map(({ id }) => `site:${id}`), ...sites.map(({ id }) => `site:${id}`),
]; ];
for (const target of targets) { for (const target of targets) {
setActiveTarget(target); 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; const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
next = legacyFullResult ? partial : mergeResult(next, partial); next = legacyFullResult ? partial : mergeResult(next, partial);
setResult(next); setResult(next);
@@ -260,17 +332,18 @@ export function ConnectivityDiagnosticsPanel({
} }
} }
function addService(event: FormEvent<HTMLFormElement>) { async function addService(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
try { try {
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return; setSettingsSaving(true);
const parsed = new URL(serviceUrl.trim()); const saved = await saveCustomDiagnosticService({
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.'); name: serviceName,
setCustomServices((services) => [...services, { url: serviceUrl,
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`, customServices,
label: serviceName.trim() || parsed.hostname, hiddenServiceIds,
url: parsed.href, updateSettings,
}]); });
if (!saved) return;
setServiceName(''); setServiceName('');
setServiceUrl(''); setServiceUrl('');
setFormError(''); setFormError('');
@@ -281,37 +354,52 @@ export function ConnectivityDiagnosticsPanel({
? Reflect.get(validationError, 'message') ? Reflect.get(validationError, 'message')
: undefined; : undefined;
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.'); setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
} finally {
setSettingsSaving(false);
} }
} }
function removeService(serviceId: string) { function removeService(serviceId: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
finishRemoveService(serviceId); void finishRemoveService(serviceId);
return; return;
} }
setRemovingServiceId(serviceId); setRemovingServiceId(serviceId);
} }
function finishRemoveService(serviceId: string) { async function finishRemoveService(serviceId: string) {
const update = () => flushSync(() => { const update = async () => {
if (serviceId === 'draft') { if (serviceId === 'draft') {
setAdding(false); flushSync(() => {
setServiceName(''); setAdding(false);
setServiceUrl(''); setServiceName('');
setFormError(''); setServiceUrl('');
} else if (serviceId.startsWith('custom-')) { setFormError('');
setCustomServices((services) => services.filter((service) => service.id !== serviceId)); setRemovingServiceId('');
} else { });
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]); return;
} }
setRemovingServiceId(''); setSettingsSaving(true);
setResult(null); 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) { if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update(); await update();
return; return;
} }
document.startViewTransition(update); await document.startViewTransition(update).finished;
} }
const pending = status === 'running'; const pending = status === 'running';
@@ -319,28 +407,24 @@ export function ConnectivityDiagnosticsPanel({
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)), ...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
...customServices, ...customServices,
]; ];
const serviceEditorBlocked = pending || Boolean(removingServiceId); const serviceEditorBlocked = pending || settingsSaving || Boolean(removingServiceId);
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : ''); const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
const { isOpen: open, panelRef, closeRef, close: onClose } = feature; const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
return ( return (
<aside <Drawer
ref={panelRef} panelRef={panelRef}
closeRef={closeRef}
sheetRef={sheetRef}
id="client-diagnostics" id="client-diagnostics"
className={`client-drawer client-instructions client-diagnostics${open ? ' is-open' : ''}`} className="client-instructions client-diagnostics"
aria-labelledby="client-diagnostics-title" sheetClassName="client-instructions-sheet client-diagnostics-sheet"
aria-hidden={!open} open={open}
inert={!open ? true : undefined} 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"> <header className="client-instructions-header client-diagnostics-header">
<span>{isGateway ? 'Gateway' : 'Connect'} · Direct VPN</span> <span>{isGateway ? 'Gateway' : 'Connect'} · Direct VPN</span>
<div className="client-diagnostics-title-row"> <div className="client-diagnostics-title-row">
@@ -352,13 +436,13 @@ export function ConnectivityDiagnosticsPanel({
aria-label="Проверить маршруты" aria-label="Проверить маршруты"
aria-busy={pending} aria-busy={pending}
disabled={pending} disabled={pending}
onClick={run} onClick={() => run()}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" /> <path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
</svg> </svg>
</button> </button>
<span className="client-tooltip" role="tooltip">Проверить маршруты</span> <Tooltip>Проверить маршруты</Tooltip>
</span> </span>
</div> </div>
</header> </header>
@@ -366,7 +450,7 @@ export function ConnectivityDiagnosticsPanel({
{Boolean(error) && <div className="client-diagnostics-feedback"> {Boolean(error) && <div className="client-diagnostics-feedback">
<div className="client-diagnostics-error" role="alert"> <div className="client-diagnostics-error" role="alert">
<span>{requestError.message}</span> <span>{requestError.message}</span>
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>} {requestError.retryable && <button type="button" onClick={() => run(retryTargetRef.current)}>Повторить</button>}
</div> </div>
</div>} </div>}
@@ -380,11 +464,44 @@ export function ConnectivityDiagnosticsPanel({
<th>Напрямую</th> <th>Напрямую</th>
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th> <th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
</tr></thead> </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 target = `ip:${source.id}`;
const running = activeTarget === target; const running = activeTarget === target;
return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}> 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?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td>
<td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td> <td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td>
</tr>})}</tbody> </tr>})}</tbody>
@@ -416,11 +533,19 @@ export function ConnectivityDiagnosticsPanel({
style={{ viewTransitionName: removing ? 'none' : `diagnostic-service-${site.id}` }} style={{ viewTransitionName: removing ? 'none' : `diagnostic-service-${site.id}` }}
inert={removing ? true : undefined} 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(direct, running)} route={`Напрямую, ${site.label}`} /></span>
<span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span> <span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span>
<button <button
className="client-local-rule-delete" className="client-row-delete"
type="button" type="button"
aria-label={`Удалить сервис ${site.label}`} aria-label={`Удалить сервис ${site.label}`}
disabled={serviceEditorBlocked} disabled={serviceEditorBlocked}
@@ -429,7 +554,7 @@ export function ConnectivityDiagnosticsPanel({
<span <span
className="client-delete-strike" className="client-delete-strike"
aria-hidden="true" aria-hidden="true"
onAnimationEnd={() => finishRemoveService(site.id)} onAnimationEnd={() => void finishRemoveService(site.id)}
/> />
</div>; </div>;
})} })}
@@ -466,7 +591,7 @@ export function ConnectivityDiagnosticsPanel({
<button type="submit" disabled={serviceEditorBlocked}>Добавить</button> <button type="submit" disabled={serviceEditorBlocked}>Добавить</button>
</span> </span>
<button <button
className="client-local-rule-delete" className="client-row-delete"
type="button" type="button"
aria-label="Отменить добавление сервиса" aria-label="Отменить добавление сервиса"
onClick={() => removeService('draft')} onClick={() => removeService('draft')}
@@ -474,15 +599,15 @@ export function ConnectivityDiagnosticsPanel({
<span <span
className="client-delete-strike" className="client-delete-strike"
aria-hidden="true" aria-hidden="true"
onAnimationEnd={() => finishRemoveService('draft')} onAnimationEnd={() => void finishRemoveService('draft')}
/> />
</form>} </form>}
{!sites.length && !adding && <p className="client-diagnostics-services-empty">Сервисов пока нет.</p>} {!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 <button
className="client-local-rule-add" className="client-row-add"
type="button" type="button"
disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES} disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
onClick={() => setAdding(true)} onClick={() => setAdding(true)}
@@ -493,7 +618,6 @@ export function ConnectivityDiagnosticsPanel({
</div> </div>
</section> </section>
</div> </Drawer>
</aside>
); );
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { RailAction } from '../../ui/RailAction.js';
export function useDiagnosticsFeature() { export function useDiagnosticsFeature() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -42,24 +43,30 @@ export type DiagnosticsFeature = ReturnType<typeof useDiagnosticsFeature>;
export function DiagnosticsToggle({ export function DiagnosticsToggle({
feature, feature,
open,
onToggle, onToggle,
}: { }: {
feature: DiagnosticsFeature; feature: DiagnosticsFeature;
open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
return <button return <RailAction
ref={feature.toggleRef} buttonRef={feature.toggleRef}
className={`client-instructions-toggle client-diagnostics-toggle${feature.isOpen ? ' is-open' : ''}`} className="client-instructions-toggle client-diagnostics-toggle"
type="button" open={open}
aria-expanded={feature.isOpen} controls="client-diagnostics"
aria-controls="client-diagnostics" ariaLabel={open ? 'Закрыть диагностику' : 'Проверить маршруты'}
aria-label={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'} label="Диагностика"
onClick={onToggle} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <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-trace" d="M4.5 10h2l1.2-2.4 2.2 4.8 1.2-2.4h3.4" />
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" /> <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> </svg>
<span>Диагностика</span> </RailAction>;
</button>;
} }
@@ -12,6 +12,14 @@ export interface DiagnosticSiteResult extends Record<string, unknown> {
latencyMs: number | null; 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> { export interface DiagnosticServer extends Record<string, unknown> {
id: string; id: string;
label: string; label: string;
@@ -26,6 +34,7 @@ export interface DiagnosticPath extends Record<string, unknown> {
}; };
ipv6: string | null; ipv6: string | null;
ipv6Source: DiagnosticIpResult | null; ipv6Source: DiagnosticIpResult | null;
network?: DiagnosticNetworkResult | null;
sites: DiagnosticSiteResult[]; sites: DiagnosticSiteResult[];
server?: DiagnosticServer | null; server?: DiagnosticServer | null;
} }
@@ -59,6 +68,19 @@ function validSiteResult(value: unknown): value is DiagnosticSiteResult {
&& nullableNonnegativeNumber(value.latencyMs); && 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 { function validServer(value: unknown): value is DiagnosticServer | null {
return value === null || (record(value) return value === null || (record(value)
&& typeof value.id === 'string' && typeof value.id === 'string'
@@ -76,6 +98,7 @@ function validPath(value: unknown): value is DiagnosticPath {
&& value.ipv4.sources.every(validIpResult) && value.ipv4.sources.every(validIpResult)
&& (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0)) && (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0))
&& (value.ipv6Source === null || validIpResult(value.ipv6Source)) && (value.ipv6Source === null || validIpResult(value.ipv6Source))
&& (!Object.hasOwn(value, 'network') || value.network === null || validNetworkResult(value.network))
&& Array.isArray(value.sites) && Array.isArray(value.sites)
&& value.sites.every(validSiteResult); && 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;
}
+1
View File
@@ -4,3 +4,4 @@ export {
useDiagnosticsFeature, useDiagnosticsFeature,
type DiagnosticsFeature, type DiagnosticsFeature,
} from './DiagnosticsFeature.js'; } 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>;
}
+1
View File
@@ -0,0 +1 @@
export { FailoverPanel, FailoverToggle, useFailoverFeature } from './FailoverFeature.js';
@@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom'; 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 { copyText } from '../../utils/clientControls.js';
import { instructionBlocks } from './instructionBlocks.js'; import { instructionBlocks } from './instructionBlocks.js';
@@ -55,22 +58,48 @@ function InstructionBlock({
open: boolean; open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null); const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 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(() => () => { 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) { 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 { try {
await copyText(action.text); await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false }); feedback = { failed: false };
} catch { } 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 ( return (
@@ -106,23 +135,19 @@ function InstructionBlock({
: <code>{block.code}</code>)} : <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies"> {block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => { {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}> return <div className="client-instruction-copy" key={action.id}>
<span>{action.label}</span> <span>{action.label}</span>
<button <CopyButton
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`} className="client-instruction-copy-button"
type="button" label="Скопировать"
feedback={feedback}
onClick={() => copyInstruction(action)} onClick={() => copyInstruction(action)}
> />
<span className="client-copy-label">Скопировать</span>
{feedback && <span className="client-copy-feedback" aria-hidden="true">
{feedback.failed ? 'Ошибка' : 'Скопировано'}
</span>}
</button>
</div>; </div>;
})} })}
<span className="client-live-region" role="status" aria-live="polite"> <span className="client-live-region" role="status" aria-live="polite">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''} {copyAnnouncement?.message || ''}
</span> </span>
</div>} </div>}
{block.note && <p className="client-instruction-note">{block.note}</p>} {block.note && <p className="client-instruction-note">{block.note}</p>}
@@ -207,26 +232,32 @@ export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({ export function InstructionsToggle({
feature, feature,
open,
onToggle, onToggle,
}: { }: {
feature: InstructionsFeature; feature: InstructionsFeature;
open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
return <button return <RailAction
ref={feature.toggleRef} buttonRef={feature.toggleRef}
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`} className="client-instructions-toggle"
type="button" open={open}
aria-expanded={feature.isOpen} controls="client-instructions"
aria-controls="client-instructions" ariaLabel={open ? 'Закрыть инструкции' : 'Как использовать'}
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'} label="Как использовать"
onClick={onToggle} onClick={onToggle}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" /> <g className="client-rail-book-page-position is-left">
<path d="M12 11v5M12 8h.01" /> <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> </svg>
<span>Как использовать</span> </RailAction>;
</button>;
} }
export function InstructionsPanel({ export function InstructionsPanel({
@@ -236,40 +267,34 @@ export function InstructionsPanel({
feature: InstructionsFeature; feature: InstructionsFeature;
isGateway: boolean; isGateway: boolean;
}) { }) {
return <aside return <Drawer
ref={feature.panelRef} panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-instructions" id="client-instructions"
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`} className="client-instructions"
aria-labelledby="instructions-title" sheetClassName="client-instructions-sheet"
aria-hidden={!feature.isOpen} open={feature.isOpen}
inert={!feature.isOpen ? true : undefined} labelledBy="instructions-title"
closeLabel="Закрыть инструкции"
onClose={feature.close}
> >
<div className="client-drawer-sheet client-instructions-sheet"> <header className="client-instructions-header">
<button <span>Подключение</span>
ref={feature.closeRef} <h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
className="client-drawer-close" <div className="client-instructions-intro">
type="button" {feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
aria-label="Закрыть инструкции"
onClick={feature.close}
>×</button>
<header className="client-instructions-header">
<span>Подключение</span>
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
<div className="client-instructions-intro">
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</header>
<div className="client-instruction-list">
{feature.guides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === feature.openInstructionId}
onToggle={() => feature.toggleInstruction(block.id)}
/>
))}
</div> </div>
</header>
<div className="client-instruction-list">
{feature.guides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === feature.openInstructionId}
onToggle={() => feature.toggleInstruction(block.id)}
/>
))}
</div> </div>
</aside>; </Drawer>;
} }
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) }, { id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson }, { 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.',
}] : []), }] : []),
]; ];
} }
+614 -91
View File
@@ -2,15 +2,39 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
type FocusEvent as ReactFocusEvent,
type FormEvent, type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode, type ReactNode,
type RefObject,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom'; 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 type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js'; import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.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]> = [ const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'], ['domain', 'Точный домен'],
@@ -30,6 +54,8 @@ interface DraftRule extends RouteRule {
} }
interface RoutingState { interface RoutingState {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode?: string;
localRules?: RouteRule[]; localRules?: RouteRule[];
activeLocalRules?: RouteRule[]; activeLocalRules?: RouteRule[];
localRulesRevision?: number; localRulesRevision?: number;
@@ -49,39 +75,66 @@ interface RoutingSaveState {
localRulesPendingRestart: boolean; 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; let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({ const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule, ...rule,
enabled: rule?.enabled !== false, enabled: rule?.enabled !== false,
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
_key: `route-rule-${localRuleDraftId += 1}`, _key: `route-rule-${localRuleDraftId += 1}`,
}); });
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing) .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( const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules rules
.filter((rule) => !rule.removing) .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) => ( const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}` `${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( function localRuleStatus(
rule: DraftRule, rule: DraftRule,
index: number,
savedRules: RouteRule[], savedRules: RouteRule[],
activeRules: RouteRule[], activeRules: RouteRule[],
runtimeActive: boolean, runtimeActive: boolean,
bypassed: boolean,
) { ) {
const key = localRuleKey(rule); if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено']; if (!rule.enabled) return ['disabled', 'Выключено'];
if (bypassed) return ['bypassed', 'Обходится'];
if (!runtimeActive) return ['saved', 'Сохранено']; if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно']; if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска']; return ['pending', 'Ждёт перезапуска'];
} }
@@ -120,19 +173,34 @@ export function useRoutingFeature({
const [rules, setRules] = useState<DraftRule[]>([]); const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0); const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false); const [confirmingClose, setConfirmingClose] = useState(false);
const [liftedKey, setLiftedKey] = useState<string | null>(null);
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
const panelRef = useRef<HTMLElement>(null); const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null); const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]'); 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 savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || []; 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 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 const pendingCount = pendingRestart
? savedRules.filter((rule) => ( ? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0; : 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); const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => { useEffect(() => {
@@ -152,6 +220,11 @@ export function useRoutingFeature({
if (event.type === 'keydown') { if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent; const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return; if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
if (dragRef.current?.lifted) {
keyboardEvent.preventDefault();
cancelReorder();
return;
}
} else { } else {
if (panelRef.current?.contains(event.target as Node)) return; if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.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]); }, [isOpen, dirty]);
useEffect(() => () => {
const session = dragRef.current;
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
cancelAnimationFrame(session.raf);
}
dragRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
if (!isOpen || !dirty) return undefined; if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => { const warnBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -177,8 +258,10 @@ export function useRoutingFeature({
}, [isOpen, dirty]); }, [isOpen, dirty]);
function open() { function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled }))); const nextRules = savedRules.map(createLocalRuleDraft);
setRules(savedRules.map(createLocalRuleDraft)); baselineRef.current = localRulesSignature(savedRules);
rulesRef.current = nextRules;
setRules(nextRules);
setRevision(route?.localRulesRevision || 0); setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false); setConfirmingClose(false);
onDismissError(); onDismissError();
@@ -186,11 +269,13 @@ export function useRoutingFeature({
} }
function forceClose() { function forceClose() {
cancelReorder(false);
setIsOpen(false); setIsOpen(false);
} }
function requestClose() { function requestClose() {
if (dirty) { const currentRules = cancelReorder(false);
if (localRulesSignature(currentRules) !== baselineRef.current) {
setConfirmingClose(true); setConfirmingClose(true);
return false; return false;
} }
@@ -199,36 +284,52 @@ export function useRoutingFeature({
} }
function discard() { function discard() {
cancelReorder(false);
setConfirmingClose(false); setConfirmingClose(false);
setIsOpen(false); setIsOpen(false);
} }
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) { function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
setRules((current) => current.map((rule, ruleIndex) => ( 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 ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
))); )));
} }
function add() { function add() {
setRules((current) => [ if (!editable || blocked) return;
updateRules((current) => [
...current, ...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }), createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
]); ]);
} }
function remove(ruleKey: string) { function remove(ruleKey: string) {
if (!editable || blocked) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey)); updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
return; return;
} }
setRules((current) => current.map((rule) => ( updateRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule rule._key === ruleKey ? { ...rule, removing: true } : rule
))); )));
} }
function finishRemove(ruleKey: string) { function finishRemove(ruleKey: string) {
const update = () => flushSync(() => { 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) { if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update(); update();
@@ -239,25 +340,302 @@ export function useRoutingFeature({
async function save(event: FormEvent<HTMLFormElement>) { async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); 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)); const result = routingSaveState(await onSave(values, revision));
if (!result) return; if (!result) return;
baselineRef.current = JSON.stringify(values); baselineRef.current = localRulesSignature(values);
setRevision(result.localRulesRevision); setRevision(result.localRulesRevision);
setConfirmingClose(false); setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(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 { return {
isOpen, isOpen,
rules, rules,
savedRules, savedRules,
activeRules, activeRules,
connected, connected,
editable,
bypassed,
dirty, dirty,
pendingRestart, pendingRestart,
savedUntilStart,
pendingCount, pendingCount,
pendingMessage,
blocked, blocked,
liftedKey,
reorderAnnouncement,
confirmingClose, confirmingClose,
panelRef, panelRef,
toggleRef, toggleRef,
@@ -271,6 +649,14 @@ export function useRoutingFeature({
add, add,
remove, remove,
finishRemove, finishRemove,
startPointerReorder,
movePointerReorder,
finishPointerReorder,
cancelPointerReorder,
losePointerReorder,
handleReorderKey,
handleReorderClick,
handleReorderBlur,
save, 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({ export function RoutingToggle({
feature, feature,
open,
gatewayDirect, gatewayDirect,
isGateway, isGateway,
hasSubscription, hasSubscription,
onOpen, onOpen,
}: { }: {
feature: RoutingFeature; feature: RoutingFeature;
open: boolean;
gatewayDirect: boolean; gatewayDirect: boolean;
isGateway: boolean; isGateway: boolean;
hasSubscription: boolean; hasSubscription: boolean;
onOpen: () => void; onOpen: () => void;
}) { }) {
const disabled = gatewayDirect || (isGateway && !hasSubscription); const disabled = isGateway && !hasSubscription;
return ( return (
<button <RailAction
ref={feature.toggleRef} buttonRef={feature.toggleRef}
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`} className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
type="button" open={open}
controls="client-local-rules"
disabled={disabled} disabled={disabled}
aria-expanded={feature.isOpen} ariaLabel={disabled
aria-controls="client-local-rules" ? 'Правила маршрутизации недоступны: сначала добавьте подписку'
aria-label={disabled : gatewayDirect
? hasSubscription ? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway' : open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
: 'Локальные правила недоступны: сначала добавьте подписку' label={disabled
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'} ? 'Сначала добавьте подписку'
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()} : feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
onClick={() => open ? feature.requestClose() : onOpen()}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" /> <path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" /> <g className="client-rail-rule-knob-position is-top">
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" /> <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> </svg>
<span>{disabled </RailAction>
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
); );
} }
@@ -430,10 +852,10 @@ export function RoutingPendingStatus({
blocked: boolean; blocked: boolean;
onRestart: () => unknown; onRestart: () => unknown;
}) { }) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite"> return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && ( {feature.pendingMessage && (
<> <>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span> <span>{feature.pendingMessage}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button> <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 }) { export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const [helpOpen, setHelpOpen] = useState(false);
const draftRules = feature.rules.filter((rule) => !rule.removing); 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()); const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return ( return (
<aside <Drawer
ref={feature.panelRef as RefObject<HTMLElement>} panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-local-rules" id="client-local-rules"
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`} className="client-local-rules"
aria-labelledby="local-rules-title" sheetClassName="client-local-rules-sheet"
aria-hidden={!feature.isOpen} open={feature.isOpen}
inert={!feature.isOpen ? true : undefined} 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"> <header className="client-local-rules-header">
<span>Маршрутизация</span> <span>Маршрутизация</span>
<button <button
className="client-local-rules-save" className="client-local-rules-save"
type="submit" type="submit"
form="client-local-rules-form" form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty} disabled={editorDisabled || !feature.dirty}
> >
Сохранить Сохранить
</button> </button>
<h2 id="local-rules-title">Локальные правила</h2> <h2 id="local-rules-title">Правила маршрутизации</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p> {!feature.editable && (
{feature.pendingRestart && ( <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"> <p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box. Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p> </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> </header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}> <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"> <section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span> <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) => { {feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus( const [status, statusLabel] = localRuleStatus(
rule, rule,
index,
feature.savedRules, feature.savedRules,
feature.activeRules, feature.activeRules,
feature.connected, feature.connected,
feature.bypassed,
); );
const lifted = feature.liftedKey === rule._key;
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
return ( return (
<div <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} key={rule._key}
data-rule-key={rule._key}
data-route={rule.outbound}
role="listitem"
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }} style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined} inert={rule.removing ? true : undefined}
> >
<button <button
className="client-local-rule-enabled" className="client-rule-handle"
type="button" type="button"
role="switch" aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
aria-checked={rule.enabled} aria-describedby="client-rule-reorder-instructions"
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`} aria-pressed={lifted}
onClick={() => feature.change(index, 'enabled', !rule.enabled)} 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"> <svg viewBox="0 0 16 28" aria-hidden="true">
<circle cx="10" cy="10" r="6" /> <circle cx="5" cy="5" r="1.25" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" /> <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> </svg>
</button> </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 <RuleTypePicker
value={rule.type} value={rule.type}
ruleKey={rule._key} ruleKey={rule._key}
index={index} index={index}
disabled={rule.removing} disabled={editorDisabled || rule.removing}
onChange={(type) => feature.change(index, 'type', type)} onChange={(type) => feature.change(index, 'type', type)}
/> />
<input <input
@@ -525,19 +1029,42 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
required required
disabled={editorDisabled}
aria-label={`Значение правила ${index + 1}`} aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]} placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value} value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.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 <button
className="client-local-rule-delete" className="client-row-delete"
type="button" type="button"
aria-label={`Удалить правило ${index + 1}`} aria-label={`Удалить правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.remove(rule._key)} 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> </button>
<span <span
className="client-delete-strike" className="client-delete-strike"
@@ -547,26 +1074,22 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
</div> </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>
<div className="client-local-rule-add-slot"> <div className="client-row-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}> <button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
+ Добавить правило + Добавить правило
</button> </button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span> <span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div> </div>
</section> </section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot} {statusSlot}
<div className="client-local-rules-actions"> <div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button> <button type="button" onClick={feature.requestClose}>Отмена</button>
</div> </div>
</form> </form>
</div> </Drawer>
</aside>
); );
} }
@@ -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;
}
+44 -35
View File
@@ -22,7 +22,7 @@ type PickerServer = HarborServer & {
const FAVORITES_KEY = 'harbor-server-favorites'; const FAVORITES_KEY = 'harbor-server-favorites';
const RECENT_KEY = 'harbor-server-recent'; const RECENT_KEY = 'harbor-server-recent';
const AUTO_KEY = 'harbor-server-auto'; const AUTO_KEY = 'harbor-server-auto';
const SIMPLE_SERVER_LIMIT = 5; const INLINE_SERVER_ROWS = 8;
interface PingResult { interface PingResult {
id?: string; id?: string;
@@ -53,9 +53,9 @@ function write(key: string, value: string | string[]) {
} }
} }
function readAuto() { function readAuto(key: string) {
try { try {
return localStorage.getItem(AUTO_KEY) === 'true'; return localStorage.getItem(key) === 'true';
} catch { } catch {
return false; return false;
} }
@@ -93,7 +93,7 @@ function ServerCheckButton({
onClick: () => void; onClick: () => void;
}) { }) {
return <button return <button
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`} className={`client-server-check${checking ? ' is-checking' : ''}`}
type="button" type="button"
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'} aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
disabled={checking || disabled} disabled={checking || disabled}
@@ -102,7 +102,7 @@ function ServerCheckButton({
<svg viewBox="0 0 24 24" aria-hidden="true"> <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" /> <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> </svg>
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span> <span className="client-server-check-label">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
</button>; </button>;
} }
@@ -127,19 +127,25 @@ function ServerRow({
}) { }) {
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping); 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 <button
className={`client-server${selected ? ' is-selected' : ''}`} className={`client-server${selected ? ' is-selected' : ''}`}
type="button" type="button"
disabled={disabled} disabled={disabled}
aria-pressed={selected} aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`} aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
onClick={() => onSelect(server.id)} onClick={() => onSelect(server.id)}
> >
<strong>{server.label}</strong> <strong>{server.label}</strong>
{(server.city || server.country) && <small>{[server.city, server.country].filter(Boolean).join(' · ')}</small>}
</button> </button>
{(ping || onFavorite) && <div className="client-server-meta"> <div className="client-server-meta">
<ServerHealth ping={ping} /> <ServerHealth ping={ping} />
{onFavorite && <button {onFavorite && <button
className={`client-server-favorite${favorite ? ' is-active' : ''}`} className={`client-server-favorite${favorite ? ' is-active' : ''}`}
@@ -148,38 +154,43 @@ function ServerRow({
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`} aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
onClick={() => onFavorite(server.id)} onClick={() => onFavorite(server.id)}
></button>} ></button>}
</div>} </div>
</div>; </div>;
} }
interface ServerPickerProps { interface ServerPickerProps {
pingServers: (ids: string[]) => Promise<unknown>; profileId: string;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
servers: PickerServer[]; servers: PickerServer[];
selectedServerId: string; selectedServerId: string;
disabled: boolean; disabled: boolean;
prompt: boolean;
leaving: boolean; leaving: boolean;
revealVersion: number; revealVersion: number;
anchorServerId?: string;
onSelect: (id: string) => unknown; onSelect: (id: string) => unknown;
} }
export function ServerPicker({ export function ServerPicker({
profileId,
pingServers, pingServers,
servers, servers,
selectedServerId, selectedServerId,
disabled, disabled,
prompt,
leaving, leaving,
revealVersion, revealVersion,
anchorServerId = '',
onSelect, onSelect,
}: ServerPickerProps) { }: ServerPickerProps) {
const favoritesKey = `${FAVORITES_KEY}:${profileId}`;
const recentKey = `${RECENT_KEY}:${profileId}`;
const autoKey = `${AUTO_KEY}:${profileId}`;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false); const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all'); const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY)); const [favorites, setFavorites] = useState(() => readList(favoritesKey));
const [recent, setRecent] = useState(() => readList(RECENT_KEY)); const [recent, setRecent] = useState(() => readList(recentKey));
const [autoActive, setAutoActive] = useState(readAuto); const [autoActive, setAutoActive] = useState(() => readAuto(autoKey));
const [collapsed, setCollapsed] = useState<string[]>([]); const [collapsed, setCollapsed] = useState<string[]>([]);
const [pings, setPings] = useState<PingState>({}); const [pings, setPings] = useState<PingState>({});
const [checking, setChecking] = useState(false); 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) || []); if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
return found; return found;
}, [servers, query, view, favorites, recent]); }, [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 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 visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
const grouped = servers.length >= 10; const grouped = servers.length >= 10;
@@ -208,18 +219,18 @@ export function ServerPicker({
function toggleFavorite(id: string) { function toggleFavorite(id: string) {
setFavorites((current) => { setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current]; const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next); write(favoritesKey, next);
return next; return next;
}); });
} }
function select(id: string, automatic = false) { function select(id: string, automatic = false) {
setAutoActive(automatic); setAutoActive(automatic);
write(AUTO_KEY, String(automatic)); write(autoKey, String(automatic));
if (!automatic) { if (!automatic) {
setRecent((current) => { setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5); const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next); write(recentKey, next);
return next; return next;
}); });
} }
@@ -236,7 +247,7 @@ export function ServerPicker({
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])), ...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
})); }));
try { try {
const results = parseServerPingResults(await pingServers(ids)) as PingResult[]; const results = parseServerPingResults(await pingServers(profileId, ids)) as PingResult[];
setPings((current) => ({ setPings((current) => ({
...current, ...current,
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])), ...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
@@ -251,7 +262,10 @@ export function ServerPicker({
}])), }])),
})); }));
} finally { } 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) => ({ setPings((current) => ({
...current, ...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])), ...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
@@ -262,13 +276,12 @@ export function ServerPicker({
if (servers.length === 1) { if (servers.length === 1) {
return <section className="client-servers" aria-label="Выберите сервер"> return <section className="client-servers" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-toolbar is-single"> <div className="client-server-toolbar is-single">
<span className="client-server-toolbar-title">Список серверов</span> <span className="client-server-toolbar-title">Список серверов</span>
<ServerCheckButton checking={checking} onClick={checkVisible} /> <ServerCheckButton checking={checking} onClick={checkVisible} />
</div> </div>
<div className="client-server-grid"> <div className="client-server-grid">
<ServerRow {servers[0].id !== anchorServerId && <ServerRow
server={servers[0]} server={servers[0]}
selected={servers[0].id === selectedServerId} selected={servers[0].id === selectedServerId}
favorite={false} favorite={false}
@@ -276,7 +289,7 @@ export function ServerPicker({
disabled={disabled} disabled={disabled}
index={0} index={0}
onSelect={onSelect} onSelect={onSelect}
/> />}
</div> </div>
</section>; </section>;
} }
@@ -296,12 +309,11 @@ export function ServerPicker({
)); ));
const simpleServers = [ const simpleServers = [
...(selected ? [selected] : []), ...(selected && selected.id !== anchorServerId ? [selected] : []),
...servers.filter(({ id }) => id !== selectedServerId), ...servers.filter(({ id }) => id !== selectedServerId && id !== anchorServerId),
].slice(0, SIMPLE_SERVER_LIMIT); ];
return <section className="client-servers is-scalable" aria-label="Выберите сервер"> return <section className={`client-servers is-scalable${simpleServers.length <= INLINE_SERVER_ROWS ? ' is-short' : ''}`} aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-toolbar"> <div className="client-server-toolbar">
<span className="client-server-toolbar-title">Список серверов</span> <span className="client-server-toolbar-title">Список серверов</span>
<ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} /> <ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} />
@@ -324,7 +336,7 @@ export function ServerPicker({
inert={advanced ? true : undefined} inert={advanced ? true : undefined}
> >
<div className="client-server-mode-panel-inner"> <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"> <div className="client-server-grid">
{simpleServers.map((server, index) => <ServerRow {simpleServers.map((server, index) => <ServerRow
key={server.id} key={server.id}
@@ -336,9 +348,6 @@ export function ServerPicker({
onSelect={select} onSelect={select}
/>)} />)}
</div> </div>
{servers.length > simpleServers.length && <p className="client-server-overflow-note">
Ещё {servers.length - simpleServers.length} доступны через поиск
</p>}
</div> </div>
</div> </div>
</div> </div>
@@ -389,7 +398,7 @@ export function ServerPicker({
fallback={autoActive ? undefined : 'Первый стабильный сервер'} fallback={autoActive ? undefined : 'Первый стабильный сервер'}
/> />
</button> </button>
{selected && <ServerRow {selected && selected.id !== anchorServerId && <ServerRow
server={selected} server={selected}
selected selected
favorite={favorites.includes(selected.id)} favorite={favorites.includes(selected.id)}
@@ -401,7 +410,7 @@ export function ServerPicker({
/>} />}
</div> </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>} {!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => { {grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group); const isCollapsed = collapsed.includes(group);
File diff suppressed because it is too large Load Diff
+503
View File
@@ -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