98 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
123 changed files with 11180 additions and 11882 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=8082
CLIENT_PROXY_PORT_END=8082
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/

View File

@@ -1,142 +0,0 @@
# Windows Client Interface System
Дата: 2026-07-08
## Направление
Это максимально простая Windows-утилита управления службами и их состояниями. Она не должна ощущаться как VPN-dashboard, SaaS-панель или маркетинговый клиент. Главные глаголы интерфейса:
- проверить состояние;
- установить компонент;
- удалить компонент;
- запустить службу;
- остановить службу;
- обновить/перепроверить состояние;
- добавить приложение/путь;
- применить конфигурацию только когда prerequisites готовы.
Интерфейс должен быть плотным, спокойным, системным и предсказуемым. Пользователь открывает его не для анализа статистики, а чтобы быстро понять "что сейчас установлено, что запущено, что сломано и какую одну кнопку нажать дальше".
## Домен
Ключевые понятия: Windows service, installed/running/stopped/missing, install/uninstall, helper/elevation, ProxiFyre, Local sing-box, selected apps, endpoint, generated config, logs, diagnostics, readiness blockers.
Цветовой мир: темная Windows-оболочка, service console, terminal black, muted slate panels, driver/service green, warning amber, blocked red, focus/navigation blue.
Сигнатура продукта: `Service Control Row` - компактная строка компонента, где слева состояние службы, в центре человекочитаемый статус и путь/деталь, справа ровно одно главное действие плюс меню дополнительных действий. Эта строка должна быть повторяемым паттерном для ProxiFyre, Local sing-box и будущих компонентов.
## Визуальная модель
- Основной layout: fixed header, fixed tabs, scrollable work area, adaptive log/status area.
- Основная композиция: не больше одного главного действия на компонент или экран.
- Summary должен отвечать "готово / не готово / что сделать дальше", а не показывать длинный dashboard.
- Route chain полезен, но он вторичен к service-control модели. Он должен объяснять эффект состояний служб, а не превращать приложение в карту сети.
## Depth And Surfaces
Стратегия глубины: borders-only + subtle surface shifts. Без тяжелых shadows.
- `canvas`: `#101216` - фон приложения.
- `surface`: `#131720` - обычные панели.
- `surface-raised`: `#151923` - service rows и важные панели.
- `surface-control`: `#242a35` - neutral buttons.
- `surface-inset`: `#0d1016` - inputs и read-only inset fields.
- `border`: `#2b3342`.
- `border-strong`: `#343b49`.
- `focus`: `#3b82f6`.
Радиусы: 4px для buttons, inputs, rows, panels; 999px только для pills/dots. Карточки и панели не должны становиться мягкими/крупно-скругленными.
Spacing base: 4px. Основные значения: 6, 8, 10, 12, 14, 18. Для плотной утилиты 20+ использовать редко, только между большими группами.
## Typography
Шрифт: Inter/system stack как сейчас.
Плотная шкала:
- caption: 12px / 500-700 / muted;
- body: 14px / 400-500 / secondary;
- control: 14px / 700 / primary;
- section title: 16px / 700;
- screen title: 18px / 650-750;
- status title: 22px / 700 only for primary readiness state.
Иерархия должна строиться весом и цветом больше, чем размером. Letter spacing держать `0`.
## Компонентная база
### Button
Все кнопки должны идти через общий компонент и общие variants:
- `primary`: главное безопасное действие текущего блока. Зеленый использовать только когда действие реально применимо и готово к выполнению.
- `neutral`: refresh, open, cancel, secondary action.
- `add`: добавление процесса, EXE, папки, target. Иконка + tooltip; текстовая кнопка только когда без текста смысл неясен.
- `danger`: stop/uninstall/delete. Не смешивать с neutral.
- `icon`: квадрат 40-44px, только иконка, обязательны `aria-label`, `title` или tooltip.
Button states обязательны: default, hover, active, focus-visible, disabled, loading.
Loading state не должен выглядеть как зависание: показывать spinner/progress label, менять текст на действие в процессе ("Проверяю", "Устанавливаю", "Останавливаю"), блокировать повторный запуск.
### Service Control Row
Повторяемый компонент для служб:
- left: status dot/icon (`checking`, `missing`, `installed`, `running`, `stopped`, `error`);
- center: title + short status + optional path/details;
- right: primary action (`Установить`, `Запустить`, `Остановить`, `Обновить`) + overflow menu;
- expanded area: setup checklist, diagnostics, paths, generated config.
В строке не должно быть двух конкурирующих primary actions.
### Tabs
Tabs должны быть единым компонентом:
- role tablist/tab/tabpanel;
- arrow-key navigation;
- active indicator 3px blue;
- height 46px desktop, не меньше 40px narrow;
- transition 180-240ms только `opacity` + `transform`;
- no layout shift при переключении.
### Status And Logs
Status surface должен показывать человеческий текст первым, raw details вторым уровнем.
- Preview/native command errors: friendly summary in dock, raw error in details.
- `aria-live` объявляет только короткий статус.
- Narrow width: dock collapses into compact toast/details control.
### Forms And Inputs
Inputs are inset:
- background `surface-inset`;
- border `border-strong`;
- focus ring 1px `focus`;
- validation appears directly below or beside the field;
- invalid state disables dependent action and explains exact accepted format.
### Motion
Motion должна показывать, что интерфейс живой, но не тормозить повторяющиеся операции.
- Button press: 100-140ms, `transform: scale(0.98)`.
- Tab switch: 180-240ms, `opacity` + `translateX` 12-24px.
- Popover/menu: 150-180ms, `opacity` + `scale(0.97 -> 1)`.
- Service checking/installing: subtle spinner or border trace, but not decorative glow everywhere.
- Respect `prefers-reduced-motion`.
Не использовать `transition: all`.
## Implementation Priorities
1. Создать shared UI layer: `Button`, `IconButton`, `Tabs`, `ServiceControlRow`, `StatusPill`, `LogDock`, `Field`, `ActionMenu`.
2. Перевести текущие кнопки на variants, чтобы "обновить", "добавить", "установить", "удалить", "применить" выглядели и вели себя стабильно.
3. Ввести readiness blockers для apply actions.
4. Свести все анимации к общим duration/easing tokens.
5. Проверить keyboard flow и responsive snapshots после каждого крупного UI изменения.

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 \

View File

@@ -6,11 +6,11 @@ product
## Users
macOS users without networking or Docker expertise. They open the client only to add a VPN subscription, choose a server, and turn the connection on or off.
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 a small, dependable control surface for the Docker-based macOS VPN client. Success means the current connection state is obvious and the full everyday workflow fits on one screen.
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
@@ -18,7 +18,7 @@ Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet
## Anti-references
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and controls for gateway or server administration.
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

649
README.md
View File

@@ -1,481 +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:8082` по умолчанию; установщик интерактивно спросит 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 Desktop Client
| Действие | Команда |
| --- | --- |
| Обновить и снова запустить | `./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` |
The Windows desktop client has been split out of this repository. Continue
Windows-specific work in the sibling repository:
Команда с `-v` удаляет подписку, выбранный сервер и другие сохранённые данные. Для обычной остановки используйте `down` без `-v`.
```powershell
cd D:\repos\ProxyWarden
```
## Обновление
This repository keeps the gateway and Docker client runtime; it no longer owns
the Tauri Windows app, ProxiFyre adapter, or Local sing-box installer flow.
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-адрес:
Повторно запустите однострочный установщик. Он обновит рабочую копию, снова спросит порт прокси и пересоберёт Connect:
```bash
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
```
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
```
-m set --match-set vpn_direct_bypass dst → RETURN
```
### Версии
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
Текущая версия всегда показана в правом нижнем углу интерфейса. 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`.
4. Запись истекает через TTL (по умолчанию 1 час).
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
```
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
DIRECT_BYPASS_TTL=3600 # TTL в секундах
```
Для изменения версии используйте `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`
Управляются из 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
}
]
}
```
| Переменная | По умолчанию | Назначение |
| --- | --- | --- |
| `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` | Уровень подробности журнала |
| Mode | Что делает |
| -------- | ----------------------------------------------------------------- |
| `direct` | fallback устройства после global rules → `direct` |
| `vpn` | fallback устройства после global rules → выбранный VPN |
| `block` | fallback устройства после global rules → `block` |
| `rules` | не задаёт fallback устройства; используется transparent default |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
---
## Если что-то не работает
## Кастомные правила маршрутизации
### Интерфейс не открывается
Управляются из вкладки **Маршрутизация**. Сохраняются в `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
---
## Быстрый старт
Проверьте контейнер и журнал:
```bash
# Сборка фронтенда
npm install && npm run 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
```
Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
Для 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
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 config
docker compose -f docker-compose.client.yml config
docker compose -f docker-compose.client.local.yml config
```
Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
### Локальное тестирование Harbor Connect
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
```bash
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
docker compose -f docker-compose.client.local.yml up -d --build
```
Скрипт собирает 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`.
Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
```bash
./scripts/build-runtime-base.sh
docker compose -f docker-compose.client.local.yml down -v
```
После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
UI доступен на `http://<gateway-ip>:3456`.
## Служебные команды
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
---
### Команды npm
## Переменные окружения
| Команда | Назначение |
| --- | --- |
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
| `npm test` | Запустить автоматические проверки |
| `npm run build` | Собрать веб-интерфейс в `dist/` |
| `npm run dev` | Запустить Vite для разработки интерфейса |
| `npm start` | Запустить управляющий Node.js-сервис в подготовленном окружении |
| Переменная | По умолчанию | Описание |
| ------------------- | -------------------- | -------------------------------------- |
| `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` | `8082` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
| `CLIENT_PROXY_PORT_START` | `8082` | Совместимость со старым 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 |
### Сборка и развёртывание
---
| Команда | Назначение |
| --- | --- |
| `./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 |
## REST API
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`.
| Метод | Путь | Описание |
| --------- | ---------------------- | ------------------------------------ |
| `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 трафика |
## Хранение данных
---
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
## Структура проекта
```
├── 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
```
## Ограничения
- TProxy только IPv4. IPv6 — в roadmap.
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.

View File

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

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:-8082}}
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}
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:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}"
- "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.

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,99 +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` | вынесено в `D:\repos\ProxyWarden` |
## 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 являются независимыми компонентами.
Статус: вынесено в соседний репозиторий `D:\repos\ProxyWarden`. Этот репозиторий больше не содержит Tauri Windows app, Windows-specific планы/evidence или installer scripts.
## Рабочий порядок
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. Windows desktop client развивать в `D:\repos\ProxyWarden`.

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.

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`.

113
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
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</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="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

@@ -9,29 +9,14 @@ const proxyPort = parsePort(
process.env.PROXY_PORT,
process.env.APP_MODE === "client" ? 8082 : 8080,
);
const clientProxyPortStart = parsePort(
process.env.CLIENT_PROXY_PORT_START,
proxyPort,
);
const clientProxyPortEnd = parsePort(
process.env.CLIENT_PROXY_PORT_END,
clientProxyPortStart,
);
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",
@@ -39,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);

View File

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

View File

@@ -1,152 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
export const DEFAULT_DEVICE_MODE = "vpn";
export const DEFAULT_PROXY_MODE = "vpn";
export const TPROXY_INBOUND = "tproxy-in";
export const MIXED_INBOUND = "mixed-in";
const IPISH_RE = /^[\.\d:/]+$/;
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 normalizeDeviceMode(mode, fallback = "rules") {
const value = String(mode || "").trim().toLowerCase();
return DEVICE_MODES.has(value) ? value : fallback;
}
function normalizeDefaultMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
}
function normalizeProxyMode(mode) {
const value = String(mode || "").trim().toLowerCase();
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
}
function normalizeIp(ip) {
const value = String(ip || "").trim();
return value && IPISH_RE.test(value) ? value : "";
}
function normalizeMac(mac) {
return String(mac || "").trim();
}
function fromLegacyDeviceRules(input) {
const rules = Array.isArray(input) ? input : [];
const devices = [];
for (const rule of rules) {
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
const mode = normalizeDeviceMode(rule?.outbound, "direct");
sourceIps.forEach((sourceIp, ipIndex) => {
const ip = normalizeIp(sourceIp);
if (!ip) return;
devices.push({
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
enabled: rule.enabled !== false,
ip,
mac: "",
mode,
lastSeen: null,
});
});
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices,
};
}
export function normalizeDeviceProfiles(input) {
const raw =
input && typeof input === "object" && !Array.isArray(input)
? input
: { devices: input };
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
return {
defaultTransparentMode: normalizeDefaultMode(
raw.defaultTransparentMode || raw.defaultMode,
),
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
devices: rawDevices.map((device, index) => ({
id: String(device.id || `dev-${Date.now()}-${index}`),
name: String(device.name || `Устройство ${index + 1}`).trim(),
enabled: device.enabled !== false,
ip: normalizeIp(device.ip || device.sourceIp),
mac: normalizeMac(device.mac),
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
lastSeen: device.lastSeen || null,
})),
};
}
export function readDeviceProfiles() {
if (fs.existsSync(settings.devicesPath)) {
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
}
if (fs.existsSync(settings.deviceRulesPath)) {
return normalizeDeviceProfiles(
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
);
}
return {
defaultTransparentMode: DEFAULT_DEVICE_MODE,
proxyDefaultMode: DEFAULT_PROXY_MODE,
devices: [],
};
}
export function writeDeviceProfiles(value) {
const normalized = normalizeDeviceProfiles(value);
writeJson(settings.devicesPath, normalized);
return normalized;
}
export function normalizeCidr(ip) {
const value = normalizeIp(ip);
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
export function deviceCidrs(devices, modes) {
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
return (Array.isArray(devices) ? devices : [])
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
.map((device) => normalizeCidr(device.ip))
.filter(Boolean);
}
export function legacyDeviceRulesFromProfiles(profiles) {
const { devices } = normalizeDeviceProfiles(profiles);
return devices.map((device) => ({
id: device.id,
name: device.name,
enabled: device.enabled,
sourceIps: device.ip ? [device.ip] : [],
outbound: device.mode === "rules" ? "direct" : device.mode,
}));
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,340 +0,0 @@
// Простой симулятор роутинга sing-box.
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
// мы не можем точно сказать, попадает ли IP/домен в RU.
import net from "node:net";
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
function ipv4ToInt(ip) {
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
if (
parts.length !== 4 ||
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)
)
return null;
return (
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
);
}
function ipInCidr(ip, cidr) {
if (!net.isIP(ip)) return false;
const [addr, maskStr] = String(cidr).split("/");
if (!addr) return false;
if (net.isIPv4(ip) && net.isIPv4(addr)) {
const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10);
if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false;
const ipInt = ipv4ToInt(ip);
const cidrInt = ipv4ToInt(addr);
if (ipInt === null || cidrInt === null) return false;
if (mask === 0) return true;
const m = (~0 << (32 - mask)) >>> 0;
return (ipInt & m) === (cidrInt & m);
}
// IPv6 — упрощённо: точное сравнение строк (без полноценной обработки)
return false;
}
const PRIVATE_CIDRS = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.0.0/16",
];
function isPrivateIp(ip) {
if (!ip) return false;
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
}
function normalizeCidr(ip) {
const value = String(ip || "").trim();
if (!value) return "";
return value.includes("/") ? value : `${value}/32`;
}
function deviceMatchesSourceIp(device, sourceIp) {
if (!device?.ip || !sourceIp) return false;
return ipInCidr(sourceIp, normalizeCidr(device.ip));
}
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return `${vpnTag} (VPN)`;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function likelyRuHost(host) {
const value = String(host || "").toLowerCase();
return value === "ru" || value.endsWith(".ru");
}
function hostMatchesDomain(host, domain) {
if (!host || !domain) return false;
return host.toLowerCase() === domain.toLowerCase();
}
function hostMatchesSuffix(host, suffix) {
if (!host || !suffix) return false;
const h = host.toLowerCase();
const s = suffix.toLowerCase();
return h === s || h.endsWith("." + s) || h.endsWith(s);
}
function hostMatchesKeyword(host, keyword) {
if (!host || !keyword) return false;
return host.toLowerCase().includes(keyword.toLowerCase());
}
function ruleMatches(rule, target) {
const { host = "", ip = "", port, network } = target;
if (!rule?.enabled) return false;
const checks = [];
if (rule.domains?.length) {
checks.push(rule.domains.some((d) => hostMatchesDomain(host, d)));
}
if (rule.domainSuffixes?.length) {
checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d)));
}
if (rule.domainKeywords?.length) {
checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d)));
}
if (rule.ipCidrs?.length) {
if (!ip) return false;
checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr)));
}
if (rule.ports?.length) {
if (port === undefined || port === null || port === "") return false;
const p = Number(port);
checks.push(
rule.ports.some((portStr) => {
const s = String(portStr).trim();
if (s.includes("-")) {
const [from, to] = s.split("-").map((x) => Number(x));
return p >= from && p <= to;
}
return p === Number(s);
}),
);
}
if (rule.networks?.length) {
if (!network) return false;
checks.push(rule.networks.includes(network));
}
if (!checks.length) return false;
return checks.every(Boolean);
}
/**
* Симулирует роутинг и возвращает результат.
* @param {object} target { host, ip, port, network }
* @param {Array} customRules
* @param {object} options { routingRuDirect, vpnTag }
*/
export function matchRoute(target, customRules, options = {}) {
const {
routingRuDirect = true,
vpnTag = "vpn-out",
deviceProfiles = {
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices: [],
},
} = options;
const rules = Array.isArray(customRules) ? customRules : [];
const inbound = target.inbound || TPROXY_INBOUND;
const sourceIp = target.sourceIp || "";
const devices = Array.isArray(deviceProfiles.devices)
? deviceProfiles.devices
: [];
const matchedDevice = devices.find(
(device) =>
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
);
if (
inbound === TPROXY_INBOUND &&
matchedDevice &&
matchedDevice.mode === "bypass"
) {
return {
matched: "kernel-bypass",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name} -> bypass TProxy`,
outbound: "direct",
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
};
}
// 1. private IP → direct
if (target.ip && isPrivateIp(target.ip)) {
return {
matched: "system",
ruleIndex: -1,
ruleName: "private IP → direct",
outbound: "direct",
reason: `IP ${target.ip} приватный`,
};
}
// 2. global custom rules apply to every inbound before fallbacks.
for (let i = 0; i < rules.length; i += 1) {
const rule = rules[i];
if (ruleMatches(rule, target)) {
const outbound =
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
return {
matched: "custom",
ruleIndex: i,
ruleId: rule.id,
ruleName: rule.name,
outbound,
reason: "Совпадение по global custom rule",
};
}
}
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
if (routingRuDirect && likelyRuHost(target.host)) {
return {
matched: "geo",
ruleIndex: -2,
ruleName: "geosite-category-ru → direct",
outbound: "direct",
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
};
}
// 4. transparent device defaults.
if (inbound === TPROXY_INBOUND && matchedDevice) {
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
if (outbound) {
return {
matched: "device-default",
ruleIndex: -1,
ruleId: matchedDevice.id,
ruleName: `${matchedDevice.name}${matchedDevice.mode}`,
outbound,
reason: "Fallback устройства после global rules",
};
}
}
// 5. explicit proxy default.
if (inbound === MIXED_INBOUND) {
const mode = deviceProfiles.proxyDefaultMode || "vpn";
return {
matched: "proxy-default",
ruleIndex: -1,
ruleName: `mixed-in default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
};
}
// 6. unknown transparent device default.
if (inbound === TPROXY_INBOUND) {
const mode = deviceProfiles.defaultTransparentMode || "vpn";
return {
matched: "transparent-default",
ruleIndex: -1,
ruleName: `transparent default → ${mode}`,
outbound: modeOutbound(mode, vpnTag) || "direct",
reason: "Fallback unknown transparent device после global rules",
};
}
// 7. final → direct
return {
matched: "final",
ruleIndex: -3,
ruleName: "final",
outbound: "direct",
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
};
}
/**
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
* по доменам/суффиксам/CIDR — отмечаем конфликт.
*/
export function detectRuleConflicts(rules) {
const list = Array.isArray(rules) ? rules : [];
const conflicts = [];
for (let i = 1; i < list.length; i += 1) {
const cur = list[i];
if (!cur?.enabled) continue;
for (let j = 0; j < i; j += 1) {
const prev = list[j];
if (!prev?.enabled) continue;
// Если outbound одинаковый — это не "конфликт", это дубликат
const sameOutbound = prev.outbound === cur.outbound;
// Проверка перекрытия доменов
const overlaps = [];
// Точные домены покрываются prev.suffix
for (const d of cur.domains || []) {
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
overlaps.push({
kind: "domain",
value: d,
by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}`,
});
}
if ((prev.domains || []).includes(d)) {
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
}
}
// Суффиксы покрываются более общим суффиксом prev
for (const s of cur.domainSuffixes || []) {
if (
(prev.domainSuffixes || []).some(
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
)
) {
overlaps.push({
kind: "suffix",
value: s,
by: "более общий суффикс",
});
}
}
// CIDR
for (const c of cur.ipCidrs || []) {
if ((prev.ipCidrs || []).includes(c)) {
overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" });
}
}
if (overlaps.length) {
conflicts.push({
ruleId: cur.id,
ruleIndex: i,
ruleName: cur.name,
conflictWithId: prev.id,
conflictWithIndex: j,
conflictWithName: prev.name,
severity: sameOutbound ? "info" : "warning",
overlaps,
});
}
}
}
return conflicts;
}

