Author SHA1 Message Date
dokril 4c58384056 Harden traffic history worker lifecycle and query performance
Build and Deploy Gateway / build-and-push (push) Successful in 37s
Build and Deploy Gateway / deploy (push) Successful in 19s
2026-09-19 09:58:54 +03:00
dokril 74c5b66482 Update Harbor client implementation
Build and Deploy Gateway / build-and-push (push) Successful in 34s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-09-10 21:54:18 +03:00
dokril 1ae23d848b Migrate Harbor state and traffic history to SQLite
Build and Deploy Gateway / build-and-push (push) Successful in 1m22s
Build and Deploy Gateway / deploy (push) Successful in 16s
2026-09-10 19:21:21 +03:00
dokril ab14fc979e Clarify VPN and Direct traffic metrics in Grafana dashboard
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-09-01 07:01:26 +03:00
dokril 76a99f098a Add DNS diagnostics across dataplane and client
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-09-01 04:16:15 +03:00
dokril e0bdafd25e Refine failover channel status layout
Build and Deploy Gateway / build-and-push (push) Successful in 43s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-08-31 14:52:36 +03:00
dokril 3c2eefe108 Persist traffic settings and support multi-device traffic views
Build and Deploy Gateway / build-and-push (push) Successful in 32s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-31 06:46:59 +03:00
dokril 6777422a27 Use native traffic counters by default
Build and Deploy Gateway / build-and-push (push) Successful in 29s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-08-31 06:03:02 +03:00
dokril d060e3bada Add traffic group sorting controls
Build and Deploy Gateway / build-and-push (push) Successful in 30s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-08-31 05:58:13 +03:00
dokril 797c73d35c Make shadow traffic inspection the default
Build and Deploy Gateway / build-and-push (push) Successful in 30s
Build and Deploy Gateway / deploy (push) Successful in 14s
2026-08-31 05:43:51 +03:00
dokril 79e00a2390 Interrupt live traffic retry delays on stop
Build and Deploy Gateway / build-and-push (push) Successful in 1m58s
Build and Deploy Gateway / deploy (push) Successful in 19s
2026-08-31 05:34:56 +03:00
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
217 changed files with 36299 additions and 4900 deletions
@@ -0,0 +1,70 @@
---
name: design-harbor-device-ecosystem
description: "Use when changing or reviewing Harbor companion-device discovery, pairing, trust, presence, routing handoff, or their API and persistence contracts. Not for traffic-only inventory or visual polish."
---
# 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. Start with the selected workpack task when one exists. Read `PRODUCT.md` and `workpack/PRODUCT_PRIORITIES.md` for product scope, `workpack/DATA_CONSISTENCY_MODEL.md` for persistence or projection changes, and `workpack/STATUS.md` for active roadmap/cutover work. Any visible UI decision requires `workpack/DESIGN_OWNER_POLICY.md`; reuse an exact owner decision already recorded for this scope.
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
Verify the affected contracts below. Protocol, authentication, shared projection, or migration changes require coverage across the impacted lifecycle; a documentation-only correction uses consistency/link checks. Do not operate live devices without the exact authorization described above:
- 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.
+10 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: design-vpn-client-ui name: design-vpn-client-ui
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there. description: "Use when changing or reviewing Harbor Connect and Gateway client UI. Apply the established visual and motion language; not for administrative Gateway UI unless explicitly requested."
--- ---
# Design VPN Client UI # Design VPN Client UI
@@ -9,14 +9,20 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
## Workflow ## Workflow
1. Read `PRODUCT.md` and the complete client component and styles before editing. 1. Inspect the affected client component, state owner, relevant styles, and nearest analogue. Read `PRODUCT.md` when product behavior or mode semantics change; expand context when shared invariants require it.
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt. 2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work. 3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work. 4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency. 5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
6. Keep geometry stable across every state. Reserve space before animating content. 6. Keep geometry stable across every state. Reserve space before animating content.
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 focused automated checks for affected behavior and `npm run build` for client code changes; use the full `npm test` suite when shared behavior changes or narrower evidence is insufficient. Documentation-only work uses structural/link checks and `git diff --check`. Manual visual inspection requires an explicit request in the current prompt.
## Communicating a proposed change
For a local correction, report the result and focused evidence briefly. For a change spanning
several owners or state lifecycles, use one compact table of affected modules plus the
relevant states, accessibility, and motion decisions. Omit empty sections and unrelated modules.
## Non-negotiable decisions ## Non-negotiable decisions
@@ -40,7 +46,7 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
## Acceptance pass ## Acceptance pass
Before handing off, verify: Check the affected behaviors below using automated evidence where possible. Shared layout or motion changes require broader coverage; a local correction does not require exercising unrelated controls. Manual inspection remains limited to an explicit request in the current prompt:
- Power on/off is unmistakable without reading the label. - Power on/off is unmistakable without reading the label.
- Switching on/off preserves the exact positions of title, timer, and hint. - Switching on/off preserves the exact positions of title, timer, and hint.
@@ -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=native
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
+48 -8
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:24.21.0-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,19 +89,26 @@ 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"
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT" echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
if command -v npm >/dev/null 2>&1; then if command -v npm >/dev/null 2>&1 && node scripts/check-sqlite-runtime.mjs; then
npm ci --no-audit --no-fund npm ci --no-audit --no-fund
npm run typecheck npm run typecheck
npm run check:boundaries npm run check:boundaries
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: the pinned Node 24.21.0 build toolchain is 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,18 +139,21 @@ 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 }}" \
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \ APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \ SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
./scripts/build-runtime-base.sh ./scripts/build-runtime-base.sh
fi fi
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 +166,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 +224,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 }}\""
+2
View File
@@ -8,11 +8,13 @@ data/
# Local roadmap and task workspace # Local roadmap and task workspace
/workpack/ /workpack/
/design-qa.md
# Node/Vite # Node/Vite
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*
+1
View File
@@ -0,0 +1 @@
24.21.0
+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.
+16 -5
View File
@@ -1,8 +1,10 @@
ARG NODE_BUILD_IMAGE=node:20.19-alpine ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
ARG BASE_IMAGE=debian:bookworm-slim ARG BASE_IMAGE=debian:bookworm-slim
FROM ${NODE_BUILD_IMAGE} AS build FROM ${NODE_BUILD_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
RUN node scripts/check-sqlite-runtime.mjs
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY index.html vite.config.ts tsconfig*.json ./ COPY index.html vite.config.ts tsconfig*.json ./
@@ -13,13 +15,14 @@ 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 COPY --from=build /usr/local /usr/local
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
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \ RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
apt-get update \ apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \ && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data dumb-init \
&& rm -rf /var/lib/apt/lists/*; \ && rm -rf /var/lib/apt/lists/*; \
else \ else \
command -v dumb-init >/dev/null \ command -v dumb-init >/dev/null \
@@ -35,7 +38,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 +49,13 @@ 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 --from=build /src/node_modules/tldts /app/node_modules/tldts
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
RUN node /app/scripts/check-sqlite-runtime.mjs
COPY package.json /app/package.json COPY package.json /app/package.json
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -59,6 +69,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"]
+15 -4
View File
@@ -1,8 +1,10 @@
ARG NODE_BUILD_IMAGE=node:20.19-alpine ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
ARG RUNTIME_IMAGE=debian:bookworm-slim ARG RUNTIME_IMAGE=debian:bookworm-slim
FROM ${NODE_BUILD_IMAGE} AS build FROM ${NODE_BUILD_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
RUN node scripts/check-sqlite-runtime.mjs
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY index.html vite.config.ts tsconfig*.json ./ COPY index.html vite.config.ts tsconfig*.json ./
@@ -13,10 +15,11 @@ 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 COPY --from=build /usr/local /usr/local
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 tar \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
@@ -26,7 +29,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 +37,13 @@ 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 --from=build /src/node_modules/tldts /app/node_modules/tldts
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
RUN node /app/scripts/check-sqlite-runtime.mjs
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 +59,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
+8 -3
View File
@@ -1,6 +1,11 @@
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
FROM ${NODE_BUILD_IMAGE} AS node-runtime
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.12.13 COPY --from=node-runtime /usr/local /usr/local
COPY scripts/check-sqlite-runtime.mjs /opt/harbor/check-sqlite-runtime.mjs
RUN node /opt/harbor/check-sqlite-runtime.mjs
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
@@ -32,7 +37,7 @@ RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
-o Acquire::http::Timeout=20 \ -o Acquire::http::Timeout=20 \
-o Acquire::https::Timeout=20 \ -o Acquire::https::Timeout=20 \
-o Acquire::ForceIPv4=true \ -o Acquire::ForceIPv4=true \
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \ install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data dumb-init \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN set -eux; \ RUN set -eux; \
@@ -45,7 +50,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; \
+83 -21
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. Назначения сохраняются в документе устройств внутри `harbor.sqlite`, но маршруты не меняют. После удаления устройства по 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,14 +270,36 @@ 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` | `native` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
| `LOG_LEVEL` | `info` | Уровень подробности журнала | | `LOG_LEVEL` | `info` | Уровень подробности журнала |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно. После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
Production Gateway по умолчанию использует `native`: новый счётчик видит полный жизненный цикл соединений, включая короткие соединения и последние байты перед закрытием. `shadow` оставляет основным старый счётчик и запускает новый только для сравнения. `snapshot` полностью выключает инспектор и опрашивает активные соединения раз в 2 секунды. Режим меняется только при пересоздании обоих 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
```
## Локальная история трафика
Harbor полностью работает без Prometheus. В существующем drawer «Трафик» режимы `Сейчас / История` разделяют текущие соединения и локальные суммы. История поддерживает `24 часа / 7 дней / 30 дней / 90 дней`, поиск, маршрут и устройство на Gateway; строки раскрываются как сервис → домен → полное имя → IP. Например, `www.yandex.ru` и `mail.yandex.com` остаются разными именами внутри группы «Яндекс». IP без наблюдённого домена не выдаётся за распознанный сайт.
`traffic.sqlite` хранит рабочие данные за 90 дней: завершённые минуты за последние 7 дней, далее часы. Текущая история отстаёт не более чем на минуту при исправном сборе; API сообщает фактически доступный период, детализацию и пропуски. История начинается с включения нового native-сбора. Данные по доменам относятся только к соединениям, наблюдаемым sing-box, и не восстанавливают ранее накопленные общие счётчики.
Запись и запросы выполняются в отдельном рабочем потоке. Ошибка базы или переполнение ограниченной очереди отмечает историю как неполную, но не останавливает VPN или экспорт метрик. Для защиты от повторного учёта сохраняются позиции счётчиков: активные — пока нужны их исходные значения, закрытые — до 90 дней либо смены процесса sing-box. Размер зависит не только от доменов, но и от числа соединений; это не база фиксированного размера. Освобождённые страницы переиспользуются без обязательного немедленного уменьшения файла.
## Prometheus и Grafana ## Prometheus и Grafana
Prometheus необязателен. Он хранит только экспортируемые метрики по политике своего владельца, а не копию всей SQLite. Полную доменную/IP-детализацию внешнего архива этот релиз не обещает. Нет синхронизации баз, автоматического восполнения пропущенных scrape, восстановления SQLite из Prometheus или переключения интерфейса на него. Очистка локальной истории не удаляет внешнюю; отсутствие Prometheus не продлевает локальные 90 дней.
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 секунд:
```yaml ```yaml
@@ -272,11 +312,17 @@ scrape_configs:
- targets: ["<gateway>:3456"] - targets: ["<gateway>:3456"]
``` ```
`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`. Gauge `harbor_device_applied_policy{device_id}` показывает последнюю применённую policy: `0` для Direct и `1` для VPN. История начинается с первого Prometheus scrape после обновления и не восстанавливается задним числом. `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 за этот период. Под обзором полоса `Применённый режим` показывает applied policy устройства, а график `Фактический VPN / Direct` независимо показывает маршрут наблюдённых байтов. Поэтому компьютер с policy Direct, браузер которого использует Harbor Proxy, остаётся Direct на полосе режима, но его proxy-соединения учитываются в VPN. Единый фильтр `Устройства` управляет режимом, скоростью, общим трафиком, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация показывает `source × outbound`, включая `proxy · vpn`, и раздельные Direct-пути через sing-box и Linux мимо sing-box. Автообновление настроено на 30 секунд; индикатор предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; существующая проекция экспортируемых domain/outbound counters хранится в памяти до перезапуска, а необязательный Prometheus независимо сохраняет полученные метрики. Полный поток также поступает в отдельную локальную `traffic.sqlite`; её очистка не сбрасывает эту проекцию. Перед 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. Основные Grafana panels складывают их только как приблизительную пользовательскую оценку непересекающихся Direct-путей; техническая секция сохраняет значения раздельными. Эту сумму нельзя считать точным provider или wire total. 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,20 +369,32 @@ 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`, если хотите сохранить тестовые подписки и настройки.
## Служебные команды ## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется. Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
### Команды npm ### Команды npm
Для сборки и backend закреплён Node **24.21.0** (`.node-version`); используется встроенная SQLite без ORM. `npm run check:runtime` проверяет точную Node-версию, движок SQLite не старше 3.51.3 и точность 64-битных счётчиков. Та же проверка выполняется в сборочных и конечных Docker-образах. При использовании fnm: `fnm use 24.21.0`.
| Команда | Назначение | | Команда | Назначение |
| --- | --- | | --- | --- |
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` | | `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
@@ -360,4 +418,8 @@ docker compose -f docker-compose.client.local.yml down -v
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки. Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
На каждом Mac/Gateway свои `harbor.sqlite` (настройки, подписки, устройства, правила, накопленные счётчики и журнал) и `traffic.sqlite` (ограниченная история). Журнал сохраняет прежний предел 30 дней/10 000 событий; настройки не подчиняются retention истории. Секреты, hardware ID, генерируемый конфиг и кеш sing-box остаются файлами.
Первый запуск транзакционно импортирует прежние JSON, сохраняя IDs, revisions и исходные значения счётчиков. После успеха SQLite становится единственным рабочим хранилищем; исходные JSON остаются неизменными резервными копиями, без параллельной записи. Повреждение или неизвестная версия останавливает миграцию без обнуления. Старый бинарник не читает новые данные: простой downgrade вернул бы устаревшие JSON. Правила backup и восстановления описаны в [state recovery](docs/recovery/state-recovery.md).
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git. Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
+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:-native}
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:-native}
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports: ports:
- "${PORT:-3456}:${PORT:-3456}" - "${PORT:-3456}:${PORT:-3456}"
+88 -43
View File
@@ -1,85 +1,130 @@
# 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 the `state` JSON document in `harbor.sqlite` (currently document schema v10). 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",
"profiles": [
{
"id": "profile_primary",
"label": "Личный",
"subscription": { "subscription": {
"status": "ready", "status": "ready",
"host": "provider.example/…", "host": "provider.example/…",
"fetchedAt": "2026-07-11T14:58:00.000Z", "fetchedAt": "2026-08-11T12:00:00.000Z",
"userInfo": {} "userInfo": {},
}, "lastRefreshAttemptAt": null,
"selection": { "errorCode": null
"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
}, },
"desiredServerId": "srv_amsterdam",
"servers": [ "servers": [
{ {
"id": "srv_4d7c5d1bcd60d665", "id": "srv_amsterdam",
"label": "Amsterdam", "label": "Amsterdam",
"host": "nl.example.net", "host": "nl.example.net",
"port": 443, "port": 443,
"protocol": "vless" "protocol": "vless"
} }
] ]
}
],
"selection": {
"desiredProfileId": "profile_primary",
"desiredServerId": "srv_amsterdam",
"appliedProfileId": "profile_primary",
"appliedServerId": "srv_amsterdam",
"appliedServerSnapshot": {
"id": "srv_amsterdam",
"label": "Amsterdam",
"host": "nl.example.net",
"port": 443,
"protocol": "vless"
}
},
"operation": {
"kind": null,
"status": "idle",
"startedAt": null,
"error": null,
"profileId": null,
"serverId": null
}
} }
``` ```
The backend owns subscription metadata, servers, desired/applied selection, desired/process connection state, route and current operation. React may keep only unsaved form values, pending selection and visual state. Browser transport freshness is not part of this contract. Every profile owns one private provider URL/config, public metadata, server list and desired server. The URL/config never enters the public snapshot. `subscription` and top-level `servers` remain a one-release projection of the desired profile for older clients; they are not a second owner.
## Revision rules ## Revision 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 separate rows in the `harbor.sqlite` journal table 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 retains at most 30 days and 10,000 entries.
## 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. The initial SQLite migration imports settings, devices and journal in one transaction, including a legacy subscription cache when needed. Original JSON files remain unchanged as transition-time backups, with no parallel writes. Invalid input or a conflicting cache owner aborts migration without replacing state or starting stale configuration. Subsequent starts use only SQLite. Current document normalizers also preserve traffic-display settings and the device inventory's tag catalogue.
The existing HTTP compatibility projection is unchanged. A pre-SQLite binary cannot read current persistence: restore a complete compatible backup or explicitly export current data before downgrading. Old JSON files do not contain post-migration changes. See [state recovery](../recovery/state-recovery.md).
## Local traffic history
`GET /api/traffic/history` reads only the local collector's `traffic.sqlite`, through a worker and, on a split Gateway, the existing control/dataplane socket. It accepts `range=24h|7d|30d|90d`, `level=service|domain|hostname|ip`, parent filters `service/domain/hostname`, `originId`, `route=all|vpn|direct|other`, `search`, `offset` and an optional `until` timestamp in milliseconds. Pages contain at most 100 groups. Bytes are decimal strings, preserving integers above JavaScript's safe-number range.
The response reports the requested/effective period, first available observation, minute/hour boundary, current collector state, gap count and partial coverage. `query.until` is the effective end of a complete bucket; use it for matching drilldown and pagination. Current history can lag by one minute. Data older than 7 days is hourly; retention and rollup can change available granularity between requests.
History starts with the new collector, not with previously accumulated device counters. Full observed hostnames and IPs remain distinct; service grouping is a local presentation classification, not proof of ownership of an IP. Unknown domains remain unknown. A history storage error reports unavailable/partial data without stopping VPN or exported metrics.
Prometheus is optional, independent and contains only exported metrics—not a copy of this database. There is no synchronization, automatic UI fallback, scrape backfill or restoration from Prometheus. Local cleanup does not delete external history or change its retention.
+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.
+24 -21
View File
@@ -1,36 +1,39 @@
# 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`. ## Storage owners
## Atomic writes Harbor uses the existing data directory. `harbor.sqlite` is the only working owner of settings, profiles, subscriptions, device inventory and accumulated device counters. Settings and devices are versioned JSON documents inside SQLite (currently state schema 10 and inventory schema 3); the journal is a separate indexed table with the existing 30-day/10,000-event limit.
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. `traffic.sqlite` is separate, replaceable working history: 90 days, completed minute buckets for the latest 7 days and hourly buckets before that. Removing history does not reset settings or exported counters. Prometheus is optional and independently retains only the metrics it scrapes; it cannot restore this database.
## Migration Secrets, hardware identity, generated configuration and sing-box's own cache remain files.
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: ## Atomic writes and migration
```text SQLite uses WAL, FULL synchronous commits and a five-second busy timeout. A document mutation runs its read and write in one transaction. Journal append/deduplication/pruning is transactional. Profile/server switching still prepares candidate configuration and runtime before publishing the canonical state.
state.json.backup-v0-2026-07-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. Before the first successful SQLite startup, Harbor imports `state.json`, `devices.json`, `activity-journal.json` and, when required by a pre-profile schema, `subscription-cache.json` in one transaction. Existing normalizers preserve revision, stable IDs, ordered rules and decimal-string counters. A null optional subscription cache is valid.
## Corrupt JSON The import marker commits with all imported records. An unsupported version, invalid input or conflicting cache owner aborts the entire import. Harbor does not erase settings, rename damaged originals, start stale configuration or silently return to first-run. Correct the reported original and retry only after making a backup.
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to: After a successful import, the original JSON files remain unchanged under their original names as transition-time backups. They are never read or written as current state again. Changing them does not change Harbor. A corrupt or unsupported SQLite database does not fall back to those stale JSON files.
```text ## Backup and recovery
state.json.corrupt-2026-07-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. Stop both control and collector processes before offline recovery. On a Gateway this means the control and dataplane components; stop the Mac backend for Mac recovery.
Recovery should be performed while Harbor is stopped: 1. Preserve the whole data directory, including any `-wal` and `-shm` files, before changing anything.
2. Restore a matching backup of `harbor.sqlite` and any necessary secret/config files. Do not mix a database with another backup's WAL.
3. Start the same compatible release and inspect `GET /api/state` before applying a profile.
1. Copy the whole data directory before changing anything. For online backups use SQLite's backup API; copying only a live `.sqlite` file can omit committed WAL data. Offline copies after a clean stop are simpler.
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. To discard only working traffic history, stop the collector and move its `traffic.sqlite` plus any associated `traffic.sqlite-wal` and `traffic.sqlite-shm` aside together. Leave `harbor.sqlite` untouched. A new collector database starts a new coverage period; there is no automatic Prometheus backfill. Do not unlink an open database.
Deletion/retention makes pages reusable; it does not necessarily shrink the physical file immediately. Traffic retention and compaction run in the worker, outside connection processing.
## Downgrade
A pre-SQLite binary ignores `harbor.sqlite`. Merely starting it would revive old JSON settings and lose all changes since the cutover. Automatic downgrade is unsupported.
Either restore a complete pre-upgrade backup deliberately, accepting the loss of subsequent changes, or first export current state into the exact schema required by the older binary. No automatic export/downgrade tool is provided. Preserve the SQLite backup in either case; do not overwrite current state with stale JSON as a recovery shortcut.
+74 -4
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
if ! setup_device_traffic; then
log "device traffic counters unavailable; VPN routing remains active" log "device traffic counters unavailable; VPN routing remains active"
cleanup_device_traffic 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
+271 -27
View File
@@ -8,21 +8,29 @@
"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",
"tldts": "7.4.12",
"vite": "^7.0.0" "vite": "^7.0.0"
}, },
"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": "24.13.4",
"@types/node18": "npm:@types/node@18.19.130",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"postcss": "8.5.14", "postcss": "8.5.14",
"postcss-selector-parser": "7.1.4", "postcss-selector-parser": "7.1.4",
"typescript": "7.0.2" "typescript": "7.0.2"
},
"engines": {
"node": "24.21.x"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -288,6 +296,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",
@@ -1151,33 +1382,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.17", "version": "24.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/node18": {
"name": "@types/node",
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/node18/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -1538,6 +1751,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",
@@ -2005,6 +2231,24 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tldts": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -2041,9 +2285,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
+13 -2
View File
@@ -2,10 +2,16 @@
"name": "vpn-proxy-gateway", "name": "vpn-proxy-gateway",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": "24.21.x"
},
"type": "module", "type": "module",
"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": {
"check:runtime": "node scripts/check-sqlite-runtime.mjs",
"pretest": "npm run check:runtime",
"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,16 +24,21 @@
"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",
"tldts": "7.4.12",
"vite": "^7.0.0" "vite": "^7.0.0"
}, },
"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": "24.13.4",
"@types/node18": "npm:@types/node@18.19.130",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"postcss": "8.5.14", "postcss": "8.5.14",
@@ -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;
}
+3 -3
View File
@@ -10,9 +10,9 @@ GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}" IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}" 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:24.21.0-bookworm}"
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}'\"; node scripts/check-sqlite-runtime.mjs && 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
+3 -1
View File
@@ -2,8 +2,9 @@
set -euo pipefail 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}"
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:24.21.0-bookworm}"
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}')}"
@@ -18,6 +19,7 @@ if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
docker build \ docker build \
--build-arg NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE}" \
--build-arg BASE_IMAGE="${BASE_IMAGE}" \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \ --build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
--build-arg APT_MIRROR="${APT_MIRROR}" \ --build-arg APT_MIRROR="${APT_MIRROR}" \
+19
View File
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
assert.equal(process.versions.node, '24.21.0', 'Harbor requires the pinned Node 24.21.0 runtime');
const db = new DatabaseSync(':memory:');
try {
const version = db.prepare('SELECT sqlite_version() AS version').get().version;
const [major, minor, patch] = version.split('.').map(Number);
assert.ok(major > 3 || (major === 3 && (minor > 51 || (minor === 51 && patch >= 3))),
'Harbor requires SQLite >= 3.51.3 with the WAL-reset fix');
db.exec('CREATE TABLE probe (bytes INTEGER NOT NULL) STRICT');
db.prepare('INSERT INTO probe VALUES (?)').run(9007199254740993n);
const statement = db.prepare('SELECT bytes FROM probe');
statement.setReadBigInts(true);
assert.equal(statement.get().bytes, 9007199254740993n);
console.log(`Harbor runtime: Node ${process.versions.node}, SQLite ${version}, ${process.platform}/${process.arch}`);
} finally {
db.close();
}
+2
View File
@@ -36,6 +36,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:-native}
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
@@ -59,6 +60,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:-native}
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports: ports:
- "\${PORT:-3456}:\${PORT:-3456}" - "\${PORT:-3456}:\${PORT:-3456}"
+2 -2
View File
@@ -52,8 +52,8 @@ export function affectedComponents(files) {
const add = (...components) => components.forEach((component) => affected.add(component)); const add = (...components) => components.forEach((component) => affected.add(component));
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$|\.node-version$|scripts\/check-sqlite-runtime\.mjs$|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"
+13 -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,8 +19,11 @@ 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 = [
/^\.node-version$/,
/^scripts\/check-sqlite-runtime\.mjs$/,
/^\.dockerignore$/, /^\.dockerignore$/,
/^\.gitea\/workflows\//, /^\.gitea\/workflows\//,
/^Dockerfile(?:\.runtime-base)?$/, /^Dockerfile(?:\.runtime-base)?$/,
@@ -30,11 +35,16 @@ const foundation = [
/^tsconfig(?:\.[^.]+)?\.json$/, /^tsconfig(?:\.[^.]+)?\.json$/,
]; ];
const controlAndDataplane = [ const controlAndDataplane = [
/^src\/server\/services\/(?:sqlite|trafficHistoryStore|trafficHistoryService|trafficHistoryWorker)\.ts$/,
/^src\/shared\/trafficHistory\.ts$/,
/^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:
+262 -7
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';
@@ -9,22 +11,44 @@ import { readNeighborSnapshot } from './adapters/neighbors.js';
import { createDeviceTrafficService } from './services/deviceTrafficService.js'; import { createDeviceTrafficService } from './services/deviceTrafficService.js';
import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createDevicePolicyService } from './services/devicePolicyService.js';
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
import { 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';
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
import { parseTrafficHistoryQuery } from '../shared/trafficHistory.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 +60,35 @@ const devicePolicy = createDevicePolicyService({
const connectivityDiagnostics = createConnectivityDiagnosticsService({ const connectivityDiagnostics = createConnectivityDiagnosticsService({
proxyPort: settings.diagnosticsProxyPort, proxyPort: settings.diagnosticsProxyPort,
}); });
const domainTraffic = createDomainTrafficService({ const dnsDiagnostics = createDnsDiagnosticsService({
proxyPort: settings.diagnosticsProxyPort,
});
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,
});
const trafficHistory = createTrafficHistoryService({
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
source: () => liveTrafficSnapshot().source.state,
});
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 +104,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 +271,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 +282,14 @@ 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());
}
const url = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/traffic/history') {
return sendJson(res, 200, await trafficHistory.query(parseTrafficHistoryQuery(url.searchParams)));
} }
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 +306,55 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
target, target,
})); }));
} }
if (req.method === 'POST' && req.url === '/diagnostics/dns/catalog') {
const { customResolvers = [], customDomains = [] } = record(await readJson(req));
return sendJson(res, 200, await dnsDiagnostics.catalog(
Array.isArray(customResolvers) ? customResolvers : [],
Array.isArray(customDomains) ? customDomains : [],
));
}
if (req.method === 'POST' && req.url === '/diagnostics/dns') {
const {
customResolvers = [], customDomains = [], domainId, resolverId = null,
} = record(await readJson(req));
return sendJson(res, 200, await dnsDiagnostics.run({
vpnAvailable: runtime.running,
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
customDomains: Array.isArray(customDomains) ? customDomains : [],
domainId,
resolverId,
}));
}
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 +379,45 @@ 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) => {
trafficHistory.enqueue(batch);
nativeDomainTraffic.ingestNative(batch);
},
});
liveTraffic.start();
ready = true; ready = true;
if (settings.deviceTrafficAccountingEnabled) {
setImmediate(() => { setImmediate(() => {
traffic.refresh() refreshDeviceTraffic()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
}); });
trafficTimer = setInterval(() => { trafficTimer = setInterval(() => {
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
}, 15_000); }, 15_000);
trafficTimer.unref(); trafficTimer.unref();
} else {
trafficTimer = setInterval(refreshOrigins, 15_000);
trafficTimer.unref();
}
if (trafficMode !== 'native') {
setImmediate(() => { setImmediate(() => {
domainTraffic.refresh() snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
}); });
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
domainTrafficTimer = setInterval(() => { domainTrafficTimer = setInterval(() => {
domainTraffic.refresh() snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
}, 2_000); }, 2_000);
domainTrafficTimer.unref(); domainTrafficTimer.unref();
}
console.log(`[dataplane] control socket: ${socketPath}`); console.log(`[dataplane] control socket: ${socketPath}`);
} }
}); });
@@ -176,6 +429,8 @@ 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 trafficHistory.close();
await runtime.shutdown(); await runtime.shutdown();
server.close(() => { server.close(() => {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
+30
View File
@@ -1,5 +1,6 @@
import http from 'node:http'; import http from 'node:http';
import { HarborError } from '../shared/errors.js'; import { HarborError } from '../shared/errors.js';
import { historyQueryParams, type TrafficHistoryQuery } from '../shared/trafficHistory.js';
type SendDataplaneRequest = ( type SendDataplaneRequest = (
socketPath: string, socketPath: string,
@@ -73,6 +74,8 @@ 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'),
observeTrafficHistory: (query: TrafficHistoryQuery) => send(socketPath, `/traffic/history?${historyQueryParams(query)}`, 'GET', null, 12_000),
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 +85,33 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
throw new HarborError('DIAGNOSTICS_FAILED', { cause }); throw new HarborError('DIAGNOSTICS_FAILED', { cause });
} }
}, },
getDnsDiagnosticsCatalog: (customResolvers: unknown = [], customDomains: unknown = []) => (
send(socketPath, '/diagnostics/dns/catalog', 'POST', { customResolvers, customDomains })
),
runDnsDiagnostics: async (
customResolvers: unknown = [],
customDomains: unknown = [],
domainId: unknown,
resolverId: unknown = null,
) => {
try {
return await send(socketPath, '/diagnostics/dns', 'POST', {
customResolvers, customDomains, domainId, resolverId,
}, 40_000);
} catch (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,8 +22,18 @@ 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>;
dnsCatalog(customResolvers: unknown, customDomains: unknown): Promise<unknown>;
runDnsDiagnostics(
customResolvers: unknown,
customDomains: unknown,
domainId: unknown,
resolverId: unknown,
): Promise<unknown>;
} }
function diagnosticsResult(value: unknown): DiagnosticsResult { function diagnosticsResult(value: unknown): DiagnosticsResult {
@@ -25,16 +43,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 +73,41 @@ export function createConnectivityDiagnosticsUseCase(
}, },
}; };
}, },
async dnsCatalog() {
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
return dependencies.dnsCatalog(settings.customDnsResolvers, settings.customDnsDomains);
},
async runDns(domainId: unknown, resolverId: unknown) {
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
return dependencies.runDnsDiagnostics(
settings.customDnsResolvers,
settings.customDnsDomains,
domainId,
resolverId,
);
},
updateSettings(settings: unknown, expectedRevision: unknown) {
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
throw new HarborError('REQUEST_INVALID');
}
const current = dependencies.state.read();
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
let diagnostics: DiagnosticSettings;
try {
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
? settings as Record<string, unknown>
: {};
diagnostics = normalizeDiagnosticSettings({
...normalizeDiagnosticSettings(current.diagnostics),
...requested,
configured: true,
}, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
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 {
if (configChanged) {
configMutationStarted = true; configMutationStarted = true;
dependencies.config.write(candidateConfig); dependencies.config.write(candidateConfig);
if (wasRunning) { }
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,
previousState.servers,
parsed.servers, parsed.servers,
); );
const candidateConfig = selectedServerId const refreshedProfile: StoredProfile = {
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules) ...previousProfile,
: null; subscriptionConfig: parsed.config,
const previousCache = dependencies.cache.read(); servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
desiredServerId,
lastRefreshAttemptAt: parsed.fetchedAt,
lastRefreshErrorCode: null,
};
const contentChanged = JSON.stringify({
subscriptionConfig: previousProfile.subscriptionConfig,
servers: previousProfile.servers,
userInfo: previousProfile.userInfo,
}) !== JSON.stringify({
subscriptionConfig: refreshedProfile.subscriptionConfig,
servers: refreshedProfile.servers,
userInfo: refreshedProfile.userInfo,
});
const appendRefreshEvent = () => {
if (origin === 'scheduled' && !contentChanged && !previousProfile.lastRefreshErrorCode) return;
dependencies.onEvent?.({
type: 'subscription.refreshed',
severity: 'info',
source: 'subscription',
dedupeKey: `subscription.refreshed:${dependencies.state.read().revision}`,
data: {
profileId: refreshedProfile.id,
profileLabel: refreshedProfile.label,
host: publicHost(refreshedProfile.subscriptionUrl),
serverCount: refreshedProfile.servers.length,
added: refreshedProfile.servers.filter(({ id }) => !previousProfile.servers.some((server) => server.id === id)).length,
removed: previousProfile.servers.filter(({ id }) => !refreshedProfile.servers.some((server) => server.id === id)).length,
},
});
};
const loadedTargets = previousState.appliedFailoverPolicy
? [previousState.appliedFailoverPolicy.primary, previousState.appliedFailoverPolicy.reserve]
: [];
const missingLoadedTarget = loadedTargets.some((target) => (
target.profileId === profileId
&& !refreshedProfile.servers.some(({ id }) => id === target.serverId)
));
const pauseFailover = previousState.failoverPolicy?.enabled
&& !previousState.failoverPolicy.paused
&& missingLoadedTarget;
const running = await dependencies.runtime.isRunning();
const refreshesApplied = running
&& previousState.appliedProfileId === profileId
&& !previousState.appliedFailoverPolicy;
const nextAppliedServerId = refreshesApplied
? dependencies.provider.selectRefreshedServer(
previousState.appliedServerId,
previousProfile.servers,
parsed.servers,
)
: '';
if (!refreshesApplied || !nextAppliedServerId) {
dependencies.state.update((current) => ({
...current,
profiles: replaceProfile(current, refreshedProfile),
...(pauseFailover ? { failoverPolicy: { ...current.failoverPolicy, paused: true } } : {}),
}));
if (pauseFailover) dependencies.onEvent?.({
type: 'failover.paused',
severity: 'warning',
source: 'failover',
dedupeKey: `failover.paused:missing-target:${profileId}:${dependencies.state.read().revision}`,
data: {},
});
await dependencies.failover?.reconcile();
appendRefreshEvent();
return mutationResult(refreshedProfile);
}
const nextAppliedServer = parsed.servers.find((server) => server.id === nextAppliedServerId)!;
const candidateConfig = dependencies.config.build(
parsed.config,
nextAppliedServerId,
previousState.routeRules,
);
const previousConfig = dependencies.config.read(); const previousConfig = dependencies.config.read();
const previousGatewayAuto = dependencies.gatewayAuto.read(); let configMutationStarted = false;
const wasRunning = await dependencies.runtime.isRunning(); let runtimeMutationStarted = false;
let restoreRuntime = false;
let stateCommitStarted = false; let stateCommitStarted = false;
try { try {
if ((resetSelection || !candidateConfig) && wasRunning) { configMutationStarted = true;
restoreRuntime = true; dependencies.config.write(candidateConfig);
await dependencies.runtime.stop(); runtimeMutationStarted = true;
}
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(); await dependencies.runtime.start();
}
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
stateCommitStarted = true; stateCommitStarted = true;
dependencies.state.update((state) => ({ dependencies.state.update((current) => ({
...(resetSelection ? { ...current,
routeRules: state.routeRules, profiles: replaceProfile(current, refreshedProfile),
gatewayAutoEnabled: state.gatewayAutoEnabled !== false, appliedServerId: nextAppliedServerId,
connectionDesired: 'stopped', appliedServerSnapshot: nextAppliedServer,
} : state), appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct'
subscriptionUrl, ? []
servers: parsed.servers, : current.routeRules,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
})); }));
subscriptionGeneration += 1;
} catch (error) { } catch (error) {
await finishRollback(error, [ await finishRollback(error, [
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []), ...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) }, ...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
{ run: () => restoreCache(previousCache) }, ...(runtimeMutationStarted ? [{
{ run: () => restoreConfig(previousConfig) }, run: async () => {
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []), await dependencies.runtime.start();
], 'Subscription rollback failed'); await dependencies.failover?.restoreAppliedActivation(previousState);
},
runtime: true,
}] : []),
], 'Subscription refresh rollback failed');
} }
await dependencies.failover?.reconcile();
return { appendRefreshEvent();
success: true as const, return mutationResult(refreshedProfile);
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
};
}); });
const importSubscription = async (subscriptionUrl: string) => { const 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' | 'dnsCatalog' | 'runDns' | '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,28 @@ 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/dns' && req.method === 'GET') {
const { services = [], target = null } = await dependencies.readBody(req); sendJson(res, 200, await dependencies.diagnostics.dnsCatalog());
const result = await dependencies.diagnostics.run(services, target); return true;
}
if (req.url === '/api/diagnostics/dns' && req.method === 'POST') {
const { domainId, resolverId = null } = await dependencies.readBody(req);
sendJson(res, 200, await dependencies.diagnostics.runDns(domainId, resolverId));
return true;
}
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
const { target = null } = await dependencies.readBody(req);
const result = await dependencies.diagnostics.run(target);
sendJson(res, 200, result); 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;
},
};
}
+115
View File
@@ -0,0 +1,115 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { isDeepStrictEqual } from 'node:util';
import {
assertLiveTrafficSnapshot,
type LiveTrafficSnapshot,
} from '../../../shared/liveTraffic.js';
import type { StoredState } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import {
normalizeTrafficSettings,
type TrafficSettings,
} from '../../../shared/trafficSettings.js';
import { sendJson } from '../response.js';
interface LiveTrafficReader {
snapshot(): unknown | Promise<unknown>;
}
interface DeviceInventoryReader {
snapshot(): unknown;
}
interface TrafficSettingsState {
read(): { revision?: unknown; traffic?: TrafficSettings };
update(mutator: (state: StoredState) => Record<string, unknown>): 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,
settingsState = null,
readBody = null,
sendState = null,
}: {
traffic: LiveTrafficReader | null;
deviceInventory?: DeviceInventoryReader | null;
settingsState?: TrafficSettingsState | null;
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
sendState?: ((res: ServerResponse) => Promise<void>) | null;
}) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
if (pathname === '/api/traffic/live') {
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;
}
if (pathname === '/api/traffic/settings') {
if (req.method !== 'PUT' || !settingsState || !readBody || !sendState) {
throw new HarborError('ENDPOINT_NOT_FOUND');
}
const body = await readBody(req);
const expectedRevision = body.expectedRevision;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
throw new HarborError('REQUEST_INVALID');
}
let settings: TrafficSettings;
try {
settings = normalizeTrafficSettings(body.settings, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const current = settingsState.read();
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (!isDeepStrictEqual(current.traffic, settings)) {
settingsState.update((state) => ({ ...state, traffic: settings }));
}
await sendState(res);
return true;
}
return false;
},
};
}
+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;
@@ -0,0 +1,33 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { HarborError } from '../../../shared/errors.js';
import { assertTrafficHistorySnapshot, emptyTrafficHistory, parseTrafficHistoryQuery, type TrafficHistoryQuery } from '../../../shared/trafficHistory.js';
import { sendJson } from '../response.js';
export function createTrafficHistoryRoute({ readHistory, deviceInventory }: {
readHistory: ((query: TrafficHistoryQuery) => Promise<unknown>) | null;
deviceInventory?: { snapshot(): unknown } | null;
}) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url || '/', 'http://localhost');
if (url.pathname !== '/api/traffic/history') return false;
if (req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
let query: TrafficHistoryQuery;
try { query = parseTrafficHistoryQuery(url.searchParams); }
catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); }
let snapshot;
try {
snapshot = readHistory ? assertTrafficHistorySnapshot(await readHistory(query)) : emptyTrafficHistory(query);
} catch {
snapshot = emptyTrafficHistory(query, 'stale');
snapshot.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
snapshot.coverage.partial = true;
}
const inventory = deviceInventory?.snapshot() as { devices?: Array<{ id: string; alias?: string; hostname?: string; ip?: string }> } | undefined;
const labels = new Map((inventory?.devices || []).map((device) => [device.id, device.alias || device.hostname || device.ip]));
snapshot.origins = snapshot.origins.map((origin) => ({ ...origin, label: labels.get(origin.id) || origin.label }));
sendJson(res, 200, snapshot);
return true;
},
};
}
+480 -103
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,23 +31,23 @@ 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 { openHarborStorage } from './services/harborStorage.js';
import { createDevicePolicyService } from './services/devicePolicyService.js'; import { createDevicePolicyService } from './services/devicePolicyService.js';
import { import {
createDeviceInventoryService, createDeviceInventoryService,
createVendorLookup, createVendorLookup,
DEVICE_INVENTORY_SCHEMA_VERSION,
migrateDeviceInventoryState,
type InventoryState,
} from './services/deviceInventoryService.js'; } from './services/deviceInventoryService.js';
import { buildVersionInfo } from './version.js'; import { buildVersionInfo } from './version.js';
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
import { createStateService } from './features/state/stateService.js'; import { createStateService } from './features/state/stateService.js';
import { createStateRoute } from './http/routes/stateRoute.js'; import { createStateRoute } from './http/routes/stateRoute.js';
import { sendError } from './http/response.js'; import { sendError } from './http/response.js';
@@ -75,6 +78,17 @@ 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 { createTrafficHistoryRoute } from './http/routes/trafficHistoryRoute.js';
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
import type { LiveTrafficSourceState } from '../shared/liveTraffic.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,50 +107,26 @@ function errorMessage(error: unknown) {
fs.mkdirSync(settings.dataDir, { recursive: true }); fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath); const storage = openHarborStorage(settings.dataDir);
const subscriptionCacheStore = createJsonStore({ const stateStore = storage.state;
filePath: settings.subscriptionCachePath, const deviceStore = storage.devices;
defaultValue: null,
});
const deviceStore = createJsonStore<InventoryState>({
filePath: settings.deviceStatePath,
defaultValue: migrateDeviceInventoryState({}),
migrate: migrateDeviceInventoryState,
initializeMissing: true,
backupWhen: () => true,
});
deviceStore.read();
if (deviceStore.migration) {
console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`);
}
if (deviceStore.recovery) {
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
}
let cacheRecoveryLogged = false;
function readRawSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached;
}
function readSubscriptionCache() {
const raw = readRawSubscriptionCache();
const cached = record(raw);
return cached.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
}
const initialStoredState = stateStore.read(); const initialStoredState = stateStore.read();
if (stateStore.migration) { if (storage.imported) console.log('[storage] SQLite migration committed; original JSON files retained as backups');
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`); const activityJournal = createActivityJournalService({ db: storage.db });
} const appendJournal = (event: ActivityJournalEventInput) => {
if (stateStore.recovery) { try {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`); activityJournal.append(event);
} catch (error) {
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
}
};
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);
@@ -153,6 +143,22 @@ function selectRuntime() {
throw new Error('Harbor runtime is not configured'); throw new Error('Harbor runtime is not configured');
} }
const singboxRuntime = selectRuntime(); const singboxRuntime = selectRuntime();
let clientLiveTraffic: ReturnType<typeof import('./services/liveTrafficService.js').createLiveTrafficService> | null = null;
const clientHistory = settings.appMode === 'client' ? createTrafficHistoryService({
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
source: (): LiveTrafficSourceState => clientLiveTraffic?.snapshot().source.state || 'disabled',
}) : null;
clientLiveTraffic = settings.appMode === 'client'
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
port: settings.singboxNativeApiPort,
enabled: settings.singboxTrafficSource === 'native',
isRuntimeRunning: () => Boolean(localRuntime?.running),
onProjection: (batch) => clientHistory?.enqueue(batch),
})
: 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 +200,40 @@ const deviceInventory = settings.appMode === 'gateway'
const localConnectivityDiagnostics = !remoteDataplane const localConnectivityDiagnostics = !remoteDataplane
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
: null; : null;
const localDnsDiagnostics = !remoteDataplane
? createDnsDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
: null;
const localFailoverDiagnostics = !remoteDataplane ? {
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
} : null;
const localSelector = !remoteDataplane && settings.appMode === 'gateway'
? createSingboxSelectorService({ port: settings.singboxApiPort })
: null;
const localFailoverTraffic = !remoteDataplane && settings.appMode === 'gateway'
? createDomainTrafficService({
observe: () => readSingboxConnections(settings.singboxApiPort),
devices: () => record(deviceInventory?.snapshot()).devices,
})
: null;
let localFailoverTrafficTimer: NodeJS.Timeout | null = null;
function setLocalFailoverActivityEnabled(enabled: boolean) {
if (!localFailoverTraffic) throw new Error('Failover activity недоступна');
if (!enabled) {
if (localFailoverTrafficTimer) clearInterval(localFailoverTrafficTimer);
localFailoverTrafficTimer = null;
localFailoverTraffic.disableActivity();
return;
}
localFailoverTraffic.enableActivity();
if (localFailoverTrafficTimer) return;
const refresh = () => localFailoverTraffic.refresh()
.catch((error: unknown) => console.warn(`[control] failover activity: ${errorMessage(error)}`));
void refresh();
localFailoverTrafficTimer = setInterval(refresh, 2_000);
localFailoverTrafficTimer.unref();
}
function requireLocalConnectivityDiagnostics() { function requireLocalConnectivityDiagnostics() {
if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured'); if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured');
@@ -201,12 +241,7 @@ function requireLocalConnectivityDiagnostics() {
} }
let deviceDiscoveryTimer: NodeJS.Timeout | null = null; let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
let controlOperation: Promise<unknown> = Promise.resolve(); let controlOperation: Promise<unknown> = Promise.resolve();
let operationState: OperationState = stateStore.recovery ? { let operationState: OperationState = { kind: null, status: 'idle', startedAt: null, error: null };
kind: 'storage-recovery',
status: 'failed',
startedAt: stateStore.recovery.recoveredAt,
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
} : { kind: null, status: 'idle', startedAt: null, error: null };
let revision = normalizeStoredState(initialStoredState).revision; let revision = normalizeStoredState(initialStoredState).revision;
const gatewayAutoService = createGatewayAutoService({ const gatewayAutoService = createGatewayAutoService({
appMode: settings.appMode, appMode: settings.appMode,
@@ -215,7 +250,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 +273,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 +301,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 +350,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 +364,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({
@@ -299,14 +389,32 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
services, services,
target, target,
}), }),
dnsCatalog: (customResolvers, customDomains) => remoteDataplane
? requireRemoteRuntime().getDnsDiagnosticsCatalog(customResolvers, customDomains)
: localDnsDiagnostics!.catalog(
Array.isArray(customResolvers) ? customResolvers : [],
Array.isArray(customDomains) ? customDomains : [],
),
runDnsDiagnostics: async (customResolvers, customDomains, domainId, resolverId) => remoteDataplane
? requireRemoteRuntime().runDnsDiagnostics(
customResolvers, customDomains, domainId, resolverId,
)
: localDnsDiagnostics!.run({
vpnAvailable: Boolean((await singboxRuntime.refresh()).running),
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
customDomains: Array.isArray(customDomains) ? customDomains : [],
domainId,
resolverId,
}),
}); });
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 +429,21 @@ const versionRoute = createVersionRoute({
? () => requireRemoteRuntime().refresh() ? () => requireRemoteRuntime().refresh()
: null, : null,
}); });
const liveTrafficRoute = createLiveTrafficRoute({
traffic: liveTraffic,
deviceInventory: remoteDataplane ? deviceInventory : null,
settingsState: {
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
readBody,
sendState: (res) => stateRoute.send(res),
});
const trafficHistoryRoute = createTrafficHistoryRoute({
readHistory: clientHistory ? (query) => clientHistory.query(query)
: remoteRuntime ? (query) => remoteRuntime.observeTrafficHistory(query) : null,
deviceInventory,
});
const subscriptionValidationRoute = createSubscriptionValidationRoute({ const subscriptionValidationRoute = createSubscriptionValidationRoute({
validateSubscription: createValidateSubscription(fetchSubscription), validateSubscription: createValidateSubscription(fetchSubscription),
readBody, readBody,
@@ -332,15 +455,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 +485,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 +507,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 +518,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 +543,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 +567,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 +591,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 +608,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 +721,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 +891,15 @@ 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 trafficHistoryRoute.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,7 +948,11 @@ 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 clientHistory?.close();
await serializeControl(() => singboxRuntime.shutdown()); await serializeControl(() => singboxRuntime.shutdown());
storage.close();
process.exit(0); process.exit(0);
} }
@@ -623,29 +961,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
await startSingbox() && fs.existsSync(settings.configPath)
.then(() => { && currentConfigMatchesAppliedTarget(normalizeStoredState(stateStore.read()));
if (fs.existsSync(settings.configPath)) { if (target || canReuseCurrentConfig) {
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules })); 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((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`)); .catch(async (error: unknown) => {
console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`);
await reconcileStoppedBoot();
});
} else {
await reconcileStoppedBoot({ removeConfig: true });
}
} else {
await reconcileStoppedBoot();
}
await gatewayFailover?.reconcile()
.catch((error) => console.warn(`[control] failover reconcile: ${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}`);
}); });
+174
View File
@@ -1,6 +1,13 @@
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 APPLIED_POLICY_VALUES: Readonly<Record<string, string>> = Object.freeze({ direct: '0', vpn: '1' });
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 +30,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;
@@ -74,6 +94,20 @@ export function renderPrometheusMetrics(value: unknown) {
ip: device.ip || '', ip: device.ip || '',
}, '1'); }, '1');
} }
lines.push(
'# HELP harbor_device_applied_policy Current applied device policy: 0 Direct, 1 VPN.',
'# TYPE harbor_device_applied_policy gauge',
);
for (const device of devices) {
const appliedPolicy = String(device.appliedPolicy || '');
if (!Object.hasOwn(APPLIED_POLICY_VALUES, appliedPolicy)) {
throw new Error('Invalid applied device policy');
}
metric(lines, 'harbor_device_applied_policy', {
device_id: device.id,
}, APPLIED_POLICY_VALUES[appliedPolicy]);
}
} }
const deviceTraffic = devices.flatMap((device) => [ const deviceTraffic = devices.flatMap((device) => [
@@ -114,7 +148,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 +312,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,76 @@
import crypto from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import {
ACTIVITY_JOURNAL_MAX_EVENTS,
ACTIVITY_JOURNAL_RETENTION_DAYS,
normalizeActivityEventInput,
type ActivityJournalEvent,
type ActivityJournalEventInput,
type ActivityJournalPage,
} from '../../shared/activityJournal.js';
import { transaction } from './sqlite.js';
export function createActivityJournalService({ db, now = () => new Date() }: {
db: DatabaseSync;
now?: () => Date;
}) {
let writeFailed = false;
const insert = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?) ON CONFLICT(dedupe_key) DO NOTHING');
function prune() {
const cutoff = new Date(now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000).toISOString();
db.prepare('DELETE FROM journal WHERE occurred_at < ?').run(cutoff);
db.prepare(`DELETE FROM journal WHERE sequence IN (
SELECT sequence FROM journal ORDER BY sequence DESC LIMIT -1 OFFSET ?
)`).run(ACTIVITY_JOURNAL_MAX_EVENTS);
}
function append(value: ActivityJournalEventInput) {
const input = normalizeActivityEventInput(value);
const event: ActivityJournalEvent = {
...input,
id: crypto.randomUUID(),
occurredAt: now().toISOString(),
dedupeKey: input.dedupeKey
? `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`
: null,
};
try {
const appended = transaction(db, () => {
prune();
const { changes } = insert.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
prune();
return Number(changes) ? event : null;
});
writeFailed = false;
return appended;
} catch (error) {
writeFailed = true;
throw error;
}
}
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
const base = { retentionDays: ACTIVITY_JOURNAL_RETENTION_DAYS, generatedAt: now().toISOString() } as const;
try {
transaction(db, prune);
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
const before = cursor ? db.prepare('SELECT sequence FROM journal WHERE id = ?').get(cursor) : null;
const rows = cursor && !before ? [] : db.prepare(`
SELECT id, value FROM journal WHERE (? IS NULL OR sequence < ?) ORDER BY sequence DESC LIMIT ?
`).all(before?.sequence ?? null, before?.sequence ?? null, limit + 1);
const selected = rows.slice(0, limit);
return {
...base,
events: selected.map((row) => ({ ...JSON.parse(String(row.value)) as ActivityJournalEvent, dedupeKey: null })),
nextCursor: rows.length > limit ? String(selected.at(-1)?.id) : null,
storage: writeFailed
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
: { status: 'ready', errorCode: null },
};
} catch {
return { ...base, events: [], nextCursor: null, storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' } };
}
}
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,
};
} }
+134 -14
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,12 +543,14 @@ export function createDeviceTrafficService({
} }
} }
if (activeSlot) {
try { try {
activeCounters = await readCounters(activeDevices, activeSlot); activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true; countersRead = true;
} catch (error) { } catch (error) {
sourceError = sourceError || (error instanceof Error ? error.message : String(error)); sourceError = sourceError || (error instanceof Error ? error.message : String(error));
} }
}
current = { current = {
epoch, epoch,
generation: current.generation, generation: current.generation,
@@ -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);
@@ -0,0 +1,591 @@
import crypto from 'node:crypto';
import dgram from 'node:dgram';
import { getServers as dnsGetServers } from 'node:dns';
import { lookup as dnsLookup } from 'node:dns/promises';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import {
DNS_DIAGNOSTIC_DOMAINS,
DNS_DIAGNOSTIC_RESOLVERS,
type CustomDnsDomain,
type CustomDnsResolver,
type DnsDomainDefinition,
type DnsResolverDefinition,
} from '../../shared/connectivityDiagnostics.js';
type PathKind = 'direct' | 'vpn';
type RecordKind = 'A' | 'AAAA';
interface ParsedDnsResponse {
rcode: string;
truncated: boolean;
ipv4: string[];
ipv6: string[];
}
interface WireExchange {
response: Buffer;
transport: 'udp' | 'tcp' | 'doh';
latencyMs: number;
}
interface QueryPathResult {
status: string;
rcode: string | null;
ipv4: string[];
ipv6: string[];
latencyMs: number | null;
transport: string | null;
error: string | null;
}
interface ExchangeRequest {
path: PathKind;
resolver: DnsResolverDefinition;
packet: Buffer;
proxyPort: number;
timeoutMs: number;
}
type ClassicExchange = (request: ExchangeRequest) => Promise<WireExchange>;
type DohExchange = (request: ExchangeRequest) => Promise<WireExchange>;
type TcpExchange = (request: ExchangeRequest) => Promise<WireExchange>;
const RCODE = ['NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED'];
const MAX_DNS_PACKET_BYTES = 65_535;
const CLASSIC_TIMEOUT_MS = 2_500;
const DOH_TIMEOUT_MS = 5_000;
const MAX_PARALLEL_RESOLVERS = 3;
const BLOCKED_IPV4 = new net.BlockList();
for (const [address, prefix] of [
['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],
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4],
] as Array<[string, number]>) BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4');
const BLOCKED_IPV6 = new net.BlockList();
for (const [address, prefix] of [
['::', 128], ['::1', 128], ['64:ff9b:1::', 48], ['100::', 64],
['2001:db8::', 32], ['3fff::', 20], ['5f00::', 16],
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
] as Array<[string, number]>) BLOCKED_IPV6.addSubnet(address, prefix, 'ipv6');
function isPublicAddress(address: string) {
const family = net.isIP(address);
if (family === 4) return !BLOCKED_IPV4.check(address, 'ipv4');
if (family === 6) return !BLOCKED_IPV6.check(address, 'ipv6');
return false;
}
function encodeName(hostname: string) {
const labels = hostname.split('.');
const chunks = labels.map((label) => {
const value = Buffer.from(label, 'ascii');
if (!value.length || value.length > 63) throw new Error('invalid DNS hostname');
return Buffer.concat([Buffer.from([value.length]), value]);
});
return Buffer.concat([...chunks, Buffer.from([0])]);
}
export function buildDnsQuery(hostname: string, type: RecordKind, id: number) {
const header = Buffer.alloc(12);
header.writeUInt16BE(id, 0);
header.writeUInt16BE(0x0100, 2);
header.writeUInt16BE(1, 4);
const question = Buffer.alloc(4);
question.writeUInt16BE(type === 'A' ? 1 : 28, 0);
question.writeUInt16BE(1, 2);
return Buffer.concat([header, encodeName(hostname), question]);
}
function readName(packet: Buffer, start: number) {
let offset = start;
let next = start;
let jumped = false;
const labels: string[] = [];
const visited = new Set<number>();
for (let depth = 0; depth < 64; depth += 1) {
if (offset >= packet.length) throw new Error('malformed DNS name');
const length = packet[offset];
if ((length & 0xc0) === 0xc0) {
if (offset + 1 >= packet.length) throw new Error('malformed DNS pointer');
const pointer = ((length & 0x3f) << 8) | packet[offset + 1];
if (visited.has(pointer)) throw new Error('recursive DNS pointer');
visited.add(pointer);
if (!jumped) next = offset + 2;
jumped = true;
offset = pointer;
continue;
}
if (length & 0xc0) throw new Error('unsupported DNS label');
offset += 1;
if (length === 0) {
if (!jumped) next = offset;
return { name: labels.join('.'), next };
}
if (offset + length > packet.length) throw new Error('malformed DNS label');
labels.push(packet.subarray(offset, offset + length).toString('ascii'));
offset += length;
if (!jumped) next = offset;
}
throw new Error('DNS name is too deep');
}
function ipv6Address(value: Buffer) {
const groups = [];
for (let offset = 0; offset < 16; offset += 2) groups.push(value.readUInt16BE(offset).toString(16));
return groups.join(':');
}
export function parseDnsResponse(packet: Buffer, expectedId: number): ParsedDnsResponse {
if (packet.length < 12 || packet.length > MAX_DNS_PACKET_BYTES) throw new Error('malformed DNS response');
if (packet.readUInt16BE(0) !== expectedId || !(packet.readUInt16BE(2) & 0x8000)) {
throw new Error('unexpected DNS response');
}
const flags = packet.readUInt16BE(2);
const questionCount = packet.readUInt16BE(4);
const answerCount = packet.readUInt16BE(6);
let offset = 12;
for (let index = 0; index < questionCount; index += 1) {
offset = readName(packet, offset).next;
if (offset + 4 > packet.length) throw new Error('malformed DNS question');
offset += 4;
}
const ipv4: string[] = [];
const ipv6: string[] = [];
for (let index = 0; index < answerCount; index += 1) {
offset = readName(packet, offset).next;
if (offset + 10 > packet.length) throw new Error('malformed DNS answer');
const type = packet.readUInt16BE(offset);
const dnsClass = packet.readUInt16BE(offset + 2);
const length = packet.readUInt16BE(offset + 8);
offset += 10;
if (offset + length > packet.length) throw new Error('malformed DNS data');
if (dnsClass === 1 && type === 1 && length === 4) ipv4.push([...packet.subarray(offset, offset + 4)].join('.'));
if (dnsClass === 1 && type === 28 && length === 16) ipv6.push(ipv6Address(packet.subarray(offset, offset + 16)));
offset += length;
}
const code = flags & 0x0f;
return {
rcode: RCODE[code] || `RCODE_${code}`,
truncated: Boolean(flags & 0x0200),
ipv4: [...new Set(ipv4)],
ipv6: [...new Set(ipv6)],
};
}
function resolverEndpoint(endpoint: string) {
if (net.isIP(endpoint)) return { address: endpoint, port: 53 };
const ipv6 = /^\[([^\]]+)\]:(\d+)$/.exec(endpoint);
if (ipv6 && net.isIP(ipv6[1]) === 6) return { address: ipv6[1], port: Number(ipv6[2]) };
const ipv4 = /^([^:]+):(\d+)$/.exec(endpoint);
if (ipv4 && net.isIP(ipv4[1]) === 4) return { address: ipv4[1], port: Number(ipv4[2]) };
throw new Error('invalid DNS resolver');
}
function timeoutError() {
const error = new Error('timeout');
Object.assign(error, { code: 'ETIMEDOUT' });
return error;
}
async function udpExchange(address: string, port: number, packet: Buffer, timeoutMs: number) {
const socket = dgram.createSocket(net.isIP(address) === 6 ? 'udp6' : 'udp4');
return new Promise<Buffer>((resolve, reject) => {
const timer = setTimeout(() => {
socket.close();
reject(timeoutError());
}, timeoutMs);
socket.once('error', (error) => {
clearTimeout(timer);
socket.close();
reject(error);
});
socket.once('message', (message) => {
clearTimeout(timer);
socket.close();
resolve(message);
});
socket.send(packet, port, address, (error) => {
if (!error) return;
clearTimeout(timer);
socket.close();
reject(error);
});
});
}
async function readExact(socket: net.Socket, length: number) {
const chunks: Buffer[] = [];
let total = 0;
while (total < length) {
const chunk = socket.read(length - total) as Buffer | null;
if (chunk) {
chunks.push(chunk);
total += chunk.length;
continue;
}
await Promise.race([
once(socket, 'readable'),
once(socket, 'error').then(([error]) => Promise.reject(error)),
once(socket, 'close').then(() => Promise.reject(new Error('connection closed'))),
]);
}
return Buffer.concat(chunks, total);
}
async function connectSocket(host: string, port: number, timeoutMs: number) {
const socket = net.connect({ host, port });
socket.setTimeout(timeoutMs, () => socket.destroy(timeoutError()));
await Promise.race([
once(socket, 'connect'),
once(socket, 'error').then(([error]) => Promise.reject(error)),
]);
return socket;
}
function socksAddress(address: string, port: number) {
const family = net.isIP(address);
const portBytes = Buffer.alloc(2);
portBytes.writeUInt16BE(port, 0);
if (family === 4) return Buffer.concat([Buffer.from([1, ...address.split('.').map(Number)]), portBytes]);
if (family === 6) {
const bytes = Buffer.alloc(16);
const [head = '', tail = ''] = address.split('::');
const headGroups = head ? head.split(':') : [];
const tailGroups = tail ? tail.split(':') : [];
const groups = address.includes('::')
? [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill('0'), ...tailGroups]
: headGroups;
if (groups.length !== 8) throw new Error('invalid IPv6 resolver');
groups.forEach((group, index) => bytes.writeUInt16BE(Number.parseInt(group || '0', 16), index * 2));
return Buffer.concat([Buffer.from([4]), bytes, portBytes]);
}
throw new Error('invalid SOCKS destination');
}
async function socksHandshake(command: 1 | 3, address: string, port: number, proxyPort: number, timeoutMs: number) {
const socket = await connectSocket('127.0.0.1', proxyPort, timeoutMs);
socket.write(Buffer.from([5, 1, 0]));
const greeting = await readExact(socket, 2);
if (greeting[0] !== 5 || greeting[1] !== 0) throw new Error('SOCKS authentication failed');
socket.write(Buffer.concat([Buffer.from([5, command, 0]), socksAddress(address, port)]));
const header = await readExact(socket, 4);
if (header[0] !== 5 || header[1] !== 0) throw new Error('SOCKS connection failed');
const addressLength = header[3] === 1 ? 4 : header[3] === 4 ? 16 : header[3] === 3 ? (await readExact(socket, 1))[0] : 0;
if (!addressLength) throw new Error('invalid SOCKS response');
const bound = await readExact(socket, addressLength + 2);
const boundAddress = header[3] === 1
? [...bound.subarray(0, 4)].join('.')
: header[3] === 4
? ipv6Address(bound.subarray(0, 16))
: bound.subarray(0, addressLength).toString('ascii');
return {
socket,
boundAddress: ['0.0.0.0', '::'].includes(boundAddress) ? '127.0.0.1' : boundAddress,
boundPort: bound.readUInt16BE(addressLength),
};
}
async function tcpDns(socket: net.Socket, packet: Buffer) {
const length = Buffer.alloc(2);
length.writeUInt16BE(packet.length, 0);
socket.write(Buffer.concat([length, packet]));
const size = (await readExact(socket, 2)).readUInt16BE(0);
if (!size || size > MAX_DNS_PACKET_BYTES) throw new Error('invalid DNS TCP response');
return readExact(socket, size);
}
async function defaultClassicExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
const { address, port } = resolverEndpoint(resolver.endpoint);
const startedAt = Date.now();
if (path === 'direct') {
const response = await udpExchange(address, port, packet, timeoutMs);
return { response, transport: 'udp', latencyMs: Date.now() - startedAt };
}
const control = await socksHandshake(3, '0.0.0.0', 0, proxyPort, timeoutMs);
try {
const target = socksAddress(address, port);
const request = Buffer.concat([Buffer.from([0, 0, 0]), target, packet]);
const response = await udpExchange(control.boundAddress, control.boundPort, request, timeoutMs);
const headerLength = response[3] === 1 ? 10 : response[3] === 4 ? 22 : response[3] === 3 ? 7 + response[4] : 0;
if (!headerLength || response[2] !== 0 || response.length <= headerLength) throw new Error('invalid SOCKS UDP response');
return { response: response.subarray(headerLength), transport: 'udp', latencyMs: Date.now() - startedAt };
} finally {
control.socket.destroy();
}
}
async function tcpFallback({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest) {
const { address, port } = resolverEndpoint(resolver.endpoint);
const startedAt = Date.now();
const socket = path === 'direct'
? await connectSocket(address, port, timeoutMs)
: (await socksHandshake(1, address, port, proxyPort, timeoutMs)).socket;
try {
return { response: await tcpDns(socket, packet), transport: 'tcp' as const, latencyMs: Date.now() - startedAt };
} finally {
socket.destroy();
}
}
async function defaultDohExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
const endpoint = new URL(resolver.endpoint);
const marker = Buffer.from('\n__HARBOR_DOH_META__');
const args = [
'--silent', '--show-error', '--proto', '=https', '--connect-timeout', '3',
'--max-time', String(timeoutMs / 1_000), '--request', 'POST',
'--header', 'content-type: application/dns-message', '--header', 'accept: application/dns-message',
'--data-binary', '@-', '--output', '-', '--write-out', `${marker.toString()}%{http_code}:%{time_total}`,
...(path === 'vpn' ? ['--proxy', `http://127.0.0.1:${proxyPort}`] : ['--noproxy', '*']),
...(resolver.bootstrap ? ['--resolve', `${endpoint.hostname}:443:${resolver.bootstrap}`] : []),
resolver.endpoint,
];
const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.stdin.end(packet);
const [code] = await once(child, 'close') as [number | null];
const output = Buffer.concat(stdout);
const markerIndex = output.lastIndexOf(marker);
if (code !== 0 || markerIndex < 0) throw new Error(Buffer.concat(stderr).toString('utf8').trim() || 'DoH request failed');
const [statusText, secondsText] = output.subarray(markerIndex + marker.length).toString('ascii').split(':');
const status = Number(statusText);
if (status < 200 || status >= 300) throw new Error(`DoH HTTP ${status}`);
const response = output.subarray(0, markerIndex);
if (!response.length || response.length > MAX_DNS_PACKET_BYTES) throw new Error('invalid DoH response');
return { response, transport: 'doh', latencyMs: Math.round(Number(secondsText) * 1_000) };
}
function availableResolvers(customResolvers: CustomDnsResolver[], getServers: () => string[]) {
const system = getServers().map((endpoint) => ({
id: `system-${crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 12)}`,
label: 'Системный DNS',
kind: 'dns' as const,
endpoint,
system: true,
}));
const custom: DnsResolverDefinition[] = customResolvers.map((resolver) => ({ ...resolver, custom: true }));
return [...system, ...DNS_DIAGNOSTIC_RESOLVERS, ...custom];
}
async function prepareResolver(resolver: DnsResolverDefinition, lookup: typeof dnsLookup) {
if (resolver.kind !== 'doh' || resolver.bootstrap) return resolver;
const endpoint = new URL(resolver.endpoint);
const addresses = await lookup(endpoint.hostname, { all: true, verbatim: true });
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
throw new Error('DoH endpoint is not public');
}
return {
...resolver,
bootstrap: addresses.find(({ family }) => family === 4)?.address || addresses[0].address,
};
}
function domains(customDomains: CustomDnsDomain[]): DnsDomainDefinition[] {
return [...DNS_DIAGNOSTIC_DOMAINS, ...customDomains.map((domain) => ({ ...domain, custom: true }))];
}
function errorCode(error: unknown) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (code === 'ETIMEDOUT' || (error instanceof Error && error.message === 'timeout')) return 'timeout';
const message = error instanceof Error ? error.message : '';
if (
message === 'DoH endpoint is not public'
|| message.startsWith('malformed DNS')
|| message.startsWith('unexpected DNS')
|| /^DoH HTTP \d{3}$/.test(message)
) return message;
return 'DNS request failed';
}
async function queryType(
hostname: string,
type: RecordKind,
path: PathKind,
resolver: DnsResolverDefinition,
proxyPort: number,
classicExchange: ClassicExchange,
dohExchange: DohExchange,
tcpExchange: TcpExchange,
) {
const id = crypto.randomInt(0, 65_536);
const packet = buildDnsQuery(hostname, type, id);
const exchange = resolver.kind === 'doh' ? dohExchange : classicExchange;
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
let result = await exchange({
path, resolver, packet, proxyPort,
timeoutMs: resolver.kind === 'doh' ? DOH_TIMEOUT_MS : CLASSIC_TIMEOUT_MS,
});
let parsed = parseDnsResponse(result.response, id);
if (resolver.kind === 'dns' && parsed.truncated) {
result = await tcpExchange({ path, resolver, packet, proxyPort, timeoutMs: CLASSIC_TIMEOUT_MS });
parsed = parseDnsResponse(result.response, id);
}
return { ...parsed, latencyMs: result.latencyMs, transport: result.transport };
} catch (error) {
lastError = error;
if (errorCode(error) !== 'timeout') break;
}
}
throw lastError;
}
async function queryPath(
hostname: string,
path: PathKind,
resolver: DnsResolverDefinition,
proxyPort: number,
classicExchange: ClassicExchange,
dohExchange: DohExchange,
tcpExchange: TcpExchange,
): Promise<QueryPathResult> {
const settled = await Promise.allSettled((['A', 'AAAA'] as RecordKind[]).map((type) => (
queryType(hostname, type, path, resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
)));
const values = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
const errors = settled.flatMap((result) => result.status === 'rejected' ? [errorCode(result.reason)] : []);
const rcodes = [...new Set(values.map(({ rcode }) => rcode))];
const ipv4 = [...new Set(values.flatMap((value) => value.ipv4))];
const ipv6 = [...new Set(values.flatMap((value) => value.ipv6))];
const rcode = rcodes.find((value) => value !== 'NOERROR') || rcodes[0] || null;
const status = ipv4.length || ipv6.length
? 'answered'
: rcode === 'NXDOMAIN'
? 'nxdomain'
: rcode === 'SERVFAIL'
? 'servfail'
: values.length
? 'no-addresses'
: errors.every((error) => error === 'timeout')
? 'timeout'
: 'error';
return {
status,
rcode,
ipv4,
ipv6,
latencyMs: values.length ? Math.max(...values.map(({ latencyMs }) => latencyMs)) : null,
transport: [...new Set(values.map(({ transport }) => transport))].join('+') || null,
error: errors.length ? [...new Set(errors)].join('; ') : null,
};
}
function addressSet(result: QueryPathResult) {
return [...result.ipv4, ...result.ipv6].sort();
}
function comparison(direct: QueryPathResult, vpn: QueryPathResult) {
const directOk = direct.status === 'answered' || direct.status === 'no-addresses' || direct.status === 'nxdomain';
const vpnOk = vpn.status === 'answered' || vpn.status === 'no-addresses' || vpn.status === 'nxdomain';
if (!directOk && !vpnOk) return 'failed';
if (directOk && !vpnOk) return 'direct-only';
if (!directOk && vpnOk) return 'vpn-only';
return JSON.stringify(addressSet(direct)) === JSON.stringify(addressSet(vpn)) && direct.rcode === vpn.rcode
? 'same'
: 'different';
}
export function createDnsDiagnosticsService({
proxyPort,
getServers = dnsGetServers,
lookup = dnsLookup,
classicExchange = defaultClassicExchange,
dohExchange = defaultDohExchange,
tcpExchange = tcpFallback,
now = () => new Date().toISOString(),
}: {
proxyPort: number;
getServers?: () => string[];
lookup?: typeof dnsLookup;
classicExchange?: ClassicExchange;
dohExchange?: DohExchange;
tcpExchange?: TcpExchange;
now?: () => string;
}) {
async function catalog(customResolvers: CustomDnsResolver[] = [], customDomains: CustomDnsDomain[] = []) {
return {
resolvers: availableResolvers(customResolvers, getServers),
domains: domains(customDomains),
};
}
async function run({
vpnAvailable,
customResolvers = [],
customDomains = [],
domainId,
resolverId = null,
}: {
vpnAvailable: boolean;
customResolvers?: CustomDnsResolver[];
customDomains?: CustomDnsDomain[];
domainId: unknown;
resolverId?: unknown;
}) {
const available = await catalog(customResolvers, customDomains);
const domain = available.domains.find(({ id }) => id === domainId);
if (!domain) throw new Error('Unknown DNS diagnostic domain');
const selected = resolverId
? available.resolvers.filter(({ id }) => id === resolverId)
: available.resolvers;
if (!selected.length) throw new Error('Unknown DNS diagnostic resolver');
const results: unknown[] = new Array(selected.length);
let cursor = 0;
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_RESOLVERS, selected.length) }, async () => {
while (cursor < selected.length) {
const index = cursor;
cursor += 1;
const catalogResolver = selected[index];
let resolver: DnsResolverDefinition;
try {
resolver = await prepareResolver(catalogResolver, lookup);
} catch (error) {
const unavailable: QueryPathResult = {
status: 'error', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
transport: null, error: errorCode(error),
};
results[index] = {
resolver: catalogResolver,
direct: unavailable,
vpn: vpnAvailable ? unavailable : { ...unavailable, status: 'vpn-off', error: 'VPN выключен' },
comparison: 'failed',
warning: null,
};
continue;
}
const directPromise = queryPath(domain.hostname, 'direct', resolver, proxyPort, classicExchange, dohExchange, tcpExchange);
const vpnPromise = vpnAvailable
? queryPath(domain.hostname, 'vpn', resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
: Promise.resolve<QueryPathResult>({
status: 'vpn-off', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
transport: null, error: 'VPN выключен',
});
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
const addresses = [...direct.ipv4, ...direct.ipv6, ...vpn.ipv4, ...vpn.ipv6];
results[index] = {
resolver: catalogResolver,
direct,
vpn,
comparison: comparison(direct, vpn),
warning: domain.builtIn && addresses.some((address) => !isPublicAddress(address))
? 'private-address'
: null,
};
}
}));
return { checkedAt: now(), domain, results };
}
return { catalog, run };
}
export type DnsDiagnosticsService = ReturnType<typeof createDnsDiagnosticsService>;
+345 -27
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,31 +336,73 @@ export function createDomainTrafficService({
}; };
} }
async function performRefresh() { function applyParsedConnections({
try { connections,
const response = record(await observe()); reset,
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array'); closedIds = [],
const devicesByIp = new Map<string, string | null>(); observed,
const observedDevices = devices(); deviceLabels,
for (const value of Array.isArray(observedDevices) ? observedDevices : []) { sourceActiveConnections,
const device = record(value); }: {
const ip = String(device.ip || ''); connections: ParsedConnection[];
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null; reset: boolean;
if (!net.isIPv4(ip) || !id) continue; closedIds?: string[];
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); observed: Date;
} deviceLabels: Map<string, string>;
const activeConnections = new Map<string, PreviousConnection>(); sourceActiveConnections?: number;
for (const rawConnection of response.connections) { }) {
const connection = parseConnection(rawConnection, devicesByIp); const nextConnections = reset
const previous = previousConnections.get(connection.id); ? 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) { if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n; 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') { if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
activeConnections.set(connection.id, { nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome, outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null, countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? 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; continue;
} }
@@ -257,6 +428,17 @@ export function createDomainTrafficService({
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload ? connection.download - previous.countedDownload
: connection.download; : 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) || { const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other', deviceId: key === requestedKey ? connection.deviceId : '_other',
domain, domain,
@@ -268,18 +450,95 @@ export function createDomainTrafficService({
total.uploadBytes += uploadDelta; total.uploadBytes += uploadDelta;
total.downloadBytes += downloadDelta; total.downloadBytes += downloadDelta;
totals.set(key, total); totals.set(key, total);
activeConnections.set(connection.id, { nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome, outcome: connection.outcome,
key, key,
requestedKey, requestedKey,
countedUpload: connection.upload, countedUpload: connection.upload,
countedDownload: connection.download, countedDownload: connection.download,
trackedUpload: connection.upload,
trackedDownload: connection.download,
}); });
} }
previousConnections = activeConnections; for (const id of closedIds) {
current = { ...current, observedAt: now().toISOString() }; 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(); current = buildSnapshot();
return current; return current;
}
async function performRefresh() {
try {
const response = record(await observe());
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
const devicesByIp = new Map<string, string | null>();
const deviceLabels = new Map<string, string>();
const observedDevices = devices();
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
const device = record(value);
const ip = String(device.ip || '');
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
deviceLabels.set(id, publicDeviceLabel(device));
}
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
nativeEpoch = null;
return applyParsedConnections({
connections,
reset: true,
observed: now(),
deviceLabels,
sourceActiveConnections: response.connections.length,
});
} catch (error) {
current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw error;
}
}
function ingestNative(batch: NativeTrafficProjectionBatch) {
try {
const observed = new Date(batch.observedAt);
if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch');
const deviceLabels = new Map<string, string>();
for (const value of batch.connections) {
const connection = record(value);
const origin = record(connection.origin);
if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) {
deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label }));
}
}
if (nativeEpoch !== batch.epoch) {
nativeEpoch = batch.epoch;
previousConnections = new Map();
settledNativeConnections.clear();
}
const connections = batch.connections.map(parseNativeConnection);
const result = applyParsedConnections({
connections,
reset: batch.reset,
closedIds: batch.closedIds,
observed,
deviceLabels,
});
return result;
} catch (error) { } 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 };
} }
+121
View File
@@ -0,0 +1,121 @@
import fs from 'node:fs';
import path from 'node:path';
import type { DatabaseSync } from 'node:sqlite';
import { normalizeStoredActivityEvent, type ActivityJournalEvent } from '../../shared/activityJournal.js';
import { normalizeSubscriptionConfig } from '../subscription.js';
import { migrateDeviceInventoryState } from './deviceInventoryService.js';
import { migrateStoredState } from './stateStore.js';
import { openSqlite, transaction } from './sqlite.js';
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Invalid stored Harbor document');
}
return value as Record<string, unknown>;
}
function legacyDocument(directory: string, name: string) {
const file = path.join(directory, name);
if (!fs.existsSync(file)) return null;
// Never run the legacy JSON recovery writer during import: originals are the backup.
try {
const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
return value === null && name === 'subscription-cache.json' ? null : object(value);
}
catch { throw new Error(`Cannot migrate ${name}: invalid JSON document; original retained`); }
}
export function createSqliteDocumentStore<T>(db: DatabaseSync, key: string, migrate: (value: unknown) => T) {
const select = db.prepare('SELECT value FROM documents WHERE key = ?');
const save = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
function read(): T {
const row = select.get(key);
if (!row || typeof row.value !== 'string') throw new Error(`Missing Harbor document: ${key}`);
return migrate(JSON.parse(row.value));
}
function write(value: T) {
const next = migrate(structuredClone(value));
save.run(key, JSON.stringify(next));
return structuredClone(next);
}
return {
read,
write,
update: (change: (value: T) => T) => transaction(db, () => {
const next = change(read());
if (next && typeof next === 'object' && 'then' in next) throw new TypeError('State store mutator must be synchronous');
return write(next);
}),
};
}
export function openHarborStorage(directory: string) {
const db = openSqlite(path.join(directory, 'harbor.sqlite'));
let imported = false;
try {
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
if (version !== 0 && version !== 1) throw new Error(`Unsupported Harbor database version: ${version}`);
transaction(db, () => {
db.exec(`
CREATE TABLE IF NOT EXISTS documents (key TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT;
CREATE TABLE IF NOT EXISTS journal (
sequence INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, occurred_at TEXT NOT NULL,
dedupe_key TEXT UNIQUE, value TEXT NOT NULL CHECK(json_valid(value))
) STRICT;
CREATE INDEX IF NOT EXISTS journal_time ON journal(occurred_at);
`);
const initialized = db.prepare("SELECT value FROM documents WHERE key = 'storage-version'").get();
if (!initialized) {
// All inputs are read and normalized before any imported record is committed.
const rawState = legacyDocument(directory, 'state.json') || {};
const rawDevices = legacyDocument(directory, 'devices.json') || {};
const rawJournal = legacyDocument(directory, 'activity-journal.json');
const stateVersion = Number(rawState.schemaVersion || 0);
const legacyCache = stateVersion < 5 && !Array.isArray(rawState.profiles)
? legacyDocument(directory, 'subscription-cache.json')
: null;
let cache: unknown = null;
if (legacyCache?.config) {
const stateUrl = String(rawState.subscriptionUrl || '').trim();
const cacheUrl = String(legacyCache.url || '').trim();
if (!cacheUrl || (stateUrl && cacheUrl !== stateUrl)) {
throw new Error('Cannot migrate subscription cache: owner mismatch; originals retained');
}
cache = { ...legacyCache, ...normalizeSubscriptionConfig(legacyCache.config) };
}
const state = migrateStoredState(rawState, cache);
const devices = migrateDeviceInventoryState(rawDevices);
let events: ActivityJournalEvent[] = [];
if (rawJournal) {
if (rawJournal.schemaVersion !== 1 || !Array.isArray(rawJournal.events)) {
throw new Error('Unsupported activity journal document; original retained');
}
events = rawJournal.events.map((raw) => {
const event = normalizeStoredActivityEvent(raw);
if (!event) throw new Error('Invalid activity journal event; original retained');
return event;
});
}
const insert = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?)');
insert.run('state', JSON.stringify(state));
insert.run('devices', JSON.stringify(devices));
const insertEvent = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?)');
for (const event of events) insertEvent.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
insert.run('storage-version', '1');
imported = true;
} else if (initialized.value !== '1') {
throw new Error('Unsupported Harbor storage version');
}
db.exec('PRAGMA user_version = 1');
});
const state = createSqliteDocumentStore(db, 'state', migrateStoredState);
const devices = createSqliteDocumentStore(db, 'devices', migrateDeviceInventoryState);
// Validate stored versions before startup. SQLite corruption never falls back to JSON.
state.read();
devices.read();
return { db, state, devices, imported, close: () => db.close() };
} catch (error) {
db.close();
throw error;
}
}
+786
View File
@@ -0,0 +1,786 @@
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, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const finish = () => {
clearTimeout(timer);
signal.removeEventListener('abort', finish);
resolve();
};
const timer = setTimeout(finish, milliseconds);
timer.unref();
signal.addEventListener('abort', finish, { once: true });
if (signal.aborted) finish();
});
}
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;
const stopController = new AbortController();
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, stopController.signal);
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, stopController.signal);
}
};
return {
start() {
if (!enabled || running) return;
running = loop();
},
async stop() {
stopped = true;
stopController.abort();
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 };
}
+31
View File
@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
export function openSqlite(filePath: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const db = new DatabaseSync(filePath);
try {
fs.chmodSync(filePath, 0o600);
db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;');
return db;
} catch (error) {
db.close();
throw error;
}
}
export function transaction<T>(db: DatabaseSync, change: () => T): T {
db.exec('BEGIN IMMEDIATE');
try {
const result = change();
if (result && typeof result === 'object' && 'then' in result) {
throw new TypeError('SQLite transaction must be synchronous');
}
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
+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 = 10;
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,
}); });
} }
@@ -0,0 +1,194 @@
import { Worker } from 'node:worker_threads';
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
import {
assertTrafficHistorySnapshot,
emptyTrafficHistory,
type TrafficHistoryQuery,
} from '../../shared/trafficHistory.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
import type { HistoryWorkerRequest } from './trafficHistoryWorker.js';
type Request = HistoryWorkerRequest extends infer R ? R extends { id: number } ? Omit<R, 'id'> : never : never;
const MAX_QUEUED_CONNECTIONS = 16_384;
interface Job {
id: number;
message: Request;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
export function createTrafficHistoryService({ filePath, source }: {
filePath: string;
source: () => LiveTrafficSourceState;
}) {
let worker: Worker | null = null;
let ready = false;
let terminating: Promise<void> | null = null;
let active: Job | null = null;
const jobs: Job[] = [];
let watchdog: NodeJS.Timeout | undefined;
let readDeadline: NodeJS.Timeout | undefined;
let readers = 0;
let sequence = 0;
let pending: NativeTrafficProjectionBatch[] = [];
let queued = 0;
let missedSince: number | null = null;
let storageError = false;
let stopping = false;
let closed = false;
let flushing: Promise<void> | null = null;
function clearDeadlines() {
clearTimeout(watchdog);
clearTimeout(readDeadline);
}
function failed(current: Worker) {
if (worker !== current) return;
const startupFailed = !ready;
worker = null;
ready = false;
clearDeadlines();
active?.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
active = null;
if (startupFailed) {
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
}
// Native SQLite may still be finishing a call. Never start a second writer until it exits.
terminating = current.terminate().then(() => {}, () => {}).then(() => {
terminating = null;
dispatch();
});
}
function ensureWorker() {
if (worker) return worker;
const current = new Worker(new URL('./trafficHistoryWorker.js', import.meta.url), { workerData: { filePath } });
worker = current;
watchdog = setTimeout(() => failed(current), 60_000);
current.on('message', (message: { ready?: boolean; id?: number; result?: unknown; error?: string }) => {
if (worker !== current) return;
if (message.ready) {
clearDeadlines();
ready = true;
dispatch();
return;
}
if (!active || active.id !== message.id) return;
const job = active;
active = null;
clearDeadlines();
if (message.error) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
else job.resolve(message.result);
dispatch();
});
current.on('error', () => failed(current));
current.on('exit', () => failed(current));
return current;
}
function dispatch() {
if (active || terminating || !jobs.length) return;
let current: Worker;
try { current = ensureWorker(); }
catch {
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
return;
}
if (!ready) return;
const job = jobs.shift()!;
active = job;
if (job.message.kind === 'query') {
// A slow read only expires its caller. Keep the slot until SQL actually finishes.
readDeadline = setTimeout(() => job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE')), 5_000);
}
watchdog = setTimeout(() => failed(current), 60_000);
try { current.postMessage({ ...job.message, id: job.id }); }
catch { failed(current); }
}
function request(message: Request): Promise<unknown> {
return new Promise((resolve, reject) => {
const job = { id: ++sequence, message, resolve, reject };
if (message.kind === 'ingest') jobs.unshift(job);
else jobs.push(job);
dispatch();
});
}
function enqueue(batch: NativeTrafficProjectionBatch) {
if (stopping) return;
if (queued + batch.connections.length > MAX_QUEUED_CONNECTIONS || pending.length >= 120) {
missedSince ??= Date.parse(batch.observedAt);
return;
}
pending.push(batch);
queued += batch.connections.length;
}
function flush(): Promise<void> {
if (closed) return Promise.resolve();
if (flushing) return flushing;
const batches = pending;
const missed = missedSince;
pending = [];
queued = 0;
missedSince = null;
flushing = request({ kind: 'ingest', batches, source: source(), missedSince: missed })
.then(() => { storageError = false; })
.catch(() => {
storageError = true;
missedSince = Math.min(missedSince ?? Infinity, missed ?? Infinity,
batches.length ? Date.parse(batches[0].observedAt) : Date.now());
// Recoverable cumulative counters will reconcile on the next batch. Lost closed flows
// stay an explicit gap; history must never backpressure the native/metrics collector.
})
.finally(() => { flushing = null; });
return flushing;
}
const timer = setInterval(() => { if (!stopping) void flush(); }, 1_000);
timer.unref();
return {
enqueue,
flush,
async query(query: TrafficHistoryQuery) {
let admitted = false;
try {
if (stopping || readers >= 32) throw new Error('TRAFFIC_HISTORY_BUSY');
readers++;
admitted = true;
await flush();
if (stopping) throw new Error('TRAFFIC_HISTORY_UNAVAILABLE');
const result = assertTrafficHistorySnapshot(await request({ kind: 'query', query }));
result.source = source();
if (missedSince !== null || storageError) result.coverage.partial = true;
if (storageError) result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
return result;
} catch {
const result = emptyTrafficHistory(query, source());
result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
result.coverage.partial = true;
return result;
} finally {
if (admitted) readers--;
}
},
async close() {
if (stopping) return;
stopping = true;
clearInterval(timer);
for (let index = jobs.length - 1; index >= 0; index--) {
if (jobs[index].message.kind === 'query') {
jobs.splice(index, 1)[0].reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
}
}
await flushing;
await flush();
if (worker) {
try { await request({ kind: 'close' }); } catch { /* shutdown remains bounded */ }
const current = worker;
worker = null;
ready = false;
clearDeadlines();
await current?.terminate();
}
await terminating;
closed = true;
},
};
}
+262
View File
@@ -0,0 +1,262 @@
import net from 'node:net';
import { domainToASCII } from 'node:url';
import { getDomain } from 'tldts';
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
import { emptyTrafficHistory, type TrafficHistoryQuery, type TrafficHistorySnapshot } from '../../shared/trafficHistory.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
import { classifyDomain } from './domainTrafficService.js';
import { openSqlite, transaction } from './sqlite.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MAX_INTEGER = (1n << 63n) - 1n;
function hostname(value: string | null) {
const name = domainToASCII((value || '').trim().replace(/\.$/, '')).toLowerCase();
return name.length <= 253 && !net.isIP(name)
&& name.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part)) ? name : '';
}
export function openTrafficHistoryStore(filePath: string, now = Date.now) {
const db = openSqlite(filePath);
try {
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
if (version !== 0 && version !== 1) throw new Error(`Unsupported traffic database version: ${version}`);
transaction(db, () => db.exec(`
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE IF NOT EXISTS dimensions (
id INTEGER PRIMARY KEY, identity TEXT NOT NULL UNIQUE,
origin_id TEXT NOT NULL, origin_label TEXT NOT NULL, source_ip TEXT NOT NULL,
inbound TEXT NOT NULL, service TEXT NOT NULL, domain TEXT NOT NULL,
hostname TEXT NOT NULL, ip TEXT NOT NULL, route TEXT NOT NULL, outbound TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS buckets (
at INTEGER NOT NULL, resolution INTEGER NOT NULL, dimension_id INTEGER NOT NULL REFERENCES dimensions(id),
upload INTEGER NOT NULL CHECK(upload >= 0), download INTEGER NOT NULL CHECK(download >= 0),
PRIMARY KEY(at, resolution, dimension_id)
) STRICT, WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS buckets_dimension_time ON buckets(dimension_id, at, upload, download);
CREATE TABLE IF NOT EXISTS checkpoints (
identity TEXT PRIMARY KEY, upload INTEGER NOT NULL, download INTEGER NOT NULL,
last_seen INTEGER NOT NULL, closed INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS checkpoint_age ON checkpoints(last_seen);
CREATE INDEX IF NOT EXISTS checkpoint_active ON checkpoints(json_extract(identity, '$[1]')) WHERE closed = 0;
CREATE TABLE IF NOT EXISTS gaps (at INTEGER PRIMARY KEY, until_at INTEGER NOT NULL) STRICT;
PRAGMA user_version = 1;
`));
} catch (error) { db.close(); throw error; }
db.exec(`CREATE TEMP TABLE period_totals (
period TEXT NOT NULL, dimension_id INTEGER NOT NULL, upload INTEGER NOT NULL, download INTEGER NOT NULL,
PRIMARY KEY(period, dimension_id)
) STRICT, WITHOUT ROWID`);
const periods = new Map<string, { from: number; to: number }>();
const dropPeriod = db.prepare('DELETE FROM period_totals WHERE period = ?');
function periodTotals(from: number, to: number) {
const key = `${from}:${to}`;
if (!periods.has(key)) {
// Four visible ranges; old frozen pages are recomputed after eviction.
if (periods.size === 4) {
const oldest = periods.keys().next().value!;
dropPeriod.run(oldest);
periods.delete(oldest);
}
db.prepare(`INSERT INTO period_totals
SELECT ?, d.id, SUM(b.upload), SUM(b.download)
FROM dimensions d CROSS JOIN buckets b INDEXED BY buckets_dimension_time
ON b.dimension_id = d.id WHERE b.at >= ? AND b.at < ? GROUP BY d.id`).run(key, from, to);
periods.set(key, { from, to });
}
return key;
}
// SQLite's built-in lower() handles ASCII only; service labels also use Cyrillic.
db.function('lower_unicode', { deterministic: true }, (value) => String(value).toLowerCase());
const meta = (key: string) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value as string | undefined;
const setMeta = db.prepare('INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
const checkpoint = db.prepare('SELECT upload, download, last_seen FROM checkpoints WHERE identity = ?');
checkpoint.setReadBigInts(true);
const saveCheckpoint = db.prepare(`INSERT INTO checkpoints VALUES (?, ?, ?, ?, ?)
ON CONFLICT(identity) DO UPDATE SET upload = excluded.upload, download = excluded.download,
last_seen = excluded.last_seen, closed = excluded.closed`);
const saveDimension = db.prepare(`INSERT INTO dimensions(identity, origin_id, origin_label, source_ip, inbound,
service, domain, hostname, ip, route, outbound) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(identity) DO UPDATE SET origin_label = excluded.origin_label RETURNING id`);
const saveBucket = db.prepare(`INSERT INTO buckets VALUES (?, ?, ?, ?, ?)
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
upload = upload + excluded.upload, download = download + excluded.download`);
const saveGap = db.prepare('INSERT INTO gaps VALUES (?, ?) ON CONFLICT(at) DO UPDATE SET until_at = MAX(until_at, excluded.until_at)');
let lastMaintenance = 0;
function gap(from: number, to: number) {
saveGap.run(Math.floor(from / MINUTE) * MINUTE, Math.max(from, to));
}
// A reopened collector cannot prove that short-lived flows were observed while it was down.
const previousObservation = Number(meta('observed') || 0);
if (previousObservation) transaction(db, () => gap(previousObservation, now()));
function maintain(timestamp = now()) {
const hourCutoff = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
const cutoff = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
transaction(db, () => {
db.prepare('DELETE FROM buckets WHERE at < ?').run(cutoff);
db.prepare(`INSERT INTO buckets(at, resolution, dimension_id, upload, download)
SELECT (at / ?) * ?, ?, dimension_id, SUM(upload), SUM(download)
FROM buckets WHERE resolution = ? AND at < ? GROUP BY (at / ?), dimension_id
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
upload = upload + excluded.upload, download = download + excluded.download
`).run(HOUR, HOUR, HOUR, MINUTE, hourCutoff, HOUR);
db.prepare('DELETE FROM buckets WHERE resolution = ? AND at < ?').run(MINUTE, hourCutoff);
// Idle active flows can outlive retention; their cumulative baseline is still required.
// ponytail: closed tombstones can grow for 90 days; tighter pruning needs an upstream replay watermark.
db.prepare('DELETE FROM checkpoints WHERE closed = 1 AND last_seen < ?').run(cutoff);
db.prepare('DELETE FROM gaps WHERE until_at < ?').run(cutoff);
db.exec('DELETE FROM dimensions WHERE id NOT IN (SELECT dimension_id FROM buckets)');
db.exec('DELETE FROM period_totals');
});
periods.clear();
lastMaintenance = timestamp;
}
function ingest(batches: NativeTrafficProjectionBatch[], source: LiveTrafficSourceState, missedSince: number | null = null) {
const timestamp = now();
const changedPeriods = new Set<string>();
transaction(db, () => {
const collectionStart = Number(meta('started') || batches[0] && Date.parse(batches[0].observedAt) || timestamp);
if (!meta('started') && batches.length) setMeta.run('started', String(collectionStart));
let lastAt = Number(meta('observed') || 0);
if (missedSince !== null) gap(missedSince, timestamp);
for (const batch of batches) {
const at = Date.parse(batch.observedAt);
if (!Number.isFinite(at) || !batch.epoch) continue;
if (meta('epoch') !== batch.epoch) {
// Projection batches are FIFO. A new sing-box epoch cannot replay old lifecycles.
db.exec('DELETE FROM checkpoints');
setMeta.run('epoch', batch.epoch);
}
if (batch.reset) db.prepare('UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?) WHERE closed = 0').run(at);
if (lastAt && (batch.reset || at - lastAt > 5_000)) gap(lastAt, at);
for (const connection of batch.connections) {
const started = Date.parse(connection.startedAt);
if (!Number.isFinite(started)) continue;
if (connection.closedAt && Date.parse(connection.closedAt) < timestamp - 90 * DAY) continue;
const identity = JSON.stringify([batch.epoch, connection.id, connection.startedAt]);
const old = checkpoint.get(identity);
const upload = BigInt(connection.traffic.uploadBytes);
const download = BigInt(connection.traffic.downloadBytes);
if (upload < 0n || download < 0n || upload > MAX_INTEGER || download > MAX_INTEGER) {
gap(lastAt || at, at);
continue;
}
if (old && at < Number(old.last_seen)) continue;
// Initial snapshots baseline pre-existing flows; they are not historical observations.
const baseline = !old && started < collectionStart;
let up = baseline ? 0n : upload - BigInt(String(old?.upload ?? 0));
let down = baseline ? 0n : download - BigInt(String(old?.download ?? 0));
if (up < 0n || down < 0n) {
gap(old ? Number(old.last_seen) : at, at);
up = up < 0n ? 0n : up;
down = down < 0n ? 0n : down;
}
saveCheckpoint.run(identity, old && upload < BigInt(String(old.upload)) ? old.upload : upload,
old && download < BigInt(String(old.download)) ? old.download : download,
at, connection.closedAt === null ? 0 : 1);
if (up === 0n && down === 0n) continue;
const host = hostname(connection.destination.domain);
const domain = host ? getDomain(host, { allowPrivateDomains: true }) || host : '';
const classification = classifyDomain(host);
const service = ['yandex.ru', 'yandex.com', 'yandex.net', 'yastatic.net'].includes(domain) ? 'Яндекс'
: classification && classification.service !== classification.domain ? classification.service : domain;
const origin = connection.origin.kind === 'this-mac' ? 'this-mac'
: connection.origin.id || `unknown:${connection.source.ip}`;
const ip = net.isIP(connection.destination.ip || '') ? connection.destination.ip! : '';
const dims = [origin, connection.source.ip, connection.inbound.tag, service, domain,
host, ip, connection.route.kind, connection.route.outbound || ''];
const dimension = saveDimension.get(JSON.stringify(dims), origin, connection.origin.label,
...dims.slice(1));
const resolution = at < Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR ? HOUR : MINUTE;
const bucketAt = Math.floor(at / resolution) * resolution;
saveBucket.run(bucketAt, resolution, dimension!.id, up, down);
for (const [key, period] of periods) {
if (bucketAt >= period.from && bucketAt < period.to) changedPeriods.add(key);
}
}
if (batch.closedIds.length) {
const closed = db.prepare(`UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?)
WHERE closed = 0 AND json_extract(identity, '$[1]') IN (SELECT value FROM json_each(?))`)
.run(at, JSON.stringify(batch.closedIds));
if (closed.changes) gap(lastAt || at, at); // Terminal identity without final byte totals.
}
lastAt = Math.max(lastAt, at);
}
if (lastAt) setMeta.run('observed', String(lastAt));
if (source === 'degraded' || source === 'stale' || source === 'incompatible'
|| (source === 'connecting' && lastAt)) gap(lastAt || timestamp, timestamp);
setMeta.run('source', source);
for (const key of changedPeriods) dropPeriod.run(key);
});
for (const key of changedPeriods) {
periods.delete(key);
}
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
}
function query(input: TrafficHistoryQuery): TrafficHistorySnapshot {
const timestamp = now();
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
const result = emptyTrafficHistory(input, (meta('source') || 'connecting') as LiveTrafficSourceState, timestamp);
const requestedTo = Date.parse(result.period.to);
const retentionStart = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
const from = Math.max(Date.parse(result.period.from), retentionStart);
const minuteFrom = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
const terminalResolution = requestedTo < minuteFrom ? HOUR : MINUTE;
const to = Math.floor(requestedTo / terminalResolution) * terminalResolution;
result.period.to = new Date(to).toISOString();
result.query.until = to;
// Bounds describe complete stored buckets; old data is explicitly hourly, not minute-precise.
const effectiveFrom = Math.min(to, Math.floor(from / (from < minuteFrom ? HOUR : MINUTE)) * (from < minuteFrom ? HOUR : MINUTE));
result.period.from = new Date(effectiveFrom).toISOString();
result.period.minuteFrom = new Date(minuteFrom).toISOString();
result.period.availableFrom = meta('started') ? new Date(Math.max(Number(meta('started')), retentionStart)).toISOString() : null;
const lastAt = Number(meta('observed') || 0);
result.coverage.lastObservedAt = lastAt ? new Date(lastAt).toISOString() : null;
result.coverage.gapCount = Number(db.prepare('SELECT COUNT(*) AS count FROM gaps WHERE until_at >= ? AND at < ?').get(effectiveFrom, to)?.count);
result.coverage.partial = result.coverage.gapCount > 0 || ['stale', 'degraded', 'incompatible'].includes(result.source)
|| (lastAt > 0 && result.source === 'live' && timestamp - lastAt > 5_000);
const period = periodTotals(effectiveFrom, to);
const where = ['b.period = ?'];
const args: Array<string | number> = [period];
for (const [column, value] of [
['origin_id', input.originId], ['service', input.service], ['domain', input.domain], ['hostname', input.hostname],
['route', input.route === 'all' ? '' : input.route],
]) if (value) { where.push(`d.${column} = ?`); args.push(value); }
// Empty domain is a real IP-only group. Parent filters need an explicit level, not truthiness alone.
if (input.level !== 'service') { where.push('d.service = ?'); args.push(input.service); }
if (input.level === 'hostname' || input.level === 'ip') { where.push('d.domain = ?'); args.push(input.domain); }
if (input.level === 'ip') { where.push('d.hostname = ?'); args.push(input.hostname); }
if (input.search) {
where.push("instr(lower_unicode(d.hostname || ' ' || d.ip || ' ' || d.service), ?) > 0"); args.push(input.search.toLowerCase());
}
const joined = `FROM period_totals b JOIN dimensions d ON d.id = b.dimension_id WHERE ${where.join(' AND ')}`;
const totals = db.prepare(`SELECT COALESCE(SUM(b.upload), 0) AS upload, COALESCE(SUM(b.download), 0) AS download ${joined}`);
totals.setReadBigInts(true);
const sum = totals.get(...args)!;
result.totals = { uploadBytes: String(sum.upload), downloadBytes: String(sum.download) };
const rows = db.prepare(`SELECT d.${input.level} AS key, SUM(b.upload) AS upload, SUM(b.download) AS download,
CASE WHEN COUNT(DISTINCT d.route) = 1 THEN MIN(d.route) ELSE 'mixed' END AS route
${joined} GROUP BY d.${input.level} ORDER BY SUM(b.upload) + SUM(b.download) DESC, key LIMIT 101 OFFSET ?`);
rows.setReadBigInts(true);
const page = rows.all(...args, input.offset);
result.rows = page.slice(0, 100).map((row) => ({
key: String(row.key), label: String(row.key) || (input.level === 'ip' ? 'IP неизвестен' : 'Без домена'),
uploadBytes: String(row.upload), downloadBytes: String(row.download), route: String(row.route) as 'mixed',
}));
result.nextOffset = page.length > 100 ? input.offset + 100 : null;
const origins = db.prepare(`SELECT d.origin_id AS id, MAX(d.origin_label) AS label FROM dimensions d
JOIN period_totals b ON b.dimension_id = d.id WHERE b.period = ?
GROUP BY d.origin_id ORDER BY id LIMIT 257`).all(period);
result.origins = origins.slice(0, 256).map((row) => ({ id: String(row.id), label: String(row.label) }));
result.originsTruncated = origins.length > 256;
return result;
}
return { ingest, query, maintain, close: () => db.close() };
}
@@ -0,0 +1,26 @@
import { parentPort, workerData } from 'node:worker_threads';
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
import type { TrafficHistoryQuery } from '../../shared/trafficHistory.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
import { openTrafficHistoryStore } from './trafficHistoryStore.js';
export type HistoryWorkerRequest =
| { id: number; kind: 'ingest'; batches: NativeTrafficProjectionBatch[]; source: LiveTrafficSourceState; missedSince: number | null }
| { id: number; kind: 'query'; query: TrafficHistoryQuery }
| { id: number; kind: 'close' };
const store = openTrafficHistoryStore(workerData.filePath);
parentPort!.postMessage({ ready: true });
parentPort!.on('message', (message: HistoryWorkerRequest) => {
try {
let result: unknown = null;
if (message.kind === 'ingest') store.ingest(message.batches, message.source, message.missedSince);
else if (message.kind === 'query') result = store.query(message.query);
else store.close();
parentPort!.postMessage({ id: message.id, result });
if (message.kind === 'close') parentPort!.close();
} catch {
// Do not expose filesystem paths, SQL or native metadata through the public error.
parentPort!.postMessage({ id: message.id, error: 'TRAFFIC_HISTORY_UNAVAILABLE' });
}
});
+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 },
};
}
+218
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' },
@@ -18,6 +24,217 @@ export const CONNECTIVITY_SITES = Object.freeze([
]); ]);
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5; export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
export const MAX_CUSTOM_DNS_RESOLVERS = 5;
export const MAX_CUSTOM_DNS_DOMAINS = 5;
export const DNS_DIAGNOSTIC_DOMAINS = Object.freeze([
{ id: 'youtube', label: 'YouTube', hostname: 'www.youtube.com', builtIn: true },
{ id: 'chatgpt', label: 'ChatGPT', hostname: 'chatgpt.com', builtIn: true },
]);
export const DNS_DIAGNOSTIC_RESOLVERS = Object.freeze([
{ id: 'google-dns', label: 'Google DNS', kind: 'dns', endpoint: '8.8.8.8' },
{
id: 'google-doh',
label: 'Google DoH',
kind: 'doh',
endpoint: 'https://dns.google/dns-query',
bootstrap: '8.8.8.8',
},
{ id: 'cloudflare-dns', label: 'Cloudflare DNS', kind: 'dns', endpoint: '1.1.1.1' },
{
id: 'cloudflare-doh',
label: 'Cloudflare DoH',
kind: 'doh',
endpoint: 'https://cloudflare-dns.com/dns-query',
bootstrap: '1.1.1.1',
},
{ id: 'yandex-dns', label: 'Яндекс DNS', kind: 'dns', endpoint: '77.88.8.8' },
{
id: 'yandex-doh',
label: 'Яндекс DoH',
kind: 'doh',
endpoint: 'https://common.dot.dns.yandex.net/dns-query',
bootstrap: '77.88.8.8',
},
] satisfies DnsResolverDefinition[]);
export interface DiagnosticService {
id: string;
label: string;
url: string;
}
export interface DnsResolverDefinition {
id: string;
label: string;
kind: 'dns' | 'doh';
endpoint: string;
bootstrap?: string;
system?: boolean;
custom?: boolean;
}
export interface DnsDomainDefinition {
id: string;
label: string;
hostname: string;
builtIn?: boolean;
custom?: boolean;
}
export interface CustomDnsResolver {
id: string;
label: string;
kind: 'dns' | 'doh';
endpoint: string;
}
export interface CustomDnsDomain {
id: string;
label: string;
hostname: string;
}
export interface DiagnosticSettings {
configured: boolean;
customServices: DiagnosticService[];
hiddenServiceIds: string[];
customDnsResolvers: CustomDnsResolver[];
customDnsDomains: CustomDnsDomain[];
}
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;
}
}
function ipLiteral(value: string) {
if (!value || /[\s/?#@\[\]]/.test(value)) return false;
try {
const parsed = new URL(`http://${value.includes(':') ? `[${value}]` : value}/`);
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
return hostname === value.toLowerCase() && (value.includes(':') || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value));
} catch {
return false;
}
}
function dohEndpoint(value: string) {
try {
const parsed = new URL(value);
if (
parsed.protocol !== 'https:'
|| parsed.username
|| parsed.password
|| parsed.search
|| parsed.hash
|| (parsed.port && parsed.port !== '443')
) return null;
return parsed.href;
} catch {
return null;
}
}
function customDnsResolver(value: unknown): CustomDnsResolver | null {
const candidate = record(value);
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const kind = candidate.kind === 'dns' || candidate.kind === 'doh' ? candidate.kind : null;
const rawEndpoint = typeof candidate.endpoint === 'string' ? candidate.endpoint.trim() : '';
if (!/^custom-dns-[a-z0-9-]{1,72}$/i.test(id) || !label || label.length > 40 || !kind) return null;
const endpoint = kind === 'dns' ? (ipLiteral(rawEndpoint) ? rawEndpoint.toLowerCase() : null) : dohEndpoint(rawEndpoint);
return endpoint ? { id, label, kind, endpoint } : null;
}
function normalizedHostname(value: unknown) {
const text = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
if (!text || text.length > 253 || /[\s/?#@:]/.test(text)) return null;
try {
const hostname = new URL(`http://${text}/`).hostname.toLowerCase();
return hostname && hostname.length <= 253 ? hostname : null;
} catch {
return null;
}
}
function customDnsDomain(value: unknown): CustomDnsDomain | null {
const candidate = record(value);
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const hostname = normalizedHostname(candidate.hostname);
if (!/^custom-domain-[a-z0-9-]{1,68}$/i.test(id) || !label || label.length > 40 || !hostname) return null;
return { id, label, hostname };
}
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);
const requestedDnsResolvers = Array.isArray(candidate.customDnsResolvers) ? candidate.customDnsResolvers : [];
const customDnsResolvers = requestedDnsResolvers
.map(customDnsResolver)
.filter((resolver): resolver is CustomDnsResolver => Boolean(resolver))
.filter((resolver, index, resolvers) => resolvers.findIndex(({ id }) => id === resolver.id) === index)
.slice(0, MAX_CUSTOM_DNS_RESOLVERS);
const requestedDnsDomains = Array.isArray(candidate.customDnsDomains) ? candidate.customDnsDomains : [];
const customDnsDomains = requestedDnsDomains
.map(customDnsDomain)
.filter((domain): domain is CustomDnsDomain => Boolean(domain))
.filter((domain, index, domains) => domains.findIndex(({ id }) => id === domain.id) === index)
.slice(0, MAX_CUSTOM_DNS_DOMAINS);
if (strict && (
typeof candidate.configured !== 'boolean'
|| !Array.isArray(candidate.customServices)
|| !Array.isArray(candidate.hiddenServiceIds)
|| customServices.length !== requestedServices.length
|| hiddenServiceIds.length !== requestedHiddenIds.length
|| (candidate.customDnsResolvers !== undefined && !Array.isArray(candidate.customDnsResolvers))
|| (candidate.customDnsDomains !== undefined && !Array.isArray(candidate.customDnsDomains))
|| customDnsResolvers.length !== requestedDnsResolvers.length
|| customDnsDomains.length !== requestedDnsDomains.length
)) throw new TypeError('Invalid diagnostic settings');
return {
configured: candidate.configured === true,
customServices,
hiddenServiceIds,
customDnsResolvers,
customDnsDomains,
};
}
export interface ConnectivitySiteResult { export interface ConnectivitySiteResult {
id: string; id: string;
@@ -32,6 +249,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;
} }
+380 -40
View File
@@ -1,5 +1,27 @@
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';
import {
normalizeTrafficSettings,
type TrafficSettings,
} from '../trafficSettings.js';
export type HarborMode = 'client' | 'gateway'; export type HarborMode = 'client' | 'gateway';
export type ConnectionState = 'running' | 'stopped'; export type ConnectionState = 'running' | 'stopped';
@@ -18,6 +40,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 +76,31 @@ 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;
traffic: TrafficSettings;
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 +118,41 @@ 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;
traffic: TrafficSettings;
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 +176,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 +190,157 @@ 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),
traffic: normalizeTrafficSettings(state.traffic),
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 +350,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 +359,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 +382,45 @@ 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,
traffic: stored.traffic,
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 +431,53 @@ 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 candidateWithTraffic = candidateWithDiagnostics && candidateWithDiagnostics.traffic === undefined
? { ...candidateWithDiagnostics, traffic: normalizeTrafficSettings(null) }
: candidateWithDiagnostics;
const candidateWithFailover = candidateWithTraffic && candidateWithTraffic.failover === undefined
? {
...candidateWithTraffic,
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
}
: candidateWithTraffic;
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 +495,73 @@ 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 validTraffic = (traffic: TrafficSettings) => {
try {
normalizeTrafficSettings(traffic, { 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 +570,43 @@ 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) ||
!validTraffic(candidate.traffic) ||
!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 +626,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);
+111
View File
@@ -0,0 +1,111 @@
import type { LiveTrafficSourceState } from './liveTraffic.js';
export const TRAFFIC_HISTORY_RANGES = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 } as const;
export type TrafficHistoryRange = keyof typeof TRAFFIC_HISTORY_RANGES;
export type TrafficHistoryLevel = 'service' | 'domain' | 'hostname' | 'ip';
export interface TrafficHistoryQuery {
range: TrafficHistoryRange;
level: TrafficHistoryLevel;
originId: string;
search: string;
route: 'all' | 'vpn' | 'direct' | 'other';
service: string;
domain: string;
hostname: string;
offset: number;
until: number | null;
}
export interface TrafficHistoryRow {
key: string;
label: string;
uploadBytes: string;
downloadBytes: string;
route: 'vpn' | 'direct' | 'other' | 'mixed';
}
export interface TrafficHistorySnapshot {
apiVersion: 1;
generatedAt: string;
query: TrafficHistoryQuery;
period: { from: string; to: string; availableFrom: string | null; minuteFrom: string; retentionDays: 90 };
storage: { status: 'ready' | 'error'; errorCode: string | null };
source: LiveTrafficSourceState;
coverage: { partial: boolean; gapCount: number; lastObservedAt: string | null };
totals: { uploadBytes: string; downloadBytes: string };
rows: TrafficHistoryRow[];
nextOffset: number | null;
origins: Array<{ id: string; label: string }>;
originsTruncated: boolean;
}
export function parseTrafficHistoryQuery(params: URLSearchParams): TrafficHistoryQuery {
const range = params.get('range') || '24h';
const level = params.get('level') || 'service';
const route = params.get('route') || 'all';
if (!Object.hasOwn(TRAFFIC_HISTORY_RANGES, range)
|| !['service', 'domain', 'hostname', 'ip'].includes(level)
|| !['all', 'vpn', 'direct', 'other'].includes(route)) throw new TypeError('Invalid history query');
const text = (key: string, max = 253) => {
const value = params.get(key) || '';
if (value.length > max || /[\x00-\x1f]/.test(value)) throw new TypeError('Invalid history filter');
return value;
};
const offset = Number(params.get('offset') || 0);
const until = params.has('until') ? Number(params.get('until')) : null;
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|| (until !== null && (!Number.isSafeInteger(until) || until <= 0 || until > 8_640_000_000_000_000))) throw new TypeError('Invalid history page');
return {
range: range as TrafficHistoryRange, level: level as TrafficHistoryLevel,
route: route as TrafficHistoryQuery['route'], originId: text('originId', 128), search: text('search', 200).trim(),
service: text('service'), domain: text('domain'), hostname: text('hostname'), offset, until,
};
}
export function historyQueryParams(query: TrafficHistoryQuery) {
return new URLSearchParams(Object.entries(query).filter(([, value]) => value !== null)
.map(([key, value]): [string, string] => [key, String(value)]));
}
export function emptyTrafficHistory(query: TrafficHistoryQuery, source: LiveTrafficSourceState = 'disabled', now = Date.now()): TrafficHistorySnapshot {
const to = Math.min(query.until ?? now, now);
return {
apiVersion: 1, generatedAt: new Date(now).toISOString(), query: { ...query, until: to },
period: { from: new Date(to - TRAFFIC_HISTORY_RANGES[query.range] * 86_400_000).toISOString(),
to: new Date(to).toISOString(), availableFrom: null,
minuteFrom: new Date(now - 7 * 86_400_000).toISOString(), retentionDays: 90 },
storage: { status: 'ready', errorCode: null }, source,
coverage: { partial: false, gapCount: 0, lastObservedAt: null },
totals: { uploadBytes: '0', downloadBytes: '0' }, rows: [], nextOffset: null, origins: [], originsTruncated: false,
};
}
export function assertTrafficHistorySnapshot(value: unknown): TrafficHistorySnapshot {
if (!value || typeof value !== 'object') throw new TypeError('Invalid history snapshot');
const v = value as TrafficHistorySnapshot;
const iso = (s: unknown) => typeof s === 'string' && Number.isFinite(Date.parse(s));
const bytes = (s: unknown) => typeof s === 'string' && /^\d+$/.test(s);
if (v.apiVersion !== 1 || !iso(v.generatedAt) || !v.query || !v.period || !v.storage || !v.coverage || !v.totals
|| v.period.retentionDays !== 90 || !iso(v.period.from) || !iso(v.period.to) || !iso(v.period.minuteFrom)
|| !(v.period.availableFrom === null || iso(v.period.availableFrom))
|| !['ready', 'error'].includes(v.storage.status)
|| !(v.storage.errorCode === null || typeof v.storage.errorCode === 'string')
|| !['live', 'connecting', 'disabled', 'stopped', 'stale', 'degraded', 'incompatible'].includes(v.source)
|| typeof v.coverage.partial !== 'boolean' || !Number.isSafeInteger(v.coverage.gapCount) || v.coverage.gapCount < 0
|| !(v.coverage.lastObservedAt === null || iso(v.coverage.lastObservedAt))
|| !bytes(v.totals.uploadBytes) || !bytes(v.totals.downloadBytes)
|| !Array.isArray(v.rows) || v.rows.length > 100
|| !Array.isArray(v.origins) || v.origins.length > 256 || typeof v.originsTruncated !== 'boolean'
|| !(v.nextOffset === null || (Number.isSafeInteger(v.nextOffset) && v.nextOffset >= 0))) {
throw new TypeError('Invalid history snapshot');
}
parseTrafficHistoryQuery(historyQueryParams(v.query));
for (const row of v.rows) {
if (!row || typeof row.key !== 'string' || typeof row.label !== 'string'
|| row.key.length > 253 || row.label.length > 253
|| !bytes(row.uploadBytes) || !bytes(row.downloadBytes)
|| !['vpn', 'direct', 'other', 'mixed'].includes(row.route)) throw new TypeError('Invalid history row');
}
for (const origin of v.origins) {
if (!origin || typeof origin.id !== 'string' || typeof origin.label !== 'string') throw new TypeError('Invalid history origin');
}
return v;
}
+41
View File
@@ -0,0 +1,41 @@
export const TRAFFIC_RETENTION_OPTIONS = [5, 10, 30] as const;
export type TrafficGrouping = 'site' | 'device';
export type TrafficSort = 'popular' | 'recent';
export type TrafficRetentionSeconds = typeof TRAFFIC_RETENTION_OPTIONS[number];
export interface TrafficSettings {
grouping: TrafficGrouping;
sort: TrafficSort;
retentionSeconds: TrafficRetentionSeconds;
}
export const DEFAULT_TRAFFIC_SETTINGS: TrafficSettings = {
grouping: 'site',
sort: 'popular',
retentionSeconds: 10,
};
export function normalizeTrafficSettings(
value: unknown,
{ strict = false }: { strict?: boolean } = {},
): TrafficSettings {
const candidate = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const grouping = candidate.grouping;
const sort = candidate.sort;
const retentionSeconds = candidate.retentionSeconds;
if (strict && (
!['site', 'device'].includes(String(grouping))
|| !['popular', 'recent'].includes(String(sort))
|| !TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
)) throw new TypeError('Invalid traffic settings');
return {
grouping: grouping === 'device' ? 'device' : 'site',
sort: sort === 'recent' ? 'recent' : 'popular',
retentionSeconds: TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
? Number(retentionSeconds) as TrafficRetentionSeconds
: DEFAULT_TRAFFIC_SETTINGS.retentionSeconds,
};
}
+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.37.2',
gatewayClient: '0.21.21', gatewayClient: '0.39.2',
gatewayBackend: '0.21.21', gatewayBackend: '0.39.1',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+201 -57
View File
@@ -18,34 +18,74 @@ 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,
loadDnsDiagnosticsCatalog: api.diagnostics.dnsCatalog,
runDnsDiagnostics: api.diagnostics.dns,
loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
loadTrafficHistory: api.traffic.history,
}; };
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',
'traffic-settings': 'traffic',
'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 +93,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) {
@@ -73,9 +113,14 @@ export function App() {
} }
useEffect(() => { useEffect(() => {
loadState(); let cancelled = false;
const timer = setInterval(loadState, 5000); let timer: ReturnType<typeof setTimeout>;
return () => clearInterval(timer); const poll = async () => {
await loadState();
if (!cancelled) timer = setTimeout(poll, 5000);
};
void poll();
return () => { cancelled = true; clearTimeout(timer); };
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -98,30 +143,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.code === 'STATE_CONFLICT'
? 'Harbor снова получил новые данные во время действия. Повторите ещё раз.'
: safeError.message, : 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 +198,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 +210,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 +274,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 +317,40 @@ 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',
)}
onUpdateTrafficSettings={(settings: unknown) => run(
'trafficSettings',
() => api.traffic.updateSettings(settings, revisionRef.current),
'traffic',
)}
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>
+140 -15
View File
@@ -1,5 +1,7 @@
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';
import { historyQueryParams, type TrafficHistoryQuery } from '../../shared/trafficHistory.js';
type RequestOptions = Omit<RequestInit, 'headers'> & { type RequestOptions = Omit<RequestInit, 'headers'> & {
headers?: Record<string, string>; headers?: Record<string, string>;
@@ -48,24 +50,25 @@ export async function request(
options: RequestOptions = {}, options: RequestOptions = {},
fetchImpl: FetchImplementation = fetch, fetchImpl: FetchImplementation = fetch,
): Promise<unknown> { ): Promise<unknown> {
let response: JsonResponse; const deadline = new AbortController();
const timer = setTimeout(() => deadline.abort(new HarborApiError({ code: 'CONTROL_UNREACHABLE' })),
(options.method || 'GET') === 'GET' ? 15_000 : 60_000);
const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
try { try {
response = await fetchImpl(url, { const response = await fetchImpl(url, {
...options, ...options,
signal,
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...(options.headers || {}), ...(options.headers || {}),
}, },
}); });
} catch (error) {
if (record(error).name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data: unknown = {}; let data: unknown = {};
try { try {
data = await response.json(); data = await response.json();
} catch { } catch {
if (signal.aborted) throw signal.reason;
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status); if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
} }
const payload = record(data); const payload = record(data);
@@ -76,6 +79,13 @@ export async function request(
throw new HarborApiError(errorPayload, response.status); throw new HarborApiError(errorPayload, response.status);
} }
return data; return data;
} catch (error) {
if (signal.aborted) throw signal.reason;
if (error instanceof HarborApiError || record(error).name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
} finally {
clearTimeout(timer);
}
} }
export const api = { export const api = {
@@ -96,10 +106,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', method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends. body: JSON.stringify({ label, url, expectedRevision }),
body: JSON.stringify({ serverId, selectedTag: serverId }), }),
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',
body: JSON.stringify({ profileId, serverId, expectedRevision }),
}), }),
gatewayAuto: { gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', { setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
@@ -108,14 +158,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 +177,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,11 +204,57 @@ 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 }),
},
),
dnsCatalog: () => request('/api/diagnostics/dns'),
dns: (domainId: string, resolverId: string | null = null) => request(
'/api/diagnostics/dns',
{
method: 'POST',
body: JSON.stringify({ domainId, resolverId }),
},
),
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: {
history: (query: TrafficHistoryQuery, signal?: AbortSignal) => request(`/api/traffic/history?${historyQueryParams(query)}`, { signal }),
live: () => request('/api/traffic/live'),
updateSettings: (settings: unknown, expectedRevision: number) => request(
'/api/traffic/settings',
{
method: 'PUT',
body: JSON.stringify({ settings, expectedRevision }),
}, },
), ),
}, },
@@ -145,10 +263,13 @@ export const api = {
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 +298,13 @@ 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,
traffic: snapshot.traffic,
failover: snapshot.failover,
route: snapshot.route, route: snapshot.route,
operation: snapshot.operation, operation: snapshot.operation,
servers: snapshot.servers, servers: snapshot.servers,
+501 -144
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,21 @@ interface VersionBadgeProps {
} }
interface ComponentActions { interface ComponentActions {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>; loadTrafficHistory: import('../features/traffic/index.js').LoadTrafficHistory;
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>;
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
} }
interface ClientViewState extends StateSnapshot { interface ClientViewState extends StateSnapshot {
@@ -102,23 +153,28 @@ 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>;
onUpdateTrafficSettings: (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 +296,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 +343,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 +437,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 +469,111 @@ 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,
onUpdateTrafficSettings,
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 +581,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 +595,73 @@ 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,
loadHistory: actions.loadTrafficHistory,
settings: state.traffic,
updateSettings: onUpdateTrafficSettings,
});
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 +671,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]);
}, [hasSubscription, isGateway]);
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 +915,93 @@ 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}
loadDnsDiagnosticsCatalog={actions.loadDnsDiagnosticsCatalog}
runDnsDiagnostics={actions.runDnsDiagnostics}
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';
+49 -46
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,28 +163,21 @@ 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>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
<div className="client-state-detail"> <div className="client-state-detail">
{connected ? ( {connected && !remoteOwned ? (
<button <button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`} className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button" type="button"
@@ -223,16 +218,26 @@ export function ConnectionPanel({
</span> </span>
</time> </time>
</span> </span>
<span className="client-tooltip" role="tooltip"> <Tooltip>
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'} {durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
</span> </Tooltip>
</button> </button>
) : ( ) : (
<p key="hint"> <p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} {remoteOwned
? 'Управляется Harbor Gateway'
: canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p> </p>
)} )}
</div> </div>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
</div> </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}
+221 -17
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">
<small>Учтено Harbor</small>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong> <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
+173 -51
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>
<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>} {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,12 @@ 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';
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.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 +36,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: Partial<DiagnosticSettings>) => 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 +84,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 +114,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 +164,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 +223,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 +237,53 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
export function ConnectivityDiagnosticsPanel({ export function ConnectivityDiagnosticsPanel({
feature, feature,
runConnectivityDiagnostics, runConnectivityDiagnostics,
loadDnsDiagnosticsCatalog,
runDnsDiagnostics,
settings,
updateSettings,
isGateway, isGateway,
}: { }: {
feature: DiagnosticsFeature; feature: DiagnosticsFeature;
runConnectivityDiagnostics: RunConnectivityDiagnostics; runConnectivityDiagnostics: RunConnectivityDiagnostics;
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
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 +309,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 +337,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 +359,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') {
flushSync(() => {
setAdding(false); setAdding(false);
setServiceName(''); setServiceName('');
setServiceUrl(''); setServiceUrl('');
setFormError(''); setFormError('');
} else if (serviceId.startsWith('custom-')) {
setCustomServices((services) => services.filter((service) => service.id !== serviceId));
} else {
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]);
}
setRemovingServiceId(''); setRemovingServiceId('');
setResult(null);
}); });
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return; return;
} }
document.startViewTransition(update); setSettingsSaving(true);
const saved = await updateSettings({
customServices: serviceId.startsWith('custom-')
? customServices.filter((service) => service.id !== serviceId)
: customServices,
hiddenServiceIds: serviceId.startsWith('custom-')
? hiddenServiceIds
: [...new Set([...hiddenServiceIds, serviceId])],
});
flushSync(() => {
setRemovingServiceId('');
setSettingsSaving(false);
if (saved === false) setFormError('Не удалось сохранить список сервисов.');
else setResult(null);
});
};
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
await update();
return;
}
await document.startViewTransition(update).finished;
} }
const pending = status === 'running'; const pending = status === 'running';
@@ -319,28 +412,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 +441,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 +455,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 +469,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 +538,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 +559,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 +596,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 +604,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 +623,14 @@ export function ConnectivityDiagnosticsPanel({
</div> </div>
</section> </section>
</div> <DnsDiagnosticsSection
</aside> open={open}
settings={settings}
updateSettings={updateSettings}
loadCatalog={loadDnsDiagnosticsCatalog}
runDiagnostics={runDnsDiagnostics}
/>
</Drawer>
); );
} }
@@ -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>;
} }

Some files were not shown because too many files have changed in this diff Show More