184 Commits

Author SHA1 Message Date
fdc6f687f3 Keep verified Gateway active through transient discovery failures
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-31 10:57:00 +03:00
c6d3fd39fb Add stop confirmation to client power action
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 17s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-15 15:25:10 +03:00
b4adcce26a Switch mac client install to tarball download
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-15 12:13:12 +03:00
7b94f2dee4 Refine server health loading indicator and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-15 00:22:57 +03:00
7f276b0404 Refine server picker layout and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-15 00:09:50 +03:00
c2f9623394 Refine server picker layout and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-14 23:55:10 +03:00
bc3cc12f69 Bump Harbor versions and tighten server picker layout
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-14 23:06:29 +03:00
8139543e9a Refine server picker ping labels and layout
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-14 22:53:06 +03:00
162ef861d7 Simplify client overview access controls and layout
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-14 22:06:38 +03:00
e5a69dcb73 Bump Harbor clients and clarify disabled local rules
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-14 21:55:37 +03:00
a37c211c42 Raise Harbor versions and move secondary menus to the right rail
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-14 21:49:48 +03:00
f629309f32 Bump Harbor versions and surface gateway mode in overview
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-13 20:32:59 +03:00
56f5e408e3 Handle rejected subscriptions and add local client compose
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-13 20:23:37 +03:00
8c19f2cba9 Remove initial server health check and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-13 13:36:56 +03:00
90447de0aa Polish server health checking feedback and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 19:14:00 +03:00
5874df2fce Add initial server health check and update Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 18:49:45 +03:00
d551d41b71 Keep the server picker simple by default
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 16:30:53 +03:00
9a539409f0 Improve server picker health state handling and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 15:31:41 +03:00
0480e617cd Bump Harbor versions and adjust subscribed desktop layout
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 15:22:18 +03:00
77eaed8d90 Align client panel desktop spacing and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 14:58:55 +03:00
e8c1c9d403 Bump Harbor versions and add desktop subscription spacing
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 14:52:43 +03:00
12c1b128f8 Bump Harbor client versions and pin form content alignment
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 14:48:07 +03:00
6bd51dc8fb Raise client layout version and add subscription form height cap
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 14:41:47 +03:00
fca3c0b705 Refine server picker toggle and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 14:31:34 +03:00
d9745e9aed Add simple server picker mode with version bump
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 14:13:42 +03:00
57330f1c78 Refactor server picker and limit ping requests
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 13:42:22 +03:00
24fda3e34e Improve client accessibility and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 13:24:05 +03:00
5b3d288405 Bump Harbor versions and refine disabled local rules toggle
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 13:09:56 +03:00
2a71466670 Refine client panel layout and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 13:01:45 +03:00
2d1d89911e Animate Harbor brand swap arrow and bump client versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 12:47:37 +03:00
394fceac15 Bump Harbor client versions and refine mode swap animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 12:44:45 +03:00
c40f465708 Bump Harbor versions and refine mode swap animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:42:35 +03:00
ba1e53a824 Bump Harbor versions and refine mode swap arrow animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:38:50 +03:00
1fe13703eb Bump Harbor client versions and refine arrow reveal animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:36:56 +03:00
17577ea460 Bump Harbor client versions and tighten gateway mode animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:33:44 +03:00
2a214fc28b Clarify and strengthen state invariant tests
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 12:22:59 +03:00
5e33360c92 Bump Harbor versions and refine gateway mode transition
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:09:31 +03:00
ef33ad9c84 Bump Harbor client versions and refine brand mode transition
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 12:04:00 +03:00
267afc5c7e Introduce stable server IDs for subscription state
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 11:59:22 +03:00
005c7a101b Refine subscription import and refresh flow
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-07-12 11:18:33 +03:00
ba15a25c89 Refine local rule delete animation and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 11:00:54 +03:00
d4897e5dcf Bump Harbor versions and soften rule editor dimming animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 01:48:24 +03:00
7182bc2c1a Bump Harbor versions and add delete dimming animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 01:45:47 +03:00
8db9d30828 Refine rule editor exit animations and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 00:28:34 +03:00
c56f51e07b Add delete strike overlay and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 00:23:19 +03:00
d6ba05ac7d Refine client delete animation and local rule button styling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 00:21:21 +03:00
4ed25301db Refine rule delete animation and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 00:17:17 +03:00
d49a1f6837 Raise delete strike overlay above row content
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 00:14:54 +03:00
56304514ff Bump Harbor versions and refine delete strike styling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-12 00:10:25 +03:00
4519577295 Bump Harbor versions and refine delete strike styling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-12 00:06:14 +03:00
87cd83f89a Refine client rule delete strike animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-11 23:57:00 +03:00
fbbd6e40b4 Add delete strike animation for local rules
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 28s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-11 23:54:21 +03:00
483fce55f3 Update Harbor Gateway UI for direct routing mode
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 23:47:45 +03:00
62d2044dc1 Bump Harbor versions and raise open rule row stacking
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 23:41:02 +03:00
f135ade43b Track applied route rules separately from pending edits
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 23:07:20 +03:00
65bf88bf41 Add shared critical confirmation popup for destructive actions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 23s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 22:50:51 +03:00
387cc273e8 Improve local rules persistence and dirty-state handling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 22:37:04 +03:00
1304a22f1f Add enabled local routing rules and gateway version reporting
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s
2026-07-11 22:14:43 +03:00
a0c66edb02 Add local routing rules to Harbor
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 17s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-11 21:52:03 +03:00
306a9b8ced Bump Harbor versions and add direct .ru routing
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-11 21:36:04 +03:00
7a6f9a26ac Add runtime version reporting and display
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-11 21:24:18 +03:00
c9223aa3a9 Harden server state and config persistence
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 6s
2026-07-11 21:07:11 +03:00
e6b21ed8a9 Copy shared sources into client Docker build
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 20:58:43 +03:00
74660d915f Track client operations and show inline progress
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 20:55:20 +03:00
9da4fef1f0 Unify Harbor error handling across server and client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-11 20:38:41 +03:00
457dd912d1 Bypass bridge traffic in TProxy and simplify subscription refresh
Some checks failed
Build and Deploy Gateway / build-and-push (push) Failing after 1m12s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-07-11 19:48:04 +03:00
198669694c Handle stale sync state in client UI
Some checks failed
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Failing after 1m21s
2026-07-11 19:31:35 +03:00
a775d8456a Ignore local workpack roadmap workspace
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 7s
2026-07-11 19:18:50 +03:00
40f73ee98c Include shared server modules in runtime images
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-11 19:02:27 +03:00
e81a48a5b1 Persist operation state in server and reuse returned snapshots
Some checks failed
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Failing after 1s
2026-07-11 18:59:14 +03:00
b0b9da51b6 Split gateway control and dataplane into separate services
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 12s
2026-07-11 17:52:48 +03:00
4b326c5e99 Refine VPN client design guidance for mode-aware motion
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:51:41 +03:00
0bf7d2ee30 Refresh Harbor docs and subscription controls
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:42:03 +03:00
b53cd08dcc Rename client app to Harbor Connect and refresh state animations
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:24:28 +03:00
edad26d978 Refresh Harbor Gateway branding assets and accent styling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:15:54 +03:00
322f5a125b Polish client duration and tooltip interactions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:12:09 +03:00
85053f9948 Add ambient motion to harbor mode branding
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:06:25 +03:00
9efd446d4e Refine Harbor brand mode hover animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 16:02:49 +03:00
bfc85c3056 Add smooth transform transition to harbor mode swap icons
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:59:09 +03:00
89feffd0b7 Refine Harbor mode swap icon animation
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:56:02 +03:00
aa54be9c9b Refine Harbor mode swap and client delete modal animations
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:52:56 +03:00
befd41933d Refine Harbor delete flow and gateway affordances
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:46:15 +03:00
b389664824 Refine client accent styling with dynamic harbor colors
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:39:30 +03:00
d5a42d8b7b Add Harbor mode tooltip on hover and focus
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:28:42 +03:00
e6bcdc9c62 Refine harbor brand mode swap positioning and styling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:24:23 +03:00
a58fb26e4f Accept 16-character subscription secrets for gateway presence
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:18:22 +03:00
0ab912c64c Add gateway auto toggle for client mode
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:17:07 +03:00
51312d51cd Add Harbor Gateway auto-detection for client routing
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 15:02:33 +03:00
0a1aa8aed3 Add subscription validation and first-run onboarding reveal
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 14:13:39 +03:00
84efbe7450 Rename product to Harbor and refine connection timer
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 13:20:32 +03:00
42c15df8c9 Close client instructions panel on outside click
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 9s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 12:36:30 +03:00
fa3b455fab Refine VPN client instructions and subscription refresh flow
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 12:34:14 +03:00
6f565ded2e Rename gateway proxy labels and update window title
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 11:40:19 +03:00
41922ad30b Add direct gateway forwarding when VPN is off
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 11:37:08 +03:00
9d4f312595 Remove legacy vpn proxy code and simplify the client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-07-11 11:18:03 +03:00
e19d33adb9 Refine client setup state and subscription controls 2026-07-11 10:43:28 +03:00
99f7f58fcb Add subscription info refresh endpoint and UI stats 2026-07-11 09:49:55 +03:00
6bc7840fb1 Refine client overview proxy and subscription display 2026-07-11 05:42:22 +03:00
d3b7f0d613 Simplify proxy routing and configuration 2026-07-11 04:42:16 +03:00
efa46d1ee5 Remove obsolete VPN proxy code 2026-07-08 09:51:15 +03:00
149bb999dc Refactor VPN proxy routing logic 2026-07-08 09:39:24 +03:00
288acbf0c8 Refactor proxy handling and update related UI flows 2026-07-08 09:01:10 +03:00
b45dd2ae05 Refactor proxy routing and session management 2026-07-08 00:09:38 +03:00
c5bdb10445 Add VPN proxy connection handling 2026-07-07 22:33:15 +03:00
7dbf786c56 Refactor VPN proxy routing and session handling 2026-07-07 22:07:17 +03:00
59f2264a2e Clarify active Windows client architecture 2026-07-07 21:19:41 +03:00
a0f41baa36 Simplify client proxy port handling
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-06-04 10:24:33 +03:00
c3d3aaa699 Add kernel forwarding for bypassed devices 2026-05-24 14:00:09 +03:00
301b76c03e Shorten tproxy source bypass chain name 2026-05-24 13:46:04 +03:00
ab6de6996f Add UI-controlled TProxy bypass for devices 2026-05-24 13:38:52 +03:00
0092ec4cde Add source CIDR bypass for TProxy 2026-05-24 13:26:03 +03:00
12ad0c8b78 chore: ignore local worktrees 2026-05-21 20:13:18 +03:00
b5d4c61783 docs: add windows client implementation plan
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 10s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-21 20:04:51 +03:00
f4990a4f55 docs: add windows client design 2026-05-21 19:55:08 +03:00
ab44626a0f feat: simplify mac client interface
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 9s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-20 09:31:14 +03:00
95edefa84f feat: link mac client to shared gateway proxy
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 22:47:05 +03:00
f914c28bc5 fix: detect macos client port conflicts
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 10s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 16:51:40 +03:00
73488384e4 feat: improve macos client proxy setup
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 0s
2026-05-19 16:31:33 +03:00
c6352d781f Add home bypass mode for the Mac client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s
2026-05-19 13:47:53 +03:00
d02dbe10de Add Mac client mode and simplify local proxy UI 2026-05-19 13:12:39 +03:00
2ef1e09986 Fix gateway CI to build and deploy via registry
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 2m51s
Build and Deploy Gateway / deploy (push) Successful in 5s
2026-05-09 11:11:39 +03:00
6df8c525ef Fix Gitea gateway build and deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-push (push) Failing after 0s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-05-09 11:10:16 +03:00
f264ce4a2f Switch gateway CI to registry-based build and deploy
Some checks failed
Build and Deploy Gateway / build-and-push (push) Failing after 0s
Build and Deploy Gateway / deploy (push) Has been skipped
2026-05-09 11:08:05 +03:00
371adbcb50 Break gateway build cycle with runtime base bootstrap
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-09 11:03:50 +03:00
3a930c9d8c Fix gateway workflow runner and deploy host
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-09 10:58:19 +03:00
1bdf12f174 Break gateway build cycle with runtime base bootstrap
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2m58s
2026-05-09 10:54:13 +03:00
3e8925c609 Fix gateway workflow runner selection
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 1s
2026-05-09 10:51:23 +03:00
d12b0c01fc Use runtime base to break gateway build cycle
Some checks failed
Build and Deploy Gateway / build-and-push (push) Has been cancelled
Build and Deploy Gateway / deploy (push) Has been cancelled
2026-05-09 10:46:13 +03:00
e16f401dc5 Run gateway builds on 107 and deploy on 111
Some checks failed
Build and Deploy Gateway / build-and-push (push) Has been cancelled
Build and Deploy Gateway / deploy (push) Has been cancelled
2026-05-09 10:41:36 +03:00
68844d67df Add remote build and deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:37:27 +03:00
ec8e748a43 Add routed build and deploy flow for gateway image
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:32:18 +03:00
62f50d9c28 Allow special characters in rule-set tags
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:23:57 +03:00
cab4313c70 Fix LAN proxy binding in routing setup
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
2026-05-09 10:11:40 +03:00
aab7533438 Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
2026-05-09 09:53:12 +03:00
62b39cdf58 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-09 09:24:43 +03:00
6ab5f50f95 feat: добавлена поддержка отображения устройства в журнале трафика
Refs: None
2026-05-09 09:24:34 +03:00
4bb8507e3f feat: добавлены правила маршрутизации по устройствам и управление ими через API
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-09 09:12:03 +03:00
b3fad00f80 feat: добавлена возможность сортировки трафика по частоте и времени
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
Refs: None
2026-05-09 08:38:37 +03:00
5c9a291920 feat: добавлена поддержка кэша прямого обхода с использованием ipset
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 22:27:58 +03:00
781cbbb026 feat: добавлено использование хеширования для ключа кеша
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 6s
Refs: None
2026-05-08 21:57:54 +03:00
499d2d3367 fix: удален ненужный параметр SING_BOX_CONFIG из конфигурации сервиса
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 6s
2026-05-08 21:42:45 +03:00
eeec4359b0 feat: добавлена возможность обхода правил для трафика
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
- Реализована функция для включения и отключения обхода правил.
- Обновлены компоненты интерфейса для управления режимом обхода.
- Добавлена обработка состояния обхода в API.

Refs: None
2026-05-08 21:28:42 +03:00
11f2c0ccb2 feat: добавлена группировка трафика с возможностью переключения
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 24s
2026-05-08 21:05:26 +03:00
f89cba4a24 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 22s
2026-05-08 21:02:31 +03:00
49be90a82c feat: добавлена обработка трафика и интерфейс для его отображения
Refs: None
2026-05-08 21:02:18 +03:00
bb7250e4ac feat: добавлена возможность поиска и отображения rule-sets из каталога SagerNet
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 20:38:27 +03:00
4f1a2f8bf6 feat: обновлены источники rule-set для sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 18s
2026-05-08 20:18:55 +03:00
7d1f5f89ed feat: добавлена возможность поиска и декомпиляции rule-sets
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2s
Refs: None
2026-05-08 20:15:33 +03:00
b1c8eea976 style: отформатирован код для улучшения читаемости
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 18s
2026-05-08 19:49:54 +03:00
27b71077b1 feat: добавлены функции для работы с пользовательскими rule-sets
Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets.

Refs: None
2026-05-08 19:49:44 +03:00
3e18b833c6 style: исправлены кавычки в коде для единообразия
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
2026-05-08 19:41:24 +03:00
0cd898d1c1 feat: добавлены функции для работы с PID sing-box
Refs: None
2026-05-08 19:41:17 +03:00
8476ab16e5 feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON.
- Добавлен компонент ServersPage для отображения и управления серверами.
- Реализован компонент SettingsPage для управления подписками и конфигурациями.
- Создан компонент Sidebar для навигации по приложению.
- Добавлен компонент StatusPane для отображения статуса сервера.
- Реализован компонент Toasts для отображения уведомлений.
- Создан компонент Topbar для отображения информации о текущем состоянии.
- Добавлен модуль country.js для определения страны по тегу сервера.

Refs: None
2026-05-08 19:31:49 +03:00
a8f2c6f3f9 fix: добавить ESC-символ в regex парсинга уровня лога sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 19:01:30 +03:00
a961b1b415 fix: хранить конфиг sing-box в volume (dataDir), а не в /etc/sing-box
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 18:50:53 +03:00
7489b5ef97 fix: парсить уровень лога sing-box из stderr вместо hardcode error
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 5s
2026-05-08 18:45:16 +03:00
b716b370ac ci: retry after npm installed on lxc-111
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 20s
2026-05-08 18:37:15 +03:00
abd5a73b51 fix: перенести сборку фронта на хост CI, убрать ui-build стадию из Docker
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 18:32:26 +03:00
1ed79c3a1e style: исправлены стили и форматирование кода
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 18:23:56 +03:00
8789496ae6 feat: добавлены компоненты для управления конфигурацией и логами
Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации.

Refs: None
2026-05-08 18:23:29 +03:00
7d41dd86e7 Reduce Docker build memory usage
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 0s
2026-05-08 17:27:56 +03:00
81bed1513c Remove proxy args from gateway build
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 30s
2026-05-08 17:19:43 +03:00
d13eb0a9a4 Fix Gitea workflow labels and runner deployment
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 7s
2026-05-08 16:58:32 +03:00
71f8e0b84c Update vpn proxy routing checks
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Has been cancelled
2026-05-08 16:52:01 +03:00
03885d2e09 Add gateway deploy workflow
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 39s
2026-05-08 16:36:41 +03:00
88eef527d5 Фикс TProxy: добавлен bypass для LOCAL трафика хоста
Some checks failed
Build Gateway Image / build (push) Successful in 3s
Build Gateway Image / deploy (push) Failing after 0s
Добавлено правило --dst-type LOCAL в начало цепочки VPN_PROXY_TPROXY.
Без него ответные пакеты от VPN серверов (storage.dokops.ru, media.dokops.ru)
перехватывались TProxy и sing-box не мог установить VLESS соединение.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 16:36:06 +03:00
c971b40eae Add gateway auto-deploy and tag matching fallback
Some checks failed
Build Gateway Image / build (push) Successful in 3s
Build Gateway Image / deploy (push) Failing after 0s
2026-05-08 16:34:29 +03:00
327561b2e9 Dockerfile: добавлен COPY package.json для поддержки ES modules
Some checks failed
Build Gateway Image / build (push) Failing after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 16:17:02 +03:00
185a311a38 Merge pull request 'develop' (#1) from develop into master
All checks were successful
Build Gateway Image / build (push) Successful in 33s
Reviewed-on: http://192.168.50.109:3000/dokril/vpn-proxy/pulls/1
2026-05-08 16:05:18 +03:00
ef752d66bc Rebuild vpn proxy around gateway mode 2026-05-08 16:04:38 +03:00
a3816cbedc feat: add network module and service for TCP latency measurement and proxy performance 2026-03-14 18:19:02 +03:00
51d26a4c1b feat: add network module and service for TCP latency measurement and proxy performance 2026-03-14 17:04:53 +03:00
638940c694 feat: полный CI/CD — build на 107, deploy на 111
Some checks failed
Build and Deploy Sing-proxy / build (push) Successful in 2s
Build and Deploy Sing-proxy / deploy (push) Failing after 0s
- build job (ubuntu-latest/107): docker build + push в Gitea Registry
- deploy job (lxc-111): docker pull + docker run с network=host
- Данные сохраняются в /opt/vpn-proxy/data volume
- Ansible плейбук больше не нужен для деплоя

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 02:45:05 +03:00
2e16d33618 fix: ветка master в CI trigger
All checks were successful
Build and Push Docker Image / build (push) Successful in 12s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 02:06:24 +03:00
6b38c7b15f feat: Gitea CI workflow + registry image для деплоя
- .gitea/workflows/docker-build.yml: билд и пуш образа в Gitea Container Registry
- docker-compose.server.yml: убрал build context, используем registry image
- Требует REGISTRY_TOKEN секрет в настройках репо

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 02:05:31 +03:00
6e97bb9f61 feat: Реализован новый веб-интерфейс и бэкенд для управления VPN-клиентом, включая списки серверов, элементы управления прокси и опции конфигурации. 2026-01-15 18:39:39 +03:00
c4915389a7 feat: Добавлена начальная реализация веб-интерфейса и основной логики приложения для VPN-прокси с новыми компонентами, скриптами и модулями. 2026-01-15 18:39:10 +03:00
48178fa3ae docs: Обновить README.md. 2026-01-15 17:58:54 +03:00
ede0370b3a feat: Реализовано включение/выключение прокси через веб-интерфейс с сохранением состояния и обновлением конфигурации, а также добавлен соответствующий UI. 2026-01-15 01:15:25 +03:00
116856c1d1 feat: добавляет визуализацию цепочки прокси, настройки подключения и интерфейс для конфигурации резервного прокси. 2026-01-15 00:34:46 +03:00
13c92c7413 feat: Добавлены скрипты для установки Sing-box и Discord, а также для просмотра логов. 2025-12-31 15:41:53 +03:00
479a7232b1 feat: Добавлены скрипты для установки Sing-box и Discord, а также для просмотра логов. 2025-12-31 12:26:17 +03:00
e1f71f95ad feat: Добавить скрипт для настройки Discord. 2025-12-30 22:42:20 +03:00
d7a3b20da9 feat: Добавлены скрипты для работы с сетью, системными утилитами и настройки Discord. 2025-12-30 21:08:02 +03:00
114 changed files with 14935 additions and 3678 deletions

View File

@@ -0,0 +1,55 @@
---
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.
---
# Design VPN Client UI
Preserve the repo's focused one-screen VPN client language: a centered primary action, quiet technical typography, mode-specific accents, and motion that makes live state and interaction legible without moving layout.
## Workflow
1. Read `PRODUCT.md` and the complete client component and styles before editing.
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.
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.
6. Keep geometry stable across every state. Reserve space before animating content.
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.
## Non-negotiable decisions
- Keep the power action on the screen's central vertical axis. Place subscription content to its right without shifting that axis.
- Keep the power hit target generous while rendering only the icon, never a large enclosing accent circle.
- Drive every active accent from the current mode token: Connect is blue-green; Gateway is orange. Keep inactive power gray, including hover, and preserve semantic warning/error colors.
- Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content.
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
- Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement.
- Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame.
- Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion.
- Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable.
- Keep tooltips outside transformed, rotating, glowing, or filtered controls. Show them quickly above the control as independent translucent cloud surfaces.
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
- When the owner explicitly chooses modal treatment for critical confirmations, reuse one accessible full-screen confirmation popup: blur and block the background, reveal from center, then stage text and actions.
- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete.
## Acceptance pass
Before handing off, verify:
- Power on/off is unmistakable without reading the label.
- Switching on/off preserves the exact positions of title, timer, and hint.
- Switching Connect/Gateway crossfades status in a fixed slot, changes the full accent palette, and clearly de-emphasizes data irrelevant to the active route.
- A timer tick animates only changed digits and reads as a soft flow, never a blink.
- Hover motion completes its current cycle and settles before stopping; ambient affordance motion remains subtle and infrequent.
- Tooltips remain upright, unfiltered, above adjacent content, and visually consistent across controls.
- Refresh and copy feedback cannot change element width or alignment.
- Server separators are compact and only slightly wider than their content.
- Repeated polling does not replay decorative list animations.
- Manual refresh has an obvious but non-jarring response.
- Keyboard focus remains visible even when the text caret is intentionally hidden.
- Narrow screens return to a simple single-column layout.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Design VPN Client UI"
short_description: "Design the repo's calm animated VPN client UI."
default_prompt: "Use $design-vpn-client-ui to design or refine the VPN client interface in this repository."

View File

@@ -0,0 +1,116 @@
# Motion and interaction
## Motion character
Aim for fluid, slightly viscous motion: noticeable, calm, and complete. Avoid bounce, elastic easing, abrupt unmounts, decorative page choreography, or tiny effects too weak to communicate feedback.
Motion is functional feedback. If the system is running, refreshing, counting, switching route, or inviting interaction, show that activity with restrained movement. Do not animate every static decoration; animate the part that proves work, state, or affordance.
Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals. Typical timing:
- hover and press: 180-300 ms;
- state color and glow: 600-900 ms;
- content reveal: 600-850 ms;
- numeric tween and progress: about 900 ms;
- copy feedback: about 800 ms;
- server cascade: 620-760 ms per row with 90-110 ms stagger.
- tooltip arrival: about 90-140 ms with almost no delay;
- ambient affordance hint: one small cycle roughly every 10 seconds.
## Cycle completion
- On pointer leave, do not snap an infinite hover animation or reverse it from the middle. Mark it for stopping, let the current iteration reach its original coordinates, then remove the animation.
- If the user re-enters before the iteration ends, clear the stop request and continue the same behavior.
- Separate state transforms from repeating motion when both affect one control. Animate a child for the cycle and its wrapper for durable state, or wait for `animationiteration` before clearing the animated class.
- Keep reduced-motion behavior immediate and static; never wait for an iteration event that will not fire.
## Power state
- Transition gray to the current mode accent slowly when connecting and back to gray when disconnecting.
- Animate icon color, localized light, and SVG shadow together.
- Let the light expand and brighten on enable, then contract and fade on disable.
- Keep the hit target and all surrounding geometry fixed.
- Use a short press compression, followed by a slower release.
## Changing text and numbers
- Put alternate labels in fixed-size slots.
- Reveal connection title, timer, and hint with overlapping fixed layers, opacity, and light blur, never vertical layout movement.
- Crossfade `VPN включён`, `Gateway подключён`, and disconnected copy in the same reserved slot when route state changes.
- Split changing numeric values into stable digits. On a tick such as `33 → 34`, keep the first `3` mounted and animate only `3 → 4` with a soft color/glow/blur flow; avoid low-opacity blinking or scaling the whole seconds value.
- Persist user-selected timer presentation locally and restore it on the next visit.
- Tween numeric traffic values from old to new with `requestAnimationFrame` or an equivalent stable counter.
- Animate progress width concurrently and add a brief glow that fully fades.
- Never translate changing numbers if the user asked for a fluid morph; use numerical interpolation, opacity, color, blur, and light.
## Mode switch affordance
- Treat the Connect/Gateway brand as one state control with a foreground label, a background label, and two independently colored direction arrows.
- On hover, move both labels continuously: let the foreground drift slightly down while the background rises toward it. Move the right arrow right and the left arrow left, then return; keep amplitudes small.
- When mode changes, swap the arrows' positions smoothly and bring the new label to the foreground without changing the brand's centered geometry.
- When hover ends, finish the current cycle at rest before stopping. Outside hover, replay one smaller cycle about every 10 seconds to hint that the control is clickable.
- Keep explanatory tooltip geometry tied to the mode-label-to-arrows span, not to the entire Harbor wordmark.
## Refresh
- Use a clean, symmetric SVG refresh icon aligned in the same flex row as its label.
- Spin for at least one full cycle. If the request finishes mid-cycle, continue to the next cycle boundary before stopping.
- Update data immediately when it arrives; finishing the icon cycle must not delay the data.
- Manual refresh may replay meaningful data and server transitions.
- Background polling should update quietly and must not repeatedly replay the server cascade.
- On updated traffic, tween the number, advance the bar, and emit a visible but brief mode-accent flare.
- Keep refresh tooltip outside the rotating button so it remains upright and unfiltered.
## Server cascade
- On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur.
- On manual refresh, animate an explicit exit phase first. Fade rows top to bottom, then remount and enter top to bottom.
- Wait for the last exit delay and duration before starting entry.
- Disable pointer interaction during exit.
- Do not replay on ping updates or unrelated renders.
## Dynamic editors
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
- Keep interactive add latency constant regardless of collection length. Never multiply an added row's delay by its index; use bounded staggering only for a one-time group reveal.
- Give removal its own exit state and keep the row mounted until `animationend`; then animate surviving rows into their new positions instead of letting layout snap. Under reduced motion, remove it immediately.
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
- Track the editor's dirty draft against its open/save baseline. Guard Escape, outside click, navigation controls, Cancel, and page unload; use an inline discard confirmation for in-app exits.
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.
## Subscription input
- Show the public domain while retaining the full URL internally.
- Disable browser autocomplete suggestions and neutralize autofill backgrounds.
- Hide the blinking caret when the paste-first interaction does not need it, while preserving keyboard input and focus outline.
- When an existing subscription is being edited and the field is idle, use the mode-accent underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
- Pause the timeout once the user enters content.
- Close and clear unfinished input on outside click or Escape.
- Animate the trash lid independently on hover. Use the shared critical confirmation popup instead of a browser-native confirm dialog.
## Critical confirmation popup
- Reserve the blocking popup for explicit destructive or data-loss confirmation. It must cover the viewport, make the background inert, and use `alertdialog` with `aria-modal`.
- Fade and blur the backdrop first, resolve the popup from the center, then reveal its title, description, and actions in a short sequence.
- Put initial focus on the safe action, trap Tab within the popup, let Escape and backdrop click choose the safe action, and restore the invoking focus on close.
- Reuse the same component and motion vocabulary for every critical confirmation. Reduced motion presents the final state immediately.
## First-run initialization
- With no subscription, show only the centered subscription input. Hide power, proxy controls, usage, and servers.
- After a valid subscription loads, keep the subscription and server list centered. Require an explicit server choice instead of silently selecting the first server.
- On server choice, slide the subscription column to the right while revealing the power column on the viewport's central axis.
- Preserve the chosen server on later visits, but return to first-run initialization after subscription deletion.
- Deleting a subscription must stop the VPN, clear its cached/configured state, and return the UI to the centered input without leaving stale controls visible.
## Copy feedback
- Keep protocol buttons fixed-size and centered.
- Copy the complete protocol URL while showing a shared address separately.
- Overlay mode-accent `Copied` feedback in the same fixed box; do not append text or move the label.
- Make feedback appear immediately, hold briefly, and fade fully before restoring the original label. Keep the whole cycle near 800 ms.
## Reduced motion
Under `prefers-reduced-motion: reduce`, remove transitions and keyframe animations while preserving final state, focus, color contrast, copy wording, and all functionality.

View File

@@ -0,0 +1,62 @@
# Visual language
## Scene and character
Design for a macOS user glancing at a small VPN control surface in a quiet desktop environment. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
## Composition
- Make one primary action dominant: the VPN power icon.
- Keep the power control centered on the viewport's vertical axis, not merely centered inside a left column.
- Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions.
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
- Collapse to one centered column on narrow screens.
- Avoid enclosing frames, borders, and divider lines. Use spacing, type, subtle surface changes, light, depth, and state color for hierarchy.
- Do not introduce popups or modals without an explicit user request. Prefer inline disclosure or a non-modal side layer when supporting content must coexist with the main control surface.
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
## Geometry and alignment
- Reserve identical height for mutually exclusive content such as timer versus connection hint.
- Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow.
- Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets.
- Preserve a generous invisible hit area around icon-only controls.
- Center proxy address and protocol actions with the power column.
- Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels.
- Center the semantic brand or label independently from optional action icons. Place secondary icons beside it without letting their width move the centered content.
- Layer mutually exclusive status text in one fixed slot and crossfade between layers. Never replace text in normal flow when its length can move the interface.
## Typography
- Prefer the existing JetBrains Mono / SF Mono stack for the client surface.
- Use uppercase, tracked, muted micro-labels for metadata.
- Use stronger weight and size for the subscription domain and connection state.
- Use tabular numerals for timers and changing numeric data.
- Avoid display fonts, oversized headings, and mixed type families.
## Color and light
- Preserve green-tinted dark and light neutrals through the existing OKLCH variables.
- Treat mode color as a system-wide state, not a logo-only decoration: Connect uses its blue-green token and Gateway uses its orange token for power, glow, selected rows, progress, copy/refresh feedback, focus, and mode-relevant labels.
- Inactive power stays neutral gray even on hover. Warning and destructive actions remain semantic red rather than inheriting the mode accent.
- Prefer localized `drop-shadow`, `text-shadow`, or a soft radial light layer over filled accent containers.
- Let glow support state recognition. Do not leave every element glowing continuously.
- Give Connect and Gateway distinct favicons and brand marks using the same mode palette.
- When Gateway carries traffic, fade, desaturate, and disable the local subscription/server block: it remains understandable context but must not look active or actionable.
## Interactive surfaces
- Use one fast translucent cloud treatment for explanatory tooltips. Place the cloud above its target with strong enough contrast to survive busy content beneath it.
- Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control.
- Use the shared full-screen critical confirmation popup for destructive actions and unsaved-data exits. Keep the centered surface flat, with hierarchy from blur, spacing, type, and staged motion rather than borders or rounded cards.
- Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed.
- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy.
## Data presentation
- Show subscription domain, not the credential-like full URL.
- Show used traffic and total limit as the primary statistic.
- Omit upload/download breakdown when provider support is absent or ambiguous.
- Show expiry as both date and remaining days, with correct Russian forms.
- If there is no total, say `без лимита` and omit the progress bar.
- Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements.

View File

@@ -0,0 +1,26 @@
---
name: manage-harbor-versions
description: Check and bump Harbor component versions for every runtime, UI, API, dependency, packaging, or deployment-config change in this repository. Use before completing implementation work, release preparation, or any change that can alter the shipped Mac client, Gateway client, or Gateway backend.
---
# Manage Harbor Versions
Treat `src/shared/versions.js` as the only component-version source. Do not use the root package version as a release version.
## Required workflow
1. Inspect the complete diff and choose the comparison base, normally `HEAD` for working-tree changes or the target branch for a review.
2. Run `npm run version:harbor -- affected <base>`.
3. Classify the highest compatibility impact:
- `major`: changes an ecosystem contract or requires all cooperating components and clients to update;
- `minor`: changes one component and its tightly linked components while remaining compatible with other clients on the same major;
- `hotfix`: changes only the affected component without requiring linked components or other clients to update.
4. Run one explicit bump command:
- `npm run version:harbor -- bump major`
- `npm run version:harbor -- bump minor <components...>`
- `npm run version:harbor -- bump hotfix <components...>`
5. Run `npm run version:harbor -- check <base>` and the repository tests before completion.
Valid component names are `mac`, `gateway-client`, and `gateway-backend`. A major bump always updates all three components. A minor bump for either Gateway component automatically updates both Gateway client and Gateway backend. A hotfix updates only the named component.
Do not bump documentation- or test-only changes. If the version contract is new and the base has no `src/shared/versions.js`, keep the initial versions and let the checker report that no baseline exists.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Manage Harbor Versions"
short_description: "Check and bump Harbor component versions."
default_prompt: "Use $manage-harbor-versions to classify changes and update the required Harbor component versions."

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.vpn-proxy
.runtime
.git
.gitea
.github
.vscode
*.log
.DS_Store

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
PORT=3456
APP_MODE=gateway
CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT=8082
HARBOR_GATEWAY_CONTROL_PORT=3456
BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.12.13
INSTALL_RUNTIME_DEPS=true
INSTALL_SINGBOX=true
PROXY_PORT=8080
PROXY_BIND_IP=0.0.0.0
TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
LOG_LEVEL=info

View File

@@ -0,0 +1,125 @@
name: Build and Deploy Gateway
on:
push:
branches: [master]
workflow_dispatch:
env:
DEPLOY_PATH: /opt/vpn-proxy
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
APT_MIRROR: http://mirror.yandex.ru/debian
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
SINGBOX_VERSION: 1.12.13
jobs:
build-and-push:
runs-on: ubuntu-22.04
steps:
- name: Clone repository
env:
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
rm -rf repo
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
cd repo
git checkout ${{ gitea.sha }}
- name: Build and push gateway image
run: |
set -euo pipefail
cd repo
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
CONTROL_IMAGE="${IMAGE}-control"
DATAPLANE_IMAGE="${IMAGE}-dataplane"
echo "Build runner: $(hostname)"
echo "Base image: ${{ env.BASE_IMAGE }}"
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'
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
APT_MIRROR="${{ env.APT_MIRROR }}" \
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
./scripts/build-runtime-base.sh
fi
if command -v npm >/dev/null 2>&1; then
npm ci --no-audit --no-fund
npm run build
else
echo "Host npm not found; building frontend inside ${{ env.BASE_IMAGE }}"
docker run --rm \
--network host \
-v "$PWD:/work" \
-w /work \
"${{ env.BASE_IMAGE }}" \
sh -lc 'npm ci --no-audit --no-fund && npm run build'
fi
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
DOCKER_BUILDKIT=1 docker build \
--network host \
--pull=false \
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
--build-arg INSTALL_RUNTIME_DEPS=false \
--build-arg INSTALL_SINGBOX=false \
-t "${CONTROL_IMAGE}:latest" \
-t "${CONTROL_IMAGE}:${{ gitea.sha }}" \
-t "${DATAPLANE_IMAGE}:latest" \
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \
.
docker push "${CONTROL_IMAGE}:latest"
docker push "${CONTROL_IMAGE}:${{ gitea.sha }}"
docker push "${DATAPLANE_IMAGE}:latest"
docker push "${DATAPLANE_IMAGE}:${{ gitea.sha }}"
deploy:
runs-on: lxc-111
needs: build-and-push
steps:
- name: Clone repository
env:
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
rm -rf repo
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
cd repo
git checkout ${{ gitea.sha }}
- name: Pull and deploy gateway image
run: |
set -euo pipefail
cd repo
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}"
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
UPDATE_DATAPLANE=false
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/shared/errors\.js)$'; then
UPDATE_DATAPLANE=true
fi
echo "Deploy runner: $(hostname)"
echo "Update dataplane: ${UPDATE_DATAPLANE}"
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
CONTROL_IMAGE="${CONTROL_IMAGE}" \
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
bash scripts/deploy-gateway.sh