View File

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

View File

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

View File

@@ -1,17 +1,3 @@
function normalizeControlUrl(value) {
const raw = String(value || "").trim();
if (!raw) return "";
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
const url = new URL(withProtocol);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Gateway URL must use http or https");
}
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
return url.toString().replace(/\/$/, "");
}
function proxyHostFromHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return "";
@@ -22,17 +8,6 @@ function proxyHostFromHeader(hostHeader) {
return raw.split(":")[0];
}
function normalizeProxyInfo(proxy) {
if (!proxy || typeof proxy !== "object") return null;
const host = String(proxy.host || "").trim();
const port = Number.parseInt(proxy.port, 10);
const protocol = proxy.protocol === "http" ? "http" : "socks5";
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return null;
}
return { host, port, protocol };
}
export function buildSharedProxyInfo({
appMode,
proxyPort,
@@ -67,28 +42,3 @@ export function buildSharedProxyInfo({
proxy,
};
}
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
const baseUrl = normalizeControlUrl(controlUrl);
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
headers: { accept: "application/json" },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `Gateway returned ${response.status}`);
}
if (!data.available) {
throw new Error("Gateway shared proxy is not available");
}
const sharedProxy = normalizeProxyInfo(data.proxy);
if (!sharedProxy) {
throw new Error("Gateway returned invalid shared proxy settings");
}
return {
sharedProxyEnabled: true,
sharedProxyControlUrl: baseUrl,
sharedProxy,
};
}

View File

