104 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
178 changed files with 11621 additions and 35822 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."

View File

@@ -1,6 +1,6 @@
node_modules
.vpn-proxy
_archive
.runtime
.git
.gitea
.github

View File

@@ -1,8 +1,8 @@
PORT=3456
APP_MODE=gateway
CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT_START=8080
CLIENT_PROXY_PORT_END=8090
CLIENT_PROXY_PORT=8082
HARBOR_GATEWAY_CONTROL_PORT=3456
BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.12.13
INSTALL_RUNTIME_DEPS=true
@@ -13,5 +13,7 @@ TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
ROUTING_RU_DIRECT=true
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

@@ -35,6 +35,8 @@ jobs:
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 }}"
@@ -73,11 +75,15 @@ jobs:
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
--build-arg INSTALL_RUNTIME_DEPS=false \
--build-arg INSTALL_SINGBOX=false \
-t "${IMAGE}:latest" \
-t "${IMAGE}:${{ gitea.sha }}" \
-t "${CONTROL_IMAGE}:latest" \
-t "${CONTROL_IMAGE}:${{ gitea.sha }}" \
-t "${DATAPLANE_IMAGE}:latest" \
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \
.
docker push "${IMAGE}:latest"
docker push "${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
@@ -101,7 +107,19 @@ jobs:
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 }}" GATEWAY_IMAGE="${IMAGE}:${{ gitea.sha }}" bash scripts/deploy-gateway.sh
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
CONTROL_IMAGE="${CONTROL_IMAGE}" \
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
bash scripts/deploy-gateway.sh

8
.gitignore vendored
View File

@@ -1,14 +1,14 @@
# Local archive with the previous implementation and runtime secrets
_archive/
# Runtime state
.env
*.env.local
data/
.vpn-proxy/
.superpowers/
.runtime/
.worktrees/
# Local roadmap and task workspace
/workpack/
# Node/Vite
node_modules/
dist/

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.

View File

@@ -7,13 +7,12 @@ 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 ipset iproute2 nodejs dumb-init \
&& 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 \
&& command -v ipset >/dev/null; \
&& command -v iptables >/dev/null; \
fi
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
@@ -36,6 +35,7 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
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 \
@@ -45,8 +45,6 @@ ENV PORT=3456 \
PROXY_PORT=8080 \
PROXY_BIND_IP=0.0.0.0 \
TPROXY_PORT=7895 \
DIRECT_BYPASS_CACHE=false \
RULE_SET_DOWNLOAD_DETOUR=vpn \
DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db

View File

@@ -7,6 +7,7 @@ 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}
@@ -33,6 +34,7 @@ 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 \
@@ -40,7 +42,7 @@ RUN chmod +x /entrypoint.client.sh \
ENV APP_MODE=client \
PORT=3456 \
PROXY_PORT=8080 \
PROXY_PORT=8082 \
PROXY_BIND_IP=0.0.0.0 \
DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \
@@ -49,6 +51,6 @@ ENV APP_MODE=client \
ROUTING_RU_DIRECT=true \
LOG_LEVEL=info
EXPOSE 3456 8080
EXPOSE 3456 8082
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]

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.

729
README.md
View File