28
.gitignore vendored
View File

@@ -1,2 +1,26 @@
data
_legacy
# Runtime state
.env
*.env.local
data/
.vpn-proxy/
.runtime/
.worktrees/
# Local roadmap and task workspace
/workpack/
# Node/Vite
node_modules/
dist/
coverage/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS/editors
.DS_Store
.idea/
.vscode/
*.swp
*.swo

7
AGENTS.md Normal file
View File

@@ -0,0 +1,7 @@
# Harbor task workflow
Use the checked-in `workpack/` directory as the only roadmap source. Do not require or read the original archive.
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.
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.

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
ARG BASE_IMAGE=debian:bookworm-slim
FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.12.13
ARG INSTALL_RUNTIME_DEPS=true
ARG INSTALL_SINGBOX=true
COPY dist /app/dist
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \
&& rm -rf /var/lib/apt/lists/*; \
else \
command -v dumb-init >/dev/null \
&& command -v node >/dev/null \
&& command -v iptables >/dev/null; \
fi
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) sb_arch="amd64" ;; \
arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \
rm -rf /tmp/sing-box*; \
else \
command -v sing-box >/dev/null; \
fi
WORKDIR /app
COPY package.json /app/package.json
COPY src/server /app/src/server
COPY src/shared /app/src/shared
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
ENV PORT=3456 \
PROXY_PORT=8080 \
PROXY_BIND_IP=0.0.0.0 \
TPROXY_PORT=7895 \
DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]

56
Dockerfile.client Normal file
View File

@@ -0,0 +1,56 @@
ARG NODE_BUILD_IMAGE=node:20-alpine
ARG RUNTIME_IMAGE=debian:bookworm-slim
FROM ${NODE_BUILD_IMAGE} AS web-build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY index.html vite.config.js ./
COPY src/web ./src/web
COPY src/shared ./src/shared
RUN npm run build
FROM ${RUNTIME_IMAGE}
ARG SINGBOX_VERSION=1.12.13
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) sb_arch="amd64" ;; \
arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \
rm -rf /tmp/sing-box*
WORKDIR /app
COPY --from=web-build /src/dist /app/dist
COPY package.json /app/package.json
COPY src/server /app/src/server
COPY src/shared /app/src/shared
COPY entrypoint.client.sh /entrypoint.client.sh
RUN chmod +x /entrypoint.client.sh \
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
ENV APP_MODE=client \
PORT=3456 \
PROXY_PORT=8082 \
PROXY_BIND_IP=0.0.0.0 \
DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
RULE_SET_DOWNLOAD_DETOUR=vpn \
ROUTING_RU_DIRECT=true \
LOG_LEVEL=info
EXPOSE 3456 8082
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]

52
Dockerfile.runtime-base Normal file
View File

@@ -0,0 +1,52 @@
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.12.13
ARG APT_MIRROR=http://mirror.yandex.ru/debian
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
ARG HTTP_PROXY
ARG HTTPS_PROXY
ARG NO_PROXY
ARG http_proxy
ARG https_proxy
ARG no_proxy
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
[ -f "$file" ] || continue; \
sed -i \
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
"$file"; \
done \
&& apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=20 \
-o Acquire::https::Timeout=20 \
-o Acquire::ForceIPv4=true \
update \
&& apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=20 \
-o Acquire::https::Timeout=20 \
-o Acquire::ForceIPv4=true \
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) sb_arch="amd64" ;; \
arm64) sb_arch="arm64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
tar -xzf /tmp/sing-box.tgz -C /tmp; \
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
chmod +x /usr/local/bin/sing-box; \
rm -rf /tmp/sing-box*

35
PRODUCT.md Normal file
View File

@@ -0,0 +1,35 @@
# Product
## Register
product
## Users
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
## Product Purpose
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
## Brand Personality
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
## Anti-references
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
## Design Principles
- One screen, one primary action.
- Use plain language and hide implementation details.
- Make connection state unmistakable without relying on color alone.
- Prefer native controls and predictable macOS behavior.
- Show saved subscriptions as a domain, not as a credential-like URL.
- Make servers directly selectable instead of hiding them in a dropdown.
- Keep advanced and server-only features out of the client path.
## Accessibility & Inclusion
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.

499
README.md
View File

@@ -1,353 +1,330 @@
# 🌐 VPN Proxy — Домашний VPN в одной программе
# Harbor
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`.
---
Проект работает в двух режимах:
## 📖 Что это такое?
| Режим | Где работает | Для чего нужен |
| --- | --- | --- |
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
Это набор инструментов, который позволяет:
В обоих режимах управление одинаковое: откройте веб-интерфейс, вставьте ссылку VPN-подписки, выберите сервер и нажмите кнопку подключения.
1. **Запустить VPN-прокси** на вашем компьютере
2. **Управлять через удобное меню** — всё настраивается автоматически
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
## Что понадобится
### 🎯 Для кого это?
- ссылка на подписку от VPN-провайдера;
- Docker с командой `docker compose`;
- для ручной установки Gateway — Git;
- для Gateway — Linux-машина в одной локальной сети с устройствами;
- для Connect — Mac с запущенным Docker Desktop.
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
- Геймеры, которым нужно запустить игры или Discord через VPN
- Люди, которые получили VLESS ссылку от VPN-провайдера
Harbor не является VPN-провайдером и не создаёт подписки самостоятельно.
---
## Что выбрать
## 🧩 Как это работает?
Используйте **Harbor Connect**, если VPN нужен только приложениям на одном Mac.
```
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
└─────────────────┘ └──────────────────┘ └──────────────────┘
Используйте **Harbor Gateway**, если нужно подключить телевизор, телефон, игровую приставку или сразу несколько устройств. Устройства можно направить через Gateway целиком либо настроить в отдельных приложениях общий прокси.
Оба режима можно использовать вместе. Дома Connect автоматически распознаёт настроенный Harbor Gateway и не запускает второй VPN-маршрут. В другой сети Connect возвращается к локальному VPN.
## Установка Harbor Gateway
### 1. Скачайте проект
```bash
git clone https://git.dokops.ru/dokril/vpn-proxy.git
cd vpn-proxy
```
---
### 2. Создайте настройки
## 🔧 Перед началом: Требования
### ✅ PowerShell 7 (Обязательно!)
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
#### Проверьте вашу версию
Откройте любой PowerShell и выполните:
```powershell
$PSVersionTable.PSVersion.Major
```bash
cp .env.example .env
```
- Если результат **7 или выше** — всё хорошо, переходите к установке ✅
- Если **5 или ниже** — нужно установить PowerShell 7 👇
Стандартные значения подходят для обычной домашней сети. При необходимости откройте `.env` в текстовом редакторе и измените порты.
#### Установка PowerShell 7
### 3. Запустите Gateway
**Способ 1: Через winget (самый простой)**
Откройте обычный PowerShell или Командную строку и выполните:
```powershell
winget install Microsoft.PowerShell
```bash
docker compose -f docker-compose.gateway.yml up -d --build
```
После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск).
Откройте в браузере:
**Способ 2: Скачать вручную**
1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest
2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия)
3. Запустите установщик и следуйте инструкциям
4. После установки используйте **PowerShell 7** из меню Пуск
> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон.
---
### ✅ URL Подписки или VLESS-ссылка
Получите от вашего VPN-провайдера:
- **Подписку**: URL, который начинается с `http://` или `https://`
- **VLESS-ссылку**: начинается с `vless://...`
---
## 🚀 Установка на Windows
### ⚡ Быстрая установка (Одной командой)
Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`.
1. Откройте **PowerShell 7** от имени **Администратора**
2. Скопируйте и вставьте команду:
```powershell
Set-ExecutionPolicy RemoteSigned -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
```text
http://АДРЕС-GATEWAY:3456
```
> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже.
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
---
### 4. Добавьте подписку
### 📦 Ручная установка (если авто-установка не работает)
1. Вставьте ссылку VPN-подписки.
2. Нажмите «Сохранить подписку».
3. Выберите сервер.
4. Включите VPN.
Если вы предпочитаете всё делать сами:
После подключения Harbor покажет два варианта использования:
#### Шаг 1: Скачайте проект
- **Gateway** — укажите IP-адрес Linux-машины как основной шлюз устройства. Через VPN пойдёт весь его интернет-трафик;
- **Gateway Proxy** — укажите адрес Linux-машины и порт `8080` в приложении. Поддерживаются HTTP и SOCKS5 на одном порту.
Мы рекомендуем использовать папку `C:\Tools`.
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
```powershell
# 1. Создаем папку и переходим
New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null
cd C:\Tools
## Установка Harbor Connect на macOS
# 2. Клонируем или скачиваем архив
git clone https://git.dokops.ru/dokril/vpn-proxy
### 1. Запустите Docker Desktop
# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy)
Установщик проверит наличие Docker, Docker Compose, `curl` и `tar`. Если Docker Desktop не запущен, установка остановится с понятным сообщением.
### 2. Запустите установщик
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
```
#### Шаг 2: Запустите
Установщик:
```powershell
cd C:\Tools\vpn-proxy
.\manage.ps1
- сохранит рабочую копию в `~/.vpn-proxy-client`;
- предложит порт для локального прокси;
- соберёт и запустит контейнер Harbor Connect;
- добавит пользовательский LaunchAgent для определения текущего Gateway.
По умолчанию используются адреса:
| Назначение | Адрес |
| --- | --- |
| Интерфейс Harbor Connect | `http://127.0.0.1:3456` |
| HTTP-прокси | `127.0.0.1:8082` |
| SOCKS5-прокси | `127.0.0.1:8082` |
### 3. Добавьте подписку
Откройте `http://127.0.0.1:3456`, вставьте ссылку подписки, выберите сервер и включите VPN.
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
### Другие порты
Передайте нужные значения при повторном запуске установщика:
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | \
VPN_PROXY_CLIENT_PORT=9080 \
VPN_PROXY_CLIENT_UI_PORT=3457 \
sh
```
### Шаг 3: Выберите пункт [1] — VPN Клиент
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
```
[1] 📦 VPN Клиент (Sing-box) [НЕ УСТАНОВЛЕН]
Основной способ. Поддерживает UDP и игры.
## Локальные правила маршрутизации
[2] 🎮 Настройка Discord/Vesktop [НЕ АКТИВЕН]
Маршрутизация приложений через прокси.
После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер.
---------------------------------------
[3] 🔄 Обновить статус
[U] ❌ Удалить всё (Uninstall)
[q] Выход
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
👉 Ваш выбор: 1
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
## Системный прокси macOS
Сначала посмотрите точное имя сетевого подключения:
```bash
networksetup -listallnetworkservices
```
### Шаг 4: Введите VLESS-ссылку или URL подписки
Для подключения с именем `Wi-Fi` включите HTTP, HTTPS и SOCKS5-прокси:
Скрипт попросит ввести ссылку. Вставьте и нажмите Enter.
**Готово!** 🎉 Прокси запущен на `127.0.0.1:1080`
### 📂 Где всё хранится?
Всё организовано в папке `C:\Tools`:
1. **Сам проект:** `C:\Tools\vpn-proxy`
- Скрипты управления и настройки
2. **Sing-box (VPN клиент):** `C:\Tools\sing-box`
- Здесь лежит `config.json` с вашими настройками и сам исполняемый файл
3. **ProxiFyre (для Discord):** `C:\Program Files\ProxiFyre` (системная служба)
---
## ✅ Проверка работы
После установки меню покажет статус и адреса подключения:
```
[1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ]
Основной способ. Поддерживает UDP и игры.
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
─────────────────────────────
Локально: 127.0.0.1:1080
Из сети:
192.168.1.100:1080
```bash
networksetup -setwebproxy Wi-Fi 127.0.0.1 8082
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8082
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8082
```
### Проверка через терминал
Чтобы отключить их:
```powershell
# Без прокси — покажет ваш домашний IP
Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
# Через прокси — должен показать IP VPN-сервера
Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
```bash
networksetup -setwebproxystate Wi-Fi off
networksetup -setsecurewebproxystate Wi-Fi off
networksetup -setsocksfirewallproxystate Wi-Fi off
```
Если IP-адреса разные — VPN работает! 🎉
Если сетевое подключение называется иначе, замените `Wi-Fi` его точным именем.
---
## Автоматическое использование домашнего Gateway
## 🎮 Настройка Discord / Vesktop
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка.
Для этого:
### Требования
1. добавьте одну и ту же ссылку подписки в Gateway и Connect;
2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
3. оставьте автоматический режим включённым в Harbor Connect.
- ✅ Установленный VPN клиент (пункт [1] в меню)
- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ")
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
### Установка
## Повседневные команды
1. Запустите `.\manage.ps1`
2. Выберите пункт **[2] — Настройка Discord/Vesktop**
3. Выберите какое приложение настроить:
- Discord
- Vesktop
- Оба
Все команды Gateway выполняются из каталога проекта. Команды Connect — из `~/.vpn-proxy-client`.
**Что устанавливается:**
- Windows Packet Filter — драйвер для перехвата трафика
- ProxiFyre — служба, которая направляет трафик Discord через прокси
### Harbor Gateway
После установки Discord/Vesktop будут автоматически работать через VPN!
| Действие | Команда |
| --- | --- |
| Запустить или обновить после изменения файлов | `docker compose -f docker-compose.gateway.yml up -d --build` |
| Обновить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml build vpn-proxy-control && docker compose -f docker-compose.gateway.yml up -d --no-deps vpn-proxy-control` |
| Показать состояние | `docker compose -f docker-compose.gateway.yml ps` |
| Смотреть журнал | `docker compose -f docker-compose.gateway.yml logs -f` |
| Перезапустить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-control` |
| Перезапустить VPN dataplane | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-dataplane` |
| Остановить | `docker compose -f docker-compose.gateway.yml down` |
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.gateway.yml down -v` |
---
### Harbor Connect
## ⚙️ Настройка приложений
### Для VS Code
Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте:
```
http.proxy: http://127.0.0.1:1080
```bash
cd ~/.vpn-proxy-client
```
Или добавьте в `settings.json`:
| Действие | Команда |
| --- | --- |
| Обновить и снова запустить | `./scripts/install-macos-client.sh` |
| Показать состояние | `docker compose -f docker-compose.client.yml ps` |
| Смотреть журнал | `docker compose -f docker-compose.client.yml logs -f` |
| Перезапустить | `docker compose -f docker-compose.client.yml restart` |
| Остановить | `docker compose -f docker-compose.client.yml down` |
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.client.yml down -v` |
```json
{
"http.proxy": "http://127.0.0.1:1080",
"http.proxyStrictSSL": true
}
Команда с `-v` удаляет подписку, выбранный сервер и другие сохранённые данные. Для обычной остановки используйте `down` без `-v`.
## Обновление
### Gateway
```bash
git pull --ff-only
docker compose -f docker-compose.gateway.yml up -d --build
```
### Для браузера
### Connect
В настройках прокси вашего браузера укажите:
Повторно запустите однострочный установщик. Он обновит рабочую копию, снова спросит порт прокси и пересоберёт Connect:
- **Тип**: HTTP или SOCKS5
- **Адрес**: `127.0.0.1`
- **Порт**: `1080`
> 💡 **Совет:** Используйте расширение [Proxy SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif) для удобного переключения прокси в Chrome.
### Для других программ
Укажите SOCKS5 прокси: `127.0.0.1:1080`
---
## 📋 Управление
При повторном запуске `.\manage.ps1` скрипт покажет меню управления:
| Действие | Как сделать |
|----------|-------------|
| Посмотреть статус | Запустить `.\manage.ps1` |
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
| Перезапустить | Пункт [1] → "Перезапустить" |
| Остановить | Пункт [1] → "Остановить" |
| Полностью удалить | Пункт [U] |
---
## 🌍 Подключение из локальной сети
Если вы хотите использовать прокси с других устройств (телефон, планшет):
1. Посмотрите IP-адрес в меню (раздел "Из сети:")
2. На другом устройстве настройте прокси: `IP_ВАШЕГОК:1080`
Например: `192.168.1.100:1080`
---
## ❓ Часто задаваемые вопросы
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
**Решение:** Включите выполнение скриптов:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
```
### Ошибка при запуске — непонятные символы или синтаксис
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
**Причина:** Вы используете старый PowerShell 5.1
### Версии
**Решение:** Установите PowerShell 7 (см. раздел "Перед началом")
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
### Discord не подключается к голосовым каналам
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
**Причина:** ProxiFyre не запущен или VPN клиент остановлен
Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
**Решение:**
1. Запустите `.\manage.ps1`
2. Убедитесь что пункт [1] показывает "РАБОТАЕТ"
3. Убедитесь что пункт [2] показывает "АКТИВЕН"
## Настройки `.env`
### Как узнать, работает ли VPN?
Для большинства установок достаточно стандартных значений.
1. Откройте https://ipinfo.io в браузере — это ваш реальный IP
2. Настройте прокси в браузере
3. Откройте https://ipinfo.io снова — должен показать другой IP
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `PORT` | `3456` | Внутренний порт веб-интерфейса Gateway |
| `CLIENT_UI_PORT` | `3456` | Порт интерфейса Connect на Mac |
| `PROXY_PORT` | `8080` | Порт общего прокси Gateway |
| `CLIENT_PROXY_PORT` | `8082` | Порт локального прокси Connect |
| `HARBOR_GATEWAY_CONTROL_PORT` | `3456` | Порт, на котором Connect проверяет домашний Gateway |
| `PROXY_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
---
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
## 🔧 Продвинутые варианты
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
### Docker с веб-интерфейсом
## Если что-то не работает
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
### Интерфейс не открывается
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
Проверьте контейнер и журнал:
📖 **[Инструкция по Docker](docs/DOCKER.md)**
```bash
docker compose -f docker-compose.gateway.yml ps
docker compose -f docker-compose.gateway.yml logs --tail=100
```
### Установка на удалённый сервер (VPS)
Для Connect замените имя файла на `docker-compose.client.yml` и выполняйте команду из `~/.vpn-proxy-client`.
Если вы хотите развернуть прокси на своём сервере в другой стране:
### Прокси не отвечает
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
Убедитесь, что Harbor включён в интерфейсе, а приложение использует правильные адрес и порт. Для Connect это обычно `127.0.0.1:8082`; для Gateway — IP Linux-машины и порт `8080`.
---
### Connect не распознаёт Gateway
## 📚 Словарь терминов
Проверьте три условия:
| Термин | Объяснение |
|--------|------------|
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
| **VLESS** | Современный протокол VPN-соединения |
| **sing-box** | Программа-клиент для подключения к VPN |
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
| **Порт** | "Номер двери" для сетевых соединений |
- Gateway является текущим основным шлюзом Mac;
- на обоих устройствах сохранена одна и та же подписка;
- с Mac открывается `http://АДРЕС-GATEWAY:3456`.
---
### Проверка конфигурации без запуска
## 🆘 Нужна помощь?
```bash
docker compose -f docker-compose.gateway.yml config
docker compose -f docker-compose.client.yml config
docker compose -f docker-compose.client.local.yml config
```
Если что-то не работает:
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
1. Убедитесь что используете **PowerShell 7**
2. Запустите от имени **Администратора**
3. Проверьте статус в главном меню
4. Попробуйте переустановить: пункт [U], затем пункт [1]
### Локальное тестирование Harbor Connect
---
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
_Создано для простого и безопасного доступа в интернет_ 🛡️
```bash
docker compose -f docker-compose.client.local.yml up -d --build
```
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
```bash
docker compose -f docker-compose.client.local.yml down -v
```
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
### Команды npm
| Команда | Назначение |
| --- | --- |
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
| `npm test` | Запустить автоматические проверки |
| `npm run build` | Собрать веб-интерфейс в `dist/` |
| `npm run dev` | Запустить Vite для разработки интерфейса |
| `npm start` | Запустить управляющий Node.js-сервис в подготовленном окружении |
### Сборка и развёртывание
| Команда | Назначение |
| --- | --- |
| `./scripts/build-runtime-base.sh` | Собрать базовый runtime-образ с Node.js, сетевыми утилитами и `sing-box` |
| `./scripts/build-on-107-deploy-111.sh` | Собрать Gateway на хосте `107` и развернуть на хосте `111`; хосты меняются через `BUILD_HOST` и `DEPLOY_HOST` |
| `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` |
| `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent |
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`.
## Хранение данных
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.

View File

@@ -0,0 +1,24 @@
name: harbor-connect-local
services:
harbor-connect:
extends:
file: docker-compose.client.yml
service: harbor-connect
container_name: harbor-connect-local
environment:
PORT: ${LOCAL_CLIENT_UI_PORT:-3457}
PROXY_PORT: ${LOCAL_CLIENT_PROXY_PORT:-8083}
ports: !override
- "127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}:${LOCAL_CLIENT_UI_PORT:-3457}"
- "127.0.0.1:${LOCAL_CLIENT_PROXY_PORT:-8083}:${LOCAL_CLIENT_PROXY_PORT:-8083}"
volumes: !override
- vpn-proxy-client-local-data:/var/lib/vpn-proxy
- sing-box-client-local-cache:/var/lib/sing-box
- ./.runtime:/run/harbor-host:ro
healthcheck:
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}/api/state"]
volumes:
vpn-proxy-client-local-data:
sing-box-client-local-cache:

45
docker-compose.client.yml Normal file
View File

@@ -0,0 +1,45 @@
services:
harbor-connect:
build:
context: .
dockerfile: Dockerfile.client
args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
container_name: harbor-connect
environment:
APP_MODE: client
PORT: ${PORT:-3456}
PROXY_PORT: ${CLIENT_PROXY_PORT:-8082}
PROXY_BIND_IP: 0.0.0.0
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_PROXY: ""
HTTPS_PROXY: ""
ALL_PROXY: ""
http_proxy: ""
https_proxy: ""
all_proxy: ""
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
no_proxy: "localhost,127.0.0.1,host.docker.internal"
ports:
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
- "127.0.0.1:${CLIENT_PROXY_PORT:-8082}:${CLIENT_PROXY_PORT:-8082}"
volumes:
- vpn-proxy-client-data:/var/lib/vpn-proxy
- sing-box-client-cache:/var/lib/sing-box
- ./.runtime:/run/harbor-host:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
vpn-proxy-client-data:
sing-box-client-cache:

View File

@@ -0,0 +1,72 @@
x-gateway-image: &gateway-image
image: ${GATEWAY_IMAGE:-vpn-proxy-gateway:local}
build:
context: .
dockerfile: Dockerfile
args:
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
services:
vpn-proxy-dataplane:
<<: *gateway-image
container_name: vpn-proxy-dataplane
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
env_file:
- path: .env
required: false
environment:
APP_COMPONENT: dataplane
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
- sing-box-cache:/var/lib/sing-box
- vpn-proxy-runtime:/run/vpn-proxy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
vpn-proxy-control:
<<: *gateway-image
container_name: vpn-proxy-gateway
env_file:
- path: .env
required: false
environment:
APP_COMPONENT: control
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports:
- "${PORT:-3456}:${PORT:-3456}"
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
- vpn-proxy-runtime:/run/vpn-proxy
depends_on:
vpn-proxy-dataplane:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
vpn-proxy-data:
sing-box-cache:
vpn-proxy-runtime:

View File

@@ -1,41 +0,0 @@
# ==========================================
# СЕРВЕРНАЯ КОНФИГУРАЦИЯ (Linux VPS)
# ==========================================
# Используйте этот файл на удалённом сервере:
# docker compose -f docker-compose.server.yml up -d
#
# network_mode: host решает проблему UDP ASSOCIATE
# для SOCKS5 прокси (важно для Discord голоса!)
# ==========================================
version: "3.9"
services:
sing-proxy:
container_name: sing-proxy
build:
context: .
dockerfile: docker/Dockerfile.singbox
# HOST MODE — контейнер использует сеть хоста напрямую
# Это решает проблему UDP ASSOCIATE для SOCKS5
# ВАЖНО: работает только на Linux, не на Windows/macOS!
network_mode: host
environment:
# Порт веб-интерфейса (по умолчанию 3456)
- PORT=${PORT:-3456}
# Порт прокси HTTP/SOCKS5 (по умолчанию 8080)
- PROXY_PORT=${PROXY_PORT:-8080}
volumes:
- ./data:/app/data
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m
# Порты при network_mode: host не нужно пробрасывать,
# они автоматически доступны на хосте:
# - 3456: Веб-интерфейс (PORT)
# - 8080: SOCKS5/HTTP прокси (PROXY_PORT)

View File

@@ -1,22 +0,0 @@
version: "3.9"
services:
sing-proxy:
container_name: sing-proxy
build:
context: .
dockerfile: docker/Dockerfile.singbox
ports:
# Веб-интерфейс (можно переопределить: PORT=9090 docker compose up)
- "${PORT:-3456}:${PORT:-3456}"
# Прокси HTTP/SOCKS5 (можно переопределить: PROXY_PORT=8082 docker compose up)
- "${PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
environment:
- PORT=${PORT:-3456}
- PROXY_PORT=${PROXY_PORT:-8080}
volumes:
- ./data:/app/data
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m

View File

@@ -1,28 +0,0 @@
FROM alpine:3.20
ARG SINGBOX_VER=1.12.13
# Устанавливаем зависимости, включая dos2unix для исправления скриптов
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 dos2unix && update-ca-certificates
# Автоматическое определение архитектуры и установка sing-box
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then SB_ARCH="amd64"; \
elif [ "$ARCH" = "aarch64" ]; then SB_ARCH="arm64"; \
else SB_ARCH="amd64"; fi && \
curl -L -o /tmp/sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VER}/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}.tar.gz \
&& tar -xf /tmp/sb.tar.gz -C /tmp \
&& mv /tmp/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}/sing-box /usr/local/bin/sing-box \
&& chmod +x /usr/local/bin/sing-box \
&& adduser -D -u 1000 suser
COPY --chown=suser:suser docker/entrypoint.sh /app/
COPY --chown=suser:suser web/ /app/web/
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh
# Порты по умолчанию (можно переопределить через ENV)
# PORT - веб-интерфейс, PROXY_PORT - прокси
EXPOSE 3456 8080 9090
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env bash
set -e
CONFIG_FILE="/app/data/client.json"
SINGBOX_PID=""
# Порты из ENV (по умолчанию: 3456 для веба, 8080 для прокси)
PORT="${PORT:-3456}"
PROXY_PORT="${PROXY_PORT:-8080}"
# Ensure data directory exists
mkdir -p /app/data
start_singbox() {
if [[ -f "$CONFIG_FILE" ]]; then
echo "$(date): Starting sing-box..."
sing-box run -c "$CONFIG_FILE" &
SINGBOX_PID=$!
echo "$(date): sing-box started with PID $SINGBOX_PID"
else
echo "$(date): Config file not found. Use web UI at :$PORT to apply config."
SINGBOX_PID=""
fi
}
stop_singbox() {
if [[ -n "$SINGBOX_PID" ]]; then
echo "$(date): Stopping sing-box (PID $SINGBOX_PID)..."
kill "$SINGBOX_PID" 2>/dev/null || true
wait "$SINGBOX_PID" 2>/dev/null || true
SINGBOX_PID=""
fi
}
restart_singbox() {
stop_singbox
start_singbox
}
start_singbox
# Start Web UI Server with configurable port
echo "$(date): Starting Web UI on port $PORT..."
PORT=$PORT PROXY_PORT=$PROXY_PORT python3 /app/web/server.py &
WEBUI_PID=$!
# HTTP Control Server (Simple Netcat loop)
# Listens on 9090.
# Endpoint: /reload -> Restart sing-box (used by web_server.py after config change)
(
while true; do
# Read the request using nc.
REQ=$(echo -e "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" | nc -l -p 9090 -q 1)
echo "$(date): Received request on 9090"
if echo "$REQ" | grep -q "GET /reload"; then
echo "$(date): Action: RELOAD (Restart sing-box)"
restart_singbox
else
echo "$(date): Unknown request or ping."
fi
done
) &
CONTROL_PID=$!
# Keep container alive - wait for any background process
echo "$(date): Entrypoint ready. Waiting for processes..."
# Wait indefinitely - if WebUI dies, restart container
wait $WEBUI_PID

View File

@@ -1,178 +0,0 @@
# 🐳 Docker — Веб-интерфейс для управления VPN
> **Это продвинутый способ** установки с красивым веб-интерфейсом. Для большинства пользователей рекомендуется использовать [основной способ через PowerShell](../README.md).
---
## 📖 Что это даёт?
- 🌐 **Веб-интерфейс** — управление через браузер на http://localhost:3456
- 📡 **Подписки** — автоматическое получение списка серверов
- 🔄 **Переключение серверов** — в один клик
- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются
---
## 🔧 Требования
### Docker Desktop
1. Скачайте: https://www.docker.com/products/docker-desktop/
2. Установите и запустите
3. Убедитесь, что иконка 🐳 есть в трее (панель задач)
> 💡 На Windows может потребоваться WSL2. Docker Desktop предложит его установить автоматически.
---
## 🚀 Установка
### Шаг 1: Откройте терминал
Откройте PowerShell или Командную строку и перейдите в папку проекта:
```powershell
cd путь\к\папке\vpn-proxy
```
### Шаг 2: Соберите контейнер
```powershell
docker compose build
```
Это создаст образ со всеми необходимыми компонентами. Выполняется один раз.
### Шаг 3: Запустите
```powershell
docker compose up -d
```
Флаг `-d` запускает контейнер в фоновом режиме.
### Шаг 4: Откройте веб-интерфейс
Перейдите в браузере: **http://localhost:3456**
---
## 🌐 Использование веб-интерфейса
### Режим подписки
1. Вставьте URL подписки в поле "Подписка"
2. Нажмите **"Загрузить серверы"**
3. Выберите сервер из списка
4. Нажмите **"Применить"**
### Режим VLESS
1. Перейдите на вкладку "VLESS Ключ"
2. Вставьте VLESS-ссылку (`vless://...`)
3. Нажмите **"Применить"**
> 💡 Настройки сохраняются в папке `data/` и восстанавливаются при перезапуске.
---
## 🌐 Порты
| Порт | Назначение | URL |
|------|------------|-----|
| `3456` | Веб-интерфейс | http://localhost:3456 |
| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` |
| `9090` | API управления (внутренний) | — |
---
## 📋 Управление контейнером
| Действие | Команда |
|----------|---------|
| Посмотреть статус | `docker ps` |
| Посмотреть логи | `docker logs --tail 50 sing-proxy` |
| Остановить | `docker compose stop` |
| Запустить снова | `docker compose start` |
| Перезапустить | `docker compose restart` |
| Полностью удалить | `docker compose down` |
| Пересобрать | `docker compose up -d --build` |
---
## 🔄 Обновление
Если вы обновили код из репозитория:
```powershell
# Остановить текущий контейнер
docker compose down
# Пересобрать с новыми изменениями
docker compose build --no-cache
# Запустить заново
docker compose up -d
```
> 💡 Подписка и настройки сохраняются в папке `data/` и не потеряются.
---
## ⚙️ Настройка приложений
### Для VS Code
```json
{
"http.proxy": "http://127.0.0.1:8080",
"http.proxyStrictSSL": true
}
```
### Для браузера
- **Адрес**: `127.0.0.1`
- **Порт**: `8080`
- **Тип**: HTTP или SOCKS5
---
## ❓ Проблемы и решения
### Страница localhost:3456 не открывается
**Причина:** Контейнер не запущен.
```powershell
# Проверьте статус
docker ps
# Если контейнера нет — запустите
docker compose up -d
```
### "Connection refused"
**Причина:** VPN-ссылка не применена.
1. Откройте http://localhost:3456
2. Примените VLESS-ссылку или загрузите подписку
### Медленное подключение
Попробуйте другой сервер в веб-интерфейсе — некоторые серверы могут быть перегружены.
---
## ⚠️ Ограничения Docker на Windows
- **UDP для Discord:** Docker на Windows/macOS имеет проблемы с UDP ASSOCIATE для SOCKS5. Для Discord рекомендуется использовать [нативную установку](../README.md).
- **Для полной поддержки UDP** используйте [установку на Linux сервер](SERVER.md) с `network_mode: host`.
---
[← Вернуться к основной инструкции](../README.md)

View File

@@ -1,278 +0,0 @@
# 🌍 Установка на Сервер (Linux VPS)
> Эта инструкция для установки прокси на удалённый сервер. После установки вы сможете подключаться к нему с любого устройства.
---
## 📖 Зачем это нужно?
- 🌐 **Один прокси для всех устройств** — компьютер, телефон, планшет
- 🔒 **Работает 24/7** — не нужно держать компьютер включённым
- 📡 **Полная поддержка UDP** — голосовые звонки и игры работают отлично
- 🏠 **Доступ из любого места** — дома, на работе, в поездке
---
## 🔧 Требования к серверу
- **ОС:** Ubuntu 20.04+, Debian 11+, или любой современный Linux
- **Ресурсы:** Минимум 512 MB RAM, 1 CPU
- **Порты:** 3456 (веб-интерфейс), 8080 (прокси)
- **Доступ:** SSH подключение
> 💡 Подойдёт любой VPS за $3-5/месяц от DigitalOcean, Vultr, Hetzner и др.
---
## 🚀 Установка
### Шаг 1: Подключитесь к серверу
Откройте терминал (PowerShell на Windows, Terminal на Mac/Linux):
```bash
ssh root@ваш_сервер_ip
```
Введите пароль когда попросят.
> 💡 **Совет:** Если вы на Windows и нет ssh команды, используйте PuTTY или Windows Terminal.
---
### Шаг 2: Установите Docker
Если Docker ещё не установлен:
```bash
# Автоматическая установка Docker
curl -fsSL https://get.docker.com | sh
# Проверка что Docker работает
docker --version
```
---
### Шаг 3: Загрузите проект
**Вариант A: Через Git**
```bash
git clone https://github.com/your-repo/vpn-proxy.git
cd vpn-proxy
```
**Вариант B: Загрузка файлов вручную**
Если git недоступен, скачайте ZIP архив и распакуйте на сервере.
---
### Шаг 4: Запустите контейнер
> ⚠️ **Важно:** Используйте `docker-compose.server.yml` — он настроен для серверов!
```bash
docker compose -f docker-compose.server.yml up -d
```
Это запустит контейнер с `network_mode: host`, что решает проблемы с UDP.
---
### Шаг 5: Откройте порты в файрволе
**Для UFW (Ubuntu/Debian):**
```bash
ufw allow 3456/tcp # Веб-интерфейс
ufw allow 8080/tcp # Прокси TCP
ufw allow 8080/udp # Прокси UDP (для голоса/игр)
ufw reload
```
**Для firewalld (CentOS/RHEL):**
```bash
firewall-cmd --permanent --add-port=3456/tcp
firewall-cmd --permanent --add-port=8080/tcp
firewall-cmd --permanent --add-port=8080/udp
firewall-cmd --reload
```
**Для iptables:**
```bash
iptables -A INPUT -p tcp --dport 3456 -j ACCEPT
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
iptables -A INPUT -p udp --dport 8080 -j ACCEPT
```
---
### Шаг 6: Настройте VPN через веб-интерфейс
1. Откройте в браузере: `http://ваш_сервер_ip:3456`
2. Вставьте VLESS-ссылку или URL подписки
3. Нажмите "Применить"
---
## ✅ Проверка работы
На сервере:
```bash
# Проверить что контейнер запущен
docker ps
# Посмотреть логи
docker logs --tail 20 sing-proxy
```
С вашего компьютера:
```bash
# Проверить прокси
curl -x http://ваш_сервер_ip:8080 https://ipinfo.io/ip
```
Должен показать IP VPN-сервера (не IP вашего VPS).
---
## 🖥️ Подключение с Windows
### Настройка в manage.ps1
При настройке Discord (пункт [2]) вы можете указать адрес удалённого прокси:
```
Введите адрес прокси (IP:порт): ваш_сервер_ip:8080
```
### Настройка в браузере/приложениях
- **Адрес:** аш_сервер_ip`
- **Порт:** `8080`
- **Тип:** HTTP или SOCKS5
---
## 📋 Управление
| Действие | Команда |
|----------|---------|
| Посмотреть статус | `docker ps` |
| Логи | `docker logs --tail 50 sing-proxy` |
| Остановить | `docker compose -f docker-compose.server.yml stop` |
| Запустить | `docker compose -f docker-compose.server.yml start` |
| Перезапустить | `docker compose -f docker-compose.server.yml restart` |
| Удалить | `docker compose -f docker-compose.server.yml down` |
---
## 🔐 Рекомендации по безопасности
### 1. Смените стандартные порты
Отредактируйте `docker-compose.server.yml`:
```yaml
environment:
- PORT=54321 # Вместо 3456
- PROXY_PORT=12345 # Вместо 8080
```
### 2. Ограничьте доступ к веб-интерфейсу
Если веб-интерфейс нужен только для первоначальной настройки:
```bash
# Закрыть веб-порт после настройки
ufw delete allow 3456/tcp
```
### 3. Используйте SSH туннель
Для безопасного доступа к веб-интерфейсу:
```bash
ssh -L 3456:localhost:3456 root@ваш_сервер_ip
```
Затем откройте http://localhost:3456 в браузере.
---
## 🔄 Обновление
```bash
cd vpn-proxy
# Получить обновления
git pull
# Пересобрать контейнер
docker compose -f docker-compose.server.yml down
docker compose -f docker-compose.server.yml build --no-cache
docker compose -f docker-compose.server.yml up -d
```
---
## ❓ Проблемы и решения
### Порт 3456 не открывается
**Причина:** Файрвол блокирует подключения.
**Решение:** Проверьте настройки файрвола, см. Шаг 5.
### "Permission denied" при запуске Docker
**Решение:**
```bash
# Добавить пользователя в группу docker
sudo usermod -aG docker $USER
# Перезайти
exit
ssh root@ваш_сервер_ip
```
### Контейнер постоянно перезапускается
```bash
# Посмотреть логи ошибок
docker logs sing-proxy
```
Обычно проблема в неверной VLESS-ссылке.
---
## 📐 Изменение портов
По умолчанию:
- **3456** — веб-интерфейс
- **8080** — прокси
Для изменения создайте файл `.env` в папке проекта:
```env
PORT=54321
PROXY_PORT=12345
```
И перезапустите:
```bash
docker compose -f docker-compose.server.yml up -d
```
---
[← Вернуться к основной инструкции](../README.md)

View File

@@ -0,0 +1,21 @@
# Harbor responsive layout
The client page has three stable regions: the Harbor brand, the primary power control and the subscription/details form.
## Desktop
At widths above 920 px the main panel uses a symmetric three-column grid. Equal outer columns keep the power control on the exact horizontal center axis; the details form occupies the right column. The form has a viewport-relative maximum height and its own vertical scroll for unusually long content, so a large server list cannot move the power control away from the visual center.
The no-subscription setup state collapses the panel to one column and centers the form. No `left` offset or translated absolute element participates in either layout.
## Tablet and mobile
At 920 px and below all main regions use one normal-flow grid column. The brand becomes an absolute header inside the page shell, while reserved top padding prevents it from overlapping the primary content. Errors become normal-flow rows instead of floating over nearby controls. Form width is capped by both the available space and a readable maximum.
At 560 px and below spacing and drawer padding become more compact. Controls with a preferred fixed size, such as the duration switch, use `min(..., 100%)` so the primary flow remains available at 320 px.
## Motion and overflow contract
Layout properties are not animated. State feedback may animate opacity and blur, and the existing `prefers-reduced-motion` rules disable those transitions and animations. Drawers are capped at `100vw`; dialogs and inline content retain viewport-relative width limits.
Automated source-contract tests protect the symmetric desktop grid, normal-flow narrow layout, viewport-safe control widths and reduced-motion fallback. Release acceptance still includes a rendered check at 390, 768 and 1440 px; TASK-017 will later make that browser matrix automatic in CI.

View File

@@ -0,0 +1,85 @@
# 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.
```json
{
"apiVersion": 1,
"revision": 42,
"generatedAt": "2026-07-11T15:00:00.000Z",
"mode": "client",
"subscription": {
"status": "ready",
"host": "provider.example/…",
"fetchedAt": "2026-07-11T14:58:00.000Z",
"userInfo": {}
},
"selection": {
"desiredServerId": "srv_4d7c5d1bcd60d665",
"appliedServerId": "srv_4d7c5d1bcd60d665"
},
"connection": {
"desired": "running",
"process": "running",
"startedAt": "2026-07-11T14:59:10.000Z",
"lastError": null
},
"route": {
"mode": "local-vpn",
"gatewayAddress": null,
"lastVerifiedAt": null,
"reason": "auto"
},
"operation": {
"kind": null,
"status": "idle",
"startedAt": null,
"error": null
},
"servers": [
{
"id": "srv_4d7c5d1bcd60d665",
"label": "Amsterdam",
"host": "nl.example.net",
"port": 443,
"protocol": "vless"
}
]
}
```
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.
## 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.
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 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 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.
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 state
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.
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.
## Subscription import and refresh
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.
Import and refresh share one commit path. It prepares the candidate server list and sing-box config first, then updates cache, config, runtime and canonical state. If provider fetch, parsing, config validation or runtime apply fails, the previous subscription cache, selected server, config and running process remain active. Refreshes for the saved URL share one in-flight Promise; a refresh that finishes after another import is rejected with `STATE_CONFLICT` instead of overwriting the newer subscription.
The existing background refresh remains every 15 minutes. Provider requests time out after 15 seconds by default (`SUBSCRIPTION_TIMEOUT_MS` may override it). A failed background refresh logs a redacted warning and keeps the last successful subscription snapshot.
## 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.
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.
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.

View File

@@ -0,0 +1,17 @@
# Data consistency regression suite
`npm test` is the required fast regression gate. It uses generated fixtures, temporary directories, loopback HTTP servers and a fake sing-box executable; it does not require internet, root or an installed sing-box.
The protected invariants are:
| Invariant | Regression coverage |
|---|---|
| One backend canonical snapshot owns servers and desired/applied selection | `test/data-consistency-regression.test.js`, `test/server/state-contract.test.js` |
| Revisions increase and an older response cannot replace newer state | `test/server/state-contract.test.js`, `test/web/harbor-state.test.js` |
| Provider failure, parser failure and runtime failure do not partially commit subscription state | `test/server/state-contract.test.js` |
| Atomic write failure preserves the last file and corrupt JSON preserves its original bytes | `test/server/state-store.test.js` |
| Legacy state migrates with an explicit result for ambiguous selection | `test/server/state-store.test.js` |
| Stable IDs survive reorder and duplicate labels for 1, 30 and 300 servers | `test/data-consistency-regression.test.js`, `test/server/subscription.test.js` |
| Initial control outage does not invent domain state; repeated failures retain and mark the last snapshot stale | `test/web/harbor-state.test.js` |
Fixtures are generated in test code to keep the suite small and deterministic. Packet-level networking, browser screenshots and accessibility automation are intentionally deferred to their dedicated roadmap tasks.

View File

@@ -0,0 +1,22 @@
# Harbor error contract v1
Public API failures use one envelope:
```json
{
"success": false,
"error": {
"code": "PROVIDER_UNAVAILABLE",
"message": "Провайдер подписки временно недоступен.",
"retryable": true,
"correlationId": "6f1a63de-30f9-4dc5-b8ce-38d38c164fe3",
"details": "HTTP 503"
}
}
```
`code`, Russian user copy, HTTP status and retry policy come from `src/shared/errors.js`. The browser maps copy and retry behavior by `code`; it does not display server-provided `details`. Unknown failures use `UNKNOWN`, never expose the raw exception, and always receive a correlation reference. Server logs use the same reference and redact complete HTTP(S) URLs.
Errors are local operation results, not canonical state replacements. A failed apply keeps the previous snapshot; in particular, server existence is validated before `desiredServerId` is persisted. Frontend errors are shown beside subscription or connection controls. Only retryable codes expose `Повторить`.
This is a coordinated API change: old frontends do not understand the object-valued `error` field, so frontend and control plane must be deployed together. Persisted files and volumes are unchanged. Rollback is code-only and requires no data migration.

View File

@@ -0,0 +1,16 @@
# Frontend operation registry
Harbor tracks active browser mutations by operation key instead of one global `busy` flag:
- `connection`: start, stop and restart;
- `serverApply`: apply the selected server;
- `subscriptionImport`, `subscriptionRefresh`, `subscriptionDelete`;
- `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.js`.
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.
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.

View File

@@ -0,0 +1,36 @@
# 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`.
## Atomic writes
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
## Migration
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:
```text
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.
## Corrupt JSON
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
```text
state.json.corrupt-2026-07-11T12-00-00-000Z
```
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.
Recovery should be performed while Harbor is stopped:
1. Copy the whole data directory before changing anything.
2. Inspect a backup with `jq . <backup-file>`.
3. Restore only a valid JSON backup to the original filename.
4. Start Harbor and verify `GET /api/state` before applying or importing anything.
Generated config rollback also uses the atomic writer. No automatic recovery tries to guess missing subscription credentials or repair semantically invalid sing-box configuration.

21
entrypoint.client.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${PORT:-3456}"
PROXY_PORT="${PROXY_PORT:-8082}"
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
log() {
printf '[client-entrypoint] %s\n' "$*"
}
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
export APP_MODE=client
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
exec node /app/src/server/index.js

140
entrypoint.sh Executable file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
set -euo pipefail
APP_COMPONENT="${APP_COMPONENT:-combined}"
TPROXY_PORT="${TPROXY_PORT:-7895}"
TPROXY_MARK="${TPROXY_MARK:-1}"
TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
PROXY_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
log() {
printf '[gateway-entrypoint] %s\n' "$*"
}
if [[ "$APP_COMPONENT" == "control" ]]; then
exec node /app/src/server/index.js
fi
ipt() {
iptables -w "$@"
}
cleanup_proxy_firewall() {
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
}
cleanup_tproxy() {
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
}
cleanup_gateway_forwarding() {
ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
ipt -F "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
ipt -X "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -F "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
}
enable_ip_forwarding() {
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
elif command -v sysctl >/dev/null 2>&1; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
fi
}
setup_proxy_firewall() {
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
return
fi
cleanup_proxy_firewall
ipt -N "$PROXY_INPUT_CHAIN"
for cidr in $PROXY_ALLOWED_CIDRS; do
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
done
ipt -A "$PROXY_INPUT_CHAIN" -j DROP
ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
}
setup_gateway_forwarding() {
log "setup direct gateway forwarding"
cleanup_gateway_forwarding
enable_ip_forwarding
ipt -N "$GATEWAY_FORWARD_CHAIN"
ipt -t nat -N "$GATEWAY_NAT_CHAIN"
for cidr in $GATEWAY_CLIENT_CIDRS; do
ipt -A "$GATEWAY_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$GATEWAY_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ipt -t nat -A "$GATEWAY_NAT_CHAIN" -s "$cidr" -m addrtype ! --dst-type LOCAL -j MASQUERADE
done
ipt -I FORWARD 1 -j "$GATEWAY_FORWARD_CHAIN"
ipt -t nat -I POSTROUTING 1 -j "$GATEWAY_NAT_CHAIN"
}
setup_tproxy() {
log "setup tproxy on port ${TPROXY_PORT}"
cleanup_tproxy
enable_ip_forwarding
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
ipt -t mangle -N "$TPROXY_CHAIN"
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
ipt -t mangle -A "$TPROXY_CHAIN" -i 'br-+' -j RETURN
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
done
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
}
setup_gateway_forwarding
setup_tproxy
setup_proxy_firewall
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
node /app/src/server/dataplane.js &
else
node /app/src/server/index.js &
fi
APP_PID=$!
shutdown() {
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
cleanup_proxy_firewall
cleanup_tproxy
cleanup_gateway_forwarding
}
trap 'shutdown; exit 0' SIGTERM SIGINT
wait "$APP_PID"
STATUS=$?
cleanup_proxy_firewall
cleanup_tproxy
cleanup_gateway_forwarding
exit "$STATUS"

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link id="harbor-favicon" rel="icon" href="/harbor-connect.svg?v=2" type="image/svg+xml" sizes="any" />
<title>Harbor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/web/App.jsx"></script>
</body>
</html>

View File

@@ -1,104 +0,0 @@
# ==========================================
# 🚀 VPN PROXY INSTALLER
# ==========================================
# This script automatically downloads and installs VPN Proxy
# Usage:
# iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
# Enable UTF-8 for emoji support
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
# --- 1. Check Admin Rights ---
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) {
Write-Warning "⚠️ Administrator rights required!"
Write-Host "🔄 Restarting script as Administrator..." -ForegroundColor Cyan
# Save script to temp file if running from memory (iex)
if ($MyInvocation.MyCommand.CommandType -eq 'Script') {
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
}
else {
# If running via IEX, we cannot simple restart the file.
# We ask user to run terminal as admin.
Write-Error "Please run PowerShell as Administrator and try again."
}
exit
}
# --- 2. Settings ---
$InstallRoot = "C:\Tools"
$InstallDir = "$InstallRoot\vpn-proxy"
# Exact link provided by user
$ZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip"
$TempZip = "$env:TEMP\vpn-proxy-install.zip"
Write-Host "🚀 Starting VPN Proxy installation..." -ForegroundColor Green
Write-Host "📂 Install path: $InstallDir" -ForegroundColor Gray
# Move to temp folder to avoid blocking deletion if we are already in C:\Tools\vpn-proxy
Set-Location $env:TEMP
# --- 3. Prepare Directory ---
if (-not (Test-Path $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
# --- 4. Downloading ---
Write-Host "⬇️ Downloading update archive..." -ForegroundColor Cyan
try {
Invoke-WebRequest -Uri $ZipUrl -OutFile $TempZip
}
catch {
Write-Error "❌ Failed to download from $ZipUrl`nCheck your internet connection."
exit 1
}
# --- 5. Extracting ---
Write-Host "📦 Extracting..." -ForegroundColor Cyan
# If folder exists, delete old one
if (Test-Path $InstallDir) {
try {
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
}
catch {
Write-Warning "⚠️ Failed to delete old folder $InstallDir"
Write-Warning " Error: $($_.Exception.Message)"
Write-Warning " Make sure files are not open in other programs and you are not inside this folder."
$retry = Read-Host " Press Enter to try again (or Ctrl+C to cancel)"
try {
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
}
catch {
Write-Error "❌ Still failed to delete folder. Installation aborted."
exit 1
}
}
}
Expand-Archive -Path $TempZip -DestinationPath $InstallRoot -Force
# Archives usually extract to vpn-proxy-master or vpn-proxy-main
# We need to rename it to vpn-proxy
$ExtractedFolder = Get-ChildItem -Path $InstallRoot -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1
if ($ExtractedFolder) {
Rename-Item -Path $ExtractedFolder.FullName -NewName "vpn-proxy" -Force
}
# Remove temp archive
Remove-Item $TempZip -Force
if (-not (Test-Path "$InstallDir\manage.ps1")) {
Write-Error "❌ Installation error: manage.ps1 not found in $InstallDir"
exit 1
}
# --- 6. Finish ---
Write-Host "✅ Installation complete!" -ForegroundColor Green
Write-Host ""
Write-Host "To start the control menu, run:" -ForegroundColor Cyan
Write-Host "& `"$InstallDir\manage.ps1`"" -ForegroundColor Yellow
Write-Host ""

27
install.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
need() {
command -v "$1" >/dev/null 2>&1 || {
printf '[harbor-connect] error: %s is required\n' "$1" >&2
exit 1
}
}
need curl
need tar
[ -x /bin/bash ] || { printf '[harbor-connect] error: /bin/bash is required\n' >&2; exit 1; }
branch="${VPN_PROXY_BRANCH:-master}"
archive_url="${VPN_PROXY_ARCHIVE_URL:-https://git.dokops.ru/dokril/vpn-proxy/archive/${branch}.tar.gz}"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/harbor-connect.XXXXXX")"
trap 'rm -rf "$tmp"' 0 1 2 3 15
mkdir -p "$tmp/source"
curl -fsSL "$archive_url" | tar -xzf - -C "$tmp/source" --strip-components=1
[ -f "$tmp/source/scripts/install-macos-client.sh" ] || {
printf '[harbor-connect] error: installer is missing from archive\n' >&2
exit 1
}
VPN_PROXY_SOURCE_DIR="$tmp/source" /bin/bash "$tmp/source/scripts/install-macos-client.sh"

View File

@@ -1,94 +0,0 @@
# ==========================================
# 🚀 VPN PROXY CONTROL CENTER (WINDOWS)
# ==========================================
# Главный скрипт управления. Запускать от имени Администратора.
# Использование: .\manage.ps1 [-Debug]
param([switch]$Debug)
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
$LibDir = "$ScriptDir\scripts\lib"
# Проверка библиотек
if (!(Test-Path "$LibDir\Common.ps1")) {
Write-Host "❌ Ошибка: Не найдены библиотеки в $LibDir" -ForegroundColor Red
exit 1
}
. "$LibDir\Common.ps1"
. "$LibDir\System.ps1"
# Установка режима отладки
if ($Debug) {
Set-DebugMode -Enabled $true
}
Ensure-Admin
while ($true) {
Write-Header "VPN PROXY CONTROL CENTER" -ClearScreen
# --- СБОР СТАТУСОВ ---
# 1. Native Sing-box
$sbStatus = Get-TaskStatus -Name "SingBoxProxy"
$sbStr = if ($sbStatus -eq "Running") { "РАБОТАЕТ" } else { "ОСТАНОВЛЕН" }
$sbColor = if ($sbStatus -eq "Running") { "Green" } else { "Yellow" }
if (!$sbStatus) { $sbStr = "НЕ УСТАНОВЛЕН"; $sbColor = "Gray" }
# 2. Discord Proxy
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
$discStr = if ($discSvc.Status -eq 'Running') { "АКТИВЕН" } else { "НЕ АКТИВЕН" }
$discColor = if ($discSvc.Status -eq 'Running') { "Green" } else { "Gray" }
# --- ОТРИСОВКА МЕНЮ ---
Write-Host " [1] 📦 VPN Клиент (Sing-box)" -NoNewline -ForegroundColor White
Write-Host " [$sbStr]" -ForegroundColor $sbColor
Write-Host " Основной способ. Поддерживает UDP и игры." -ForegroundColor Gray
# Показываем информацию о подключении если sing-box работает
if ($sbStatus -eq "Running") {
$LocalProxyPort = 1080
. "$LibDir\Net.ps1"
$ips = Get-LocalIPs
Write-Host ""
Write-Host " 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ" -ForegroundColor Cyan
Write-Host " ─────────────────────────────" -ForegroundColor DarkGray
Write-Host " Локально: " -NoNewline -ForegroundColor Gray
Write-Host "127.0.0.1:$LocalProxyPort" -ForegroundColor Green
if ($ips) {
Write-Host " Из сети:" -ForegroundColor Gray
foreach ($ip in $ips) {
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Yellow
}
}
Write-Host ""
}
Write-Host ""
Write-Host " [2] 🎮 Настройка Discord/Vesktop" -NoNewline -ForegroundColor White
Write-Host " [$discStr]" -ForegroundColor $discColor
Write-Host " Маршрутизация приложений через прокси." -ForegroundColor Gray
Write-Host ""
Write-Host " ---------------------------------------" -ForegroundColor DarkGray
Write-Host " [3] 🔄 Обновить статус" -ForegroundColor White
Write-Host " [U] ❌ Удалить всё (Uninstall)" -ForegroundColor Red
Write-Host " [q] Выход" -ForegroundColor White
Write-Host ""
$choice = Read-Host "👉 Ваш выбор"
switch ($choice) {
"1" { & "$ScriptDir\scripts\setup-singbox.ps1" }
"2" { & "$ScriptDir\scripts\setup-discord.ps1" }
"3" { continue }
"u" { & "$ScriptDir\scripts\uninstall-all.ps1" }
"q" { exit }
}
}

1663
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "vpn-proxy-gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"test": "node --test",
"version:harbor": "node scripts/harbor-version.mjs",
"start": "node src/server/index.js"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^7.0.0"
}
}

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#101812"/>
<circle cx="16" cy="16" r="11" fill="#56c9bd" opacity=".09"/>
<g fill="none" stroke="#62d6c9" stroke-width="2.6" stroke-linecap="round">
<path d="M16 6.5v9"/>
<path d="M10.1 10.3a8 8 0 1 0 11.8 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#18150f"/>
<circle cx="16" cy="7" r="2.5" fill="none" stroke="#efad58" stroke-width="2.3"/>
<g fill="none" stroke="#efad58" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 9.5V25M10.5 14h11"/>
<path d="M16 27c-5 0-8-2.8-9.5-6.5M16 27c5 0 8-2.8 9.5-6.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
BUILD_HOST="${BUILD_HOST:-107}"
DEPLOY_HOST="${DEPLOY_HOST:-111}"
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
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)}"
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
echo "Build host: ${BUILD_HOST}"
echo "Deploy host: ${DEPLOY_HOST}"
echo "Image: ${GATEWAY_IMAGE}"
echo "Base image: ${BASE_IMAGE}"
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
ensure_known_host() {
local host="$1"
if [ "${host}" = "local" ]; then return 0; fi
local scan_host="${host#*@}"
scan_host="${scan_host%%:*}"
mkdir -p "${HOME}/.ssh"
chmod 700 "${HOME}/.ssh"
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
fi
}
ssh_cmd() {
ssh \
-o BatchMode=yes \
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=4 \
"$@"
}
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
if [ "${BUILD_HOST}" = "local" ]; then
BUILD_PATH="$(pwd)"
echo "Using local source at ${BUILD_PATH}"
else
ensure_known_host "${BUILD_HOST}"
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
rsync -az --delete \
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
--exclude '.git' \
--exclude '.vpn-proxy' \
--exclude 'node_modules' \
--exclude 'dist' \
./ "${BUILD_HOST}:${BUILD_PATH}/"
fi
echo "Building image on ${BUILD_HOST}"
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && docker build --pull='${DOCKER_BUILD_PULL}' --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}' ."
if [ "${BUILD_HOST}" = "local" ]; then
bash -lc "${BUILD_COMMAND}"
else
ensure_known_host "${BUILD_HOST}"
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
fi
echo "Loading image into ${DEPLOY_HOST}"
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
elif [ "${BUILD_HOST}" = "local" ]; then
ensure_known_host "${DEPLOY_HOST}"
echo "Checking SSH access to ${DEPLOY_HOST}"
ssh_cmd "${DEPLOY_HOST}" "true"
echo "Transferring image to ${DEPLOY_HOST}"
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
elif [ "${DEPLOY_HOST}" = "local" ]; then
ensure_known_host "${BUILD_HOST}"
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
else
ensure_known_host "${BUILD_HOST}"
ensure_known_host "${DEPLOY_HOST}"
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
fi
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
if [ "${DEPLOY_HOST}" = "local" ]; then
mkdir -p "${DEPLOY_PATH}"
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
else
ensure_known_host "${DEPLOY_HOST}"
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
rsync -az \
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
fi
echo "Starting gateway on ${DEPLOY_HOST}"
if [ "${DEPLOY_HOST}" = "local" ]; then
cd "${DEPLOY_PATH}"
chmod +x ./deploy-gateway.sh
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh
else
ensure_known_host "${DEPLOY_HOST}"
ssh_cmd "${DEPLOY_HOST}" \
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh"
fi

33
scripts/build-runtime-base.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
echo "Source base image: ${BASE_IMAGE}"
echo "APT mirror: ${APT_MIRROR}"
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
docker build \
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
--build-arg APT_MIRROR="${APT_MIRROR}" \
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
--build-arg NO_PROXY="${NO_PROXY}" \
--build-arg http_proxy="${HTTP_PROXY}" \
--build-arg https_proxy="${HTTPS_PROXY}" \
--build-arg no_proxy="${NO_PROXY}" \
-f Dockerfile.runtime-base \
-t "${RUNTIME_BASE_IMAGE}" \
.

131
scripts/deploy-gateway.sh Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
CONTROL_IMAGE="${CONTROL_IMAGE:-${GATEWAY_IMAGE:-}}"
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:-${GATEWAY_IMAGE:-}}"
CONTROL_IMAGE="${CONTROL_IMAGE:?CONTROL_IMAGE or GATEWAY_IMAGE is required}"
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:?DATAPLANE_IMAGE or GATEWAY_IMAGE is required}"
UPDATE_DATAPLANE="${UPDATE_DATAPLANE:-false}"
PULL_IMAGE="${PULL_IMAGE:-true}"
echo "Preparing deploy directory: ${DEPLOY_PATH}"
mkdir -p "${DEPLOY_PATH}"
EXISTING_DATAPLANE_IMAGE="$(docker inspect --format '{{.Config.Image}}' vpn-proxy-dataplane 2>/dev/null || true)"
FIRST_SPLIT_DEPLOY=false
if [ -z "${EXISTING_DATAPLANE_IMAGE}" ]; then
FIRST_SPLIT_DEPLOY=true
elif [ "${UPDATE_DATAPLANE}" != "true" ]; then
DATAPLANE_IMAGE="${EXISTING_DATAPLANE_IMAGE}"
fi
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
services:
vpn-proxy-dataplane:
image: ${DATAPLANE_IMAGE}
container_name: vpn-proxy-dataplane
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
env_file:
- .env
environment:
APP_COMPONENT: dataplane
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
- sing-box-cache:/var/lib/sing-box
- vpn-proxy-runtime:/run/vpn-proxy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
vpn-proxy-control:
image: ${CONTROL_IMAGE}
container_name: vpn-proxy-gateway
env_file:
- .env
environment:
APP_COMPONENT: control
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports:
- "\${PORT:-3456}:\${PORT:-3456}"
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
- vpn-proxy-runtime:/run/vpn-proxy
depends_on:
vpn-proxy-dataplane:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:\${PORT:-3456}/api/state"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
vpn-proxy-data:
sing-box-cache:
vpn-proxy-runtime:
EOF
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
cat > "${DEPLOY_PATH}/.env" <<'EOF'
PORT=3456
PROXY_PORT=8080
PROXY_BIND_IP=0.0.0.0
TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
LOG_LEVEL=info
EOF
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
else
echo "Preserving existing .env"
fi
cd "${DEPLOY_PATH}"
echo "Control image: ${CONTROL_IMAGE}"
echo "Dataplane image: ${DATAPLANE_IMAGE}"
if [ "${PULL_IMAGE}" = "true" ]; then
docker compose -f docker-compose.server.yml pull vpn-proxy-control
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ] || [ "${UPDATE_DATAPLANE}" = "true" ]; then
docker compose -f docker-compose.server.yml pull vpn-proxy-dataplane
fi
else
echo "Skipping image pull"
fi
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ]; then
echo "Migrating the legacy combined gateway to split services..."
docker stop vpn-proxy-gateway 2>/dev/null || true
docker rm vpn-proxy-gateway 2>/dev/null || true
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
elif [ "${UPDATE_DATAPLANE}" = "true" ]; then
echo "Updating control and dataplane..."
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
else
echo "Updating control; keeping dataplane running..."
docker compose -f docker-compose.server.yml up -d --no-deps --wait --wait-timeout 90 vpn-proxy-control
fi
echo "Current containers:"
docker ps --filter "name=vpn-proxy-gateway" --filter "name=vpn-proxy-dataplane"

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
RUNTIME_DIR="${HARBOR_RUNTIME_DIR:-$HOME/.vpn-proxy-client/.runtime}"
STATE_FILE="$RUNTIME_DIR/network.json"
ROUTE_BIN="${HARBOR_ROUTE_BIN:-/sbin/route}"
ARP_BIN="${HARBOR_ARP_BIN:-/usr/sbin/arp}"
NETSTAT_BIN="${HARBOR_NETSTAT_BIN:-/usr/sbin/netstat}"
route_info="$($ROUTE_BIN -n get default 2>/dev/null || true)"
gateway="$(awk '/^[[:space:]]*gateway:/{print $2; exit}' <<<"$route_info")"
network_interface="$(awk '/^[[:space:]]*interface:/{print $2; exit}' <<<"$route_info")"
if [[ -z "$gateway" || -z "$network_interface" ]]; then
read -r gateway network_interface < <(
"$NETSTAT_BIN" -rn -f inet 2>/dev/null \
| awk '$1 == "default" && $2 ~ /^[0-9]+\./ { print $2, $4; exit }'
) || true
fi
if [[ ! "$gateway" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
gateway=""
network_interface=""
fi
if [[ ! "$network_interface" =~ ^[a-zA-Z0-9._-]{1,32}$ ]]; then
network_interface=""
fi
mac=""
if [[ -n "$gateway" ]]; then
mac="$($ARP_BIN -n "$gateway" 2>/dev/null | awk '/ at /{print $4; exit}' || true)"
if [[ ! "$mac" =~ ^[a-fA-F0-9]{2}(:[a-fA-F0-9]{2}){5}$ ]]; then
mac=""
else
mac="$(printf '%s' "$mac" | tr '[:upper:]' '[:lower:]')"
fi
fi
mkdir -p "$RUNTIME_DIR"
tmp="$(mktemp "${STATE_FILE}.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '{"gateway":"%s","interface":"%s","mac":"%s","observedAt":"%s"}\n' \
"$gateway" "$network_interface" "$mac" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$tmp"
mv "$tmp" "$STATE_FILE"

177
scripts/harbor-version.mjs Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseVersion, versionCompatibility } from '../src/shared/versions.js';
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
const VERSION_FILE = 'src/shared/versions.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const aliases = {
mac: 'macClient',
'mac-client': 'macClient',
client: 'gatewayClient',
'gateway-client': 'gatewayClient',
backend: 'gatewayBackend',
'gateway-backend': 'gatewayBackend',
};
export function versionsFromSource(source) {
return Object.fromEntries(COMPONENTS.map((component) => {
const match = new RegExp(`${component}:\\s*'(\\d+\\.\\d+\\.\\d+)'`).exec(source);
if (!match) throw new Error(`Не найдена версия ${component}`);
return [component, match[1]];
}));
}
export function affectedComponents(files) {
const affected = new Set();
const add = (...components) => components.forEach((component) => affected.add(component));
for (const file of files) {
if (file === VERSION_FILE) continue;
if (/^(package-lock\.json|src\/shared\/)/.test(file)) add(...COMPONENTS);
else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
add('macClient', 'gatewayClient');
} else if (/^src\/server\//.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)) {
add('macClient');
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
add('gatewayBackend');
}
}
return COMPONENTS.filter((component) => affected.has(component));
}
function formatVersion({ major, minor, hotfix }) {
return `${major}.${minor}.${hotfix}`;
}
export function bumpVersions(versions, level, requested = []) {
const current = Object.fromEntries(COMPONENTS.map((component) => {
const parsed = parseVersion(versions[component]);
if (!parsed) throw new Error(`Некорректная версия ${component}: ${versions[component]}`);
return [component, parsed];
}));
if (level === 'major') {
const major = Math.max(...COMPONENTS.map((component) => current[component].major)) + 1;
return Object.fromEntries(COMPONENTS.map((component) => [component, `${major}.0.0`]));
}
const targets = new Set(requested.map((target) => aliases[target] || target));
if (!targets.size) throw new Error(`${level} требует хотя бы один компонент`);
for (const target of targets) {
if (!COMPONENTS.includes(target)) throw new Error(`Неизвестный компонент: ${target}`);
}
if (level === 'minor' && (targets.has('gatewayClient') || targets.has('gatewayBackend'))) {
targets.add('gatewayClient');
targets.add('gatewayBackend');
}
if (!['minor', 'hotfix'].includes(level)) throw new Error(`Неизвестный уровень: ${level}`);
const next = { ...versions };
if (level === 'minor' && targets.has('gatewayClient')) {
const minor = Math.max(current.gatewayClient.minor, current.gatewayBackend.minor) + 1;
next.gatewayClient = `${current.gatewayClient.major}.${minor}.0`;
next.gatewayBackend = `${current.gatewayBackend.major}.${minor}.0`;
targets.delete('gatewayClient');
targets.delete('gatewayBackend');
}
for (const target of targets) {
const value = current[target];
next[target] = level === 'minor'
? `${value.major}.${value.minor + 1}.0`
: formatVersion({ ...value, hotfix: value.hotfix + 1 });
}
return next;
}
function git(args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
function changedFiles(base) {
const tracked = git(['diff', '--name-only', base, '--']).split('\n');
const untracked = git(['ls-files', '--others', '--exclude-standard']).split('\n');
return [...new Set([...tracked, ...untracked].filter(Boolean))];
}
function baselineVersions(base) {
try {
return versionsFromSource(git(['show', `${base}:${VERSION_FILE}`]));
} catch {
return null;
}
}
function compareVersions(before, after) {
const left = parseVersion(before);
const right = parseVersion(after);
if (!left || !right) return -1;
for (const key of ['major', 'minor', 'hotfix']) {
if (right[key] !== left[key]) return right[key] > left[key] ? 1 : -1;
}
return 0;
}
function validateCompatibility(versions) {
const compatibility = versionCompatibility(versions);
if (!compatibility.major) throw new Error('У всех компонентов должен совпадать major');
if (!compatibility.gateway) throw new Error('Gateway client и backend должны совпадать по major.minor');
}
function writeVersions(versions) {
const file = path.join(root, VERSION_FILE);
let source = fs.readFileSync(file, 'utf8');
for (const component of COMPONENTS) {
source = source.replace(
new RegExp(`(${component}:\\s*')\\d+\\.\\d+\\.\\d+(')`),
`$1${versions[component]}$2`,
);
}
fs.writeFileSync(file, source);
}
function printVersions(versions) {
for (const component of COMPONENTS) console.log(`${component}: ${versions[component]}`);
}
function main([command = 'check', ...args]) {
const source = fs.readFileSync(path.join(root, VERSION_FILE), 'utf8');
const current = versionsFromSource(source);
validateCompatibility(current);
if (command === 'bump') {
const next = bumpVersions(current, args[0], args.slice(1));
validateCompatibility(next);
writeVersions(next);
printVersions(next);
return;
}
const base = args[0] || 'HEAD';
const affected = affectedComponents(changedFiles(base));
if (command === 'affected') {
console.log(affected.length ? affected.join('\n') : 'Нет изменений, требующих bump.');
return;
}
if (command !== 'check') throw new Error(`Неизвестная команда: ${command}`);
const baseline = baselineVersions(base);
if (!baseline) {
console.log('Version contract создаётся впервые; baseline для bump отсутствует.');
return;
}
const missing = affected.filter((component) => compareVersions(baseline[component], current[component]) <= 0);
if (missing.length) throw new Error(`Не повышена версия: ${missing.join(', ')}`);
console.log(affected.length ? `Version check: ${affected.join(', ')}` : 'Version check: bump не требуется.');
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
main(process.argv.slice(2));
} catch (error) {
console.error(`[harbor-version] ${error.message}`);
process.exitCode = 1;
}
}

357
scripts/install-macos-client.sh Executable file
View File

@@ -0,0 +1,357 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
BRANCH="${VPN_PROXY_BRANCH:-master}"
ARCHIVE_URL="${VPN_PROXY_ARCHIVE_URL:-https://git.dokops.ru/dokril/vpn-proxy/archive/${BRANCH}.tar.gz}"
SOURCE_DIR="${VPN_PROXY_SOURCE_DIR:-}"
COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8082"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
CLIENT_CONTAINER_NAME="harbor-connect"
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
log() {
printf '[harbor-connect] %s\n' "$*"
}
die() {
printf '[harbor-connect] error: %s\n' "$*" >&2
exit 1
}
need() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
is_valid_port() {
case "$1" in
''|*[!0-9]*) return 1 ;;
esac
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
}
ask_proxy_port() {
local value=""
if [ -n "$REQUESTED_PROXY_PORT" ]; then
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$REQUESTED_PROXY_PORT"
return 0
fi
if [ -r /dev/tty ]; then
while true; do
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
IFS= read -r value </dev/tty || value=""
value="${value:-$DEFAULT_PROXY_PORT}"
if is_valid_port "$value"; then
printf '%s\n' "$value"
return 0
fi
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
done
fi
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
fi
printf '%s\n' "$DEFAULT_PROXY_PORT"
}
published_port_conflicts() {
local port="$1"
local line
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
*) printf '%s\n' "$line" ;;
esac
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
}
proxy_port_conflicts() {
published_port_conflicts "$1"
}
assert_proxy_port_available() {
local port="$1"
local conflicts
conflicts="$(proxy_port_conflicts "$port")"
if [ -z "$conflicts" ]; then
return 0
fi
printf '[harbor-connect] proxy port %s is already used:\n%s\n' \
"$port" "$conflicts" >&2
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
}
assert_single_port_available() {
local label="$1"
local port="$2"
local conflicts
conflicts="$(published_port_conflicts "$port")"
if [ -z "$conflicts" ]; then
return 0
fi
printf '[harbor-connect] %s port %s is already used:\n%s\n' \
"$label" "$port" "$conflicts" >&2
die "choose another ${label} port or stop the conflicting container"
}
first_free_port() {
local start="$1"
local port
for port in $(seq "$start" 65535); do
if [ -z "$(published_port_conflicts "$port")" ]; then
printf '%s\n' "$port"
return 0
fi
done
return 1
}
choose_ui_port() {
local value="$1"
local suggested
if ! is_valid_port "$value"; then
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
fi
if [ -z "$(published_port_conflicts "$value")" ]; then
printf '%s\n' "$value"
return 0
fi
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
assert_single_port_available "UI" "$value"
fi
suggested="$(first_free_port "$((value + 1))" || true)"
suggested="${suggested:-3457}"
while true; do
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
IFS= read -r value </dev/tty || value=""
value="${value:-$suggested}"
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
printf '%s\n' "$value"
return 0
fi
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
done
}
assert_ui_outside_proxy_range() {
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
die "UI port ${UI_PORT} overlaps proxy port"
fi
}
wait_for_client_ui() {
local ui_port="${UI_PORT:-3456}"
local ui_url="http://127.0.0.1:${ui_port}/api/state"
local attempt
for attempt in $(seq 1 30); do
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
printf '\n[harbor-connect] client did not become ready at %s\n' "$ui_url" >&2
printf '[harbor-connect] docker compose status:\n' >&2
docker compose -f "$COMPOSE_FILE" ps >&2 || true
printf '\n[harbor-connect] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 harbor-connect >&2 || true
die "client UI is not ready; see Docker status and logs above"
}
xml_escape() {
sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g' -e 's/"/\&quot;/g'
}
install_network_monitor() {
local launch_agents_dir="$HOME/Library/LaunchAgents"
local plist_path="$launch_agents_dir/${NETWORK_MONITOR_LABEL}.plist"
local escaped_script_path
local escaped_runtime_dir
local user_domain="gui/$(id -u)"
escaped_script_path="$(printf '%s' "$INSTALL_DIR/scripts/harbor-network-monitor.sh" | xml_escape)"
escaped_runtime_dir="$(printf '%s' "$INSTALL_DIR/.runtime" | xml_escape)"
mkdir -p "$INSTALL_DIR/.runtime" "$launch_agents_dir"
cat > "$plist_path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${NETWORK_MONITOR_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${escaped_script_path}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HARBOR_RUNTIME_DIR</key>
<string>${escaped_runtime_dir}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>5</integer>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
EOF
/bin/bash "$INSTALL_DIR/scripts/harbor-network-monitor.sh"
launchctl bootout "$user_domain" "$plist_path" >/dev/null 2>&1 || true
launchctl bootstrap "$user_domain" "$plist_path"
log "automatic Gateway detection enabled"
}
set_env_value() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
if [ -f .env ] && grep -q "^${key}=" .env; then
awk -v key="$key" -v value="$value" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print key "=" value; next }
{ print }
' .env > "$tmp"
else
[ -f .env ] && cat .env > "$tmp"
printf '%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv "$tmp" .env
}
get_env_value() {
local key="$1"
[ -f .env ] || return 0
awk -v key="$key" '
BEGIN { prefix = key "=" }
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
' .env
}
copy_source() {
local source_dir="$1"
[ -f "$source_dir/docker-compose.client.yml" ] || die "invalid Harbor source archive"
log "installing files to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp -R "$source_dir/." "$INSTALL_DIR/"
}
download_source() {
local tmp_dir
tmp_dir="$(mktemp -d)"
mkdir -p "$tmp_dir/source"
log "downloading $ARCHIVE_URL"
if ! curl -fsSL "$ARCHIVE_URL" | tar -xzf - -C "$tmp_dir/source" --strip-components=1; then
rm -rf "$tmp_dir"
die "failed to download Harbor source"
fi
copy_source "$tmp_dir/source"
rm -rf "$tmp_dir"
}
if [[ "$(uname -s)" != "Darwin" ]]; then
die "this installer is intended for macOS"
fi
need docker
need curl
need tar
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"
if [[ -n "$SOURCE_DIR" ]]; then
copy_source "$SOURCE_DIR"
elif [[ -d "$INSTALL_DIR/.git" ]]; then
need git
log "updating $INSTALL_DIR"
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
git -C "$INSTALL_DIR" checkout "$BRANCH"
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
else
mkdir -p "$(dirname "$INSTALL_DIR")"
download_source
fi
cd "$INSTALL_DIR"
if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env
fi
PROXY_PORT="$(ask_proxy_port)"
assert_proxy_port_available "$PROXY_PORT"
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
UI_PORT="${UI_PORT:-3456}"
UI_PORT="$(choose_ui_port "$UI_PORT")"
assert_ui_outside_proxy_range
set_env_value APP_MODE client
set_env_value CLIENT_UI_PORT "$UI_PORT"
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
set_env_value PROXY_PORT "$PROXY_PORT"
log "UI port: http://127.0.0.1:${UI_PORT}"
log "proxy port: 127.0.0.1:${PROXY_PORT}"
install_network_monitor
log "building and starting Docker client"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
wait_for_client_ui
cat <<EOF
Harbor Connect is running.
UI:
http://127.0.0.1:${UI_PORT}
Proxy:
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
Useful commands:
cd ~/.vpn-proxy-client
docker compose -f docker-compose.client.yml logs -f
docker compose -f docker-compose.client.yml restart
docker compose -f docker-compose.client.yml down
Optional macOS system proxy example:
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
Disable later:
networksetup -setwebproxystate Wi-Fi off
networksetup -setsecurewebproxystate Wi-Fi off
networksetup -setsocksfirewallproxystate Wi-Fi off
EOF

View File

@@ -1,118 +0,0 @@
# ==========================================
# 🛠️ COMMON UTILS
# ==========================================
# --- ГЛОБАЛЬНЫЕ НАСТРОЙКИ ---
# Режим отладки (передаётся через -Debug)
if (-not (Test-Path variable:script:DebugMode)) {
$script:DebugMode = $false
}
function Set-DebugMode {
param([bool]$Enabled)
$script:DebugMode = $Enabled
if ($Enabled) {
Write-Host " 🔧 Debug режим включён" -ForegroundColor Magenta
}
}
function Get-DebugMode {
return $script:DebugMode
}
# --- ЦВЕТА И ВЫВОД ---
function Write-Step { param($msg) Write-Host "`n📦 $msg" -ForegroundColor Cyan }
function Write-Success { param($msg) Write-Host "$msg" -ForegroundColor Green }
function Write-Warning { param($msg) Write-Host " ⚠️ $msg" -ForegroundColor Yellow }
function Write-Error { param($msg) Write-Host "$msg" -ForegroundColor Red }
function Write-Info { param($msg) Write-Host " $msg" -ForegroundColor Gray }
function Write-DebugLog {
param($msg)
if ($script:DebugMode) {
Write-Host " [DEBUG] $msg" -ForegroundColor DarkGray
}
}
function Write-Header {
param($Title, [switch]$ClearScreen)
if ($ClearScreen -and -not $script:DebugMode) {
Clear-Host
}
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host " $Title" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
}
# --- ЗАПУСК КОМАНД ---
function Invoke-Silent {
param(
[string]$FilePath,
[string]$Arguments,
[switch]$Wait
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.Arguments = $Arguments
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
if (-not $script:DebugMode) {
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
}
$process = [System.Diagnostics.Process]::Start($psi)
if ($Wait) {
$process.WaitForExit()
return $process.ExitCode
}
return $process
}
# --- ПОЛЕЗНЫЕ ФУНКЦИИ ---
function Get-ScriptDirectory {
if ($PSScriptRoot) { return $PSScriptRoot }
return Split-Path -Parent $MyInvocation.MyCommand.Path
}
function Ensure-Admin {
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
if (-not $isAdmin) {
Write-Host "⛔ Требуются права АДМИНИСТРАТОРА!" -ForegroundColor Red
Write-Host " Пожалуйста, запустите скрипт от имени администратора." -ForegroundColor Gray
Start-Sleep -Seconds 3
exit 1
}
}
function Show-Menu {
param(
[string]$Title,
[System.Collections.Specialized.OrderedDictionary]$Options,
[string]$Prompt = "👉 Ваш выбор"
)
if ($Title) {
Write-Host "`n$Title" -ForegroundColor Yellow
}
$keys = $Options.Keys
foreach ($key in $keys) {
Write-Host " [$key] $($Options[$key])" -ForegroundColor White
}
Write-Host ""
return Read-Host "$Prompt"
}

View File

@@ -1,140 +0,0 @@
# ==========================================
# 🌐 NET UTILS
# ==========================================
# --- CONFIG ---
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# --- ФУНКЦИИ ---
$script:HwidFile = "C:\Tools\sing-box\hwid"
$script:AppName = "VPN-Proxy-Control by Dokril"
function Get-HWID {
# Генерация или чтение HWID из файла
if (Test-Path $script:HwidFile) {
return (Get-Content $script:HwidFile -Raw).Trim()
}
# Генерируем новый HWID
$hwid = [Guid]::NewGuid().ToString("N").Substring(0, 16)
# Сохраняем
$dir = Split-Path $script:HwidFile -Parent
if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
Set-Content -Path $script:HwidFile -Value $hwid
return $hwid
}
function Get-SubscriptionHeaders {
# Формируем заголовки как в server.py
$osName = "windows"
$osVersion = [Environment]::OSVersion.Version.ToString()
return @{
"User-Agent" = "singbox"
"x-hwid" = (Get-HWID)
"x-device-os" = $osName
"x-ver-os" = $osVersion
"x-device-model" = $script:AppName
}
}
function Download-File {
param(
[string]$Url,
[string]$Destination,
[string]$UserAgent = "VPN-Proxy-Installer"
)
try {
$req = [System.Net.HttpWebRequest]::Create($Url)
$req.UserAgent = $UserAgent
$resp = $req.GetResponse()
$stream = $resp.GetResponseStream()
$fs = [System.IO.File]::Create($Destination)
$msgLen = $resp.ContentLength
$buffer = New-Object byte[] 10240
$count = 0
$total = 0
do {
$count = $stream.Read($buffer, 0, $buffer.Length)
$fs.Write($buffer, 0, $count)
$total += $count
# Можно добавить прогресс бар, но пока просто качаем
} while ($count -gt 0)
$fs.Close()
$stream.Close()
$resp.Close()
return $true
}
catch {
Write-Error "Ошибка скачивания: $_"
return $false
}
}
function Get-SubscriptionData {
param(
[string]$Url,
[string]$UserAgent = "singbox",
$Headers = @{}
)
Write-Info "Загружаю подписку..."
$rawContent = $null
$userInfo = @{}
# 1. Получаем ответ
try {
$response = Invoke-WebRequest -Uri $Url -Headers $Headers -TimeoutSec 15 -UseBasicParsing
$rawContent = $response.Content
# Парсим subscription-userinfo header
$userInfoHeader = $response.Headers["subscription-userinfo"]
if ($userInfoHeader) {
$parts = $userInfoHeader -split ";"
foreach ($part in $parts) {
if ($part -match "(\w+)=(\d+)") {
$userInfo[$matches[1]] = [int64]$matches[2]
}
}
}
}
catch {
return @{
success = $false
error = "Ошибка загрузки: $($_.Exception.Message)"
rawContent = $null
}
}
# 2. Пробуем парсить как JSON
try {
$config = $rawContent | ConvertFrom-Json
return @{
success = $true
config = $config
rawContent = $rawContent
userInfo = $userInfo
}
}
catch {
# JSON не распарсился — возвращаем rawContent для дальнейшей обработки
return @{
success = $false
error = "Ответ не в формате JSON (возможно Base64 или список ссылок)"
rawContent = $rawContent
userInfo = $userInfo
}
}
}

View File

@@ -1,138 +0,0 @@
# ==========================================
# 🖥️ SYSTEM UTILS
# ==========================================
# --- СИСТЕМНАЯ ИНФОРМАЦИЯ ---
function Get-SystemInfo {
return @{
os = "windows"
version = [System.Environment]::OSVersion.Version.Major.ToString()
}
}
# --- DOCKER ---
function Test-Docker {
$status = @{
Installed = $false
Running = $false
Compose = $false
}
try {
$ver = docker --version 2>&1
if ($LASTEXITCODE -eq 0) { $status.Installed = $true }
}
catch {}
if ($status.Installed) {
try {
$info = docker info 2>&1
if ($LASTEXITCODE -eq 0) { $status.Running = $true }
}
catch {}
}
if ($status.Running) {
try {
$comp = docker compose version 2>&1
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
}
catch {
# Check legacy
try {
$comp = docker-compose --version 2>&1
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
}
catch {}
}
}
return $status
}
# --- СЛУЖБЫ И ЗАДАЧИ ---
function Manage-ScheduledTask {
param(
[string]$Name,
[string]$ExePath,
[string]$Arguments,
[string]$WorkDir,
[string]$Action = "Install" # Install, Uninstall, Start, Stop
)
switch ($Action) {
"Install" {
# Удаляем старую
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
$act = New-ScheduledTaskAction -Execute "$ExePath" -Argument "$Arguments" -WorkingDirectory $WorkDir
$trig = New-ScheduledTaskTrigger -AtStartup
$princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$sett = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $Name -Action $act -Trigger $trig -Principal $princ -Settings $sett -Force | Out-Null
return $true
}
"Uninstall" {
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
}
"Start" {
Start-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
}
"Stop" {
Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
# Пытаемся убить процесс по имени exe
if ($ExePath) {
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExePath)
if ($procName) {
Stop-Process -Name $procName -Force -ErrorAction SilentlyContinue
}
}
}
}
}
function Get-TaskStatus {
param([string]$Name)
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
if ($task) {
# Если задача в статусе Running — возвращаем Running
if ($task.State -eq "Running") {
return "Running"
}
# Если задача Ready — проверяем, работает ли процесс sing-box
# (scheduled task может быть Ready даже когда процесс работает)
$process = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue
if ($process) {
return "Running"
}
return $task.State
}
return $null
}
function Ensure-FirewallPort {
param(
[int]$Port,
[string]$Name,
[string]$Protocol = "TCP"
)
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
if (-not $rule) {
New-NetFirewallRule -DisplayName $Name -Direction Inbound -LocalPort $Port -Protocol $Protocol -Action Allow -Profile Any | Out-Null
return $true
}
return $false
}
function Get-LocalIPs {
return (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias * | Where-Object { $_.IPAddress -notmatch "^127\." -and $_.IPAddress -notmatch "^169\.254\." }).IPAddress
}

View File

@@ -1,274 +0,0 @@
# ==========================================
# 🎮 DISCORD PROXY SETUP
# ==========================================
param(
[switch]$Force,
[switch]$Debug
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$ScriptDir\lib\Common.ps1"
. "$ScriptDir\lib\Net.ps1"
. "$ScriptDir\lib\System.ps1"
if ($Debug) { Set-DebugMode -Enabled $true }
Write-Header "НАСТРОЙКА DISCORD / VESKTOP" -ClearScreen
Ensure-Admin
$InstallPath = "C:\Tools\ProxiFyre"
$ConfigPath = "$InstallPath\app-config.json"
$DriverUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi"
$AppUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.1.4/ProxiFyre-v2.1.4-x64-signed.zip"
# --- ФУНКЦИИ ---
function Test-ProxyConnection {
param([string]$ProxyAddr)
Write-Info "Проверка подключения к прокси $ProxyAddr..."
try {
$parts = $ProxyAddr -split ":"
$host_ = $parts[0]
$port = [int]$parts[1]
# 1. Проверяем TCP соединение
$tcp = New-Object System.Net.Sockets.TcpClient
$tcp.Connect($host_, $port)
$tcp.Close()
Write-Success "TCP соединение установлено"
# 2. Пробуем получить внешний IP через прокси (используем curl для SOCKS5)
try {
$result = & curl.exe -s -x "socks5://$ProxyAddr" "http://v4.ident.me" --connect-timeout 5 2>$null
if ($result -match "^\d+\.\d+\.\d+\.\d+$") {
Write-Success "Внешний IP через прокси: $result"
return $true
}
}
catch {}
Write-Warning "TCP работает, но не удалось получить IP. Возможно прокси не полностью настроен."
return $true
}
catch {
Write-Error "Не удалось подключиться к $ProxyAddr"
Write-Host " Убедитесь, что прокси запущен и доступен." -ForegroundColor Gray
return $false
}
}
function Get-CurrentConfig {
if (Test-Path $ConfigPath) {
try {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
return @{
Apps = $cfg.proxies[0].appNames -join ", "
Proxy = $cfg.proxies[0].socks5ProxyEndpoint
}
}
catch {}
}
return $null
}
function Install-ProxiFyre {
# Установка драйвера
Write-Step "Установка драйвера..."
$msi = "$env:TEMP\WinpkFilter.msi"
if (Download-File -Url $DriverUrl -Destination $msi) {
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait
Write-Success "Драйвер готов"
}
# Установка ProxiFyre
Write-Step "Установка ProxiFyre..."
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
$zip = "$env:TEMP\ProxiFyre.zip"
if (Download-File -Url $AppUrl -Destination $zip) {
Expand-Archive -Path $zip -DestinationPath $InstallPath -Force
$exe = Get-ChildItem $InstallPath -Recurse -Filter "ProxiFyre.exe" | Select -First 1
if ($exe.DirectoryName -ne $InstallPath) {
Copy-Item "$($exe.DirectoryName)\*" $InstallPath -Recurse -Force
}
Write-Success "Распаковано"
}
}
function Configure-And-Start {
param($TargetApps, $ProxyAddr)
# Конфиг
$cfg = @{
logLevel = "Info"
proxies = @(@{
appNames = $TargetApps
socks5ProxyEndpoint = $ProxyAddr
supportedProtocols = @("TCP", "UDP")
})
excludes = @()
}
$cfg | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath -Encoding UTF8
# Служба
Write-Step "Перезапуск службы..."
if (Get-DebugMode) {
& "$InstallPath\ProxiFyre.exe" stop
& "$InstallPath\ProxiFyre.exe" install
& "$InstallPath\ProxiFyre.exe" start
}
else {
& "$InstallPath\ProxiFyre.exe" stop 2>&1 | Out-Null
& "$InstallPath\ProxiFyre.exe" install 2>&1 | Out-Null
& "$InstallPath\ProxiFyre.exe" start 2>&1 | Out-Null
}
Write-Success "Готово! Discord должен работать через прокси."
}
function Select-Apps {
Write-Host "`n🎮 Какие приложения проксировать?" -ForegroundColor Yellow
$appOpts = [Ordered]@{
"1" = "Discord"
"2" = "Vesktop"
"3" = "Discord + Vesktop"
}
$appChoice = Show-Menu -Options $appOpts
$result = switch ($appChoice) {
"1" { @("Discord") }
"2" { @("Vesktop") }
"3" { @("Vesktop", "Discord") }
default { @("Discord") }
}
return $result
}
function Get-ProxyAddress {
# Проверяем локальный sing-box
$singboxStatus = Get-TaskStatus -Name "SingBoxProxy"
$localProxy = "127.0.0.1:1080"
if ($singboxStatus -eq "Running") {
Write-Info "Обнаружен работающий VPN клиент (Sing-box)."
Write-Host " Рекомендуется использовать локальный прокси: " -NoNewline -ForegroundColor Gray
Write-Host $localProxy -ForegroundColor Green
$useLocal = Read-Host " Использовать локальный? (y/n) [y]"
if ($useLocal -ne 'n') {
return $localProxy
}
}
else {
Write-Warning "VPN клиент не запущен!"
Write-Host " Вы можете указать адрес удалённого прокси." -ForegroundColor Gray
}
# Запрашиваем адрес
while ($true) {
$proxyAddr = Read-Host "`n Введите адрес прокси (IP:порт)"
if ([string]::IsNullOrWhiteSpace($proxyAddr)) {
Write-Warning "Адрес не указан"
continue
}
if ($proxyAddr -notmatch "^[\d\.]+:\d+$") {
Write-Error "Неверный формат. Ожидается: IP:порт (например 192.168.1.100:1080)"
continue
}
# Проверяем подключение
if (Test-ProxyConnection -ProxyAddr $proxyAddr) {
return $proxyAddr
}
$retry = Read-Host " Попробовать другой адрес? (y/n)"
if ($retry -ne 'y') { return $null }
}
}
# --- MAIN ---
$isInstalled = Test-Path "$InstallPath\ProxiFyre.exe"
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
$currentConfig = Get-CurrentConfig
if ($isInstalled -and $currentConfig -and -not $Force) {
# Уже установлено — показываем меню управления
Write-Info "ProxiFyre уже установлен."
Write-Host ""
Write-Host " Статус: " -NoNewline -ForegroundColor Gray
if ($discSvc.Status -eq 'Running') {
Write-Host "АКТИВЕН" -ForegroundColor Green
}
else {
Write-Host "ОСТАНОВЛЕН" -ForegroundColor Yellow
}
Write-Host " Приложения: $($currentConfig.Apps)" -ForegroundColor Gray
Write-Host " Прокси: $($currentConfig.Proxy)" -ForegroundColor Gray
Write-Host ""
$opts = [Ordered]@{
"1" = "Изменить настройки (приложения/прокси)"
"2" = "Проверить подключение к прокси"
"3" = "Перезапустить службу"
"4" = "Остановить службу"
"5" = "Переустановить"
"b" = "Назад"
}
$action = Show-Menu -Options $opts
switch ($action) {
"1" {
$targetApps = Select-Apps
$proxyAddr = Get-ProxyAddress
if ($proxyAddr) {
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
}
}
"2" {
Test-ProxyConnection -ProxyAddr $currentConfig.Proxy | Out-Null
}
"3" {
Write-Step "Перезапуск службы..."
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "start" -Wait -NoNewWindow
Write-Success "Перезапущено!"
}
"4" {
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
Write-Success "Остановлено!"
}
"5" {
$Force = $true
}
"b" { exit }
}
if (-not $Force) {
Start-Sleep -Seconds 2
exit
}
}
# --- НОВАЯ УСТАНОВКА ---
if (-not $isInstalled -or $Force) {
Install-ProxiFyre
}
$targetApps = Select-Apps
$proxyAddr = Get-ProxyAddress
if (-not $proxyAddr) {
Write-Error "Прокси не настроен. Выход."
Start-Sleep -Seconds 2
exit
}
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
Start-Sleep -Seconds 3

View File

@@ -1,417 +0,0 @@
# ==========================================
# 📦 SING-BOX NATIVE INSTALLER
# ==========================================
param(
[switch]$Force,
[switch]$Debug,
[string]$SubscriptionUrl = ""
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$ScriptDir\lib\Common.ps1"
. "$ScriptDir\lib\Net.ps1"
. "$ScriptDir\lib\System.ps1"
# --- CONFIG ---
$SingboxVersion = "1.11.4"
$InstallDir = "C:\Tools\sing-box"
$LocalProxyPort = 1080
$SingboxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingboxVersion/sing-box-$SingboxVersion-windows-amd64.zip"
$TaskName = "SingBoxProxy"
Ensure-Admin
# --- LOGIC ---
function Select-Server {
param($Config)
$outbounds = $Config.outbounds
$servers = @()
foreach ($outbound in $outbounds) {
if ($outbound.type -in @("vless", "vmess", "trojan", "shadowsocks", "hysteria2")) {
$servers += @{
tag = $outbound.tag
type = $outbound.type
server = $outbound.server
server_port = $outbound.server_port
outbound = $outbound
}
}
}
if ($servers.Count -eq 0) {
Write-Error "Серверы не найдены в подписке!"
return $null
}
$options = [Ordered]@{}
for ($i = 0; $i -lt $servers.Count; $i++) {
$s = $servers[$i]
$options["$($i+1)"] = "$($s.tag) ($($s.server):$($s.server_port))"
}
$choice = Show-Menu -Title "🌐 Доступные серверы" -Options $options -Prompt "👉 Выберите сервер (номер)"
$index = [int]$choice - 1
if ($index -lt 0 -or $index -ge $servers.Count) {
Write-Error "Неверный выбор!"
return $null
}
return $servers[$index]
}
function New-SingboxConfig {
param($Outbound, $Port)
return @{
log = @{ level = "info"; timestamp = $true }
dns = @{ independent_cache = $true }
inbounds = @(
@{
type = "socks"
tag = "socks-in"
listen = "0.0.0.0"
listen_port = $Port
}
)
outbounds = @(
$Outbound,
@{ type = "direct"; tag = "direct" }
)
route = @{
final = $Outbound.tag
auto_detect_interface = $true
}
}
}
function Parse-VlessUrl {
param([string]$Url)
if (-not $Url.StartsWith("vless://")) { throw "URL должен начинаться с vless://" }
# Remove scheme
$raw = $Url.Substring(8)
# Split fragment
$tag = "reality"
if ($raw -match "#(.*)$") {
$tag = [System.Web.HttpUtility]::UrlDecode($matches[1])
$raw = $raw -replace "#.*$", ""
}
# Split query
$queryStr = ""
if ($raw -match "\?(.*)$") {
$queryStr = $matches[1]
$raw = $raw -replace "\?.*$", ""
}
# Parse UUID@HOST:PORT
if ($raw -notmatch "([^@]+)@([^:]+):(\d+)") { throw "Неверный формат vless (ожидается uuid@host:port)" }
$uuid = $matches[1][0]
$serverHost = $matches[2][0]
$port = [int]$matches[3][0] # Fix for regex object access in PS
if (-not $uuid) {
# Fallback if regex returns match info differently in different PS versions
$uuid = $matches[1]
$serverHost = $matches[2]
$port = [int]$matches[3]
}
# Parse Query
$params = @{}
if ($queryStr) {
$parts = $queryStr -split "&"
foreach ($p in $parts) {
$kv = $p -split "="
if ($kv.Count -eq 2) {
$params[[System.Web.HttpUtility]::UrlDecode($kv[0])] = [System.Web.HttpUtility]::UrlDecode($kv[1])
}
}
}
# Extract
$pbk = if ($params["pbk"]) { $params["pbk"] } else { throw "Отсутствует параметр pbk (Public Key)" }
$sid = if ($params["sid"]) { $params["sid"] } else { throw "Отсутствует параметр sid (Short ID)" }
$sni = if ($params["sni"]) { $params["sni"] } else { $serverHost }
$fp = if ($params["fp"]) { $params["fp"] } else { "chrome" }
$flow = if ($params["flow"]) { $params["flow"] } else { "" }
return @{
uuid = $uuid
server = $serverHost
server_port = $port
tag = $tag
public_key = $pbk
short_id = $sid
server_name = $sni
fingerprint = $fp
flow = $flow
}
}
# --- MAIN ---
if ($Debug) { Set-DebugMode -Enabled $true }
Write-Header "NATIVE SING-BOX (UDP ПОДДЕРЖКА)" -ClearScreen
$taskStatus = Get-TaskStatus -Name $TaskName
if ($taskStatus -and -not $Force) {
Write-Info "Sing-box уже установлен."
Write-Host " Статус: $taskStatus" -ForegroundColor ($taskStatus -eq "Running" ? "Green" : "Red")
Write-Host ""
$opts = [Ordered]@{
"1" = "Сменить сервер (из подписки)"
"2" = "Ввести новую ссылку на подписку"
"3" = "Перезапустить службу"
"4" = "Остановить службу"
"5" = "Показать конфиг"
"6" = "Переустановить"
"b" = "Назад"
}
$act = Show-Menu -Options $opts
switch ($act) {
"1" {
# Reload existing sub logic could be added here, currently just re-runs install flow partially
# Simplification: treat as new setup but try to load saved sub url
$Force = $true
}
"2" { $SubscriptionUrl = ""; $Force = $true }
"3" { Manage-ScheduledTask -Name $TaskName -Action "Start"; Write-Success "Запущено!"; exit }
"4" { Manage-ScheduledTask -Name $TaskName -Action "Stop"; Write-Success "Остановлено!"; exit }
"5" { Get-Content "$InstallDir\config.json"; exit }
"6" { $Force = $true }
"b" { exit }
}
}
if ($Force -or -not $taskStatus) {
# 1. Загрузка
Write-Step "Установка Sing-box..."
if (!(Test-Path "$InstallDir\sing-box.exe")) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$zipCtx = "$env:TEMP\sing-box.zip"
if (Download-File -Url $SingboxUrl -Destination $zipCtx) {
Expand-Archive -Path $zipCtx -DestinationPath $env:TEMP -Force
$extracted = Get-ChildItem "$env:TEMP\sing-box-*" -Directory | Select -First 1
Copy-Item "$($extracted.FullName)\sing-box.exe" "$InstallDir\sing-box.exe" -Force
Remove-Item $zipCtx; Remove-Item $extracted.FullName -Recurse -Force
Write-Success "Sing-box скачан"
}
else {
Read-Host "Нажмите Enter для выхода..."
exit 1
}
}
# 2. Подписка
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
# Try load saved
$savedSub = "$InstallDir\sub_info.json"
if (Test-Path $savedSub) {
try {
$json = Get-Content $savedSub -Raw | ConvertFrom-Json
if ($json.url) {
Write-Info "Найдена сохраненная подписка: $($json.url)"
if ((Read-Host "Использовать? (y/n)") -eq 'y') { $SubscriptionUrl = $json.url }
}
}
catch {}
}
}
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
$SubscriptionUrl = Read-Host "`n🔗 Введите URL подписки (VLESS)"
}
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
Write-Error "Url не указан"
Read-Host "Нажмите Enter для выхода..."
exit
}
# --- PARSING ---
$data = @{ success = $false; config = $null; error = "" }
if ($SubscriptionUrl.StartsWith("vless://")) {
try {
$p = Parse-VlessUrl -Url $SubscriptionUrl
$outbound = [Ordered]@{
type = "vless"
tag = $p.tag
server = $p.server
server_port = $p.server_port
uuid = $p.uuid
flow = $p.flow
tls = @{
enabled = $true
server_name = $p.server_name
utls = @{ enabled = $true; fingerprint = $p.fingerprint }
reality = @{
enabled = $true
public_key = $p.public_key
short_id = $p.short_id
}
}
packet_encoding = "xudp"
}
$data.success = $true
$data.config = @{ outbounds = @($outbound) }
}
catch {
$data.error = $_.Exception.Message
}
}
else {
$data = Get-SubscriptionData -Url $SubscriptionUrl -Headers (Get-SubscriptionHeaders)
}
# --- PARSING LOGIC ENHANCEMENT ---
if (-not $data.success) {
# Fallback: Try to handle non-JSON body (Base64 or Plain Text)
try {
Write-Info "JSON парсинг не удался, пробую как список ссылок..."
$content = $data.rawContent
# Base64 decode if needed
if ($content -match "^[A-Za-z0-9+/=]+$") {
try {
$bytes = [System.Convert]::FromBase64String($content)
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
}
catch {}
}
# Try to find vless:// links
$links = $content -split "[\r\n]+" | Where-Object { $_ -match "^vless://" }
if ($links.Count -gt 0) {
Write-Success "Найдено ссылок: $($links.Count)"
# Mock a config object with these links as "outbounds"
# Note: We can't fully parsing VLESS query params in pure PS easily without a lot of regex
# So we will try a simpler approach: Let sing-box do it? No, sing-box needs config.
# WORKAROUND: Create a minimal outbound for each link
# Parsing `vless://UUID@HOST:PORT?security=reality&...#NAME`
$parsedOutbounds = @()
foreach ($link in $links) {
if ($link -match "vless://([^@]+)@([^:]+):(\d+)(\?.*)?(#.*)?") {
$uuid = $matches[1]
$server = $matches[2]
$port = [int]$matches[3]
$query = $matches[4]
$hash = $matches[5]
$tag = if ($hash) { $hash.Substring(1) } else { "${server}:${port}" }
$tag = [System.Web.HttpUtility]::UrlDecode($tag)
# Parse Query Params
$flow = ""; $fp = ""; $pbk = ""; $sid = ""; $sni = ""; $serviceName = ""
if ($query) {
if ($query -match "flow=([^&]+)") { $flow = $matches[1] }
if ($query -match "fp=([^&]+)") { $fp = $matches[1] }
if ($query -match "pbk=([^&]+)") { $pbk = $matches[1] }
if ($query -match "sid=([^&]+)") { $sid = $matches[1] }
if ($query -match "sni=([^&]+)") { $sni = $matches[1] }
if ($query -match "serviceName=([^&]+)") { $serviceName = $matches[1] }
}
# Construct Sing-box outbound (REALITY based assumption for modern vless)
$out = [Ordered]@{
type = "vless"
tag = $tag
server = $server
server_port = $port
uuid = $uuid
flow = $flow
tls = @{
enabled = $true
server_name = $sni
utls = @{ enabled = $true; fingerprint = $fp }
reality = @{
enabled = $true
public_key = $pbk
short_id = $sid
}
}
packet_encoding = "xudp"
}
$parsedOutbounds += $out
}
}
if ($parsedOutbounds.Count -gt 0) {
$data.success = $true
$data.config = @{ outbounds = $parsedOutbounds }
$data.error = $null
}
else {
throw "Не удалось распарсить VLESS ссылки"
}
}
else {
throw $data.error
}
}
catch {
Write-Error "Ошибка обработки подписки: $_"
Write-Host " Скрипт поддерживает: SIP008 (JSON) или список VLESS+Reality ссылок." -ForegroundColor Yellow
Read-Host "Нажмите Enter для выхода..."
exit
}
}
# Save sub info
@{ url = $SubscriptionUrl } | ConvertTo-Json | Set-Content "$InstallDir\sub_info.json"
# 3. Выбор сервера
$server = Select-Server -Config $data.config
if (!$server) {
Read-Host "Нажмите Enter для выхода..."
exit
}
# 4. Конфиг
$cfg = New-SingboxConfig -Outbound $server.outbound -Port $LocalProxyPort
$cfg | ConvertTo-Json -Depth 10 | Set-Content "$InstallDir\config.json" -Encoding UTF8
# 5. Задача
Manage-ScheduledTask -Name $TaskName -ExePath "$InstallDir\sing-box.exe" -Arguments "run -c `"$InstallDir\config.json`"" -WorkDir $InstallDir -Action "Install"
Manage-ScheduledTask -Name $TaskName -Action "Start"
# 6. Firewall
if (Ensure-FirewallPort -Port $LocalProxyPort -Name "SingBox-Proxy-Port") {
Write-Success "Правило Firewall создано (порт $LocalProxyPort)"
}
Write-Success "Успешно установлено и запущено!"
Write-Info "Локальный прокси: 127.0.0.1:$LocalProxyPort"
$ips = Get-LocalIPs
if ($ips) {
Write-Info "Доступно из сети по адресам:"
foreach ($ip in $ips) {
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Gray
}
}
Start-Sleep -Seconds 3
}

View File

@@ -1,58 +0,0 @@
# ==========================================
# 🗑️ UNINSTALL ALL (CLEANUP)
# ==========================================
param([switch]$Debug)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$ScriptDir\lib\Common.ps1"
. "$ScriptDir\lib\System.ps1"
if ($Debug) { Set-DebugMode -Enabled $true }
Write-Header "ПОЛНОЕ УДАЛЕНИЕ" -ClearScreen
Ensure-Admin
Write-Warning "Это действие удалит весь установленный софт:"
Write-Host " - Sing-box (Служба и файлы)" -ForegroundColor Gray
Write-Host " - ProxiFyre (Служба и файлы)" -ForegroundColor Gray
Write-Host " - Драйвер WinPacketFilter" -ForegroundColor Gray
Write-Host ""
if ((Read-Host "Вы уверены? (y/n)") -ne 'y') { exit }
Write-Step "Удаление Sing-box..."
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Stop"
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Uninstall"
if (Test-Path "C:\Tools\sing-box") {
Remove-Item "C:\Tools\sing-box" -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Файлы удалены"
}
Write-Step "Удаление Discord Proxy (ProxiFyre)..."
$pfDir = "C:\Tools\ProxiFyre"
if (Test-Path "$pfDir\ProxiFyre.exe") {
if (Get-DebugMode) {
& "$pfDir\ProxiFyre.exe" uninstall
}
else {
& "$pfDir\ProxiFyre.exe" uninstall 2>&1 | Out-Null
}
Start-Sleep -Seconds 2
Write-Success "Служба удалена"
}
if (Test-Path $pfDir) {
Remove-Item $pfDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Файлы удалены"
}
Write-Step "Удаление драйвера..."
# Тут сложно удалить MSI тихо без GUID, но попробуем через known path или пропустим, т.к. драйвер может быть нужен другим
Write-Info "Драйвер WinPacketFilter оставлен (он может использоваться другим ПО)."
Write-Info "Если нужно, удалите его через 'Установка и удаление программ'."
Write-Success "Очистка завершена!"
Start-Sleep -Seconds 3

36
src/server/config.js Normal file
View File

@@ -0,0 +1,36 @@
import path from "node:path";
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const parsePort = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback;
};
const proxyPort = parsePort(
process.env.PROXY_PORT,
process.env.APP_MODE === "client" ? 8082 : 8080,
);
export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: parsePort(process.env.PORT, 3456),
proxyPort,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,
distDir: process.env.DIST_DIR || "/app/dist",
configPath:
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
statePath: path.join(dataDir, "state.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hostNetworkStatePath:
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
hwidPath: path.join(dataDir, "hwid"),
logLevel: process.env.LOG_LEVEL || "info",
appName: "VPN Proxy Gateway",
};

74
src/server/dataplane.js Normal file
View File

@@ -0,0 +1,74 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js';
const socketPath = settings.dataplaneSocket;
const runtime = createSingboxRuntime({
configPath: settings.configPath,
gateway: true,
tproxyChain: settings.tproxyChain,
});
const versionInfo = buildVersionInfo('gateway');
let ready = false;
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(payload));
}
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/status') {
return sendJson(res, ready ? 200 : 503, {
...await runtime.refresh(),
gatewayBackendVersion: versionInfo.components.gatewayBackend,
singBoxVersion: versionInfo.runtime.singBox,
ready,
});
}
if (req.method === 'POST' && req.url === '/apply') {
return sendJson(res, 200, await runtime.apply());
}
if (req.method === 'POST' && req.url === '/restart') {
return sendJson(res, 200, await runtime.restart());
}
if (req.method === 'POST' && req.url === '/stop') {
return sendJson(res, 200, await runtime.stop());
}
return sendJson(res, 404, { error: 'Не найдено' });
} catch (error) {
return sendJson(res, 500, { error: error.message || String(error) });
}
});
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
fs.rmSync(socketPath, { force: true });
server.listen(socketPath, async () => {
fs.chmodSync(socketPath, 0o660);
try {
await runtime.apply();
} catch (error) {
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
} finally {
ready = true;
console.log(`[dataplane] control socket: ${socketPath}`);
}
});
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
ready = false;
await runtime.shutdown();
server.close(() => {
fs.rmSync(socketPath, { force: true });
process.exit(0);
});
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@@ -0,0 +1,51 @@
import http from 'node:http';
import { HarborError } from '../shared/errors.js';
function request(socketPath, pathname, method = 'GET') {
return new Promise((resolve, reject) => {
const req = http.request({ socketPath, path: pathname, method }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
let body = {};
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
} catch {
return reject(new Error('Dataplane вернул невалидный JSON'));
}
if ((res.statusCode || 500) >= 400) {
return reject(new Error(body.error || `Dataplane HTTP ${res.statusCode}`));
}
resolve(body);
});
});
req.on('error', reject);
req.setTimeout(6000, () => req.destroy(new Error('Dataplane не ответил за 6 секунд')));
req.end();
});
}
export function createDataplaneClient(socketPath, send = request) {
let current = { running: false, startedAt: null };
const update = async (pathname, method) => {
try {
current = await send(socketPath, pathname, method);
return current;
} catch (cause) {
if (pathname === '/apply' || pathname === '/restart') {
throw new HarborError('PROCESS_START_FAILED', { cause });
}
throw cause;
}
};
return {
get running() { return Boolean(current.running); },
get startedAt() { return current.startedAt || null; },
refresh: () => update('/status', 'GET'),
apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'),
stop: () => update('/stop', 'POST'),
shutdown: async () => current,
};
}

View File

@@ -0,0 +1,228 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { HarborError } from '../shared/errors.js';
const NONCE_RE = /^[a-f0-9]{32}$/;
const PROOF_RE = /^[a-f0-9]{64}$/;
const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/;
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
function isIpv4(value) {
const parts = String(value || '').split('.');
return parts.length === 4 && parts.every((part) => (
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
));
}
function subscriptionSecret(subscriptionUrl) {
try {
const url = new URL(String(subscriptionUrl || '').trim());
const pathSegments = url.pathname.split('/').filter(Boolean);
const candidates = [
url.username,
url.password,
...[...url.searchParams.entries()]
.filter(([key]) => SECRET_QUERY_KEYS.has(key.toLowerCase()))
.map(([, value]) => value),
pathSegments.at(-1),
]
.map((value) => String(value || '').trim())
.filter((value) => value.length >= 16);
if (!candidates.length) return '';
url.hash = '';
url.searchParams.sort();
return url.toString();
} catch {
return '';
}
}
function presenceProof(subscriptionUrl, nonce, gatewayId) {
const credentialUrl = subscriptionSecret(subscriptionUrl);
if (!credentialUrl) return '';
const key = crypto.createHash('sha256')
.update(`harbor-gateway-presence-key\n${credentialUrl}`)
.digest();
return crypto.createHmac('sha256', key)
.update(`v1\n${nonce}\n${gatewayId}`)
.digest('hex');
}
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
if (!NONCE_RE.test(String(nonce || ''))) {
throw new HarborError('REQUEST_INVALID');
}
const subscription = String(subscriptionUrl || '').trim();
const id = String(gatewayId || '').trim();
if (appMode !== 'gateway' || !subscriptionSecret(subscription) || !id) {
return {
success: true,
available: false,
product: 'harbor',
role: appMode,
protocolVersion: 1,
};
}
return {
success: true,
available: true,
product: 'harbor',
role: 'gateway',
protocolVersion: 1,
gatewayId: id,
transparentRouting: true,
proof: presenceProof(subscription, nonce, id),
};
}
export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
if (
payload?.available !== true ||
payload?.product !== 'harbor' ||
payload?.role !== 'gateway' ||
payload?.protocolVersion !== 1 ||
payload?.transparentRouting !== true ||
!payload.gatewayId ||
!NONCE_RE.test(String(nonce || '')) ||
!PROOF_RE.test(String(payload.proof || ''))
) return false;
const actual = Buffer.from(payload.proof, 'hex');
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
if (!expectedProof) return false;
const expected = Buffer.from(expectedProof, 'hex');
return crypto.timingSafeEqual(actual, expected);
}
export async function probeGatewayPresence({
gateway,
subscriptionUrl,
port = 3456,
fetchImpl = fetch,
timeoutMs = 1000,
nonce = crypto.randomBytes(16).toString('hex'),
}) {
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
const response = await fetchImpl(
presenceUrl,
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
}
return {
gatewayId: payload.gatewayId,
uiOrigin: new URL(presenceUrl).origin,
verifiedAt: new Date().toISOString(),
};
}
export function normalizeHostNetworkState(value, {
now = Date.now(),
maxAgeMs = 15_000,
} = {}) {
const gateway = String(value?.gateway || '').trim();
const networkInterface = String(value?.interface || '').trim();
const mac = String(value?.mac || '').trim().toLowerCase();
const observedAt = Date.parse(value?.observedAt || '');
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
if (
!isIpv4(gateway) ||
!INTERFACE_RE.test(networkInterface) ||
!MAC_RE.test(mac) ||
!Number.isFinite(observedAt) ||
observedAt > now + 5_000 ||
now - observedAt > maxAgeMs
) return null;
return { gateway, interface: networkInterface, mac, observedAt };
}
export function readHostNetworkState(filePath, options) {
try {
return normalizeHostNetworkState(
JSON.parse(fs.readFileSync(filePath, 'utf8')),
options,
);
} catch {
return null;
}
}
export function sameGatewayRoute(previous, current) {
return Boolean(
previous &&
current &&
previous.gateway === current.gateway &&
previous.interface === current.interface &&
previous.mac === current.mac,
);
}
export function createGatewayAutoState() {
return {
mode: 'local-vpn',
failures: 0,
gateway: null,
gatewayId: '',
uiOrigin: '',
lastVerifiedAt: null,
lastError: '',
};
}
export function applyGatewayPreference(state, enabled) {
return {
...state,
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
};
}
export function nextGatewayAutoState(current, {
network,
verifiedGateway = null,
error = 'Gateway presence check failed',
}) {
if (!network) {
if (!current.gatewayId) return createGatewayAutoState();
return {
...current,
failures: current.failures + 1,
lastError: String(error || 'Gateway presence check failed'),
};
}
const routeChanged = !sameGatewayRoute(current.gateway, network);
const base = routeChanged
? { ...createGatewayAutoState(), gateway: network }
: { ...current, gateway: network };
if (verifiedGateway?.gatewayId) {
return {
...base,
mode: 'gateway-direct',
failures: 0,
gatewayId: verifiedGateway.gatewayId,
uiOrigin: verifiedGateway.uiOrigin || '',
lastVerifiedAt: verifiedGateway.verifiedAt || new Date().toISOString(),
lastError: '',
};
}
const failures = base.failures + 1;
return {
...base,
mode: base.gatewayId ? 'gateway-direct' : 'local-vpn',
failures,
lastError: String(error || 'Gateway presence check failed'),
};
}

View File

@@ -0,0 +1,23 @@
import { spawnSync } from 'node:child_process';
const options = { encoding: 'utf8' };
export function setGatewayInterception(enabled, chain, run = spawnSync) {
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
if (!enabled) {
if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
return;
}
if (exists) return;
const result = run(
'iptables',
[...rule.slice(0, 3), '-I', 'PREROUTING', '1', '-j', chain],
options,
);
if (result.status !== 0) {
throw new Error((result.stderr || 'Не удалось включить Gateway VPN').trim());
}
}

812
src/server/index.js Normal file
View File

@@ -0,0 +1,812 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { createDataplaneClient } from './dataplaneClient.js';
import { settings } from './config.js';
import {
applyGatewayPreference,
buildGatewayPresence,
createGatewayAutoState,
nextGatewayAutoState,
probeGatewayPresence,
readHostNetworkState,
sameGatewayRoute,
} from './gatewayPresence.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { tcpPing } from './ping.js';
import { checkServerHealth } from './serverHealth.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
import {
buildGatewayConfig,
removeSingboxConfig,
restoreSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
import {
fetchSubscription,
getHwid,
normalizeSubscriptionConfig,
selectRefreshedServer,
} from './subscription.js';
import {
createStateSnapshot,
normalizeStoredState,
withStateV0Compatibility,
} from '../shared/contracts/state.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
'SUBSCRIPTION_REJECTED',
]);
fs.mkdirSync(settings.dataDir, { recursive: true });
const stateStore = createStateStore(settings.statePath);
const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
let cacheRecoveryLogged = false;
function readSubscriptionCache() {
const cached = subscriptionCacheStore.read();
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
}
return cached?.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
: cached;
}
const initialStoredState = stateStore.read();
if (stateStore.migration) {
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
}
if (stateStore.recovery) {
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
}
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
const versionInfo = buildVersionInfo(settings.appMode);
const singboxRuntime = remoteDataplane
? createDataplaneClient(settings.dataplaneSocket)
: createSingboxRuntime({
configPath: settings.configPath,
gateway: settings.appMode === 'gateway',
tproxyChain: settings.tproxyChain,
});
let subscriptionRefreshPromise = null;
let subscriptionRefreshTimer = null;
let gatewayDiscoveryPromise = null;
let gatewayDiscoveryTimer = null;
let gatewayAutoState = createGatewayAutoState();
let controlOperation = Promise.resolve();
let operationState = stateStore.recovery ? {
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;
function updateStoredState(update) {
return stateStore.update((stored) => {
const current = normalizeStoredState(stored);
const next = normalizeStoredState({ schemaVersion: current.schemaVersion, ...update(current) });
revision = Math.max(revision, current.revision) + 1;
next.revision = revision;
return next;
});
}
async function withOperation(kind, operation) {
operationState = {
kind,
status: 'running',
startedAt: new Date().toISOString(),
error: null,
};
updateStoredState((state) => state);
try {
const result = await operation();
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
updateStoredState((state) => state);
return result;
} catch (error) {
const harborError = normalizeHarborError(error);
operationState = {
...operationState,
status: 'failed',
error: harborError.message,
};
updateStoredState((state) => state);
throw error;
}
}
function serializeControl(operation) {
const result = controlOperation.then(operation, operation);
// The caller observes result; this settled tail only keeps the next operation runnable.
controlOperation = result.then(() => undefined, () => undefined);
return result;
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(payload));
}
function redactLogDetails(value) {
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
}
function sendError(res, error) {
const harborError = normalizeHarborError(error);
const correlationId = crypto.randomUUID();
const technical = harborError.cause?.message || harborError.details || error;
console.error(
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technical?.message || technical)}`,
);
return sendJson(res, harborError.status, {
success: false,
error: {
code: harborError.code,
message: harborError.message,
retryable: harborError.retryable,
correlationId,
...(harborError.details ? { details: harborError.details } : {}),
},
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk) => {
if (tooLarge) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
tooLarge = true;
reject(new HarborError('REQUEST_INVALID'));
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (tooLarge) return;
if (!chunks.length) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (cause) {
reject(new HarborError('REQUEST_INVALID', { cause }));
}
});
req.on('error', reject);
});
}
function subscriptionHost(url) {
try {
return `${new URL(url).host}/…`;
} catch {
return '';
}
}
function buildActiveConfig(
subscriptionConfig,
selectedServerId,
routeRules = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
routeRules,
});
}
const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply();
function resetSavedSubscription({ stopRuntime = true } = {}) {
return serializeControl(async () => {
if (stopRuntime) await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.remove();
updateStoredState((state) => ({ routeRules: state.routeRules }));
gatewayAutoState = createGatewayAutoState();
});
}
async function publicState() {
const runtime = await singboxRuntime.refresh();
const state = normalizeStoredState(stateStore.read());
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
const configExists = fs.existsSync(settings.configPath);
const snapshot = createStateSnapshot({
storedState: state,
runtime,
gatewayAuto: gatewayAutoState,
appMode: settings.appMode,
configExists,
subscriptionHost: subscriptionHost(state.subscriptionUrl),
operation: operationState,
});
return withStateV0Compatibility(snapshot, {
storedState: { ...state, gatewayAutoEnabled },
gatewayAuto: gatewayAutoState,
port: settings.port,
proxyPort: settings.proxyPort,
configExists,
});
}
function writeCurrentConfig() {
const state = stateStore.read();
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
return true;
}
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
const previousState = gatewayAutoState;
const stateChanged = !isDeepStrictEqual(previousState, nextState);
const modeChanged = previousState.mode !== nextState.mode;
gatewayAutoState = nextState;
if (!modeChanged) {
if (stateChanged) updateStoredState((state) => state);
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = singboxRuntime.running;
try {
const configured = writeCurrentConfig();
if (reconfigure && configured && wasRunning) await startSingbox();
} catch (error) {
gatewayAutoState = previousState;
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
throw error;
}
if (stateChanged) updateStoredState((state) => state);
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
console.log(`[control] client route: ${nextState.mode}${route}`);
}
function refreshGatewayAutoMode({ reconfigure = true } = {}) {
if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState);
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
gatewayDiscoveryPromise = serializeControl(async () => {
const state = stateStore.read();
const network = state.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
if (!network) {
const discoveryError = 'macOS default gateway недоступен или устарел';
const discoveredState = nextGatewayAutoState(gatewayAutoState, {
network: null,
error: discoveryError,
});
const nextState = applyGatewayPreference(
state.subscriptionUrl
? { ...discoveredState, lastError: discoveryError }
: discoveredState,
state.gatewayAutoEnabled !== false,
);
await applyGatewayAutoState(
nextState,
{ reconfigure },
);
return gatewayAutoState;
}
if (
gatewayAutoState.mode === 'gateway-direct' &&
!sameGatewayRoute(gatewayAutoState.gateway, network)
) {
await applyGatewayAutoState(
nextGatewayAutoState(gatewayAutoState, { network }),
{ reconfigure },
);
}
try {
const verifiedGateway = await probeGatewayPresence({
gateway: network.gateway,
port: settings.gatewayPresencePort,
subscriptionUrl: state.subscriptionUrl,
});
const latestState = stateStore.read();
const latestNetwork = latestState.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
!sameGatewayRoute(network, latestNetwork)
) {
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
return gatewayAutoState;
}
await applyGatewayAutoState(
applyGatewayPreference(
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
latestState.gatewayAutoEnabled !== false,
),
{ reconfigure },
);
} catch (error) {
const reason = error?.message || 'Gateway presence check failed';
const latestState = stateStore.read();
const latestNetwork = latestState.subscriptionUrl
? readHostNetworkState(settings.hostNetworkStatePath)
: null;
if (
latestState.subscriptionUrl !== state.subscriptionUrl ||
!sameGatewayRoute(network, latestNetwork)
) {
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
return gatewayAutoState;
}
if (gatewayAutoState.lastError !== reason) {
console.warn(`[control] Gateway не используется: ${reason}`);
}
await applyGatewayAutoState(
applyGatewayPreference(
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
latestState.gatewayAutoEnabled !== false,
),
{ reconfigure },
);
}
return gatewayAutoState;
}).finally(() => {
gatewayDiscoveryPromise = null;
});
return gatewayDiscoveryPromise;
}
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
const cached = readSubscriptionCache();
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
if (persist) {
updateStoredState((state) => ({
...state,
selectedServerId,
connectionDesired: 'running',
}));
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
writeSingboxConfig(nextConfig);
try {
await startSingbox();
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
throw error;
}
updateStoredState((state) => ({
...state,
...(persist ? {
appliedServerId: selectedServerId,
appliedAt: new Date().toISOString(),
} : {}),
appliedRouteRules: state.routeRules,
}));
}
async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedServerId || !cached?.config) {
updateStoredState((current) => ({
...current,
routeRules,
routeRulesRevision: current.routeRulesRevision + 1,
}));
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
if (wasRunning) await startSingbox();
updateStoredState((current) => ({
...current,
routeRules,
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
routeRulesRevision: current.routeRulesRevision + 1,
}));
} catch (error) {
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
if (wasRunning) {
try {
await startSingbox();
} catch (rollbackError) {
throw new HarborError('PROCESS_START_FAILED', {
cause: new AggregateError([error, rollbackError], 'Route rules rollback failed'),
});
}
}
throw error;
}
}
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
return serializeControl(async () => {
const previousState = normalizeStoredState(stateStore.read());
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
throw new HarborError('STATE_CONFLICT');
}
const selectedServerId = resetSelection
? ''
: selectRefreshedServer(
previousState.selectedServerId,
previousState.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
: null;
const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const previousGatewayAutoState = gatewayAutoState;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try {
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
if (candidateConfig) writeSingboxConfig(candidateConfig);
else removeSingboxConfig();
subscriptionCacheStore.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
updateStoredState((state) => ({
...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
}));
if (resetSelection) gatewayAutoState = createGatewayAutoState();
} catch (error) {
gatewayAutoState = previousGatewayAutoState;
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
if (wasRunning) {
try {
await startSingbox();
} catch (rollbackError) {
throw new HarborError('PROCESS_START_FAILED', {
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
});
}
}
throw error;
}
return {
success: true,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
};
});
}
async function importSubscription(subscriptionUrl) {
const parsed = await fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
}
function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
try {
const subscriptionUrl = stateStore.read().subscriptionUrl;
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed);
} catch (error) {
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
await resetSavedSubscription();
}
throw error;
}
})().finally(() => {
subscriptionRefreshPromise = null;
});
return subscriptionRefreshPromise;
}
async function sendState(res, extra = {}) {
return sendJson(res, 200, { success: true, ...extra, state: await publicState() });
}
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, await publicState());
}
if (req.method === 'GET' && req.url === '/api/version') {
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
const runtime = await singboxRuntime.refresh();
return sendJson(res, 200, buildGatewayVersionInfo(versionInfo, runtime));
}
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
return sendJson(res, 200, buildSharedProxyInfo({
appMode: settings.appMode,
proxyPort: settings.proxyPort,
running: (await singboxRuntime.refresh()).running,
hostHeader: req.headers.host,
sharedProxyHost: settings.sharedProxyHost,
}));
}
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
const state = stateStore.read();
return sendJson(res, 200, buildGatewayPresence({
appMode: settings.appMode,
subscriptionUrl: state.subscriptionUrl,
gatewayId: getHwid(),
nonce: requestUrl.searchParams.get('nonce'),
}));
}
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
const state = stateStore.read();
const { serverIds = [] } = await readBody(req);
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
const servers = requestedIds.size
? (state.servers || []).filter((server) => requestedIds.has(server.id))
: state.servers || [];
const results = await checkServerHealth(servers, tcpPing);
return sendState(res, { results });
}
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim();
const parsed = await withOperation('subscription-import', async () => {
return importSubscription(normalizedUrl);
});
return sendState(res, parsed);
}
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
const { url = '' } = await readBody(req);
const parsed = await fetchSubscription(String(url).trim());
return sendState(res, { servers: parsed.servers.length });
}
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
const { success, ...result } = await withOperation(
'subscription-refresh',
() => refreshSavedSubscription(),
);
return sendState(res, result);
}
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
if (settings.appMode !== 'client') {
throw new HarborError('REQUEST_INVALID');
}
const { enabled } = await readBody(req);
if (typeof enabled !== 'boolean') {
throw new HarborError('REQUEST_INVALID');
}
await withOperation('gateway-auto', () => serializeControl(async () => {
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
updateStoredState((state) => ({
...state,
gatewayAutoEnabled: enabled,
}));
}));
const state = await publicState();
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
}
if (req.method === 'PUT' && req.url === '/api/route-rules') {
const { rules, expectedRulesRevision, expectedRevision } = await readBody(req);
let routeRules;
try {
routeRules = normalizeRouteRules(rules, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const rulesRevision = expectedRulesRevision ?? expectedRevision;
if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) {
throw new HarborError('REQUEST_INVALID');
}
await serializeControl(async () => {
const current = normalizeStoredState(stateStore.read());
const currentRevision = expectedRulesRevision == null
? current.revision
: current.routeRulesRevision;
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await withOperation('route-rules', () => applyRouteRules(routeRules));
});
return sendState(res);
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
await withOperation('subscription-forget', () => resetSavedSubscription());
return sendState(res);
}
if (req.method === 'POST' && req.url === '/api/apply') {
const { serverId = '', selectedTag = '' } = await readBody(req);
const state = normalizeStoredState(stateStore.read());
const id = String(serverId).trim() || (() => {
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
return matches.length === 1 ? matches[0].id : '';
})();
if (!id || !state.servers.some((server) => server.id === id)) {
throw new HarborError('SERVER_NOT_FOUND');
}
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
return sendState(res, {
serverId: id,
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
});
}
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
await withOperation('stop', () => serializeControl(async () => {
await stopSingbox();
updateStoredState((state) => ({ ...state, connectionDesired: 'stopped' }));
}));
return sendState(res, { singboxRunning: false });
}
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
await withOperation('start', () => serializeControl(async () => {
if (!fs.existsSync(settings.configPath)) {
throw new HarborError('CONFIG_INVALID');
}
await singboxRuntime.restart();
updateStoredState((state) => ({
...state,
appliedServerId: state.selectedServerId,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
}));
}));
return sendState(res, { singboxRunning: true });
}
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
}
const mime = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.json': 'application/json; charset=utf-8',
};
function serveStatic(req, res) {
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
const filePath = path.resolve(settings.distDir, requested);
const relative = path.relative(path.resolve(settings.distDir), filePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
res.writeHead(403);
return res.end('Forbidden');
}
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath
: path.join(settings.distDir, 'index.html');
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
fs.createReadStream(finalPath).pipe(res);
}
const server = http.createServer(async (req, res) => {
try {
return req.url?.startsWith('/api/')
? await handleApi(req, res)
: serveStatic(req, res);
} catch (error) {
return sendError(res, error);
}
});
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
clearInterval(gatewayDiscoveryTimer);
await serializeControl(() => singboxRuntime.shutdown());
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
await refreshGatewayAutoMode({ reconfigure: false })
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
try {
writeCurrentConfig();
} catch (error) {
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
await resetSavedSubscription({ stopRuntime: false });
}
}
await startSingbox()
.then(() => {
if (fs.existsSync(settings.configPath)) {
updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules }));
}
})
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
});
subscriptionRefreshTimer = setInterval(() => {
if (!stateStore.read().subscriptionUrl) return;
refreshSavedSubscription()
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
subscriptionRefreshTimer.unref();
gatewayDiscoveryTimer = setInterval(() => {
refreshGatewayAutoMode()
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
}, GATEWAY_DISCOVERY_INTERVAL_MS);
gatewayDiscoveryTimer.unref();

50
src/server/ping.js Normal file
View File

@@ -0,0 +1,50 @@
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
import net from "node:net";
import dns from "node:dns/promises";
const DEFAULT_TIMEOUT = 3000;
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
const start = Date.now();
return new Promise((resolve) => {
const socket = new net.Socket();
let done = false;
const finish = (result) => {
if (done) return;
done = true;
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.setTimeout(timeout);
socket.once("connect", () =>
finish({ ok: true, latency: Date.now() - start }),
);
socket.once("timeout", () =>
finish({ ok: false, latency: null, error: "timeout" }),
);
socket.once("error", (err) =>
finish({ ok: false, latency: null, error: err.code || err.message }),
);
try {
socket.connect(port, host);
} catch (err) {
finish({ ok: false, latency: null, error: err.message });
}
});
}
export async function resolveHost(host) {
if (net.isIP(host)) return host;
try {
const result = await dns.lookup(host);
return result.address;
} catch {
return null;
}
}

View File

@@ -0,0 +1,27 @@
export const SERVER_HEALTH_MAX_COUNT = 30;
export const SERVER_HEALTH_CONCURRENCY = 4;
export async function checkServerHealth(servers, ping, {
maxCount = SERVER_HEALTH_MAX_COUNT,
concurrency = SERVER_HEALTH_CONCURRENCY,
} = {}) {
const queue = servers.slice(0, maxCount);
const results = new Array(queue.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < queue.length) {
const index = nextIndex++;
const server = queue[index];
results[index] = {
id: server.id,
tag: server.label,
...await ping(server.host, server.port),
checkedAt: new Date().toISOString(),
};
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
return results;
}

View File

@@ -0,0 +1,154 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 4;
const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
function syncDirectory(directory) {
let descriptor;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch (error) {
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(error.code)) throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function atomicWriteFile(filePath, contents, { beforeRename, mode } = {}) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
beforeRename?.(temporaryPath, filePath);
fs.renameSync(temporaryPath, filePath);
syncDirectory(directory);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(temporaryPath, { force: true });
}
}
export function atomicWriteJson(filePath, value, options) {
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(value) {
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const version = Number.isSafeInteger(stored.schemaVersion) ? stored.schemaVersion : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
return {
...normalizeStoredState({ ...stored, routeRules }),
schemaVersion: STATE_SCHEMA_VERSION,
};
}
export function createJsonStore({
filePath,
defaultValue,
migrate = (value) => value,
initializeMissing = false,
backupWhen = () => false,
now = () => new Date(),
} = {}) {
let recovery = null;
let migration = null;
function write(value, options) {
const migrated = migrate(clone(value));
atomicWriteJson(filePath, migrated, options);
return clone(migrated);
}
function read() {
if (!fs.existsSync(filePath)) {
const initial = migrate(clone(defaultValue));
return initializeMissing ? write(initial) : clone(initial);
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed;
try {
parsed = JSON.parse(raw);
} catch (cause) {
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
fs.renameSync(filePath, backupPath);
try {
const recovered = write(defaultValue);
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
return recovered;
} catch (error) {
fs.renameSync(backupPath, filePath);
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
}
}
const migrated = migrate(clone(parsed));
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
if (backupWhen(parsed, migrated)) {
const fromVersion = Number.isSafeInteger(parsed?.schemaVersion) ? parsed.schemaVersion : 0;
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
migration = {
fromVersion,
toVersion: migrated.schemaVersion,
backupPath,
migratedAt: now().toISOString(),
};
}
atomicWriteJson(filePath, migrated);
}
return clone(migrated);
}
function update(mutator) {
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
const next = mutator(read());
if (next && typeof next.then === 'function') {
throw new TypeError('State store mutator must be synchronous');
}
return write(next);
}
return {
read,
write,
update,
remove: () => fs.rmSync(filePath, { force: true }),
get recovery() { return recovery; },
get migration() { return migration; },
};
}
export function createStateStore(filePath, options = {}) {
return createJsonStore({
filePath,
defaultValue: {},
migrate: migrateStoredState,
initializeMissing: true,
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
...options,
});
}

44
src/server/sharedProxy.js Normal file
View File

@@ -0,0 +1,44 @@
function proxyHostFromHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return "";
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
return end > 0 ? raw.slice(1, end) : "";
}
return raw.split(":")[0];
}
export function buildSharedProxyInfo({
appMode,
proxyPort,
running,
hostHeader,
sharedProxyHost,
}) {
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
const port = Number.parseInt(proxyPort, 10);
const available =
appMode === "gateway" &&
Boolean(running) &&
host &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535;
const proxy = available
? {
host,
port,
protocol: "socks5",
httpUrl: `http://${host}:${port}`,
socksUrl: `socks5://${host}:${port}`,
}
: null;
return {
success: true,
available,
mode: appMode,
proxy,
};
}

97
src/server/singbox.js Normal file
View File

@@ -0,0 +1,97 @@
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
? subscriptionConfig.outbounds
: [];
const tag = String(selectedTag || '').trim();
return outbounds.find((outbound) => (
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
));
}
export function buildGatewayConfig(subscriptionConfig, selectedTag, {
clientDirect = false,
routeRules = [],
} = {}) {
const clientMode = settings.appMode === 'client';
const directClient = clientMode && clientDirect;
const vpnOutbound = directClient
? null
: structuredClone(findOutbound(subscriptionConfig, selectedTag));
if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp';
}
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
const inbounds = [
...(!clientMode ? [{
type: 'tproxy',
tag: TPROXY_INBOUND,
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
}] : []),
{
type: 'mixed',
tag: MIXED_INBOUND,
listen: settings.bindIp,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
];
const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [
...directRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
];
return {
log: { level: settings.logLevel, timestamp: true },
experimental: {
cache_file: { enabled: true, path: settings.cachePath },
},
dns: { independent_cache: true },
inbounds,
outbounds: [
...(vpnOutbound ? [vpnOutbound] : []),
{ type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' },
],
route: {
rule_set: [],
rules,
final: outboundTag,
...(clientMode ? {} : { auto_detect_interface: true }),
},
};
}
export function writeSingboxConfig(config) {
atomicWriteJson(settings.configPath, config);
}
export function restoreSingboxConfig(contents) {
atomicWriteFile(settings.configPath, contents);
}
export function removeSingboxConfig() {
fs.rmSync(settings.configPath, { force: true });
}

View File

@@ -0,0 +1,100 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { setGatewayInterception } from './gatewayRouting.js';
import { HarborError } from '../shared/errors.js';
export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '' }) {
let child = null;
let configHash = '';
let startedAt = null;
const state = () => ({ running: Boolean(child), startedAt });
async function stop() {
if (gateway) setGatewayInterception(false, tproxyChain);
if (!child) {
configHash = '';
startedAt = null;
return state();
}
const current = child;
child = null;
configHash = '';
startedAt = null;
await new Promise((resolve) => {
const timeout = setTimeout(() => {
current.kill('SIGKILL');
resolve();
}, 4000);
current.once('exit', () => {
clearTimeout(timeout);
resolve();
});
current.kill('SIGTERM');
});
return state();
}
async function apply({ force = false } = {}) {
if (!fs.existsSync(configPath)) {
await stop();
return state();
}
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
if (check.status !== 0) {
throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
});
}
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
if (!force && child && nextHash === configHash) return state();
await stop();
let current;
try {
current = spawn('sing-box', ['run', '-c', configPath], {
stdio: ['ignore', 'inherit', 'inherit'],
});
await new Promise((resolve, reject) => {
current.once('spawn', resolve);
current.once('error', reject);
});
} catch (cause) {
throw new HarborError('PROCESS_START_FAILED', { cause });
}
child = current;
configHash = nextHash;
startedAt = new Date().toISOString();
try {
if (gateway) setGatewayInterception(true, tproxyChain);
} catch (error) {
current.kill('SIGTERM');
child = null;
configHash = '';
startedAt = null;
throw new HarborError('PROCESS_START_FAILED', { cause: error });
}
current.once('exit', () => {
if (child !== current) return;
child = null;
configHash = '';
startedAt = null;
if (gateway) setGatewayInterception(false, tproxyChain);
});
return state();
}
return {
get running() { return Boolean(child); },
get startedAt() { return startedAt; },
refresh: async () => state(),
apply,
restart: () => apply({ force: true }),
stop,
shutdown: stop,
};
}

240
src/server/subscription.js Normal file
View File

@@ -0,0 +1,240 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import {
createServerId,
normalizeServer,
serverIdentityKey,
} from '../shared/serverIdentity.js';
import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
function usableProxyOutbound(outbound) {
const host = String(outbound?.server || '').trim().toLowerCase();
const port = Number(outbound?.server_port);
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
}
function rejectedSubscriptionCode(outbounds) {
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
? 'SUBSCRIPTION_REJECTED'
: 'SUBSCRIPTION_INVALID';
}
export function getHwid() {
fs.mkdirSync(settings.dataDir, { recursive: true });
if (fs.existsSync(settings.hwidPath)) {
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
}
const hwid = crypto.randomBytes(8).toString('hex');
atomicWriteFile(settings.hwidPath, hwid);
return hwid;
}
export function subscriptionHeaders() {
return {
'user-agent': 'singbox',
'x-hwid': getHwid(),
'x-device-os': process.platform,
'x-ver-os': process.version,
'x-device-model': settings.appName,
};
}
export function parseUserInfo(headerValue) {
const result = {};
if (!headerValue) return result;
for (const part of String(headerValue).split(';')) {
const [key, value] = part.trim().split('=', 2);
if (!key || value === undefined) continue;
const parsed = Number.parseInt(value, 10);
if (!Number.isNaN(parsed)) result[key] = parsed;
}
return result;
}
export function parseVlessUrl(rawUrl) {
if (!rawUrl.startsWith('vless://')) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
let parsed;
try {
parsed = new URL(rawUrl);
} catch (cause) {
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
}
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
const uuid = decodeURIComponent(parsed.username || '');
const server = parsed.hostname;
const serverPort = Number.parseInt(parsed.port || '443', 10);
const publicKey = parsed.searchParams.get('pbk') || '';
const shortId = parsed.searchParams.get('sid') || '';
const serverName = parsed.searchParams.get('sni') || server;
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
const flow = parsed.searchParams.get('flow') || '';
if (!uuid || !server || !serverPort) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
if (!publicKey || !shortId) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
return {
type: 'vless',
tag,
server,
server_port: serverPort,
uuid,
flow,
tls: {
enabled: true,
server_name: serverName,
utls: {
enabled: true,
fingerprint,
},
reality: {
enabled: true,
public_key: publicKey,
short_id: shortId,
},
},
packet_encoding: 'xudp',
};
}
function maybeDecodeBase64(content) {
const compact = content.trim().replace(/\s+/g, '');
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
try {
const decoded = Buffer.from(compact, 'base64').toString('utf8');
if (decoded.includes('vless://') || decoded.includes('{')) return decoded;
} catch {}
return content;
}
export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = [];
const rejectedOutbounds = [];
const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => {
if (!outbound || typeof outbound !== 'object') {
rejectedOutbounds.push(outbound);
return [];
}
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
if (!usableProxyOutbound(outbound)) {
rejectedOutbounds.push(outbound);
return [];
}
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return [];
seen.add(id);
servers.push(normalizeServer({ ...outbound, id }));
return [{ ...outbound, tag: id }];
});
if (!servers.length) throw new HarborError(rejectedSubscriptionCode(rejectedOutbounds));
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
}
export function parseSubscriptionBody(body) {
let parsedConfig;
try {
parsedConfig = JSON.parse(body);
} catch {
const decoded = maybeDecodeBase64(body);
const links = decoded
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith('vless://'));
if (!links.length) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
parsedConfig = {
outbounds: links.map(parseVlessUrl),
};
}
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
}
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch (cause) {
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
let response;
try {
response = await fetchImpl(parsedUrl, {
headers: subscriptionHeaders(),
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
} catch (cause) {
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
}
if (!response.ok) {
throw new HarborError('PROVIDER_UNAVAILABLE', { details: `HTTP ${response.status}` });
}
return response;
}
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
if (!currentServerId) return '';
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId);
if (!previous) return '';
const identity = serverIdentityKey(previous);
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
return matches.length === 1 ? matches[0].id : '';
}
export async function fetchSubscription(url, options) {
const response = await requestSubscription(url, options);
const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
if (userInfo.expire > 0 && userInfo.expire * 1000 <= Date.now()) {
throw new HarborError('SUBSCRIPTION_EXPIRED');
}
if (userInfo.total > 0 && (userInfo.upload || 0) + (userInfo.download || 0) >= userInfo.total) {
throw new HarborError('SUBSCRIPTION_TRAFFIC_EXHAUSTED');
}
const parsed = parseSubscriptionBody(body);
return {
...parsed,
userInfo,
fetchedAt: new Date().toISOString(),
};
}

30
src/server/version.js Normal file
View File

@@ -0,0 +1,30 @@
import { spawnSync } from 'node:child_process';
import { HARBOR_VERSIONS } from '../shared/versions.js';
export function detectSingBoxVersion(run = spawnSync) {
const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 });
const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`);
return match?.[1] || null;
}
export function buildVersionInfo(appMode, run = spawnSync) {
const client = appMode === 'client';
return {
apiVersion: 1,
location: client ? 'mac' : 'gateway',
components: client
? { macClient: HARBOR_VERSIONS.macClient }
: { gatewayBackend: HARBOR_VERSIONS.gatewayBackend },
runtime: { singBox: detectSingBoxVersion(run) },
};
}
export function buildGatewayVersionInfo(controlInfo, dataplaneState) {
return {
...controlInfo,
runtime: {
dataplaneVersion: dataplaneState?.gatewayBackendVersion || null,
singBox: dataplaneState?.singBoxVersion || null,
},
};
}

View File

@@ -0,0 +1,218 @@
import { normalizeRouteRules } from '../routingRules.js';
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
const MODES = new Set(['client', 'gateway']);
const CONNECTION_STATES = new Set(['running', 'stopped']);
const OPERATION_STATES = new Set(['idle', 'running', 'failed']);
const text = (value) => String(value || '').trim();
const nullableText = (value) => value == null ? null : String(value);
const dateOrNull = (value) => (
typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null
);
export function normalizeStoredState(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const servers = normalizeServers(state.servers);
const selectedServerId = resolveServerId(servers, state.selectedServerId, state.selectedTag);
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(servers, state.appliedServerId)
: resolveServerId(servers, '', state.appliedTag || state.selectedTag);
const selectedServer = servers.find((server) => server.id === selectedServerId);
const appliedServer = servers.find((server) => server.id === appliedServerId);
return {
...state,
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
selectedServerId,
appliedServerId,
selectedTag: selectedServer?.label || '',
appliedTag: appliedServer?.label || '',
servers,
routeRules: normalizeRouteRules(state.routeRules),
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
? state.routeRulesRevision
: 0,
};
}
export function createStateSnapshot({
storedState,
runtime,
gatewayAuto,
appMode,
configExists,
subscriptionHost,
operation = { kind: null, status: 'idle', startedAt: null, error: null },
now = new Date(),
}) {
const stored = normalizeStoredState(storedState);
const mode = MODES.has(appMode) ? appMode : 'gateway';
const hasSubscription = Boolean(stored.subscriptionUrl);
const desired = CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired
: configExists ? 'running' : 'stopped';
const servers = stored.servers;
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client'
? 'gateway-host'
: !gatewayAutoEnabled
? 'disabled'
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
return assertStateSnapshot({
apiVersion: 1,
revision: stored.revision,
generatedAt: now.toISOString(),
mode,
subscription: {
status: hasSubscription ? 'ready' : 'missing',
host: hasSubscription ? subscriptionHost : '',
fetchedAt: dateOrNull(stored.fetchedAt),
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
},
selection: {
desiredServerId: stored.selectedServerId,
appliedServerId: stored.appliedServerId,
},
connection: {
desired,
process: runtime?.running ? 'running' : 'stopped',
startedAt: dateOrNull(runtime?.startedAt),
lastError: null,
},
route: {
mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
autoEnabled: mode === 'client' && gatewayAutoEnabled,
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
reason: routeReason,
localRules: stored.routeRules,
activeLocalRules,
localRulesRevision: stored.routeRulesRevision,
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
},
operation: {
kind: nullableText(operation.kind),
status: operation.status,
startedAt: nullableText(operation.startedAt),
error: nullableText(operation.error),
},
servers,
});
}
export function withStateV0Compatibility(snapshot, {
storedState,
gatewayAuto,
port,
proxyPort,
configExists,
}) {
const stored = normalizeStoredState(storedState);
return {
...snapshot,
port,
proxyPort,
configExists,
singboxRunning: snapshot.connection.process === 'running',
singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready',
selectedTag: stored.selectedTag,
userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt,
gatewayAuto: snapshot.mode === 'client' ? {
mode: gatewayAuto?.mode || 'local-vpn',
enabled: stored.gatewayAutoEnabled !== false,
available: Boolean(gatewayAuto?.gatewayId),
address: gatewayAuto?.gateway?.gateway || '',
uiOrigin: gatewayAuto?.uiOrigin || '',
interface: gatewayAuto?.gateway?.interface || '',
failures: Number(gatewayAuto?.failures) || 0,
lastError: gatewayAuto?.lastError || '',
} : null,
};
}
export function assertStateSnapshot(snapshot) {
const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value) => value === null || validDate(value);
const nullableString = (value) => value === null || typeof value === 'string';
const validServer = (server) => (
server &&
typeof server.id === 'string' &&
typeof server.label === 'string' &&
typeof server.host === 'string' &&
Number.isInteger(server.port) &&
server.port >= 0 &&
typeof server.protocol === 'string'
);
const validRouteRule = (rule) => (
rule &&
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' &&
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
);
if (
!snapshot ||
snapshot.apiVersion !== 1 ||
!Number.isSafeInteger(snapshot.revision) ||
snapshot.revision < 0 ||
!validDate(snapshot.generatedAt) ||
!MODES.has(snapshot.mode) ||
!snapshot.subscription ||
!['missing', 'ready'].includes(snapshot.subscription.status) ||
typeof snapshot.subscription.host !== 'string' ||
Object.hasOwn(snapshot.subscription, 'url') ||
!nullableDate(snapshot.subscription.fetchedAt) ||
!snapshot.subscription.userInfo ||
typeof snapshot.subscription.userInfo !== 'object' ||
!snapshot.selection ||
typeof snapshot.selection.desiredServerId !== 'string' ||
typeof snapshot.selection.appliedServerId !== 'string' ||
!snapshot.connection ||
!CONNECTION_STATES.has(snapshot.connection.desired) ||
!CONNECTION_STATES.has(snapshot.connection.process) ||
!nullableDate(snapshot.connection.startedAt) ||
!nullableString(snapshot.connection.lastError) ||
!snapshot.route ||
typeof snapshot.route.mode !== 'string' ||
!nullableString(snapshot.route.gatewayAddress) ||
!nullableString(snapshot.route.gatewayUiOrigin) ||
!nullableDate(snapshot.route.lastVerifiedAt) ||
typeof snapshot.route.autoEnabled !== 'boolean' ||
typeof snapshot.route.fallbackPreference !== 'string' ||
typeof snapshot.route.reason !== 'string' ||
!Array.isArray(snapshot.route.localRules) ||
!snapshot.route.localRules.every(validRouteRule) ||
!Array.isArray(snapshot.route.activeLocalRules) ||
!snapshot.route.activeLocalRules.every(validRouteRule) ||
!Number.isSafeInteger(snapshot.route.localRulesRevision) ||
snapshot.route.localRulesRevision < 0 ||
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
!snapshot.operation ||
!nullableString(snapshot.operation.kind) ||
!OPERATION_STATES.has(snapshot.operation.status) ||
!nullableDate(snapshot.operation.startedAt) ||
!nullableString(snapshot.operation.error) ||
!Array.isArray(snapshot.servers) ||
!snapshot.servers.every(validServer)
) {
throw new TypeError('Invalid Harbor state snapshot v1');
}
return snapshot;
}
function isSameRules(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}

37
src/shared/errors.js Normal file
View File

@@ -0,0 +1,37 @@
export const ERROR_DEFINITIONS = Object.freeze({
CONTROL_UNREACHABLE: { status: 503, message: 'Harbor сейчас недоступен.', retryable: true },
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
SUBSCRIPTION_EXPIRED: { status: 400, message: 'Срок действия подписки истёк.', retryable: false },
SUBSCRIPTION_TRAFFIC_EXHAUSTED: { status: 400, message: 'Трафик по подписке закончился.', retryable: false },
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
UNKNOWN: { status: 500, message: 'Не удалось выполнить действие.', retryable: false },
});
export function errorDefinition(code) {
return ERROR_DEFINITIONS[code] || ERROR_DEFINITIONS.UNKNOWN;
}
export class HarborError extends Error {
constructor(code, { cause, details } = {}) {
const definition = errorDefinition(code);
super(definition.message, { cause });
this.name = 'HarborError';
this.code = ERROR_DEFINITIONS[code] ? code : 'UNKNOWN';
this.status = definition.status;
this.retryable = definition.retryable;
this.details = details;
}
}
export function normalizeHarborError(error) {
return error instanceof HarborError ? error : new HarborError('UNKNOWN', { cause: error });
}

View File

@@ -0,0 +1,61 @@
export const INITIAL_ROUTE_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }),
]);
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
export const MAX_ROUTE_RULES = 200;
function hostname(value) {
const input = String(value || '').trim().replace(/^\*\./, '').replace(/^\./, '');
if (!input) throw new TypeError('Domain rule value is required');
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(input) ? input : `https://${input}`);
const normalized = url.hostname.replace(/\.$/, '').toLowerCase();
if (!normalized || normalized.length > 253) throw new TypeError('Invalid domain rule value');
return normalized;
}
function normalizeRule(rule) {
const type = String(rule?.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule || {}, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state');
}
const value = type === 'domain_keyword'
? String(rule?.value || '').trim().toLowerCase()
: hostname(rule?.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule value');
}
return { type, value, enabled: rule?.enabled !== false };
}
export function normalizeRouteRules(value, { strict = false } = {}) {
if (!Array.isArray(value)) {
if (strict) throw new TypeError('Route rules must be an array');
return [];
}
if (strict && value.length > MAX_ROUTE_RULES) {
throw new TypeError(`Route rules limit is ${MAX_ROUTE_RULES}`);
}
const seen = new Set();
const normalized = [];
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try {
const rule = normalizeRule(candidate);
const key = `${rule.type}:${rule.value}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push(rule);
} catch (error) {
if (strict) throw error;
}
}
return normalized;
}
export function canAppendRouteRule(rules) {
return Array.isArray(rules) &&
rules.length < MAX_ROUTE_RULES &&
rules.every((rule) => String(rule?.value || '').trim());
}

View File

@@ -0,0 +1,67 @@
const text = (value) => String(value || '').trim();
function hash64(value) {
let hash = 0xcbf29ce484222325n;
for (let index = 0; index < value.length; index += 1) {
hash ^= BigInt(value.charCodeAt(index));
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return hash.toString(16).padStart(16, '0');
}
export function serverIdentityKey(server) {
const protocol = text(server?.protocol || server?.type).toLowerCase();
const host = text(server?.host || server?.server).toLowerCase();
const port = Number(server?.port || server?.server_port) || 0;
return `${protocol}\u0000${host}\u0000${port}`;
}
export function createServerId(server) {
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
}
export function normalizeServer(server) {
const source = server && typeof server === 'object' ? server : {};
const protocol = text(source.protocol || source.type).toLowerCase();
const host = text(source.host || source.server);
const port = Number(source.port || source.server_port) || 0;
const id = text(source.id) || createServerId(source);
const label = text(source.label || source.tag) || host;
const metadata = Object.fromEntries([
['country', text(source.country)],
['city', text(source.city)],
['provider', text(source.provider)],
].filter(([, value]) => value));
return {
id,
label,
host,
port,
protocol,
...metadata,
// v0 aliases remain until old clients no longer consume this API.
tag: label,
server: host,
server_port: port,
type: protocol,
};
}
export function normalizeServers(servers) {
const seen = new Set();
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
const normalized = normalizeServer(server);
if (!normalized.id || seen.has(normalized.id)) return [];
seen.add(normalized.id);
return [normalized];
});
}
export function resolveServerId(servers, serverId, legacyTag = '') {
const id = text(serverId);
if (id) return servers.some((server) => server.id === id) ? id : '';
const tag = text(legacyTag);
const matches = tag ? servers.filter((server) => server.label === tag) : [];
return matches.length === 1 ? matches[0].id : '';
}

27
src/shared/versions.js Normal file
View File

@@ -0,0 +1,27 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.8.12',
gatewayClient: '0.8.10',
gatewayBackend: '0.8.1',
});
export function parseVersion(value) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
return match ? {
major: Number(match[1]),
minor: Number(match[2]),
hotfix: Number(match[3]),
} : null;
}
export function versionCompatibility(versions) {
const mac = parseVersion(versions?.macClient);
const client = parseVersion(versions?.gatewayClient);
const backend = parseVersion(versions?.gatewayBackend);
const major = Boolean(mac && client && backend
&& mac.major === client.major
&& client.major === backend.major);
const gateway = Boolean(client && backend
&& client.major === backend.major
&& client.minor === backend.minor);
return { compatible: major && gateway, major, gateway };
}

189
src/web/App.jsx Normal file
View File

@@ -0,0 +1,189 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api, HarborApiError } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import {
compatibleSnapshot,
harborReducer,
initialHarborState,
} from './state/harborReducer.js';
import { createOperationRegistry } from './state/operations.js';
function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState({});
const [error, setError] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
const pollGeneration = useRef(0);
const operationRegistry = useRef(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry(setOperations);
}
function setPendingServerId(serverId) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await api.state();
if (!compatibleSnapshot(snapshot)) {
const incompatible = new Error('Ожидался Harbor state apiVersion 1');
incompatible.code = 'INCOMPATIBLE_API';
throw incompatible;
}
if (generation === pollGeneration.current) {
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
} catch (requestError) {
if (generation === pollGeneration.current) {
dispatch({ type: 'sync-failed', error: requestError });
}
}
}
useEffect(() => {
loadState();
const timer = setInterval(loadState, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
let cancelled = false;
api.version().then((info) => {
if (!cancelled) setVersionInfo(info);
}).catch((requestError) => {
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
if (!cancelled) setVersionInfo(null);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!state?.mode) return;
const isGateway = state.mode === 'gateway';
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
document.getElementById('harbor-favicon').href = isGateway
? '/harbor-gateway.svg?v=2'
: '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key, action, context) {
setError(null);
return operationRegistry.current.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: err?.code }, err?.status);
setError({
context,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context)
: null,
});
return false;
}
});
}
async function applyMutation(action) {
pollGeneration.current += 1;
const result = await action();
if (!result?.state) throw new Error('Harbor API не вернул state snapshot');
if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1');
dispatch({
type: 'sync-succeeded',
snapshot: result.state,
receivedAt: new Date().toISOString(),
});
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 })} />;
return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
<div className="app-body client-mode">
<main className="app-main">
<ClientOverviewPage
state={previewReady ? {
...state,
mode: 'client',
hasSubscription: true,
subscriptionHost: 'harbor.example',
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
proxyPort: 8082,
} : state}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
onSaveRouteRules={(rules, expectedRevision) => run(
'routeRules',
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onDismissError={() => setError(null)}
/>
</main>
</div>
</div>
);
}
createRoot(document.getElementById('root')).render(<App />);

92
src/web/api.js Normal file
View File

@@ -0,0 +1,92 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
export class HarborApiError extends Error {
constructor(payload = {}, status = 0) {
const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
const definition = errorDefinition(code);
super(definition.message);
this.name = 'HarborApiError';
this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = payload.details;
this.correlationId = payload.correlationId
|| globalThis.crypto?.randomUUID?.()
|| new Date().toISOString();
}
}
export async function request(url, options = {}, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
if (!response.ok || data?.success === false) {
const payload = data?.error && typeof data.error === 'object'
? data.error
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(payload, response.status);
}
return data;
}
export const api = {
state: () => request('/api/state'),
version: () => request('/api/version'),
subscription: {
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}),
gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
},
routeRules: {
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }),
}),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds) => request('/api/servers/ping-all', {
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
export function ConfirmationPopup({
open,
id,
kicker,
title,
description,
cancelLabel,
confirmLabel,
busy = false,
onCancel,
onConfirm,
}) {
const overlayRef = useRef(null);
const dialogRef = useRef(null);
const cancelRef = useRef(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
useEffect(() => {
if (!open) return undefined;
const previousFocus = document.activeElement;
const background = [...(overlayRef.current?.parentElement?.children || [])]
.filter((element) => !element.classList.contains('client-confirmation-popup'))
.map((element) => [element, element.inert]);
background.forEach(([element]) => { element.inert = true; });
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const frame = requestAnimationFrame(() => cancelRef.current?.focus());
const onKeyDown = (event) => {
if (event.key === 'Escape' && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== 'Tab') return;
const controls = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])];
if (!controls.length) return;
const first = controls[0];
const last = controls.at(-1);
if (!dialogRef.current?.contains(document.activeElement)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
background.forEach(([element, inert]) => { element.inert = inert; });
document.body.style.overflow = previousOverflow;
requestAnimationFrame(() => previousFocus?.focus?.());
};
}, [open]);
return createPortal(
<div
ref={overlayRef}
className={`client-confirmation-popup${open ? ' is-open' : ''}`}
aria-hidden={!open}
inert={!open ? true : undefined}
onPointerDown={(event) => {
if (event.target === event.currentTarget && !busy) onCancel();
}}
>
<section
ref={dialogRef}
className="client-confirmation-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`}
aria-busy={busy}
>
{kicker && <span className="client-confirmation-kicker">{kicker}</span>}
<h2 id={`${id}-title`}>{title}</h2>
<p id={`${id}-description`}>{description}</p>
<div className="client-confirmation-actions">
<button ref={cancelRef} type="button" disabled={busy} onClick={onCancel}>{cancelLabel}</button>
<button className="is-danger" type="button" disabled={busy} onClick={onConfirm}>{confirmLabel}</button>
</div>
</section>
</div>,
document.querySelector('.app.client-app') || document.body,
);
}

View File

@@ -0,0 +1,361 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import {
autoServer,
filterServers,
groupServers,
SERVER_RESULT_WINDOW,
} from '../utils/serverPicker.js';
const FAVORITES_KEY = 'harbor-server-favorites';
const RECENT_KEY = 'harbor-server-recent';
const AUTO_KEY = 'harbor-server-auto';
const SIMPLE_SERVER_LIMIT = 5;
function readList(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value.map(String) : [];
} catch {
return [];
}
}
function write(key, value) {
try {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
} catch {
// Preferences remain available for this session.
}
}
function readAuto() {
try {
return localStorage.getItem(AUTO_KEY) === 'true';
} catch {
return false;
}
}
function serverHealthText(ping) {
if (ping?.error) return 'Проверка недоступна';
if (ping?.ok) return `${ping.latency} мс`;
return ping ? 'Недоступен' : null;
}
function ServerHealth({ ping, fallback }) {
const health = fallback || serverHealthText(ping);
if (!health && !ping?.checking) return null;
return <small
className={`client-server-health${ping?.checking ? ' is-checking' : ''}`}
title={ping?.checkedAt || undefined}
aria-label={ping?.checking ? 'Проверяем пинг' : health}
>
<span aria-hidden="true">{health}</span>
<svg className="client-server-health-checking" viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</small>;
}
function ServerCheckButton({ checking, disabled, onClick }) {
return <button
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
type="button"
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
disabled={checking || disabled}
onClick={onClick}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
</button>;
}
function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect, onFavorite }) {
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping);
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
<button
className={`client-server${selected ? ' is-selected' : ''}`}
type="button"
disabled={disabled}
aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
style={{ '--server-index': Math.min(index, 7) }}
onClick={() => onSelect(server.id)}
>
<strong>{server.label}</strong>
</button>
{(ping || onFavorite) && <div className="client-server-meta">
<ServerHealth ping={ping} />
{onFavorite && <button
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
type="button"
aria-pressed={favorite}
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
onClick={() => onFavorite(server.id)}
></button>}
</div>}
</div>;
}
export function ServerPicker({
servers,
selectedServerId,
disabled,
prompt,
leaving,
revealVersion,
onSelect,
}) {
const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState('all');
const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
const [autoActive, setAutoActive] = useState(readAuto);
const [collapsed, setCollapsed] = useState([]);
const [pings, setPings] = useState({});
const [checking, setChecking] = useState(false);
const serverKey = servers.map(({ id }) => id).join('|');
useEffect(() => {
setPage(0);
}, [query, view, serverKey]);
const selected = servers.find(({ id }) => id === selectedServerId);
const filtered = useMemo(() => {
const found = filterServers(servers, query);
if (view === 'favorites') return found.filter(({ id }) => favorites.includes(id));
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
return found;
}, [servers, query, view, favorites, recent]);
const results = filtered.filter(({ id }) => id !== selectedServerId);
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
const grouped = servers.length >= 10;
useEffect(() => {
setPage((current) => Math.min(current, pageCount - 1));
}, [pageCount]);
function toggleFavorite(id) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
return next;
});
}
function select(id, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
if (!automatic) {
setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next);
return next;
});
}
onSelect(id);
}
async function checkVisible() {
const ids = [...new Set([selectedServerId, ...visible.map(({ id }) => id)].filter(Boolean))].slice(0, 30);
if (!ids.length) return;
const startedAt = performance.now();
setChecking(true);
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
}));
try {
const data = await api.servers.ping(ids);
setPings((current) => ({
...current,
...Object.fromEntries((data.results || []).map((result) => [result.id, { ...result, checking: true }])),
}));
} catch {
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { error: true, checking: true, checkedAt: new Date().toISOString() }])),
}));
} finally {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 700 - (performance.now() - startedAt))));
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
}));
setChecking(false);
}
}
if (servers.length === 1) {
return <section className="client-servers" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-toolbar is-single">
<span className="client-server-toolbar-title">Список серверов</span>
<ServerCheckButton checking={checking} onClick={checkVisible} />
</div>
<div className="client-server-grid">
<ServerRow
server={servers[0]}
selected={servers[0].id === selectedServerId}
favorite={false}
ping={pings[servers[0].id]}
disabled={disabled}
index={0}
onSelect={onSelect}
/>
</div>
</section>;
}
const renderRows = (items, offset = 0) => items.map((server, index) => (
<ServerRow
key={server.id}
server={server}
selected={!autoActive && server.id === selectedServerId}
favorite={favorites.includes(server.id)}
ping={pings[server.id]}
disabled={disabled}
index={offset + index}
onSelect={select}
onFavorite={toggleFavorite}
/>
));
const simpleServers = [
...(selected ? [selected] : []),
...servers.filter(({ id }) => id !== selectedServerId),
].slice(0, SIMPLE_SERVER_LIMIT);
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
<div className="client-server-toolbar">
<span className="client-server-toolbar-title">Список серверов</span>
<ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} />
<button
className={`client-server-mode-toggle${advanced ? ' is-open' : ''}`}
type="button"
aria-expanded={advanced}
aria-label={advanced ? 'Скрыть поиск и фильтры' : 'Показать поиск и фильтры'}
onClick={() => setAdvanced((current) => !current)}
>
<span>Поиск и фильтры</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
</div>
<div className="client-server-mode-panels">
<div
className={`client-server-mode-panel is-simple${advanced ? '' : ' is-open'}`}
aria-hidden={advanced}
inert={advanced ? true : undefined}
>
<div className="client-server-mode-panel-inner">
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
<div className="client-server-grid">
{simpleServers.map((server, index) => <ServerRow
key={server.id}
server={server}
selected={server.id === selectedServerId}
ping={pings[server.id]}
disabled={disabled}
index={index}
onSelect={select}
/>)}
</div>
{servers.length > simpleServers.length && <p className="client-server-overflow-note">
Ещё {servers.length - simpleServers.length} доступны через поиск
</p>}
</div>
</div>
</div>
<div
className={`client-server-mode-panel is-advanced${advanced ? ' is-open' : ''}`}
aria-hidden={!advanced}
inert={!advanced ? true : undefined}
>
<div className="client-server-mode-panel-inner">
<div className="client-server-tools">
<input
type="search"
value={query}
aria-label="Найти сервер"
placeholder="Поиск сервера"
onChange={(event) => setQuery(event.target.value)}
/>
<div className="client-server-filters" aria-label="Фильтр серверов">
{[
['all', 'Все'],
['favorites', '★'],
['recent', 'Недавние'],
].map(([id, label]) => <button
type="button"
className={view === id ? 'is-active' : ''}
aria-pressed={view === id}
key={id}
onClick={() => setView(id)}
>{label}</button>)}
</div>
</div>
<div className="client-server-pinned">
<button
className={`client-server-auto${autoActive ? ' is-selected' : ''}`}
type="button"
aria-pressed={autoActive}
disabled={disabled || !servers.length}
onClick={() => select(autoServer(servers)?.id, true)}
>
<strong>Auto</strong>
<ServerHealth
ping={autoActive ? pings[selectedServerId] : undefined}
fallback={autoActive ? undefined : 'Первый стабильный сервер'}
/>
</button>
{selected && <ServerRow
server={selected}
selected
favorite={favorites.includes(selected.id)}
ping={pings[selected.id]}
disabled={disabled}
index={0}
onSelect={select}
onFavorite={toggleFavorite}
/>}
</div>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group);
return <section className="client-server-group" key={group}>
<button
className="client-server-group-toggle"
type="button"
aria-expanded={!isCollapsed}
onClick={() => setCollapsed((current) => current.includes(group)
? current.filter((item) => item !== group)
: [...current, group])}
>{group} <small>{items.length}</small></button>
{!isCollapsed && <div className="client-server-grid">{renderRows(items)}</div>}
</section>;
}) : <div className="client-server-grid">{renderRows(visible)}</div>}
{pageCount > 1 && <nav className="client-server-pages" aria-label="Страницы серверов">
<button className="client-server-more" type="button" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Назад</button>
<span>{page + 1} / {pageCount}</span>
<button className="client-server-more" type="button" disabled={page + 1 === pageCount} onClick={() => setPage((current) => current + 1)}>Дальше</button>
</nav>}
</div>
</div>
</div>
</div>
</section>;
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
const bootCopy = {
'control-unreachable': {
title: 'Harbor недоступен',
message: 'Control plane не ответил. Проверьте, что контейнер запущен, и повторите запрос.',
},
'incompatible-api': {
title: 'Версия Harbor несовместима',
message: 'Интерфейс получил state неизвестной версии. Обновите frontend и control plane вместе.',
},
fatal: {
title: 'Harbor не удалось запустить',
message: 'Произошла непредвиденная ошибка. Технические детали помогут найти причину.',
},
};
export function BootStatePage({ transport, onRetry }) {
if (transport.bootStatus === 'loading') return <div className="app-loading">Harbor</div>;
const copy = bootCopy[transport.bootStatus] || bootCopy.fatal;
return (
<main className="app-boot">
<span>Harbor</span>
<h1>{copy.title}</h1>
<p>{copy.message}</p>
<button type="button" onClick={onRetry}>Повторить</button>
<details>
<summary>Технические детали</summary>
<code>{transport.error?.message}</code>
<pre>{`curl -i ${window.location.origin}/api/state\ndocker compose logs --tail=100`}</pre>
</details>
</main>
);
}
export function StaleBanner({ transport, onRetry }) {
if (!transport.stale) return null;
const lastSync = transport.lastSuccessfulSyncAt
? new Date(transport.lastSuccessfulSyncAt).toLocaleTimeString('ru-RU')
: 'неизвестно';
const incompatible = transport.error?.kind === 'incompatible-api';
return (
<aside className="client-stale-banner" role="status">
<strong>{incompatible ? 'API несовместим' : 'Показано последнее известное состояние'}</strong>
<span>Последняя синхронизация: {lastSync}</span>
<button type="button" onClick={onRetry}>Повторить</button>
</aside>
);
}

87
src/web/instructions.js Normal file
View File

@@ -0,0 +1,87 @@
export function instructionBlocks({ isGateway, host, port }) {
const httpProxy = `http://${host}:${port}`;
const socksProxy = `socks5://${host}:${port}`;
return [
{
id: 'about',
label: 'Основы',
title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
summary: isGateway
? 'Два способа направить трафик через это устройство.'
: 'Способ направить трафик выбранного приложения через VPN.',
paragraphs: isGateway
? [
`Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
`Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
]
: [
`Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
],
},
{
id: 'proxybridge',
label: 'Приложения',
title: 'ProxyBridge',
summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
steps: [
{
link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
after: ' с официальной страницы проекта.',
},
`Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
'Создайте правило, выберите нужное приложение и действие Proxy.',
'Включите ProxyBridge и запустите приложение заново.',
],
note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
},
{
id: 'switchyomega',
label: 'Браузер',
title: 'SwitchyOmega',
summary: 'Переключает прокси-профили только для браузера.',
steps: [
{
link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
after: ' и откройте его настройки.',
},
'Создайте профиль Proxy Profile.',
`Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
],
note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
},
{
id: 'vscode',
label: 'Редактор',
title: 'Visual Studio Code',
summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
steps: [
'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
'Для отдельного запуска через SOCKS5 используйте команду ниже.',
{
link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
after: ' описывает также системный прокси, HTTP и параметры исключений.',
},
],
code: `code --proxy-server="${socksProxy}"`,
note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
},
...(isGateway ? [{
id: 'router',
label: 'Вся сеть',
title: 'Заменить Gateway в роутере',
summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
steps: [
`Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
`В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
],
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
}] : []),
];
}

View File

@@ -0,0 +1,92 @@
export const initialHarborState = {
snapshot: null,
pendingServerId: '',
transport: {
bootStatus: 'loading',
lastSuccessfulSyncAt: null,
consecutiveFailures: 0,
stale: false,
error: null,
},
};
export const STALE_FAILURE_THRESHOLD = 3;
export function compatibleSnapshot(snapshot) {
return snapshot?.apiVersion === 1 &&
Number.isSafeInteger(snapshot.revision) &&
typeof snapshot.selection?.desiredServerId === 'string' &&
Array.isArray(snapshot.servers);
}
export function classifySyncError(error) {
const status = Number(error?.status) || 0;
if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api';
if (error?.code === 'CONTROL_UNREACHABLE' || error?.name === 'TypeError' || status >= 500) return 'control-unreachable';
return 'fatal';
}
function reconcilePendingServer(pendingServerId, snapshot) {
if (!pendingServerId || snapshot.selection.desiredServerId === pendingServerId) return '';
return snapshot.servers.some((server) => server.id === pendingServerId)
? pendingServerId
: '';
}
export function harborReducer(current, action) {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
: { ...current, pendingServerId: action.serverId };
}
if (action.type === 'clear-pending-server') {
return current.pendingServerId ? { ...current, pendingServerId: '' } : current;
}
if (action.type === 'retry-sync') {
return current.snapshot ? current : {
...current,
transport: { ...current.transport, bootStatus: 'loading', error: null },
};
}
if (action.type === 'sync-failed') {
const consecutiveFailures = current.transport.consecutiveFailures + 1;
const bootStatus = classifySyncError(action.error);
return {
...current,
transport: {
...current.transport,
bootStatus: current.snapshot ? 'ready' : bootStatus,
consecutiveFailures,
stale: Boolean(current.snapshot) && (
bootStatus === 'incompatible-api' || consecutiveFailures >= STALE_FAILURE_THRESHOLD
),
error: {
kind: bootStatus,
message: action.error?.message || 'Неизвестная ошибка',
},
},
};
}
if (action.type !== 'sync-succeeded') return current;
const snapshot = action.snapshot;
const newer = !current.snapshot || snapshot.revision > current.snapshot.revision;
return {
snapshot: newer ? snapshot : current.snapshot,
pendingServerId: newer
? reconcilePendingServer(current.pendingServerId, snapshot)
: current.pendingServerId,
transport: {
bootStatus: 'ready',
lastSuccessfulSyncAt: action.receivedAt,
consecutiveFailures: 0,
stale: false,
error: null,
},
};
}

View File

@@ -0,0 +1,42 @@
export const OPERATION_CONFLICTS = Object.freeze({
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
});
export function operationBlocked(operations, key) {
if (operations[key]?.status === 'running') return true;
return (OPERATION_CONFLICTS[key] || []).some(
(conflict) => operations[conflict]?.status === 'running',
);
}
export function createOperationRegistry(onChange = () => {}, now = () => new Date().toISOString()) {
let operations = {};
const inFlight = new Map();
function run(key, action) {
if (inFlight.has(key)) return inFlight.get(key);
if (operationBlocked(operations, key)) return Promise.resolve(false);
operations = { ...operations, [key]: { status: 'running', startedAt: now() } };
onChange(operations);
const promise = Promise.resolve()
.then(action)
.finally(() => {
const { [key]: completed, ...remaining } = operations;
operations = remaining;
inFlight.delete(key);
onChange(operations);
});
inFlight.set(key, promise);
return promise;
}
return { run, getSnapshot: () => operations };
}

3734
src/web/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
export function connectionAction({ connected, selectedServerId, configExists }) {
if (connected) return { type: 'stop' };
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' };
return null;
}
export function formatConnectionDuration(startedAt, now = Date.now()) {
const { totalHours, minutes, seconds } = connectionDurationParts(startedAt, now);
return [totalHours, minutes.value, seconds.value]
.map((part) => String(part).padStart(2, '0'))
.join(':');
}
function durationLabel(value, forms) {
const mod10 = value % 10;
const mod100 = value % 100;
return mod10 === 1 && mod100 !== 11
? forms[0]
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? forms[1] : forms[2];
}
export function connectionDurationParts(startedAt, now = Date.now()) {
const started = Date.parse(startedAt);
const totalSeconds = Number.isFinite(started)
? Math.max(0, Math.floor((now - started) / 1000))
: 0;
const days = Math.floor(totalSeconds / 86_400);
const totalHours = Math.floor(totalSeconds / 3600);
const hours = Math.floor((totalSeconds % 86_400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return {
totalHours,
days: { value: days, label: durationLabel(days, ['день', 'дня', 'дней']) },
hours: { value: hours, label: durationLabel(hours, ['час', 'часа', 'часов']) },
minutes: { value: minutes, label: durationLabel(minutes, ['минута', 'минуты', 'минут']) },
seconds: { value: seconds, label: durationLabel(seconds, ['секунда', 'секунды', 'секунд']) },
};
}
export function formatConnectionDurationWords(startedAt, now = Date.now()) {
const { days, hours, minutes, seconds } = connectionDurationParts(startedAt, now);
return [
days.value && `${days.value} ${days.label}`,
hours.value && `${hours.value} ${hours.label}`,
minutes.value && `${minutes.value} ${minutes.label}`,
`${seconds.value} ${seconds.label}`,
].filter(Boolean).join(' ');
}
export function subscriptionDomain(subscriptionHost) {
const value = String(subscriptionHost || '');
try {
return new URL(value).host;
} catch {
return value.split('/')[0];
}
}
export function isSubscriptionUrlValid(value) {
try {
return ['http:', 'https:'].includes(new URL(String(value).trim()).protocol);
} catch {
return false;
}
}
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return {
socks5: `socks5://${urlHost}:${port}`,
http: `http://${urlHost}:${port}`,
};
}
export async function copyText(text, options = {}) {
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
const documentRef = options.documentRef ?? globalThis.document;
if (documentRef?.execCommand) {
const textarea = documentRef.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
documentRef.body.append(textarea);
textarea.select();
const copied = documentRef.execCommand('copy');
textarea.remove();
if (copied) return;
}
if (!clipboard?.writeText) throw new Error('Copy failed');
await clipboard.writeText(text);
}
export function subscriptionUsage(userInfo = {}) {
const upload = Math.max(0, Number(userInfo.upload) || 0);
const download = Math.max(0, Number(userInfo.download) || 0);
const total = Math.max(0, Number(userInfo.total) || 0);
const used = upload + download;
return {
upload,
download,
total,
used,
percent: total ? Math.min(100, (used / total) * 100) : null,
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
};
}
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
if (!Number.isFinite(days)) return '';
if (days <= 0) return 'срок истёк';
const mod10 = days % 10;
const mod100 = days % 100;
const unit = mod10 === 1 && mod100 !== 11
? 'день'
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
}

31
src/web/utils/format.js Normal file
View File

@@ -0,0 +1,31 @@
export function formatBytes(value) {
if (!value) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatRelative(iso) {
if (!iso) return "";
const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return "";
const diff = Math.max(0, Date.now() - ts);
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec} с назад`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min} мин назад`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} ч назад`;
const days = Math.floor(hr / 24);
return `${days} дн назад`;
}
export function formatTime(iso) {
if (!iso) return "";
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
}

View File

@@ -0,0 +1,31 @@
export const SERVER_RESULT_WINDOW = 60;
const searchable = (server) => [
server.label,
server.host,
server.country,
server.city,
server.provider,
server.protocol,
].filter(Boolean).join(' ').toLocaleLowerCase('ru');
export function filterServers(servers, query) {
const needle = String(query || '').trim().toLocaleLowerCase('ru');
return needle ? servers.filter((server) => searchable(server).includes(needle)) : servers;
}
export function serverGroup(server) {
return server.country || server.provider || 'Другие';
}
export function groupServers(servers) {
return [...servers.reduce((groups, server) => {
const name = serverGroup(server);
groups.set(name, [...(groups.get(name) || []), server]);
return groups;
}, new Map())];
}
export function autoServer(servers) {
return [...servers].sort((left, right) => left.id.localeCompare(right.id))[0] || null;
}

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubscriptionBody } from '../src/server/subscription.js';
import { createStateSnapshot, normalizeStoredState } from '../src/shared/contracts/state.js';
const outbound = (index) => ({
type: 'vless',
tag: index % 2 ? 'Amsterdam' : 'Frankfurt',
server: `vpn-${index}.example.test`,
server_port: 443,
});
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
test('data invariant: 1, 30 and 300 servers keep unique IDs across reorder and duplicate labels', () => {
for (const size of [1, 30, 300]) {
const source = Array.from({ length: size }, (_, index) => outbound(index));
const before = parse(source).servers;
const after = parse([...source].reverse()).servers;
assert.equal(before.length, size);
assert.equal(new Set(before.map((server) => server.id)).size, size);
assert.deepEqual(
after.map((server) => server.id).sort(),
before.map((server) => server.id).sort(),
);
}
});
test('data invariant: one canonical snapshot owns server selection and never exposes the subscription URL', () => {
const servers = parse(Array.from({ length: 30 }, (_, index) => outbound(index))).servers;
const selectedServerId = servers[17].id;
const stored = normalizeStoredState({
revision: 9,
subscriptionUrl: 'https://provider.example/private-token',
servers,
selectedServerId,
appliedServerId: selectedServerId,
});
const snapshot = createStateSnapshot({
storedState: stored,
runtime: { running: false },
appMode: 'client',
configExists: true,
subscriptionHost: 'provider.example/…',
now: new Date('2026-07-12T12:00:00.000Z'),
});
assert.equal(snapshot.revision, 9);
assert.deepEqual(snapshot.selection, { desiredServerId: selectedServerId, appliedServerId: selectedServerId });
assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test');
assert.equal(JSON.stringify(snapshot).includes('private-token'), false);
});

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
test('one-line installer extracts the archive and hands it to the macOS installer', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-install-'));
try {
const source = path.join(tmp, 'fixture', 'harbor-net');
fs.mkdirSync(path.join(source, 'scripts'), { recursive: true });
fs.writeFileSync(path.join(source, 'marker'), 'ok');
fs.writeFileSync(path.join(source, 'scripts', 'install-macos-client.sh'), [
'#!/bin/bash',
'set -eu',
'test "$(cat "$VPN_PROXY_SOURCE_DIR/marker")" = ok',
'printf bootstrap-ok',
].join('\n'));
const archive = path.join(tmp, 'source.tar.gz');
execFileSync('tar', ['-czf', archive, '-C', path.dirname(source), path.basename(source)]);
const bin = path.join(tmp, 'bin');
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'curl'), '#!/bin/sh\ncat "$FIXTURE_ARCHIVE"\n', { mode: 0o755 });
const output = execFileSync('sh', [path.join(root, 'install.sh')], {
encoding: 'utf8',
env: {
...process.env,
FIXTURE_ARCHIVE: archive,
PATH: `${bin}:/usr/bin:/bin`,
VPN_PROXY_ARCHIVE_URL: 'https://example.invalid/source.tar.gz',
},
});
assert.equal(output, 'bootstrap-ok');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDataplaneClient } from '../../src/server/dataplaneClient.js';
test('control uses the dataplane socket protocol', async () => {
const requests = [];
const send = async (socketPath, pathname, method) => {
requests.push(`${method} ${pathname} ${socketPath}`);
return {
running: pathname !== '/stop',
startedAt: 'now',
gatewayBackendVersion: '0.1.0',
singBoxVersion: '1.12.13',
};
};
const client = createDataplaneClient('/run/dataplane.sock', send);
const status = await client.refresh();
assert.equal(status.running, true);
assert.equal(status.gatewayBackendVersion, '0.1.0');
assert.equal(status.singBoxVersion, '1.12.13');
await client.apply();
await client.restart();
assert.equal((await client.stop()).running, false);
assert.deepEqual(requests, [
'GET /status /run/dataplane.sock',
'POST /apply /run/dataplane.sock',
'POST /restart /run/dataplane.sock',
'POST /stop /run/dataplane.sock',
]);
});

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
const compose = fs.readFileSync(path.join(root, 'docker-compose.gateway.yml'), 'utf8');
const deploy = fs.readFileSync(path.join(root, 'scripts/deploy-gateway.sh'), 'utf8');
const workflow = fs.readFileSync(path.join(root, '.gitea/workflows/gateway-build.yml'), 'utf8');
const dockerfiles = ['Dockerfile', 'Dockerfile.client']
.map((file) => fs.readFileSync(path.join(root, file), 'utf8'));
test('gateway deploy updates control without recreating dataplane', () => {
assert.match(compose, /vpn-proxy-control:/);
assert.match(compose, /vpn-proxy-dataplane:/);
assert.match(compose, /DATAPLANE_SOCKET: \/run\/vpn-proxy\/dataplane\.sock/);
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singboxRuntime\|version\)/);
assert.match(workflow, /src\/shared\/errors/);
assert.doesNotMatch(workflow, /dataplaneClient/);
});
test('runtime images include shared server modules', () => {
for (const dockerfile of dockerfiles) {
assert.match(dockerfile, /COPY src\/shared \/app\/src\/shared/);
}
});

View File

@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const entrypoint = fs.readFileSync(
path.resolve(import.meta.dirname, '../../entrypoint.sh'),
'utf8',
);
test('gateway keeps direct forwarding active while TProxy interception is switchable', () => {
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
assert.match(entrypoint, /-I FORWARD 1 -j "\$GATEWAY_FORWARD_CHAIN"/);
assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/);
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -i 'br-\+' -j RETURN/);
assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/);
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
});
test('control bypasses host routing while dataplane owns it', () => {
assert.match(entrypoint, /APP_COMPONENT.*control/);
assert.match(entrypoint, /exec node \/app\/src\/server\/index\.js/);
assert.match(entrypoint, /APP_COMPONENT.*dataplane/);
assert.match(entrypoint, /node \/app\/src\/server\/dataplane\.js/);
});

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ERROR_DEFINITIONS,
HarborError,
normalizeHarborError,
} from '../../src/shared/errors.js';
test('every Harbor error code has stable Russian copy and retry policy', () => {
for (const [code, definition] of Object.entries(ERROR_DEFINITIONS)) {
const error = new HarborError(code);
assert.equal(error.code, code);
assert.equal(error.message, definition.message);
assert.equal(error.retryable, definition.retryable);
assert.match(error.message, /[А-Яа-яЁё]/);
assert.equal(typeof error.status, 'number');
}
});
test('unknown failures use the safe non-retryable fallback', () => {
const error = normalizeHarborError(new Error('secret internal failure'));
assert.equal(error.code, 'UNKNOWN');
assert.equal(error.message, ERROR_DEFINITIONS.UNKNOWN.message);
assert.equal(error.retryable, false);
assert.equal(error.message.includes('secret'), false);
});

View File

@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
applyGatewayPreference,
buildGatewayPresence,
createGatewayAutoState,
nextGatewayAutoState,
probeGatewayPresence,
readHostNetworkState,
verifyGatewayPresence,
} from '../../src/server/gatewayPresence.js';
import { createStateSnapshot } from '../../src/shared/contracts/state.js';
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
const nonce = '0123456789abcdef0123456789abcdef';
test('Gateway presence is authenticated by the shared subscription secret', async () => {
const payload = buildGatewayPresence({
appMode: 'gateway',
subscriptionUrl,
gatewayId: 'gateway-1',
nonce,
});
assert.equal(verifyGatewayPresence(payload, { subscriptionUrl, nonce }), true);
assert.equal(verifyGatewayPresence(payload, {
subscriptionUrl: 'https://subscription.example/fedcba9876543210fedcba9876543210',
nonce,
}), false);
const result = await probeGatewayPresence({
gateway: '192.168.50.111',
subscriptionUrl,
port: 4567,
nonce,
fetchImpl: async (url) => {
assert.equal(
url,
`http://192.168.50.111:4567/api/gateway-presence?nonce=${nonce}`,
);
return { ok: true, json: async () => payload };
},
});
assert.equal(result.gatewayId, 'gateway-1');
assert.equal(result.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(Number.isFinite(Date.parse(result.verifiedAt)), true);
assert.equal(buildGatewayPresence({
appMode: 'gateway',
subscriptionUrl: 'https://subscription.example/public-feed',
gatewayId: 'gateway-1',
nonce,
}).available, false);
assert.equal(buildGatewayPresence({
appMode: 'gateway',
subscriptionUrl: 'https://subscription.example/0123456789abcdef',
gatewayId: 'gateway-1',
nonce,
}).available, true);
const sharedPublicValue = 'https://public.example/sing-box-configuration-v1';
const firstUrl = `${subscriptionUrl}?redirect=${encodeURIComponent(sharedPublicValue)}`;
const secondUrl = `https://subscription.example/fedcba9876543210fedcba9876543210?redirect=${encodeURIComponent(sharedPublicValue)}`;
const firstPayload = buildGatewayPresence({
appMode: 'gateway',
subscriptionUrl: firstUrl,
gatewayId: 'gateway-1',
nonce,
});
assert.equal(verifyGatewayPresence(firstPayload, {
subscriptionUrl: secondUrl,
nonce,
}), false);
});
test('verified Gateway stays active through transient discovery failures', () => {
const now = Date.now();
const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json');
fs.writeFileSync(statePath, JSON.stringify({
gateway: '192.168.50.111',
interface: 'en0',
mac: 'aa:bb:cc:dd:ee:ff',
observedAt: new Date(now).toISOString(),
}));
const network = readHostNetworkState(statePath, { now });
assert.equal(network.gateway, '192.168.50.111');
let state = nextGatewayAutoState(createGatewayAutoState(), {
network,
verifiedGateway: {
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
verifiedAt: new Date(now).toISOString(),
},
});
assert.equal(state.mode, 'gateway-direct');
assert.equal(state.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(state.lastVerifiedAt, new Date(now).toISOString());
state = nextGatewayAutoState(state, { network });
state = nextGatewayAutoState(state, { network });
assert.equal(state.mode, 'gateway-direct');
state = nextGatewayAutoState(state, { network });
assert.equal(state.mode, 'gateway-direct');
assert.equal(state.gatewayId, 'gateway-1');
assert.equal(state.failures, 3);
state = nextGatewayAutoState(state, {
network: null,
error: 'host snapshot stale',
});
assert.equal(state.mode, 'gateway-direct');
assert.equal(state.gatewayId, 'gateway-1');
assert.equal(state.lastError, 'host snapshot stale');
assert.equal(applyGatewayPreference(state, false).mode, 'local-vpn');
const newNetwork = { ...network, mac: '11:22:33:44:55:66' };
state = nextGatewayAutoState(state, { network: newNetwork });
assert.equal(state.mode, 'local-vpn');
assert.equal(state.gatewayId, '');
assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null);
fs.writeFileSync(statePath, JSON.stringify({
gateway: '192.168.50.111',
interface: 'en0',
mac: '',
observedAt: new Date(now).toISOString(),
}));
assert.equal(readHostNetworkState(statePath, { now }), null);
});
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
const storedState = {
revision: 1,
subscriptionUrl,
gatewayAutoEnabled: true,
};
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
storedState: stored,
runtime: { running: true },
gatewayAuto,
appMode: 'client',
configExists: true,
subscriptionHost: 'subscription.example/…',
now: new Date('2026-07-13T12:00:00.000Z'),
}).route;
const fresh = {
...createGatewayAutoState(),
mode: 'gateway-direct',
gateway: { gateway: '192.168.50.111' },
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
lastVerifiedAt: '2026-07-13T11:59:59.000Z',
};
const found = snapshot(fresh);
assert.equal(found.mode, 'gateway-direct');
assert.equal(found.reason, 'gateway-found');
assert.equal(found.gatewayAddress, '192.168.50.111');
assert.equal(found.gatewayUiOrigin, 'http://192.168.50.111:4567');
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
assert.equal(found.autoEnabled, true);
assert.equal(found.fallbackPreference, 'local-vpn');
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
});
test('client can ignore and restore a verified Gateway without losing discovery', () => {
const detected = {
...createGatewayAutoState(),
mode: 'gateway-direct',
gatewayId: 'gateway-1',
};
const ignored = applyGatewayPreference(detected, false);
assert.equal(ignored.mode, 'local-vpn');
assert.equal(ignored.gatewayId, 'gateway-1');
assert.equal(applyGatewayPreference(ignored, true).mode, 'gateway-direct');
assert.equal(applyGatewayPreference(createGatewayAutoState(), true).mode, 'local-vpn');
});

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { setGatewayInterception } from '../../src/server/gatewayRouting.js';
test('gateway switches only the TProxy PREROUTING jump', () => {
const calls = [];
const missing = (command, args) => {
calls.push([command, args]);
return { status: args.includes('-C') ? 1 : 0, stderr: '' };
};
setGatewayInterception(true, 'VPN_PROXY_TPROXY', missing);
assert.deepEqual(calls.map(([, args]) => args), [
['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
['-w', '-t', 'mangle', '-I', 'PREROUTING', '1', '-j', 'VPN_PROXY_TPROXY'],
]);
calls.length = 0;
const existing = (command, args) => {
calls.push([command, args]);
return { status: 0, stderr: '' };
};
setGatewayInterception(false, 'VPN_PROXY_TPROXY', existing);
assert.deepEqual(calls.map(([, args]) => args), [
['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
['-w', '-t', 'mangle', '-D', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
]);
});

View File

@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { checkServerHealth } from '../../src/server/serverHealth.js';
test('server health checks cap count and concurrency', async () => {
const servers = Array.from({ length: 300 }, (_, index) => ({
id: `srv-${index}`,
label: `Server ${index}`,
host: `server-${index}.example`,
port: 443,
}));
let active = 0;
let peak = 0;
const results = await checkServerHealth(servers, async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return { ok: true, latency: 1 };
});
assert.equal(results.length, 30);
assert.equal(peak, 4);
assert.deepEqual(results.map(({ id }) => id), servers.slice(0, 30).map(({ id }) => id));
});

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
buildSharedProxyInfo,
} = await import("../../src/server/sharedProxy.js");
test("gateway shared proxy info exposes host and socks proxy when running", () => {
const info = buildSharedProxyInfo({
appMode: "gateway",
proxyPort: 8080,
running: true,
hostHeader: "192.168.50.111:3456",
});
assert.equal(info.available, true);
assert.deepEqual(info.proxy, {
host: "192.168.50.111",
port: 8080,
protocol: "socks5",
httpUrl: "http://192.168.50.111:8080",
socksUrl: "socks5://192.168.50.111:8080",
});
});

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
process.env.APP_MODE = 'client';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-client-test-'));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?client=${Date.now()}`);
const subscriptionConfig = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [
{ type: 'domain_suffix', value: 'ru', enabled: true },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_keyword', value: 'cdn', enabled: false },
],
});
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
assert.equal(config.inbounds[0].listen_port, 8082);
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ domain: ['example.com'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
assert.equal(config.route.auto_detect_interface, undefined);
});
test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
clientDirect: true,
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'direct' },
]);
assert.equal(config.route.final, 'direct');
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['direct', 'block']);
});

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
process.env.APP_MODE = 'gateway';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?gateway=${Date.now()}`);
const subscriptionConfig = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
});
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.route.rules, [
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
]);
assert.equal(config.route.final, 'test-vpn');
});

View File

@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createSingboxRuntime } from '../../src/server/singboxRuntime.js';
async function waitForStarts(filePath, count) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8').length >= count) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`sing-box did not start ${count} time(s)`);
}
test('dataplane keeps sing-box running when the applied config is unchanged', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-dataplane-'));
const binDir = path.join(dir, 'bin');
const configPath = path.join(dir, 'config.json');
const startsPath = path.join(dir, 'starts');
fs.mkdirSync(binDir);
fs.writeFileSync(configPath, '{}');
fs.writeFileSync(path.join(binDir, 'sing-box'), `#!/usr/bin/env node
if (process.argv[2] === 'check') process.exit(0);
require('node:fs').appendFileSync(process.env.SINGBOX_TEST_STARTS, 'x');
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(path.join(binDir, 'sing-box'), 0o755);
const previousPath = process.env.PATH;
process.env.PATH = `${binDir}:${previousPath}`;
process.env.SINGBOX_TEST_STARTS = startsPath;
const runtime = createSingboxRuntime({ configPath });
t.after(async () => {
await runtime.stop();
process.env.PATH = previousPath;
delete process.env.SINGBOX_TEST_STARTS;
fs.rmSync(dir, { recursive: true, force: true });
});
await runtime.apply();
await waitForStarts(startsPath, 1);
await runtime.apply();
assert.equal(fs.readFileSync(startsPath, 'utf8'), 'x');
fs.writeFileSync(configPath, '{"changed":true}');
await runtime.apply();
await waitForStarts(startsPath, 2);
assert.equal(fs.readFileSync(startsPath, 'utf8'), 'xx');
});

View File

@@ -0,0 +1,543 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertStateSnapshot,
createStateSnapshot,
normalizeStoredState,
} from '../../src/shared/contracts/state.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
const root = path.resolve(import.meta.dirname, '../..');
function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
async function freePort() {
const server = http.createServer();
const port = await listen(server);
await close(server);
return port;
}
async function rawRequest(port, pathname, method = 'GET', body) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json();
return { response, payload };
}
async function request(port, pathname, method = 'GET', body) {
const { response, payload } = await rawRequest(port, pathname, method, body);
assert.equal(response.ok, true, JSON.stringify(payload));
return payload;
}
async function waitForState(port, child, stderr) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
try {
return await request(port, '/api/state');
} catch {
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
throw new Error(`Harbor did not start: ${stderr()}`);
}
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({
subscriptionUrl: 'https://provider.example/subscription/test',
selectedTag: ' legacy ',
servers: [legacyServer],
});
const snapshot = createStateSnapshot({
storedState: stored,
runtime: { running: true, startedAt: '2026-07-11T10:00:00.000Z' },
gatewayAuto: null,
appMode: 'gateway',
configExists: true,
subscriptionHost: 'provider.example/…',
now: new Date('2026-07-11T12:00:00.000Z'),
});
assert.equal(snapshot.apiVersion, 1);
assert.deepEqual(snapshot.selection, {
desiredServerId: legacyServerId,
appliedServerId: legacyServerId,
});
assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
assert.throws(
() => assertStateSnapshot({ ...snapshot, revision: -1 }),
/Invalid Harbor state snapshot v1/,
);
});
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
const port = await freePort();
const subscriptionUrl = 'https://provider.example/disabled';
const rejectedServer = {
type: 'vless',
tag: '🚫 Subscription disabled',
server: '0.0.0.0',
server_port: 1,
};
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl,
selectedTag: rejectedServer.tag,
servers: [rejectedServer],
routeRules,
}));
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
url: subscriptionUrl,
config: { outbounds: [rejectedServer] },
}));
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
const child = spawn(process.execPath, ['src/server/index.js'], {
cwd: root,
env: {
...process.env,
APP_MODE: 'client',
DATA_DIR: dir,
PORT: String(port),
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
child.kill('SIGTERM');
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const state = await waitForState(port, child, () => stderr);
assert.equal(state.subscription.status, 'missing');
assert.equal(state.hasSubscription, false);
assert.deepEqual(state.servers, []);
assert.ok(state.route.localRules.some((rule) => (
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
)));
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
assert.equal(child.exitCode, null);
});
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin');
const config = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
const testServerId = createServerId(config.outbounds[0]);
fs.mkdirSync(binDir);
const singboxPath = path.join(binDir, 'sing-box');
const workingSingbox = `#!/usr/bin/env node
if (process.argv[2] === 'check') process.exit(0);
if (process.argv[2] === 'version') {
console.log('sing-box version 1.12.13');
process.exit(0);
}
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`;
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let trafficExhaustedNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') {
res.writeHead(503);
return res.end('unavailable');
}
if (req.url === '/invalid') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === '/traffic' || req.url === trafficExhaustedNextPath) {
trafficExhaustedNextPath = '';
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=60; download=40; total=100; expire=4102444800',
});
return res.end(JSON.stringify({
outbounds: [{
type: 'vless',
tag: 'Account unavailable',
server: '0.0.0.0',
server_port: 1,
}],
}));
}
if (req.url === '/expired') {
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100; expire=1',
});
return res.end(JSON.stringify(config));
}
if (req.url === '/disabled') {
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=0; download=0; total=100; expire=4102444800',
});
return res.end(JSON.stringify({
outbounds: [{
type: 'vless',
tag: '🚫 Subscription disabled',
server: '0.0.0.0',
server_port: 1,
}],
}));
}
if (req.url === invalidNextPath) {
invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100',
});
res.end(JSON.stringify(config));
});
const subscriptionPort = await listen(subscriptionServer);
const subscriptionUrl = `http://127.0.0.1:${subscriptionPort}/subscription/test`;
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl,
selectedTag: 'test-vpn',
servers: [{ tag: 'test-vpn', type: 'vless', server: 'vpn.example.test', server_port: 443 }],
}));
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
url: subscriptionUrl,
config,
}));
const port = await freePort();
const child = spawn(process.execPath, ['src/server/index.js'], {
cwd: root,
env: {
...process.env,
APP_MODE: 'client',
DATA_DIR: dir,
PORT: String(port),
PATH: `${binDir}:${process.env.PATH}`,
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
SUBSCRIPTION_TIMEOUT_MS: '50',
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
child.kill('SIGTERM');
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
await close(subscriptionServer);
fs.rmSync(dir, { recursive: true, force: true });
});
const initial = await waitForState(port, child, () => stderr);
const version = await request(port, '/api/version');
assert.deepEqual(version, {
apiVersion: 1,
location: 'mac',
components: { macClient: HARBOR_VERSIONS.macClient },
runtime: { singBox: '1.12.13' },
});
assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, testServerId);
assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
assert.equal(initial.route.localRulesRevision, 0);
assert.equal(initial.route.localRulesPendingRestart, false);
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
const stateKeys = Object.keys(initial).sort();
let revision = initial.revision;
let rulesRevision = initial.route.localRulesRevision;
const invalidSubscription = await rawRequest(
port,
'/api/subscription/validate',
'POST',
{ url: 'not-a-url' },
);
assert.equal(invalidSubscription.response.status, 400);
assert.deepEqual(
{
code: invalidSubscription.payload.error.code,
retryable: invalidSubscription.payload.error.retryable,
},
{ code: 'SUBSCRIPTION_INVALID', retryable: false },
);
assert.equal(typeof invalidSubscription.payload.error.correlationId, 'string');
const providerUnavailable = await rawRequest(
port,
'/api/subscription/validate',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}/unavailable` },
);
assert.equal(providerUnavailable.response.status, 502);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
['/expired', 'SUBSCRIPTION_EXPIRED'],
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
['/disabled', 'SUBSCRIPTION_DISABLED'],
]) {
const failedImport = await rawRequest(
port,
'/api/subscription/fetch',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
);
assert.equal(failedImport.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
);
}
trafficExhaustedNextPath = '/subscription/test';
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(exhaustedRefresh.response.status, 400);
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
const stateAfterExhaustedRefresh = await request(port, '/api/state');
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
revision = stateAfterExhaustedRefresh.revision;
const missingServer = await rawRequest(
port,
'/api/apply',
'POST',
{ selectedTag: 'missing-server' },
);
assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body);
assert.deepEqual(Object.keys(result.state).sort(), stateKeys);
assertStateSnapshot(result.state);
return result;
}
async function mutation(pathname, method = 'POST', body) {
const result = await stateResponse(pathname, method, body);
assert.ok(result.state.revision > revision, `${pathname} did not increase revision`);
revision = result.state.revision;
return result;
}
const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, {
desiredServerId: testServerId,
appliedServerId: testServerId,
});
assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
let routed = await mutation('/api/route-rules', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
],
});
rulesRevision = routed.state.route.localRulesRevision;
assert.deepEqual(routed.state.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: false },
{ type: 'domain', value: 'example.com', enabled: true },
{ type: 'domain_suffix', value: 'example.org', enabled: true },
]);
assert.equal(routed.state.route.localRulesPendingRestart, false);
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: testServerId },
]);
await mutation('/api/singbox/stop');
routed = await mutation('/api/route-rules', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [
...routed.state.route.localRules,
{ type: 'domain_keyword', value: 'media', enabled: true },
],
});
rulesRevision = routed.state.route.localRulesRevision;
assert.equal(routed.state.connection.process, 'stopped');
assert.equal(routed.state.route.localRulesPendingRestart, true);
assert.deepEqual(routed.state.route.activeLocalRules, []);
const restartedRules = await mutation('/api/singbox/restart');
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules);
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [{ type: 'domain_regex', value: '.*' }],
});
assert.equal(invalidRules.response.status, 400);
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
assert.equal((await request(port, '/api/state')).revision, revision);
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRulesRevision: 0,
rules: [],
});
assert.equal(staleRules.response.status, 409);
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRevision: revision,
rules: routed.state.route.localRules,
});
assert.equal(legacyNoop.response.status, 200);
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'check') {
const config = fs.readFileSync(process.argv[4], 'utf8');
process.exit(config.includes('broken.example') ? 1 : 0);
}
if (process.argv[2] === 'version') process.exit(0);
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(singboxPath, 0o755);
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
expectedRulesRevision: rulesRevision,
rules: [{ type: 'domain', value: 'broken.example' }],
});
assert.equal(failedRules.response.status, 422);
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
const rolledBack = await request(port, '/api/state');
assert.deepEqual(rolledBack.route.localRules, routed.state.route.localRules);
assert.equal(rolledBack.connection.process, 'running');
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), workingConfig);
revision = rolledBack.revision;
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
if (process.argv[2] === 'check') {
require('node:fs').unlinkSync(process.argv[1]);
process.exit(0);
}
`);
fs.chmodSync(singboxPath, 0o755);
const processFailure = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(processFailure.response.status, 503);
assert.equal(processFailure.payload.error.code, 'PROCESS_START_FAILED');
assert.equal(processFailure.payload.error.retryable, true);
fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755);
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing');
assert.equal(forgotten.state.servers.length, 0);
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
assert.equal(missingConfig.response.status, 422);
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
assert.equal(missingConfig.payload.error.retryable, false);
});

View File

@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
atomicWriteJson,
createStateStore,
STATE_SCHEMA_VERSION,
} from '../../src/server/services/stateStore.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
const fixture = (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return path.join(directory, 'state.json');
};
test('data invariant: failure before rename preserves the last successful file', (t) => {
const filePath = fixture(t);
atomicWriteJson(filePath, { revision: 1 });
assert.throws(
() => atomicWriteJson(filePath, { revision: 2 }, {
beforeRename: () => { throw new Error('injected failure'); },
}),
/injected failure/,
);
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')), { revision: 1 });
assert.equal(
fs.readdirSync(path.dirname(filePath)).some((name) => name.endsWith('.tmp')),
false,
);
});
test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) => {
const filePath = fixture(t);
const legacy = {
schemaVersion: 2,
revision: 7,
selectedTag: 'nl',
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
};
fs.writeFileSync(filePath, JSON.stringify(legacy));
const store = createStateStore(filePath, {
now: () => new Date('2026-07-11T12:00:00.000Z'),
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
assert.deepEqual(migrated.routeRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true },
]);
assert.equal(migrated.appliedTag, 'nl');
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 3,
selectedTag: 'Amsterdam',
servers: [
{ tag: 'Amsterdam', type: 'vless', server: 'nl-1.example', server_port: 443 },
{ tag: 'Amsterdam', type: 'vless', server: 'nl-2.example', server_port: 443 },
],
}));
const migrated = createStateStore(filePath).read();
assert.equal(migrated.selectedServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.servers.length, 2);
});
test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken');
const store = createStateStore(filePath, {
now: () => new Date('2026-07-11T12:00:00.000Z'),
});
const recovered = store.read();
assert.equal(recovered.schemaVersion, STATE_SCHEMA_VERSION);
assert.equal(recovered.revision, 0);
assert.equal(store.recovery.kind, 'corrupt-json');
assert.equal(fs.readFileSync(store.recovery.backupPath, 'utf8'), '{broken');
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
});
test('concurrent updates are serialized without lost values', async (t) => {
const store = createStateStore(fixture(t));
store.read();
await Promise.all(Array.from({ length: 50 }, () => Promise.resolve().then(() => (
store.update((state) => ({ ...state, counter: (state.counter || 0) + 1 }))
))));
assert.equal(store.read().counter, 50);
assert.throws(() => store.update(async (state) => state), /must be synchronous/);
});

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