@@ -1,377 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import { settings } from "./config.js";
import {
MIXED_INBOUND,
TPROXY_INBOUND,
normalizeCidr,
readDeviceProfiles,
} from "./devices.js";
import { readClientSettings } from "./clientSettings.js";
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set([
"vless",
"vmess",
"trojan",
"shadowsocks",
"hysteria2",
]);
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
? subscriptionConfig.outbounds
: [];
const exact = outbounds.find(
(outbound) =>
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
);
if (exact) return exact;
const trimmedTag = String(selectedTag || "").trim();
return outbounds.find(
(outbound) =>
String(outbound.tag || "").trim() === trimmedTag &&
PROXY_TYPES.has(outbound.type),
);
const tag = String(selectedTag || '').trim();
return outbounds.find((outbound) => (
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
));
}
function readCustomRuleSets() {
try {
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
const data = JSON.parse(
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
);
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function ruleSetDownloadDetour(vpnTag) {
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
if (!detour || detour === "vpn") return vpnTag;
return detour;
}
function ruleSets(customRuleSets = [], vpnTag = "direct") {
const downloadDetour = ruleSetDownloadDetour(vpnTag);
const builtIn = settings.routingRuDirect
? [
{
type: "remote",
tag: "geoip-ru",
format: "binary",
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
download_detour: downloadDetour,
},
{
type: "remote",
tag: "geosite-category-ru",
format: "binary",
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
download_detour: downloadDetour,
},
]
: [];
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
.filter((rs) => rs.tag && rs.url)
.map((rs) => ({
type: "remote",
tag: String(rs.tag).trim(),
format: rs.format || "binary",
url: String(rs.url).trim(),
download_detour: downloadDetour,
}));
// Пользовательские rule-sets не должны дублировать встроенные
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
const merged = [
...builtIn,
...custom.filter((rs) => !builtInTags.has(rs.tag)),
];
return merged;
}
function uniqueClean(values) {
return Array.from(
new Set(
(Array.isArray(values) ? values : [])
.map((value) => String(value || "").trim())
.filter(Boolean),
),
);
}
function parsePorts(values) {
return uniqueClean(values)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
}
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
if (!customRule?.enabled) return null;
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
const rule = { ...baseRule };
const domains = uniqueClean(customRule.domains);
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
const domainKeywords = uniqueClean(customRule.domainKeywords);
const ipCidrs = uniqueClean(customRule.ipCidrs);
const ports = parsePorts(customRule.ports);
const networks = uniqueClean(customRule.networks).filter((network) =>
["tcp", "udp"].includes(network),
);
if (domains.length) rule.domain = domains;
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
if (ports.length) rule.port = ports;
if (networks.length) rule.network = networks;
const ruleSetsRef = uniqueClean(customRule.ruleSets);
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
if (
!rule.domain &&
!rule.domain_suffix &&
!rule.domain_keyword &&
!rule.ip_cidr &&
!rule.port &&
!rule.network &&
!rule.rule_set
) {
return null;
}
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
return rule;
}
function customRouteRules(customRules, vpnTag, baseRule = {}) {
return (Array.isArray(customRules) ? customRules : [])
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
.filter(Boolean);
}
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
function modeOutbound(mode, vpnTag) {
if (mode === "vpn") return vpnTag;
if (mode === "direct" || mode === "block") return mode;
return null;
}
function deviceDefaultRouteRule(device, vpnTag) {
if (!device?.enabled) return null;
const outbound = modeOutbound(device.mode, vpnTag);
if (!outbound) return null;
const cidr = normalizeCidr(device.ip);
if (!cidr) return null;
return {
inbound: [TPROXY_INBOUND],
source_ip_cidr: [cidr],
outbound,
};
}
function deviceDefaultRouteRules(devices, vpnTag) {
return (Array.isArray(devices) ? devices : [])
.map((device) => deviceDefaultRouteRule(device, vpnTag))
.filter(Boolean);
}
function inboundDefaultRule(inbound, mode, vpnTag) {
const outbound = modeOutbound(mode, vpnTag);
if (!outbound) return null;
return { inbound: [inbound], outbound };
}
function ruDirectRule() {
if (!settings.routingRuDirect) return null;
return {
rule_set: ["geoip-ru", "geosite-category-ru"],
outbound: "direct",
};
}
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
const deviceProfiles = readDeviceProfiles();
const rules = [
{
ip_is_private: true,
outbound: "direct",
},
];
// Global rules apply to every inbound before contextual fallbacks.
rules.push(...customRouteRules(customRules, vpnTag));
const ruRule = ruDirectRule();
if (ruRule) rules.push(ruRule);
if (includeTransparent) {
// Device defaults are only transparent-gateway fallbacks after global rules.
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
}
const proxyFallback = inboundDefaultRule(
MIXED_INBOUND,
deviceProfiles.proxyDefaultMode,
vpnTag,
);
if (proxyFallback) rules.push(proxyFallback);
if (includeTransparent) {
const transparentFallback = inboundDefaultRule(
TPROXY_INBOUND,
deviceProfiles.defaultTransparentMode,
vpnTag,
);
if (transparentFallback) rules.push(transparentFallback);
}
return rules;
}
function sharedProxyOutbound(sharedProxy) {
if (!sharedProxy?.host || !sharedProxy?.port) return null;
if (sharedProxy.protocol === "http") {
return {
type: "http",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
};
}
return {
type: "socks",
tag: "shared-proxy",
server: sharedProxy.host,
server_port: sharedProxy.port,
version: "5",
};
}
export function buildGatewayConfig(
subscriptionConfig,
selectedTag,
{ bypassAll = false } = {},
) {
const customRuleSets = readCustomRuleSets();
const clientMode = settings.appMode === "client";
const clientSettings = clientMode ? readClientSettings() : null;
const sharedOutbound =
clientMode && clientSettings?.sharedProxyEnabled
? sharedProxyOutbound(clientSettings.sharedProxy)
: null;
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
const selectedOutbound = sharedOutbound
export function buildGatewayConfig(subscriptionConfig, selectedTag, {
clientDirect = false,
routeRules = [],
} = {}) {
const clientMode = settings.appMode === 'client';
const directClient = clientMode && clientDirect;
const vpnOutbound = directClient
? null
: findOutbound(subscriptionConfig, selectedTag);
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
throw new Error(`Outbound не найден: ${selectedTag}`);
: structuredClone(findOutbound(subscriptionConfig, selectedTag));
if (!directClient && !vpnOutbound) throw new HarborError('SERVER_NOT_FOUND');
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = 'xudp';
}
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
vpnOutbound.packet_encoding = "xudp";
}
const clientOutbound = sharedOutbound
? sharedOutbound.tag
: clientSettings?.homeBypassEnabled
? "direct"
: vpnOutbound.tag;
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
const inbounds = [
...(clientMode
? []
: [
{
type: "tproxy",
tag: "tproxy-in",
listen: "::",
...(!clientMode ? [{
type: 'tproxy',
tag: TPROXY_INBOUND,
listen: '::',
listen_port: settings.tproxyPort,
sniff: true,
sniff_override_destination: true,
},
]),
}] : []),
{
type: "mixed",
tag: "mixed-in",
type: 'mixed',
tag: MIXED_INBOUND,
listen: settings.bindIp,
listen_port: mixedProxyPort,
listen_port: settings.proxyPort,
sniff: true,
set_system_proxy: false,
},
];
const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [
...directRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
];
return {
log: {
level: settings.logLevel,
timestamp: true,
},
log: { level: settings.logLevel, timestamp: true },
experimental: {
cache_file: {
enabled: true,
path: settings.cachePath,
},
},
dns: {
independent_cache: true,
cache_file: { enabled: true, path: settings.cachePath },
},
dns: { independent_cache: true },
inbounds,
outbounds: [
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
{ type: "direct", tag: "direct" },
{ type: "block", tag: "block" },
...(vpnOutbound ? [vpnOutbound] : []),
{ type: 'direct', tag: 'direct' },
{ type: 'block', tag: 'block' },
],
route: {
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
rules: bypassAll
? [{ ip_is_private: true, outbound: "direct" }]
: clientMode
? proxyOnlyRules
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
includeTransparent: !clientMode,
}),
final: "direct",
rule_set: [],
rules,
final: outboundTag,
...(clientMode ? {} : { auto_detect_interface: true }),
},
};
}
export function writeSingboxConfig(config) {
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
fs.writeFileSync(
settings.configPath,
JSON.stringify(config, null, 2),
"utf8",
);
atomicWriteJson(settings.configPath, config);
}
export function readSingboxConfig() {
if (!fs.existsSync(settings.configPath)) return null;
try {
return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
} catch {
return null;
}
export function restoreSingboxConfig(contents) {
atomicWriteFile(settings.configPath, contents);
}
export function removeSingboxConfig() {
if (fs.existsSync(settings.configPath)) {
fs.rmSync(settings.configPath);
}
fs.rmSync(settings.configPath, { force: true });
}

View File

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

View File

@@ -1,8 +1,32 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { settings } from './config.js';
import { HarborError } from '../shared/errors.js';
import {
createServerId,
normalizeServer,
serverIdentityKey,
} from '../shared/serverIdentity.js';
import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
function usableProxyOutbound(outbound) {
const host = String(outbound?.server || '').trim().toLowerCase();
const port = Number(outbound?.server_port);
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
}
function rejectedSubscriptionCode(outbounds) {
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
? 'SUBSCRIPTION_REJECTED'
: 'SUBSCRIPTION_INVALID';
}
export function getHwid() {
fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -10,7 +34,7 @@ export function getHwid() {
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
}
const hwid = crypto.randomBytes(8).toString('hex');
fs.writeFileSync(settings.hwidPath, hwid, 'utf8');
atomicWriteFile(settings.hwidPath, hwid);
return hwid;
}
@@ -40,10 +64,15 @@ export function parseUserInfo(headerValue) {
export function parseVlessUrl(rawUrl) {
if (!rawUrl.startsWith('vless://')) {
throw new Error('VLESS URL must start with vless://');
throw new HarborError('SUBSCRIPTION_INVALID');
}
const parsed = new URL(rawUrl);
let parsed;
try {
parsed = new URL(rawUrl);
} catch (cause) {
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
}
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
const uuid = decodeURIComponent(parsed.username || '');
const server = parsed.hostname;
@@ -55,11 +84,11 @@ export function parseVlessUrl(rawUrl) {
const flow = parsed.searchParams.get('flow') || '';
if (!uuid || !server || !serverPort) {
throw new Error('VLESS URL misses uuid, host or port');
throw new HarborError('SUBSCRIPTION_INVALID');
}
if (!publicKey || !shortId) {
throw new Error('VLESS REALITY parameters pbk and sid are required');
throw new HarborError('SUBSCRIPTION_INVALID');
}
return {
@@ -98,8 +127,36 @@ function maybeDecodeBase64(content) {
return content;
}
export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = [];
const rejectedOutbounds = [];
const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => {
if (!outbound || typeof outbound !== 'object') {
rejectedOutbounds.push(outbound);
return [];
}
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
if (!usableProxyOutbound(outbound)) {
rejectedOutbounds.push(outbound);
return [];
}
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return [];
seen.add(id);
servers.push(normalizeServer({ ...outbound, id }));
return [{ ...outbound, tag: id }];
});
if (!servers.length) throw new HarborError(rejectedSubscriptionCode(rejectedOutbounds));
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
}
export function parseSubscriptionBody(body) {
let parsedConfig = null;
let parsedConfig;
try {
parsedConfig = JSON.parse(body);
@@ -111,7 +168,7 @@ export function parseSubscriptionBody(body) {
.filter((line) => line.startsWith('vless://'));
if (!links.length) {
throw new Error('Subscription does not contain JSON config or VLESS links');
throw new HarborError('SUBSCRIPTION_INVALID');
}
parsedConfig = {
@@ -119,46 +176,60 @@ export function parseSubscriptionBody(body) {
};
}
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = outbounds
.filter((outbound) => PROXY_TYPES.has(outbound.type))
.map((outbound) => ({
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
type: outbound.type,
server: outbound.server || 'unknown',
server_port: outbound.server_port || 443,
}));
if (!servers.length) {
throw new Error('No supported proxy outbounds found in subscription');
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
}
return { config: parsedConfig, servers };
}
export async function fetchSubscription(url) {
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
throw new Error('Invalid subscription URL');
} catch (cause) {
throw new HarborError('SUBSCRIPTION_INVALID', { cause });
}
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Subscription URL must use http or https');
throw new HarborError('SUBSCRIPTION_INVALID');
}
const response = await fetch(parsedUrl, {
let response;
try {
response = await fetchImpl(parsedUrl, {
headers: subscriptionHeaders(),
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
} catch (cause) {
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
}
if (!response.ok) {
throw new Error(`Subscription request failed: HTTP ${response.status}`);
throw new HarborError('PROVIDER_UNAVAILABLE', { details: `HTTP ${response.status}` });
}
return response;
}
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
if (!currentServerId) return '';
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId);
if (!previous) return '';
const identity = serverIdentityKey(previous);
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
return matches.length === 1 ? matches[0].id : '';
}
export async function fetchSubscription(url, options) {
const response = await requestSubscription(url, options);
const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
if (userInfo.expire > 0 && userInfo.expire * 1000 <= Date.now()) {
throw new HarborError('SUBSCRIPTION_EXPIRED');
}
if (userInfo.total > 0 && (userInfo.upload || 0) + (userInfo.download || 0) >= userInfo.total) {
throw new HarborError('SUBSCRIPTION_TRAFFIC_EXHAUSTED');
}
const parsed = parseSubscriptionBody(body);
return {

View File

@@ -1,124 +0,0 @@
import { spawnSync } from "node:child_process";
import { settings } from "./config.js";
import { deviceCidrs, normalizeCidr } from "./devices.js";
const DEFAULT_NAT_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";
function splitCidrs(value) {
return String(value || "")
.split(/[\s,]+/)
.map((item) => normalizeCidr(item))
.filter(Boolean);
}
function unique(list) {
return [...new Set(list)];
}
export function sourceBypassCidrs(
profiles,
envCidrs = process.env.TPROXY_BYPASS_SOURCE_CIDRS || "",
) {
return unique([
...splitCidrs(envCidrs),
...deviceCidrs(profiles?.devices || [], "bypass"),
]);
}
export function buildSourceBypassIptablesCommands(
cidrs,
{
chain = settings.tproxySourceBypassChain,
forwardChain = settings.tproxySourceForwardChain,
natChain = settings.tproxySourceNatChain,
natBypassCidrs = splitCidrs(
process.env.BYPASS_CIDRS || DEFAULT_NAT_BYPASS_CIDRS,
),
} = {},
) {
return [
["-w", "-t", "mangle", "-F", chain],
["-w", "-F", forwardChain],
["-w", "-t", "nat", "-F", natChain],
...cidrs.map((cidr) => [
"-w",
"-t",
"mangle",
"-A",
chain,
"-s",
cidr,
"-j",
"ACCEPT",
]),
...cidrs.flatMap((cidr) => [
["-w", "-A", forwardChain, "-s", cidr, "-j", "ACCEPT"],
[
"-w",
"-A",
forwardChain,
"-d",
cidr,
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
]),
...natBypassCidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-d",
cidr,
"-j",
"RETURN",
]),
...cidrs.map((cidr) => [
"-w",
"-t",
"nat",
"-A",
natChain,
"-s",
cidr,
"-j",
"MASQUERADE",
]),
];
}
export function syncTproxySourceBypass(profiles, options = {}) {
if (settings.appMode !== "gateway") {
return { success: true, skipped: true, cidrs: [] };
}
const cidrs = sourceBypassCidrs(
profiles,
options.envCidrs ?? process.env.TPROXY_BYPASS_SOURCE_CIDRS,
);
const commands = buildSourceBypassIptablesCommands(cidrs, options);
for (const args of commands) {
const result = spawnSync("iptables", args, {
encoding: "utf8",
timeout: 1000,
});
if (result.error || result.status !== 0) {
return {
success: false,
cidrs,
error:
result.error?.message ||
(result.stderr || result.stdout || "iptables command failed").trim(),
};
}
}
return { success: true, cidrs };
}

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@@ -1,570 +1,187 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api } from './api.js';
import { Topbar } from './components/Topbar.jsx';
import { Sidebar } from './components/Sidebar.jsx';
import { StatusPane } from './components/StatusPane.jsx';
import { OverviewPage } from './components/OverviewPage.jsx';
import { api, HarborApiError } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { ServersPage } from './components/ServersPage.jsx';
import { RoutingPage } from './components/RoutingPage.jsx';
import { LogsPage } from './components/LogsPage.jsx';
import { SettingsPage } from './components/SettingsPage.jsx';
import { ConfigViewer } from './components/ConfigViewer.jsx';
import { Toasts } from './components/Toasts.jsx';
const ROLLBACK_WINDOW_MS = 12_000;
function getInitialPage() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
const valid = ['overview', 'servers', 'routing', 'logs', 'settings'];
return valid.includes(hash) ? hash : 'overview';
}
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import {
compatibleSnapshot,
harborReducer,
initialHarborState,
} from './state/harborReducer.js';
import { createOperationRegistry } from './state/operations.js';
function App() {
const [page, setPage] = useState(getInitialPage());
const [state, setState] = useState(null);
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = useState([]);
const [devicesConfig, setDevicesConfig] = useState({
defaultTransparentMode: 'vpn',
proxyDefaultMode: 'vpn',
devices: [],
});
const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
const [configOpen, setConfigOpen] = useState(false);
const [pings, setPings] = useState({});
const [toasts, setToasts] = useState([]);
const [applyStatus, setApplyStatus] = useState('idle'); // idle | applying | error
const [rollbackOffer, setRollbackOffer] = useState(null);
const rulesDirtyRef = useRef(false);
const rulesSaveTimerRef = useRef(null);
const rulesRevisionRef = useRef(0);
const rollbackTimerRef = useRef(null);
function pushToast(toast) {
const id = `t-${Date.now()}-${Math.random()}`;
setToasts((prev) => [...prev, { id, ...toast }]);
}
function dismissToast(id) {
setToasts((prev) => prev.filter((t) => t.id !== id));
const [operations, setOperations] = useState({});
const [error, setError] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
const pollGeneration = useRef(0);
const operationRegistry = useRef(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry(setOperations);
}
function navigate(p) {
setPage(p);
window.location.hash = `#/${p}`;
function setPendingServerId(serverId) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await api.state();
if (!compatibleSnapshot(snapshot)) {
const incompatible = new Error('Ожидался Harbor state apiVersion 1');
incompatible.code = 'INCOMPATIBLE_API';
throw incompatible;
}
if (generation === pollGeneration.current) {
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
} catch (requestError) {
if (generation === pollGeneration.current) {
dispatch({ type: 'sync-failed', error: requestError });
}
}
}
useEffect(() => {
function onHash() { setPage(getInitialPage()); }
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
async function loadState() {
const data = await api.state();
setState(data);
setServers(data.servers || []);
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
setDevicesConfig(data.devicesConfig || {
defaultTransparentMode: 'vpn',
proxyDefaultMode: 'vpn',
devices: data.devices || [],
});
setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || '');
}
useEffect(() => {
loadState().catch((err) => setError(err.message));
const timer = setInterval(() => loadState().catch(() => {}), 5000);
loadState();
const timer = setInterval(loadState, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (state?.mode === 'client' && page !== 'overview') {
navigate('overview');
}
}, [state?.mode, page]);
useEffect(() => () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
let cancelled = false;
api.version().then((info) => {
if (!cancelled) setVersionInfo(info);
}).catch((requestError) => {
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
if (!cancelled) setVersionInfo(null);
});
return () => { cancelled = true; };
}, []);
async function withBusy(label, fn, { quiet = false } = {}) {
setBusy(true);
setError('');
useEffect(() => {
if (!state?.mode) return;
const isGateway = state.mode === 'gateway';
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
document.getElementById('harbor-favicon').href = isGateway
? '/harbor-gateway.svg?v=2'
: '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key, action, context) {
setError(null);
return operationRegistry.current.run(key, async () => {
try {
const result = await fn();
if (!quiet && label && state?.mode !== 'client') {
pushToast({ kind: 'success', title: label });
}
return result;
return await applyMutation(action);
} catch (err) {
setError(err.message);
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message, duration: 6000 });
throw err;
} finally {
setBusy(false);
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: err?.code }, err?.status);
setError({
context,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context)
: null,
});
return false;
}
});
}
// === Subscription ===
async function fetchSubscription() {
return withBusy('Подписка обновлена', async () => {
const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || '');
setServers(data.servers || []);
if (data.servers?.length) {
const nextTag = data.servers.some((server) => server.tag === selectedTag)
? selectedTag
: data.servers[0].tag;
setSelectedTag(nextTag);
setPendingTag(nextTag);
}
await loadState();
async function applyMutation(action) {
pollGeneration.current += 1;
const result = await action();
if (!result?.state) throw new Error('Harbor API не вернул state snapshot');
if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1');
dispatch({
type: 'sync-succeeded',
snapshot: result.state,
receivedAt: new Date().toISOString(),
});
return result;
}
async function fetchSubscription() {
return run('subscriptionImport', async () => {
const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
async function refreshSubscription() {
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
}
async function forgetSubscription() {
if (!confirm('Удалить подписку и остановить sing-box?')) return;
return withBusy('Подписка удалена', async () => {
await api.subscription.forget();
return run('subscriptionDelete', async () => {
const data = await api.subscription.forget();
setSubscriptionUrl('');
setServers([]);
setSelectedTag('');
setPendingTag('');
await loadState();
});
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
// === Apply with rollback offer ===
async function applyServer(tag) {
const target = tag || selectedTag;
if (!target) return;
const previous = state?.selectedTag;
setApplyStatus('applying');
try {
await withBusy('Сервер применён', async () => {
await api.apply(target);
await loadState();
});
setApplyStatus('idle');
if (state?.mode !== 'client' && previous && previous !== target) {
setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS });
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS);
}
} catch {
setApplyStatus('error');
}
}
async function rollback() {
if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current);
setRollbackOffer(null);
return withBusy('Откат выполнен', async () => {
const data = await api.rollback();
setSelectedTag(data.selectedTag);
setPendingTag(data.selectedTag);
await loadState();
});
}
// === sing-box control ===
async function stopSingbox(confirmFirst = true) {
if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); });
}
async function restartSingbox() {
return withBusy('Перезапущено', async () => { await api.singbox.restart(); await loadState(); });
}
async function clearConfig() {
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
return withBusy('Config сброшен', async () => {
await api.singbox.clear();
setSelectedTag('');
setPendingTag('');
await loadState();
});
}
async function toggleBypass() {
const next = !state?.bypassMode;
return withBusy(
next ? 'Обход правил включён — весь трафик напрямую' : 'Обход правил отключён',
async () => {
await api.bypass(next);
await loadState();
},
);
}
async function flushDirectCache() {
return withBusy('Bypass-кэш сброшен', async () => {
await api.directCache.flush();
await loadState();
});
}
// === Devices ===
async function saveDevicesConfig(nextConfig) {
try {
const data = await api.devices.save(nextConfig);
setDevicesConfig({
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn',
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
devices: data.devices || [],
});
setState((prev) => prev ? {
...prev,
devicesUpdatedAt: data.devicesUpdatedAt,
sourceBypassCidrs: data.sourceBypassCidrs,
} : prev);
if (data.sourceBypassResult && data.sourceBypassResult.success === false) {
pushToast({
kind: 'warning',
title: 'Bypass сохранён, но не применён',
message: data.sourceBypassResult.error,
duration: 7000,
});
}
} catch (err) {
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
}
}
function addDevice() {
const nextConfig = {
...devicesConfig,
devices: [
...devicesConfig.devices,
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
],
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function updateDevice(id, patch) {
const nextConfig = {
...devicesConfig,
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function removeDevice(id) {
const nextConfig = {
...devicesConfig,
devices: devicesConfig.devices.filter((d) => d.id !== id),
};
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
function updateDeviceDefaults(patch) {
const nextConfig = { ...devicesConfig, ...patch };
setDevicesConfig(nextConfig);
saveDevicesConfig(nextConfig);
}
// === Rules CRUD ===
function emptyRule() {
return {
id: `rule-${Date.now()}`,
name: 'Новое правило',
enabled: true,
outbound: 'direct',
domains: [], domainSuffixes: [], domainKeywords: [],
ipCidrs: [], ports: [], networks: [],
};
}
function queueRulesSave(nextRules) {
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
setRulesSaveStatus('pending');
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesSaveTimerRef.current = setTimeout(() => saveRules(nextRules, { silent: true, revision }), 700);
}
async function saveRules(nextRules = customRules, options = {}) {
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
setError('');
setRulesSaveStatus('saving');
try {
const data = await api.rules.save(nextRules);
if (rulesRevisionRef.current === revision) {
rulesDirtyRef.current = false;
setCustomRules(data.rules || []);
setRulesSaveStatus('saved');
await loadState();
if (!silent) pushToast({ kind: 'success', title: 'Правила сохранены' });
} else {
setRulesSaveStatus('pending');
}
} catch (err) {
setError(err.message);
setRulesSaveStatus('error');
pushToast({ kind: 'danger', title: 'Не удалось сохранить', message: err.message });
}
}
function saveRulesNow() {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
saveRules(customRules, { silent: false, revision });
}
function updateRule(id, patch) {
setCustomRules((rules) => {
const next = rules.map((r) => (r.id === id ? { ...r, ...patch } : r));
queueRulesSave(next);
return next;
});
}
function addRule() {
setCustomRules((rules) => {
const next = [emptyRule(), ...rules];
queueRulesSave(next);
return next;
});
}
function addRuleFromTemplate(tpl) {
setCustomRules((rules) => {
const next = [tpl, ...rules];
queueRulesSave(next);
return next;
});
}
function removeRule(id) {
setCustomRules((rules) => {
const next = rules.filter((r) => r.id !== id);
queueRulesSave(next);
return next;
});
}
function reorderRules(next) {
setCustomRules(next);
queueRulesSave(next);
}
// === Computed ===
const status = useMemo(() => {
if (applyStatus === 'applying') return 'applying';
if (applyStatus === 'error') return 'error';
if (state?.singboxRunning) return 'running';
if (state?.configExists) return 'stopped';
return 'no_config';
}, [state, applyStatus]);
const activeServer = useMemo(
() => servers.find((s) => s.tag === state?.selectedTag) || null,
[servers, state?.selectedTag],
);
const isClientMode = state?.mode === 'client';
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
const dirtyDevices = Boolean(
state?.devicesUpdatedAt &&
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
);
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
const dirtyRouting = dirtyRules || dirtyDevices;
const dirty = dirtyRouting || dirtyServer;
const sidebarBadges = {
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
};
// === Render ===
if (!state) return <div className="app-loading">VPN</div>;
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
return (
<div className={`app${isClientMode ? ' client-app' : ''}`}>
{!isClientMode && (
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
)}
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
<div className="app-body client-mode">
<main className="app-main">
{(page === 'overview' || isClientMode) && (
isClientMode ? (
<ClientOverviewPage
state={state}
status={status}
activeServer={activeServer}
busy={busy}
state={previewReady ? {
...state,
mode: 'client',
hasSubscription: true,
subscriptionHost: 'harbor.example',
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
proxyPort: 8082,
} : state}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
onRestart={restartSingbox}
onStop={() => stopSingbox(false)}
/>
) : (
<OverviewPage
state={state}
status={status}
busy={busy}
onRestart={restartSingbox}
onStop={stopSingbox}
onShowConfig={() => setConfigOpen(true)}
onNav={navigate}
onBypassToggle={toggleBypass}
onFlushDirectCache={flushDirectCache}
/>
)
)}
{page === 'servers' && !isClientMode && (
<ServersPage
state={state}
servers={servers}
selectedTag={selectedTag}
setSelectedTag={setSelectedTag}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onApply={applyServer}
onRollback={rollback}
pings={pings}
setPings={setPings}
pushToast={pushToast}
/>
)}
{page === 'routing' && !isClientMode && (
<RoutingPage
rules={customRules}
saveStatus={rulesSaveStatus}
busy={busy}
onAdd={addRule}
onAddTemplate={addRuleFromTemplate}
onUpdate={updateRule}
onRemove={removeRule}
onSaveNow={saveRulesNow}
onReorder={reorderRules}
devicesConfig={devicesConfig}
onUpdateDeviceDefaults={updateDeviceDefaults}
onAddDevice={addDevice}
onUpdateDevice={updateDevice}
onRemoveDevice={removeDevice}
/>
)}
{page === 'logs' && !isClientMode && <LogsPage devices={devicesConfig.devices} />}
{page === 'settings' && !isClientMode && (
<SettingsPage
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
servers={previewReady ? [{
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onShowConfig={() => setConfigOpen(true)}
onClearConfig={clearConfig}
pushToast={pushToast}
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
onSaveRouteRules={(rules, expectedRevision) => run(
'routeRules',
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onDismissError={() => setError(null)}
/>
)}
{/* Sticky bar — для routing/servers */}
{(page === 'routing' && dirtyRouting) && (
<div className="sticky-bar">
<div className="flex">
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
<strong>
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
</strong>
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
</div>
<div className="btn-group">
{rulesSaveStatus !== 'saved' && (
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
)}
{state?.selectedTag && (
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
Применить config
</button>
)}
</div>
</div>
)}
{(page === 'servers' && dirtyServer) && (
<div className="sticky-bar">
<div className="flex">
<span className="dot warning" />
<strong>Сервер не применён</strong>
<small className="muted">Выбран: {pendingTag}</small>
</div>
<div className="btn-group">
<button className="btn btn-ghost sm" onClick={() => setPendingTag(state?.selectedTag || '')}>Отменить</button>
<button className="btn btn-primary sm" onClick={() => applyServer(pendingTag)} disabled={busy}>
Применить
</button>
</div>
</div>
)}
</main>
{!isClientMode && (
<StatusPane
state={state}
busy={busy}
onStop={stopSingbox}
onRestart={restartSingbox}
onShowConfig={() => setConfigOpen(true)}
/>
)}
</div>
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
<Toasts items={toasts} onDismiss={dismissToast} />
{rollbackOffer && (
<div className="toasts">
<div className="toast warning">
<span className="dot warning" style={{ marginTop: 4 }} />
<div className="body">
<strong>Сервер применён</strong>
<small>Можно откатиться к «{rollbackOffer.to}»</small>
<button className="btn btn-link" onClick={rollback} style={{ padding: 0, marginTop: 4 }}>
Откатить
</button>
</div>
<button onClick={() => setRollbackOffer(null)}>×</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,113 +1,92 @@
async function request(url, options = {}) {
const response = await fetch(url, {
import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
export class HarborApiError extends Error {
constructor(payload = {}, status = 0) {
const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
const definition = errorDefinition(code);
super(definition.message);
this.name = 'HarborApiError';
this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = payload.details;
this.correlationId = payload.correlationId
|| globalThis.crypto?.randomUUID?.()
|| new Date().toISOString();
}
}
export async function request(url, options = {}, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
...options,
headers: {
"content-type": "application/json",
'content-type': 'application/json',
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok || (data && data.success === false)) {
throw new Error(
data?.error || `Запрос ${url} завершился ошибкой ${response.status}`,
);
} catch (error) {
if (error?.name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
if (!response.ok || data?.success === false) {
const payload = data?.error && typeof data.error === 'object'
? data.error
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(payload, response.status);
}
return data;
}
export const api = {
state: () => request("/api/state"),
config: () => request("/api/config"),
rules: {
get: () => request("/api/rules"),
save: (rules) =>
request("/api/rules", { method: "PUT", body: JSON.stringify({ rules }) }),
conflicts: () => request("/api/rules/conflicts"),
},
deviceRules: {
get: () => request("/api/device-rules"),
save: (deviceRules) =>
request("/api/device-rules", {
method: "PUT",
body: JSON.stringify({ deviceRules }),
}),
},
devices: {
get: () => request("/api/devices"),
save: (devicesConfig) =>
request("/api/devices", {
method: "PUT",
body: JSON.stringify(devicesConfig),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>
request("/api/rule-sets", {
method: "PUT",
body: JSON.stringify({ ruleSets }),
}),
lookup: (tag, url) =>
request("/api/rule-sets/lookup", {
method: "POST",
body: JSON.stringify({ tag, url }),
}),
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
},
state: () => request('/api/state'),
version: () => request('/api/version'),
subscription: {
fetch: (url) =>
request("/api/subscription/fetch", {
method: "POST",
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
forget: () => request("/api/subscription", { method: "DELETE" }),
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (selectedTag) =>
request("/api/apply", {
method: "POST",
body: JSON.stringify({ selectedTag }),
apply: (serverId) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}),
rollback: () => request("/api/apply/rollback", { method: "POST" }),
singbox: {
stop: () => request("/api/singbox/stop", { method: "POST" }),
restart: () => request("/api/singbox/restart", { method: "POST" }),
clear: () => request("/api/singbox/clear", { method: "POST" }),
},
servers: {
ping: (host, port) =>
request("/api/servers/ping", {
method: "POST",
body: JSON.stringify({ host, port }),
}),
pingAll: () => request("/api/servers/ping-all", { method: "POST" }),
},
bypass: (enabled) =>
request("/api/bypass", {
method: "POST",
gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
directCache: {
get: () => request("/api/direct-cache"),
flush: () => request("/api/direct-cache", { method: "DELETE" }),
},
route: {
check: ({ host, ip, port, network, sourceIp, inbound }) =>
request("/api/route/check", {
method: "POST",
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
routeRules: {
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }),
}),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds) => request('/api/servers/ping-all', {
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
configValidate: () => request("/api/config/validate", { method: "POST" }),
};

View File

@@ -1,61 +0,0 @@
import React, { useState } from 'react';
/**
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
* Невалидные элементы помечаются красным.
*/
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
const [draft, setDraft] = useState('');
function commit(text) {
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
if (!parts.length) return;
const next = Array.from(new Set([...value, ...parts]));
onChange(next);
setDraft('');
}
function remove(item) {
onChange(value.filter((v) => v !== item));
}
function onKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
}
function onPaste(e) {
const text = e.clipboardData.getData('text');
if (text && splitter.test(text)) {
e.preventDefault();
commit(text);
}
}
return (
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
{value.map((item) => {
const invalid = validate ? !validate(item) : false;
return (
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
{item}
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
</span>
);
})}
<input
className="chip-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onBlur={() => draft.trim() && commit(draft)}
placeholder={value.length ? '' : placeholder}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
export function ConfigViewer({ open, onClose }) {
const [config, setConfig] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
if (!open) return;
let cancelled = false;
setConfig(null);
setError('');
api.config()
.then((data) => { if (!cancelled) setConfig(data.config); })
.catch((err) => { if (!cancelled) setError(err.message); });
return () => { cancelled = true; };
}, [open]);
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
const highlighted = useMemo(() => {
if (!search || !text) return text;
try {
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
return text.split(re);
} catch {
return text;
}
}, [text, search]);
if (!open) return null;
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
function download() {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sing-box-config.json';
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3>sing-box config</h3>
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
</div>
<div className="btn-group">
<input
className="input"
placeholder="Поиск…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 160 }}
/>
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
</div>
</div>
<div className="modal-body">
{error && <div className="conflict-banner danger">{error}</div>}
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
{config && (
<pre className="config-view">
{Array.isArray(highlighted)
? highlighted.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
</React.Fragment>
))
: text}
</pre>
)}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -1,337 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { formatTime } from '../utils/format.js';
const MAX_ENTRIES = 800;
const MAX_TRAFFIC = 500;
const GROUP_WINDOW_MS = 30_000;
function normalizeLine(line) {
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
}
function groupEntries(entries) {
const out = [];
for (const e of entries) {
const key = `${e.level}|${normalizeLine(e.line)}`;
const last = out[out.length - 1];
const ts = new Date(e.ts).getTime();
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
last.count += 1;
last._lastTs = ts;
last.lastTs = e.ts;
} else {
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
return out;
}
const CATEGORY_BADGE = {
direct: { cls: 'success', label: 'direct' },
vpn: { cls: 'info', label: 'VPN' },
block: { cls: 'danger', label: 'block' },
other: { cls: '', label: 'other' },
};
function getDeviceName(sourceIp, devices) {
if (!sourceIp || !devices?.length) return null;
for (const d of devices) {
if (d.enabled === false) continue;
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
if (plain === sourceIp) return d.name;
}
return null;
}
function groupTraffic(list, sortBy = 'time') {
const map = new Map();
for (const e of list) {
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
const ts = new Date(e.ts).getTime();
if (map.has(key)) {
const g = map.get(key);
g.count++;
g._lastTs = ts;
g.lastTs = e.ts;
} else {
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
const arr = Array.from(map.values());
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
return arr.sort((a, b) => b._lastTs - a._lastTs);
}
function TrafficTab({ devices = [] }) {
const [traffic, setTraffic] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
const [search, setSearch] = useState('');
const [grouped, setGrouped] = useState(true);
const [sortBy, setSortBy] = useState('count'); // time | count
const [autoscroll, setAutoscroll] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/traffic/stream');
source.onmessage = (ev) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(ev.data);
setTraffic((prev) => {
const next = [...prev, entry];
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = traffic;
if (filter !== 'all') list = list.filter((e) => e.category === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) =>
e.host?.toLowerCase().includes(s) ||
String(e.port || '').includes(s) ||
e.outbound?.toLowerCase().includes(s) ||
e.matchedRule?.toLowerCase().includes(s) ||
e.sourceIp?.toLowerCase().includes(s) ||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
);
}
return grouped ? groupTraffic(list, sortBy) : list;
}, [traffic, filter, search, grouped, sortBy, devices]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
const counts = useMemo(() => {
const c = { direct: 0, vpn: 0, block: 0 };
for (const e of traffic) if (e.category in c) c[e.category]++;
return c;
}, [traffic]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<input
className="input"
placeholder="Поиск: host, порт, правило…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 180 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все ({traffic.length})</option>
<option value="direct">direct ({counts.direct})</option>
<option value="vpn">VPN ({counts.vpn})</option>
<option value="block">block ({counts.block})</option>
</select>
<label className="checkbox">
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
Группировать
</label>
{grouped && (
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
<option value="count">По частоте</option>
<option value="time">По времени</option>
</select>
)}
<label className="checkbox">
<input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} />
Автоскролл
</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>
{paused ? '▶ Продолжить' : '⏸ Пауза'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
>
Очистить
</button>
</div>
{traffic.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
Ожидаем трафик Убедитесь что sing-box запущен и уровень логов не выше INFO.
</div>
) : (
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr>
<th style={{ width: 70 }}>Время</th>
<th style={{ width: 70 }}>Туннель</th>
<th style={{ width: 110 }}>Устройство</th>
<th>Хост / IP</th>
<th style={{ width: 55 }}>Порт</th>
<th>Правило</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{filtered.map((e, i) => {
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
const deviceName = getDeviceName(e.sourceIp, devices);
return (
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
<td>
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
</td>
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
{deviceName
? <span style={{ fontSize: 11 }}>{deviceName}</span>
: e.sourceIp
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
<td className="muted text-mono">{e.port || '—'}</td>
<td>
{e.matchedRule
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
: <span className="muted" style={{ fontSize: 11 }}></span>}
</td>
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
{e.count > 1 && <span className="repeat">×{e.count}</span>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export function LogsPage({ devices = [] }) {
const [tab, setTab] = useState('traffic'); // traffic | logs
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [autoscroll, setAutoscroll] = useState(true);
const [grouped, setGrouped] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/logs/stream');
source.onmessage = (event) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(event.data);
setEntries((prev) => {
const next = [...prev, entry];
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = entries;
if (filter !== 'all') list = list.filter((e) => e.level === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
}
return grouped ? groupEntries(list) : list;
}, [entries, filter, search, grouped]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
function copy(text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
<div className="card-header">
<h2>Логи sing-box</h2>
<div className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
</div>
</div>
{tab === 'traffic' && <TrafficTab devices={devices} />}
{tab === 'logs' && (
<>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тексту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все уровни</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="debug">debug</option>
</select>
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
</div>
<div ref={containerRef} className="logs-stream">
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
{filtered.map((entry, index) => {
const text = normalizeLine(entry.line);
if (grouped && entry.count > 1) {
return (
<div key={`${entry.ts}-${index}`} className="log-group">
<span className="log-time mono">{formatTime(entry.ts)}</span>
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
{entry.level}
</span>
<span className="log-text">{text}</span>
<span className="repeat">×{entry.count}</span>
</div>
);
}
return (
<div
key={`${entry.ts}-${index}`}
className={`log-line ${entry.level}`}
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
title="Двойной клик — скопировать"
>
<span className="log-time">{formatTime(entry.ts)}</span>
<span className="log-level">{entry.level}</span>
<span className="log-text">{text}</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -1,192 +0,0 @@
import React, { useEffect, useState } from 'react';
import { formatRelative, formatBytes } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
import { api } from '../api.js';
function StatusHero({ state, status }) {
const text = {
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
error: { title: '🔴 Ошибка', kind: 'danger' },
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
}[status];
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
: 'нет данных';
return (
<div className="card">
<div className="flex-between">
<div>
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
<small className="muted">
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
</small>
</div>
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
</div>
<div className="divider" />
<div className="grid-3">
<div>
<small className="muted">Активный сервер</small>
<div style={{ marginTop: 4 }}>
{state?.selectedTag ? (
<>
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
</>
) : <span className="muted">Не выбран</span>}
</div>
</div>
<div>
<small className="muted">Трафик</small>
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
</div>
<div>
<small className="muted">Правил маршрутизации</small>
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
</div>
</div>
</div>
);
}
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
return (
<div className="card">
<div className="card-header">
<h3>Быстрые действия</h3>
</div>
<div className="btn-group">
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
Сменить сервер
</button>
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
Перезапустить
</button>
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
Остановить
</button>
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
Показать config
</button>
<button
className={`btn ${state?.bypassMode ? 'btn-warning' : 'btn-ghost'}`}
disabled={busy || !state?.singboxRunning}
onClick={onBypassToggle}
title="Весь трафик напрямую — для диагностики"
>
{state?.bypassMode ? '⚠ Обход правил ВКЛЮЧЁН' : '↗ Весь трафик напрямую'}
</button>
</div>
</div>
);
}
function RecentEvents({ onNav }) {
const [entries, setEntries] = useState([]);
useEffect(() => {
let cancelled = false;
fetch('/api/logs')
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const list = (data.logs || []).slice(-15).reverse();
setEntries(list);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
return (
<div className="card">
<div className="card-header">
<h3>Последние события</h3>
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи </button>
</div>
{entries.length === 0 ? (
<small className="muted">Пока ничего нет.</small>
) : (
<div className="events-list">
{entries.slice(0, 8).map((e, i) => {
const dot = e.level === 'error' ? 'danger'
: e.level === 'warning' ? 'warning'
: 'success';
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
return (
<div key={`${e.ts}-${i}`} className="event-row">
<span className={`dot ${dot}`} />
<span className="event-time">{time}</span>
<span className="text-truncate" title={e.line}>{e.line}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
const rules = state?.customRules || [];
const enabled = rules.filter((r) => r.enabled).length;
const cacheCount = state?.directBypassCount || 0;
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn';
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<h3>Маршрутизация</h3>
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила </button>
</div>
<div className="kv-list">
<div className="row"><span className="key">Private IP</span><span className="val text-success"> direct</span></div>
{state?.routingRuDirect && (
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success"> direct</span></div>
)}
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Transparent fallback</span><span className="val"> {transparentDefault}</span></div>
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning"> {proxyDefault}</span></div>
{cacheAvailable && (
<div className="row">
<span className="key">Direct bypass cache</span>
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-success">{cacheCount} IP</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
сбросить
</button>
</span>
</div>
)}
</div>
</div>
);
}
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
return (
<div className="section-stack">
{state?.bypassMode && (
<div className="alert alert-warning" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<strong> Режим обхода правил активен</strong>
<span className="muted"> весь трафик идёт напрямую, VPN-правила не применяются.</span>
<button className="btn btn-sm btn-warning" style={{ marginLeft: 'auto' }} onClick={onBypassToggle}>
Отключить
</button>
</div>
)}
<StatusHero state={state} status={status} />
<div className="grid-2">
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
</div>
<RecentEvents onNav={onNav} />
</div>
);
}

View File

@@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { api } from '../api.js';
export function RouteChecker() {
const [host, setHost] = useState('');
const [port, setPort] = useState('443');
const [network, setNetwork] = useState('tcp');
const [sourceIp, setSourceIp] = useState('');
const [inbound, setInbound] = useState('tproxy-in');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
async function check() {
setBusy(true);
setError('');
setResult(null);
try {
const data = await api.route.check({
host,
port: port || undefined,
network,
sourceIp: sourceIp || undefined,
inbound,
});
setResult(data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
const r = result?.result;
const kind = r?.outbound?.startsWith('direct') ? 'success'
: r?.outbound === 'block' ? 'danger'
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
: 'warning';
return (
<div className="card flat compact">
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
<div className="filter-bar" style={{ marginTop: 12 }}>
<input
className="input"
placeholder="домен или IP (riotgames.com)"
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
style={{ minWidth: 220, flex: 1 }}
/>
<input
className="input"
placeholder="port"
value={port}
onChange={(e) => setPort(e.target.value)}
style={{ width: 90 }}
/>
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input
className="input"
placeholder="source IP"
value={sourceIp}
onChange={(e) => setSourceIp(e.target.value)}
style={{ width: 145 }}
/>
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
<option value="tproxy-in">tproxy-in</option>
<option value="mixed-in">mixed-in</option>
</select>
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div>
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
{r && (
<div className="route-result" style={{ marginTop: 12 }}>
<div className="flex-between">
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
<span className={`badge ${kind}`}> {r.outbound}</span>
</div>
{result.resolvedIp && result.resolvedFrom && (
<small className="muted text-mono">DNS: {result.resolvedFrom} {result.resolvedIp}</small>
)}
<small className="muted">{r.reason}</small>
</div>
)}
</div>
);
}

View File

@@ -1,384 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ruleTemplates } from '../templates/ruleTemplates.js';
import { ruleErrors, hasErrors } from '../utils/validation.js';
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
import { RouteChecker } from './RouteChecker.jsx';
import { api } from '../api.js';
const OUTBOUND_KIND = {
direct: { kind: 'success', label: 'direct' },
vpn: { kind: 'info', label: 'VPN' },
block: { kind: 'danger', label: 'block' },
};
const DEVICE_MODES = {
bypass: { kind: 'warning', label: 'bypass TProxy', hint: 'мимо sing-box; ручной proxy отдельно' },
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
};
function DeviceModeSelect({ value, onChange }) {
return (
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
<option value="bypass">bypass TProxy</option>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="rules">default</option>
<option value="block">block</option>
</select>
);
}
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
const devices = devicesConfig?.devices || [];
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn';
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
return (
<div className="card">
<div className="card-header">
<div>
<h2>Устройства</h2>
<small className="muted">bypass TProxy применяется до sing-box. Остальные режимы fallback после global rules.</small>
</div>
<div className="btn-group">
<label className="field" style={{ minWidth: 180, margin: 0 }}>
<span className="field-label">Transparent default</span>
<select
className="select sm"
value={defaultTransparentMode}
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
>
<option value="direct">direct</option>
<option value="vpn">VPN</option>
<option value="block">block</option>
</select>
</label>
<label className="field" style={{ minWidth: 160, margin: 0 }}>
<span className="field-label">Proxy default</span>
<select
className="select sm"
value={proxyDefaultMode}
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
>
<option value="vpn">VPN</option>
<option value="direct">direct</option>
<option value="block">block</option>
</select>
</label>
<button className="btn btn-primary sm" onClick={onAdd}>
+ Добавить устройство
</button>
</div>
</div>
{devices.length === 0 ? (
<div className="empty-state" style={{ padding: '16px 0' }}>
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Название</th>
<th style={{ width: 170 }}>IP</th>
<th style={{ width: 150 }}>MAC</th>
<th style={{ width: 150 }}>Mode</th>
<th>Поведение</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{devices.map((dev) => {
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
return (
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
<td>
<input
type="checkbox"
checked={dev.enabled !== false}
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
</td>
<td>
<input
className="input sm"
value={dev.name || ''}
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
placeholder="Название устройства"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.ip || ''}
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
placeholder="192.168.1.50"
style={{ width: '100%', minWidth: 140 }}
/>
</td>
<td>
<input
className="input sm"
value={dev.mac || ''}
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
placeholder="опционально"
style={{ width: '100%', minWidth: 120 }}
/>
</td>
<td>
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
</td>
<td>
<span className={`badge ${mode.kind}`}>{mode.label}</span>
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
</td>
<td>
<button
className="btn btn-ghost sm"
onClick={() => {
if (confirm('Удалить устройство?')) onRemove(dev.id);
}}
>×</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function summary(rule) {
const parts = [];
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
if (totalDomains) parts.push(`${totalDomains} дом.`);
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
if (rule.networks?.length) parts.push(rule.networks.join('/'));
return parts.join(' · ') || '—';
}
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
return (
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
<td style={{ width: 30 }}>
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить"></span>
</td>
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
<td>
<div className="flex" style={{ alignItems: 'center' }}>
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
{rule.name || '(без названия)'}
</button>
{invalid && <span className="badge danger">ошибки</span>}
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
</div>
</td>
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
</div>
</td>
</tr>
);
}
function TemplatesModal({ open, onClose, onAdd }) {
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h3>Шаблоны маршрутизации</h3>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<div className="modal-body">
<div className="template-grid">
{ruleTemplates.map((tpl) => (
<div key={tpl.key} className="template-card">
<h4>{tpl.label}</h4>
<small>{tpl.description}</small>
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
+ Добавить
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutingPage({
rules, saveStatus, busy,
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
}) {
const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false);
const [conflicts, setConflicts] = useState([]);
const [availableRuleSets, setAvailableRuleSets] = useState([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
const t = setTimeout(() => {
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
}, 600);
return () => { cancelled = true; clearTimeout(t); };
}, [rules]);
const conflictsByRuleId = useMemo(() => {
const map = {};
for (const c of conflicts) map[c.ruleId] = c;
return map;
}, [conflicts]);
function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = rules.findIndex((r) => r.id === active.id);
const newIndex = rules.findIndex((r) => r.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(rules, oldIndex, newIndex));
}
const editing = rules.find((r) => r.id === editingId) || null;
return (
<div className="section-stack">
<RouteChecker />
<DevicesCard
devicesConfig={devicesConfig}
onDefaultsChange={onUpdateDeviceDefaults}
onAdd={onAddDevice}
onUpdate={onUpdateDevice}
onRemove={onRemoveDevice}
/>
<div className="card">
<div className="card-header">
<h2>Правила маршрутизации</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
+ Добавить
</button>
</div>
</div>
{conflicts.length > 0 && (
<div className="conflict-banner" style={{ marginBottom: 12 }}>
<span></span>
<div>
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
<div style={{ marginTop: 4 }}>
{conflicts.slice(0, 3).map((c, i) => (
<div key={i} style={{ fontSize: 12 }}>
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
</div>
))}
</div>
</div>
</div>
)}
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
Применяются <strong>сверху вниз</strong>. Перетаскивай чтобы менять порядок.
</small>
{rules.length === 0 ? (
<div className="empty-state">
<h3>Правил пока нет</h3>
<p>Добавь шаблон (например «League of Legends direct») или создай пустое правило.</p>
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
Открыть шаблоны
</button>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>Правило</th>
<th>Outbound</th>
<th>Условия</th>
<th></th>
</tr>
</thead>
<tbody>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
{rules.map((rule, i) => (
<SortableRuleRow
key={rule.id}
rule={rule}
index={i}
total={rules.length}
onEdit={setEditingId}
onUpdate={onUpdate}
onRemove={onRemove}
conflict={conflictsByRuleId[rule.id]}
/>
))}
</SortableContext>
</DndContext>
</tbody>
</table>
</div>
)}
</div>
<RuleEditorDrawer
rule={editing}
onUpdate={onUpdate}
onClose={() => setEditingId(null)}
onRemove={onRemove}
availableRuleSets={availableRuleSets}
/>
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
</div>
);
}

View File

@@ -1,453 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChipsInput } from './ChipsInput.jsx';
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
import { api } from '../api.js';
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
const RULE_SET_TAG = /^[a-z0-9][a-z0-9_.@!-]*$/i;
const validDomain = (v) => DOMAIN.test(String(v).trim());
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
const RS_PAGE_SIZE = 100;
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
const [status, setStatus] = useState('loading');
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
api.ruleSets.lookup(tag, url)
.then((d) => { setData(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}, [tag, url]);
useEffect(() => {
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [status]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, typeFilter]);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
function addEntry(entry) {
const val = entry.value;
switch (entry.type) {
case 'domain': {
const cur = new Set(rule.domains || []);
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
break;
}
case 'suffix': {
const cur = new Set(rule.domainSuffixes || []);
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
break;
}
case 'keyword': {
const cur = new Set(rule.domainKeywords || []);
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
break;
}
case 'cidr': {
const cur = new Set(rule.ipCidrs || []);
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
break;
}
default: break;
}
}
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
const addedValues = useMemo(() => new Set([
...(rule.domains || []),
...(rule.domainSuffixes || []),
...(rule.domainKeywords || []),
...(rule.ipCidrs || []),
]), [rule]);
return (
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
<div
className="modal lg"
style={{ maxWidth: 680, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">Кликните запись чтобы добавить в правило</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{status === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{status === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{status === 'done' && data && (
<>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
</div>
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(RS_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '6px 0' }}>
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
<span className="muted" style={{ marginLeft: 12 }}> нажмите строку чтобы добавить в правило</span>
</div>
<table className="table" style={{ fontSize: 12 }}>
<thead>
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => {
const already = addedValues.has(e.value);
return (
<tr
key={i}
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
onClick={() => !already && addEntry(e)}
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
>
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
</tr>
);
})}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '10px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 12 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
const [view, setView] = useState(mode); // builder | json
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
const [jsonError, setJsonError] = useState('');
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
const errors = ruleErrors(rule);
// Индекс URL по тегу из доступных rule-sets
const ruleSetUrlMap = useMemo(() => {
const map = {};
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
return map;
}, [availableRuleSets]);
function patch(p) {
onUpdate(rule.id, p);
}
function applyJson() {
try {
const parsed = JSON.parse(jsonDraft);
onUpdate(rule.id, { ...parsed, id: rule.id });
setJsonError('');
} catch (err) {
setJsonError(err.message);
}
}
return (
<div className="drawer-body">
<div className="tabs">
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
</div>
{view === 'builder' ? (
<>
<div className="field">
<span className="field-label">Название</span>
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<div className="field-row">
<div className="field">
<span className="field-label">Outbound</span>
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
<option value="direct">direct (напрямую)</option>
<option value="vpn">vpn (через выбранный сервер)</option>
<option value="block">block (заблокировать)</option>
</select>
</div>
<div className="field">
<span className="field-label">Состояние</span>
<label className="checkbox">
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => patch({ enabled: e.target.checked })}
/>
Правило включено
</label>
</div>
</div>
<div className="field">
<span className="field-label">Rule-sets (geo-базы)</span>
<ChipsInput
value={rule.ruleSets || []}
onChange={(v) => patch({ ruleSets: v })}
placeholder="geosite-runet"
validate={validRuleSetTag}
/>
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
{(rule.ruleSets || []).length > 0 && (
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{(rule.ruleSets || []).map((tag) => {
const url = ruleSetUrlMap[tag];
return url ? (
<button
key={tag}
className="btn btn-ghost sm"
style={{ padding: '0 6px', fontSize: 11 }}
onClick={() => setBrowseTag({ tag, url })}
title={`Просмотреть содержимое ${tag}`}
>
🔍 {tag}
</button>
) : null;
})}
</div>
)}
{availableRuleSets.length > 0 && (
<div className="field-hint">
Доступны:{' '}
{availableRuleSets.map((rs) => (
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
<button
className="btn btn-ghost sm"
style={{ padding: '0 6px', marginRight: 2 }}
onClick={() => {
const current = new Set(rule.ruleSets || []);
if (!current.has(rs.tag)) {
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
}
}}
>
+ {rs.tag}
</button>
<button
className="btn btn-ghost sm"
style={{ padding: '0 4px', fontSize: 12 }}
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
title="Просмотреть содержимое"
>
🔍
</button>
</span>
))}
</div>
)}
{availableRuleSets.length === 0 && (
<span className="field-hint">
Настройте rule-sets в Настройках, затем вводите их теги здесь
</span>
)}
</div>
<div className="field">
<span className="field-label">Домены (точное совпадение)</span>
<ChipsInput
value={rule.domains || []}
onChange={(v) => patch({ domains: v })}
placeholder="riotgames.com"
validate={validDomain}
/>
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Суффиксы доменов</span>
<ChipsInput
value={rule.domainSuffixes || []}
onChange={(v) => patch({ domainSuffixes: v })}
placeholder="riotcdn.net"
validate={validDomain}
/>
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">IP / CIDR</span>
<ChipsInput
value={rule.ipCidrs || []}
onChange={(v) => patch({ ipCidrs: v })}
placeholder="104.160.128.0/19"
validate={isValidCidr}
/>
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
<ChipsInput
value={rule.ports || []}
onChange={(v) => patch({ ports: v })}
placeholder="443"
validate={(p) => {
const s = String(p);
if (s.includes('-')) {
const [a, b] = s.split('-');
return isValidPort(a) && isValidPort(b);
}
return isValidPort(p);
}}
/>
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Протоколы</span>
<div className="flex">
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('tcp') : set.delete('tcp');
patch({ networks: Array.from(set) });
}}
/>
TCP
</label>
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('udp') : set.delete('udp');
patch({ networks: Array.from(set) });
}}
/>
UDP
</label>
<span className="field-hint">Если ничего оба</span>
</div>
</div>
</>
) : (
<>
<div className="field">
<span className="field-label">Сырой JSON правила</span>
<textarea
className="textarea"
style={{ minHeight: 320 }}
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
/>
{jsonError && <span className="field-error">{jsonError}</span>}
</div>
<div className="btn-group">
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
</div>
</>
)}
{browseTag && (
<RuleSetBrowseModal
tag={browseTag.tag}
url={browseTag.url}
rule={rule}
onPatch={(p) => patch(p)}
onClose={() => setBrowseTag(null)}
/>
)}
</div>
);
}
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
if (!rule) return null;
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className="drawer">
<div className="drawer-head">
<div>
<h3>Редактирование правила</h3>
<small className="muted">{rule.name || '(без названия)'}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
<div className="drawer-foot">
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
<div className="btn-group">
{invalid && <span className="badge danger">Есть ошибки</span>}
<button className="btn btn-primary" onClick={onClose}>Готово</button>
</div>
</div>
</aside>
</>
);
}

View File

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

View File

@@ -1,210 +0,0 @@
import React, { useMemo, useState } from 'react';
import { api } from '../api.js';
import { flagFor } from '../utils/country.js';
import { formatRelative } from '../utils/format.js';
function PingCell({ ping }) {
if (!ping) return <span className="muted"></span>;
if (ping.checking) return <span className="badge neutral pulse">проверяем</span>;
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
const ms = ping.latency;
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
return <span className={`badge ${kind}`}>{ms} ms</span>;
}
function StatusCell({ ping }) {
if (!ping) return <span className="badge neutral">unknown</span>;
if (ping.checking) return <span className="badge neutral pulse"></span>;
return ping.ok
? <span className="badge success"> online</span>
: <span className="badge danger"> offline</span>;
}
export function ServersPage({
state,
servers,
selectedTag,
setSelectedTag,
pendingTag,
setPendingTag,
busy,
onApply,
onRollback,
pings,
setPings,
pushToast,
}) {
const [filter, setFilter] = useState('all'); // all | online
const [search, setSearch] = useState('');
async function pingOne(server) {
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
try {
const res = await api.servers.ping(server.server, server.server_port);
setPings((prev) => ({
...prev,
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
}));
} catch (err) {
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
}
}
async function pingAll() {
setPings((prev) => {
const next = { ...prev };
for (const s of servers) next[s.tag] = { checking: true };
return next;
});
try {
const res = await api.servers.pingAll();
const map = {};
for (const r of res.results || []) {
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
}
setPings((prev) => ({ ...prev, ...map }));
pushToast({ kind: 'success', title: 'Пинг завершён' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
}
}
const filtered = useMemo(() => {
return servers.filter((s) => {
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
return false;
}
if (filter === 'online' && !pings[s.tag]?.ok) return false;
return true;
});
}, [servers, search, filter, pings]);
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
const pendingServer = servers.find((s) => s.tag === pendingTag);
if (!servers.length) {
return (
<div className="card">
<div className="empty-state">
<h3>Серверы ещё не загружены</h3>
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
</div>
</div>
);
}
return (
<div className="section-stack">
{pendingDifferent && (
<div className="card" style={{ borderColor: 'var(--warning)' }}>
<div className="flex-between">
<div>
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
</div>
</div>
<div className="btn-group">
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
Отменить
</button>
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
Применить изменения
</button>
</div>
</div>
</div>
)}
<div className="card">
<div className="card-header">
<h2>Серверы ({servers.length})</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
Проверить все
</button>
{state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
Откатить ({state.previousTag})
</button>
)}
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тегу или хосту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все</option>
<option value="online">Только online</option>
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 16 }}></th>
<th>Сервер</th>
<th>Хост</th>
<th>Тип</th>
<th>Ping</th>
<th>Статус</th>
<th style={{ textAlign: 'right' }}>Действие</th>
</tr>
</thead>
<tbody>
{filtered.map((server) => {
const isActive = server.tag === state?.selectedTag;
const isPending = server.tag === pendingTag && !isActive;
const ping = pings[server.tag];
return (
<tr key={server.tag} className={isActive ? 'active' : ''}>
<td>{flagFor(server)}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong>{server.tag}</strong>
{isActive && <span className="badge success">ACTIVE</span>}
{isPending && <span className="badge warning">pending</span>}
</div>
</td>
<td className="text-mono muted">{server.server}:{server.server_port}</td>
<td><span className="badge neutral">{server.type}</span></td>
<td><PingCell ping={ping} /></td>
<td><StatusCell ping={ping} /></td>
<td>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
Ping
</button>
{isActive ? (
<button className="btn btn-secondary sm" disabled>Активен</button>
) : (
<button
className="btn btn-primary sm"
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
disabled={busy}
>
Выбрать
</button>
)}
</div>
</td>
</tr>
);
})}
{!filtered.length && (
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,784 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { formatRelative } from '../utils/format.js';
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
const PAGE_SIZE = 100;
function RuleSetLookupModal({ tag, url, onClose }) {
const [state, setState] = useState('idle'); // idle | loading | done | error
const [error, setError] = useState('');
const [data, setData] = useState(null); // { entries, stats, cachedAt }
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('all');
const [page, setPage] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
setState('loading');
api.ruleSets.lookup(tag, url)
.then((res) => { setData(res); setState('done'); })
.catch((err) => { setError(err.message); setState('error'); });
}, [tag, url]);
useEffect(() => {
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
}, [state]);
const filtered = useMemo(() => {
if (!data?.entries) return [];
const q = search.trim().toLowerCase();
return data.entries.filter((e) => {
if (filterType !== 'all' && e.type !== filterType) return false;
if (!q) return true;
return e.value.toLowerCase().includes(q);
});
}, [data, search, filterType]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function onSearchChange(v) { setSearch(v); setPage(0); }
function onTypeChange(v) { setFilterType(v); setPage(0); }
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
<small className="muted">{url}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
{state === 'loading' && (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
Скачивание и декомпиляция<br />
<small>Может занять 1030 секунд</small>
</div>
)}
{state === 'error' && (
<div style={{ padding: 24 }}>
<div className="conflict-banner danger"><span></span><div>{error}</div></div>
</div>
)}
{state === 'done' && data && (
<>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
кеш: {formatRelative(data.cachedAt)}
</span>
</div>
</div>
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
<input
ref={inputRef}
className="input"
style={{ flex: 1 }}
placeholder="Поиск: youtube, 149.154, .ru…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
<option value="all">Все типы</option>
{Object.entries(TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
{filtered.length === 0 ? (
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
) : (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
</div>
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
</thead>
<tbody>
{pageItems.map((e, i) => (
<tr key={i}>
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}></button>
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}></button>
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
</div>
)}
</>
)}
</div>
</>
)}
</div>
</div>
);
}
// Каталог готовых rule-set источников для sing-box (.srs формат)
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
const RULE_SET_CATALOG = [
{
tag: 'geosite-runet',
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
source: 'runetfreedom',
category: 'RU',
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
builtIn: false,
},
{
tag: 'geoip-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
source: 'SagerNet/sing-geoip',
category: 'RU',
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
use: 'direct — российские сервисы без VPN.',
builtIn: true,
},
{
tag: 'geosite-category-ru',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
source: 'SagerNet/sing-geosite',
category: 'RU',
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
builtIn: true,
},
{
tag: 'geosite-google',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
builtIn: false,
},
{
tag: 'geosite-youtube',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
use: 'vpn — для разблокировки YouTube.',
builtIn: false,
},
{
tag: 'geosite-telegram',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
builtIn: false,
},
{
tag: 'geosite-openai',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
builtIn: false,
},
{
tag: 'geosite-apple',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
source: 'SagerNet/sing-geosite',
category: 'Сервисы',
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
builtIn: false,
},
{
tag: 'geosite-github',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
source: 'SagerNet/sing-geosite',
category: 'Разработка',
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
use: 'vpn — если GitHub замедлен или заблокирован.',
builtIn: false,
},
{
tag: 'geosite-category-ads-all',
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
source: 'SagerNet/sing-geosite',
category: 'Блокировка',
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
builtIn: false,
},
];
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
const [editing, setEditing] = useState(!state?.hasSubscription);
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
const masked = state?.hasSubscription && !editing;
return (
<div className="card">
<div className="card-header">
<h2>Подписка</h2>
{state?.hasSubscription && (
<span className="badge success"> активна</span>
)}
</div>
{masked ? (
<div className="kv-list">
<div className="row">
<span className="key">URL</span>
<span className="val text-mono">{state.subscriptionHost}</span>
</div>
<div className="row">
<span className="key">Серверов</span>
<span className="val">{state.servers?.length || 0}</span>
</div>
<div className="row">
<span className="key">Загружено</span>
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
</div>
</div>
) : (
<div className="field">
<span className="field-label">Subscription URL</span>
<div className="subscription-input">
<input
className="input"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder="https://provider.example/sub/..."
/>
</div>
</div>
)}
<div className="btn-group" style={{ marginTop: 16 }}>
{masked ? (
<>
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}> Обновить серверы</button>
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
</>
) : (
<>
<button
className="btn btn-primary"
onClick={async () => { await onFetch(); setEditing(false); }}
disabled={busy || !subscriptionUrl}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
</button>
{state?.hasSubscription && (
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
)}
</>
)}
</div>
</div>
);
}
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
const [validation, setValidation] = useState(null);
const [validating, setValidating] = useState(false);
async function validate() {
setValidating(true);
try {
const data = await api.configValidate();
setValidation(data);
pushToast({
kind: data.valid ? 'success' : 'danger',
title: data.valid ? 'Config валиден' : 'Config невалиден',
message: data.error || data.note,
});
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
} finally {
setValidating(false);
}
}
return (
<div className="card">
<div className="card-header">
<h2>sing-box config</h2>
{validation && (
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
{validation.valid ? '✓ валиден' : '✗ ошибка'}
</span>
)}
</div>
<div className="kv-list">
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
</div>
<div className="btn-group" style={{ marginTop: 16 }}>
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
{validating ? 'Проверяем…' : '✓ Валидировать'}
</button>
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
Сбросить config
</button>
</div>
{validation && !validation.valid && validation.error && (
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
<span></span><div>{validation.error}</div>
</div>
)}
</div>
);
}
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
const [open, setOpen] = useState(false);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1 }}>
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
{entry.builtIn && (
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
)}
</div>
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
</div>
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
<button
className="btn btn-ghost sm"
onClick={() => setOpen((o) => !o)}
title="Примеры и подсказка"
>
{open ? '▲' : '▼'}
</button>
<button
className="btn btn-ghost sm"
onClick={() => onLookup(entry)}
title="Просмотреть содержимое и искать внутри"
>
🔍
</button>
<button
className="btn btn-secondary sm"
disabled={busy || added}
onClick={() => onAdd(entry)}
>
{added ? '✓ добавлен' : '+ Добавить'}
</button>
</div>
</div>
{open && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<span className="muted">Примеры содержимого: </span>
{entry.examples.map((ex, i) => (
<span key={i}>
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
{i < entry.examples.length - 1 ? ' ' : ''}
</span>
))}
</div>
<div style={{ fontSize: 12 }}>
<span className="muted">Рекомендуемый outbound: </span>
<span>{entry.use}</span>
</div>
</div>
)}
</div>
);
}
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState('idle'); // idle | loading | done | error
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
function load() {
if (status !== 'idle') return;
setStatus('loading');
api.ruleSets.sagernetCatalog()
.then((d) => { setCatalog(d); setStatus('done'); })
.catch((err) => { setError(err.message); setStatus('error'); });
}
function toggle() {
if (!open && status === 'idle') load();
setOpen((o) => !o);
}
const results = useMemo(() => {
if (!catalog) return [];
const q = query.trim().toLowerCase();
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
const all = [...gs, ...gi];
if (!q) return all;
return all.filter((item) => item.name.includes(q));
}, [catalog, query, repoFilter]);
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
return (
<div className="card">
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
<h2>Поиск в каталоге SagerNet</h2>
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
{status === 'done' && catalog && (
<span className="badge info" style={{ fontSize: 11 }}>
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
</span>
)}
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
</div>
</div>
{open && (
<>
{status === 'loading' && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
Загрузка списка из GitHub
</div>
)}
{status === 'error' && (
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
<span></span><div>{error}</div>
</div>
)}
{status === 'done' && (
<>
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
Кеш обновляется раз в 24 ч.
</small>
{catalog.fallback && (
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
</div>
)}
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="steam, gaming, netflix, cloudflare…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
<option value="all">geosite + geoip</option>
<option value="geosite">только geosite</option>
<option value="geoip">только geoip</option>
</select>
</div>
{query.trim() === '' ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
Введите запрос покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
</div>
) : results.length === 0 ? (
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
) : (
<table className="table" style={{ fontSize: 13 }}>
<thead>
<tr>
<th style={{ width: 60 }}>Тип</th>
<th>Тег</th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{results.slice(0, 100).map((item) => (
<tr key={item.name}>
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
<td className="text-mono">{item.name}</td>
<td style={{ textAlign: 'right' }}>
{addedTags.has(item.name) ? (
<span className="badge success" style={{ fontSize: 11 }}> добавлен</span>
) : (
<button
className="btn btn-secondary sm"
disabled={busy}
onClick={() => onAdd({ tag: item.name, url: item.url })}
>
+ Добавить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{results.length > 100 && (
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
Показано 100 из {results.length} уточните запрос
</div>
)}
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
</div>
</>
)}
</>
)}
</div>
);
}
function RuleSetsCard({ pushToast }) {
const [ruleSets, setRuleSets] = useState([]);
const [newTag, setNewTag] = useState('');
const [newUrl, setNewUrl] = useState('');
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('Все');
const [lookup, setLookup] = useState(null); // { tag, url }
useEffect(() => {
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
}, []);
async function save(next) {
setBusy(true);
try {
const data = await api.ruleSets.save(next);
setRuleSets(data.ruleSets || []);
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
} finally {
setBusy(false);
}
}
function addNew() {
const tag = newTag.trim();
const url = newUrl.trim();
if (!tag || !url) return;
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
return;
}
if (ruleSets.some((rs) => rs.tag === tag)) {
pushToast({ kind: 'danger', title: 'Тег уже существует' });
return;
}
const next = [...ruleSets, { tag, url }];
setNewTag('');
setNewUrl('');
save(next);
}
function remove(tag) {
save(ruleSets.filter((rs) => rs.tag !== tag));
}
function addFromCatalog(entry) {
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
return;
}
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
}
const q = search.trim().toLowerCase();
const filtered = RULE_SET_CATALOG.filter((entry) => {
if (category !== 'Все' && entry.category !== category) return false;
if (!q) return true;
return (
entry.tag.includes(q) ||
entry.description.toLowerCase().includes(q) ||
entry.source.toLowerCase().includes(q) ||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
);
});
return (
<>
<div className="card">
<div className="card-header">
<h2>Источники (rule-sets)</h2>
</div>
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
</small>
{ruleSets.length > 0 && (
<>
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
<table className="table" style={{ marginBottom: 20 }}>
<thead>
<tr>
<th>Тег</th>
<th>URL</th>
<th></th>
</tr>
</thead>
<tbody>
{ruleSets.map((rs) => (
<tr key={rs.tag}>
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
style={{ flex: 1, minWidth: 180 }}
placeholder="Поиск: telegram, реклама, youtube…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
{filtered.length === 0 && (
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
)}
{filtered.map((entry) => (
<CatalogEntry
key={entry.tag}
entry={entry}
added={ruleSets.some((rs) => rs.tag === entry.tag)}
busy={busy}
onAdd={addFromCatalog}
onLookup={(e) => setLookup(e)}
/>
))}
<div className="field" style={{ marginTop: 16 }}>
<span className="field-label">Добавить свой .srs</span>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
<input
className="input"
style={{ width: 200 }}
placeholder="тег (напр. geosite-custom)"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
/>
<input
className="input"
style={{ flex: 1, minWidth: 200 }}
placeholder="https://…/rule-set.srs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
Добавить
</button>
</div>
</div>
</div>
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
{lookup && (
<RuleSetLookupModal
tag={lookup.tag}
url={lookup.url}
onClose={() => setLookup(null)}
/>
)}
</>
);
}
function PortsCard({ state }) {
const isClient = state?.mode === 'client';
return (
<div className="card">
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</h2></div>
<div className="kv-list">
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
<div className="row"><span className="key">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
{!isClient && <div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>}
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
</div>
</div>
);
}
export function SettingsPage({
state, subscriptionUrl, setSubscriptionUrl, busy,
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
}) {
return (
<div className="section-stack">
<SubscriptionCard
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetch={onFetchSubscription}
onForget={onForgetSubscription}
pushToast={pushToast}
/>
<ConfigCard
state={state}
busy={busy}
onShowConfig={onShowConfig}
onClearConfig={onClearConfig}
pushToast={pushToast}
/>
<RuleSetsCard pushToast={pushToast} />
<PortsCard state={state} />
</div>
);
}

View File

@@ -1,37 +0,0 @@
import React from 'react';
const NAV = [
{ id: 'overview', label: 'Обзор', ico: '◉' },
{ id: 'servers', label: 'Серверы', ico: '⋆' },
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
{ id: 'logs', label: 'Логи', ico: '≡' },
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
export function Sidebar({ active, onChange, badges = {}, mode = 'gateway' }) {
const items = mode === 'client'
? NAV.filter((item) => item.id !== 'routing')
: NAV;
return (
<nav className="sidebar">
{items.map((item) => {
const badge = badges[item.id];
return (
<button
key={item.id}
type="button"
className={`sidebar-item${active === item.id ? ' active' : ''}`}
onClick={() => onChange(item.id)}
>
<span className="ico">{item.ico}</span>
{item.label}
{badge && (
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
)}
</button>
);
})}
</nav>
);
}

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
function StatusRow({ label, value, kind }) {
return (
<div className="row">
<span className="key">{label}</span>
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
</div>
);
}
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
: '—';
let singboxStatus = 'Остановлен';
let singboxKind = 'muted';
if (state?.singboxRunning) {
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
singboxKind = 'success';
} else if (state?.configExists) {
singboxStatus = 'остановлен (конфиг есть)';
singboxKind = 'warning';
}
return (
<aside className="status-pane">
<div className="card compact flat">
<div className="card-header no-margin">
<h3>sing-box</h3>
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
{state?.singboxRunning ? '● online' : '○ offline'}
</span>
</div>
<div className="kv-list" style={{ marginTop: 12 }}>
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
<StatusRow label="Mixed proxy" value={`${state?.proxyBindIp || '0.0.0.0'}:${state?.proxyPort || 8080}`} />
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
<StatusRow label="Трафик" value={traffic} />
<StatusRow
label="Применено"
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
/>
</div>
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<button
className="btn btn-secondary sm block"
disabled={busy || !state?.configExists}
onClick={onRestart}
>
Перезапустить
</button>
<button
className="btn btn-ghost sm block"
disabled={busy || !state?.singboxRunning}
onClick={onStop}
>
Остановить
</button>
<button
className="btn btn-ghost sm block"
disabled={!state?.configExists}
onClick={onShowConfig}
>
Показать config
</button>
</div>
</div>
{state?.appliedHistory?.length > 0 && (
<div className="card compact flat">
<h4 style={{ marginBottom: 8 }}>История применений</h4>
<div className="events-list">
{state.appliedHistory.slice(0, 5).map((h) => (
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
<span className="text-truncate">{h.tag}</span>
<span className="event-time">{formatRelative(h.at)}</span>
</div>
))}
</div>
</div>
)}
</aside>
);
}

View File

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

View File

@@ -1,32 +0,0 @@
import React, { useEffect } from 'react';
export function Toasts({ items, onDismiss }) {
useEffect(() => {
const timers = items.map((t) =>
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
);
return () => timers.forEach((t) => t && clearTimeout(t));
}, [items, onDismiss]);
if (!items.length) return null;
return (
<div className="toasts">
{items.map((t) => (
<div key={t.id} className={`toast ${t.kind || ''}`}>
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
<div className="body">
<strong>{t.title}</strong>
{t.message && <small>{t.message}</small>}
{t.action && (
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
{t.action.label}
</button>
)}
</div>
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
</div>
))}
</div>
);
}

View File

@@ -1,76 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
function StatusBadge({ status }) {
const map = {
running: { dot: 'success', text: 'Работает', cls: '' },
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
error: { dot: 'danger', text: 'Ошибка', cls: '' },
stopped: { dot: '', text: 'Остановлен', cls: '' },
no_config: { dot: '', text: 'Не настроен', cls: '' },
};
const cfg = map[status] || map.stopped;
return (
<span className="flex">
<span className={`dot ${cfg.dot}`} />
<strong>{cfg.text}</strong>
</span>
);
}
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: null;
const isClient = state?.mode === 'client';
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
{state?.mode === 'client' ? 'VPN Client' : 'VPN Gateway'}
</div>
<div className="topbar-status">
<StatusBadge status={status} />
{activeServer && (
<div className="status-text">
<strong>
{flagFor(activeServer)} {activeServer.tag}
</strong>
<small>
{activeServer.server}:{activeServer.server_port}
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
</small>
</div>
)}
{!activeServer && (
<small className="muted">Сервер не выбран</small>
)}
{traffic && <span className="badge neutral">{traffic}</span>}
</div>
<div className="topbar-actions">
{!isClient && dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{!isClient && state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
)}
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Перезапуск
</button>
</div>
</header>
);
}

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,127 +0,0 @@
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
let counter = 0;
function id(prefix) {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
function template(name, outbound, fields) {
return {
id: id("tpl"),
name,
enabled: true,
outbound,
domains: [],
domainSuffixes: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
...fields,
};
}
export const ruleTemplates = [
{
key: "lol-direct",
label: "League of Legends → direct",
description: "Riot/LoL домены и порты — играть напрямую без VPN.",
build: () =>
template("League of Legends", "direct", {
domainSuffixes: [
"leagueoflegends.com",
"riotgames.com",
"riotcdn.net",
"dyn.riotcdn.net",
],
ports: ["5000", "5223", "5222", "8088"],
}),
},
{
key: "discord-direct",
label: "Discord/Vesktop → direct",
description: "Discord voice/video и WebSocket напрямую.",
build: () =>
template("Discord", "direct", {
domainSuffixes: [
"discord.com",
"discord.gg",
"discord.media",
"discordapp.com",
"discordapp.net",
],
ports: ["50000-65535"],
networks: ["udp"],
}),
},
{
key: "telegram-vpn",
label: "Telegram → VPN",
description: "Telegram через выбранный VPN outbound.",
build: () =>
template("Telegram", "vpn", {
domainSuffixes: [
"telegram.org",
"t.me",
"telegram.me",
"telegra.ph",
"tdesktop.com",
],
ipCidrs: [
"149.154.160.0/20",
"91.108.4.0/22",
"91.108.8.0/22",
"91.108.12.0/22",
"91.108.16.0/22",
"91.108.56.0/22",
],
}),
},
{
key: "youtube-vpn",
label: "YouTube → VPN",
description: "YouTube/Google Video через VPN.",
build: () =>
template("YouTube", "vpn", {
domainSuffixes: [
"youtube.com",
"youtu.be",
"ytimg.com",
"googlevideo.com",
"youtube-nocookie.com",
],
}),
},
{
key: "steam-direct",
label: "Steam → direct",
description: "Загрузка/обновления Steam напрямую.",
build: () =>
template("Steam", "direct", {
domainSuffixes: [
"steampowered.com",
"steamcontent.com",
"steamcommunity.com",
"steamserver.net",
"cm.steampowered.com",
],
}),
},
{
key: "ads-block",
label: "Реклама → block",
description: "Базовый набор рекламных доменов — заблокировать.",
build: () =>
template("Реклама (block)", "block", {
domainSuffixes: [
"doubleclick.net",
"googlesyndication.com",
"googleadservices.com",
"adservice.google.com",
"adnxs.com",
],
}),
},
];

View File

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

View File

@@ -1,37 +0,0 @@
// Грубое определение страны по тегу сервера и/или хосту.
// Это эвристика — мы не делаем GeoIP-lookup.
const COUNTRIES = [
{ re: /\b(ru|россия|russia|moscow|spb)\b/i, code: "RU", flag: "🇷🇺" },
{ re: /\b(de|germany|frankfurt|berlin|deu)\b/i, code: "DE", flag: "🇩🇪" },
{ re: /\b(nl|netherlands|amsterdam|holland)\b/i, code: "NL", flag: "🇳🇱" },
{
re: /\b(us|usa|america|new[-_ ]?york|chicago|miami)\b/i,
code: "US",
flag: "🇺🇸",
},
{ re: /\b(uk|britain|london|england)\b/i, code: "GB", flag: "🇬🇧" },
{ re: /\b(fr|france|paris)\b/i, code: "FR", flag: "🇫🇷" },
{ re: /\b(jp|japan|tokyo)\b/i, code: "JP", flag: "🇯🇵" },
{ re: /\b(sg|singapore)\b/i, code: "SG", flag: "🇸🇬" },
{ re: /\b(hk|hongkong|hong[-_ ]?kong)\b/i, code: "HK", flag: "🇭🇰" },
{ re: /\b(fi|finland|helsinki)\b/i, code: "FI", flag: "🇫🇮" },
{ re: /\b(se|sweden|stockholm)\b/i, code: "SE", flag: "🇸🇪" },
{ re: /\b(pl|poland|warsaw)\b/i, code: "PL", flag: "🇵🇱" },
{ re: /\b(tr|turkey|istanbul)\b/i, code: "TR", flag: "🇹🇷" },
{ re: /\b(ua|ukraine|kiev|kyiv)\b/i, code: "UA", flag: "🇺🇦" },
];
export function detectCountry(...inputs) {
const text = inputs.filter(Boolean).join(" ").toLowerCase();
for (const c of COUNTRIES) {
if (c.re.test(text)) return c;
}
return null;
}
export function flagFor(server) {
if (!server) return "";
const detected = detectCountry(server.tag, server.server);
return detected?.flag || "🌐";
}

View File

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

View File

@@ -1,56 +0,0 @@
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
const IPV4 =
/^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
const IPV6 = /^[0-9a-f:]+$/i;
const DOMAIN =
/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
export function invalidCidrs(values) {
return (values || []).filter((value) => !isValidCidr(value));
}
export function isValidCidr(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
const [addr, mask] = trimmed.split("/");
if (!addr) return false;
if (IPV4.test(addr)) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 32;
}
if (IPV6.test(addr) && addr.includes(":")) {
if (mask === undefined) return true;
const m = Number(mask);
return Number.isInteger(m) && m >= 0 && m <= 128;
}
return false;
}
export function invalidPorts(values) {
return (values || []).filter((value) => !isValidPort(value));
}
export function isValidPort(value) {
const n = Number.parseInt(String(value).trim(), 10);
return Number.isInteger(n) && n > 0 && n <= 65535;
}
export function invalidDomains(values) {
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
}
export function ruleErrors(rule) {
return {
domains: invalidDomains(rule.domains),
domainSuffixes: invalidDomains(rule.domainSuffixes),
ipCidrs: invalidCidrs(rule.ipCidrs),
ports: invalidPorts(rule.ports),
};
}
export function hasErrors(errors) {
return Object.values(errors).some((arr) => arr.length > 0);
}

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function withEnv(patch, fn) {
const previous = {};
for (const key of Object.keys(patch)) {
previous[key] = process.env[key];
if (patch[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = patch[key];
}
}
try {
return await fn();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
test("client proxy range defaults to the single configured proxy port", async () => {
await withEnv(
{
PROXY_PORT: "8082",
CLIENT_PROXY_PORT_START: "8082",
CLIENT_PROXY_PORT_END: undefined,
},
async () => {
const { settings } = await import(
`../../src/server/config.js?single-proxy-port=${Date.now()}`
);
assert.equal(settings.proxyPort, 8082);
assert.equal(settings.clientProxyPortStart, 8082);
assert.equal(settings.clientProxyPortEnd, 8082);
},
);
});
test("client proxy defaults to 8082 when no port is configured", async () => {
await withEnv(
{
APP_MODE: "client",
PROXY_PORT: undefined,
CLIENT_PROXY_PORT_START: undefined,
CLIENT_PROXY_PORT_END: undefined,
},
async () => {
const { settings } = await import(
`../../src/server/config.js?client-default-port=${Date.now()}`
);
assert.equal(settings.proxyPort, 8082);
assert.equal(settings.clientProxyPortStart, 8082);
assert.equal(settings.clientProxyPortEnd, 8082);
},
);
});

View File

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

View File

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

View File

@@ -1,167 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
deviceCidrs,
normalizeDeviceProfiles,
} = await import("../../src/server/devices.js");
const { matchRoute } = await import("../../src/server/routeMatcher.js");
const {
sourceBypassCidrs,
buildSourceBypassIptablesCommands,
} = await import("../../src/server/tproxySourceBypass.js");
const { settings } = await import("../../src/server/config.js");
test("default source bypass chain name fits iptables chain length limit", () => {
assert.equal(settings.tproxySourceBypassChain, "VPN_PROXY_SRC_BYPASS");
assert.equal(settings.tproxySourceForwardChain, "VPN_PROXY_FWD_BYPASS");
assert.equal(settings.tproxySourceNatChain, "VPN_PROXY_NAT_BYPASS");
assert.ok(settings.tproxySourceBypassChain.length <= 28);
assert.ok(settings.tproxySourceForwardChain.length <= 28);
assert.ok(settings.tproxySourceNatChain.length <= 28);
});
test("device profiles preserve bypass mode for kernel-level TProxy bypass", () => {
const profiles = normalizeDeviceProfiles({
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
});
assert.equal(profiles.devices[0].mode, "bypass");
assert.deepEqual(deviceCidrs(profiles.devices, "bypass"), [
"192.168.50.25/32",
]);
});
test("route checker reports transparent bypass before sing-box rules", () => {
const result = matchRoute(
{
host: "example.com",
ip: "93.184.216.34",
sourceIp: "192.168.50.25",
inbound: "tproxy-in",
},
[
{
id: "vpn-all",
enabled: true,
name: "VPN all",
domains: ["example.com"],
outbound: "vpn",
},
],
{
vpnTag: "test-vpn",
deviceProfiles: {
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices: [
{
id: "pc",
name: "PC",
enabled: true,
ip: "192.168.50.25",
mode: "bypass",
},
],
},
},
);
assert.equal(result.matched, "kernel-bypass");
assert.equal(result.ruleName, "PC -> bypass TProxy");
assert.equal(result.outbound, "direct");
});
test("source bypass sync combines env CIDRs and bypass-mode devices", () => {
const cidrs = sourceBypassCidrs(
{
devices: [
{ enabled: true, ip: "192.168.50.25", mode: "bypass" },
{ enabled: false, ip: "192.168.50.26", mode: "bypass" },
{ enabled: true, ip: "192.168.50.27", mode: "direct" },
],
},
"192.168.50.30/32",
);
assert.deepEqual(cidrs, ["192.168.50.30/32", "192.168.50.25/32"]);
});
test("source bypass iptables commands use ACCEPT inside the managed subchain", () => {
assert.deepEqual(
buildSourceBypassIptablesCommands(["192.168.50.25/32"], {
chain: "VPN_PROXY_SOURCE_BYPASS",
forwardChain: "VPN_PROXY_FWD_BYPASS",
natChain: "VPN_PROXY_NAT_BYPASS",
natBypassCidrs: ["10.0.0.0/8"],
}),
[
["-w", "-t", "mangle", "-F", "VPN_PROXY_SOURCE_BYPASS"],
["-w", "-F", "VPN_PROXY_FWD_BYPASS"],
["-w", "-t", "nat", "-F", "VPN_PROXY_NAT_BYPASS"],
[
"-w",
"-t",
"mangle",
"-A",
"VPN_PROXY_SOURCE_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"ACCEPT",
],
[
"-w",
"-A",
"VPN_PROXY_FWD_BYPASS",
"-d",
"192.168.50.25/32",
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-j",
"ACCEPT",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-d",
"10.0.0.0/8",
"-j",
"RETURN",
],
[
"-w",
"-t",
"nat",
"-A",
"VPN_PROXY_NAT_BYPASS",
"-s",
"192.168.50.25/32",
"-j",
"MASQUERADE",
],
],
);
});

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