@@ -1,519 +1,330 @@
# VPN Proxy
# Harbor
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`.
## macOS: локальный Docker-клиент
Проект работает в двух режимах:
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
| Режим | Где работает | Для чего нужен |
| --- | --- | --- |
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
В обоих режимах управление одинаковое: откройте веб-интерфейс, вставьте ссылку VPN-подписки, выберите сервер и нажмите кнопку подключения.
## Что понадобится
- ссылка на подписку от VPN-провайдера;
- Docker с командой `docker compose`;
- для ручной установки Gateway — Git;
- для Gateway — Linux-машина в одной локальной сети с устройствами;
- для Connect — Mac с запущенным Docker Desktop.
Harbor не является VPN-провайдером и не создаёт подписки самостоятельно.
## Что выбрать
Используйте **Harbor Connect**, если VPN нужен только приложениям на одном Mac.
Используйте **Harbor Gateway**, если нужно подключить телевизор, телефон, игровую приставку или сразу несколько устройств. Устройства можно направить через Gateway целиком либо настроить в отдельных приложениях общий прокси.
Оба режима можно использовать вместе. Дома Connect автоматически распознаёт настроенный Harbor Gateway и не запускает второй VPN-маршрут. В другой сети Connect возвращается к локальному VPN.
## Установка Harbor Gateway
### 1. Скачайте проект
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
git clone https://git.dokops.ru/dokril/vpn-proxy.git
cd vpn-proxy
```
После запуска по умолчанию:
- UI: `http://127.0.0.1:3456`
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
### 2. Создайте настройки
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
cp .env.example .env
```
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
Стандартные значения подходят для обычной домашней сети. При необходимости откройте `.env` в текстовом редакторе и измените порты.
### 3. Запустите Gateway
```bash
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_UI_PORT=3457 VPN_PROXY_CLIENT_PORT=18080 bash
docker compose -f docker-compose.gateway.yml up -d --build
```
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
Откройте в браузере:
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
```text
http://АДРЕС-GATEWAY:3456
```
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
### 4. Добавьте подписку
1. Вставьте ссылку VPN-подписки.
2. Нажмите «Сохранить подписку».
3. Выберите сервер.
4. Включите VPN.
После подключения Harbor покажет два варианта использования:
- **Gateway** — укажите IP-адрес Linux-машины как основной шлюз устройства. Через VPN пойдёт весь его интернет-трафик;
- **Gateway Proxy** — укажите адрес Linux-машины и порт `8080` в приложении. Поддерживаются HTTP и SOCKS5 на одном порту.
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
## Установка Harbor Connect на macOS
### 1. Запустите Docker Desktop
Установщик проверит наличие Docker, Docker Compose, `curl` и `tar`. Если Docker Desktop не запущен, установка остановится с понятным сообщением.
### 2. Запустите установщик
```bash
GET http://<gateway-ui-host>:3456/api/shared-proxy
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
```
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
Установщик:
Ручной запуск из checkout:
- сохранит рабочую копию в `~/.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
docker compose -f docker-compose.client.yml up -d --build
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
```
Перезапуск и логи:
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
## Локальные правила маршрутизации
После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер.
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
## Системный прокси macOS
Сначала посмотрите точное имя сетевого подключения:
```bash
networksetup -listallnetworkservices
```
Для подключения с именем `Wi-Fi` включите HTTP, HTTPS и SOCKS5-прокси:
```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
```
Чтобы отключить их:
```bash
networksetup -setwebproxystate Wi-Fi off
networksetup -setsecurewebproxystate Wi-Fi off
networksetup -setsocksfirewallproxystate Wi-Fi off
```
Если сетевое подключение называется иначе, замените `Wi-Fi` его точным именем.
## Автоматическое использование домашнего Gateway
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
Для этого:
1. добавьте одну и ту же ссылку подписки в Gateway и Connect;
2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
3. оставьте автоматический режим включённым в Harbor Connect.
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
## Повседневные команды
Все команды Gateway выполняются из каталога проекта. Команды Connect — из `~/.vpn-proxy-client`.
### Harbor Gateway
| Действие | Команда |
| --- | --- |
| Запустить или обновить после изменения файлов | `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
```bash
cd ~/.vpn-proxy-client
docker compose -f docker-compose.client.yml logs -f
docker compose -f docker-compose.client.yml restart
```
## Windows: standalone desktop client direction
| Действие | Команда |
| --- | --- |
| Обновить и снова запустить | `./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` |
Windows app routing lives in a separate Tauri 2 desktop utility, not as
`APP_MODE=windows` inside the current Node gateway/client server. The active
workspace slice is `apps/windows-client`.
Команда с `-v` удаляет подписку, выбранный сервер и другие сохранённые данные. Для обычной остановки используйте `down` без `-v`.
Active design documents:
## Обновление
- Product/tech brief: `docs/windows-client-product-tech-brief.md`
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`
- Windows client README: `apps/windows-client/README.md`
Target shape:
- Control App: compact Windows UI for status, profiles, targets, components,
logs, and diagnostics.
- Proxyfier Layer: adapter boundary with ProxiFyre as the first engine for
per-app TCP/UDP routing.
- Local sing-box: optional local runtime; external SOCKS5/HTTP targets must
work without it.
Development checks:
```powershell
cd apps/windows-client
npm install
npm run build
npm run tauri -- info
cd src-tauri
cargo test
```
Native Tauri build requires WebView2, Rust/rustup, and Visual Studio Build
Tools with MSVC and Windows SDK components. In the current checkpoint, frontend
builds pass, while native Rust/Tauri tests require that Windows toolchain.
The three Windows pieces are installed and operated separately:
```powershell
cd apps/windows-client
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
`-PlanOnly` returns structured JSON without install side effects. Real install
or service operations must be explicit; profile apply must not silently install
Proxyfier or Local sing-box.
Windows source configuration is owned by JSON under
`C:\ProgramData\VpnProxy\config`. Generated ProxiFyre and sing-box files under
`C:\ProgramData\VpnProxy\generated` are derived artifacts.
---
# VPN Proxy Gateway
Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
---
## Архитектура
```
Клиент (ПК/телефон)
│ TCP/UDP трафик
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
│ └─ FORWARD + MASQUERADE → обычный internet path
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
├─ приватные CIDR (RFC1918, ...) → RETURN
└─ TCP/UDP → TPROXY :7895
sing-box (tproxy inbound :7895)
роутинг по правилам
┌──────────┼──────────┐
▼ ▼ ▼
direct VPN out block
```
ПК-приложения, которым нужен VPN явно:
```
Windows app → ProxiFyre/Proxifier → gateway:8080 → sing-box mixed-in → global rules → default VPN
```
**Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса.
---
## Стек
| Слой | Технология |
| ---------------- | ------------------------------------------------------------- |
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
| Перехват трафика | iptables TProxy + iproute2 policy routing |
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
| Веб-интерфейс | React 18 + Vite 7, SPA |
---
## Как работает прозрачное проксирование
### 1. TProxy и policy routing
При старте контейнера `entrypoint.sh` настраивает ядро:
### Gateway
```bash
# Policy routing: пакеты с меткой TPROXY_MARK уходят через loopback
ip rule add fwmark 1 table 100
ip route replace local 0.0.0.0/0 dev lo table 100
# Цепочка iptables (порядок правил — критичен)
iptables -t mangle -N VPN_PROXY_TPROXY
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
iptables -N VPN_PROXY_FWD_BYPASS
iptables -t nat -N VPN_PROXY_NAT_BYPASS
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
-m mark --mark 1 → RETURN # уже помеченные пакеты
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
-p tcp → TPROXY :7895 mark 1
-p udp → TPROXY :7895 mark 1
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
git pull --ff-only
docker compose -f docker-compose.gateway.yml up -d --build
```
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
### Connect
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
### 2. Маршрутизация внутри sing-box
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
| Приоритет | Условие | Действие |
| --------- | ------------------------------------------- | ---------------------------------------- |
| 1 | `ip_is_private: true` | `direct` (защита LAN) |
| 2 | Global custom rules | `direct` / VPN / `block` для всех inbound |
| 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` |
| 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` |
| 5 | Proxy default для `mixed-in` | по умолчанию VPN |
| 6 | Transparent default для unknown devices | по умолчанию VPN |
| 7 | Всё остальное (`final`) | `direct` |
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
### 3. Bypass Mode (весь трафик напрямую)
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
---
## Direct Bypass Cache (ipset)
Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.
Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.
**Цепочка событий:**
1. sing-box маршрутизирует соединение как `direct`, пишет в лог:
`[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`
2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:
```bash
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
```
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
```
-m set --match-set vpn_direct_bypass dst → RETURN
```
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
4. Запись истекает через TTL (по умолчанию 1 час).
```
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
DIRECT_BYPASS_TTL=3600 # TTL в секундах
```
## Профили устройств
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
```json
{
"defaultTransparentMode": "vpn",
"proxyDefaultMode": "vpn",
"devices": [
{
"id": "gaming-pc",
"name": "Gaming PC",
"ip": "192.168.1.50",
"mac": "",
"mode": "direct",
"enabled": true
},
{
"id": "phone",
"name": "Phone",
"ip": "192.168.1.60",
"mode": "vpn",
"enabled": true
}
]
}
```
| Mode | Что делает |
| -------- | ----------------------------------------------------------------- |
| `direct` | fallback устройства после global rules → `direct` |
| `vpn` | fallback устройства после global rules → выбранный VPN |
| `block` | fallback устройства после global rules → `block` |
| `rules` | не задаёт fallback устройства; используется transparent default |
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
---
## Кастомные правила маршрутизации
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
| Поле | Тип | Описание |
| ---------------- | ---------------------------- | ------------------------------------------- |
| `name` | string | Название правила |
| `enabled` | bool | Вкл/выкл |
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
| `domains` | string[] | Точные домены (`example.com`) |
| `domainSuffixes` | string[] | Суффикс домена (`.example.com` + поддомены) |
| `domainKeywords` | string[] | Keyword в имени хоста |
| `ipCidrs` | string[] | IP-диапазоны CIDR |
| `ports` | string[] | Порты или диапазоны (`443`, `8000-9000`) |
| `networks` | `tcp` \| `udp` | Протокол |
| `ruleSets` | string[] | Ссылки на remote rule-set |
UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.
### Remote Rule Sets
В **Настройках** можно добавить произвольные rule-set:
```json
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
```
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
---
## Подписки
Поддерживаемые форматы:
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
---
## Просмотр трафика
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
### Парсинг логов sing-box
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
```
[router] match[2][my-rule] => outbound/direct[direct] ← имя правила
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag] ← соединение
```
1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
3. Тип трафика: `direct` / `vpn` / `block` по outbound
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`
### Группировка и сортировка
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
- **По частоте** — самые частые наверху (по умолчанию)
- **По времени** — последние наверху
---
## Проверка маршрута
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
1. private IP → direct
2. global custom rules
3. geoip-ru / geosite-category-ru → direct
4. `tproxy-in` + device default
5. `mixed-in` + proxy default
6. final → direct
---
## Быстрый старт
Повторно запустите однострочный установщик. Он обновит рабочую копию, снова спросит порт прокси и пересоберёт Connect:
```bash
# Сборка фронтенда
npm install && npm run build
# Запуск контейнера
docker compose -f docker-compose.gateway.yml up -d
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
```
Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
### Версии
Текущая версия всегда показана в правом нижнем углу интерфейса. 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`.
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
Для изменения версии используйте `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`.
## Настройки `.env`
Для большинства установок достаточно стандартных значений.
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `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` недостаточно.
## Если что-то не работает
### Интерфейс не открывается
Проверьте контейнер и журнал:
```bash
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
docker compose -f docker-compose.gateway.yml build
docker compose -f docker-compose.gateway.yml up -d
docker compose -f docker-compose.gateway.yml ps
docker compose -f docker-compose.gateway.yml logs --tail=100
```
Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
Для Connect замените имя файла на `docker-compose.client.yml` и выполняйте команду из `~/.vpn-proxy-client`.
### Прокси не отвечает
Убедитесь, что Harbor включён в интерфейсе, а приложение использует правильные адрес и порт. Для Connect это обычно `127.0.0.1:8082`; для Gateway — IP Linux-машины и порт `8080`.
### Connect не распознаёт Gateway
Проверьте три условия:
- Gateway является текущим основным шлюзом Mac;
- на обоих устройствах сохранена одна и та же подписка;
- с Mac открывается `http://АДРЕС-GATEWAY:3456`.
### Проверка конфигурации без запуска
```bash
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
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
```
Скрипт собирает image на `BUILD_HOST`, переносит его на `DEPLOY_HOST` через `docker save | docker load` и запускает без `docker pull`. Если `107`/`111` не являются SSH-алиасами, укажите реальные адреса, например `BUILD_HOST=root@192.168.1.107 DEPLOY_HOST=root@192.168.1.111`.
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:
### Локальное тестирование Harbor Connect
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
```bash
./scripts/build-runtime-base.sh
docker compose -f docker-compose.client.local.yml up -d --build
```
После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
UI доступен на `http://<gateway-ip>:3456`.
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
---
## Переменные окружения
| Переменная | По умолчанию | Описание |
| ------------------- | -------------------- | -------------------------------------- |
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` |
| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
| `PORT` | `3456` | Порт веб-интерфейса |
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
| `INSTALL_RUNTIME_DEPS` | `true` | Устанавливать runtime-пакеты в Docker build; `false` для подготовленного runtime base |
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
| `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен |
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
| `RULE_SET_DOWNLOAD_DETOUR` | `vpn` | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер |
| `PROXY_BIND_IP` | `0.0.0.0` | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway |
| `PROXY_FIREWALL` | `true` | Закрыть `PROXY_PORT` не из allowed CIDR |
| `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy |
---
## REST API
| Метод | Путь | Описание |
| --------- | ---------------------- | ------------------------------------ |
| `GET` | `/api/state` | Полное состояние системы |
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
| `POST` | `/api/subscription` | Загрузить подписку по URL |
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
| `GET` | `/api/servers` | Список серверов из кэша |
| `GET/PUT` | `/api/rules` | Кастомные правила |
| `GET/PUT` | `/api/devices` | Профили устройств и default fallback |
| `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set |
| `POST` | `/api/singbox/start` | Запустить sing-box |
| `POST` | `/api/singbox/stop` | Остановить sing-box |
| `POST` | `/api/singbox/restart` | Перезапустить sing-box |
| `POST` | `/api/bypass` | `{ enabled }` — bypass mode |
| `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша |
| `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш |
| `POST` | `/api/route/check` | Симулировать маршрут |
| `POST` | `/api/servers/ping` | TCP-пинг до хоста |
| `GET` | `/api/logs/stream` | SSE системных логов |
| `GET` | `/api/traffic/stream` | SSE трафика |
---
## Структура проекта
```
├── Dockerfile # debian + sing-box + ipset + node
├── entrypoint.sh # iptables/ipset setup → запуск node
├── docker-compose.gateway.yml
├── src/
│ ├── server/
│ │ ├── index.js # HTTP-сервер, управление sing-box, SSE
│ │ ├── singbox.js # генерация конфига sing-box
│ │ ├── subscription.js # парсинг подписок (JSON/VLESS/base64)
│ │ ├── routeMatcher.js # симулятор маршрутизации
│ │ ├── ping.js # TCP-пинг и DNS-resolve
│ │ └── config.js # настройки из env
│ └── web/
│ ├── App.jsx # корневой компонент, глобальный state
│ ├── api.js # обёртка fetch для API
│ └── components/
│ ├── OverviewPage.jsx # дашборд, bypass-toggle
│ ├── LogsPage.jsx # трафик + системные логи
│ ├── RoutingPage.jsx # кастомные правила
│ ├── ServersPage.jsx # подписка и выбор сервера
│ ├── SettingsPage.jsx # rule-sets и настройки
│ └── RouteChecker.jsx # проверка маршрута
└── docs/
└── roadmap.md
```bash
docker compose -f docker-compose.client.local.yml down -v
```
## Ограничения
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
- TProxy только IPv4. IPv6 — в roadmap.
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.
## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
### Команды 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

@@ -1,4 +0,0 @@
node_modules/
dist/
src-tauri/target/

View File

@@ -1,116 +0,0 @@
# VPN Proxy Windows Client
Standalone Windows desktop utility for app-level proxy routing. This app is
separate from the current Docker gateway/client runtime and must not be wired
through `APP_MODE=windows`.
## Components
- Control App: Tauri 2 + React/TypeScript UI and Rust command layer.
- Proxyfier Layer: ProxiFyre adapter for per-application routing.
- Local sing-box: optional local runtime, used only by targets that explicitly
require `singbox`.
External SOCKS5 targets are the MVP path and do not require Local sing-box.
## Source And Generated Files
Source configuration is owned by Rust domain models and JSON files under:
```text
C:\ProgramData\VpnProxy\config\profiles.json
C:\ProgramData\VpnProxy\config\targets.json
C:\ProgramData\VpnProxy\config\components.json
C:\ProgramData\VpnProxy\state\activity.json
```
Generated artifacts are derived and can be recreated:
```text
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
C:\ProgramData\VpnProxy\generated\sing-box-config.json
```
## Development
```powershell
cd apps/windows-client
npm install
npm run build
```
Run the browser preview shell:
```powershell
npm run dev -- --host 127.0.0.1
```
Run Tauri checks when the native Windows toolchain is installed:
```powershell
npm run tauri -- info
npm run tauri -- dev
npm run tauri -- build
```
Run Rust tests when Rust/Cargo are installed:
```powershell
cd apps/windows-client/src-tauri
cargo test
```
Native Tauri build requires WebView2, Rust via rustup, and Visual Studio Build
Tools with MSVC and Windows SDK components.
## Explicit Installer Boundaries
Installer scripts are explicit per component and return structured JSON in
`-PlanOnly` mode:
```powershell
& .\scripts\install-control-app.ps1 -PlanOnly
& .\scripts\install-proxyfier.ps1 -PlanOnly
& .\scripts\install-singbox.ps1 -PlanOnly
```
Installers must be launched intentionally by the user or by a future narrow
helper permission. Profile apply must not silently install Control App,
Proxyfier, or Local sing-box.
## Existing Proxyfier Detection
The app detects an already installed Proxyfier layer before showing component
status or applying profiles. Detection checks:
- uninstall registry entries for `ProxiFyre` and `Proxifier`;
- common install folders such as `C:\Tools\ProxiFyre`,
`%ProgramFiles%\ProxiFyre`, and `%ProgramFiles%\Proxifier`;
- running `ProxiFyre` / `Proxifier` processes and the `ProxiFyreService`
service.
For portable installs, set an override before launching the app:
```powershell
$env:VPN_PROXY_PROXIFYRE_ROOT = 'D:\Tools\ProxiFyre'
npm run tauri -- dev
```
`ProxiFyre` installs are compatible with the current generated
`app-config.json` apply path. Plain `Proxifier` installs are detected and shown,
but automatic profile apply is not enabled for them yet because they use a
different profile format.
## MVP Verification Flow
1. Start the Control App or browser preview.
2. Confirm Components shows Control App, Proxyfier Layer, and optional Local
sing-box separately.
3. Add or keep an external SOCKS5 target.
4. Add a process/folder/exe profile such as Discord.
5. Apply profiles and verify generated ProxiFyre config plus activity entry.
6. Install Proxyfier separately before applying to a real service.
7. Install and start Local sing-box only when using a local target.
Task evidence is recorded in
`docs/goals/windows-modular-client/EVIDENCE.md`.

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPN Proxy для Windows</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "vpn-proxy-windows-client",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Standalone Windows desktop proxy management app for VPN Proxy.",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0"
}
}

View File

@@ -1,79 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\VpnProxy\ControlApp",
[string]$DataRoot = "C:\ProgramData\VpnProxy",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
return $true
}
return $false
}
try {
$details = @{
installRoot = $InstallRoot
dataRoot = $DataRoot
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
$changed = $false
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
} catch {
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
exit 1
}

View File

@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Tools\ProxiFyre",
[string]$PackagePath = "",
[string]$ServiceName = "ProxiFyreService",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
packagePath = $PackagePath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "app-config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "proxyfier"
packagePath = $PackagePath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
exit 1
}

View File

@@ -1,96 +0,0 @@
param(
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
[string]$BinaryPath = "",
[string]$ServiceName = "VpnProxySingBox",
[switch]$PlanOnly,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function New-Result {
param(
[bool]$Success,
[string]$Action,
[bool]$Changed,
[string]$Message,
[hashtable]$Details = @{}
)
[ordered]@{
success = $Success
action = $Action
changed = $Changed
message = $Message
details = $Details
} | ConvertTo-Json -Depth 6
}
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Backup-File {
param([string]$Path)
if (Test-Path -LiteralPath $Path) {
$backup = "$Path.bak"
Copy-Item -LiteralPath $Path -Destination $backup -Force
return $backup
}
return $null
}
try {
$details = @{
installRoot = $InstallRoot
binaryPath = $BinaryPath
serviceName = $ServiceName
planOnly = [bool]$PlanOnly
}
if ($PlanOnly) {
New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
exit 0
}
if (-not (Test-IsAdministrator)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
exit 1
}
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
exit 2
}
$changed = $false
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
$changed = $true
}
$configPath = Join-Path $InstallRoot "config.json"
$backupPath = Backup-File -Path $configPath
if ($backupPath) {
$details.backupPath = $backupPath
}
$markerPath = Join-Path $InstallRoot "install-singbox.marker.json"
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
@{
component = "singbox"
binaryPath = $BinaryPath
serviceName = $ServiceName
installedAt = (Get-Date).ToString("o")
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
$changed = $true
}
$details.markerPath = $markerPath
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
} catch {
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
exit 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
[package]
name = "vpn-proxy-windows-client"
version = "0.1.0"
description = "Standalone Windows desktop proxy management app for VPN Proxy."
authors = ["VPN Proxy"]
edition = "2021"
[lib]
name = "vpn_proxy_windows_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-dialog = "2.7.1"

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build();
}

View File

@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.",
"windows": ["main"],
"permissions": ["core:default", "dialog:allow-open"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,23 +0,0 @@
use crate::models::ActivityEntry;
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
entries
}
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
let mut entries = sort_activity_desc(entries);
entries.truncate(limit);
entries
}
pub fn append_activity(
mut entries: Vec<ActivityEntry>,
entry: ActivityEntry,
limit: usize,
) -> Vec<ActivityEntry> {
entries.push(entry);
cap_activity(entries, limit)
}

View File

@@ -1,245 +0,0 @@
#[cfg(not(test))]
use crate::adapters::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use crate::models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
ProxyProtocol, Target,
};
#[cfg(test)]
use crate::proxy_router::{
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
ProxyRouterRequest,
};
use serde::{Deserialize, Serialize};
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxiFyreAdapter {
log_level: String,
bypass_lan: bool,
}
impl ProxiFyreAdapter {
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
Self {
log_level: log_level.into(),
bypass_lan,
}
}
pub fn generate_proxifyre_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxiFyreConfig, ProxyRouterError> {
let mut proxies = Vec::new();
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
let target = find_target(profile, request.targets)?;
ensure_target_supported(profile, target, request.components)?;
let app_names = app_names_for_profile(profile);
if app_names.is_empty() {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::EmptyProfileItems,
format!(
"В профиле '{}' нет приложений для маршрутизации",
profile.id
),
));
}
proxies.push(ProxiFyreProxy {
app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
supported_protocols: protocols_for_profile(profile),
});
}
Ok(ProxiFyreConfig {
log_level: self.log_level.clone(),
bypass_lan: self.bypass_lan,
proxies,
})
}
}
impl Default for ProxiFyreAdapter {
fn default() -> Self {
Self::new("Info", true)
}
}
impl ProxyRouterAdapter for ProxiFyreAdapter {
fn id(&self) -> &'static str {
PROXIFYRE_ADAPTER_ID
}
fn output_file_name(&self) -> &'static str {
PROXIFYRE_OUTPUT_FILE
}
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
let config = self.generate_proxifyre_config(request)?;
let enabled_profiles = config.proxies.len();
let routed_apps = config
.proxies
.iter()
.map(|proxy| proxy.app_names.len())
.sum();
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
ProxyRouterError::new(
ProxyRouterErrorKind::Serialization,
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
)
})?;
Ok(ProxyRouterGeneratedConfig {
adapter_id: self.id().to_string(),
output_file_name: self.output_file_name().to_string(),
contents,
enabled_profiles,
routed_apps,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreConfig {
#[serde(rename = "logLevel")]
pub log_level: String,
#[serde(rename = "bypassLan")]
pub bypass_lan: bool,
pub proxies: Vec<ProxiFyreProxy>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxiFyreProxy {
#[serde(rename = "appNames")]
pub app_names: Vec<String>,
#[serde(rename = "socks5ProxyEndpoint")]
pub socks5_proxy_endpoint: String,
#[serde(rename = "supportedProtocols")]
pub supported_protocols: Vec<String>,
}
fn find_target<'a>(
profile: &Profile,
targets: &'a [Target],
) -> Result<&'a Target, ProxyRouterError> {
targets
.iter()
.find(|target| target.id == profile.target_id)
.ok_or_else(|| {
ProxyRouterError::new(
ProxyRouterErrorKind::MissingTarget,
format!(
"Профиль '{}' ссылается на отсутствующую цель '{}'",
profile.id, profile.target_id
),
)
})
}
fn ensure_target_supported(
profile: &Profile,
target: &Target,
components: &[ComponentStatus],
) -> Result<(), ProxyRouterError> {
if target.protocol != ProxyProtocol::Socks5 {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::UnsupportedTargetProtocol,
format!(
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
target.id
),
));
}
if let Some(required_component) = &target.requires_component {
let Some(status) = components
.iter()
.find(|component| &component.id == required_component)
else {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::MissingRequiredComponent,
format!(
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
};
if !component_is_running(status) {
return Err(ProxyRouterError::new(
ProxyRouterErrorKind::RequiredComponentNotRunning,
format!(
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
target.id,
profile.id,
component_id_label(required_component)
),
));
}
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
let mut names = Vec::new();
for item in &profile.items {
let value = item.value.trim();
if value.is_empty() {
continue;
}
let app_name = match item.item_type {
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
};
if !names.iter().any(|existing| existing == app_name) {
names.push(app_name.to_string());
}
}
names
}
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
let mut protocols = Vec::new();
for protocol in &profile.protocols {
let value = match protocol {
Protocol::Tcp => "TCP",
Protocol::Udp => "UDP",
};
if !protocols.iter().any(|existing| existing == value) {
protocols.push(value.to_string());
}
}
protocols
}
fn component_id_label(component_id: &ComponentId) -> &'static str {
match component_id {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxyfier",
ComponentId::Singbox => "singbox",
}
}

View File

@@ -1,67 +0,0 @@
use crate::models::{ComponentStatus, Profile, Target};
#[derive(Debug, Clone, Copy)]
pub struct ProxyRouterRequest<'a> {
pub profiles: &'a [Profile],
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
}
impl<'a> ProxyRouterRequest<'a> {
pub fn new(
profiles: &'a [Profile],
targets: &'a [Target],
components: &'a [ComponentStatus],
) -> Self {
Self {
profiles,
targets,
components,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub enabled_profiles: usize,
pub routed_apps: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRouterError {
pub kind: ProxyRouterErrorKind,
pub message: String,
}
impl ProxyRouterError {
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyRouterErrorKind {
EmptyProfileItems,
MissingTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTargetProtocol,
Serialization,
}
pub trait ProxyRouterAdapter {
fn id(&self) -> &'static str;
fn output_file_name(&self) -> &'static str;
fn generate_config(
&self,
request: ProxyRouterRequest<'_>,
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
}

View File

@@ -1,367 +0,0 @@
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
};
use serde::{Deserialize, Serialize};
use std::{
env, fs,
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxAdapter {
log_level: String,
inbound_tag: String,
outbound_tag: String,
}
impl SingBoxAdapter {
pub fn new(
log_level: impl Into<String>,
inbound_tag: impl Into<String>,
outbound_tag: impl Into<String>,
) -> Self {
Self {
log_level: log_level.into(),
inbound_tag: inbound_tag.into(),
outbound_tag: outbound_tag.into(),
}
}
pub fn generate_config<C>(
&self,
request: SingBoxGenerationRequest<'_>,
checker: &C,
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
where
C: SingBoxConfigChecker,
{
let target = find_local_singbox_target(request.targets)?;
ensure_local_singbox_target(target, request.components)?;
let config = SingBoxConfig {
log: SingBoxLog {
disabled: false,
level: self.log_level.clone(),
timestamp: true,
},
inbounds: vec![SingBoxInbound {
inbound_type: "mixed".to_string(),
tag: self.inbound_tag.clone(),
listen: target.host.clone(),
listen_port: target.port,
users: Vec::new(),
set_system_proxy: false,
}],
outbounds: vec![SingBoxOutbound {
outbound_type: "direct".to_string(),
tag: self.outbound_tag.clone(),
}],
route: SingBoxRoute {
final_outbound: self.outbound_tag.clone(),
},
};
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::Serialization,
format!("Не удалось сериализовать конфиг sing-box: {error}"),
)
})?;
let check = match request.binary_path {
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
None => None,
};
Ok(SingBoxGeneratedConfig {
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
contents,
local_target_id: target.id.clone(),
listen: target.host.clone(),
listen_port: target.port,
check,
})
}
}
impl Default for SingBoxAdapter {
fn default() -> Self {
Self::new(
"info",
DEFAULT_MIXED_INBOUND_TAG,
DEFAULT_DIRECT_OUTBOUND_TAG,
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SingBoxGenerationRequest<'a> {
pub targets: &'a [Target],
pub components: &'a [ComponentStatus],
pub binary_path: Option<&'a Path>,
}
impl<'a> SingBoxGenerationRequest<'a> {
pub fn new(
targets: &'a [Target],
components: &'a [ComponentStatus],
binary_path: Option<&'a Path>,
) -> Self {
Self {
targets,
components,
binary_path,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxGeneratedConfig {
pub adapter_id: String,
pub output_file_name: String,
pub contents: String,
pub local_target_id: String,
pub listen: String,
pub listen_port: u16,
pub check: Option<SingBoxCheckResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxCheckResult {
pub checked: bool,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxConfigError {
pub kind: SingBoxConfigErrorKind,
pub message: String,
}
impl SingBoxConfigError {
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SingBoxConfigErrorKind {
MissingLocalTarget,
MissingRequiredComponent,
RequiredComponentNotRunning,
UnsupportedTarget,
Serialization,
CheckFailed,
}
pub trait SingBoxConfigChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SingBoxCommandChecker;
impl SingBoxConfigChecker for SingBoxCommandChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
let config_path = env::temp_dir().join(format!(
"vpn-proxy-sing-box-{}-{}.json",
std::process::id(),
now_millis()
));
fs::write(&config_path, config_json).map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let output = Command::new(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось выполнить '{} check': {error}",
binary_path.display()
),
)
})?;
let _ = fs::remove_file(&config_path);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let message = command_message(&stdout, &stderr);
if !output.status.success() {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"),
));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: if message.is_empty() {
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxConfig {
pub log: SingBoxLog,
pub inbounds: Vec<SingBoxInbound>,
pub outbounds: Vec<SingBoxOutbound>,
pub route: SingBoxRoute,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxLog {
pub disabled: bool,
pub level: String,
pub timestamp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxInbound {
#[serde(rename = "type")]
pub inbound_type: String,
pub tag: String,
pub listen: String,
#[serde(rename = "listen_port")]
pub listen_port: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<SingBoxUser>,
#[serde(rename = "set_system_proxy")]
pub set_system_proxy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxUser {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxOutbound {
#[serde(rename = "type")]
pub outbound_type: String,
pub tag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SingBoxRoute {
#[serde(rename = "final")]
pub final_outbound: String,
}
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
targets
.iter()
.find(|target| {
target.kind == TargetKind::Local
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingLocalTarget,
"Локальная цель, требующая sing-box, не настроена",
)
})
}
fn ensure_local_singbox_target(
target: &Target,
components: &[ComponentStatus],
) -> Result<(), SingBoxConfigError> {
if target.kind != TargetKind::Local
|| target.protocol != ProxyProtocol::Socks5
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
{
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::UnsupportedTarget,
format!(
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
target.id
),
));
}
let Some(status) = components
.iter()
.find(|component| component.id == ComponentId::Singbox)
else {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingRequiredComponent,
format!(
"Локальная цель '{}' требует состояние компонента sing-box",
target.id
),
));
};
if !component_is_running(status) {
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::RequiredComponentNotRunning,
format!(
"Локальная цель '{}' требует установленный и запущенный sing-box",
target.id
),
));
}
Ok(())
}
fn component_is_running(status: &ComponentStatus) -> bool {
status.installed && status.running && status.state == ComponentState::Running
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,413 +0,0 @@
use crate::models::{ComponentId, ComponentState, ComponentStatus};
use serde::Deserialize;
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyfierEngine {
ProxiFyre,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedProxyfier {
pub engine: ProxyfierEngine,
pub name: String,
pub install_dir: PathBuf,
pub executable_path: PathBuf,
pub config_path: Option<PathBuf>,
pub running: bool,
pub service_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryInstallEntry {
pub display_name: String,
pub install_location: Option<PathBuf>,
pub display_icon: Option<PathBuf>,
}
pub trait ProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String>;
fn path_exists(&self, path: &Path) -> bool;
fn process_running(&self, process_name: &str) -> bool;
fn service_running(&self, service_name: &str) -> bool;
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemProxyfierDetectionHost;
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
fn env_var(&self, name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn path_exists(&self, path: &Path) -> bool {
path.exists()
}
fn process_running(&self, process_name: &str) -> bool {
let process_name = process_name.trim_end_matches(".exe");
let script = format!(
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(process_name)
);
powershell_bool(&script)
}
fn service_running(&self, service_name: &str) -> bool {
let script = format!(
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
escape_powershell_single(service_name)
);
powershell_bool(&script)
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
read_registry_install_entries()
}
}
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
}
pub fn detect_proxyfier_install_with_host(
host: &impl ProxyfierDetectionHost,
) -> Option<DetectedProxyfier> {
let proxifyre_running = host.process_running("ProxiFyre.exe")
|| host.service_running("ProxiFyreService")
|| host.service_running("ProxiFyre");
proxyfier_candidates(host)
.into_iter()
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
.next()
}
pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus {
match detected {
Some(proxyfier) => detected_proxyfier_component(proxyfier),
None => missing_proxyfier_component(),
}
}
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
let state = if proxyfier.running {
ComponentState::Running
} else {
ComponentState::Installed
};
let actions = match proxyfier.engine {
ProxyfierEngine::ProxiFyre => {
if proxyfier.running {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Остановить".to_string(),
]
} else {
vec![
"Применить сгенерированный конфиг".to_string(),
"Открыть папку конфига".to_string(),
"Запустить".to_string(),
]
}
}
};
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state,
installed: true,
running: proxyfier.running,
version: Some(match proxyfier.engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
}),
path: Some(proxyfier.install_dir.display().to_string()),
problems: Vec::new(),
actions,
}
}
fn missing_proxyfier_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyfierCandidate {
engine: ProxyfierEngine,
name: String,
install_dir: PathBuf,
}
impl ProxyfierCandidate {
fn into_detected(
self,
host: &impl ProxyfierDetectionHost,
proxifyre_running: bool,
) -> Option<DetectedProxyfier> {
let executable_path = self.install_dir.join(executable_name(&self.engine));
let config_path = config_path(&self.engine, &self.install_dir);
let exists = host.path_exists(&self.install_dir)
|| host.path_exists(&executable_path)
|| config_path
.as_ref()
.is_some_and(|path| host.path_exists(path));
if !exists {
return None;
}
Some(DetectedProxyfier {
service_name: service_name(&self.engine).map(str::to_string),
engine: self.engine,
name: self.name,
install_dir: self.install_dir,
executable_path,
config_path,
running: proxifyre_running,
})
}
}
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
let mut candidates = Vec::new();
push_env_candidate(
&mut candidates,
host,
ProxyfierEngine::ProxiFyre,
"ProxiFyre",
"VPN_PROXY_PROXIFYRE_ROOT",
);
for entry in host.registry_install_entries() {
if let Some(engine) = engine_from_name(&entry.display_name) {
let install_dir = entry
.install_location
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
if let Some(install_dir) = install_dir {
push_candidate(
&mut candidates,
ProxyfierCandidate {
name: entry.display_name,
engine,
install_dir,
},
);
}
}
}
for install_dir in common_install_dirs(host, "ProxiFyre") {
push_candidate(
&mut candidates,
ProxyfierCandidate {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir,
},
);
}
candidates
}
fn push_env_candidate(
candidates: &mut Vec<ProxyfierCandidate>,
host: &impl ProxyfierDetectionHost,
engine: ProxyfierEngine,
name: &str,
env_name: &str,
) {
if let Some(path) = host.env_var(env_name) {
push_candidate(
candidates,
ProxyfierCandidate {
engine,
name: name.to_string(),
install_dir: PathBuf::from(path),
},
);
}
}
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
if !candidates.iter().any(|existing| {
existing.engine == candidate.engine
&& same_path(&existing.install_dir, &candidate.install_dir)
}) {
candidates.push(candidate);
}
}
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
if let Some(root) = host.env_var(env_name) {
dirs.push(PathBuf::from(root).join(folder_name));
}
}
dirs
}
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
match engine {
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
}
}
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
match engine {
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
}
}
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
match engine {
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
}
}
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
let normalized = name.to_ascii_lowercase();
if normalized.contains("proxifyre") {
Some(ProxyfierEngine::ProxiFyre)
} else {
None
}
}
fn executable_parent(path: &Path) -> Option<PathBuf> {
path.parent().map(Path::to_path_buf)
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
fn powershell_bool(script: &str) -> bool {
Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct RegistryInstallJson {
display_name: Option<String>,
install_location: Option<String>,
display_icon: Option<String>,
}
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
let script = r#"
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$items = foreach ($path in $paths) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
}
$items |
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
Select-Object DisplayName,InstallLocation,DisplayIcon |
ConvertTo-Json -Compress
"#;
let Ok(output) = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let Ok(stdout) = String::from_utf8(output.stdout) else {
return Vec::new();
};
let stdout = stdout.trim();
if stdout.is_empty() {
return Vec::new();
}
parse_registry_json(stdout)
}
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
match value {
serde_json::Value::Array(entries) => entries
.into_iter()
.filter_map(registry_entry_from_value)
.collect(),
entry => registry_entry_from_value(entry).into_iter().collect(),
}
}
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
let display_name = parsed.display_name?;
Some(RegistryInstallEntry {
display_name,
install_location: parsed
.install_location
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from),
display_icon: parsed
.display_icon
.and_then(|value| display_icon_path(&value)),
})
}
fn display_icon_path(value: &str) -> Option<PathBuf> {
let trimmed = value.trim().trim_matches('"');
if trimmed.is_empty() {
return None;
}
let without_icon_index = trimmed
.split_once(',')
.map(|(path, _)| path)
.unwrap_or(trimmed)
.trim()
.trim_matches('"');
Some(PathBuf::from(without_icon_index))
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}

View File

@@ -1,184 +0,0 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}

View File

@@ -1,5 +0,0 @@
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -1,46 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod activity;
mod commands;
mod component_detection;
mod models;
mod storage;
mod validation;
mod adapters {
pub mod proxifyre;
pub mod proxy_router;
}
#[cfg(test)]
pub(crate) mod proxifyre {
pub use crate::adapters::proxifyre::*;
}
#[cfg(test)]
pub(crate) mod proxy_router {
pub use crate::adapters::proxy_router::*;
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_profiles,
commands::get_saved_state,
commands::save_profile,
commands::get_targets,
commands::save_target,
commands::get_components,
commands::resolve_profile_preview,
commands::apply_profiles,
commands::get_logs,
commands::open_config_location,
commands::start_proxifyre_service,
commands::stop_proxifyre_service
])
.run(tauri::generate_context!())
.expect("не удалось запустить клиент VPN Proxy для Windows");
}

View File

@@ -1,167 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Protocol {
Tcp,
Udp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ProfileItemType {
Process,
Folder,
Exe,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TargetKind {
Local,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyProtocol {
Socks5,
Http,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
ControlApp,
Proxyfier,
Singbox,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ComponentState {
Installed,
Missing,
Stopped,
Running,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItemInput {
#[serde(rename = "type")]
pub item_type: String,
pub value: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_target_id")]
pub target_id: String,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub items: Vec<ProfileItemInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileItem {
#[serde(rename = "type")]
pub item_type: ProfileItemType,
pub value: String,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub enabled: bool,
pub target_id: String,
pub protocols: Vec<Protocol>,
pub items: Vec<ProfileItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetInput {
pub id: Option<String>,
pub name: String,
#[serde(default = "default_target_kind")]
pub kind: String,
#[serde(default = "default_proxy_protocol")]
pub protocol: String,
pub host: String,
pub port: u32,
#[serde(default)]
pub requires_component: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
pub id: String,
pub name: String,
pub kind: TargetKind,
pub protocol: ProxyProtocol,
pub host: String,
pub port: u16,
pub requires_component: Option<ComponentId>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentStatus {
pub id: ComponentId,
pub name: String,
pub state: ComponentState,
pub installed: bool,
pub running: bool,
pub version: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivityEntry {
pub id: String,
pub at: String,
pub level: ActivityLevel,
pub title: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActivityLevel {
Info,
Warning,
Error,
Success,
}
fn default_enabled() -> bool {
true
}
fn default_target_id() -> String {
"local-singbox".to_string()
}
fn default_protocols() -> Vec<String> {
vec!["TCP".to_string(), "UDP".to_string()]
}
fn default_target_kind() -> String {
"external".to_string()
}
fn default_proxy_protocol() -> String {
"socks5".to_string()
}

View File

@@ -1,187 +0,0 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
use std::path::{Path, PathBuf};
pub fn default_config_root() -> PathBuf {
PathBuf::from(r"C:\ProgramData\VpnProxy")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePaths {
pub root: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub generated_dir: PathBuf,
pub profiles_file: PathBuf,
pub targets_file: PathBuf,
pub components_file: PathBuf,
pub activity_file: PathBuf,
}
impl StoragePaths {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let config_dir = root.join("config");
let state_dir = root.join("state");
let generated_dir = root.join("generated");
Self {
root,
profiles_file: config_dir.join("profiles.json"),
targets_file: config_dir.join("targets.json"),
components_file: config_dir.join("components.json"),
activity_file: state_dir.join("activity.json"),
config_dir,
state_dir,
generated_dir,
}
}
}
impl Default for StoragePaths {
fn default() -> Self {
Self::new(default_config_root())
}
}
#[derive(Debug, Clone)]
pub struct JsonStorage {
paths: StoragePaths,
activity_limit: usize,
}
impl JsonStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
}
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
Self {
paths: StoragePaths::new(root),
activity_limit,
}
}
pub fn paths(&self) -> &StoragePaths {
&self.paths
}
pub fn ensure_dirs(&self) -> io::Result<()> {
fs::create_dir_all(&self.paths.config_dir)?;
fs::create_dir_all(&self.paths.state_dir)?;
fs::create_dir_all(&self.paths.generated_dir)?;
Ok(())
}
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
self.read_json_or_default(&self.paths.profiles_file)
}
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
self.write_json(&self.paths.profiles_file, profiles)
}
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
self.read_json_or_default(&self.paths.targets_file)
}
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
self.write_json(&self.paths.targets_file, targets)
}
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
self.read_json_or_default(&self.paths.components_file)
}
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
self.write_json(&self.paths.components_file, components)
}
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_json_or_default(&self.paths.activity_file)?;
Ok(cap_activity(entries, self.activity_limit))
}
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
let entries = cap_activity(entries.to_vec(), self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)
}
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_activity()?;
let entries = append_activity(entries, entry, self.activity_limit);
self.write_json(&self.paths.activity_file, &entries)?;
Ok(entries)
}
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
where
T: DeserializeOwned + Default,
{
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(_) => Ok(T::default()),
},
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
}
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
where
T: Serialize + ?Sized,
{
let contents = serde_json::to_vec_pretty(value)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
write_atomic(path, &contents)
}
}
impl Default for JsonStorage {
fn default() -> Self {
Self::new(default_config_root())
}
}
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "tmp")
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage.json");
path.with_file_name(format!("{file_name}.{suffix}"))
}
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
Err(error)
}
}
}

View File

@@ -1,223 +0,0 @@
use crate::models::{
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
Target, TargetInput, TargetKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
ValidationError {
field: field.into(),
message: message.into(),
}
}
fn clean(value: &str) -> String {
value.trim().to_string()
}
fn slug(value: &str, fallback: &str) -> String {
let mut output = String::new();
let mut previous_dash = false;
for ch in value.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch);
previous_dash = false;
} else if !previous_dash {
output.push('-');
previous_dash = true;
}
}
let output = output.trim_matches('-').to_string();
if output.is_empty() {
fallback.to_string()
} else {
output
}
}
fn process_name(value: &str) -> String {
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
base.strip_suffix(".exe")
.or_else(|| base.strip_suffix(".EXE"))
.unwrap_or(base)
.trim()
.to_string()
}
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Ok(Protocol::Tcp),
"UDP" => Ok(Protocol::Udp),
_ => Err(error(
"protocols",
format!("Неподдерживаемый протокол: {value}"),
)),
}
}
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"process" => Ok(ProfileItemType::Process),
"folder" => Ok(ProfileItemType::Folder),
"exe" => Ok(ProfileItemType::Exe),
_ => Err(error(
"items.type",
format!("Неподдерживаемый тип элемента: {value}"),
)),
}
}
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"local" => Ok(TargetKind::Local),
"external" => Ok(TargetKind::External),
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
}
}
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"socks5" => Ok(ProxyProtocol::Socks5),
"http" => Ok(ProxyProtocol::Http),
_ => Err(error(
"protocol",
format!("Неподдерживаемый протокол прокси: {value}"),
)),
}
}
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
match value.trim().to_ascii_lowercase().as_str() {
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
"proxyfier" => Ok(ComponentId::Proxyfier),
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
_ => Err(error(
"requires_component",
format!("Неподдерживаемый компонент: {value}"),
)),
}
}
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
let mut errors = Vec::new();
let name = clean(&input.name);
if name.is_empty() {
errors.push(error("name", "Укажите название профиля"));
}
let target_id = clean(&input.target_id);
if target_id.is_empty() {
errors.push(error("target_id", "Укажите цель профиля"));
}
let mut protocols = Vec::new();
for value in input.protocols {
match parse_protocol(&value) {
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
Ok(_) => {}
Err(err) => errors.push(err),
}
}
if protocols.is_empty() {
errors.push(error("protocols", "Выберите хотя бы один протокол"));
}
let mut items = Vec::new();
for raw_item in input.items {
let item_type = match parse_profile_item_type(&raw_item.item_type) {
Ok(item_type) => item_type,
Err(err) => {
errors.push(err);
continue;
}
};
let value = match item_type {
ProfileItemType::Process => process_name(&raw_item.value),
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
};
if value.is_empty() {
errors.push(error("items.value", "Укажите значение элемента профиля"));
continue;
}
let recursive =
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
items.push(ProfileItem {
item_type,
value,
recursive,
});
}
if !errors.is_empty() {
return Err(errors);
}
Ok(Profile {
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
name,
enabled: input.enabled,
target_id,
protocols,
items,
})
}
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
let mut errors = Vec::new();
let name = clean(&input.name);
let host = clean(&input.host);
if name.is_empty() {
errors.push(error("name", "Укажите название цели"));
}
if host.is_empty() {
errors.push(error("host", "Укажите хост цели"));
}
if input.port == 0 || input.port > u16::MAX as u32 {
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
}
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
errors.push(err);
TargetKind::External
});
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
errors.push(err);
ProxyProtocol::Socks5
});
let requires_component = match input.requires_component {
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
Ok(component) => Some(component),
Err(err) => {
errors.push(err);
None
}
},
_ => None,
};
if !errors.is_empty() {
return Err(errors);
}
Ok(Target {
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
name,
kind,
protocol,
host,
port: input.port as u16,
requires_component,
})
}

View File

@@ -1,37 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VPN Proxy для Windows",
"version": "0.1.0",
"identifier": "ru.dokops.vpn-proxy.windows",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "VPN Proxy для Windows",
"width": 1120,
"height": 760,
"minWidth": 760,
"minHeight": 560,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}

View File

@@ -1,447 +0,0 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/commands.rs"]
mod commands;
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/storage.rs"]
mod storage;
#[path = "../src/validation.rs"]
mod validation;
use commands::{
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
TargetInputDto,
};
use component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
};
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::ProxiFyreAdapter;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[test]
fn save_commands_normalize_and_persist_profile_and_target() {
let root = test_root("save");
let storage = JsonStorage::new(root.clone());
let target = save_target_to_storage(
&storage,
TargetInputDto {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: Some("external".to_string()),
protocol: Some("socks5".to_string()),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
},
)
.expect("target command should normalize");
let profile = save_profile_to_storage(
&storage,
ProfileInputDto {
id: Some("Discord".to_string()),
name: " Discord ".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]),
items: Some(vec![ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
}]),
},
)
.expect("profile command should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(profile.id, "discord");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.items[0].value, "Discord");
let status = build_status(&storage).expect("status command should read stored state");
assert_eq!(
status.route_line,
"Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080"
);
assert_eq!(status.active_profile_count, 1);
assert_eq!(status.routed_app_count, 1);
cleanup(&root);
}
#[test]
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
let preview = resolve_preview(ProfileInputDto {
id: Some("Game".to_string()),
name: "Game".to_string(),
enabled: Some(true),
target_id: Some("home-gateway".to_string()),
protocols: Some(vec!["TCP".to_string()]),
items: Some(vec![
ProfileItemInputDto {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInputDto {
item_type: "folder".to_string(),
value: r"C:\Games\Launcher".to_string(),
recursive: Some(true),
},
]),
})
.expect("preview should normalize profile input");
assert_eq!(preview.profile_id, "game");
assert_eq!(preview.apps.len(), 2);
assert_eq!(preview.apps[0].app_name, "Discord");
assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder);
assert!(preview
.warnings
.iter()
.any(|warning| warning.contains("Сканирование папок отложено")));
}
#[test]
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
let root = test_root("apply");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("home-gateway")])
.expect("write profiles");
storage
.write_targets(&[external_socks5_target()])
.expect("write targets");
storage
.write_components(&[proxyfier_running(), singbox_missing()])
.expect("write components");
let response = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect("apply command should generate config and call helper");
let generated_path = PathBuf::from(&response.generated_config_path);
let generated_contents = fs::read_to_string(&generated_path).expect("read generated config");
let activity = storage.read_activity().expect("read activity");
assert!(response.success);
assert!(response.changed);
assert_eq!(response.adapter_id, "proxifyre");
assert_eq!(response.enabled_profiles, 1);
assert_eq!(response.routed_apps, 1);
assert_eq!(response.helper.action, "proxyfier.apply.mock");
assert!(generated_contents.contains("\"appNames\""));
assert!(generated_contents.contains("Discord"));
assert!(generated_path.ends_with("proxifyre-app-config.json"));
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
cleanup(&root);
}
#[test]
fn apply_blocks_local_singbox_target_when_component_is_missing() {
let root = test_root("missing-singbox");
let storage = JsonStorage::new(root.clone());
storage
.write_profiles(&[discord_profile("local-singbox")])
.expect("write profiles");
storage
.write_targets(&[local_singbox_target()])
.expect("write targets");
storage
.write_components(&[singbox_missing()])
.expect("write components");
let error = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.expect_err("missing sing-box should block local target apply");
let activity = storage.read_activity().expect("read blocked activity");
assert_eq!(error.code, "required_component_not_running");
assert_eq!(activity.len(), 1);
assert_eq!(activity[0].level, models::ActivityLevel::Error);
assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано");
cleanup(&root);
}
#[test]
fn component_status_merges_detected_existing_proxifyre() {
let components = resolve_component_statuses(
Vec::new(),
Some(DetectedProxyfier {
engine: ProxyfierEngine::ProxiFyre,
name: "ProxiFyre".to_string(),
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
running: true,
service_name: Some("ProxiFyreService".to_string()),
}),
);
let proxyfier = components
.iter()
.find(|component| component.id == ComponentId::Proxyfier)
.expect("proxyfier component");
assert_eq!(proxyfier.state, ComponentState::Running);
assert!(proxyfier.installed);
assert!(proxyfier.running);
assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(proxyfier.problems.is_empty());
}
#[test]
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let root = test_root("detected-proxifyre");
let install_dir = root.join("ProxiFyre");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("ProxiFyre", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("ProxiFyre.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("detected helper should apply");
let applied =
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#);
assert!(install_dir.join("app-config.json.bak").exists());
cleanup(&root);
}
#[test]
fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
let root = test_root("detected-proxifier");
let install_dir = root.join("Proxifier");
fs::create_dir_all(&install_dir).expect("install dir");
fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe");
let generated_config = root.join("generated").join("proxifyre-app-config.json");
let host = DetectionHost::new()
.with_registry("Proxifier", &install_dir)
.with_path(&install_dir)
.with_path(&install_dir.join("Proxifier.exe"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("plain Proxifier should be ignored and config should be staged");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.stage-generated-config");
assert!(result
.message
.contains("совместимая установка ProxiFyre не найдена"));
cleanup(&root);
}
struct MockApplyHelper;
impl ProxyApplyHelper for MockApplyHelper {
fn apply_proxy_config(
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
assert_eq!(request.adapter_id, "proxifyre");
assert!(request.config_contents.contains("Discord"));
assert!(request.config_path.ends_with("proxifyre-app-config.json"));
Ok(HelperApplyResult {
success: true,
changed: true,
action: "proxyfier.apply.mock".to_string(),
message: "Mock helper accepted generated ProxiFyre config".to_string(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now(&self) -> String {
"2026-07-03T00:00:00Z".to_string()
}
}
#[derive(Default)]
struct DetectionHost {
paths: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl DetectionHost {
fn new() -> Self {
Self::default()
}
fn with_path(mut self, path: &Path) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(install_location.to_path_buf()),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for DetectionHost {
fn env_var(&self, _name: &str) -> Option<String> {
None
}
fn path_exists(&self, path: &Path) -> bool {
self.paths.contains(&normalize_path(path))
}
fn process_running(&self, _process_name: &str) -> bool {
false
}
fn service_running(&self, _service_name: &str) -> bool {
false
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &Path) -> String {
path.display()
.to_string()
.replace('/', "\\")
.to_ascii_lowercase()
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("vpn-proxy-commands-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Домашний шлюз".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Локальный sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn proxyfier_running() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string()],
}
}
fn singbox_missing() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()],
}
}

View File

@@ -1,146 +0,0 @@
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
use component_detection::{
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry,
};
use models::ComponentState;
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
#[test]
fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre")
.with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host)
.expect("existing ProxiFyre install should be detected");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
assert_eq!(
detected.config_path,
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
);
assert!(detected.running);
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn ignores_plain_proxifier_install() {
let host = MockHost::new()
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
.with_path(r"C:\Program Files\Proxifier")
.with_process("Proxifier.exe");
assert!(detect_proxyfier_install_with_host(&host).is_none());
}
#[test]
fn env_override_can_point_to_portable_proxifyre_install() {
let host = MockHost::new()
.with_env("VPN_PROXY_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
let detected = detect_proxyfier_install_with_host(&host)
.expect("env override should be checked before common paths");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(
detected.executable_path,
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
);
}
#[test]
fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
}
#[derive(Default)]
struct MockHost {
env: HashMap<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl MockHost {
fn new() -> Self {
Self::default()
}
fn with_env(mut self, name: &str, value: &str) -> Self {
self.env.insert(name.to_string(), value.to_string());
self
}
fn with_path(mut self, path: &str) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_process(mut self, process: &str) -> Self {
self.processes.insert(process.to_ascii_lowercase());
self
}
fn with_service(mut self, service: &str) -> Self {
self.services.insert(service.to_ascii_lowercase());
self
}
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(PathBuf::from(install_location)),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for MockHost {
fn env_var(&self, name: &str) -> Option<String> {
self.env.get(name).cloned()
}
fn path_exists(&self, path: &Path) -> bool {
self.paths
.contains(&normalize_path(&path.display().to_string()))
}
fn process_running(&self, process_name: &str) -> bool {
self.processes.contains(&process_name.to_ascii_lowercase())
}
fn service_running(&self, service_name: &str) -> bool {
self.services.contains(&service_name.to_ascii_lowercase())
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}

View File

@@ -1,128 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/validation.rs"]
mod validation;
use models::{
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
TargetInput, TargetKind,
};
use validation::{normalize_profile, normalize_target};
#[test]
fn normalizes_profile_source_items() {
let profile = normalize_profile(ProfileInput {
id: Some("Discord + Vesktop".to_string()),
name: " Discord + Vesktop ".to_string(),
enabled: true,
target_id: " home-gateway ".to_string(),
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
items: vec![
ProfileItemInput {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInput {
item_type: "folder".to_string(),
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
recursive: Some(true),
},
ProfileItemInput {
item_type: "exe".to_string(),
value: "C:\\Games\\Game\\game.exe".to_string(),
recursive: Some(true),
},
],
})
.expect("profile should normalize");
assert_eq!(profile.id, "discord-vesktop");
assert_eq!(profile.name, "Discord + Vesktop");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
assert_eq!(profile.items[0].value, "Discord");
assert!(!profile.items[0].recursive);
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
assert!(profile.items[1].recursive);
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
assert!(!profile.items[2].recursive);
}
#[test]
fn rejects_unsupported_profile_protocols() {
let error = normalize_profile(ProfileInput {
id: None,
name: "Bad protocol".to_string(),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec!["icmp".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "Discord".to_string(),
recursive: None,
}],
})
.expect_err("unsupported protocol should fail");
assert!(error.iter().any(|item| item.field == "protocols"));
}
#[test]
fn normalizes_external_target_without_local_singbox() {
let target = normalize_target(TargetInput {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
})
.expect("external target should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(target.kind, TargetKind::External);
assert_eq!(target.protocol, ProxyProtocol::Socks5);
assert_eq!(target.host, "192.168.50.111");
assert_eq!(target.port, 8080);
assert_eq!(target.requires_component, None);
}
#[test]
fn local_singbox_target_can_exist_before_component_is_installed() {
let target = normalize_target(TargetInput {
id: Some("local-singbox".to_string()),
name: "Local sing-box".to_string(),
kind: "local".to_string(),
protocol: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some("singbox".to_string()),
})
.expect("local target definition should not require installed component");
assert_eq!(target.kind, TargetKind::Local);
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
}
#[test]
fn rejects_malformed_target_fields() {
let error = normalize_target(TargetInput {
id: None,
name: "".to_string(),
kind: "external".to_string(),
protocol: "ftp".to_string(),
host: "".to_string(),
port: 70_000,
requires_component: Some("unknown".to_string()),
})
.expect_err("invalid target should fail");
assert!(error.iter().any(|item| item.field == "name"));
assert!(error.iter().any(|item| item.field == "host"));
assert!(error.iter().any(|item| item.field == "port"));
assert!(error.iter().any(|item| item.field == "protocol"));
assert!(error.iter().any(|item| item.field == "requires_component"));
}

View File

@@ -1,139 +0,0 @@
#[path = "../src/helper.rs"]
mod helper;
#[path = "../src/models.rs"]
mod models;
use helper::{
helper_action_requires_elevation, install_request, parse_helper_response,
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
};
use models::ComponentId;
use serde_json::json;
use std::cell::RefCell;
use std::path::PathBuf;
#[test]
fn structured_helper_serializes_request_and_parses_json_response() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: serde_json::to_string(&HelperResponse {
success: true,
action: HelperAction::ProxyfierApply,
changed: true,
message: "Applied".to_string(),
details: json!({ "serviceName": "ProxiFyreService" }),
})
.expect("response json"),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let response = helper
.execute(&proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
))
.expect("helper response");
assert!(response.success);
assert_eq!(response.action, HelperAction::ProxyfierApply);
assert_eq!(response.details["serviceName"], "ProxiFyreService");
}
#[test]
fn helper_runner_receives_json_stdin_and_elevation_flag() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
let _ = helper.execute(&request).expect("helper response");
let seen = helper.runner().seen.borrow();
let spec = seen.first().expect("runner should be called");
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
assert_eq!(spec.program, PathBuf::from("vpn-proxy-helper.exe"));
assert_eq!(spec.args, vec!["--json"]);
assert!(spec.requires_elevation);
assert_eq!(stdin["action"], "service.restart");
assert_eq!(stdin["component"], "proxyfier");
}
#[test]
fn install_requests_are_explicit_component_actions() {
let control = install_request(ComponentId::ControlApp);
let proxyfier = install_request(ComponentId::Proxyfier);
let singbox = install_request(ComponentId::Singbox);
assert_eq!(control.action, HelperAction::InstallControlApp);
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
assert_eq!(singbox.action, HelperAction::InstallSingbox);
assert!(helper_action_requires_elevation(&proxyfier.action));
}
#[test]
fn apply_request_does_not_encode_installer_action() {
let request = proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
);
assert_eq!(request.action, HelperAction::ProxyfierApply);
assert_eq!(request.component, Some(ComponentId::Proxyfier));
assert_eq!(
request.payload["configPath"],
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json"
);
}
#[test]
fn non_json_helper_stdout_is_rejected() {
let error = parse_helper_response("Proxyfier restarted successfully")
.expect_err("raw stdout should not be accepted");
assert_eq!(error.code, "helper_response_decode");
}
#[test]
fn failed_helper_exit_is_structured_error() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 5,
stdout: String::new(),
stderr: "Access denied".to_string(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let error = helper
.execute(&service_request(
ComponentId::Proxyfier,
HelperAction::ServiceRestart,
))
.expect_err("failed exit should become helper error");
assert_eq!(error.code, "helper_exit");
assert!(error.message.contains("Access denied"));
}
struct MockRunner {
output: HelperCommandOutput,
seen: RefCell<Vec<HelperCommandSpec>>,
}
impl HelperCommandRunner for MockRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
self.seen.borrow_mut().push(spec.clone());
Ok(self.output.clone())
}
}

View File

@@ -1,207 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
#[test]
fn generates_proxifyre_config_for_discord_external_socks5_target() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external socks5 target should not require sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.adapter_id, "proxifyre");
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
assert_eq!(generated.enabled_profiles, 1);
assert_eq!(generated.routed_apps, 1);
assert_eq!(config.log_level, "Info");
assert!(config.bypass_lan);
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
}
#[test]
fn skips_disabled_profiles_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut disabled = discord_profile("home-gateway");
disabled.enabled = false;
let profiles = vec![disabled];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("disabled profiles should produce empty config");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.enabled_profiles, 0);
assert_eq!(generated.routed_apps, 0);
assert!(config.proxies.is_empty());
}
#[test]
fn includes_folder_paths_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut profile = discord_profile("home-gateway");
profile.items.push(ProfileItem {
item_type: ProfileItemType::Folder,
value: r"C:\Games\MyGame".to_string(),
recursive: true,
});
let profiles = vec![profile];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("folder paths should be accepted by ProxiFyre config generation");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.routed_apps, 2);
assert_eq!(
config.proxies[0].app_names,
vec!["Discord", r"C:\Games\MyGame"]
);
}
#[test]
fn blocks_local_singbox_target_when_required_component_is_missing() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("local sing-box target should require installed running sing-box");
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
}
#[test]
fn local_singbox_target_generates_when_required_component_is_running() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("running sing-box should satisfy local target dependency");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
}
#[test]
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("office-http")];
let targets = vec![Target {
id: "office-http".to_string(),
name: "Office HTTP".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Http,
host: "192.168.50.111".to_string(),
port: 3128,
requires_component: None,
}];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("ProxiFyre should reject HTTP targets");
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}

View File

@@ -1,284 +0,0 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use singbox::{
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
};
use std::{
cell::RefCell,
path::{Path, PathBuf},
};
#[test]
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("configuration OK");
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
&checker,
)
.expect("running local sing-box should generate config");
let config: SingBoxConfig =
serde_json::from_str(&generated.contents).expect("generated sing-box json");
assert_eq!(generated.adapter_id, "singbox");
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
assert_eq!(generated.local_target_id, "local-singbox");
assert_eq!(generated.listen, "127.0.0.1");
assert_eq!(generated.listen_port, 1080);
assert_eq!(
generated.check,
Some(SingBoxCheckResult {
checked: true,
success: true,
message: "configuration OK".to_string(),
})
);
assert_eq!(config.log.level, "info");
assert_eq!(config.inbounds.len(), 1);
assert_eq!(config.inbounds[0].inbound_type, "mixed");
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
assert_eq!(config.inbounds[0].listen_port, 1080);
assert!(!config.inbounds[0].set_system_proxy);
assert_eq!(config.outbounds[0].outbound_type, "direct");
assert_eq!(config.route.final_outbound, "direct");
let calls = checker.calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_path(), binary_path);
assert!(calls[0].1.contains(r#""type": "mixed""#));
}
#[test]
fn skips_singbox_check_when_binary_path_is_not_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect("binary path is optional");
assert_eq!(generated.check, None);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_required_component_is_missing() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = Vec::new();
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires component state");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_component_is_not_running() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![stopped_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires running component");
assert_eq!(
error.kind,
SingBoxConfigErrorKind::RequiredComponentNotRunning
);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn propagates_failed_singbox_check_as_structured_error() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::err("invalid config");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
&checker,
)
.expect_err("failed sing-box check should block generated config");
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
assert!(error.message.contains("invalid config"));
}
#[test]
fn external_proxifyre_apply_does_not_require_singbox_component() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external SOCKS5 target should not require local sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(
config.proxies[0].socks5_proxy_endpoint,
"192.168.50.111:8080"
);
}
struct RecordingChecker {
calls: RefCell<Vec<(PathBuf, String)>>,
result: Result<SingBoxCheckResult, SingBoxConfigError>,
}
impl RecordingChecker {
fn ok(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Ok(SingBoxCheckResult {
checked: true,
success: true,
message: message.to_string(),
}),
}
}
fn err(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
message,
)),
}
}
}
impl SingBoxConfigChecker for RecordingChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
self.calls
.borrow_mut()
.push((binary_path.to_path_buf(), config_json.to_string()));
self.result.clone()
}
}
fn discord_profile(target_id: &str) -> Profile {
Profile {
id: "discord".to_string(),
name: "Discord".to_string(),
enabled: true,
target_id: target_id.to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn external_socks5_target() -> Target {
Target {
id: "home-gateway".to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}
fn stopped_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Stopped,
installed: true,
running: false,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: vec!["Service is stopped".to_string()],
actions: vec!["Start".to_string()],
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}

View File

@@ -1,198 +0,0 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/storage.rs"]
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
#[test]
fn storage_defaults_to_programdata_root() {
let expected = PathBuf::from(r"C:\ProgramData\VpnProxy");
assert_eq!(default_config_root(), expected);
assert_eq!(StoragePaths::default().root, expected);
}
#[test]
fn roundtrips_profiles_targets_components_and_activity() {
let root = test_root("roundtrip");
let storage = JsonStorage::new(root.clone());
let profiles = vec![sample_profile("discord")];
let targets = vec![sample_target("home-gateway")];
let components = vec![sample_component()];
let activity = vec![sample_activity(
"created",
"2026-01-01T10:00:00Z",
ActivityLevel::Success,
)];
storage.write_profiles(&profiles).expect("write profiles");
storage.write_targets(&targets).expect("write targets");
storage
.write_components(&components)
.expect("write components");
storage.write_activity(&activity).expect("write activity");
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
assert_eq!(storage.read_targets().expect("read targets"), targets);
assert_eq!(
storage.read_components().expect("read components"),
components
);
assert_eq!(storage.read_activity().expect("read activity"), activity);
cleanup(&root);
}
#[test]
fn invalid_json_falls_back_to_empty_collection() {
let root = test_root("invalid-json");
let storage = JsonStorage::new(root.clone());
storage.ensure_dirs().expect("create storage dirs");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"),
Vec::<Profile>::new()
);
cleanup(&root);
}
#[test]
fn write_creates_backup_before_overwriting_source_file() {
let root = test_root("backup");
let storage = JsonStorage::new(root.clone());
let first = vec![sample_profile("first")];
let second = vec![sample_profile("second")];
storage.write_profiles(&first).expect("first write");
storage.write_profiles(&second).expect("second write");
let backup = backup_path(&storage.paths().profiles_file);
assert!(backup.exists(), "backup file should exist");
let backup_contents = fs::read_to_string(backup).expect("read backup");
let backup_profiles: Vec<Profile> =
serde_json::from_str(&backup_contents).expect("backup json");
assert_eq!(backup_profiles, first);
assert_eq!(storage.read_profiles().expect("current profiles"), second);
cleanup(&root);
}
#[test]
fn activity_entries_are_sorted_and_capped() {
let root = test_root("activity");
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
storage
.append_activity(sample_activity(
"old",
"2026-01-01T10:00:00Z",
ActivityLevel::Info,
))
.expect("append old");
storage
.append_activity(sample_activity(
"new",
"2026-01-03T10:00:00Z",
ActivityLevel::Success,
))
.expect("append new");
storage
.append_activity(sample_activity(
"middle",
"2026-01-02T10:00:00Z",
ActivityLevel::Warning,
))
.expect("append middle");
let entries = storage.read_activity().expect("read capped activity");
assert_eq!(entries.len(), 2);
assert_eq!(
entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["new", "middle"]
);
cleanup(&root);
}
fn test_root(name: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("vpn-proxy-storage-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),
name: format!("Profile {id}"),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn sample_target(id: &str) -> Target {
Target {
id: id.to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn sample_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
ActivityEntry {
id: id.to_string(),
at: at.to_string(),
level,
title: format!("Activity {id}"),
message: "Storage test activity".to_string(),
}
}

View File

@@ -1,97 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import type {
ActivityEntry,
ComponentStatus,
Profile,
ProfileInput,
Target,
TargetInput,
} from '../domain/types';
export interface CommandError {
code: string;
message: string;
details?: Array<{
field: string;
message: string;
}>;
}
export interface StatusResponse {
routeLine: string;
activeProfileCount: number;
routedAppCount: number;
activeTarget?: Target;
components: ComponentStatus[];
recentActivity: ActivityEntry[];
generatedConfigPath: string;
}
export interface SavedStateResponse {
profiles: Profile[];
targets: Target[];
generatedConfigPath: string;
}
export interface HelperApplyResult {
success: boolean;
changed: boolean;
action: string;
message: string;
}
export interface ApplyProfilesResponse {
success: boolean;
changed: boolean;
message: string;
adapterId: string;
generatedConfigPath: string;
enabledProfiles: number;
routedApps: number;
helper: HelperApplyResult;
activity: ActivityEntry;
}
export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status');
}
export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state');
}
export function getProfiles(): Promise<Profile[]> {
return invoke<Profile[]>('get_profiles');
}
export function saveProfile(input: ProfileInput): Promise<Profile> {
return invoke<Profile>('save_profile', { input });
}
export function getTargets(): Promise<Target[]> {
return invoke<Target[]>('get_targets');
}
export function saveTarget(input: TargetInput): Promise<Target> {
return invoke<Target>('save_target', { input });
}
export function getComponents(): Promise<ComponentStatus[]> {
return invoke<ComponentStatus[]>('get_components');
}
export function applyProfiles(): Promise<ApplyProfilesResponse> {
return invoke<ApplyProfilesResponse>('apply_profiles');
}
export function openConfigLocation(): Promise<string> {
return invoke<string>('open_config_location');
}
export function startProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('start_proxifyre_service');
}
export function stopProxiFyreService(): Promise<ComponentStatus> {
return invoke<ComponentStatus>('stop_proxifyre_service');
}

View File

@@ -1,770 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen } from 'lucide-react';
import {
applyProfiles,
getComponents,
getSavedState,
openConfigLocation,
saveProfile,
saveTarget,
startProxiFyreService,
stopProxiFyreService,
type ApplyProfilesResponse,
} from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
type ServiceVisualState = 'active' | 'settling' | null;
interface DraftItem {
id: string;
type: DraftItemType;
value: string;
}
interface Notice {
kind: 'success' | 'error' | 'info';
title: string;
text: string;
}
interface LogEntry extends Notice {
id: string;
at: number;
}
const MAIN_TARGET_ID = 'main-proxy';
const MAIN_PROFILE_ID = 'main-profile';
const LOG_VISIBLE_MS = 6500;
const fallbackComponents: ComponentStatus[] = [
{
id: 'proxyfier',
name: 'ProxiFyre',
state: 'missing',
installed: false,
running: false,
problems: ['ProxiFyre не найден'],
actions: [],
},
];
export function App() {
const [proxyInput, setProxyInput] = useState('');
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
const [items, setItems] = useState<DraftItem[]>([]);
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
const [processInput, setProcessInput] = useState('');
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null);
const [isLogOpen, setIsLogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
const [serviceAction, setServiceAction] = useState<'start' | 'stop' | null>(null);
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
const serviceVisualTimerRef = useRef<number | null>(null);
const proxyfier = useMemo(
() => components.find((component) => component.id === 'proxyfier'),
[components],
);
const activeLog = useMemo(
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
[activeLogId, logEntries],
);
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
const finderVisualClass =
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
return () => {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!activeLogId) return undefined;
const timer = window.setTimeout(() => {
setActiveLogId((current) => (current === activeLogId ? null : current));
}, LOG_VISIBLE_MS);
return () => window.clearTimeout(timer);
}, [activeLogId]);
async function refresh() {
setIsLoading(true);
try {
const saved = await getSavedState();
applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath);
} catch {
showNotice({
kind: 'info',
title: 'Режим предпросмотра',
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
});
} finally {
setIsLoading(false);
}
void refreshComponents();
}
async function refreshComponents() {
setIsDetectingComponents(true);
try {
const detectedComponents = await getComponents();
setComponents(detectedComponents);
} catch (error) {
showNotice({
kind: 'error',
title: 'ProxiFyre не проверен',
text: errorMessage(error),
});
} finally {
setIsDetectingComponents(false);
}
}
function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) {
const activeProfiles = profiles.filter((profile) => profile.enabled);
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
const activeProfile = mainProfile ?? activeProfiles[0];
const activeTarget = targetForUi(targets, activeProfile);
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
if (activeTarget) setProxyInput(formatProxy(activeTarget));
setItems(itemsForProfiles(editableProfiles));
setLoadedProfiles(profiles);
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
setGeneratedConfigPath(generatedPath);
}
function addItem(type: DraftItemType, rawValue: string) {
const value = normalizeItemValue(rawValue, type);
if (!value) {
showNotice({
kind: 'error',
title: 'Нечего добавить',
text: emptyItemMessage(type),
});
return false;
}
if (items.some((item) => item.type === type && sameValue(item.value, value))) {
showNotice({
kind: 'info',
title: 'Уже добавлено',
text: value,
});
return false;
}
setItems((current) => [
...current,
{
id: `${type}-${Date.now()}`,
type,
value,
},
]);
return true;
}
function addProcess() {
if (addItem('process', processInput)) {
setProcessInput('');
setIsProcessInputOpen(false);
}
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
setPickerAction(type);
try {
const selectedPath = await pickPath(type);
if (selectedPath) {
addItem(type, selectedPath);
}
} catch (error) {
showNotice({
kind: 'error',
title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана',
text: errorMessage(error),
});
} finally {
setPickerAction(null);
}
}
async function updateConfig() {
let parsedProxy: ParsedProxy;
try {
parsedProxy = parseProxy(proxyInput);
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
} catch (error) {
showNotice({
kind: 'error',
title: 'Проверь данные',
text: errorMessage(error),
});
return;
}
setIsApplying(true);
try {
await saveTarget({
id: targetId,
name: 'Основной прокси',
kind: 'external',
protocol: parsedProxy.protocol,
host: parsedProxy.host,
port: parsedProxy.port,
});
await saveProfile({
id: profileId,
name: 'Приложения через прокси',
enabled: true,
targetId,
protocols: ['TCP', 'UDP'],
items: items.map(profileItemInput),
});
await Promise.all(
loadedProfiles
.filter((profile) => profile.enabled && profile.id !== profileId)
.map((profile) => saveProfile(profileInputFromProfile(profile, false))),
);
const result = await applyProfiles();
const [saved, detectedComponents] = await Promise.all([
getSavedState(),
getComponents(),
]);
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
setComponents(detectedComponents);
showNotice(noticeFromApply(result));
} catch (error) {
showNotice({
kind: 'error',
title: 'Конфиг не обновлен',
text: errorMessage(error),
});
} finally {
setIsApplying(false);
}
}
async function openConfig() {
setIsOpeningConfig(true);
try {
const openedPath = await openConfigLocation();
showNotice({
kind: 'info',
title: 'Конфиг открыт',
text: openedPath,
});
} catch (error) {
showNotice({
kind: 'error',
title: 'Не удалось открыть конфиг',
text: errorMessage(error),
});
} finally {
setIsOpeningConfig(false);
}
}
async function setProxiFyreServiceRunning(shouldRun: boolean) {
const action = shouldRun ? 'start' : 'stop';
setServiceAction(action);
startServiceVisual();
try {
await nextFrame();
const component = shouldRun
? await startProxiFyreService()
: await stopProxiFyreService();
setComponents((current) => upsertComponent(current, component));
showNotice({
kind: 'success',
title: shouldRun ? 'Служба запущена' : 'Служба остановлена',
text: proxyfierDetails(component, false),
});
} catch (error) {
showNotice({
kind: 'error',
title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена',
text: errorMessage(error),
});
} finally {
setServiceAction(null);
settleServiceVisual();
}
}
function startServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
serviceVisualTimerRef.current = null;
}
setServiceVisualState('active');
}
function settleServiceVisual() {
if (serviceVisualTimerRef.current !== null) {
window.clearTimeout(serviceVisualTimerRef.current);
}
setServiceVisualState('settling');
serviceVisualTimerRef.current = window.setTimeout(() => {
setServiceVisualState(null);
serviceVisualTimerRef.current = null;
}, 700);
}
function showNotice(notice: Notice) {
const entry: LogEntry = {
...notice,
id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`,
at: Date.now(),
};
setLogEntries((current) => [entry, ...current].slice(0, 40));
setActiveLogId(entry.id);
}
return (
<main className="simple-shell">
<section className="simple-panel">
<header className="simple-header">
<div>
<small>VPN Proxy</small>
<h1>Прокси для приложений</h1>
</div>
<button
type="button"
className="ghost-button"
onClick={refresh}
disabled={isLoading || isDetectingComponents}
>
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
</button>
</header>
<div className={`finder-card ${finderStateClass} ${finderVisualClass}`.trim()}>
<span className="finder-border-glow" aria-hidden="true">
<span className="finder-border-glow-segment top" />
<span className="finder-border-glow-segment right" />
<span className="finder-border-glow-segment bottom" />
<span className="finder-border-glow-segment left" />
</span>
<span className="status-light" />
<div className="finder-text">
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
</div>
<div className="service-actions" aria-label="Управление службой ProxiFyre">
<button
type="button"
className="service-button"
onClick={() => setProxiFyreServiceRunning(true)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || Boolean(proxyfier?.running)}
>
{serviceAction === 'start' ? '...' : 'Запустить'}
</button>
<button
type="button"
className="service-button stop"
onClick={() => setProxiFyreServiceRunning(false)}
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || !proxyfier?.running}
>
{serviceAction === 'stop' ? '...' : 'Остановить'}
</button>
</div>
</div>
<label className="simple-field">
<span>Прокси</span>
<input
value={proxyInput}
onChange={(event) => setProxyInput(event.target.value)}
placeholder="socks5://127.0.0.1:1080"
spellCheck={false}
/>
</label>
<section className="apps-section">
<div className="section-head">
<h2>Приложения</h2>
<span>{items.length}</span>
</div>
{isProcessInputOpen ? (
<div className="process-add-line">
<input
value={processInput}
onChange={(event) => setProcessInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') addProcess();
if (event.key === 'Escape') {
setProcessInput('');
setIsProcessInputOpen(false);
}
}}
placeholder="Discord"
spellCheck={false}
autoFocus
/>
<button type="button" onClick={addProcess}>
OK
</button>
<button
type="button"
className="process-cancel-button"
onClick={() => {
setProcessInput('');
setIsProcessInputOpen(false);
}}
>
Отмена
</button>
</div>
) : (
<div className="add-toolbar" aria-label="Добавить приложение">
<button
type="button"
className="add-tile"
onClick={() => setIsProcessInputOpen(true)}
aria-label="Добавить процесс"
title="Процесс"
>
<Cpu size={22} strokeWidth={1.8} />
<span className="sr-only">Процесс</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('exe')}
disabled={Boolean(pickerAction)}
aria-label="Добавить EXE-файл"
title="EXE-файл"
>
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={22} strokeWidth={1.8} />}
<span className="sr-only">EXE-файл</span>
</button>
<button
type="button"
className="add-tile"
onClick={() => void pickAndAddItem('folder')}
disabled={Boolean(pickerAction)}
aria-label="Добавить папку"
title="Папка"
>
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={22} strokeWidth={1.8} />}
<span className="sr-only">Папка</span>
</button>
</div>
)}
<div className="app-list">
{isLoading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
Удалить
</button>
</div>
))
) : (
<div className="empty-state">Добавь процесс, папку или путь к EXE-файлу.</div>
)}
</div>
</section>
<div className="command-row">
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
</button>
<button
type="button"
className="open-config-button"
onClick={openConfig}
disabled={isOpeningConfig}
>
{isOpeningConfig ? '...' : 'Открыть'}
</button>
</div>
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
</section>
{logEntries.length ? (
<aside className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
{activeLog ? (
<>
<strong>{activeLog.title}</strong>
<span>{activeLog.text}</span>
</>
) : (
<span className="log-muted">Журнал событий</span>
)}
</div>
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
<span>{logEntries.length}</span>
</button>
{isLogOpen ? (
<div className="log-history">
{logEntries.map((entry) => (
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
<time>{formatLogTime(entry.at)}</time>
<div>
<strong>{entry.title}</strong>
<span>{entry.text}</span>
</div>
</div>
))}
</div>
) : null}
</aside>
) : null}
</main>
);
}
interface ParsedProxy {
protocol: 'socks5';
host: string;
port: number;
}
function parseProxy(rawValue: string): ParsedProxy {
const value = rawValue.trim();
if (!value) throw new Error('Введи адрес прокси.');
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
let parsed: URL;
try {
parsed = new URL(withProtocol);
} catch {
throw new Error('Формат: socks5://host:port или host:port.');
}
const protocol = parsed.protocol.replace(':', '').toLowerCase();
if (protocol !== 'socks5') {
throw new Error('Сейчас поддерживается только SOCKS5.');
}
if (parsed.username || parsed.password) {
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
}
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const port = Number(parsed.port);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('Укажи хост и порт прокси.');
}
return { protocol: 'socks5', host, port };
}
function targetForUi(targets: Target[], profile: Profile | undefined) {
if (profile) return targets.find((target) => target.id === profile.targetId);
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
}
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
const seen = new Set<string>();
const items: DraftItem[] = [];
for (const profile of profiles) {
for (const item of profile.items) {
if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue;
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
items.push({
id: `${item.type}-${items.length}-${item.value}`,
type: item.type,
value: item.value,
});
}
}
return items;
}
function formatProxy(target: Target) {
return target.protocol === 'socks5'
? `${target.host}:${target.port}`
: `${target.protocol}://${target.host}:${target.port}`;
}
function normalizeItemValue(value: string, type: DraftItemType) {
const clean = value.trim().replace(/^"|"$/g, '');
if (!clean) return '';
if (type === 'folder' || type === 'exe') return clean;
return clean
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '')
.trim() ?? '';
}
async function pickPath(type: Extract<DraftItemType, 'exe' | 'folder'>) {
const selected = await open(
type === 'folder'
? {
title: 'Выбери папку',
directory: true,
multiple: false,
}
: {
title: 'Выбери EXE-файл',
directory: false,
multiple: false,
filters: [{ name: 'EXE-файлы', extensions: ['exe'] }],
},
);
if (Array.isArray(selected)) return selected[0] ?? null;
return selected;
}
function nextFrame() {
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
function profileItemInput(item: DraftItem): ProfileItemInput {
return {
type: item.type,
value: item.value,
recursive: item.type === 'folder',
};
}
function emptyItemMessage(type: DraftItemType) {
if (type === 'process') return 'Введи имя процесса.';
if (type === 'folder') return 'Введи путь к папке.';
return 'Введи путь к EXE-файлу.';
}
function itemTypeLabel(type: DraftItemType) {
if (type === 'process') return 'процесс';
if (type === 'folder') return 'папка';
return 'EXE-файл';
}
function itemIcon(type: DraftItemType) {
if (type === 'process') return <Cpu size={18} strokeWidth={1.9} />;
if (type === 'folder') return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
function profileInputFromProfile(profile: Profile, enabled: boolean) {
return {
id: profile.id,
name: profile.name,
enabled,
targetId: profile.targetId,
protocols: profile.protocols,
items: profile.items.map((item) => ({
type: item.type,
value: item.value,
recursive: item.recursive,
})),
};
}
function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Проверяю ProxiFyre';
if (!component) return 'ProxiFyre не проверен';
if (component.running) return 'ProxiFyre найден и запущен';
if (component.installed) return 'ProxiFyre найден';
return 'ProxiFyre не найден';
}
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
if (checking) return 'Ищу установленный клиент и состояние службы.';
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
if (component.path) return component.path;
return component.problems[0] ?? 'Путь установки не найден.';
}
function noticeFromApply(result: ApplyProfilesResponse): Notice {
return {
kind: result.success ? 'success' : 'error',
title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен',
text: result.message,
};
}
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
const index = components.findIndex((current) => current.id === component.id);
if (index === -1) return [...components, component];
return [
...components.slice(0, index),
component,
...components.slice(index + 1),
];
}
function sameValue(left: string, right: string) {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
function formatLogTime(timestamp: number) {
return new Date(timestamp).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
return String((error as { message: unknown }).message);
}
return 'Неизвестная ошибка.';
}

View File

@@ -1,77 +0,0 @@
export type Protocol = 'TCP' | 'UDP';
export type ProfileItemType = 'process' | 'folder' | 'exe';
export type TargetKind = 'local' | 'external';
export type ProxyProtocol = 'socks5' | 'http';
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
export interface ProfileItemInput {
type: ProfileItemType | string;
value: string;
recursive?: boolean;
}
export interface ProfileInput {
id?: string;
name: string;
enabled?: boolean;
targetId?: string;
protocols?: string[];
items?: ProfileItemInput[];
}
export interface ProfileItem {
type: ProfileItemType;
value: string;
recursive: boolean;
}
export interface Profile {
id: string;
name: string;
enabled: boolean;
targetId: string;
protocols: Protocol[];
items: ProfileItem[];
}
export interface TargetInput {
id?: string;
name: string;
kind?: TargetKind | string;
protocol?: ProxyProtocol | string;
host: string;
port: number;
requiresComponent?: ComponentId | string;
}
export interface Target {
id: string;
name: string;
kind: TargetKind;
protocol: ProxyProtocol;
host: string;
port: number;
requiresComponent?: ComponentId;
}
export interface ComponentStatus {
id: ComponentId;
name: string;
state: ComponentState;
installed: boolean;
running: boolean;
version?: string;
path?: string;
problems: string[];
actions: string[];
}
export interface ActivityEntry {
id: string;
at: string;
level: ActivityLevel;
title: string;
message: string;
}

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/app.css';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -1,776 +0,0 @@
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
color: #e5e7eb;
background: #101216;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #101216;
}
button,
input {
font: inherit;
}
button {
border: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.simple-shell {
display: block;
min-height: 100vh;
background: #101216;
padding: 0;
}
.simple-panel {
display: grid;
align-content: start;
min-height: 100vh;
width: 100%;
border: 0;
border-radius: 0;
background: #101216;
box-shadow: none;
padding: 18px 18px 96px;
}
.simple-header,
.finder-card,
.section-head,
.add-toolbar,
.process-add-line,
.app-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.simple-header {
margin: -18px -18px 16px;
border-bottom: 1px solid #2a2f3a;
background: #181b22;
padding: 14px 18px;
}
.simple-header small,
.simple-field span,
.app-row-main > div > span,
.config-path,
.finder-card span {
color: #8d99ae;
}
.simple-header h1,
.section-head h2 {
margin: 0;
letter-spacing: 0;
}
.simple-header h1 {
margin-top: 4px;
font-size: 22px;
line-height: 1.1;
font-weight: 650;
}
.section-head h2 {
font-size: 16px;
}
.ghost-button,
.add-toolbar button,
.process-add-line button,
.app-row button,
.open-config-button,
.service-button {
min-height: 36px;
border: 1px solid #343b49;
border-radius: 4px;
background: #242a35;
color: #eef2ff;
padding: 8px 12px;
cursor: pointer;
}
.ghost-button:hover,
.add-toolbar button:hover,
.process-add-line button:hover,
.app-row button:hover,
.open-config-button:hover,
.service-button:hover {
background: #2d3543;
}
.finder-card {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
justify-content: stretch;
min-height: 56px;
overflow: hidden;
border: 1px solid #2b3342;
border-radius: 4px;
background: #151923;
padding: 12px;
}
.finder-border-glow {
position: absolute;
z-index: 0;
inset: 0;
overflow: hidden;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.finder-card > :not(.finder-border-glow) {
position: relative;
z-index: 1;
}
.finder-card.checking .finder-border-glow,
.finder-card.working .finder-border-glow {
opacity: 1;
}
.finder-card.checking .finder-border-glow {
opacity: 0.78;
}
.finder-card.settling .finder-border-glow {
opacity: 0;
transition-duration: 0.7s;
}
.finder-border-glow-segment {
position: absolute;
display: block;
background: #93c5fd;
box-shadow:
0 0 8px rgba(96, 165, 250, 0.95),
0 0 16px rgba(34, 197, 94, 0.36);
opacity: 0;
}
.finder-border-glow-segment.top,
.finder-border-glow-segment.bottom {
width: 108px;
height: 2px;
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.right,
.finder-border-glow-segment.left {
width: 2px;
height: 64px;
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.finder-border-glow-segment.top {
top: 0;
animation: finder-border-top 1.6s linear infinite;
}
.finder-border-glow-segment.right {
right: 0;
animation: finder-border-right 1.6s linear infinite;
}
.finder-border-glow-segment.bottom {
bottom: 0;
animation: finder-border-bottom 1.6s linear infinite;
}
.finder-border-glow-segment.left {
left: 0;
animation: finder-border-left 1.6s linear infinite;
}
.finder-text,
.app-row > div,
.app-row-main > div {
min-width: 0;
}
.finder-card strong,
.finder-card span,
.app-row-main strong,
.app-row-main > div > span {
display: block;
overflow-wrap: anywhere;
}
.status-light {
flex: 0 0 auto;
width: 11px;
height: 11px;
border-radius: 999px;
background: #ef4444;
}
.finder-card.found .status-light {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
}
.finder-card.missing .status-light {
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.finder-card.checking .status-light {
border: 2px solid #3b82f6;
border-top-color: transparent;
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
}
.service-actions {
display: flex;
gap: 6px;
align-items: center;
}
.service-button {
min-width: 102px;
white-space: nowrap;
}
.service-button.stop {
color: #fecaca;
}
.simple-field {
display: grid;
gap: 7px;
margin: 14px 0;
}
.simple-field input,
.process-add-line input {
min-height: 42px;
width: 100%;
border: 1px solid #343b49;
border-radius: 4px;
background: #0d1016;
color: #f8fafc;
outline: none;
padding: 9px 11px;
}
.simple-field input:focus,
.process-add-line input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.apps-section {
display: grid;
gap: 10px;
margin-top: 8px;
}
.section-head span {
min-width: 28px;
border: 1px solid #343b49;
border-radius: 4px;
background: #1b202b;
color: #dbeafe;
padding: 3px 9px;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.add-toolbar {
justify-content: center;
padding: 4px 0;
}
.add-tile {
display: grid;
place-items: center;
width: 74px;
height: 48px;
border-color: #2b3342;
background: #131720;
color: #dbeafe;
padding: 0;
}
.add-tile svg {
display: block;
}
.add-tile:hover {
border-color: #3b82f6;
background: #182033;
}
.add-tile[aria-pressed="true"] {
border-color: #3b82f6;
background: #1e3a8a;
}
.tile-loading {
color: #dbeafe;
font-weight: 800;
animation: pulse 1s ease-in-out infinite;
}
.process-add-line {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px 88px;
}
.process-add-line button {
border-color: #166534;
background: #14532d;
font-weight: 700;
}
.process-add-line .process-cancel-button {
border-color: #343b49;
background: #242a35;
color: #cbd5e1;
font-weight: 600;
}
.process-add-line button:hover {
background: #166534;
}
.process-add-line .process-cancel-button:hover {
background: #2d3543;
}
.app-list {
display: grid;
gap: 8px;
}
.app-row-main {
display: flex;
align-items: center;
gap: 10px;
}
.app-row .item-icon {
display: grid;
flex: 0 0 auto;
align-self: center;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid #2b3342;
border-radius: 4px;
background: #0d1016;
color: #dbeafe;
line-height: 0;
}
.app-row .item-icon svg {
display: block;
}
.app-row,
.empty-state {
border: 1px solid #2b3342;
border-radius: 4px;
background: #131720;
padding: 10px 12px;
}
.app-row button {
color: #fecaca;
}
.empty-state {
color: #8d99ae;
min-height: 48px;
}
.list-skeleton {
display: grid;
gap: 8px;
}
.list-skeleton span {
min-height: 56px;
border: 1px solid #2b3342;
border-radius: 4px;
background:
linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent),
#131720;
background-size: 220% 100%;
animation: shimmer 1.15s linear infinite;
}
.command-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
margin-top: 14px;
}
.apply-button {
min-height: 46px;
width: 100%;
border: 1px solid #16a34a;
border-radius: 4px;
background: #22c55e;
color: #04130a;
font-weight: 800;
cursor: pointer;
}
.apply-button:hover {
background: #4ade80;
}
.open-config-button {
min-height: 46px;
width: 100%;
}
.config-path {
margin: 10px 0 0;
font-size: 12px;
overflow-wrap: anywhere;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.log-dock {
position: fixed;
right: 10px;
bottom: 10px;
left: 10px;
z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 48px;
border: 1px solid #2b3342;
border-radius: 4px;
background: rgba(19, 23, 32, 0.98);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34);
padding: 7px;
}
.log-dock.error {
border-color: rgba(239, 68, 68, 0.58);
}
.log-dock.success {
border-color: rgba(34, 197, 94, 0.58);
}
.log-dock.info {
border-color: rgba(59, 130, 246, 0.58);
}
.log-current {
display: flex;
min-width: 0;
gap: 10px;
align-items: baseline;
padding: 0 8px;
transition: opacity 180ms ease, transform 180ms ease;
}
.log-current.hidden {
color: #8d99ae;
opacity: 0.72;
}
.log-current.visible {
opacity: 1;
}
.log-current strong,
.log-current span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-current strong {
flex: 0 0 auto;
}
.log-current span {
min-width: 0;
}
.log-muted {
color: #8d99ae;
}
.log-toggle {
display: inline-flex;
gap: 8px;
align-items: center;
min-height: 34px;
border: 1px solid #343b49;
border-radius: 4px;
background: #242a35;
color: #eef2ff;
padding: 7px 10px;
cursor: pointer;
}
.log-toggle:hover {
background: #2d3543;
}
.log-toggle span {
min-width: 22px;
border-radius: 4px;
background: #1b202b;
color: #dbeafe;
padding: 2px 6px;
text-align: center;
font-size: 12px;
font-weight: 700;
}
.log-history {
display: grid;
grid-column: 1 / -1;
gap: 6px;
max-height: 230px;
overflow: auto;
border-top: 1px solid #2b3342;
padding-top: 7px;
}
.log-history-row {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid #2b3342;
border-radius: 4px;
background: #0d1016;
padding: 8px;
}
.log-history-row.error {
border-color: rgba(239, 68, 68, 0.42);
}
.log-history-row.success {
border-color: rgba(34, 197, 94, 0.36);
}
.log-history-row.info {
border-color: rgba(59, 130, 246, 0.36);
}
.log-history-row time {
color: #8d99ae;
font-size: 12px;
}
.log-history-row strong,
.log-history-row span {
display: block;
overflow-wrap: anywhere;
}
.log-history-row span {
color: #b6c2d4;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
@keyframes finder-border-top {
0% {
left: -116px;
opacity: 0;
}
4%,
23% {
opacity: 1;
}
25%,
100% {
left: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-right {
0%,
25% {
top: -72px;
opacity: 0;
}
29%,
48% {
opacity: 1;
}
50%,
100% {
top: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-bottom {
0%,
50% {
right: -116px;
opacity: 0;
}
54%,
73% {
opacity: 1;
}
75%,
100% {
right: calc(100% + 8px);
opacity: 0;
}
}
@keyframes finder-border-left {
0%,
75% {
bottom: -72px;
opacity: 0;
}
79%,
98% {
opacity: 1;
}
100% {
bottom: calc(100% + 8px);
opacity: 0;
}
}
@keyframes shimmer {
from {
background-position: 220% 0;
}
to {
background-position: -220% 0;
}
}
@media (max-width: 680px) {
.simple-shell {
padding: 0;
}
.simple-panel {
padding: 14px 14px 100px;
}
.simple-header,
.app-row {
align-items: stretch;
flex-direction: column;
}
.add-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.add-tile {
width: 100%;
}
.process-add-line {
grid-template-columns: 1fr;
}
.finder-card {
grid-template-columns: auto minmax(0, 1fr);
}
.service-actions {
grid-column: 1 / -1;
}
.service-button {
flex: 1;
}
.command-row {
grid-template-columns: 1fr;
}
.log-dock {
right: 8px;
left: 8px;
grid-template-columns: 1fr;
}
.log-current {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.log-history-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

View File

@@ -1,18 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
host: host || false,
port: 5173,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**'],
},
},
});

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:

View File

@@ -1,23 +1,21 @@
services:
vpn-proxy-client:
harbor-connect:
build:
context: .
dockerfile: Dockerfile.client
args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
container_name: vpn-proxy-client
container_name: harbor-connect
environment:
APP_MODE: client
PORT: ${PORT:-3456}
PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
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
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
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: ""
@@ -29,10 +27,11 @@ services:
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:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}"
- "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"]

View File

@@ -1,5 +1,5 @@
services:
vpn-proxy-gateway:
x-gateway-image: &gateway-image
image: ${GATEWAY_IMAGE:-vpn-proxy-gateway:local}
build:
context: .
dockerfile: Dockerfile
@@ -8,19 +8,56 @@ services:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
container_name: vpn-proxy-gateway
services:
vpn-proxy-dataplane:
<<: *gateway-image
container_name: vpn-proxy-dataplane
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
env_file:
- .env
- 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"]
@@ -32,3 +69,4 @@ services:
volumes:
vpn-proxy-data:
sing-box-cache:
vpn-proxy-runtime:

View File

@@ -1,22 +0,0 @@
services:
vpn-proxy-gateway:
image: ${GATEWAY_IMAGE}
container_name: vpn-proxy-gateway
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
env_file:
- .env
environment:
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
- sing-box-cache:/var/lib/sing-box
restart: unless-stopped
volumes:
vpn-proxy-data:
sing-box-cache:

File diff suppressed because it is too large Load Diff

View File

@@ -1,774 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VPN Proxy Route Console Redesign</title>
<style>
:root {
color-scheme: light;
--bg: oklch(0.965 0.008 232);
--surface: oklch(0.986 0.006 232);
--surface-2: oklch(0.948 0.009 232);
--surface-3: oklch(0.918 0.014 232);
--ink: oklch(0.238 0.028 238);
--muted: oklch(0.47 0.028 238);
--subtle: oklch(0.62 0.022 238);
--line: oklch(0.835 0.018 232);
--line-strong: oklch(0.72 0.032 232);
--blue: oklch(0.56 0.14 244);
--blue-soft: oklch(0.915 0.045 244);
--green: oklch(0.61 0.13 153);
--green-soft: oklch(0.915 0.052 153);
--amber: oklch(0.72 0.13 74);
--amber-soft: oklch(0.93 0.07 74);
--red: oklch(0.58 0.15 27);
--radius: 8px;
--shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-size: 14px;
line-height: 1.45;
}
button,
input,
select {
font: inherit;
}
.page {
min-height: 100vh;
padding: 24px;
}
.shell {
max-width: 1320px;
min-height: calc(100vh - 48px);
margin: 0 auto;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 64px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: oklch(0.978 0.007 232);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
width: 32px;
height: 32px;
border-radius: 7px;
background:
linear-gradient(135deg, oklch(0.52 0.13 244), oklch(0.62 0.12 153));
position: relative;
}
.mark::after {
content: "";
position: absolute;
inset: 8px;
border: 2px solid oklch(0.985 0.005 232);
border-left-color: transparent;
border-radius: 50%;
}
.brand h1 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.brand span {
color: var(--muted);
font-size: 12px;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 11px;
border: 1px solid oklch(0.73 0.05 153);
border-radius: 999px;
background: var(--green-soft);
color: oklch(0.34 0.08 153);
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.btn {
min-height: 34px;
padding: 0 13px;
border-radius: 7px;
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--ink);
font-weight: 650;
cursor: default;
}
.btn.primary {
border-color: oklch(0.49 0.13 244);
background: var(--blue);
color: oklch(0.985 0.005 232);
}
.workspace {
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
gap: 0;
min-height: 0;
}
.rail {
padding: 18px 14px;
border-right: 1px solid var(--line);
background: oklch(0.956 0.009 232);
}
.nav-title,
.panel-label,
.field-label {
margin: 0;
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.mode-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.mode {
width: 100%;
padding: 12px;
display: grid;
grid-template-columns: auto 1fr;
gap: 11px;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.mode.active {
border-color: oklch(0.68 0.08 244);
background: var(--blue-soft);
}
.mode-dot {
width: 11px;
height: 11px;
margin-top: 4px;
border-radius: 50%;
background: var(--subtle);
}
.mode.active .mode-dot {
background: var(--blue);
}
.mode strong {
display: block;
font-size: 14px;
}
.mode span {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
}
.rail-section {
margin-top: 24px;
}
.mini-list {
display: grid;
gap: 7px;
margin-top: 10px;
}
.mini-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-size: 12px;
}
.mini-row span {
color: var(--muted);
}
.main {
min-width: 0;
padding: 22px;
overflow: auto;
}
.route-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: start;
margin-bottom: 18px;
}
.route-head h2 {
margin: 0;
font-size: 28px;
line-height: 1.12;
}
.route-head p {
max-width: 68ch;
margin: 8px 0 0;
color: var(--muted);
}
.health {
min-width: 210px;
padding: 12px 14px;
border: 1px solid oklch(0.76 0.07 153);
border-radius: var(--radius);
background: var(--green-soft);
}
.health strong {
display: block;
font-size: 18px;
}
.health span {
color: oklch(0.38 0.07 153);
font-size: 12px;
}
.route-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.node {
min-height: 126px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
position: relative;
}
.node.active {
border-color: oklch(0.72 0.075 153);
background: var(--green-soft);
}
.node.pending {
border-color: oklch(0.8 0.09 74);
background: var(--amber-soft);
}
.node small {
color: var(--muted);
font-weight: 700;
}
.node strong {
display: block;
margin-top: 9px;
font-size: 18px;
}
.node span {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.flow-line {
display: flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 13px;
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
overflow-x: auto;
white-space: nowrap;
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.flow-line b {
color: var(--blue);
}
.flow-line span {
color: var(--muted);
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
gap: 14px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 50px;
padding: 0 14px;
border-bottom: 1px solid var(--line);
}
.panel-head h3 {
margin: 0;
font-size: 15px;
}
.panel-body {
padding: 14px;
}
.form-grid {
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.control-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.input,
.select {
width: 100%;
min-height: 38px;
border: 1px solid var(--line-strong);
border-radius: 7px;
background: oklch(0.992 0.004 232);
color: var(--ink);
padding: 0 11px;
}
.summary-list {
display: grid;
gap: 8px;
}
.summary-row {
display: grid;
grid-template-columns: 94px minmax(0, 1fr);
gap: 10px;
align-items: baseline;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.summary-row:last-child {
border-bottom: 0;
}
.summary-row small {
color: var(--muted);
font-weight: 700;
}
.summary-row strong {
overflow-wrap: anywhere;
}
.side {
padding: 18px 14px;
border-left: 1px solid var(--line);
background: oklch(0.956 0.009 232);
overflow: auto;
}
.copy-stack {
display: grid;
gap: 8px;
margin-top: 10px;
}
.copy-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 12px;
}
.copy-row button {
border: 0;
background: transparent;
color: var(--blue);
font-weight: 700;
}
.side-panel {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.activity {
display: grid;
gap: 0;
}
.activity-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.activity-row:last-child {
border-bottom: 0;
}
.activity-row time {
color: var(--muted);
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
font-size: 11px;
}
.activity-row strong {
display: block;
font-size: 13px;
}
.activity-row span {
display: block;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1080px) {
.workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
.side {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid var(--line);
}
.route-strip,
.settings-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.page {
padding: 10px;
}
.shell {
min-height: calc(100vh - 20px);
}
.topbar,
.route-head,
.workspace {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
padding: 14px;
}
.top-actions {
flex-wrap: wrap;
}
.rail {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.route-strip,
.settings-grid,
.control-row {
grid-template-columns: 1fr;
}
.main,
.side {
padding: 14px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="shell" aria-label="VPN Proxy redesign preview">
<header class="topbar">
<div class="brand">
<div class="mark" aria-hidden="true"></div>
<div>
<h1>VPN Proxy Client</h1>
<span>Local control panel, macOS Docker mode</span>
</div>
</div>
<div class="top-actions">
<span class="status-pill">sing-box running</span>
<button class="btn">Restart</button>
<button class="btn primary">Apply route</button>
</div>
</header>
<div class="workspace">
<aside class="rail" aria-label="Connection modes">
<p class="nav-title">Режим подключения</p>
<div class="mode-list">
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Общий gateway</strong>
<span>192.168.50.111:8080</span>
</span>
</button>
<button class="mode active" type="button">
<span class="mode-dot"></span>
<span>
<strong>Локальный VPN</strong>
<span>Finland 02 selected</span>
</span>
</button>
<button class="mode" type="button">
<span class="mode-dot"></span>
<span>
<strong>Напрямую</strong>
<span>без VPN и gateway</span>
</span>
</button>
</div>
<section class="rail-section">
<p class="nav-title">Проверки</p>
<div class="mini-list">
<div class="mini-row"><strong>Docker</strong><span>ready</span></div>
<div class="mini-row"><strong>Ports</strong><span>8082 open</span></div>
<div class="mini-row"><strong>Config</strong><span>valid</span></div>
</div>
</section>
</aside>
<section class="main">
<div class="route-head">
<div>
<h2>Текущий маршрут: приложения Mac идут через локальный VPN</h2>
<p>
Главный экран показывает не настройки как список, а фактический путь трафика:
от приложения до интернета, с портом, выбранным режимом и состоянием сервиса.
</p>
</div>
<div class="health">
<strong>42 ms</strong>
<span>последняя проверка маршрута</span>
</div>
</div>
<div class="route-strip" aria-label="Active route">
<div class="node">
<small>Источник</small>
<strong>Mac apps</strong>
<span>браузер, Discord, Telegram</span>
</div>
<div class="node active">
<small>Локальный proxy</small>
<strong>127.0.0.1:8082</strong>
<span>HTTP и SOCKS5</span>
</div>
<div class="node active">
<small>Режим</small>
<strong>Local VPN</strong>
<span>Finland 02</span>
</div>
<div class="node pending">
<small>Выход</small>
<strong>Internet</strong>
<span>проверка 11:04</span>
</div>
</div>
<div class="flow-line" aria-label="Route path">
<b>Mac apps</b><span>></span><b>127.0.0.1:8082</b><span>></span><b>sing-box</b><span>></span><b>Finland 02</b><span>></span><b>Internet</b>
</div>
<div class="settings-grid">
<section class="panel">
<div class="panel-head">
<h3>Настройка выбранного режима</h3>
<button class="btn">Load subscription</button>
</div>
<div class="panel-body">
<div class="form-grid">
<label class="field">
<span class="field-label">Подписка или VLESS</span>
<div class="control-row">
<input class="input" value="https://provider.example/subscription" />
<button class="btn">Refresh</button>
</div>
</label>
<label class="field">
<span class="field-label">VPN-сервер</span>
<div class="control-row">
<select class="select">
<option>Finland 02, 42 ms, 18 GB left</option>
</select>
<button class="btn primary">Connect</button>
</div>
</label>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h3>Сводка</h3>
</div>
<div class="panel-body">
<div class="summary-list">
<div class="summary-row"><small>Service</small><strong>running since 10:52</strong></div>
<div class="summary-row"><small>Config</small><strong>applied 2 minutes ago</strong></div>
<div class="summary-row"><small>Fallback</small><strong>VPN by default</strong></div>
<div class="summary-row"><small>Quota</small><strong>18 GB left</strong></div>
</div>
</div>
</section>
</div>
</section>
<aside class="side" aria-label="Local proxy details">
<p class="panel-label">Адреса для приложений</p>
<div class="copy-stack">
<div class="copy-row"><span>http://127.0.0.1:8082</span><button>Copy</button></div>
<div class="copy-row"><span>socks5://127.0.0.1:8082</span><button>Copy</button></div>
</div>
<section class="side-panel">
<div class="panel-head">
<h3>Порт proxy</h3>
</div>
<div class="panel-body">
<div class="control-row">
<input class="input" value="8082" />
<button class="btn">Save</button>
</div>
</div>
</section>
<section class="side-panel">
<div class="panel-head">
<h3>Активность</h3>
</div>
<div class="panel-body">
<div class="activity">
<div class="activity-row">
<time>11:04</time>
<span><strong>Route check passed</strong><span>Finland 02 returned 42 ms</span></span>
</div>
<div class="activity-row">
<time>11:02</time>
<span><strong>Port changed</strong><span>8080 was busy, 8082 selected</span></span>
</div>
<div class="activity-row">
<time>10:59</time>
<span><strong>Subscription refreshed</strong><span>12 servers available</span></span>
</div>
</div>
</div>
</section>
</aside>
</div>
</section>
</main>
</body>
</html>

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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
# Goal: Windows Tauri Proxy Client
Use Krypton Execution to execute `docs/goals/windows-modular-client/PLAN.md`.
Core rules:
- Treat `PLAN.md` as the source plan.
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
- Build a separate Tauri 2 Windows desktop app under `apps/windows-client`.
- Do not implement Windows by extending the current Node gateway/client server.
- Keep Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
- Make external proxy target + ProxiFyre profile apply the MVP.
- Keep Local sing-box optional; it must not be required for external target profiles.
- Keep generated ProxiFyre and sing-box configs derived from source models.
- Capture acceptance evidence from the target user's perspective and record it in `EVIDENCE.md`.
- Say "implemented but unproven" if Windows-only privileged evidence cannot be captured.

View File

@@ -1,506 +0,0 @@
# Windows Tauri Proxy Client Implementation Plan
**Intent:** Build a separate Windows desktop proxy management app using Tauri 2, React, TypeScript, and Rust. The app manages three independent components: Control App, Proxyfier Layer, and optional Local sing-box.
**Current Behavior:** The repo contains a gateway/client Node + React web application and planning documents for a Windows mode inside that app. A newer product/technology brief now targets a standalone Windows desktop utility instead of extending the existing web control panel.
**Expected Outcome:** A compact Windows desktop utility lets the user configure app-level proxy routing through external SOCKS5/HTTP targets first, then optionally install and use local sing-box. The app remains useful when sing-box is absent.
**Target-Perspective Output:** A Windows user opens the desktop app, sees Overview, Profiles, Targets, Components, and Logs, adds Discord or another process/folder/exe profile, selects an external proxy target, applies changes to the proxyfier layer, and sees component status plus recent activity. Later, installing Local sing-box adds a local target without changing the profile model.
**Truth Owner:** Source configuration lives in the Tauri app's Rust domain model and JSON files under `C:\ProgramData\VpnProxy`. Generated ProxiFyre and sing-box configs are derived artifacts. Privileged install/service operations are owned by explicit helper/installer flows, not by React UI state.
**Contract Boundary:** React UI calls typed Tauri commands. Tauri Rust backend validates and persists profiles/targets/components. Proxy routing is behind a `ProxyRouterAdapter` boundary, with ProxiFyre as the first adapter. Privileged operations go through explicit helper/install commands returning structured JSON.
**Cutover:** Supersede the prior Node `APP_MODE=windows` implementation direction. Keep existing gateway/client code intact. New Windows work lives under a separate Tauri app slice.
**Displaced Path:** The old plan to add Windows mode into `src/server`/`src/web` is demoted to historical context. Do not add a third app mode to the current Node server for this product.
**Value Density:** The smallest high-value slice is the desktop app MVP with external SOCKS5 target + ProxiFyre profile apply. Local sing-box is optional and comes after the proxyfier MVP is proven.
**Evidence Gate:** Evidence must include target-perspective app proof: built Tauri app or dev window screenshot/state, generated proxyfier config artifact, mocked or real helper response, and manual Windows checklist when privileged components are involved.
**Acceptance Evidence:** Automated tests pass for Rust/TypeScript domain logic, app build succeeds, the MVP can create a profile and generate/apply ProxiFyre config against an external target, and Windows manual evidence proves independent component behavior.
**Evidence Lane:** Record command output, app screenshots/state payloads, generated configs, and manual verification in `docs/goals/windows-modular-client/EVIDENCE.md`.
**Kill Criteria:** No Windows implementation inside current Node gateway/client server; no mandatory sing-box dependency; no generated config as source truth; no hidden installation during profile apply; no direct UI parsing of raw PowerShell/stdout.
**Architecture Slice:** New standalone Tauri app under `apps/windows-client`, plus docs updates that point from older Windows plans to this plan.
**Plan Review Gate:** Requires PRE review before implementation execution.
## Source Brief
Product and technology source brief:
- `docs/windows-client-product-tech-brief.md`
This plan turns that brief into an execution-ready implementation sequence.
## Outcome Contract
Plan title: Windows Tauri Proxy Client
Intent: Build a native-feeling Windows utility that manages app-level proxy routing while keeping Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
Current behavior:
- Existing runtime code is a Node HTTP server and Vite/React web UI for gateway and Mac-style client modes.
- Earlier Windows docs describe adding Windows mode to that existing app.
- The selected direction is now Tauri 2 + React/TypeScript + Rust as a separate Windows desktop app.
Expected outcome:
- `apps/windows-client` contains a Tauri 2 app.
- The app has Overview, Profiles, Targets, Components, and Logs surfaces.
- Profiles store process/folder/exe source items.
- Targets store external proxy endpoints and optional local sing-box.
- ProxiFyre is the first proxy router adapter.
- Local sing-box is optional and never required for external target profiles.
Target-perspective output:
- User can install/run only the Control App.
- User can see Proxyfier and Local sing-box as separate components.
- User can add an external SOCKS5 target.
- User can add a Discord process profile.
- User can apply the profile to generated ProxiFyre config.
- User sees activity confirming whether apply succeeded or why it was blocked.
Truth owner:
- Rust core domain crate owns normalized models and validation.
- JSON source files under `C:\ProgramData\VpnProxy\config` own persisted profiles/targets/component preferences.
- `ProxyRouterAdapter` owns conversion from source models to proxy-router generated config.
- `SingBoxAdapter` owns generated local sing-box config and service contract.
- React UI owns only transient UI state.
Contract boundary:
- UI -> Tauri commands with typed request/response DTOs.
- Tauri commands -> Rust core services.
- Core services -> adapter traits.
- Adapter traits -> helper/install/service commands when privileged operations are needed.
- Helper/install commands return structured JSON, never unstructured text for app logic.
Cutover:
- Add superseded notes to old Windows Node-mode docs.
- Keep `docs/windows-client-product-tech-brief.md` as product brief.
- Make this `PLAN.md` the execution plan.
- Do not implement Windows by adding `APP_MODE=windows` to the current Node server.
Displaced path:
- Displace old "Windows mode in current web app" implementation.
- Displace "full install vs ProxiFyre-only" as dominant architecture; those become recipes composed from separate components.
Value density:
- MVP must prove app-level routing with external proxy target and ProxiFyre before local sing-box work expands scope.
Evidence gate:
- Tests and build are not enough.
- Capture app-visible state and generated config.
- Capture Windows manual evidence for service/helper actions when those tasks execute.
Acceptance evidence:
- `cargo test` or equivalent Rust tests for domain/adapters.
- frontend typecheck/test/build for React.
- Tauri dev/build command result.
- Screenshot or state dump showing Windows app surfaces.
- Generated ProxiFyre config from a sample profile.
- Manual Windows checklist when privileged components are present.
Non-goals:
- No Electron.
- No extension of the current Node gateway/client UI for Windows MVP.
- No global Windows system proxy changes.
- No transparent routing without a proxy router.
- No mandatory local sing-box.
- No direct coupling of UI to ProxiFyre-specific config shape.
Risk if wrong:
- If built inside the current Node app, the product will inherit gateway/client assumptions and conflict with the selected Tauri direction.
- If ProxiFyre is not behind an adapter, licensing or engine changes will force UI/data rewrites.
- If privileged work is hidden behind apply, users lose control and failures become hard to diagnose.
## Architecture Slice
Files/directories to create:
- `apps/windows-client/package.json`
- `apps/windows-client/vite.config.ts`
- `apps/windows-client/tsconfig.json`
- `apps/windows-client/src/main.tsx`
- `apps/windows-client/src/app/App.tsx`
- `apps/windows-client/src/app/routes.tsx`
- `apps/windows-client/src/api/tauriCommands.ts`
- `apps/windows-client/src/domain/types.ts`
- `apps/windows-client/src/features/overview/*`
- `apps/windows-client/src/features/profiles/*`
- `apps/windows-client/src/features/targets/*`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/logs/*`
- `apps/windows-client/src/styles/*`
- `apps/windows-client/src-tauri/Cargo.toml`
- `apps/windows-client/src-tauri/tauri.conf.json`
- `apps/windows-client/src-tauri/capabilities/default.json`
- `apps/windows-client/src-tauri/src/main.rs`
- `apps/windows-client/src-tauri/src/commands.rs`
- `apps/windows-client/src-tauri/src/models.rs`
- `apps/windows-client/src-tauri/src/storage.rs`
- `apps/windows-client/src-tauri/src/activity.rs`
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
- `apps/windows-client/src-tauri/src/helper.rs`
- `apps/windows-client/src-tauri/tests/*`
- `apps/windows-client/scripts/install-control-app.ps1`
- `apps/windows-client/scripts/install-proxyfier.ps1`
- `apps/windows-client/scripts/install-singbox.ps1`
Files to modify:
- `README.md`
- `docs/roadmap.md`
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
- `docs/superpowers/plans/2026-05-21-windows-client.md`
- `docs/goals/windows-modular-client/GOAL.md`
- `docs/goals/windows-modular-client/EVIDENCE.md`
Files to avoid:
- `src/server/*` except if a later explicit migration asks for shared code extraction.
- `src/web/*` for Windows MVP.
- Docker, entrypoint, and compose files.
- macOS installer.
Source of truth:
- `C:\ProgramData\VpnProxy\config\profiles.json`
- `C:\ProgramData\VpnProxy\config\targets.json`
- `C:\ProgramData\VpnProxy\config\components.json`
- `C:\ProgramData\VpnProxy\state\activity.json`
Derived artifacts:
- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json`
- `C:\ProgramData\VpnProxy\generated\sing-box-config.json`
- ProxiFyre runtime config copied/backed up by helper/apply operation.
Read path:
- React UI calls Tauri commands.
- Tauri commands read JSON source via Rust storage service.
- Component status combines source preferences, filesystem checks, service checks, and helper responses.
Write path:
- React UI sends typed mutations.
- Rust validates with domain models.
- Rust writes source JSON atomically with backups.
- Apply generates derived config and invokes adapter/helper.
Integration points:
- ProxiFyre adapter emits `app-config.json` compatible output.
- Local sing-box adapter emits `sing-box` JSON config and validates via `sing-box check` when binary exists.
- Tauri sidecar/helper permissions are declared explicitly.
- Installer scripts may be launched or displayed explicitly, never silently during apply.
Migration/cutover:
- Older Windows docs point to this plan and source brief.
- Existing Node app remains gateway/client only.
- If shared subscription parsing is needed later, extract it intentionally into a shared package rather than importing server internals.
Acceptance evidence gate:
- MVP evidence must show external-target flow works without local sing-box.
- Optional sing-box evidence must show the same profile model can switch targets after installing sing-box.
## Task Board
### Task 1: Supersede Old Windows Node Plan
Owner: main agent
Input:
- `docs/windows-client-product-tech-brief.md`
- old Windows docs/plans
Files allowed:
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
- `docs/superpowers/plans/2026-05-21-windows-client.md`
- `docs/roadmap.md`
- `README.md`
Files forbidden:
- Runtime source files.
Output:
- Old Windows documents clearly point to this Tauri plan and no longer read as implementation authority.
Evidence:
- `rg -n "Tauri|superseded|windows-client-product-tech-brief|apps/windows-client" README.md docs`
Depends on: none
Parallel safe: yes
### Task 2: Scaffold Tauri App Shell
Owner: main agent
Input:
- Tauri 2 app structure
- Product brief UI surfaces
Files allowed:
- `apps/windows-client/package.json`
- `apps/windows-client/vite.config.ts`
- `apps/windows-client/tsconfig.json`
- `apps/windows-client/index.html`
- `apps/windows-client/src/*`
- `apps/windows-client/src-tauri/*`
Files forbidden:
- Current root `src/server/*`
- Current root `src/web/*`
Output:
- Tauri app starts with empty shell and five navigation surfaces.
- No business logic yet.
Evidence:
- `cd apps/windows-client && npm install && npm run build`
- `cd apps/windows-client/src-tauri && cargo test` if Rust tests exist
Depends on: Task 1
Parallel safe: no
### Task 3: Define Domain Models And Validation
Owner: main agent
Input:
- Profile/Target/Component models from brief
Files allowed:
- `apps/windows-client/src-tauri/src/models.rs`
- `apps/windows-client/src-tauri/src/validation.rs`
- `apps/windows-client/src/domain/types.ts`
- `apps/windows-client/src-tauri/tests/domain_tests.rs`
Files forbidden:
- Adapter/helper code except trait references.
Output:
- Typed Rust models for `Profile`, `ProfileItem`, `Target`, `ComponentStatus`, `ActivityEntry`.
- TypeScript DTOs mirror Rust command responses.
- Validation rejects malformed ports/protocols but allows missing local sing-box.
Evidence:
- Rust tests showing process/folder/exe normalization and external target validation.
Depends on: Task 2
Parallel safe: no
### Task 4: Implement JSON Storage And Activity Log
Owner: main agent
Input:
- Domain models from Task 3
Files allowed:
- `apps/windows-client/src-tauri/src/storage.rs`
- `apps/windows-client/src-tauri/src/activity.rs`
- `apps/windows-client/src-tauri/tests/storage_tests.rs`
Files forbidden:
- UI screens except command wiring stubs.
Output:
- Atomic JSON read/write for profiles, targets, components, and activity.
- Backups before overwriting source files.
- Config root defaults to `C:\ProgramData\VpnProxy`, with test override.
Evidence:
- Tests prove roundtrip, invalid JSON fallback behavior, backup creation, activity cap/sort.
Depends on: Task 3
Parallel safe: no
### Task 5: Add Proxy Router Adapter Boundary And ProxiFyre Adapter
Owner: main agent
Input:
- Domain models and storage
Files allowed:
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
- `apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs`
Files forbidden:
- Direct UI coupling to ProxiFyre config fields.
Output:
- `ProxyRouterAdapter` trait.
- `ProxiFyreAdapter` generates config from enabled profiles and targets.
- External target flow does not require sing-box.
Evidence:
- Test generates ProxiFyre config for Discord + external SOCKS5 target.
- Test blocks local-singbox target only when target requires missing component.
Depends on: Task 4
Parallel safe: no
### Task 6: Add Tauri Commands
Owner: main agent
Input:
- Storage and adapter services
Files allowed:
- `apps/windows-client/src-tauri/src/commands.rs`
- `apps/windows-client/src-tauri/src/main.rs`
- `apps/windows-client/src/api/tauriCommands.ts`
- `apps/windows-client/src-tauri/tests/command_tests.rs`
Files forbidden:
- Full UI implementation beyond command call wrappers.
Output:
- Commands for status, profiles, targets, components, scan/resolve preview, apply, logs.
- Commands return structured responses only.
Evidence:
- Command tests or integration tests prove apply generates derived config and records activity using a mock adapter/helper.
Depends on: Task 5
Parallel safe: no
### Task 7: Build MVP UI
Owner: main agent
Input:
- Tauri command API
- Product brief layout
Files allowed:
- `apps/windows-client/src/app/*`
- `apps/windows-client/src/features/overview/*`
- `apps/windows-client/src/features/profiles/*`
- `apps/windows-client/src/features/targets/*`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/logs/*`
- `apps/windows-client/src/styles/*`
Files forbidden:
- Rust adapter behavior except fixing DTO mismatches.
Output:
- Compact utility UI with Overview, Profiles, Targets, Components, Logs.
- User can create/edit profile, external target, and trigger apply.
- Missing sing-box is shown as valid optional state.
Evidence:
- `npm run build`
- Screenshot or browser/app state showing missing sing-box and usable external target flow.
Depends on: Task 6
Parallel safe: no
### Task 8: Implement Helper And Explicit Installer Boundary
Owner: main agent
Input:
- Component model
- Security model from brief
Files allowed:
- `apps/windows-client/src-tauri/src/helper.rs`
- `apps/windows-client/src-tauri/capabilities/default.json`
- `apps/windows-client/scripts/install-control-app.ps1`
- `apps/windows-client/scripts/install-proxyfier.ps1`
- `apps/windows-client/scripts/install-singbox.ps1`
- `apps/windows-client/src-tauri/tests/helper_tests.rs`
Files forbidden:
- Hidden installer invocation inside profile apply.
Output:
- Helper command abstraction for status/service/apply.
- Installer scripts are explicit and idempotent.
- Tauri sidecar/shell permissions are narrow and documented.
Evidence:
- Helper tests with mock command runner.
- PowerShell parser checks for installer scripts.
- Capability file shows limited sidecar permissions.
Depends on: Task 6
Parallel safe: partly, after command DTOs are stable
### Task 9: Add Optional Local Sing-Box Adapter
Owner: main agent
Input:
- sing-box target model
- service/helper boundary
Files allowed:
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
- `apps/windows-client/src/features/components/*`
- `apps/windows-client/src/features/targets/*`
Files forbidden:
- Making sing-box mandatory for external targets.
Output:
- Generate local sing-box config.
- Validate via `sing-box check` when binary exists.
- Local target appears only when installed/configured or as an explicit install prompt.
Evidence:
- Tests show external target apply works without sing-box.
- Tests show local-singbox target requires installed/running component.
Depends on: Tasks 5 and 8
Parallel safe: no
### Task 10: Package, Verify, And Record Evidence
Owner: main agent
Input:
- Completed MVP implementation
Files allowed:
- `apps/windows-client/*`
- `README.md`
- `docs/roadmap.md`
- `docs/goals/windows-modular-client/EVIDENCE.md`
Files forbidden:
- Unrelated app code.
Output:
- Build/test commands documented.
- README explains separate Control App, Proxyfier, and Local sing-box install flows.
- Evidence file captures automated and target-perspective proof.
Evidence:
- `npm run build`
- Rust tests
- Tauri build/dev proof
- generated ProxiFyre config summary
- UI screenshot/state
- Windows manual checklist, or clearly mark `implemented but unproven` for Windows-only service behavior if not run on a Windows host.
Depends on: all previous tasks
Parallel safe: no
## Manual Windows Verification Checklist
1. Install/run only Control App.
2. Verify Proxyfier and Local sing-box show missing as separate components.
3. Add external SOCKS5 target.
4. Add Discord process profile.
5. Apply profile; verify generated ProxiFyre config and activity entry.
6. Install Proxyfier separately; verify status changes.
7. Apply profile to real Proxyfier service.
8. Install Local sing-box separately.
9. Import subscription or config, select outbound, and start Local sing-box.
10. Switch existing profile from external target to Local sing-box and apply.
11. Stop/restart Proxyfier and Local sing-box separately.
12. Copy diagnostics and verify secrets are redacted.

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.

View File

@@ -1,122 +0,0 @@
# Roadmap: VPN Proxy rebuild
## Целевая модель
Проект должен стать multi-mode системой вокруг `sing-box`:
| Режим | Назначение | Runtime | Статус |
| --- | --- | --- | --- |
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | активное направление: `docs/goals/windows-modular-client/PLAN.md` |
## Gateway mode
Цель: контейнер, который становится прозрачным gateway для сети.
Требования:
- `sing-box` внутри контейнера.
- `network_mode: host`.
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
- TProxy inbound на `7895`.
- Mixed HTTP/SOCKS inbound на `8080`.
- Web UI на `3456`.
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
- Пользовательские routing lists управляются из UI.
- Генерируется `/etc/sing-box/config.json`.
- `sing-box check` перед применением.
- Restart `sing-box` после применения.
- Idempotent iptables setup.
- Cleanup iptables/ip rule/ip route при остановке контейнера.
Маршрутизация v1:
- private IP ranges -> `direct`.
- пользовательские списки -> `direct`, `vpn` или `block`.
- `geoip-ru` -> `direct`.
- `geosite-category-ru` -> `direct`.
- все остальное -> выбранный VPN outbound.
Порядок правил:
1. safety private-direct, чтобы не ломать LAN.
2. custom routing lists из UI.
3. RU direct rules.
4. default VPN outbound.
Формат пользовательского списка:
- `name`.
- `enabled`.
- `outbound`: `direct`, `vpn`, `block`.
- `domains`: exact domains.
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
- `domainKeywords`: keyword matching.
- `ipCidrs`: CIDR ranges.
- `ports`: TCP/UDP ports.
- `networks`: `tcp`, `udp`.
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
Отдельно решить позже:
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
- IPv6 TProxy.
- nftables backend.
- health checks и smoke diagnostics.
- secret storage через Infisical/Vault/env.
## Desktop proxy mode
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
Требования:
- UI на `3456`.
- Mixed inbound на `8080`.
- Subscription parser.
- Выбор сервера.
- Fallback proxy через `urltest`.
- Direct mode toggle.
- Не требует `NET_ADMIN`.
## Windows gaming mode
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
Current checkpoint:
- MVP slice exists under `apps/windows-client`.
- Frontend build passes with `npm run build`.
- Rust/Tauri native verification requires installing Rust/rustup and Visual Studio Build Tools with MSVC/Windows SDK.
- Local sing-box is optional; external SOCKS5 targets remain the first verified path.
Требования:
- Standalone Tauri 2 + React/TypeScript + Rust app under `apps/windows-client`.
- Profiles for process/folder/exe app routing.
- External SOCKS5/HTTP targets first; local `sing-box` is optional.
- Proxyfier adapter boundary with ProxiFyre as the first engine.
- Explicit installers for Control App, Proxyfier Layer, and Local sing-box.
- Privileged helper/install operations return structured JSON.
Source docs:
- Product/tech brief: `docs/windows-client-product-tech-brief.md`.
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`.
Superseded:
- The old Node `APP_MODE=windows` plan in `docs/superpowers/plans/2026-05-21-windows-client.md` is historical context, not the active implementation path.
## Рабочий порядок
1. Сделать новый gateway root.
2. Реализовать Docker image + entrypoint TProxy lifecycle.
3. Реализовать маленький control-server.
4. Реализовать Vite + React UI для subscription -> server select -> apply.
5. Добавить gateway docs/install script.
6. Потом переносить desktop-proxy.
7. Потом реализовать standalone Windows Tauri client по `docs/goals/windows-modular-client/PLAN.md`.

View File

@@ -1,74 +0,0 @@
# macOS Docker Client Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a macOS Docker client mode that exposes a local HTTP/SOCKS proxy on `127.0.0.1:8080` with a friendlier UI and a curl installer.
**Architecture:** Reuse the current Node API, React UI, subscription parser, sing-box process manager, and routing rule generator. Add `APP_MODE=client` so the same backend emits a proxy-only sing-box config without TProxy, and use a dedicated Dockerfile/compose path for Mac installation.
**Tech Stack:** Node.js ESM, React/Vite, sing-box, Docker Compose, POSIX shell, `node:test`.
---
### Task 1: Client Mode Config Contract
**Files:**
- Create: `test/server/singbox-client-mode.test.js`
- Modify: `package.json`
- Modify: `src/server/config.js`
- Modify: `src/server/singbox.js`
- Modify: `src/server/index.js`
- [ ] Add `node:test` coverage that proves `APP_MODE=client` config has `mixed-in`, no `tproxy-in`, no transparent fallback, and a VPN proxy fallback.
- [ ] Add `npm test` script.
- [ ] Add `settings.appMode`.
- [ ] Make `buildGatewayConfig()` conditionally emit client-only inbounds and route rules.
- [ ] Expose `mode` and hide irrelevant tproxy fields in public state.
### Task 2: macOS Client Docker Runtime
**Files:**
- Create: `entrypoint.client.sh`
- Create: `Dockerfile.client`
- Create: `docker-compose.client.yml`
- [ ] Add an entrypoint that starts only the Node control server.
- [ ] Add a Dockerfile that builds the Vite frontend inside Docker and installs only client runtime dependencies plus sing-box.
- [ ] Add compose with loopback-only port publishing for UI and proxy.
### Task 3: User-Friendly Client UI
**Files:**
- Create: `src/web/components/ClientOverviewPage.jsx`
- Modify: `src/web/App.jsx`
- Modify: `src/web/components/Sidebar.jsx`
- Modify: `src/web/components/Topbar.jsx`
- Modify: `src/web/components/StatusPane.jsx`
- Modify: `src/web/components/RouteChecker.jsx`
- Modify: `src/web/styles.css`
- [ ] Add a client overview with status, active server, copyable proxy URLs, and macOS setup commands.
- [ ] Hide gateway-only navigation and side status pane in client mode.
- [ ] Rename topbar brand to match current mode.
- [ ] Keep servers, logs, and settings reachable in client mode.
### Task 4: curl Installer and Docs
**Files:**
- Create: `scripts/install-macos-client.sh`
- Modify: `README.md`
- Modify: `.env.example`
- [ ] Add curl-friendly installer with Docker/Git checks and update-or-clone behavior.
- [ ] Document one-line install command and manual compose command.
- [ ] Add client mode environment examples.
### Task 5: Verification
**Commands:**
- `npm test`
- `npm run build`
- `docker compose -f docker-compose.client.yml config`
- [ ] Run all commands and fix any failures.
- [ ] Inspect the diff to confirm existing CI/runtime-base edits remain untouched.

File diff suppressed because it is too large Load Diff

View File

@@ -1,470 +0,0 @@
# VPN Proxy Client Route Console Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance.
**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched.
**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact.
**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html`
---
## Current Findings
- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`.
- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool.
- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now?
- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story.
- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route.
## Target Design
Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard.
The first viewport should show:
- top status: service running, restart, apply route;
- left mode rail: Gateway, Local VPN, Direct;
- main route strip: `Mac apps > local proxy > selected route > Internet`;
- right utility panel: copy proxy addresses, proxy port, recent activity;
- settings below route: only the form for the selected mode.
## File Structure
- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers.
- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query.
- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations.
- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions.
---
### Task 1: Lock Route Contract Before UI Changes
**Files:**
- Modify: `test/web/client-route.test.js`
- [ ] **Step 1: Add tests for all user-visible route statuses**
Add these cases to `test/web/client-route.test.js`:
```js
test('resolves running local VPN route', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
selectedTag: 'finland-02',
clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false },
},
activeServer: { tag: 'finland-02' },
});
assert.equal(route.mode, 'vpn');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8082');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']);
});
test('resolves gateway route when shared proxy is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
proxyPort: 8082,
clientSettings: {
sharedProxyEnabled: true,
sharedProxy: { host: '192.168.50.111', port: 8080 },
},
},
});
assert.equal(route.mode, 'gateway');
assert.equal(route.status, 'connected');
assert.equal(route.target, '192.168.50.111:8080');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']);
});
test('resolves direct route when home bypass is enabled', () => {
const route = resolveClientRoute({
state: {
singboxRunning: true,
configExists: true,
clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 },
},
});
assert.equal(route.mode, 'direct');
assert.equal(route.status, 'connected');
assert.equal(route.localProxy, '127.0.0.1:8084');
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']);
});
```
- [ ] **Step 2: Run the route tests**
Run:
```bash
npm test -- test/web/client-route.test.js
```
Expected: all existing and new route tests pass.
- [ ] **Step 3: Commit**
```bash
git add test/web/client-route.test.js
git commit -m "test: lock client route display contract"
```
---
### Task 2: Restructure Client Overview Markup
**Files:**
- Modify: `src/web/components/ClientOverviewPage.jsx`
- [ ] **Step 1: Replace the route line with route nodes**
Replace `RouteLine` with:
```jsx
function RouteStrip({ route }) {
const nodes = [
{ label: 'Источник', value: route.path[0], detail: 'приложения Mac' },
{ label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' },
{ label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' },
{ label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' },
];
return (
<div className="client-route-strip">
{nodes.map((node) => (
<div className={`client-route-node ${node.active ? 'active' : ''}`} key={node.label}>
<small>{node.label}</small>
<strong>{node.value}</strong>
<span>{node.detail}</span>
</div>
))}
</div>
);
}
function RoutePath({ route }) {
return (
<div className="client-route-path">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<strong>{item}</strong>
{index < route.path.length - 1 && <span>{'>'}</span>}
</React.Fragment>
))}
</div>
);
}
```
- [ ] **Step 2: Add a mode rail component**
Add:
```jsx
function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) {
const modes = [
{
id: 'gateway',
title: 'Общий gateway',
subtitle: clientSettings?.sharedProxy
? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`
: 'серверная proxy',
onClick: onGateway,
},
{
id: 'vpn',
title: 'Локальный VPN',
subtitle: state?.selectedTag || 'выбрать сервер',
onClick: onVpn,
},
{
id: 'direct',
title: 'Напрямую',
subtitle: 'без VPN',
onClick: onDirect,
},
];
return (
<aside className="client-mode-rail">
<div className="client-section-label">Режим подключения</div>
<div className="client-mode-list">
{modes.map((mode) => (
<button
key={mode.id}
type="button"
className={`client-rail-mode ${setupMode === mode.id ? 'selected' : ''} ${route.mode === mode.id ? 'active' : ''}`}
disabled={busy}
onClick={mode.onClick}
>
<span className="client-mode-dot" />
<span>
<strong>{mode.title}</strong>
<small>{mode.subtitle}</small>
</span>
</button>
))}
</div>
</aside>
);
}
```
- [ ] **Step 3: Replace the top-level JSX**
Use this layout in `ClientOverviewPage`:
```jsx
return (
<div className="client-console">
<ModeRail
route={route}
setupMode={setupMode}
clientSettings={clientSettings}
state={state}
busy={busy}
onGateway={selectGateway}
onVpn={selectVpn}
onDirect={() => {
setSetupMode('direct');
enableDirect();
}}
/>
<section className="client-route-workspace">
<StatusPanel route={route} state={state} />
<RouteStrip route={route} />
<RoutePath route={route} />
<section className="client-mode-panel">
{setupMode === 'gateway' && (
<GatewaySettings settings={clientSettings} busy={busy} onCheck={onCheckSharedProxy} />
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
)}
{setupMode === 'direct' && <DirectSettings busy={busy} onEnable={enableDirect} />}
</section>
</section>
<ProxySettings state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
);
```
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx
git commit -m "refactor: reshape client overview around route console"
```
---
### Task 3: Replace Client Visual System
**Files:**
- Modify: `src/web/styles.css`
- [ ] **Step 1: Replace only the client CSS block**
Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette.
Use these token values for the client block:
```css
.app-body.client-mode {
grid-template-columns: 1fr;
background: oklch(0.965 0.008 232);
}
.client-mode .topbar {
background: oklch(0.978 0.007 232);
border-bottom-color: oklch(0.835 0.018 232);
}
.client-mode .app-main {
max-width: 1320px;
width: 100%;
margin: 0 auto;
padding: 18px;
color: oklch(0.238 0.028 238);
}
.client-console {
min-height: calc(100vh - var(--topbar-h) - 36px);
display: grid;
grid-template-columns: 264px minmax(0, 1fr) 312px;
overflow: hidden;
background: oklch(0.986 0.006 232);
border: 1px solid oklch(0.835 0.018 232);
border-radius: 8px;
box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
}
```
- [ ] **Step 2: Add responsive behavior**
Add:
```css
@media (max-width: 1080px) {
.client-console {
grid-template-columns: 220px minmax(0, 1fr);
}
.client-side-panel {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid oklch(0.835 0.018 232);
}
.client-route-strip {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.client-console,
.client-route-strip,
.client-inline-form,
.client-port-row {
grid-template-columns: 1fr;
}
.client-mode-rail {
border-right: 0;
border-bottom: 1px solid oklch(0.835 0.018 232);
}
}
```
- [ ] **Step 3: Verify no banned patterns were introduced**
Run:
```bash
rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css
```
Expected: no matches.
- [ ] **Step 4: Run build**
Run:
```bash
npm run build
```
Expected: Vite build succeeds.
- [ ] **Step 5: Commit**
```bash
git add src/web/styles.css
git commit -m "style: apply light route console client theme"
```
---
### Task 4: Browser Verification
**Files:**
- No file changes expected.
- [ ] **Step 1: Start the dev server**
Run:
```bash
npm run dev -- --host 127.0.0.1 --port 4567
```
Expected: Vite listens on `http://127.0.0.1:4567`.
- [ ] **Step 2: Open client mode with representative state**
Use the browser to open:
```text
http://127.0.0.1:4567
```
Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width.
- [ ] **Step 3: Check mobile width**
Resize to 390px wide.
Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable.
- [ ] **Step 4: Run final verification**
Run:
```bash
npm test
npm run build
git diff --check
```
Expected: all commands pass.
- [ ] **Step 5: Commit**
```bash
git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js
git commit -m "feat: redesign client overview as route console"
```
---
## Self-Review
Spec coverage:
- Current UX assessment is captured in `Current Findings`.
- New design direction is captured in `Target Design`.
- Open Design artifact is referenced explicitly.
- Implementation tasks cover route contract, markup, scoped CSS, and browser verification.
Placeholder scan:
- No `TBD`, `TODO`, or unspecified validation steps remain.
Type consistency:
- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`.

View File

@@ -1,48 +0,0 @@
# macOS Docker Client Design
## Goal
Add a simple macOS-friendly Docker client that behaves like the previous local proxy product: the user runs one container, opens a web UI, loads a subscription, chooses a server, and points macOS apps at `127.0.0.1:8080`.
## Product Shape
The client is not a transparent gateway. It must not require router changes, host networking, `NET_ADMIN`, iptables, ipset, or TProxy. The first-screen UI should explain the current proxy state, active server, and exact local proxy addresses. Gateway-only controls remain available only when the app runs in gateway mode.
## Runtime Architecture
`APP_MODE=client` switches the config generator to proxy-only sing-box config:
- one `mixed` inbound on `PROXY_PORT`;
- no `tproxy` inbound;
- custom routing rules still apply before fallback;
- `proxyDefaultMode` controls the mixed proxy fallback and defaults to VPN;
- generated configs still pass `sing-box check` before restart.
The client Docker image builds the React frontend inside Docker so macOS installation does not require local Node.js. Docker publishes only loopback ports:
- `127.0.0.1:3456` for the UI;
- `127.0.0.1:8080` for HTTP/SOCKS proxy.
## Installer
The macOS installer is a curl-friendly shell script. It checks macOS, Docker, Docker Compose, and Git, clones or updates the repository under `~/.vpn-proxy-client`, then runs the client compose file with `--build`. It prints the UI URL, proxy URLs, and optional `networksetup` commands, but does not change system proxy settings automatically.
## UI
Client mode gets a user-facing overview based on the old workflow:
- status: ready, stopped, not configured, applying, error;
- active server and traffic quota;
- copyable HTTP and SOCKS5 proxy URLs;
- short macOS setup commands;
- primary actions: load subscription, choose server, restart, stop.
Gateway terminology such as TProxy, devices, router, transparent fallback, and direct bypass cache is hidden in client mode.
## Verification
Use `node:test` for server config behavior, then run:
- `npm test`;
- `npm run build`;
- `docker compose -f docker-compose.client.yml config`.

View File

@@ -1,236 +0,0 @@
# Windows Client Design
> Superseded: this document describes the earlier Node/web-control Windows direction.
> The active Windows direction is a standalone Tauri 2 desktop app under
> `apps/windows-client`, driven by `docs/windows-client-product-tech-brief.md`
> and `docs/goals/windows-modular-client/PLAN.md`.
> Content below is retained for historical context and may contradict the active
> Tauri plan.
## Goal
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
## Product Shape
The Windows mode is script-first and UI-assisted. The installer remains the durable entrypoint because Windows driver/service setup needs administrator rights and must stay easy to debug from PowerShell. The web UI is a local control surface on top of the same scripts, not a separate desktop app in the first version.
The installer supports two paths:
- **Full install:** install native `sing-box.exe`, configure a local SOCKS/HTTP proxy on `127.0.0.1:1080`, install WinPacketFilter and ProxiFyre, then route selected Windows apps through the local proxy.
- **ProxiFyre only:** install WinPacketFilter and ProxiFyre, then point selected Windows apps to an existing proxy target such as `127.0.0.1:8080`, `192.168.50.111:8080`, or another reachable SOCKS5 endpoint.
The default UI direction is the approved cleaner mockup: one route status, profiles as the main object, selected profile details on the right, and a short recent activity/log section below. The first screen should answer: what is the active proxy target, whether services are running, and which profiles are currently enabled.
## User Flows
### Install
The user opens PowerShell 7 as Administrator and runs:
```powershell
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex
```
The installer checks administrator rights, PowerShell version, architecture, internet access, and required paths. It installs under `C:\Tools\vpn-proxy-windows` and keeps third-party runtime files in focused subdirectories:
- `C:\Tools\vpn-proxy-windows\app` for this project checkout or archive.
- `C:\Tools\vpn-proxy-windows\runtime\node` for portable Node.js when no suitable Node is installed.
- `C:\Tools\vpn-proxy-windows\runtime\sing-box` for `sing-box.exe`, config, and logs.
- `C:\Tools\ProxiFyre` for ProxiFyre, matching the legacy script path.
If the user chooses Full install, the installer asks for a subscription or VLESS link, parses it through the existing subscription logic where possible, lets the user select a server, writes the native `sing-box` config, installs a scheduled task for `sing-box`, and starts it.
If the user chooses ProxiFyre only, the installer asks for a SOCKS5 proxy target and verifies TCP connectivity before writing ProxiFyre config.
After setup, the installer starts the local control UI on `http://127.0.0.1:3456` and prints recovery commands:
```powershell
& "C:\Tools\vpn-proxy-windows\manage.ps1"
& "C:\Tools\vpn-proxy-windows\manage.ps1" -OpenUi
& "C:\Tools\vpn-proxy-windows\manage.ps1" -Status
```
### Profile Management
Profiles are the central unit. A profile contains a name, enabled flag, proxy target, protocol list, and app items. Supported item types:
- `process`: process name such as `Discord`, `Update`, or `Vesktop`.
- `folder`: folder path; the system scans `.exe` files and converts them to routable entries.
- `exe`: explicit executable file path; the system resolves it to the executable name for ProxiFyre and keeps the full path for display and diagnostics.
Folder and exe entries are intentionally stored as user-facing source items, while the generated ProxiFyre config is derived. This keeps the UI understandable and makes future ProxiFyre/Proxifier adapter changes possible without changing the profile model.
When a profile changes, the UI marks it as pending. The user applies changes once. Apply regenerates ProxiFyre `app-config.json`, restarts the ProxiFyre service, then writes an activity entry showing what changed.
### Runtime Operations
The UI exposes these actions:
- start, stop, restart `sing-box` when local mode is installed;
- start, stop, restart ProxiFyre;
- switch a profile between `local-singbox` and an external proxy target;
- add process, folder, or exe entries;
- scan folder entries again;
- copy diagnostics for support/debugging;
- open logs.
The UI does not auto-change global Windows proxy settings. Routing happens only through ProxiFyre profiles.
## Architecture
The active project already has a plain Node API server, React/Vite UI, subscription parser, `sing-box` config generator, logs, traffic parsing, and client/gateway modes. Windows mode should reuse those pieces and add a Windows helper boundary.
### App Mode
Add `APP_MODE=windows`. In Windows mode:
- the HTTP server binds to `127.0.0.1`;
- the UI uses Windows labels and hides gateway-only TProxy/device controls;
- config generation is proxy-only like client mode, but it targets native `sing-box.exe` rather than Docker;
- service and driver actions go through the PowerShell helper, not direct Node assumptions.
### Windows Helper Boundary
Create a PowerShell helper module that owns privileged Windows operations:
- install/update `sing-box.exe`;
- install/start/stop scheduled tasks;
- install/update WinPacketFilter;
- install/update ProxiFyre;
- write ProxiFyre config;
- query service/task status;
- read recent log files;
- test proxy connectivity;
- manage firewall rules for local proxy ports.
The Node server calls the helper with explicit command names and JSON input/output. The helper returns structured JSON for every operation:
```json
{
"success": true,
"action": "proxies.apply",
"changed": true,
"message": "ProxiFyre config applied and service restarted"
}
```
Errors use the same shape with `success: false`, `error`, and optional `details`. The UI never parses raw PowerShell text.
### Data Files
Windows mode stores state under `C:\Tools\vpn-proxy-windows\data`:
- `windows-profiles.json` for profile source data.
- `proxy-targets.json` for `local-singbox` and external proxy targets.
- `windows-state.json` for last applied profile revision and UI status.
- `subscription-cache.json` and `state.json` stay compatible with existing subscription/server selection logic.
Profile shape:
```json
{
"id": "discord-vesktop",
"name": "Discord + Vesktop",
"enabled": true,
"proxyTargetId": "local-singbox",
"protocols": ["TCP", "UDP"],
"items": [
{ "type": "process", "value": "Discord" },
{ "type": "process", "value": "Update" },
{
"type": "folder",
"value": "%LOCALAPPDATA%\\vesktop",
"recursive": true
},
{
"type": "exe",
"value": "C:\\Games\\SomeGame\\game.exe"
}
]
}
```
Generated ProxiFyre config is not edited directly. It is derived from enabled profiles and proxy targets, then written to `C:\Tools\ProxiFyre\app-config.json`.
### API Surface
Add Windows-specific endpoints:
- `GET /api/windows/status`: returns install mode, `sing-box` status, ProxiFyre status, active target, pending changes, and recent activity.
- `GET /api/windows/profiles`: returns profile source data with resolved executable counts.
- `PUT /api/windows/profiles`: saves profiles without applying.
- `POST /api/windows/profiles/apply`: generates ProxiFyre config and restarts service.
- `POST /api/windows/profiles/scan`: resolves folder and exe entries for preview.
- `GET /api/windows/targets`: returns `local-singbox` and external proxy targets.
- `PUT /api/windows/targets`: saves external proxy targets after validation.
- `POST /api/windows/service`: start, stop, or restart `sing-box`, ProxiFyre, or the UI service.
- `GET /api/windows/logs`: returns recent helper, `sing-box`, and ProxiFyre logs.
Existing generic endpoints for subscription fetch, server selection, apply, logs, and config validation should be reused where the behavior matches Windows mode.
## UI Design
The approved direction is a restrained operational UI:
- top bar: product name, restart/stop/add profile actions;
- left nav: Overview, Profiles, Targets, Logs, Settings;
- main status panel: one sentence describing the active route, plus a compact route line such as `Selected apps -> ProxiFyre -> sing-box -> VPN`;
- main workspace: profile list on the left, selected profile details on the right;
- profile details: target selector, add process/folder/exe input, resolved items list, save/apply controls;
- activity panel: recent traffic/service events, not a full noisy log dump.
Avoid duplicate status blocks. Avoid showing raw implementation concepts like WinPacketFilter unless the user is in diagnostics/settings. The primary terms should be `Profile`, `Proxy target`, `Local sing-box`, `Existing proxy`, `App/folder/exe`, and `Apply changes`.
## Safety And Constraints
The UI binds only to `127.0.0.1`. Windows actions that require elevation stay in the installer/helper path. The installer must not delete existing `C:\Tools\vpn-proxy` legacy folders without confirmation.
Folder and exe routing needs a clear diagnostic note: ProxiFyre routing ultimately depends on what the installed ProxiFyre version accepts. The first implementation should resolve folders and exe paths to executable names for compatibility, while preserving full paths in profile data and diagnostics. If direct path matching is verified in ProxiFyre, the adapter can emit full paths without changing the UI model.
The installer should be idempotent:
- re-running it updates project files;
- existing subscriptions and profiles are preserved unless the user chooses reset;
- existing ProxiFyre config is backed up before overwrite;
- failed applies leave the previous generated config available for rollback.
## Testing And Verification
Use focused tests for pure logic:
- profile normalization;
- folder/exe item resolution;
- ProxiFyre config generation;
- proxy target validation;
- Windows helper JSON command contract;
- `APP_MODE=windows` public state and config generation.
Use manual Windows verification for privileged operations:
- fresh Full install;
- fresh ProxiFyre-only install;
- re-run installer over existing install;
- add process profile;
- add folder profile;
- add explicit exe profile;
- switch a profile from local sing-box to external proxy;
- restart ProxiFyre and verify service status;
- copy diagnostics after a failed proxy target check.
Local non-Windows development should still run `npm test` and `npm run build`. Windows-only helper commands should have dry-run or mockable modes so logic can be tested without installing drivers on the development machine.
## Non-Goals For First Version
- No Electron or Tauri wrapper.
- No global Windows system proxy changes.
- No transparent routing without ProxiFyre.
- No remote multi-device management.
- No automatic uninstall of unrelated WinPacketFilter users.
- No Proxifier support until ProxiFyre behavior is stable.
## Implementation Defaults
- The UI server runs when opened by `manage.ps1 -OpenUi` in the first version. An at-logon scheduled UI task can be added later after the helper and UI are stable.
- Full install uses portable Node/npm when the machine has no suitable Node.js. The installer builds the React UI locally for MVP; a prebuilt release artifact can replace that later without changing user-facing behavior.
- ProxiFyre generation emits process names in the first version for compatibility. Full folder and exe paths remain in profile data and diagnostics; the adapter can start emitting full paths later if ProxiFyre path matching is verified.

View File

@@ -1,635 +0,0 @@
# Windows Proxy Client: Product And Technology Brief
Дата: 2026-07-03
Цель документа: описать, как должно выглядеть и работать Windows-приложение для управления proxy/VPN-маршрутизацией приложений, и какой стек лучше использовать для реализации.
Этот документ можно отдать другой модели или команде как исходное ТЗ.
## Коротко
Нужно Windows-приложение, которое разделяет систему на три независимые части:
1. **Control App**: маленькое desktop-приложение для настройки, статуса, профилей, логов и запуска операций.
2. **Proxyfier Layer**: отдельный компонент, который заставляет выбранные Windows-приложения ходить через SOCKS5/HTTP proxy, даже если они сами не умеют proxy.
3. **Local sing-box**: опциональный локальный VPN/proxy runtime. Его можно установить, не устанавливать, остановить, заменить внешним proxy target.
Главный принцип: пользователь не обязан ставить все сразу. Если у него уже есть proxy, ему нужны только Control App + Proxyfier. Если нужен локальный VPN-клиент, он отдельно ставит `sing-box`.
## Как это должно выглядеть
Приложение должно выглядеть как компактная системная утилита, а не как сайт.
Главный экран:
- верхняя строка: общий статус маршрута;
- три карточки компонентов: `Control App`, `Proxyfier`, `Local sing-box`;
- список активных профилей;
- кнопка `Apply changes`;
- короткая лента последних событий.
Пример главного статуса:
```text
Selected apps -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> VPN
```
или:
```text
Selected apps -> ProxiFyre -> Existing proxy 192.168.50.111:8080
```
Если `sing-box` не установлен, это не ошибка. Карточка должна показывать:
```text
Local sing-box
Not installed
Install if you want this PC to run its own local VPN proxy.
```
Если Proxyfier не установлен, профили можно редактировать, но apply должен быть заблокирован:
```text
Proxyfier is required to route selected apps.
Install Proxyfier
```
## Основные экраны
### 1. Overview
Показывает:
- текущий route line;
- статус Control App;
- статус Proxyfier;
- статус Local sing-box;
- активный proxy target;
- сколько приложений сейчас включено в routing;
- последние 5-10 событий.
Действия:
- restart Proxyfier;
- restart local sing-box, если установлен;
- open logs;
- copy diagnostics.
### 2. Profiles
Профиль - главный объект настройки.
Профиль содержит:
- название;
- enabled/disabled;
- proxy target;
- протоколы: TCP, UDP;
- список приложений.
Типы элементов:
- `process`: имя процесса, например `Discord`, `Telegram`, `Code`;
- `folder`: папка, приложение сканирует `.exe` внутри;
- `exe`: конкретный путь к `.exe`.
UI профиля:
- слева список профилей;
- справа детали выбранного профиля;
- поле выбора target;
- кнопки добавления: `Process`, `Folder`, `EXE`;
- preview resolved apps;
- `Save`;
- `Apply changes`.
Важно: пользователь должен видеть понятные исходные элементы, а не только сгенерированный конфиг Proxyfier.
### 3. Targets
Proxy target - это куда Proxyfier отправляет трафик выбранных приложений.
Типы targets:
- `Local sing-box`: `127.0.0.1:1080`, доступен только если local sing-box установлен и запущен;
- `Existing SOCKS5 proxy`: например `127.0.0.1:8080` или `192.168.50.111:8080`;
- `Existing HTTP proxy`, если выбранный proxyfier поддерживает HTTP.
На экране targets:
- список targets;
- проверка соединения;
- имя, host, port, protocol;
- статус last checked;
- кнопка set default.
### 4. Components
Отдельный экран или часть Overview.
Компоненты:
- Control App;
- Proxyfier;
- Local sing-box.
Для каждого:
- installed / not installed;
- running / stopped;
- version;
- path;
- service/task status;
- actions.
Actions должны быть явными:
- `Install`;
- `Repair`;
- `Start`;
- `Stop`;
- `Restart`;
- `Open folder`;
- `View logs`.
Нельзя делать скрытую установку `sing-box` при сохранении профиля.
### 5. Logs / Diagnostics
Должно быть две зоны:
- activity: действия пользователя и результат apply;
- runtime logs: proxyfier logs, sing-box logs, helper logs.
Кнопка `Copy diagnostics` должна собирать:
- версии компонентов;
- paths;
- running status;
- активные profiles;
- targets без секретов;
- последние ошибки;
- путь к сгенерированному proxyfier config.
## Пользовательские сценарии
### Сценарий A: у пользователя уже есть proxy
1. Пользователь устанавливает Control App.
2. Открывает приложение.
3. Видит, что Proxyfier не установлен, а sing-box отсутствует.
4. Нажимает `Install Proxyfier`.
5. Добавляет target `192.168.50.111:8080`.
6. Создает профиль `Discord`.
7. Добавляет process `Discord`.
8. Нажимает `Apply changes`.
9. Приложение генерирует config для Proxyfier и перезапускает proxyfier service.
Результат: Discord ходит через внешний proxy. Local sing-box не нужен.
### Сценарий B: пользователь хочет локальный VPN proxy
1. Пользователь устанавливает Control App.
2. Устанавливает Proxyfier.
3. Устанавливает Local sing-box.
4. Вводит subscription/VLESS link.
5. Выбирает сервер.
6. Local sing-box поднимает SOCKS5/HTTP endpoint на `127.0.0.1:1080`.
7. Профили используют target `Local sing-box`.
Результат: выбранные приложения ходят через локальный sing-box.
### Сценарий C: временно отключить VPN
1. Пользователь открывает профиль.
2. Меняет target с `Local sing-box` на внешний proxy или `Direct/Disabled`.
3. Нажимает `Apply changes`.
Результат: Proxyfier перегенерирован, local sing-box можно остановить отдельно.
## Рекомендуемый стек
### Desktop shell: Tauri 2
Рекомендация: **Tauri 2 + React + TypeScript + Rust backend**.
Почему:
- Tauri ориентирован на маленькие desktop-приложения и использует системный web renderer, поэтому приложение легче Electron.
- Можно писать UI на обычном web stack: React/TypeScript/Vite.
- Backend-часть на Rust хорошо подходит для Windows APIs, файлов, процессов, sidecar binaries и безопасных команд.
- Tauri поддерживает sidecar binaries, но требует явно выдать permissions на запуск sidecar, что полезно для security boundary.
Frontend:
- React;
- TypeScript;
- Vite;
- TanStack Query для загрузки/кэша status/API;
- Zustand или Jotai для локального UI state;
- Zod для валидации JSON-моделей;
- CSS modules или Tailwind. Для этой утилиты лучше сдержанный Windows-like UI, без тяжелой дизайн-системы.
Backend внутри Tauri:
- Rust commands для простых операций;
- отдельный `core` crate с доменной логикой;
- отдельный `windows-helper` binary для elevated/privileged действий.
Не рекомендую начинать с Electron, если нет жесткой причины. Electron проще для web-команды, но тяжелее по размеру и памяти. Для маленькой системной утилиты Tauri подходит лучше.
### Privileged helper
Нужно отделить обычное приложение от операций администратора.
Рекомендуемая модель:
```text
Tauri UI
-> Rust app backend
-> unprivileged status/read operations
-> explicit elevated helper for install/repair/service operations
```
Privileged helper может быть:
- Rust CLI, который запускается elevated только для конкретной операции;
- Rust Windows service/helper, если нужен постоянный privileged agent;
- PowerShell scripts только как thin installer layer, не как основная бизнес-логика.
Для MVP можно сделать проще:
- installers запускаются отдельно от имени администратора;
- Control App работает обычным пользователем;
- service start/stop/restart идет через helper command;
- helper возвращает JSON, UI не парсит текст PowerShell.
Контракт helper:
```json
{
"action": "proxyfier.apply",
"payload": {
"configPath": "C:\\Tools\\ProxiFyre\\app-config.json",
"config": {}
}
}
```
Ответ:
```json
{
"success": true,
"action": "proxyfier.apply",
"changed": true,
"message": "Proxyfier config applied and service restarted"
}
```
Ошибки:
```json
{
"success": false,
"action": "proxyfier.apply",
"error": "Proxyfier service is not installed",
"details": {}
}
```
### Service/runtime management
Для `sing-box` как background runtime:
- использовать `sing-box check` перед применением config;
- хранить config отдельно;
- запускать как Windows service или scheduled task;
- для service wrapper можно использовать WinSW, если не хочется писать собственный Windows service wrapper.
Практичный вариант:
- v1: WinSW wraps `sing-box.exe`;
- v2: собственный Rust service/helper, если понадобится полный контроль.
Control App не должен напрямую владеть процессом `sing-box`. Он должен управлять service/task через helper.
### Local sing-box
`sing-box` - опциональный runtime.
Его роль:
- принять subscription/VLESS/sing-box config;
- поднять локальный mixed SOCKS/HTTP inbound;
- слушать только `127.0.0.1`, например `127.0.0.1:1080`;
- маршрутизировать трафик через выбранный outbound.
Config генерируется из source state приложения и проверяется:
```powershell
sing-box check -c C:\Tools\VpnProxy\sing-box\config.json
```
Local sing-box не должен быть обязательным. Если profile target указывает на внешний proxy, `sing-box` может отсутствовать.
### Proxyfier layer
Рекомендуемый стартовый backend: **ProxiFyre**.
Почему:
- open-source;
- Windows-focused;
- маршрутизирует TCP и UDP;
- работает per-application;
- использует `app-config.json`;
- может работать как Windows Service.
Важное ограничение: ProxiFyre лицензируется как AGPL-3.0. Если продукт должен быть закрытым коммерческим приложением, нужно заранее решить юридический вопрос или сделать adapter layer, чтобы можно было заменить engine на:
- коммерческий Proxifier;
- ProxyBridge;
- собственный WinDivert/NDIS/WFP-based engine;
- другой per-app proxy router.
Интерфейс должен называться не `ProxiFyreConfig`, а шире:
```text
ProxyRouterAdapter
```
Первый adapter:
```text
ProxiFyreAdapter
```
Это позволит поменять engine без переделки UI и профилей.
### Data storage
Для MVP лучше использовать простые JSON-файлы с schema validation.
Причина:
- настройки легко читать и бэкапить;
- можно быстро отлаживать;
- config portable;
- подходит для profile/target/source state.
Рекомендуемые файлы:
```text
C:\ProgramData\VpnProxy\config\profiles.json
C:\ProgramData\VpnProxy\config\targets.json
C:\ProgramData\VpnProxy\config\components.json
C:\ProgramData\VpnProxy\state\activity.json
C:\ProgramData\VpnProxy\state\last-status.json
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
C:\ProgramData\VpnProxy\generated\sing-box-config.json
```
Если нужна большая история событий, статистика трафика или сложные миграции, тогда добавить SQLite:
- `rusqlite` или `sqlx` в Rust;
- миграции;
- таблицы `activity`, `component_status`, `traffic_events`.
Но source of truth для профилей можно оставить JSON даже при наличии SQLite.
### Installer strategy
Нужны три явных installer entrypoints:
```text
Install Control App
Install Proxyfier Layer
Install Local sing-box
```
Они могут быть кнопками в UI, но каждая операция должна быть отдельной и понятной.
CLI/script names:
```text
install-control-app.ps1
install-proxyfier.ps1
install-singbox.ps1
```
Или в packaged app:
```text
VpnProxySetup.exe /component control-app
VpnProxySetup.exe /component proxyfier
VpnProxySetup.exe /component sing-box
```
Каждый installer:
- idempotent;
- делает backup перед overwrite;
- не удаляет чужие файлы без подтверждения;
- проверяет admin rights;
- пишет machine-readable install result;
- не трогает остальные компоненты без явного выбора.
### Security model
Правила:
- UI работает без admin rights.
- Admin elevation только для install/repair/service/config apply, если это реально нужно.
- Local API, если будет, слушает только `127.0.0.1`.
- Лучше использовать Tauri commands / named pipe, чем открытый HTTP port.
- Если нужен loopback HTTP, включить token или origin check.
- Секреты subscription URLs не показывать в diagnostics.
- Generated configs не редактируются вручную из UI.
- Every apply creates backup.
## Архитектура
```text
+-------------------------------+
| Tauri Control App |
| React/TypeScript UI |
+---------------+---------------+
|
v
+-------------------------------+
| Rust App Backend |
| profiles, targets, validation |
| component status aggregation |
+-------+---------------+-------+
| |
v v
+---------------+ +-------------------+
| Proxy Router | | Local sing-box |
| Adapter | | Adapter |
| ProxiFyre v1 | | config + service |
+-------+-------+ +---------+---------+
| |
v v
+---------------+ +-------------------+
| ProxiFyre | | sing-box.exe |
| Windows svc | | Windows svc/task |
+---------------+ +-------------------+
```
## Модель данных
### Profile
```json
{
"id": "discord",
"name": "Discord",
"enabled": true,
"targetId": "local-singbox",
"protocols": ["TCP", "UDP"],
"items": [
{ "type": "process", "value": "Discord" },
{ "type": "folder", "value": "%LOCALAPPDATA%\\Discord", "recursive": true },
{ "type": "exe", "value": "C:\\Games\\Game\\game.exe" }
]
}
```
### Target
```json
{
"id": "local-singbox",
"name": "Local sing-box",
"type": "local",
"protocol": "socks5",
"host": "127.0.0.1",
"port": 1080,
"requiresComponent": "singbox"
}
```
External target:
```json
{
"id": "home-gateway",
"name": "Home gateway",
"type": "external",
"protocol": "socks5",
"host": "192.168.50.111",
"port": 8080
}
```
### Component status
```json
{
"id": "proxyfier",
"name": "Proxyfier",
"installed": true,
"running": true,
"version": "2.3.0",
"path": "C:\\Tools\\ProxiFyre",
"serviceName": "ProxiFyreService",
"problems": [],
"actions": ["restart", "repair", "openLogs"]
}
```
## Apply behavior
Apply должен делать одно понятное действие:
1. Прочитать profiles.
2. Прочитать targets.
3. Проверить, что выбранные targets доступны.
4. Проверить, что Proxyfier установлен.
5. Разрешить folder/exe в process names.
6. Сгенерировать proxyfier config.
7. Сделать backup старого config.
8. Записать новый config.
9. Перезапустить Proxyfier service.
10. Записать activity entry.
Если profile использует `local-singbox`, дополнительно:
- проверить, что `sing-box` установлен;
- проверить, что service running;
- проверить, что `127.0.0.1:1080` отвечает.
Если `local-singbox` не установлен, но profile target внешний, apply должен работать.
## Что не делать
- Не делать глобальную смену Windows proxy settings.
- Не делать `sing-box` обязательным.
- Не смешивать installer и profile apply.
- Не хранить generated ProxiFyre config как source of truth.
- Не привязывать UI напрямую к ProxiFyre, нужен adapter layer.
- Не запускать privileged операции без явного согласия пользователя.
- Не делать большой dashboard с лишней статистикой в первой версии.
## MVP
Самый правильный первый slice:
1. Tauri app shell.
2. Profiles UI.
3. Targets UI.
4. Component status UI.
5. ProxiFyre adapter.
6. External SOCKS5 target.
7. Apply profile -> generate ProxiFyre config -> restart service.
В MVP `sing-box` может быть только карточкой `Not installed / Install`.
После этого добавить:
1. Local sing-box installer.
2. Subscription import.
3. Server selection.
4. Generate sing-box config.
5. Start/stop/restart local sing-box service.
## Acceptance criteria
Приложение считается успешным, если:
- можно установить только Control App;
- можно установить Proxyfier отдельно;
- можно не устанавливать sing-box;
- можно добавить внешний SOCKS5 target;
- можно создать профиль для Discord;
- можно применить профиль;
- generated ProxiFyre config не редактируется пользователем вручную;
- UI показывает, что local sing-box отсутствует, но это не ломает внешний proxy flow;
- после установки sing-box появляется target `Local sing-box`;
- пользователь может переключить профиль с внешнего target на local sing-box.
## Prompt For Another AI
Build a Windows desktop proxy management app.
Use Tauri 2 with React, TypeScript, Vite, and a Rust backend. The app must manage three independent components: the Control App, a proxyfier layer, and optional local sing-box. Do not make sing-box mandatory.
The UI must be a compact Windows utility with these screens: Overview, Profiles, Targets, Components, Logs. Profiles contain process/folder/exe entries and choose a proxy target. Targets can be local sing-box or external SOCKS5/HTTP proxies. Proxyfier is the layer that routes selected apps through the chosen target.
Start with ProxiFyre as the first proxy router adapter, but design an adapter boundary so it can later be replaced. Store source configuration as JSON with schema validation. Generated ProxiFyre and sing-box configs are derived artifacts, not source truth.
Privileged operations must be isolated in an explicit helper/installer flow. The main UI should run without admin rights. Install Control App, Install Proxyfier, and Install Local sing-box must be separate operations. Applying a profile must not silently install missing components.
MVP: external SOCKS5 target + ProxiFyre profile apply. Then add optional local sing-box installation, subscription import, server selection, and local sing-box service control.
## References
- Tauri 2: https://v2.tauri.app/
- Tauri sidecar permissions: https://v2.tauri.app/develop/sidecar/
- sing-box configuration: https://sing-box.sagernet.org/configuration/
- ProxiFyre repository: https://github.com/wiresock/proxifyre
- WinSW service wrapper: https://github.com/winsw/winsw
- Microsoft Windows Service with Worker Service: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service

View File

@@ -2,7 +2,7 @@
set -euo pipefail
PORT="${PORT:-3456}"
PROXY_PORT="${PROXY_PORT:-8080}"
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}"

115
entrypoint.sh Normal file → Executable file
View File

@@ -1,31 +1,29 @@
#!/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}"
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
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}"
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
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}"
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
# TTL записи в ipset (секунды). По умолчанию 1 час.
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
# Direct bypass cache выключен по умолчанию, потому что он обходит global rules.
DIRECT_BYPASS_CACHE="${DIRECT_BYPASS_CACHE:-false}"
log() {
printf '[gateway-entrypoint] %s\n' "$*"
}
if [[ "$APP_COMPONENT" == "control" ]]; then
exec node /app/src/server/index.js
fi
ipt() {
iptables -w "$@"
}
@@ -38,55 +36,36 @@ cleanup_proxy_firewall() {
}
cleanup_tproxy() {
log "cleanup tproxy rules"
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_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
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
ipt -t nat -X "$TPROXY_SOURCE_NAT_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
# ipset не чистим при завершении — TTL сам истечёт
}
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() {
log "enable IPv4 forwarding for source bypass"
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
return
fi
if command -v sysctl >/dev/null 2>&1; then
elif command -v sysctl >/dev/null 2>&1; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
fi
}
setup_direct_bypass_set() {
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
export DIRECT_BYPASS_CACHE
return
fi
log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
# Экспортируем имя для использования в Node.js через env
export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL DIRECT_BYPASS_CACHE
}
setup_proxy_firewall() {
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
return
fi
log "setup proxy firewall for :${PROXY_PORT} (${PROXY_ALLOWED_CIDRS})"
cleanup_proxy_firewall
ipt -N "$PROXY_INPUT_CHAIN"
for cidr in $PROXY_ALLOWED_CIDRS; do
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
@@ -96,70 +75,66 @@ setup_proxy_firewall() {
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}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
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 -N "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
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" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
for cidr in $BYPASS_CIDRS; do
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
done
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
done
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
# Включайте только если готовы к тому, что global rules для этих dst IP не будут проверяться.
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN
fi
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"
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
}
setup_direct_bypass_set
setup_gateway_forwarding
setup_tproxy
setup_proxy_firewall
node /app/src/server/index.js &
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
node /app/src/server/dataplane.js &
else
node /app/src/server/index.js &
fi
APP_PID=$!
shutdown() {
log "shutdown requested"
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"

View File

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPN Proxy Gateway</title>
<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>

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"

65
package-lock.json generated
View File

@@ -8,15 +8,11 @@
"name": "vpn-proxy-gateway",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^7.0.0"
},
"devDependencies": {}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
@@ -281,59 +277,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -1606,12 +1549,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -8,12 +8,10 @@
"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": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.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

@@ -104,9 +104,9 @@ 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}" PULL_IMAGE=false ./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}' PULL_IMAGE=false ./deploy-gateway.sh"
"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

View File

@@ -2,17 +2,29 @@
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
GATEWAY_IMAGE="${GATEWAY_IMAGE:?GATEWAY_IMAGE is required}"
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-gateway:
image: ${GATEWAY_IMAGE}
container_name: vpn-proxy-gateway
vpn-proxy-dataplane:
image: ${DATAPLANE_IMAGE}
container_name: vpn-proxy-dataplane
network_mode: host
cap_add:
- NET_ADMIN
@@ -20,11 +32,42 @@ services:
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"]
@@ -36,6 +79,7 @@ services:
volumes:
vpn-proxy-data:
sing-box-cache:
vpn-proxy-runtime:
EOF
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
@@ -47,11 +91,9 @@ TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
TPROXY_BYPASS_SOURCE_CIDRS=
ROUTING_RU_DIRECT=true
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."
@@ -61,15 +103,29 @@ fi
cd "${DEPLOY_PATH}"
echo "Pulling image: ${GATEWAY_IMAGE}"
echo "Control image: ${CONTROL_IMAGE}"
echo "Dataplane image: ${DATAPLANE_IMAGE}"
if [ "${PULL_IMAGE}" = "true" ]; then
docker compose -f docker-compose.server.yml pull
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; using local image ${GATEWAY_IMAGE}"
echo "Skipping image pull"
fi
echo "Starting gateway..."
docker compose -f docker-compose.server.yml up -d
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 container:"
docker ps --filter "name=vpn-proxy-gateway"
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;
}
}

View File

@@ -2,20 +2,23 @@
set -euo pipefail
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
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="8080"
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="vpn-proxy-client"
CLIENT_CONTAINER_NAME="harbor-connect"
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
log() {
printf '[vpn-proxy-client] %s\n' "$*"
printf '[harbor-connect] %s\n' "$*"
}
die() {
printf '[vpn-proxy-client] error: %s\n' "$*" >&2
printf '[harbor-connect] error: %s\n' "$*" >&2
exit 1
}
@@ -66,7 +69,7 @@ published_port_conflicts() {
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
"${CLIENT_CONTAINER_NAME}"$'\t'*) ;;
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
*) printf '%s\n' "$line" ;;
esac
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
@@ -85,7 +88,7 @@ assert_proxy_port_available() {
return 0
fi
printf '[vpn-proxy-client] proxy port %s is already used:\n%s\n' \
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"
}
@@ -100,7 +103,7 @@ assert_single_port_available() {
return 0
fi
printf '[vpn-proxy-client] %s port %s is already used:\n%s\n' \
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"
}
@@ -167,14 +170,62 @@ wait_for_client_ui() {
sleep 1
done
printf '\n[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
printf '[vpn-proxy-client] docker compose status:\n' >&2
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[vpn-proxy-client] recent service logs:\n' >&2
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&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"
@@ -204,26 +255,49 @@ get_env_value() {
' .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 git
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 [[ -d "$INSTALL_DIR/.git" ]]; then
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
log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
download_source
fi
cd "$INSTALL_DIR"
@@ -234,7 +308,6 @@ fi
PROXY_PORT="$(ask_proxy_port)"
assert_proxy_port_available "$PROXY_PORT"
PROXY_PORT_END="$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")"
@@ -243,20 +316,20 @@ 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 CLIENT_PROXY_PORT_START "$PROXY_PORT"
set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
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
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
wait_for_client_ui
cat <<EOF
VPN Proxy Client is running.
Harbor Connect is running.
UI:
http://127.0.0.1:${UI_PORT}

View File

@@ -1,101 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
const DEFAULT_CLIENT_SETTINGS = {
homeBypassEnabled: false,
sharedProxyEnabled: false,
sharedProxyControlUrl: "",
sharedProxy: null,
};
function normalizeProxyPort(value, fallback = settings.proxyPort) {
const parsed = Number.parseInt(value, 10);
const min = Number.isInteger(settings.clientProxyPortStart)
? settings.clientProxyPortStart
: settings.proxyPort;
const max = Number.isInteger(settings.clientProxyPortEnd)
? settings.clientProxyPortEnd
: min;
const fallbackPort =
Number.isInteger(fallback) && fallback >= min && fallback <= max
? fallback
: min;
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
return fallbackPort;
}
return parsed;
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function normalizeUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
try {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) return "";
url.hash = "";
url.search = "";
return url.toString().replace(/\/$/, "");
} catch {
return "";
}
}
function normalizeSharedProxy(value) {
if (!value || typeof value !== "object") return null;
const host = String(value.host || "").trim();
const port = Number.parseInt(value.port, 10);
const protocol = value.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return {
host,
port,
protocol,
checkedAt: value.checkedAt || null,
};
}
export function normalizeClientSettings(input = {}) {
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
return {
homeBypassEnabled: Boolean(input.homeBypassEnabled),
proxyPort: normalizeProxyPort(input.proxyPort),
sharedProxyEnabled,
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
sharedProxy,
};
}
export function readClientSettings() {
return normalizeClientSettings({
...DEFAULT_CLIENT_SETTINGS,
proxyPort: settings.proxyPort,
...readJson(settings.clientSettingsPath, {}),
});
}
export function writeClientSettings(input) {
const normalized = normalizeClientSettings({
...readClientSettings(),
...(input && typeof input === "object" ? input : {}),
});
writeJson(settings.clientSettingsPath, normalized);
return normalized;
}

View File

@@ -5,30 +5,18 @@ const parsePort = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : fallback;
};
const proxyPort = parsePort(process.env.PROXY_PORT, 8080);
const clientProxyPortStart = parsePort(
process.env.CLIENT_PROXY_PORT_START,
proxyPort,
);
const clientProxyPortEnd = parsePort(
process.env.CLIENT_PROXY_PORT_END,
clientProxyPortStart,
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,
clientProxyPortStart,
clientProxyPortEnd,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
tproxySourceBypassChain:
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
tproxySourceForwardChain:
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
tproxySourceNatChain:
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
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",
@@ -36,16 +24,13 @@ export const settings = {
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"),
customRulesPath: path.join(dataDir, "custom-rules.json"),
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
clientSettingsPath: path.join(dataDir, "client-settings.json"),
devicesPath: path.join(dataDir, "devices.json"),
deviceRulesPath: path.join(dataDir, "device-rules.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"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
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);

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