Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04b1752f4 | ||
|
|
34d8b681ad | ||
|
|
32be4380a3 | ||
|
|
837f058025 | ||
|
|
6b17f1982b | ||
|
|
6e701d4fc6 | ||
|
|
ca53b671ee | ||
|
|
10888ac012 | ||
|
|
c26d4cb43b | ||
|
|
3cdb0c735e | ||
|
|
4c61a04dc7 | ||
|
|
39f3467f9b | ||
|
|
b084d7e42c | ||
|
|
9751f4b8c8 | ||
|
|
a84e54f9dd | ||
|
|
41b6613837 | ||
|
|
ca0322f93a | ||
|
|
2151ff51f0 | ||
|
|
e9433e754f | ||
|
|
b2a2ed7104 | ||
|
|
fbf22e0b88 | ||
|
|
4c9822539d | ||
|
|
d32bc14108 | ||
|
|
70cc221f34 | ||
|
|
0a4a6d9443 | ||
|
|
0c376c72aa | ||
|
|
12375006d3 | ||
|
|
e6666558b7 | ||
|
|
ffd899033e | ||
|
|
8926a8877d | ||
|
|
ef4847a3e1 | ||
|
|
cf90fd5b4e | ||
|
|
00a2e1606b | ||
|
|
d0c5336b7e | ||
|
|
0a37121dbe | ||
|
|
c430557dc0 | ||
|
|
dfa9f09695 | ||
|
|
608f8cfcf2 | ||
|
|
53e6cf2146 | ||
|
|
9f31eaf396 | ||
|
|
560c243047 | ||
|
|
307ad02cd7 | ||
|
|
e774486b99 | ||
|
|
3157e9e8f7 | ||
|
|
36c8438b7f | ||
|
|
76b75624b8 | ||
|
|
44367e0ef3 | ||
|
|
158aaadd23 | ||
|
|
fdc6f687f3 | ||
|
|
c6d3fd39fb | ||
|
|
b4adcce26a | ||
|
|
7b94f2dee4 | ||
|
|
7f276b0404 | ||
|
|
c2f9623394 | ||
|
|
bc3cc12f69 | ||
|
|
8139543e9a | ||
|
|
162ef861d7 | ||
|
|
e5a69dcb73 | ||
|
|
a37c211c42 | ||
|
|
f629309f32 | ||
|
|
56f5e408e3 | ||
|
|
8c19f2cba9 | ||
|
|
90447de0aa | ||
|
|
5874df2fce | ||
|
|
d551d41b71 | ||
|
|
9a539409f0 | ||
|
|
0480e617cd | ||
|
|
77eaed8d90 | ||
|
|
e8c1c9d403 | ||
|
|
12c1b128f8 | ||
|
|
6bd51dc8fb | ||
|
|
fca3c0b705 | ||
|
|
d9745e9aed | ||
|
|
57330f1c78 | ||
|
|
24fda3e34e | ||
|
|
5b3d288405 | ||
|
|
2a71466670 | ||
|
|
2d1d89911e | ||
|
|
394fceac15 | ||
|
|
c40f465708 | ||
|
|
ba1e53a824 | ||
|
|
1fe13703eb | ||
|
|
17577ea460 | ||
|
|
2a214fc28b | ||
|
|
5e33360c92 | ||
|
|
ef33ad9c84 | ||
|
|
267afc5c7e | ||
|
|
005c7a101b | ||
|
|
ba15a25c89 | ||
|
|
d4897e5dcf | ||
|
|
7182bc2c1a | ||
|
|
8db9d30828 | ||
|
|
c56f51e07b | ||
|
|
d6ba05ac7d | ||
|
|
4ed25301db | ||
|
|
d49a1f6837 | ||
|
|
56304514ff | ||
|
|
4519577295 | ||
|
|
87cd83f89a | ||
|
|
fbbd6e40b4 | ||
|
|
483fce55f3 | ||
|
|
62d2044dc1 | ||
|
|
f135ade43b | ||
|
|
65bf88bf41 | ||
|
|
387cc273e8 | ||
|
|
1304a22f1f | ||
|
|
a0c66edb02 | ||
|
|
306a9b8ced | ||
|
|
7a6f9a26ac | ||
|
|
c9223aa3a9 | ||
|
|
e6b21ed8a9 | ||
|
|
74660d915f | ||
|
|
9da4fef1f0 | ||
|
|
457dd912d1 | ||
|
|
198669694c | ||
|
|
a775d8456a | ||
|
|
40f73ee98c | ||
|
|
e81a48a5b1 | ||
|
|
b0b9da51b6 | ||
|
|
4b326c5e99 | ||
|
|
0bf7d2ee30 | ||
|
|
b53cd08dcc | ||
|
|
edad26d978 | ||
|
|
322f5a125b | ||
|
|
85053f9948 | ||
|
|
9efd446d4e | ||
|
|
bfc85c3056 | ||
|
|
89feffd0b7 | ||
|
|
aa54be9c9b | ||
|
|
befd41933d | ||
|
|
b389664824 | ||
|
|
d5a42d8b7b | ||
|
|
e6bcdc9c62 | ||
|
|
a58fb26e4f | ||
|
|
0ab912c64c | ||
|
|
51312d51cd | ||
|
|
0a1aa8aed3 | ||
|
|
84efbe7450 | ||
|
|
42c15df8c9 | ||
|
|
fa3b455fab | ||
|
|
6f565ded2e | ||
|
|
41922ad30b | ||
|
|
9d4f312595 | ||
|
|
e19d33adb9 | ||
|
|
99f7f58fcb | ||
|
|
6bc7840fb1 | ||
|
|
d3b7f0d613 | ||
|
|
efa46d1ee5 | ||
|
|
149bb999dc | ||
|
|
288acbf0c8 | ||
|
|
b45dd2ae05 | ||
|
|
c5bdb10445 | ||
|
|
7dbf786c56 | ||
|
|
59f2264a2e | ||
|
|
a0f41baa36 | ||
|
|
c3d3aaa699 | ||
|
|
301b76c03e | ||
|
|
ab6de6996f | ||
|
|
0092ec4cde | ||
|
|
12ad0c8b78 | ||
|
|
b5d4c61783 | ||
|
|
f4990a4f55 | ||
|
|
ab44626a0f | ||
|
|
95edefa84f | ||
|
|
f914c28bc5 | ||
|
|
73488384e4 | ||
|
|
c6352d781f | ||
|
|
d02dbe10de | ||
|
|
2ef1e09986 | ||
|
|
6df8c525ef | ||
|
|
f264ce4a2f | ||
|
|
371adbcb50 | ||
|
|
3a930c9d8c | ||
|
|
1bdf12f174 | ||
|
|
3e8925c609 | ||
|
|
d12b0c01fc | ||
|
|
e16f401dc5 | ||
|
|
68844d67df | ||
|
|
ec8e748a43 | ||
|
|
62f50d9c28 | ||
|
|
cab4313c70 | ||
|
|
aab7533438 | ||
|
|
62b39cdf58 | ||
|
|
6ab5f50f95 | ||
|
|
4bb8507e3f | ||
|
|
b3fad00f80 | ||
|
|
5c9a291920 | ||
|
|
781cbbb026 | ||
|
|
499d2d3367 | ||
|
|
eeec4359b0 | ||
|
|
11f2c0ccb2 | ||
|
|
f89cba4a24 | ||
|
|
49be90a82c | ||
|
|
bb7250e4ac | ||
|
|
4f1a2f8bf6 | ||
|
|
7d1f5f89ed | ||
|
|
b1c8eea976 | ||
|
|
27b71077b1 | ||
|
|
3e18b833c6 | ||
|
|
0cd898d1c1 | ||
|
|
8476ab16e5 | ||
|
|
a8f2c6f3f9 | ||
|
|
a961b1b415 | ||
|
|
7489b5ef97 | ||
|
|
b716b370ac | ||
|
|
abd5a73b51 | ||
|
|
1ed79c3a1e | ||
|
|
8789496ae6 | ||
|
|
7d41dd86e7 | ||
|
|
81bed1513c | ||
|
|
d13eb0a9a4 | ||
|
|
71f8e0b84c | ||
|
|
03885d2e09 | ||
|
|
88eef527d5 | ||
|
|
c971b40eae | ||
|
|
327561b2e9 | ||
|
|
185a311a38 | ||
|
|
ef752d66bc | ||
|
|
a3816cbedc | ||
|
|
51d26a4c1b | ||
|
|
638940c694 | ||
|
|
2e16d33618 | ||
|
|
6b38c7b15f | ||
|
|
6e97bb9f61 | ||
|
|
c4915389a7 | ||
|
|
48178fa3ae | ||
|
|
ede0370b3a | ||
|
|
116856c1d1 | ||
|
|
13c92c7413 | ||
|
|
479a7232b1 | ||
|
|
e1f71f95ad | ||
|
|
d7a3b20da9 |
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
- Give every actionable icon a semantic hover/focus response; rotate cyclic actions, move the physical part of object-like controls, and keep their hit targets fixed.
|
||||||
|
- 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.
|
||||||
|
- Icon-only controls respond on hover and focus, active cyclic work spins, and durable states such as pinned remain legible at rest.
|
||||||
|
- Keyboard focus remains visible even when the text caret is intentionally hidden.
|
||||||
|
- Narrow screens return to a simple single-column layout.
|
||||||
@@ -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."
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Icon controls
|
||||||
|
|
||||||
|
- Give every actionable icon a small semantic response on hover and keyboard focus; leave decorative icons still.
|
||||||
|
- Rotate cyclic actions such as refresh, ping, and traffic sorting on hover, then use the shared continuous spin while work is running.
|
||||||
|
- Move the physical part of object-like controls: lift and tilt a pin, pencil, or trash lid instead of moving its fixed hit target.
|
||||||
|
- Keep durable state on the icon wrapper and transient motion on the SVG child. A pinned icon stays lifted and tilted while its row moves to the pinned group.
|
||||||
|
- Keep the hit target, tooltip, and surrounding layout fixed. Tooltips remain outside the transformed SVG.
|
||||||
|
- Under reduced motion, preserve color, focus, and final state without animated travel or rotation.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
@@ -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.ts` 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.ts`, keep the initial versions and let the checker report that no baseline exists.
|
||||||
@@ -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."
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vpn-proxy
|
||||||
|
.runtime
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT=8082
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT=3456
|
||||||
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
|
SINGBOX_VERSION=1.12.13
|
||||||
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
INSTALL_SINGBOX=true
|
||||||
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
SING_BOX_API_PORT=19090
|
||||||
|
TPROXY_PORT=7895
|
||||||
|
TPROXY_MARK=1
|
||||||
|
TPROXY_TABLE=100
|
||||||
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
name: Build and Deploy Gateway
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
|
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:20.19-alpine
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
|
SINGBOX_VERSION: 1.12.13
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
affected_components: ${{ steps['gateway-build'].outputs.affected_components }}
|
||||||
|
restart_scope: ${{ steps['gateway-build'].outputs.restart_scope }}
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
rm -rf repo
|
||||||
|
git clone "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build and push gateway image
|
||||||
|
id: gateway-build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
CONTROL_IMAGE="${IMAGE}-control"
|
||||||
|
DATAPLANE_IMAGE="${IMAGE}-dataplane"
|
||||||
|
|
||||||
|
EVENT_NAME="${{ gitea.event_name }}"
|
||||||
|
BEFORE_SHA="${{ gitea.event.before }}"
|
||||||
|
ZERO_SHA="0000000000000000000000000000000000000000"
|
||||||
|
if [ "$EVENT_NAME" = "push" ] \
|
||||||
|
&& [ -n "$BEFORE_SHA" ] \
|
||||||
|
&& [ "$BEFORE_SHA" != "$ZERO_SHA" ] \
|
||||||
|
&& git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
CHANGED_FILES="$(git diff --no-renames --name-only "$BEFORE_SHA" "${{ gitea.sha }}")"
|
||||||
|
elif [ "$EVENT_NAME" = "push" ]; then
|
||||||
|
CHANGED_FILES="package.json"
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git diff-tree --no-renames --no-commit-id --name-only -r -m HEAD)"
|
||||||
|
fi
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
else
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v node >/dev/null'; then
|
||||||
|
echo "Cannot classify runtime impact: Node and the existing runtime base are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
RUNTIME_IMPACT="$(printf '%s\n' "$CHANGED_FILES" | docker run --rm -i \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.BASE_IMAGE }}" \
|
||||||
|
node scripts/runtime-impact.mjs --stdin)"
|
||||||
|
fi
|
||||||
|
AFFECTED_COMPONENTS="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^affected-components=//p')"
|
||||||
|
RESTART_SCOPE="$(printf '%s\n' "$RUNTIME_IMPACT" | sed -n 's/^restart-scope=//p')"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact: ${RUNTIME_IMPACT}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
|
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run typecheck
|
||||||
|
npm run check:boundaries
|
||||||
|
npm test
|
||||||
|
npm run build:production
|
||||||
|
else
|
||||||
|
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||||
|
echo "Cannot validate change: host npm and the Node 20.19 build image are unavailable." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
|
sh -lc 'npm ci --no-audit --no-fund && npm run typecheck && npm run check:boundaries && npm test && npm run build:production'
|
||||||
|
fi
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Image build and push skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Build runner: $(hostname)"
|
||||||
|
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||||
|
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||||
|
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||||
|
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||||
|
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
|
||||||
|
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||||
|
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
./scripts/build-runtime-base.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--network host \
|
||||||
|
--pull=false \
|
||||||
|
--build-arg NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
|
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||||
|
--build-arg INSTALL_SINGBOX=false \
|
||||||
|
-t "${CONTROL_IMAGE}:latest" \
|
||||||
|
-t "${CONTROL_IMAGE}:${{ gitea.sha }}" \
|
||||||
|
-t "${DATAPLANE_IMAGE}:latest" \
|
||||||
|
-t "${DATAPLANE_IMAGE}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
|
docker push "${CONTROL_IMAGE}:latest"
|
||||||
|
docker push "${CONTROL_IMAGE}:${{ gitea.sha }}"
|
||||||
|
docker push "${DATAPLANE_IMAGE}:latest"
|
||||||
|
docker push "${DATAPLANE_IMAGE}:${{ gitea.sha }}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: lxc-111
|
||||||
|
needs: build-and-push
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
rm -rf repo
|
||||||
|
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Pull and deploy gateway image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
CONTROL_IMAGE="${IMAGE}-control:${{ gitea.sha }}"
|
||||||
|
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
||||||
|
AFFECTED_COMPONENTS="${{ needs['build-and-push'].outputs.affected_components }}"
|
||||||
|
RESTART_SCOPE="${{ needs['build-and-push'].outputs.restart_scope }}"
|
||||||
|
case "${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" in
|
||||||
|
none:none|control:control|dataplane:both|control+dataplane:both) ;;
|
||||||
|
*) echo "Invalid runtime impact output: ${AFFECTED_COMPONENTS}:${RESTART_SCOPE}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$RESTART_SCOPE" = "none" ]; then
|
||||||
|
echo "Deploy skipped: no Gateway runtime impact."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
UPDATE_DATAPLANE=false
|
||||||
|
if [ "$RESTART_SCOPE" = "both" ]; then
|
||||||
|
UPDATE_DATAPLANE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploy runner: $(hostname)"
|
||||||
|
echo "Affected components: ${AFFECTED_COMPONENTS}"
|
||||||
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
|
echo "Update dataplane: ${UPDATE_DATAPLANE}"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" \
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE}" \
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE}" \
|
||||||
|
UPDATE_DATAPLANE="${UPDATE_DATAPLANE}" \
|
||||||
|
bash scripts/deploy-gateway.sh
|
||||||
+27
-2
@@ -1,2 +1,27 @@
|
|||||||
data
|
# Runtime state
|
||||||
_legacy
|
.env
|
||||||
|
*.env.local
|
||||||
|
data/
|
||||||
|
.vpn-proxy/
|
||||||
|
.runtime/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Local roadmap and task workspace
|
||||||
|
/workpack/
|
||||||
|
|
||||||
|
# Node/Vite
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.test-dist/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS/editors
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
@@ -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.
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
|
RUN npm run build:production
|
||||||
|
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
|
ARG INSTALL_SINGBOX=true
|
||||||
|
|
||||||
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
|
apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
|
else \
|
||||||
|
command -v dumb-init >/dev/null \
|
||||||
|
&& command -v node >/dev/null \
|
||||||
|
&& command -v iptables >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||||
|
set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*; \
|
||||||
|
else \
|
||||||
|
command -v sing-box >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/dist /app/dist
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV PORT=3456 \
|
||||||
|
PROXY_PORT=8080 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
SING_BOX_API_PORT=19090 \
|
||||||
|
TPROXY_PORT=7895 \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
||||||
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
COPY src/server ./src/server
|
||||||
|
COPY src/shared ./src/shared
|
||||||
|
COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.json
|
||||||
|
RUN npm run build:production
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /src/dist /app/dist
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.client.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV APP_MODE=client \
|
||||||
|
PORT=3456 \
|
||||||
|
PROXY_PORT=8082 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
|
ROUTING_RU_DIRECT=true \
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 3456 8082
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
|
ARG HTTP_PROXY
|
||||||
|
ARG HTTPS_PROXY
|
||||||
|
ARG NO_PROXY
|
||||||
|
ARG http_proxy
|
||||||
|
ARG https_proxy
|
||||||
|
ARG no_proxy
|
||||||
|
|
||||||
|
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
||||||
|
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
|
||||||
|
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
|
||||||
|
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
|
||||||
|
[ -f "$file" ] || continue; \
|
||||||
|
sed -i \
|
||||||
|
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||||
|
"$file"; \
|
||||||
|
done \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
update \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
|
||||||
|
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
|
||||||
|
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- One screen, one primary action.
|
||||||
|
- Use plain language and hide implementation details.
|
||||||
|
- Make connection state unmistakable without relying on color alone.
|
||||||
|
- Prefer native controls and predictable macOS behavior.
|
||||||
|
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||||
|
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||||
|
- Keep advanced and server-only features out of the client path.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||||
@@ -1,353 +1,363 @@
|
|||||||
# 🌐 VPN Proxy — Домашний VPN в одной программе
|
# Harbor
|
||||||
|
|
||||||
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
|
Harbor помогает пользоваться одной VPN-подпиской дома и на Mac без ручной настройки `sing-box`.
|
||||||
|
|
||||||
---
|
Проект работает в двух режимах:
|
||||||
|
|
||||||
## 📖 Что это такое?
|
| Режим | Где работает | Для чего нужен |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
|
||||||
|
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
|
||||||
|
|
||||||
Это набор инструментов, который позволяет:
|
В Harbor Connect подписка и выбор сервера остаются на основном экране. Harbor Gateway открывается как панель маршрутизации даже без подписки: Home, «Устройства» и «Диагностика» доступны сразу, а VPN-подписка настраивается отдельной верхней кнопкой в правой панели.
|
||||||
|
|
||||||
1. **Запустить VPN-прокси** на вашем компьютере
|
## Что понадобится
|
||||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
|
||||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
|
||||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
|
||||||
|
|
||||||
### 🎯 Для кого это?
|
- ссылка на подписку от VPN-провайдера, если Harbor должен направлять трафик через VPN;
|
||||||
|
- Docker с командой `docker compose`;
|
||||||
|
- для ручной установки Gateway — Git;
|
||||||
|
- для Gateway — Linux-машина в одной локальной сети с устройствами;
|
||||||
|
- для Connect — Mac с запущенным Docker Desktop.
|
||||||
|
|
||||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
Harbor не является VPN-провайдером и не создаёт подписки самостоятельно.
|
||||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
|
||||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
|
||||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
|
||||||
|
|
||||||
---
|
## Что выбрать
|
||||||
|
|
||||||
## 🧩 Как это работает?
|
Используйте **Harbor Connect**, если VPN нужен только приложениям на одном Mac.
|
||||||
|
|
||||||
```
|
Используйте **Harbor Gateway**, если нужно подключить телевизор, телефон, игровую приставку или сразу несколько устройств. Устройства можно направить через Gateway целиком либо настроить в отдельных приложениях общий прокси.
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
|
Оба режима можно использовать вместе. Дома Connect автоматически распознаёт настроенный Harbor Gateway и не запускает второй VPN-маршрут. В другой сети Connect возвращается к локальному VPN.
|
||||||
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
|
|
||||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
## Установка Harbor Gateway
|
||||||
|
|
||||||
|
### 1. Скачайте проект
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.dokops.ru/dokril/vpn-proxy.git
|
||||||
|
cd vpn-proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 2. Создайте настройки
|
||||||
|
|
||||||
## 🔧 Перед началом: Требования
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
### ✅ PowerShell 7 (Обязательно!)
|
|
||||||
|
|
||||||
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
|
|
||||||
|
|
||||||
#### Проверьте вашу версию
|
|
||||||
|
|
||||||
Откройте любой PowerShell и выполните:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$PSVersionTable.PSVersion.Major
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Если результат **7 или выше** — всё хорошо, переходите к установке ✅
|
Стандартные значения подходят для обычной домашней сети. При необходимости откройте `.env` в текстовом редакторе и измените порты.
|
||||||
- Если **5 или ниже** — нужно установить PowerShell 7 👇
|
|
||||||
|
|
||||||
#### Установка PowerShell 7
|
### 3. Запустите Gateway
|
||||||
|
|
||||||
**Способ 1: Через winget (самый простой)**
|
```bash
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
Откройте обычный PowerShell или Командную строку и выполните:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install Microsoft.PowerShell
|
|
||||||
```
|
```
|
||||||
|
|
||||||
После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск).
|
Откройте в браузере:
|
||||||
|
|
||||||
**Способ 2: Скачать вручную**
|
```text
|
||||||
|
http://АДРЕС-GATEWAY:3456
|
||||||
1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest
|
|
||||||
2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия)
|
|
||||||
3. Запустите установщик и следуйте инструкциям
|
|
||||||
4. После установки используйте **PowerShell 7** из меню Пуск
|
|
||||||
|
|
||||||
> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ URL Подписки или VLESS-ссылка
|
|
||||||
|
|
||||||
Получите от вашего VPN-провайдера:
|
|
||||||
- **Подписку**: URL, который начинается с `http://` или `https://`
|
|
||||||
- **VLESS-ссылку**: начинается с `vless://...`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Установка на Windows
|
|
||||||
|
|
||||||
### ⚡ Быстрая установка (Одной командой)
|
|
||||||
|
|
||||||
Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`.
|
|
||||||
|
|
||||||
1. Откройте **PowerShell 7** от имени **Администратора**
|
|
||||||
2. Скопируйте и вставьте команду:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy RemoteSigned -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже.
|
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
|
||||||
|
|
||||||
---
|
### 4. При необходимости добавьте подписку
|
||||||
|
|
||||||
### 📦 Ручная установка (если авто-установка не работает)
|
1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway.
|
||||||
|
2. Вставьте ссылку VPN-подписки.
|
||||||
|
3. Нажмите «Сохранить подписку».
|
||||||
|
4. Выберите сервер.
|
||||||
|
5. Включите VPN.
|
||||||
|
|
||||||
Если вы предпочитаете всё делать сами:
|
После подключения Harbor покажет два варианта использования:
|
||||||
|
|
||||||
#### Шаг 1: Скачайте проект
|
- **Gateway** — укажите IP-адрес Linux-машины как основной шлюз устройства. Через VPN пойдёт весь его интернет-трафик;
|
||||||
|
- **Gateway Proxy** — укажите адрес Linux-машины и порт `8080` в приложении. Поддерживаются HTTP и SOCKS5 на одном порту.
|
||||||
|
|
||||||
Мы рекомендуем использовать папку `C:\Tools`.
|
Приватные и локальные адреса не отправляются в VPN, поэтому устройства сохраняют доступ к домашней сети. Общий прокси по умолчанию принимает подключения только из приватных сетей.
|
||||||
|
|
||||||
```powershell
|
### Устройства Gateway
|
||||||
# 1. Создаем папку и переходим
|
|
||||||
New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null
|
|
||||||
cd C:\Tools
|
|
||||||
|
|
||||||
# 2. Клонируем или скачиваем архив
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
|
||||||
git clone https://git.dokops.ru/dokril/vpn-proxy
|
|
||||||
|
|
||||||
# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy)
|
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
|
||||||
|
|
||||||
|
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
|
Home показывает фактически применённый VPN-сервер и общий график тех же счётчиков. `Учтено Harbor` — накопленная сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткий график последних 15-секундных интервалов после перезапуска начинает заполняться заново.
|
||||||
|
|
||||||
|
## Установка Harbor Connect на macOS
|
||||||
|
|
||||||
|
### 1. Запустите Docker Desktop
|
||||||
|
|
||||||
|
Установщик проверит наличие Docker, Docker Compose, `curl` и `tar`. Если Docker Desktop не запущен, установка остановится с понятным сообщением.
|
||||||
|
|
||||||
|
### 2. Запустите установщик
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Шаг 2: Запустите
|
Установщик:
|
||||||
|
|
||||||
```powershell
|
- сохранит рабочую копию в `~/.vpn-proxy-client`;
|
||||||
cd C:\Tools\vpn-proxy
|
- предложит порт для локального прокси;
|
||||||
.\manage.ps1
|
- соберёт и запустит контейнер Harbor Connect;
|
||||||
|
- добавит пользовательский LaunchAgent для определения текущего Gateway.
|
||||||
|
|
||||||
|
По умолчанию используются адреса:
|
||||||
|
|
||||||
|
| Назначение | Адрес |
|
||||||
|
| --- | --- |
|
||||||
|
| Интерфейс Harbor Connect | `http://127.0.0.1:3456` |
|
||||||
|
| HTTP-прокси | `127.0.0.1:8082` |
|
||||||
|
| SOCKS5-прокси | `127.0.0.1:8082` |
|
||||||
|
|
||||||
|
### 3. Добавьте подписку
|
||||||
|
|
||||||
|
Откройте `http://127.0.0.1:3456`, вставьте ссылку подписки, выберите сервер и включите VPN.
|
||||||
|
|
||||||
|
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
|
||||||
|
|
||||||
|
### Другие порты
|
||||||
|
|
||||||
|
Передайте нужные значения при повторном запуске установщика:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | \
|
||||||
|
VPN_PROXY_CLIENT_PORT=9080 \
|
||||||
|
VPN_PROXY_CLIENT_UI_PORT=3457 \
|
||||||
|
sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 3: Выберите пункт [1] — VPN Клиент
|
Допустимы порты от `1024` до `65535`. Установщик не позволит выбрать занятый порт или один порт одновременно для интерфейса и прокси.
|
||||||
|
|
||||||
```
|
## Локальные правила маршрутизации
|
||||||
[1] 📦 VPN Клиент (Sing-box) [НЕ УСТАНОВЛЕН]
|
|
||||||
Основной способ. Поддерживает UDP и игры.
|
|
||||||
|
|
||||||
[2] 🎮 Настройка Discord/Vesktop [НЕ АКТИВЕН]
|
После добавления подписки откройте «Локальные правила» справа от основного экрана. При первом обновлении Harbor добавит обычное включённое правило `*.ru`, поэтому российские домены пойдут напрямую. Его, как и любое другое правило, можно выключить или удалить. Доступны точный домен, suffix домена и фрагмент имени; включённые правила обходят VPN, а остальной трафик идёт через выбранный сервер.
|
||||||
Маршрутизация приложений через прокси.
|
|
||||||
|
|
||||||
---------------------------------------
|
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
||||||
[3] 🔄 Обновить статус
|
|
||||||
[U] ❌ Удалить всё (Uninstall)
|
|
||||||
[q] Выход
|
|
||||||
|
|
||||||
👉 Ваш выбор: 1
|
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
|
||||||
|
|
||||||
|
## Системный прокси macOS
|
||||||
|
|
||||||
|
Сначала посмотрите точное имя сетевого подключения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
networksetup -listallnetworkservices
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 4: Введите VLESS-ссылку или URL подписки
|
Для подключения с именем `Wi-Fi` включите HTTP, HTTPS и SOCKS5-прокси:
|
||||||
|
|
||||||
Скрипт попросит ввести ссылку. Вставьте и нажмите Enter.
|
```bash
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 8082
|
||||||
**Готово!** 🎉 Прокси запущен на `127.0.0.1:1080`
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 8082
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 8082
|
||||||
### 📂 Где всё хранится?
|
|
||||||
|
|
||||||
Всё организовано в папке `C:\Tools`:
|
|
||||||
|
|
||||||
1. **Сам проект:** `C:\Tools\vpn-proxy`
|
|
||||||
- Скрипты управления и настройки
|
|
||||||
2. **Sing-box (VPN клиент):** `C:\Tools\sing-box`
|
|
||||||
- Здесь лежит `config.json` с вашими настройками и сам исполняемый файл
|
|
||||||
3. **ProxiFyre (для Discord):** `C:\Program Files\ProxiFyre` (системная служба)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Проверка работы
|
|
||||||
|
|
||||||
После установки меню покажет статус и адреса подключения:
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ]
|
|
||||||
Основной способ. Поддерживает UDP и игры.
|
|
||||||
|
|
||||||
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
|
|
||||||
─────────────────────────────
|
|
||||||
Локально: 127.0.0.1:1080
|
|
||||||
Из сети:
|
|
||||||
192.168.1.100:1080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Проверка через терминал
|
Чтобы отключить их:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
# Без прокси — покажет ваш домашний IP
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
# Через прокси — должен показать IP VPN-сервера
|
|
||||||
Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Если IP-адреса разные — VPN работает! 🎉
|
Если сетевое подключение называется иначе, замените `Wi-Fi` его точным именем.
|
||||||
|
|
||||||
---
|
## Автоматическое использование домашнего Gateway
|
||||||
|
|
||||||
## 🎮 Настройка Discord / Vesktop
|
Harbor Connect раз в пять секунд узнаёт у macOS адрес текущего основного шлюза. Если по этому адресу работает Harbor Gateway с той же VPN-подпиской, Connect оставляет локальный прокси доступным для приложений, но не создаёт второй VPN-маршрут: трафик уже обрабатывает Gateway.
|
||||||
|
|
||||||
Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка.
|
Для этого:
|
||||||
|
|
||||||
### Требования
|
1. добавьте одну и ту же ссылку подписки в Gateway и Connect;
|
||||||
|
2. убедитесь, что Mac может открыть интерфейс Gateway на порту `3456`;
|
||||||
|
3. оставьте автоматический режим включённым в Harbor Connect.
|
||||||
|
|
||||||
- ✅ Установленный VPN клиент (пункт [1] в меню)
|
Ссылка должна содержать персональный секрет или token длиной не менее 16 символов — обычные ссылки подписок уже соответствуют этому условию. Ссылка между устройствами не передаётся: она используется локально для проверки, что Connect нашёл именно ваш Gateway. При смене сети или после трёх неудачных проверок Connect возвращается к локальному VPN.
|
||||||
- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ")
|
|
||||||
|
|
||||||
### Установка
|
## Повседневные команды
|
||||||
|
|
||||||
1. Запустите `.\manage.ps1`
|
Все команды Gateway выполняются из каталога проекта. Команды Connect — из `~/.vpn-proxy-client`.
|
||||||
2. Выберите пункт **[2] — Настройка Discord/Vesktop**
|
|
||||||
3. Выберите какое приложение настроить:
|
|
||||||
- Discord
|
|
||||||
- Vesktop
|
|
||||||
- Оба
|
|
||||||
|
|
||||||
**Что устанавливается:**
|
### Harbor Gateway
|
||||||
- Windows Packet Filter — драйвер для перехвата трафика
|
|
||||||
- ProxiFyre — служба, которая направляет трафик Discord через прокси
|
|
||||||
|
|
||||||
После установки Discord/Vesktop будут автоматически работать через VPN!
|
| Действие | Команда |
|
||||||
|
| --- | --- |
|
||||||
|
| Запустить или обновить после изменения файлов | `docker compose -f docker-compose.gateway.yml up -d --build` |
|
||||||
|
| Обновить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml build vpn-proxy-control && docker compose -f docker-compose.gateway.yml up -d --no-deps vpn-proxy-control` |
|
||||||
|
| Показать состояние | `docker compose -f docker-compose.gateway.yml ps` |
|
||||||
|
| Смотреть журнал | `docker compose -f docker-compose.gateway.yml logs -f` |
|
||||||
|
| Перезапустить только интерфейс и управление | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-control` |
|
||||||
|
| Перезапустить VPN dataplane | `docker compose -f docker-compose.gateway.yml restart vpn-proxy-dataplane` |
|
||||||
|
| Остановить | `docker compose -f docker-compose.gateway.yml down` |
|
||||||
|
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.gateway.yml down -v` |
|
||||||
|
|
||||||
---
|
### Harbor Connect
|
||||||
|
|
||||||
## ⚙️ Настройка приложений
|
```bash
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
### Для VS Code
|
|
||||||
|
|
||||||
Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте:
|
|
||||||
|
|
||||||
```
|
|
||||||
http.proxy: http://127.0.0.1:1080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Или добавьте в `settings.json`:
|
| Действие | Команда |
|
||||||
|
| --- | --- |
|
||||||
|
| Обновить и снова запустить | `./scripts/install-macos-client.sh` |
|
||||||
|
| Показать состояние | `docker compose -f docker-compose.client.yml ps` |
|
||||||
|
| Смотреть журнал | `docker compose -f docker-compose.client.yml logs -f` |
|
||||||
|
| Перезапустить | `docker compose -f docker-compose.client.yml restart` |
|
||||||
|
| Остановить | `docker compose -f docker-compose.client.yml down` |
|
||||||
|
| Удалить вместе с сохранёнными данными | `docker compose -f docker-compose.client.yml down -v` |
|
||||||
|
|
||||||
```json
|
Команда с `-v` удаляет подписку, выбранный сервер и другие сохранённые данные. Для обычной остановки используйте `down` без `-v`.
|
||||||
{
|
|
||||||
"http.proxy": "http://127.0.0.1:1080",
|
## Обновление
|
||||||
"http.proxyStrictSSL": true
|
|
||||||
}
|
### Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull --ff-only
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Для браузера
|
### Connect
|
||||||
|
|
||||||
В настройках прокси вашего браузера укажите:
|
Повторно запустите однострочный установщик. Он обновит рабочую копию, снова спросит порт прокси и пересоберёт Connect:
|
||||||
|
|
||||||
- **Тип**: HTTP или SOCKS5
|
```bash
|
||||||
- **Адрес**: `127.0.0.1`
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | sh
|
||||||
- **Порт**: `1080`
|
|
||||||
|
|
||||||
> 💡 **Совет:** Используйте расширение [Proxy SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif) для удобного переключения прокси в Chrome.
|
|
||||||
|
|
||||||
### Для других программ
|
|
||||||
|
|
||||||
Укажите SOCKS5 прокси: `127.0.0.1:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Управление
|
|
||||||
|
|
||||||
При повторном запуске `.\manage.ps1` скрипт покажет меню управления:
|
|
||||||
|
|
||||||
| Действие | Как сделать |
|
|
||||||
|----------|-------------|
|
|
||||||
| Посмотреть статус | Запустить `.\manage.ps1` |
|
|
||||||
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
|
|
||||||
| Перезапустить | Пункт [1] → "Перезапустить" |
|
|
||||||
| Остановить | Пункт [1] → "Остановить" |
|
|
||||||
| Полностью удалить | Пункт [U] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌍 Подключение из локальной сети
|
|
||||||
|
|
||||||
Если вы хотите использовать прокси с других устройств (телефон, планшет):
|
|
||||||
|
|
||||||
1. Посмотрите IP-адрес в меню (раздел "Из сети:")
|
|
||||||
2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080`
|
|
||||||
|
|
||||||
Например: `192.168.1.100:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Часто задаваемые вопросы
|
|
||||||
|
|
||||||
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
|
|
||||||
|
|
||||||
**Решение:** Включите выполнение скриптов:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибка при запуске — непонятные символы или синтаксис
|
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
|
||||||
|
|
||||||
**Причина:** Вы используете старый PowerShell 5.1
|
### Версии
|
||||||
|
|
||||||
**Решение:** Установите PowerShell 7 (см. раздел "Перед началом")
|
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает строку `M` (Mac client). Gateway показывает `C` (Gateway client UI), `B` (текущий control-backend) и `D` (фактически развёрнутый dataplane). Поэтому после control-only deploy `B` обновится сразу, а `D` может намеренно остаться на прежней версии до следующего runtime-deploy. Наведите курсор или переведите клавиатурный фокус на цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у `D` также указана фактическая версия `sing-box`.
|
||||||
|
|
||||||
### Discord не подключается к голосовым каналам
|
Компонентные версии меняются в `src/shared/versions.ts`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
|
||||||
|
|
||||||
**Причина:** ProxiFyre не запущен или VPN клиент остановлен
|
Для изменения версии используйте `npm run version:harbor -- affected HEAD`, затем `npm run version:harbor -- bump <major|minor|hotfix> [компонент]` и `npm run version:harbor -- check HEAD`. Правила выбора уровня закреплены в обязательном repo skill `manage-harbor-versions`.
|
||||||
|
|
||||||
**Решение:**
|
## Настройки `.env`
|
||||||
1. Запустите `.\manage.ps1`
|
|
||||||
2. Убедитесь что пункт [1] показывает "РАБОТАЕТ"
|
|
||||||
3. Убедитесь что пункт [2] показывает "АКТИВЕН"
|
|
||||||
|
|
||||||
### Как узнать, работает ли VPN?
|
Для большинства установок достаточно стандартных значений.
|
||||||
|
|
||||||
1. Откройте https://ipinfo.io в браузере — это ваш реальный IP
|
| Переменная | По умолчанию | Назначение |
|
||||||
2. Настройте прокси в браузере
|
| --- | --- | --- |
|
||||||
3. Откройте https://ipinfo.io снова — должен показать другой IP
|
| `PORT` | `3456` | Внутренний порт веб-интерфейса Gateway |
|
||||||
|
| `CLIENT_UI_PORT` | `3456` | Порт интерфейса Connect на Mac |
|
||||||
|
| `PROXY_PORT` | `8080` | Порт общего прокси Gateway |
|
||||||
|
| `CLIENT_PROXY_PORT` | `8082` | Порт локального прокси Connect |
|
||||||
|
| `HARBOR_GATEWAY_CONTROL_PORT` | `3456` | Порт, на котором Connect проверяет домашний Gateway |
|
||||||
|
| `PROXY_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
|
||||||
|
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
||||||
|
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
||||||
|
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
||||||
|
|
||||||
---
|
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
||||||
|
|
||||||
## 🔧 Продвинутые варианты
|
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||||
|
|
||||||
### Docker с веб-интерфейсом
|
## Prometheus и Grafana
|
||||||
|
|
||||||
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
|
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||||
|
|
||||||
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
|
```yaml
|
||||||
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
|
scrape_configs:
|
||||||
|
- job_name: harbor_gateway
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 3s
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["<gateway>:3456"]
|
||||||
|
```
|
||||||
|
|
||||||
📖 **[Инструкция по Docker](docs/DOCKER.md)**
|
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||||
|
|
||||||
### Установка на удалённый сервер (VPS)
|
Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||||
|
|
||||||
Если вы хотите развернуть прокси на своём сервере в другой стране:
|
Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера.
|
||||||
|
|
||||||
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
---
|
## Если что-то не работает
|
||||||
|
|
||||||
## 📚 Словарь терминов
|
### Интерфейс не открывается
|
||||||
|
|
||||||
| Термин | Объяснение |
|
Проверьте контейнер и журнал:
|
||||||
|--------|------------|
|
|
||||||
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
|
|
||||||
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
|
|
||||||
| **VLESS** | Современный протокол VPN-соединения |
|
|
||||||
| **sing-box** | Программа-клиент для подключения к VPN |
|
|
||||||
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
|
|
||||||
| **Порт** | "Номер двери" для сетевых соединений |
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
docker compose -f docker-compose.gateway.yml ps
|
||||||
|
docker compose -f docker-compose.gateway.yml logs --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
## 🆘 Нужна помощь?
|
Для Connect замените имя файла на `docker-compose.client.yml` и выполняйте команду из `~/.vpn-proxy-client`.
|
||||||
|
|
||||||
Если что-то не работает:
|
### Прокси не отвечает
|
||||||
|
|
||||||
1. Убедитесь что используете **PowerShell 7**
|
Убедитесь, что Harbor включён в интерфейсе, а приложение использует правильные адрес и порт. Для Connect это обычно `127.0.0.1:8082`; для Gateway — IP Linux-машины и порт `8080`.
|
||||||
2. Запустите от имени **Администратора**
|
|
||||||
3. Проверьте статус в главном меню
|
|
||||||
4. Попробуйте переустановить: пункт [U], затем пункт [1]
|
|
||||||
|
|
||||||
---
|
### Connect не распознаёт Gateway
|
||||||
|
|
||||||
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
Проверьте три условия:
|
||||||
|
|
||||||
|
- Gateway является текущим основным шлюзом Mac;
|
||||||
|
- на обоих устройствах сохранена одна и та же подписка;
|
||||||
|
- с Mac открывается `http://АДРЕС-GATEWAY:3456`.
|
||||||
|
|
||||||
|
### Проверка конфигурации без запуска
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.gateway.yml config
|
||||||
|
docker compose -f docker-compose.client.yml config
|
||||||
|
docker compose -f docker-compose.client.local.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
|
||||||
|
|
||||||
|
### Локальное тестирование Harbor Connect
|
||||||
|
|
||||||
|
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.client.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.client.local.yml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
|
||||||
|
|
||||||
|
## Служебные команды
|
||||||
|
|
||||||
|
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
||||||
|
|
||||||
|
### Команды npm
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
||||||
|
| `npm test` | Запустить автоматические проверки |
|
||||||
|
| `npm run build` | Собрать веб-интерфейс в `dist/` |
|
||||||
|
| `npm run dev` | Запустить Vite для разработки интерфейса |
|
||||||
|
| `npm start` | Запустить управляющий Node.js-сервис в подготовленном окружении |
|
||||||
|
|
||||||
|
### Сборка и развёртывание
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `./scripts/build-runtime-base.sh` | Собрать базовый runtime-образ с Node.js, сетевыми утилитами и `sing-box` |
|
||||||
|
| `./scripts/build-on-107-deploy-111.sh` | Собрать Gateway на хосте `107` и развернуть на хосте `111`; хосты меняются через `BUILD_HOST` и `DEPLOY_HOST` |
|
||||||
|
| `GATEWAY_IMAGE=<образ> ./scripts/deploy-gateway.sh` | Развернуть уже собранный образ в `/opt/vpn-proxy` |
|
||||||
|
| `./scripts/harbor-network-monitor.sh` | Один раз записать текущий Gateway macOS; обычно этот скрипт запускает установленный LaunchAgent |
|
||||||
|
|
||||||
|
Отправка изменений в ветку `master` также запускает автоматическую сборку и развёртывание Gateway через Gitea Actions. Каждый деплой пересоздаёт `vpn-proxy-control`, поэтому строка `B` соответствует текущему коду API. Процесс `sing-box` и сетевые правила остаются в `vpn-proxy-dataplane`; он пересоздаётся только при изменении его runtime-зависимостей, а его фактическая версия показывается отдельно как `D`.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
||||||
|
|
||||||
|
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
harbor-connect:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
args:
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
container_name: harbor-connect
|
||||||
|
environment:
|
||||||
|
APP_MODE: client
|
||||||
|
PORT: ${PORT:-3456}
|
||||||
|
PROXY_PORT: ${CLIENT_PROXY_PORT:-8082}
|
||||||
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
||||||
|
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
ALL_PROXY: ""
|
||||||
|
http_proxy: ""
|
||||||
|
https_proxy: ""
|
||||||
|
all_proxy: ""
|
||||||
|
NO_PROXY: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
no_proxy: "localhost,127.0.0.1,host.docker.internal"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}"
|
||||||
|
- "127.0.0.1:${CLIENT_PROXY_PORT:-8082}:${CLIENT_PROXY_PORT:-8082}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-client-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-client-cache:/var/lib/sing-box
|
||||||
|
- ./.runtime:/run/harbor-host:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-client-data:
|
||||||
|
sing-box-client-cache:
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
x-gateway-image: &gateway-image
|
||||||
|
image: ${GATEWAY_IMAGE:-vpn-proxy-gateway:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||||
|
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||||
|
|
||||||
|
services:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
<<: *gateway-image
|
||||||
|
container_name: vpn-proxy-dataplane
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: dataplane
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
vpn-proxy-control:
|
||||||
|
<<: *gateway-image
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: control
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3456}:${PORT:-3456}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
depends_on:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-data:
|
||||||
|
sing-box-cache:
|
||||||
|
vpn-proxy-runtime:
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# СЕРВЕРНАЯ КОНФИГУРАЦИЯ (Linux VPS)
|
|
||||||
# ==========================================
|
|
||||||
# Используйте этот файл на удалённом сервере:
|
|
||||||
# docker compose -f docker-compose.server.yml up -d
|
|
||||||
#
|
|
||||||
# network_mode: host решает проблему UDP ASSOCIATE
|
|
||||||
# для SOCKS5 прокси (важно для Discord голоса!)
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
version: "3.9"
|
|
||||||
services:
|
|
||||||
sing-proxy:
|
|
||||||
container_name: sing-proxy
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.singbox
|
|
||||||
|
|
||||||
# HOST MODE — контейнер использует сеть хоста напрямую
|
|
||||||
# Это решает проблему UDP ASSOCIATE для SOCKS5
|
|
||||||
# ВАЖНО: работает только на Linux, не на Windows/macOS!
|
|
||||||
network_mode: host
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# Порт веб-интерфейса (по умолчанию 3456)
|
|
||||||
- PORT=${PORT:-3456}
|
|
||||||
# Порт прокси HTTP/SOCKS5 (по умолчанию 8080)
|
|
||||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256m
|
|
||||||
|
|
||||||
# Порты при network_mode: host не нужно пробрасывать,
|
|
||||||
# они автоматически доступны на хосте:
|
|
||||||
# - 3456: Веб-интерфейс (PORT)
|
|
||||||
# - 8080: SOCKS5/HTTP прокси (PROXY_PORT)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
|
||||||
sing-proxy:
|
|
||||||
container_name: sing-proxy
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.singbox
|
|
||||||
ports:
|
|
||||||
# Веб-интерфейс (можно переопределить: PORT=9090 docker compose up)
|
|
||||||
- "${PORT:-3456}:${PORT:-3456}"
|
|
||||||
# Прокси HTTP/SOCKS5 (можно переопределить: PROXY_PORT=8082 docker compose up)
|
|
||||||
- "${PROXY_PORT:-8080}:${PROXY_PORT:-8080}"
|
|
||||||
environment:
|
|
||||||
- PORT=${PORT:-3456}
|
|
||||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256m
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM alpine:3.20
|
|
||||||
ARG SINGBOX_VER=1.12.13
|
|
||||||
|
|
||||||
# Устанавливаем зависимости, включая dos2unix для исправления скриптов
|
|
||||||
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd python3 dos2unix && update-ca-certificates
|
|
||||||
|
|
||||||
# Автоматическое определение архитектуры и установка sing-box
|
|
||||||
RUN ARCH=$(uname -m) && \
|
|
||||||
if [ "$ARCH" = "x86_64" ]; then SB_ARCH="amd64"; \
|
|
||||||
elif [ "$ARCH" = "aarch64" ]; then SB_ARCH="arm64"; \
|
|
||||||
else SB_ARCH="amd64"; fi && \
|
|
||||||
curl -L -o /tmp/sb.tar.gz https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VER}/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}.tar.gz \
|
|
||||||
&& tar -xf /tmp/sb.tar.gz -C /tmp \
|
|
||||||
&& mv /tmp/sing-box-${SINGBOX_VER}-linux-${SB_ARCH}/sing-box /usr/local/bin/sing-box \
|
|
||||||
&& chmod +x /usr/local/bin/sing-box \
|
|
||||||
&& adduser -D -u 1000 suser
|
|
||||||
|
|
||||||
COPY --chown=suser:suser docker/entrypoint.sh /app/
|
|
||||||
COPY --chown=suser:suser web/ /app/web/
|
|
||||||
|
|
||||||
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
|
|
||||||
RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
# Порты по умолчанию (можно переопределить через ENV)
|
|
||||||
# PORT - веб-интерфейс, PROXY_PORT - прокси
|
|
||||||
EXPOSE 3456 8080 9090
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
CONFIG_FILE="/app/data/client.json"
|
|
||||||
SINGBOX_PID=""
|
|
||||||
|
|
||||||
# Порты из ENV (по умолчанию: 3456 для веба, 8080 для прокси)
|
|
||||||
PORT="${PORT:-3456}"
|
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
mkdir -p /app/data
|
|
||||||
|
|
||||||
start_singbox() {
|
|
||||||
if [[ -f "$CONFIG_FILE" ]]; then
|
|
||||||
echo "$(date): Starting sing-box..."
|
|
||||||
sing-box run -c "$CONFIG_FILE" &
|
|
||||||
SINGBOX_PID=$!
|
|
||||||
echo "$(date): sing-box started with PID $SINGBOX_PID"
|
|
||||||
else
|
|
||||||
echo "$(date): Config file not found. Use web UI at :$PORT to apply config."
|
|
||||||
SINGBOX_PID=""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_singbox() {
|
|
||||||
if [[ -n "$SINGBOX_PID" ]]; then
|
|
||||||
echo "$(date): Stopping sing-box (PID $SINGBOX_PID)..."
|
|
||||||
kill "$SINGBOX_PID" 2>/dev/null || true
|
|
||||||
wait "$SINGBOX_PID" 2>/dev/null || true
|
|
||||||
SINGBOX_PID=""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
restart_singbox() {
|
|
||||||
stop_singbox
|
|
||||||
start_singbox
|
|
||||||
}
|
|
||||||
|
|
||||||
start_singbox
|
|
||||||
|
|
||||||
# Start Web UI Server with configurable port
|
|
||||||
echo "$(date): Starting Web UI on port $PORT..."
|
|
||||||
PORT=$PORT PROXY_PORT=$PROXY_PORT python3 /app/web/server.py &
|
|
||||||
WEBUI_PID=$!
|
|
||||||
|
|
||||||
# HTTP Control Server (Simple Netcat loop)
|
|
||||||
# Listens on 9090.
|
|
||||||
# Endpoint: /reload -> Restart sing-box (used by web_server.py after config change)
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
# Read the request using nc.
|
|
||||||
REQ=$(echo -e "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n" | nc -l -p 9090 -q 1)
|
|
||||||
echo "$(date): Received request on 9090"
|
|
||||||
|
|
||||||
if echo "$REQ" | grep -q "GET /reload"; then
|
|
||||||
echo "$(date): Action: RELOAD (Restart sing-box)"
|
|
||||||
restart_singbox
|
|
||||||
else
|
|
||||||
echo "$(date): Unknown request or ping."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
CONTROL_PID=$!
|
|
||||||
|
|
||||||
# Keep container alive - wait for any background process
|
|
||||||
echo "$(date): Entrypoint ready. Waiting for processes..."
|
|
||||||
|
|
||||||
# Wait indefinitely - if WebUI dies, restart container
|
|
||||||
wait $WEBUI_PID
|
|
||||||
-178
@@ -1,178 +0,0 @@
|
|||||||
# 🐳 Docker — Веб-интерфейс для управления VPN
|
|
||||||
|
|
||||||
> **Это продвинутый способ** установки с красивым веб-интерфейсом. Для большинства пользователей рекомендуется использовать [основной способ через PowerShell](../README.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Что это даёт?
|
|
||||||
|
|
||||||
- 🌐 **Веб-интерфейс** — управление через браузер на http://localhost:3456
|
|
||||||
- 📡 **Подписки** — автоматическое получение списка серверов
|
|
||||||
- 🔄 **Переключение серверов** — в один клик
|
|
||||||
- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Требования
|
|
||||||
|
|
||||||
### Docker Desktop
|
|
||||||
|
|
||||||
1. Скачайте: https://www.docker.com/products/docker-desktop/
|
|
||||||
2. Установите и запустите
|
|
||||||
3. Убедитесь, что иконка 🐳 есть в трее (панель задач)
|
|
||||||
|
|
||||||
> 💡 На Windows может потребоваться WSL2. Docker Desktop предложит его установить автоматически.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Установка
|
|
||||||
|
|
||||||
### Шаг 1: Откройте терминал
|
|
||||||
|
|
||||||
Откройте PowerShell или Командную строку и перейдите в папку проекта:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd путь\к\папке\vpn-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 2: Соберите контейнер
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
Это создаст образ со всеми необходимыми компонентами. Выполняется один раз.
|
|
||||||
|
|
||||||
### Шаг 3: Запустите
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Флаг `-d` запускает контейнер в фоновом режиме.
|
|
||||||
|
|
||||||
### Шаг 4: Откройте веб-интерфейс
|
|
||||||
|
|
||||||
Перейдите в браузере: **http://localhost:3456**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Использование веб-интерфейса
|
|
||||||
|
|
||||||
### Режим подписки
|
|
||||||
|
|
||||||
1. Вставьте URL подписки в поле "Подписка"
|
|
||||||
2. Нажмите **"Загрузить серверы"**
|
|
||||||
3. Выберите сервер из списка
|
|
||||||
4. Нажмите **"Применить"**
|
|
||||||
|
|
||||||
### Режим VLESS
|
|
||||||
|
|
||||||
1. Перейдите на вкладку "VLESS Ключ"
|
|
||||||
2. Вставьте VLESS-ссылку (`vless://...`)
|
|
||||||
3. Нажмите **"Применить"**
|
|
||||||
|
|
||||||
> 💡 Настройки сохраняются в папке `data/` и восстанавливаются при перезапуске.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Порты
|
|
||||||
|
|
||||||
| Порт | Назначение | URL |
|
|
||||||
|------|------------|-----|
|
|
||||||
| `3456` | Веб-интерфейс | http://localhost:3456 |
|
|
||||||
| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` |
|
|
||||||
| `9090` | API управления (внутренний) | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Управление контейнером
|
|
||||||
|
|
||||||
| Действие | Команда |
|
|
||||||
|----------|---------|
|
|
||||||
| Посмотреть статус | `docker ps` |
|
|
||||||
| Посмотреть логи | `docker logs --tail 50 sing-proxy` |
|
|
||||||
| Остановить | `docker compose stop` |
|
|
||||||
| Запустить снова | `docker compose start` |
|
|
||||||
| Перезапустить | `docker compose restart` |
|
|
||||||
| Полностью удалить | `docker compose down` |
|
|
||||||
| Пересобрать | `docker compose up -d --build` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Обновление
|
|
||||||
|
|
||||||
Если вы обновили код из репозитория:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Остановить текущий контейнер
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Пересобрать с новыми изменениями
|
|
||||||
docker compose build --no-cache
|
|
||||||
|
|
||||||
# Запустить заново
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
> 💡 Подписка и настройки сохраняются в папке `data/` и не потеряются.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Настройка приложений
|
|
||||||
|
|
||||||
### Для VS Code
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"http.proxy": "http://127.0.0.1:8080",
|
|
||||||
"http.proxyStrictSSL": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Для браузера
|
|
||||||
|
|
||||||
- **Адрес**: `127.0.0.1`
|
|
||||||
- **Порт**: `8080`
|
|
||||||
- **Тип**: HTTP или SOCKS5
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Проблемы и решения
|
|
||||||
|
|
||||||
### Страница localhost:3456 не открывается
|
|
||||||
|
|
||||||
**Причина:** Контейнер не запущен.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Проверьте статус
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Если контейнера нет — запустите
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Connection refused"
|
|
||||||
|
|
||||||
**Причина:** VPN-ссылка не применена.
|
|
||||||
|
|
||||||
1. Откройте http://localhost:3456
|
|
||||||
2. Примените VLESS-ссылку или загрузите подписку
|
|
||||||
|
|
||||||
### Медленное подключение
|
|
||||||
|
|
||||||
Попробуйте другой сервер в веб-интерфейсе — некоторые серверы могут быть перегружены.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Ограничения Docker на Windows
|
|
||||||
|
|
||||||
- **UDP для Discord:** Docker на Windows/macOS имеет проблемы с UDP ASSOCIATE для SOCKS5. Для Discord рекомендуется использовать [нативную установку](../README.md).
|
|
||||||
|
|
||||||
- **Для полной поддержки UDP** используйте [установку на Linux сервер](SERVER.md) с `network_mode: host`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Вернуться к основной инструкции](../README.md)
|
|
||||||
-278
@@ -1,278 +0,0 @@
|
|||||||
# 🌍 Установка на Сервер (Linux VPS)
|
|
||||||
|
|
||||||
> Эта инструкция для установки прокси на удалённый сервер. После установки вы сможете подключаться к нему с любого устройства.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Зачем это нужно?
|
|
||||||
|
|
||||||
- 🌐 **Один прокси для всех устройств** — компьютер, телефон, планшет
|
|
||||||
- 🔒 **Работает 24/7** — не нужно держать компьютер включённым
|
|
||||||
- 📡 **Полная поддержка UDP** — голосовые звонки и игры работают отлично
|
|
||||||
- 🏠 **Доступ из любого места** — дома, на работе, в поездке
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Требования к серверу
|
|
||||||
|
|
||||||
- **ОС:** Ubuntu 20.04+, Debian 11+, или любой современный Linux
|
|
||||||
- **Ресурсы:** Минимум 512 MB RAM, 1 CPU
|
|
||||||
- **Порты:** 3456 (веб-интерфейс), 8080 (прокси)
|
|
||||||
- **Доступ:** SSH подключение
|
|
||||||
|
|
||||||
> 💡 Подойдёт любой VPS за $3-5/месяц от DigitalOcean, Vultr, Hetzner и др.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Установка
|
|
||||||
|
|
||||||
### Шаг 1: Подключитесь к серверу
|
|
||||||
|
|
||||||
Откройте терминал (PowerShell на Windows, Terminal на Mac/Linux):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Введите пароль когда попросят.
|
|
||||||
|
|
||||||
> 💡 **Совет:** Если вы на Windows и нет ssh команды, используйте PuTTY или Windows Terminal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 2: Установите Docker
|
|
||||||
|
|
||||||
Если Docker ещё не установлен:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Автоматическая установка Docker
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
|
|
||||||
# Проверка что Docker работает
|
|
||||||
docker --version
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 3: Загрузите проект
|
|
||||||
|
|
||||||
**Вариант A: Через Git**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-repo/vpn-proxy.git
|
|
||||||
cd vpn-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
**Вариант B: Загрузка файлов вручную**
|
|
||||||
|
|
||||||
Если git недоступен, скачайте ZIP архив и распакуйте на сервере.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 4: Запустите контейнер
|
|
||||||
|
|
||||||
> ⚠️ **Важно:** Используйте `docker-compose.server.yml` — он настроен для серверов!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Это запустит контейнер с `network_mode: host`, что решает проблемы с UDP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 5: Откройте порты в файрволе
|
|
||||||
|
|
||||||
**Для UFW (Ubuntu/Debian):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ufw allow 3456/tcp # Веб-интерфейс
|
|
||||||
ufw allow 8080/tcp # Прокси TCP
|
|
||||||
ufw allow 8080/udp # Прокси UDP (для голоса/игр)
|
|
||||||
ufw reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Для firewalld (CentOS/RHEL):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
firewall-cmd --permanent --add-port=3456/tcp
|
|
||||||
firewall-cmd --permanent --add-port=8080/tcp
|
|
||||||
firewall-cmd --permanent --add-port=8080/udp
|
|
||||||
firewall-cmd --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
**Для iptables:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
iptables -A INPUT -p tcp --dport 3456 -j ACCEPT
|
|
||||||
iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
|
|
||||||
iptables -A INPUT -p udp --dport 8080 -j ACCEPT
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Шаг 6: Настройте VPN через веб-интерфейс
|
|
||||||
|
|
||||||
1. Откройте в браузере: `http://ваш_сервер_ip:3456`
|
|
||||||
2. Вставьте VLESS-ссылку или URL подписки
|
|
||||||
3. Нажмите "Применить"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Проверка работы
|
|
||||||
|
|
||||||
На сервере:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить что контейнер запущен
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# Посмотреть логи
|
|
||||||
docker logs --tail 20 sing-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
С вашего компьютера:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить прокси
|
|
||||||
curl -x http://ваш_сервер_ip:8080 https://ipinfo.io/ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Должен показать IP VPN-сервера (не IP вашего VPS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🖥️ Подключение с Windows
|
|
||||||
|
|
||||||
### Настройка в manage.ps1
|
|
||||||
|
|
||||||
При настройке Discord (пункт [2]) вы можете указать адрес удалённого прокси:
|
|
||||||
|
|
||||||
```
|
|
||||||
Введите адрес прокси (IP:порт): ваш_сервер_ip:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройка в браузере/приложениях
|
|
||||||
|
|
||||||
- **Адрес:** `ваш_сервер_ip`
|
|
||||||
- **Порт:** `8080`
|
|
||||||
- **Тип:** HTTP или SOCKS5
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Управление
|
|
||||||
|
|
||||||
| Действие | Команда |
|
|
||||||
|----------|---------|
|
|
||||||
| Посмотреть статус | `docker ps` |
|
|
||||||
| Логи | `docker logs --tail 50 sing-proxy` |
|
|
||||||
| Остановить | `docker compose -f docker-compose.server.yml stop` |
|
|
||||||
| Запустить | `docker compose -f docker-compose.server.yml start` |
|
|
||||||
| Перезапустить | `docker compose -f docker-compose.server.yml restart` |
|
|
||||||
| Удалить | `docker compose -f docker-compose.server.yml down` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Рекомендации по безопасности
|
|
||||||
|
|
||||||
### 1. Смените стандартные порты
|
|
||||||
|
|
||||||
Отредактируйте `docker-compose.server.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- PORT=54321 # Вместо 3456
|
|
||||||
- PROXY_PORT=12345 # Вместо 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Ограничьте доступ к веб-интерфейсу
|
|
||||||
|
|
||||||
Если веб-интерфейс нужен только для первоначальной настройки:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Закрыть веб-порт после настройки
|
|
||||||
ufw delete allow 3456/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Используйте SSH туннель
|
|
||||||
|
|
||||||
Для безопасного доступа к веб-интерфейсу:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -L 3456:localhost:3456 root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем откройте http://localhost:3456 в браузере.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Обновление
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd vpn-proxy
|
|
||||||
|
|
||||||
# Получить обновления
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# Пересобрать контейнер
|
|
||||||
docker compose -f docker-compose.server.yml down
|
|
||||||
docker compose -f docker-compose.server.yml build --no-cache
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Проблемы и решения
|
|
||||||
|
|
||||||
### Порт 3456 не открывается
|
|
||||||
|
|
||||||
**Причина:** Файрвол блокирует подключения.
|
|
||||||
|
|
||||||
**Решение:** Проверьте настройки файрвола, см. Шаг 5.
|
|
||||||
|
|
||||||
### "Permission denied" при запуске Docker
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Добавить пользователя в группу docker
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
|
|
||||||
# Перезайти
|
|
||||||
exit
|
|
||||||
ssh root@ваш_сервер_ip
|
|
||||||
```
|
|
||||||
|
|
||||||
### Контейнер постоянно перезапускается
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Посмотреть логи ошибок
|
|
||||||
docker logs sing-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
Обычно проблема в неверной VLESS-ссылке.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📐 Изменение портов
|
|
||||||
|
|
||||||
По умолчанию:
|
|
||||||
- **3456** — веб-интерфейс
|
|
||||||
- **8080** — прокси
|
|
||||||
|
|
||||||
Для изменения создайте файл `.env` в папке проекта:
|
|
||||||
|
|
||||||
```env
|
|
||||||
PORT=54321
|
|
||||||
PROXY_PORT=12345
|
|
||||||
```
|
|
||||||
|
|
||||||
И перезапустите:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.server.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[← Вернуться к основной инструкции](../README.md)
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.ts`. 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.
|
||||||
@@ -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.ts`.
|
||||||
|
|
||||||
|
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-3456}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
|
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[client-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||||
|
|
||||||
|
export APP_MODE=client
|
||||||
|
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||||
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
|
exec node /app/dist/server/main.js
|
||||||
Executable
+213
@@ -0,0 +1,213 @@
|
|||||||
|
#!/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}"
|
||||||
|
DEVICE_POLICY_CHAIN="${DEVICE_POLICY_CHAIN:-VPN_PROXY_DEVICE_POLICY}"
|
||||||
|
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
||||||
|
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
||||||
|
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
|
||||||
|
TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}"
|
||||||
|
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$APP_COMPONENT" == "control" ]]; then
|
||||||
|
exec node /app/dist/server/main.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
ipt() {
|
||||||
|
iptables -w "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
ipt_traffic() {
|
||||||
|
iptables -w 1 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_proxy_firewall() {
|
||||||
|
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_tproxy() {
|
||||||
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_device_policy() {
|
||||||
|
ipt -t mangle -F "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt -t mangle -F "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt -t mangle -X "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_policy() {
|
||||||
|
cleanup_device_policy
|
||||||
|
ipt -t mangle -N "$DEVICE_POLICY_CHAIN" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_B" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "$DEVICE_POLICY_CHAIN" -j "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_device_traffic() {
|
||||||
|
ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -D POSTROUTING -j "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -F "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_traffic() {
|
||||||
|
log "setup raw device traffic counters"
|
||||||
|
cleanup_device_traffic
|
||||||
|
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
|
elif command -v sysctl >/dev/null 2>&1; then
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_proxy_firewall() {
|
||||||
|
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
ipt -N "$PROXY_INPUT_CHAIN"
|
||||||
|
for cidr in $PROXY_ALLOWED_CIDRS; do
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -j DROP
|
||||||
|
ipt -I INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding() {
|
||||||
|
log "setup direct gateway forwarding"
|
||||||
|
cleanup_gateway_forwarding
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ipt -N "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -N "$GATEWAY_NAT_CHAIN"
|
||||||
|
for cidr in $GATEWAY_CLIENT_CIDRS; do
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
ipt -t nat -A "$GATEWAY_NAT_CHAIN" -s "$cidr" -m addrtype ! --dst-type LOCAL -j MASQUERADE
|
||||||
|
done
|
||||||
|
ipt -I FORWARD 1 -j "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -I POSTROUTING 1 -j "$GATEWAY_NAT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_tproxy() {
|
||||||
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
|
cleanup_tproxy
|
||||||
|
if ! setup_device_policy; then
|
||||||
|
log "device policy unavailable; using global VPN fallback"
|
||||||
|
cleanup_device_policy
|
||||||
|
fi
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -i 'br-+' -j RETURN
|
||||||
|
|
||||||
|
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
|
||||||
|
for cidr in $BYPASS_CIDRS; do
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
|
||||||
|
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
|
||||||
|
else
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding
|
||||||
|
setup_tproxy
|
||||||
|
if ! setup_device_traffic; then
|
||||||
|
log "device traffic counters unavailable; VPN routing remains active"
|
||||||
|
cleanup_device_traffic
|
||||||
|
fi
|
||||||
|
setup_proxy_firewall
|
||||||
|
|
||||||
|
node /app/dist/server/main.js &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||||
|
wait "$APP_PID"
|
||||||
|
STATUS=$?
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
|
exit "$STATUS"
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link id="harbor-favicon" rel="icon" href="/harbor-connect.svg?v=2" type="image/svg+xml" sizes="any" />
|
||||||
|
<title>Harbor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/web/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🚀 VPN PROXY INSTALLER
|
|
||||||
# ==========================================
|
|
||||||
# This script automatically downloads and installs VPN Proxy
|
|
||||||
# Usage:
|
|
||||||
# iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
|
||||||
|
|
||||||
# Enable UTF-8 for emoji support
|
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
# --- 1. Check Admin Rights ---
|
|
||||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) {
|
|
||||||
Write-Warning "⚠️ Administrator rights required!"
|
|
||||||
Write-Host "🔄 Restarting script as Administrator..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# Save script to temp file if running from memory (iex)
|
|
||||||
if ($MyInvocation.MyCommand.CommandType -eq 'Script') {
|
|
||||||
Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" -Verb RunAs
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# If running via IEX, we cannot simple restart the file.
|
|
||||||
# We ask user to run terminal as admin.
|
|
||||||
Write-Error "Please run PowerShell as Administrator and try again."
|
|
||||||
}
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 2. Settings ---
|
|
||||||
$InstallRoot = "C:\Tools"
|
|
||||||
$InstallDir = "$InstallRoot\vpn-proxy"
|
|
||||||
# Exact link provided by user
|
|
||||||
$ZipUrl = "https://git.dokops.ru/dokril/vpn-proxy/archive/master.zip"
|
|
||||||
$TempZip = "$env:TEMP\vpn-proxy-install.zip"
|
|
||||||
|
|
||||||
Write-Host "🚀 Starting VPN Proxy installation..." -ForegroundColor Green
|
|
||||||
Write-Host "📂 Install path: $InstallDir" -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Move to temp folder to avoid blocking deletion if we are already in C:\Tools\vpn-proxy
|
|
||||||
Set-Location $env:TEMP
|
|
||||||
|
|
||||||
# --- 3. Prepare Directory ---
|
|
||||||
if (-not (Test-Path $InstallRoot)) {
|
|
||||||
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 4. Downloading ---
|
|
||||||
Write-Host "⬇️ Downloading update archive..." -ForegroundColor Cyan
|
|
||||||
try {
|
|
||||||
Invoke-WebRequest -Uri $ZipUrl -OutFile $TempZip
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "❌ Failed to download from $ZipUrl`nCheck your internet connection."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 5. Extracting ---
|
|
||||||
Write-Host "📦 Extracting..." -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# If folder exists, delete old one
|
|
||||||
if (Test-Path $InstallDir) {
|
|
||||||
try {
|
|
||||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "⚠️ Failed to delete old folder $InstallDir"
|
|
||||||
Write-Warning " Error: $($_.Exception.Message)"
|
|
||||||
Write-Warning " Make sure files are not open in other programs and you are not inside this folder."
|
|
||||||
|
|
||||||
$retry = Read-Host " Press Enter to try again (or Ctrl+C to cancel)"
|
|
||||||
try {
|
|
||||||
Remove-Item $InstallDir -Recurse -Force -ErrorAction Stop
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "❌ Still failed to delete folder. Installation aborted."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Expand-Archive -Path $TempZip -DestinationPath $InstallRoot -Force
|
|
||||||
|
|
||||||
# Archives usually extract to vpn-proxy-master or vpn-proxy-main
|
|
||||||
# We need to rename it to vpn-proxy
|
|
||||||
$ExtractedFolder = Get-ChildItem -Path $InstallRoot -Directory | Where-Object { $_.Name -match "vpn-proxy-(master|main)" } | Select-Object -First 1
|
|
||||||
|
|
||||||
if ($ExtractedFolder) {
|
|
||||||
Rename-Item -Path $ExtractedFolder.FullName -NewName "vpn-proxy" -Force
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove temp archive
|
|
||||||
Remove-Item $TempZip -Force
|
|
||||||
|
|
||||||
if (-not (Test-Path "$InstallDir\manage.ps1")) {
|
|
||||||
Write-Error "❌ Installation error: manage.ps1 not found in $InstallDir"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 6. Finish ---
|
|
||||||
Write-Host "✅ Installation complete!" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "To start the control menu, run:" -ForegroundColor Cyan
|
|
||||||
Write-Host "& `"$InstallDir\manage.ps1`"" -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
Executable
+27
@@ -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"
|
||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🚀 VPN PROXY CONTROL CENTER (WINDOWS)
|
|
||||||
# ==========================================
|
|
||||||
# Главный скрипт управления. Запускать от имени Администратора.
|
|
||||||
# Использование: .\manage.ps1 [-Debug]
|
|
||||||
|
|
||||||
param([switch]$Debug)
|
|
||||||
|
|
||||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
|
||||||
$LibDir = "$ScriptDir\scripts\lib"
|
|
||||||
|
|
||||||
# Проверка библиотек
|
|
||||||
if (!(Test-Path "$LibDir\Common.ps1")) {
|
|
||||||
Write-Host "❌ Ошибка: Не найдены библиотеки в $LibDir" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
. "$LibDir\Common.ps1"
|
|
||||||
. "$LibDir\System.ps1"
|
|
||||||
|
|
||||||
# Установка режима отладки
|
|
||||||
if ($Debug) {
|
|
||||||
Set-DebugMode -Enabled $true
|
|
||||||
}
|
|
||||||
|
|
||||||
Ensure-Admin
|
|
||||||
|
|
||||||
while ($true) {
|
|
||||||
Write-Header "VPN PROXY CONTROL CENTER" -ClearScreen
|
|
||||||
|
|
||||||
# --- СБОР СТАТУСОВ ---
|
|
||||||
|
|
||||||
# 1. Native Sing-box
|
|
||||||
$sbStatus = Get-TaskStatus -Name "SingBoxProxy"
|
|
||||||
$sbStr = if ($sbStatus -eq "Running") { "РАБОТАЕТ" } else { "ОСТАНОВЛЕН" }
|
|
||||||
$sbColor = if ($sbStatus -eq "Running") { "Green" } else { "Yellow" }
|
|
||||||
if (!$sbStatus) { $sbStr = "НЕ УСТАНОВЛЕН"; $sbColor = "Gray" }
|
|
||||||
|
|
||||||
# 2. Discord Proxy
|
|
||||||
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
|
||||||
$discStr = if ($discSvc.Status -eq 'Running') { "АКТИВЕН" } else { "НЕ АКТИВЕН" }
|
|
||||||
$discColor = if ($discSvc.Status -eq 'Running') { "Green" } else { "Gray" }
|
|
||||||
|
|
||||||
# --- ОТРИСОВКА МЕНЮ ---
|
|
||||||
|
|
||||||
Write-Host " [1] 📦 VPN Клиент (Sing-box)" -NoNewline -ForegroundColor White
|
|
||||||
Write-Host " [$sbStr]" -ForegroundColor $sbColor
|
|
||||||
Write-Host " Основной способ. Поддерживает UDP и игры." -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Показываем информацию о подключении если sing-box работает
|
|
||||||
if ($sbStatus -eq "Running") {
|
|
||||||
$LocalProxyPort = 1080
|
|
||||||
. "$LibDir\Net.ps1"
|
|
||||||
$ips = Get-LocalIPs
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ" -ForegroundColor Cyan
|
|
||||||
Write-Host " ─────────────────────────────" -ForegroundColor DarkGray
|
|
||||||
Write-Host " Локально: " -NoNewline -ForegroundColor Gray
|
|
||||||
Write-Host "127.0.0.1:$LocalProxyPort" -ForegroundColor Green
|
|
||||||
|
|
||||||
if ($ips) {
|
|
||||||
Write-Host " Из сети:" -ForegroundColor Gray
|
|
||||||
foreach ($ip in $ips) {
|
|
||||||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host " [2] 🎮 Настройка Discord/Vesktop" -NoNewline -ForegroundColor White
|
|
||||||
Write-Host " [$discStr]" -ForegroundColor $discColor
|
|
||||||
Write-Host " Маршрутизация приложений через прокси." -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host " ---------------------------------------" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
|
|
||||||
Write-Host " [3] 🔄 Обновить статус" -ForegroundColor White
|
|
||||||
Write-Host " [U] ❌ Удалить всё (Uninstall)" -ForegroundColor Red
|
|
||||||
Write-Host " [q] Выход" -ForegroundColor White
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$choice = Read-Host "👉 Ваш выбор"
|
|
||||||
|
|
||||||
switch ($choice) {
|
|
||||||
"1" { & "$ScriptDir\scripts\setup-singbox.ps1" }
|
|
||||||
"2" { & "$ScriptDir\scripts\setup-discord.ps1" }
|
|
||||||
"3" { continue }
|
|
||||||
"u" { & "$ScriptDir\scripts\uninstall-all.ps1" }
|
|
||||||
"q" { exit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"label": "Prometheus",
|
||||||
|
"description": "Prometheus data source with the Harbor Gateway target",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "prometheus",
|
||||||
|
"pluginName": "Prometheus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"description": "Трафик, учтённый Harbor Gateway и Proxy. Физический и Direct-трафик вне Harbor не включён.",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Обзор",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Сколько секунд прошло с последнего успешного обновления. До 60 секунд — штатно, 60–119 — задержка, от 120 — данные устарели.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"decimals": 0,
|
||||||
|
"noValue": "Нет данных",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 120
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "time() - min(harbor_traffic_last_observed_timestamp_seconds)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Общий трафик",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "time() - harbor_domain_traffic_last_observed_timestamp_seconds",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Домены",
|
||||||
|
"range": false,
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Свежесть данных сейчас",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Текущая суммарная скорость всех устройств, сглаженная за последние 5 минут.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных",
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Скачивание"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "blue",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "Отправка"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "orange",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 18,
|
||||||
|
"x": 6,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"mean"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[5m])) > 0",
|
||||||
|
"legendFormat": "Скачивание",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[5m])) > 0",
|
||||||
|
"legendFormat": "Отправка",
|
||||||
|
"range": true,
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Скорость сейчас, средняя за 5 минут",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Устройства с наибольшим учтённым трафиком в текущем диапазоне времени. Нажмите название, чтобы выбрать устройство в детализации ниже.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "device_id"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.hidden",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "name"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "links",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"targetBlank": false,
|
||||||
|
"title": "Выбрать устройство",
|
||||||
|
"url": "${__url_time_range}&var-device_id=${__data.fields[\"device_id\"]}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 7
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"enablePagination": true,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": [
|
||||||
|
{
|
||||||
|
"desc": true,
|
||||||
|
"displayName": "За период"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "topk(10, (sum by (device_id) (increase(harbor_device_traffic_bytes_total[$__range])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info))",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Топ-10 устройств за выбранный период",
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true
|
||||||
|
},
|
||||||
|
"indexByName": {
|
||||||
|
"name": 0,
|
||||||
|
"ip": 1,
|
||||||
|
"Value": 2,
|
||||||
|
"device_id": 3
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"Value": "За период",
|
||||||
|
"ip": "IP",
|
||||||
|
"name": "Устройство"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Устройства",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Суммарная скорость Gateway/Proxy и upload/download для каждого активного устройства из фильтра, средняя за последние 5 минут. Нулевые устройства скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 6,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"scaleDistribution": {
|
||||||
|
"linearThreshold": 1048576,
|
||||||
|
"log": 2,
|
||||||
|
"type": "symlog"
|
||||||
|
},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет активного трафика",
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 17
|
||||||
|
},
|
||||||
|
"id": 14,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"mean",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "(sum by (device_id) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Скорость по устройствам сейчас",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Скорость выбранного scope по Gateway/Proxy и upload/download, средняя за последние 5 минут. При выборе «Все устройства» остаётся максимум четыре суммарные series; нулевые series скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 8,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 4,
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {
|
||||||
|
"group": "A",
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных за период",
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 26
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull",
|
||||||
|
"mean",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": true
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi",
|
||||||
|
"sort": "desc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (source, direction) (rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[5m])) > 0",
|
||||||
|
"legendFormat": "{{source}} · {{direction}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Скорость по источникам и направлениям сейчас",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Накопленные счётчики выбранного scope за всё время хранения Harbor. При «Все устройства» счётчики суммируются; нулевые series скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Трафик не учтён",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 35
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "basic",
|
||||||
|
"maxVizHeight": 300,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"namePlacement": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showUnfilled": false,
|
||||||
|
"sizing": "auto",
|
||||||
|
"valueMode": "text"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (source, direction) (harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}) > 0",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{source}} · {{direction}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Накоплено по источникам и направлениям",
|
||||||
|
"type": "bargauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 42
|
||||||
|
},
|
||||||
|
"id": 12,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Сервисы и домены",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Сервисы всех устройств или одного устройства из единого фильтра «Устройства» по объёму traffic в текущем диапазоне времени. Нулевые серии скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных за период",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 10,
|
||||||
|
"x": 0,
|
||||||
|
"y": 43
|
||||||
|
},
|
||||||
|
"id": 8,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "basic",
|
||||||
|
"maxVizHeight": 300,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"namePlacement": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showUnfilled": false,
|
||||||
|
"sizing": "auto",
|
||||||
|
"valueMode": "text"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "topk(10, sum by (service) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{service}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Сервисы за выбранный период",
|
||||||
|
"type": "bargauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Домены всех устройств или одного устройства из единого фильтра «Устройства» в текущем диапазоне времени. При общем scope одинаковые домены суммируются; нулевые строки скрыты.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"align": "auto",
|
||||||
|
"cellOptions": {
|
||||||
|
"type": "auto"
|
||||||
|
},
|
||||||
|
"inspect": false
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных за период",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 14,
|
||||||
|
"x": 10,
|
||||||
|
"y": 43
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"options": {
|
||||||
|
"cellHeight": "sm",
|
||||||
|
"footer": {
|
||||||
|
"countRows": false,
|
||||||
|
"enablePagination": true,
|
||||||
|
"fields": "",
|
||||||
|
"reducer": [
|
||||||
|
"sum"
|
||||||
|
],
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": [
|
||||||
|
{
|
||||||
|
"desc": true,
|
||||||
|
"displayName": "Трафик за период"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "topk(15, sum by (service, domain) (increase(harbor_device_domain_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0)",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{service}} · {{domain}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Домены за выбранный период",
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": {
|
||||||
|
"Time": true
|
||||||
|
},
|
||||||
|
"indexByName": {
|
||||||
|
"service": 0,
|
||||||
|
"domain": 1,
|
||||||
|
"Value": 2
|
||||||
|
},
|
||||||
|
"renameByName": {
|
||||||
|
"Value": "Трафик за период",
|
||||||
|
"domain": "Домен",
|
||||||
|
"service": "Сервис"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 1,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 53
|
||||||
|
},
|
||||||
|
"id": 13,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Техническая детализация",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Накопленный объём всех счётчиков Harbor за всё время хранения.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 54
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "none",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(harbor_traffic_bytes_total)",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Учтено Harbor",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Накоплено: весь Harbor",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Накопленные счётчики за всё время хранения отдельно для Gateway и Proxy.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"decimals": 1,
|
||||||
|
"noValue": "Нет данных",
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "gateway"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "blue",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byName",
|
||||||
|
"options": "proxy"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "purple",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 6,
|
||||||
|
"w": 16,
|
||||||
|
"x": 8,
|
||||||
|
"y": 54
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"displayMode": "basic",
|
||||||
|
"maxVizHeight": 300,
|
||||||
|
"minVizHeight": 16,
|
||||||
|
"minVizWidth": 8,
|
||||||
|
"namePlacement": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showUnfilled": false,
|
||||||
|
"sizing": "auto",
|
||||||
|
"valueMode": "text"
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (source) (harbor_traffic_bytes_total) > 0",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{source}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Накоплено: источники",
|
||||||
|
"type": "bargauge"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": [
|
||||||
|
"harbor",
|
||||||
|
"gateway",
|
||||||
|
"traffic"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {
|
||||||
|
"selected": true,
|
||||||
|
"text": "Все устройства",
|
||||||
|
"value": "$__all"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
||||||
|
"description": "Единый scope для скорости, накопленного трафика, сервисов и доменов: все устройства или одно устройство по стабильному device_id.",
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Устройства",
|
||||||
|
"multi": false,
|
||||||
|
"name": "device_id",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"query": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||||
|
},
|
||||||
|
"refresh": 1,
|
||||||
|
"regex": "/display=\"(?<text>[^\"]+)\"|device_id=\"(?<value>[^\"]+)\"/g",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-24h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": [
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Harbor Gateway: трафик",
|
||||||
|
"uid": "harbor-gateway-traffic",
|
||||||
|
"version": 6,
|
||||||
|
"weekStart": "monday"
|
||||||
|
}
|
||||||
Generated
+2168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "vpn-proxy-gateway",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:production": "npm run build && npm run build:server",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"build:test": "npm run build:production && node scripts/clean-test-dist.mjs && tsc -p tsconfig.test.json",
|
||||||
|
"check:boundaries": "node scripts/check-import-boundaries.mjs",
|
||||||
|
"prestart": "npm run build:production",
|
||||||
|
"test": "npm run build:test && node --test",
|
||||||
|
"typecheck": "tsc -p tsconfig.web.json && tsc -p tsconfig.server.json --noEmit",
|
||||||
|
"version:harbor": "node scripts/harbor-version.mjs",
|
||||||
|
"start": "node dist/server/main.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/parser": "7.29.3",
|
||||||
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
|
"@types/node": "22.19.17",
|
||||||
|
"@types/node18": "npm:@types/node@18.19.130",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"postcss": "8.5.14",
|
||||||
|
"postcss-selector-parser": "7.1.4",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_HOST="${BUILD_HOST:-107}"
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-111}"
|
||||||
|
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
|
||||||
|
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
|
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||||
|
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||||
|
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
|
||||||
|
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
echo "Build host: ${BUILD_HOST}"
|
||||||
|
echo "Deploy host: ${DEPLOY_HOST}"
|
||||||
|
echo "Image: ${GATEWAY_IMAGE}"
|
||||||
|
echo "Base image: ${BASE_IMAGE}"
|
||||||
|
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
|
||||||
|
|
||||||
|
ensure_known_host() {
|
||||||
|
local host="$1"
|
||||||
|
if [ "${host}" = "local" ]; then return 0; fi
|
||||||
|
local scan_host="${host#*@}"
|
||||||
|
scan_host="${scan_host%%:*}"
|
||||||
|
mkdir -p "${HOME}/.ssh"
|
||||||
|
chmod 700 "${HOME}/.ssh"
|
||||||
|
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
|
||||||
|
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
ssh \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=4 \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
BUILD_PATH="$(pwd)"
|
||||||
|
echo "Using local source at ${BUILD_PATH}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.vpn-proxy' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
./ "${BUILD_HOST}:${BUILD_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building image on ${BUILD_HOST}"
|
||||||
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
bash -lc "${BUILD_COMMAND}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading image into ${DEPLOY_HOST}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
|
||||||
|
elif [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
echo "Checking SSH access to ${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "true"
|
||||||
|
echo "Transferring image to ${DEPLOY_HOST}"
|
||||||
|
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
elif [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway on ${DEPLOY_HOST}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
chmod +x ./deploy-gateway.sh
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" \
|
||||||
|
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' UPDATE_DATAPLANE=true PULL_IMAGE=false ./deploy-gateway.sh"
|
||||||
|
fi
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
|
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||||
|
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||||
|
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
|
||||||
|
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
|
||||||
|
|
||||||
|
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
|
||||||
|
echo "Source base image: ${BASE_IMAGE}"
|
||||||
|
echo "APT mirror: ${APT_MIRROR}"
|
||||||
|
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
|
||||||
|
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
||||||
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
|
||||||
|
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
|
||||||
|
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
|
||||||
|
--build-arg NO_PROXY="${NO_PROXY}" \
|
||||||
|
--build-arg http_proxy="${HTTP_PROXY}" \
|
||||||
|
--build-arg https_proxy="${HTTPS_PROXY}" \
|
||||||
|
--build-arg no_proxy="${NO_PROXY}" \
|
||||||
|
-f Dockerfile.runtime-base \
|
||||||
|
-t "${RUNTIME_BASE_IMAGE}" \
|
||||||
|
.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SyntaxKind } from 'typescript/unstable/ast';
|
||||||
|
import { createScanner } from 'typescript/unstable/ast/scanner';
|
||||||
|
|
||||||
|
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function normalized(value) {
|
||||||
|
return value.replaceAll(path.sep, '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTarget(importer, specifier) {
|
||||||
|
if (!specifier.startsWith('.')) return null;
|
||||||
|
return normalized(path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function featurePath(file) {
|
||||||
|
const match = /^src\/(server|web)\/features\/([^/]+)(?:\/(.*))?$/.exec(file);
|
||||||
|
return match ? { layer: match[1], name: match[2], privatePath: match[3] || '' } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importBoundaryViolation(importerValue, specifier) {
|
||||||
|
const importer = normalized(importerValue);
|
||||||
|
const target = relativeTarget(importer, specifier);
|
||||||
|
if (!target) return null;
|
||||||
|
|
||||||
|
if (importer.startsWith('src/shared/') && /^src\/(server|web)\//.test(target)) {
|
||||||
|
return 'shared cannot import server or web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/') && target.startsWith('src/web/')) {
|
||||||
|
return 'server cannot import web';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/') && target.startsWith('src/server/')) {
|
||||||
|
return 'web cannot import server';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/http/') && target.startsWith('src/server/infrastructure/')) {
|
||||||
|
return 'server/http cannot import infrastructure directly';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/server/features/') && target.startsWith('src/server/http/')) {
|
||||||
|
return 'server/features cannot import http';
|
||||||
|
}
|
||||||
|
if (importer.startsWith('src/web/ui/')
|
||||||
|
&& /^src\/web\/(?:features(?:\/|$)|api(?:\/|\.[cm]?[jt]sx?$|$))/.test(target)) {
|
||||||
|
return 'web/ui cannot import api or features';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromFeature = featurePath(importer);
|
||||||
|
const toFeature = featurePath(target);
|
||||||
|
if (fromFeature && toFeature
|
||||||
|
&& fromFeature.layer === toFeature.layer
|
||||||
|
&& fromFeature.name !== toFeature.name
|
||||||
|
&& toFeature.privatePath
|
||||||
|
&& !/^index(?:\.[cm]?[jt]sx?)?$/.test(toFeature.privatePath)) {
|
||||||
|
return 'cross-feature imports must use the feature index';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesUnder(directory) {
|
||||||
|
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importedSpecifiers(source) {
|
||||||
|
const specifiers = [];
|
||||||
|
const scanner = createScanner(true, undefined, source);
|
||||||
|
const tokens = [];
|
||||||
|
for (let token = scanner.scan(); token !== SyntaxKind.EndOfFile; token = scanner.scan()) {
|
||||||
|
if (token === SyntaxKind.SlashToken) token = scanner.reScanSlashToken();
|
||||||
|
tokens.push({ kind: token, text: scanner.getTokenText(), value: scanner.getTokenValue() });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < tokens.length; index += 1) {
|
||||||
|
const token = tokens[index];
|
||||||
|
const previous = tokens[index - 1];
|
||||||
|
const next = tokens[index + 1];
|
||||||
|
const isProperty = previous?.kind === SyntaxKind.DotToken
|
||||||
|
|| previous?.kind === SyntaxKind.QuestionDotToken;
|
||||||
|
|
||||||
|
if (token.text === 'import' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(next.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenParenToken) {
|
||||||
|
const argument = tokens[index + 2];
|
||||||
|
if (argument?.kind === SyntaxKind.StringLiteral) specifiers.push(argument.value);
|
||||||
|
} else if (next?.kind === SyntaxKind.OpenBraceToken
|
||||||
|
|| next?.kind === SyntaxKind.AsteriskToken
|
||||||
|
|| next?.kind === SyntaxKind.Identifier
|
||||||
|
|| next?.text === 'type') {
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'export' && !isProperty) {
|
||||||
|
if (next?.kind !== SyntaxKind.OpenBraceToken
|
||||||
|
&& next?.kind !== SyntaxKind.AsteriskToken
|
||||||
|
&& next?.text !== 'type') continue;
|
||||||
|
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
||||||
|
if (tokens[cursor].kind === SyntaxKind.SemicolonToken) break;
|
||||||
|
if (tokens[cursor].text === 'from'
|
||||||
|
&& tokens[cursor + 1]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[cursor + 1].value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (token.text === 'require' && !isProperty) {
|
||||||
|
if (next?.kind === SyntaxKind.OpenParenToken
|
||||||
|
&& tokens[index + 2]?.kind === SyntaxKind.StringLiteral) {
|
||||||
|
specifiers.push(tokens[index + 2].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkImportBoundaries(repositoryRoot = root) {
|
||||||
|
const sourceRoot = path.join(repositoryRoot, 'src');
|
||||||
|
const files = filesUnder(sourceRoot).filter((file) => SOURCE_FILE.test(file));
|
||||||
|
const violations = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const importer = normalized(path.relative(repositoryRoot, file));
|
||||||
|
const source = fs.readFileSync(file, 'utf8');
|
||||||
|
for (const specifier of importedSpecifiers(source)) {
|
||||||
|
const rule = importBoundaryViolation(importer, specifier);
|
||||||
|
if (rule) violations.push({ importer, specifier, rule });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { filesChecked: files.length, violations };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const result = checkImportBoundaries();
|
||||||
|
if (result.violations.length) {
|
||||||
|
for (const violation of result.violations) {
|
||||||
|
console.error(`${violation.importer}: ${violation.rule} (${violation.specifier})`);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`Import boundaries: ${result.filesChecked} files checked.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
fs.rmSync(path.resolve('.test-dist'), { recursive: true, force: true });
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE:-${GATEWAY_IMAGE:-}}"
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:-${GATEWAY_IMAGE:-}}"
|
||||||
|
CONTROL_IMAGE="${CONTROL_IMAGE:?CONTROL_IMAGE or GATEWAY_IMAGE is required}"
|
||||||
|
DATAPLANE_IMAGE="${DATAPLANE_IMAGE:?DATAPLANE_IMAGE or GATEWAY_IMAGE is required}"
|
||||||
|
UPDATE_DATAPLANE="${UPDATE_DATAPLANE:-false}"
|
||||||
|
PULL_IMAGE="${PULL_IMAGE:-true}"
|
||||||
|
|
||||||
|
echo "Preparing deploy directory: ${DEPLOY_PATH}"
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
EXISTING_DATAPLANE_IMAGE="$(docker inspect --format '{{.Config.Image}}' vpn-proxy-dataplane 2>/dev/null || true)"
|
||||||
|
FIRST_SPLIT_DEPLOY=false
|
||||||
|
if [ -z "${EXISTING_DATAPLANE_IMAGE}" ]; then
|
||||||
|
FIRST_SPLIT_DEPLOY=true
|
||||||
|
elif [ "${UPDATE_DATAPLANE}" != "true" ]; then
|
||||||
|
DATAPLANE_IMAGE="${EXISTING_DATAPLANE_IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
|
||||||
|
services:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
image: ${DATAPLANE_IMAGE}
|
||||||
|
container_name: vpn-proxy-dataplane
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: dataplane
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--unix-socket", "/run/vpn-proxy/dataplane.sock", "-fsS", "http://localhost/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
vpn-proxy-control:
|
||||||
|
image: ${CONTROL_IMAGE}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
APP_COMPONENT: control
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
|
ports:
|
||||||
|
- "\${PORT:-3456}:\${PORT:-3456}"
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- vpn-proxy-runtime:/run/vpn-proxy
|
||||||
|
depends_on:
|
||||||
|
vpn-proxy-dataplane:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:\${PORT:-3456}/api/state"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vpn-proxy-data:
|
||||||
|
sing-box-cache:
|
||||||
|
vpn-proxy-runtime:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ! -f "${DEPLOY_PATH}/.env" ]; then
|
||||||
|
cat > "${DEPLOY_PATH}/.env" <<'EOF'
|
||||||
|
PORT=3456
|
||||||
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
TPROXY_PORT=7895
|
||||||
|
TPROXY_MARK=1
|
||||||
|
TPROXY_TABLE=100
|
||||||
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
||||||
|
else
|
||||||
|
echo "Preserving existing .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
echo "Control image: ${CONTROL_IMAGE}"
|
||||||
|
echo "Dataplane image: ${DATAPLANE_IMAGE}"
|
||||||
|
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull vpn-proxy-control
|
||||||
|
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ] || [ "${UPDATE_DATAPLANE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull vpn-proxy-dataplane
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Skipping image pull"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${FIRST_SPLIT_DEPLOY}" = "true" ]; then
|
||||||
|
echo "Migrating the legacy combined gateway to split services..."
|
||||||
|
docker stop vpn-proxy-gateway 2>/dev/null || true
|
||||||
|
docker rm vpn-proxy-gateway 2>/dev/null || true
|
||||||
|
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
|
||||||
|
elif [ "${UPDATE_DATAPLANE}" = "true" ]; then
|
||||||
|
echo "Updating control and dataplane..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d --wait --wait-timeout 90
|
||||||
|
else
|
||||||
|
echo "Updating control; keeping dataplane running..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d --no-deps --wait --wait-timeout 90 vpn-proxy-control
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current containers:"
|
||||||
|
docker ps --filter "name=vpn-proxy-gateway" --filter "name=vpn-proxy-dataplane"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
#!/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';
|
||||||
|
|
||||||
|
const COMPONENTS = ['macClient', 'gatewayClient', 'gatewayBackend'];
|
||||||
|
const VERSION_FILE = 'src/shared/versions.ts';
|
||||||
|
const LEGACY_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 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||||
|
else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||||
|
add('macClient', 'gatewayClient');
|
||||||
|
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||||
|
else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
|
||||||
|
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');
|
||||||
|
} else if (/^(\.gitea\/workflows\/gateway-build\.yml|scripts\/runtime-impact\.mjs)$/.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', '--no-renames', '--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 {
|
||||||
|
try {
|
||||||
|
return versionsFromSource(git(['show', `${base}:${LEGACY_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||||
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
ARCHIVE_URL="${VPN_PROXY_ARCHIVE_URL:-https://git.dokops.ru/dokril/vpn-proxy/archive/${BRANCH}.tar.gz}"
|
||||||
|
SOURCE_DIR="${VPN_PROXY_SOURCE_DIR:-}"
|
||||||
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
DEFAULT_PROXY_PORT="8082"
|
||||||
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
|
CLIENT_CONTAINER_NAME="harbor-connect"
|
||||||
|
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
|
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[harbor-connect] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[harbor-connect] error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_port() {
|
||||||
|
case "$1" in
|
||||||
|
''|*[!0-9]*) return 1 ;;
|
||||||
|
esac
|
||||||
|
[ "$1" -ge 1024 ] && [ "$1" -le 65535 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_proxy_port() {
|
||||||
|
local value=""
|
||||||
|
if [ -n "$REQUESTED_PROXY_PORT" ]; then
|
||||||
|
if ! is_valid_port "$REQUESTED_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$REQUESTED_PROXY_PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -r /dev/tty ]; then
|
||||||
|
while true; do
|
||||||
|
printf 'Proxy port for local apps [%s]: ' "$DEFAULT_PROXY_PORT" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$DEFAULT_PROXY_PORT}"
|
||||||
|
if is_valid_port "$value"; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! is_valid_port "$DEFAULT_PROXY_PORT"; then
|
||||||
|
die "VPN_PROXY_CLIENT_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$DEFAULT_PROXY_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
published_port_conflicts() {
|
||||||
|
local port="$1"
|
||||||
|
local line
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
case "$line" in
|
||||||
|
"${CLIENT_CONTAINER_NAME}"$'\t'*|"${LEGACY_CLIENT_CONTAINER_NAME}"$'\t'*) ;;
|
||||||
|
*) printf '%s\n' "$line" ;;
|
||||||
|
esac
|
||||||
|
done < <(docker ps --filter "publish=${port}" --format '{{.Names}} {{.Ports}}')
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_port_conflicts() {
|
||||||
|
published_port_conflicts "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_proxy_port_available() {
|
||||||
|
local port="$1"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(proxy_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[harbor-connect] proxy port %s is already used:\n%s\n' \
|
||||||
|
"$port" "$conflicts" >&2
|
||||||
|
die "choose another proxy port with VPN_PROXY_CLIENT_PORT=<port> or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_single_port_available() {
|
||||||
|
local label="$1"
|
||||||
|
local port="$2"
|
||||||
|
local conflicts
|
||||||
|
|
||||||
|
conflicts="$(published_port_conflicts "$port")"
|
||||||
|
if [ -z "$conflicts" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[harbor-connect] %s port %s is already used:\n%s\n' \
|
||||||
|
"$label" "$port" "$conflicts" >&2
|
||||||
|
die "choose another ${label} port or stop the conflicting container"
|
||||||
|
}
|
||||||
|
|
||||||
|
first_free_port() {
|
||||||
|
local start="$1"
|
||||||
|
local port
|
||||||
|
|
||||||
|
for port in $(seq "$start" 65535); do
|
||||||
|
if [ -z "$(published_port_conflicts "$port")" ]; then
|
||||||
|
printf '%s\n' "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
choose_ui_port() {
|
||||||
|
local value="$1"
|
||||||
|
local suggested
|
||||||
|
|
||||||
|
if ! is_valid_port "$value"; then
|
||||||
|
die "CLIENT_UI_PORT must be a port from 1024 to 65535"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$REQUESTED_UI_PORT" ] || [ ! -r /dev/tty ]; then
|
||||||
|
assert_single_port_available "UI" "$value"
|
||||||
|
fi
|
||||||
|
|
||||||
|
suggested="$(first_free_port "$((value + 1))" || true)"
|
||||||
|
suggested="${suggested:-3457}"
|
||||||
|
while true; do
|
||||||
|
printf 'UI port %s is busy. Choose UI port [%s]: ' "$value" "$suggested" >/dev/tty
|
||||||
|
IFS= read -r value </dev/tty || value=""
|
||||||
|
value="${value:-$suggested}"
|
||||||
|
if is_valid_port "$value" && [ -z "$(published_port_conflicts "$value")" ]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'Enter a free port from 1024 to 65535.\n' >/dev/tty
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_ui_outside_proxy_range() {
|
||||||
|
if [ "$UI_PORT" = "$PROXY_PORT" ]; then
|
||||||
|
die "UI port ${UI_PORT} overlaps proxy port"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_client_ui() {
|
||||||
|
local ui_port="${UI_PORT:-3456}"
|
||||||
|
local ui_url="http://127.0.0.1:${ui_port}/api/state"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl --noproxy "*" -fsS "$ui_url" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n[harbor-connect] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
|
printf '[harbor-connect] docker compose status:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
printf '\n[harbor-connect] recent service logs:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 harbor-connect >&2 || true
|
||||||
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
|
}
|
||||||
|
|
||||||
|
xml_escape() {
|
||||||
|
sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
install_network_monitor() {
|
||||||
|
local launch_agents_dir="$HOME/Library/LaunchAgents"
|
||||||
|
local plist_path="$launch_agents_dir/${NETWORK_MONITOR_LABEL}.plist"
|
||||||
|
local escaped_script_path
|
||||||
|
local escaped_runtime_dir
|
||||||
|
local user_domain="gui/$(id -u)"
|
||||||
|
|
||||||
|
escaped_script_path="$(printf '%s' "$INSTALL_DIR/scripts/harbor-network-monitor.sh" | xml_escape)"
|
||||||
|
escaped_runtime_dir="$(printf '%s' "$INSTALL_DIR/.runtime" | xml_escape)"
|
||||||
|
mkdir -p "$INSTALL_DIR/.runtime" "$launch_agents_dir"
|
||||||
|
|
||||||
|
cat > "$plist_path" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${NETWORK_MONITOR_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>${escaped_script_path}</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>HARBOR_RUNTIME_DIR</key>
|
||||||
|
<string>${escaped_runtime_dir}</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>5</integer>
|
||||||
|
<key>ProcessType</key>
|
||||||
|
<string>Background</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
/bin/bash "$INSTALL_DIR/scripts/harbor-network-monitor.sh"
|
||||||
|
launchctl bootout "$user_domain" "$plist_path" >/dev/null 2>&1 || true
|
||||||
|
launchctl bootstrap "$user_domain" "$plist_path"
|
||||||
|
log "automatic Gateway detection enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
set_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
if [ -f .env ] && grep -q "^${key}=" .env; then
|
||||||
|
awk -v key="$key" -v value="$value" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print key "=" value; next }
|
||||||
|
{ print }
|
||||||
|
' .env > "$tmp"
|
||||||
|
else
|
||||||
|
[ -f .env ] && cat .env > "$tmp"
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$tmp" .env
|
||||||
|
}
|
||||||
|
|
||||||
|
get_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[ -f .env ] || return 0
|
||||||
|
awk -v key="$key" '
|
||||||
|
BEGIN { prefix = key "=" }
|
||||||
|
index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }
|
||||||
|
' .env
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_source() {
|
||||||
|
local source_dir="$1"
|
||||||
|
[ -f "$source_dir/docker-compose.client.yml" ] || die "invalid Harbor source archive"
|
||||||
|
log "installing files to $INSTALL_DIR"
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cp -R "$source_dir/." "$INSTALL_DIR/"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_source() {
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
mkdir -p "$tmp_dir/source"
|
||||||
|
log "downloading $ARCHIVE_URL"
|
||||||
|
if ! curl -fsSL "$ARCHIVE_URL" | tar -xzf - -C "$tmp_dir/source" --strip-components=1; then
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
die "failed to download Harbor source"
|
||||||
|
fi
|
||||||
|
copy_source "$tmp_dir/source"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
|
die "this installer is intended for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need docker
|
||||||
|
need curl
|
||||||
|
need tar
|
||||||
|
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
|
if [[ -n "$SOURCE_DIR" ]]; then
|
||||||
|
copy_source "$SOURCE_DIR"
|
||||||
|
elif [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
need git
|
||||||
|
log "updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
download_source
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f .env && -f .env.example ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_PORT="$(ask_proxy_port)"
|
||||||
|
assert_proxy_port_available "$PROXY_PORT"
|
||||||
|
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||||
|
UI_PORT="${UI_PORT:-3456}"
|
||||||
|
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||||
|
assert_ui_outside_proxy_range
|
||||||
|
|
||||||
|
set_env_value APP_MODE client
|
||||||
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
|
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||||
|
|
||||||
|
log "UI port: http://127.0.0.1:${UI_PORT}"
|
||||||
|
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||||
|
|
||||||
|
install_network_monitor
|
||||||
|
|
||||||
|
log "building and starting Docker client"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||||
|
wait_for_client_ui
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Harbor Connect is running.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
http://127.0.0.1:${UI_PORT}
|
||||||
|
|
||||||
|
Proxy:
|
||||||
|
HTTP/SOCKS5 127.0.0.1:${PROXY_PORT}
|
||||||
|
This is the only Docker-published proxy port. Re-run the installer with VPN_PROXY_CLIENT_PORT=<port> to change it.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
docker compose -f docker-compose.client.yml logs -f
|
||||||
|
docker compose -f docker-compose.client.yml restart
|
||||||
|
docker compose -f docker-compose.client.yml down
|
||||||
|
|
||||||
|
Optional macOS system proxy example:
|
||||||
|
networksetup -setwebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
networksetup -setsocksfirewallproxy Wi-Fi 127.0.0.1 ${PROXY_PORT}
|
||||||
|
|
||||||
|
Disable later:
|
||||||
|
networksetup -setwebproxystate Wi-Fi off
|
||||||
|
networksetup -setsecurewebproxystate Wi-Fi off
|
||||||
|
networksetup -setsocksfirewallproxystate Wi-Fi off
|
||||||
|
|
||||||
|
EOF
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🛠️ COMMON UTILS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# --- ГЛОБАЛЬНЫЕ НАСТРОЙКИ ---
|
|
||||||
|
|
||||||
# Режим отладки (передаётся через -Debug)
|
|
||||||
if (-not (Test-Path variable:script:DebugMode)) {
|
|
||||||
$script:DebugMode = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
function Set-DebugMode {
|
|
||||||
param([bool]$Enabled)
|
|
||||||
$script:DebugMode = $Enabled
|
|
||||||
if ($Enabled) {
|
|
||||||
Write-Host " 🔧 Debug режим включён" -ForegroundColor Magenta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-DebugMode {
|
|
||||||
return $script:DebugMode
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ЦВЕТА И ВЫВОД ---
|
|
||||||
|
|
||||||
function Write-Step { param($msg) Write-Host "`n📦 $msg" -ForegroundColor Cyan }
|
|
||||||
function Write-Success { param($msg) Write-Host " ✅ $msg" -ForegroundColor Green }
|
|
||||||
function Write-Warning { param($msg) Write-Host " ⚠️ $msg" -ForegroundColor Yellow }
|
|
||||||
function Write-Error { param($msg) Write-Host " ❌ $msg" -ForegroundColor Red }
|
|
||||||
function Write-Info { param($msg) Write-Host " ℹ️ $msg" -ForegroundColor Gray }
|
|
||||||
|
|
||||||
function Write-DebugLog {
|
|
||||||
param($msg)
|
|
||||||
if ($script:DebugMode) {
|
|
||||||
Write-Host " [DEBUG] $msg" -ForegroundColor DarkGray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Write-Header {
|
|
||||||
param($Title, [switch]$ClearScreen)
|
|
||||||
|
|
||||||
if ($ClearScreen -and -not $script:DebugMode) {
|
|
||||||
Clear-Host
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host " $Title" -ForegroundColor Cyan
|
|
||||||
Write-Host "==========================================" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ЗАПУСК КОМАНД ---
|
|
||||||
|
|
||||||
function Invoke-Silent {
|
|
||||||
param(
|
|
||||||
[string]$FilePath,
|
|
||||||
[string]$Arguments,
|
|
||||||
[switch]$Wait
|
|
||||||
)
|
|
||||||
|
|
||||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
||||||
$psi.FileName = $FilePath
|
|
||||||
$psi.Arguments = $Arguments
|
|
||||||
$psi.UseShellExecute = $false
|
|
||||||
$psi.CreateNoWindow = $true
|
|
||||||
|
|
||||||
if (-not $script:DebugMode) {
|
|
||||||
$psi.RedirectStandardOutput = $true
|
|
||||||
$psi.RedirectStandardError = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = [System.Diagnostics.Process]::Start($psi)
|
|
||||||
|
|
||||||
if ($Wait) {
|
|
||||||
$process.WaitForExit()
|
|
||||||
return $process.ExitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
return $process
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- ПОЛЕЗНЫЕ ФУНКЦИИ ---
|
|
||||||
|
|
||||||
function Get-ScriptDirectory {
|
|
||||||
if ($PSScriptRoot) { return $PSScriptRoot }
|
|
||||||
return Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
function Ensure-Admin {
|
|
||||||
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
|
|
||||||
if (-not $isAdmin) {
|
|
||||||
Write-Host "⛔ Требуются права АДМИНИСТРАТОРА!" -ForegroundColor Red
|
|
||||||
Write-Host " Пожалуйста, запустите скрипт от имени администратора." -ForegroundColor Gray
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Show-Menu {
|
|
||||||
param(
|
|
||||||
[string]$Title,
|
|
||||||
[System.Collections.Specialized.OrderedDictionary]$Options,
|
|
||||||
[string]$Prompt = "👉 Ваш выбор"
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($Title) {
|
|
||||||
Write-Host "`n$Title" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
$keys = $Options.Keys
|
|
||||||
foreach ($key in $keys) {
|
|
||||||
Write-Host " [$key] $($Options[$key])" -ForegroundColor White
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
return Read-Host "$Prompt"
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🌐 NET UTILS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# --- CONFIG ---
|
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
||||||
|
|
||||||
# --- ФУНКЦИИ ---
|
|
||||||
|
|
||||||
$script:HwidFile = "C:\Tools\sing-box\hwid"
|
|
||||||
$script:AppName = "VPN-Proxy-Control by Dokril"
|
|
||||||
|
|
||||||
function Get-HWID {
|
|
||||||
# Генерация или чтение HWID из файла
|
|
||||||
if (Test-Path $script:HwidFile) {
|
|
||||||
return (Get-Content $script:HwidFile -Raw).Trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Генерируем новый HWID
|
|
||||||
$hwid = [Guid]::NewGuid().ToString("N").Substring(0, 16)
|
|
||||||
|
|
||||||
# Сохраняем
|
|
||||||
$dir = Split-Path $script:HwidFile -Parent
|
|
||||||
if (!(Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
||||||
Set-Content -Path $script:HwidFile -Value $hwid
|
|
||||||
|
|
||||||
return $hwid
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-SubscriptionHeaders {
|
|
||||||
# Формируем заголовки как в server.py
|
|
||||||
$osName = "windows"
|
|
||||||
$osVersion = [Environment]::OSVersion.Version.ToString()
|
|
||||||
|
|
||||||
return @{
|
|
||||||
"User-Agent" = "singbox"
|
|
||||||
"x-hwid" = (Get-HWID)
|
|
||||||
"x-device-os" = $osName
|
|
||||||
"x-ver-os" = $osVersion
|
|
||||||
"x-device-model" = $script:AppName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Download-File {
|
|
||||||
param(
|
|
||||||
[string]$Url,
|
|
||||||
[string]$Destination,
|
|
||||||
[string]$UserAgent = "VPN-Proxy-Installer"
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
$req = [System.Net.HttpWebRequest]::Create($Url)
|
|
||||||
$req.UserAgent = $UserAgent
|
|
||||||
$resp = $req.GetResponse()
|
|
||||||
|
|
||||||
$stream = $resp.GetResponseStream()
|
|
||||||
$fs = [System.IO.File]::Create($Destination)
|
|
||||||
$msgLen = $resp.ContentLength
|
|
||||||
|
|
||||||
$buffer = New-Object byte[] 10240
|
|
||||||
$count = 0
|
|
||||||
$total = 0
|
|
||||||
|
|
||||||
do {
|
|
||||||
$count = $stream.Read($buffer, 0, $buffer.Length)
|
|
||||||
$fs.Write($buffer, 0, $count)
|
|
||||||
$total += $count
|
|
||||||
# Можно добавить прогресс бар, но пока просто качаем
|
|
||||||
} while ($count -gt 0)
|
|
||||||
|
|
||||||
$fs.Close()
|
|
||||||
$stream.Close()
|
|
||||||
$resp.Close()
|
|
||||||
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "Ошибка скачивания: $_"
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-SubscriptionData {
|
|
||||||
param(
|
|
||||||
[string]$Url,
|
|
||||||
[string]$UserAgent = "singbox",
|
|
||||||
$Headers = @{}
|
|
||||||
)
|
|
||||||
|
|
||||||
Write-Info "Загружаю подписку..."
|
|
||||||
|
|
||||||
$rawContent = $null
|
|
||||||
$userInfo = @{}
|
|
||||||
|
|
||||||
# 1. Получаем ответ
|
|
||||||
try {
|
|
||||||
$response = Invoke-WebRequest -Uri $Url -Headers $Headers -TimeoutSec 15 -UseBasicParsing
|
|
||||||
$rawContent = $response.Content
|
|
||||||
|
|
||||||
# Парсим subscription-userinfo header
|
|
||||||
$userInfoHeader = $response.Headers["subscription-userinfo"]
|
|
||||||
if ($userInfoHeader) {
|
|
||||||
$parts = $userInfoHeader -split ";"
|
|
||||||
foreach ($part in $parts) {
|
|
||||||
if ($part -match "(\w+)=(\d+)") {
|
|
||||||
$userInfo[$matches[1]] = [int64]$matches[2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return @{
|
|
||||||
success = $false
|
|
||||||
error = "Ошибка загрузки: $($_.Exception.Message)"
|
|
||||||
rawContent = $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Пробуем парсить как JSON
|
|
||||||
try {
|
|
||||||
$config = $rawContent | ConvertFrom-Json
|
|
||||||
return @{
|
|
||||||
success = $true
|
|
||||||
config = $config
|
|
||||||
rawContent = $rawContent
|
|
||||||
userInfo = $userInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
# JSON не распарсился — возвращаем rawContent для дальнейшей обработки
|
|
||||||
return @{
|
|
||||||
success = $false
|
|
||||||
error = "Ответ не в формате JSON (возможно Base64 или список ссылок)"
|
|
||||||
rawContent = $rawContent
|
|
||||||
userInfo = $userInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🖥️ SYSTEM UTILS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# --- СИСТЕМНАЯ ИНФОРМАЦИЯ ---
|
|
||||||
|
|
||||||
function Get-SystemInfo {
|
|
||||||
return @{
|
|
||||||
os = "windows"
|
|
||||||
version = [System.Environment]::OSVersion.Version.Major.ToString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- DOCKER ---
|
|
||||||
|
|
||||||
function Test-Docker {
|
|
||||||
$status = @{
|
|
||||||
Installed = $false
|
|
||||||
Running = $false
|
|
||||||
Compose = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$ver = docker --version 2>&1
|
|
||||||
if ($LASTEXITCODE -eq 0) { $status.Installed = $true }
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
|
|
||||||
if ($status.Installed) {
|
|
||||||
try {
|
|
||||||
$info = docker info 2>&1
|
|
||||||
if ($LASTEXITCODE -eq 0) { $status.Running = $true }
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($status.Running) {
|
|
||||||
try {
|
|
||||||
$comp = docker compose version 2>&1
|
|
||||||
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
# Check legacy
|
|
||||||
try {
|
|
||||||
$comp = docker-compose --version 2>&1
|
|
||||||
if ($LASTEXITCODE -eq 0) { $status.Compose = $true }
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $status
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- СЛУЖБЫ И ЗАДАЧИ ---
|
|
||||||
|
|
||||||
function Manage-ScheduledTask {
|
|
||||||
param(
|
|
||||||
[string]$Name,
|
|
||||||
[string]$ExePath,
|
|
||||||
[string]$Arguments,
|
|
||||||
[string]$WorkDir,
|
|
||||||
[string]$Action = "Install" # Install, Uninstall, Start, Stop
|
|
||||||
)
|
|
||||||
|
|
||||||
switch ($Action) {
|
|
||||||
"Install" {
|
|
||||||
# Удаляем старую
|
|
||||||
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
$act = New-ScheduledTaskAction -Execute "$ExePath" -Argument "$Arguments" -WorkingDirectory $WorkDir
|
|
||||||
$trig = New-ScheduledTaskTrigger -AtStartup
|
|
||||||
$princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
|
||||||
$sett = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
|
||||||
|
|
||||||
Register-ScheduledTask -TaskName $Name -Action $act -Trigger $trig -Principal $princ -Settings $sett -Force | Out-Null
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
"Uninstall" {
|
|
||||||
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
"Start" {
|
|
||||||
Start-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
"Stop" {
|
|
||||||
Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
||||||
# Пытаемся убить процесс по имени exe
|
|
||||||
if ($ExePath) {
|
|
||||||
$procName = [System.IO.Path]::GetFileNameWithoutExtension($ExePath)
|
|
||||||
if ($procName) {
|
|
||||||
Stop-Process -Name $procName -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-TaskStatus {
|
|
||||||
param([string]$Name)
|
|
||||||
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
||||||
if ($task) {
|
|
||||||
# Если задача в статусе Running — возвращаем Running
|
|
||||||
if ($task.State -eq "Running") {
|
|
||||||
return "Running"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Если задача Ready — проверяем, работает ли процесс sing-box
|
|
||||||
# (scheduled task может быть Ready даже когда процесс работает)
|
|
||||||
$process = Get-Process -Name "sing-box" -ErrorAction SilentlyContinue
|
|
||||||
if ($process) {
|
|
||||||
return "Running"
|
|
||||||
}
|
|
||||||
|
|
||||||
return $task.State
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function Ensure-FirewallPort {
|
|
||||||
param(
|
|
||||||
[int]$Port,
|
|
||||||
[string]$Name,
|
|
||||||
[string]$Protocol = "TCP"
|
|
||||||
)
|
|
||||||
|
|
||||||
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
|
|
||||||
if (-not $rule) {
|
|
||||||
New-NetFirewallRule -DisplayName $Name -Direction Inbound -LocalPort $Port -Protocol $Protocol -Action Allow -Profile Any | Out-Null
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-LocalIPs {
|
|
||||||
return (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias * | Where-Object { $_.IPAddress -notmatch "^127\." -and $_.IPAddress -notmatch "^169\.254\." }).IPAddress
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const CODE_EXTENSION = String.raw`\.[cm]?[jt]sx?$`;
|
||||||
|
const noRuntimeImpact = [
|
||||||
|
/^\.codex\//,
|
||||||
|
/^docs\//,
|
||||||
|
/^test\//,
|
||||||
|
/^workpack\//,
|
||||||
|
/^(?:AGENTS|PRODUCT|README)\.md$/,
|
||||||
|
/^\.env\.example$/,
|
||||||
|
/^\.gitignore$/,
|
||||||
|
/^Dockerfile\.client$/,
|
||||||
|
/^docker-compose\.client(?:\.local)?\.yml$/,
|
||||||
|
/^entrypoint\.client\.sh$/,
|
||||||
|
/^install\.sh$/,
|
||||||
|
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
||||||
|
];
|
||||||
|
const foundation = [
|
||||||
|
/^\.dockerignore$/,
|
||||||
|
/^\.gitea\/workflows\//,
|
||||||
|
/^Dockerfile(?:\.runtime-base)?$/,
|
||||||
|
/^docker-compose\.gateway\.yml$/,
|
||||||
|
/^entrypoint\.sh$/,
|
||||||
|
/^package(?:-lock)?\.json$/,
|
||||||
|
/^scripts\/(?:build-on-107-deploy-111|build-runtime-base|deploy-gateway)\.sh$/,
|
||||||
|
/^scripts\/runtime-impact\.mjs$/,
|
||||||
|
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||||
|
];
|
||||||
|
const controlAndDataplane = [
|
||||||
|
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService)${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
|
||||||
|
/^src\/server\/infrastructure\/dataplane\//,
|
||||||
|
];
|
||||||
|
const dataplane = [
|
||||||
|
new RegExp(`^src/server/dataplane${CODE_EXTENSION}`),
|
||||||
|
new RegExp(`^src/server/services/(?:deviceTrafficService|domainTrafficService)${CODE_EXTENSION}`),
|
||||||
|
];
|
||||||
|
const control = [
|
||||||
|
/^index\.html$/,
|
||||||
|
/^monitoring\//,
|
||||||
|
/^public\//,
|
||||||
|
/^src\/server\//,
|
||||||
|
/^src\/shared\//,
|
||||||
|
/^src\/web\//,
|
||||||
|
/^vite\.config\.[cm]?[jt]s$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function matchesAny(file, patterns) {
|
||||||
|
return patterns.some((pattern) => pattern.test(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFile(file) {
|
||||||
|
return file.trim().replaceAll('\\', '/').replace(/^\.\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyRuntimeImpact(files) {
|
||||||
|
const affected = new Set();
|
||||||
|
for (const value of files) {
|
||||||
|
const file = normalizeFile(value);
|
||||||
|
if (!file || matchesAny(file, noRuntimeImpact)) continue;
|
||||||
|
if (matchesAny(file, foundation) || matchesAny(file, controlAndDataplane)) {
|
||||||
|
affected.add('control');
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, dataplane)) {
|
||||||
|
affected.add('dataplane');
|
||||||
|
} else if (matchesAny(file, control)) {
|
||||||
|
affected.add('control');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unclassified path: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedComponents = ['control', 'dataplane'].filter((component) => affected.has(component));
|
||||||
|
const restartScope = affected.has('dataplane') ? 'both' : affected.has('control') ? 'control' : 'none';
|
||||||
|
return { affectedComponents, restartScope };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImpact(impact) {
|
||||||
|
return [
|
||||||
|
`affected-components=${impact.affectedComponents.join('+') || 'none'}`,
|
||||||
|
`restart-scope=${impact.restartScope}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const files = args.includes('--stdin')
|
||||||
|
? fs.readFileSync(0, 'utf8').split(/\r?\n/)
|
||||||
|
: args;
|
||||||
|
console.log(formatImpact(classifyRuntimeImpact(files)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[runtime-impact] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🎮 DISCORD PROXY SETUP
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
param(
|
|
||||||
[switch]$Force,
|
|
||||||
[switch]$Debug
|
|
||||||
)
|
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
. "$ScriptDir\lib\Common.ps1"
|
|
||||||
. "$ScriptDir\lib\Net.ps1"
|
|
||||||
. "$ScriptDir\lib\System.ps1"
|
|
||||||
|
|
||||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
|
||||||
|
|
||||||
Write-Header "НАСТРОЙКА DISCORD / VESKTOP" -ClearScreen
|
|
||||||
|
|
||||||
Ensure-Admin
|
|
||||||
|
|
||||||
$InstallPath = "C:\Tools\ProxiFyre"
|
|
||||||
$ConfigPath = "$InstallPath\app-config.json"
|
|
||||||
$DriverUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi"
|
|
||||||
$AppUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.1.4/ProxiFyre-v2.1.4-x64-signed.zip"
|
|
||||||
|
|
||||||
# --- ФУНКЦИИ ---
|
|
||||||
|
|
||||||
function Test-ProxyConnection {
|
|
||||||
param([string]$ProxyAddr)
|
|
||||||
|
|
||||||
Write-Info "Проверка подключения к прокси $ProxyAddr..."
|
|
||||||
|
|
||||||
try {
|
|
||||||
$parts = $ProxyAddr -split ":"
|
|
||||||
$host_ = $parts[0]
|
|
||||||
$port = [int]$parts[1]
|
|
||||||
|
|
||||||
# 1. Проверяем TCP соединение
|
|
||||||
$tcp = New-Object System.Net.Sockets.TcpClient
|
|
||||||
$tcp.Connect($host_, $port)
|
|
||||||
$tcp.Close()
|
|
||||||
Write-Success "TCP соединение установлено"
|
|
||||||
|
|
||||||
# 2. Пробуем получить внешний IP через прокси (используем curl для SOCKS5)
|
|
||||||
try {
|
|
||||||
$result = & curl.exe -s -x "socks5://$ProxyAddr" "http://v4.ident.me" --connect-timeout 5 2>$null
|
|
||||||
if ($result -match "^\d+\.\d+\.\d+\.\d+$") {
|
|
||||||
Write-Success "Внешний IP через прокси: $result"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
|
|
||||||
Write-Warning "TCP работает, но не удалось получить IP. Возможно прокси не полностью настроен."
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "Не удалось подключиться к $ProxyAddr"
|
|
||||||
Write-Host " Убедитесь, что прокси запущен и доступен." -ForegroundColor Gray
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-CurrentConfig {
|
|
||||||
if (Test-Path $ConfigPath) {
|
|
||||||
try {
|
|
||||||
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
|
||||||
return @{
|
|
||||||
Apps = $cfg.proxies[0].appNames -join ", "
|
|
||||||
Proxy = $cfg.proxies[0].socks5ProxyEndpoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Install-ProxiFyre {
|
|
||||||
# Установка драйвера
|
|
||||||
Write-Step "Установка драйвера..."
|
|
||||||
$msi = "$env:TEMP\WinpkFilter.msi"
|
|
||||||
if (Download-File -Url $DriverUrl -Destination $msi) {
|
|
||||||
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn /norestart" -Wait
|
|
||||||
Write-Success "Драйвер готов"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Установка ProxiFyre
|
|
||||||
Write-Step "Установка ProxiFyre..."
|
|
||||||
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
|
|
||||||
$zip = "$env:TEMP\ProxiFyre.zip"
|
|
||||||
if (Download-File -Url $AppUrl -Destination $zip) {
|
|
||||||
Expand-Archive -Path $zip -DestinationPath $InstallPath -Force
|
|
||||||
$exe = Get-ChildItem $InstallPath -Recurse -Filter "ProxiFyre.exe" | Select -First 1
|
|
||||||
if ($exe.DirectoryName -ne $InstallPath) {
|
|
||||||
Copy-Item "$($exe.DirectoryName)\*" $InstallPath -Recurse -Force
|
|
||||||
}
|
|
||||||
Write-Success "Распаковано"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Configure-And-Start {
|
|
||||||
param($TargetApps, $ProxyAddr)
|
|
||||||
|
|
||||||
# Конфиг
|
|
||||||
$cfg = @{
|
|
||||||
logLevel = "Info"
|
|
||||||
proxies = @(@{
|
|
||||||
appNames = $TargetApps
|
|
||||||
socks5ProxyEndpoint = $ProxyAddr
|
|
||||||
supportedProtocols = @("TCP", "UDP")
|
|
||||||
})
|
|
||||||
excludes = @()
|
|
||||||
}
|
|
||||||
$cfg | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath -Encoding UTF8
|
|
||||||
|
|
||||||
# Служба
|
|
||||||
Write-Step "Перезапуск службы..."
|
|
||||||
if (Get-DebugMode) {
|
|
||||||
& "$InstallPath\ProxiFyre.exe" stop
|
|
||||||
& "$InstallPath\ProxiFyre.exe" install
|
|
||||||
& "$InstallPath\ProxiFyre.exe" start
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
& "$InstallPath\ProxiFyre.exe" stop 2>&1 | Out-Null
|
|
||||||
& "$InstallPath\ProxiFyre.exe" install 2>&1 | Out-Null
|
|
||||||
& "$InstallPath\ProxiFyre.exe" start 2>&1 | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Success "Готово! Discord должен работать через прокси."
|
|
||||||
}
|
|
||||||
|
|
||||||
function Select-Apps {
|
|
||||||
Write-Host "`n🎮 Какие приложения проксировать?" -ForegroundColor Yellow
|
|
||||||
$appOpts = [Ordered]@{
|
|
||||||
"1" = "Discord"
|
|
||||||
"2" = "Vesktop"
|
|
||||||
"3" = "Discord + Vesktop"
|
|
||||||
}
|
|
||||||
$appChoice = Show-Menu -Options $appOpts
|
|
||||||
$result = switch ($appChoice) {
|
|
||||||
"1" { @("Discord") }
|
|
||||||
"2" { @("Vesktop") }
|
|
||||||
"3" { @("Vesktop", "Discord") }
|
|
||||||
default { @("Discord") }
|
|
||||||
}
|
|
||||||
return $result
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-ProxyAddress {
|
|
||||||
# Проверяем локальный sing-box
|
|
||||||
$singboxStatus = Get-TaskStatus -Name "SingBoxProxy"
|
|
||||||
$localProxy = "127.0.0.1:1080"
|
|
||||||
|
|
||||||
if ($singboxStatus -eq "Running") {
|
|
||||||
Write-Info "Обнаружен работающий VPN клиент (Sing-box)."
|
|
||||||
Write-Host " Рекомендуется использовать локальный прокси: " -NoNewline -ForegroundColor Gray
|
|
||||||
Write-Host $localProxy -ForegroundColor Green
|
|
||||||
|
|
||||||
$useLocal = Read-Host " Использовать локальный? (y/n) [y]"
|
|
||||||
if ($useLocal -ne 'n') {
|
|
||||||
return $localProxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Warning "VPN клиент не запущен!"
|
|
||||||
Write-Host " Вы можете указать адрес удалённого прокси." -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
|
|
||||||
# Запрашиваем адрес
|
|
||||||
while ($true) {
|
|
||||||
$proxyAddr = Read-Host "`n Введите адрес прокси (IP:порт)"
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($proxyAddr)) {
|
|
||||||
Write-Warning "Адрес не указан"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($proxyAddr -notmatch "^[\d\.]+:\d+$") {
|
|
||||||
Write-Error "Неверный формат. Ожидается: IP:порт (например 192.168.1.100:1080)"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Проверяем подключение
|
|
||||||
if (Test-ProxyConnection -ProxyAddr $proxyAddr) {
|
|
||||||
return $proxyAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
$retry = Read-Host " Попробовать другой адрес? (y/n)"
|
|
||||||
if ($retry -ne 'y') { return $null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- MAIN ---
|
|
||||||
|
|
||||||
$isInstalled = Test-Path "$InstallPath\ProxiFyre.exe"
|
|
||||||
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
|
||||||
$currentConfig = Get-CurrentConfig
|
|
||||||
|
|
||||||
if ($isInstalled -and $currentConfig -and -not $Force) {
|
|
||||||
# Уже установлено — показываем меню управления
|
|
||||||
Write-Info "ProxiFyre уже установлен."
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " Статус: " -NoNewline -ForegroundColor Gray
|
|
||||||
if ($discSvc.Status -eq 'Running') {
|
|
||||||
Write-Host "АКТИВЕН" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "ОСТАНОВЛЕН" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
Write-Host " Приложения: $($currentConfig.Apps)" -ForegroundColor Gray
|
|
||||||
Write-Host " Прокси: $($currentConfig.Proxy)" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$opts = [Ordered]@{
|
|
||||||
"1" = "Изменить настройки (приложения/прокси)"
|
|
||||||
"2" = "Проверить подключение к прокси"
|
|
||||||
"3" = "Перезапустить службу"
|
|
||||||
"4" = "Остановить службу"
|
|
||||||
"5" = "Переустановить"
|
|
||||||
"b" = "Назад"
|
|
||||||
}
|
|
||||||
|
|
||||||
$action = Show-Menu -Options $opts
|
|
||||||
|
|
||||||
switch ($action) {
|
|
||||||
"1" {
|
|
||||||
$targetApps = Select-Apps
|
|
||||||
$proxyAddr = Get-ProxyAddress
|
|
||||||
if ($proxyAddr) {
|
|
||||||
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"2" {
|
|
||||||
Test-ProxyConnection -ProxyAddr $currentConfig.Proxy | Out-Null
|
|
||||||
}
|
|
||||||
"3" {
|
|
||||||
Write-Step "Перезапуск службы..."
|
|
||||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
|
|
||||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "start" -Wait -NoNewWindow
|
|
||||||
Write-Success "Перезапущено!"
|
|
||||||
}
|
|
||||||
"4" {
|
|
||||||
Start-Process "$InstallPath\ProxiFyre.exe" -ArgumentList "stop" -Wait -NoNewWindow
|
|
||||||
Write-Success "Остановлено!"
|
|
||||||
}
|
|
||||||
"5" {
|
|
||||||
$Force = $true
|
|
||||||
}
|
|
||||||
"b" { exit }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $Force) {
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- НОВАЯ УСТАНОВКА ---
|
|
||||||
|
|
||||||
if (-not $isInstalled -or $Force) {
|
|
||||||
Install-ProxiFyre
|
|
||||||
}
|
|
||||||
|
|
||||||
$targetApps = Select-Apps
|
|
||||||
$proxyAddr = Get-ProxyAddress
|
|
||||||
|
|
||||||
if (-not $proxyAddr) {
|
|
||||||
Write-Error "Прокси не настроен. Выход."
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
Configure-And-Start -TargetApps $targetApps -ProxyAddr $proxyAddr
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 📦 SING-BOX NATIVE INSTALLER
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
param(
|
|
||||||
[switch]$Force,
|
|
||||||
[switch]$Debug,
|
|
||||||
[string]$SubscriptionUrl = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
. "$ScriptDir\lib\Common.ps1"
|
|
||||||
. "$ScriptDir\lib\Net.ps1"
|
|
||||||
. "$ScriptDir\lib\System.ps1"
|
|
||||||
|
|
||||||
# --- CONFIG ---
|
|
||||||
$SingboxVersion = "1.11.4"
|
|
||||||
$InstallDir = "C:\Tools\sing-box"
|
|
||||||
$LocalProxyPort = 1080
|
|
||||||
$SingboxUrl = "https://github.com/SagerNet/sing-box/releases/download/v$SingboxVersion/sing-box-$SingboxVersion-windows-amd64.zip"
|
|
||||||
$TaskName = "SingBoxProxy"
|
|
||||||
|
|
||||||
Ensure-Admin
|
|
||||||
|
|
||||||
# --- LOGIC ---
|
|
||||||
|
|
||||||
function Select-Server {
|
|
||||||
param($Config)
|
|
||||||
|
|
||||||
$outbounds = $Config.outbounds
|
|
||||||
$servers = @()
|
|
||||||
|
|
||||||
foreach ($outbound in $outbounds) {
|
|
||||||
if ($outbound.type -in @("vless", "vmess", "trojan", "shadowsocks", "hysteria2")) {
|
|
||||||
$servers += @{
|
|
||||||
tag = $outbound.tag
|
|
||||||
type = $outbound.type
|
|
||||||
server = $outbound.server
|
|
||||||
server_port = $outbound.server_port
|
|
||||||
outbound = $outbound
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($servers.Count -eq 0) {
|
|
||||||
Write-Error "Серверы не найдены в подписке!"
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
$options = [Ordered]@{}
|
|
||||||
for ($i = 0; $i -lt $servers.Count; $i++) {
|
|
||||||
$s = $servers[$i]
|
|
||||||
$options["$($i+1)"] = "$($s.tag) ($($s.server):$($s.server_port))"
|
|
||||||
}
|
|
||||||
|
|
||||||
$choice = Show-Menu -Title "🌐 Доступные серверы" -Options $options -Prompt "👉 Выберите сервер (номер)"
|
|
||||||
$index = [int]$choice - 1
|
|
||||||
|
|
||||||
if ($index -lt 0 -or $index -ge $servers.Count) {
|
|
||||||
Write-Error "Неверный выбор!"
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
return $servers[$index]
|
|
||||||
}
|
|
||||||
|
|
||||||
function New-SingboxConfig {
|
|
||||||
param($Outbound, $Port)
|
|
||||||
|
|
||||||
return @{
|
|
||||||
log = @{ level = "info"; timestamp = $true }
|
|
||||||
dns = @{ independent_cache = $true }
|
|
||||||
inbounds = @(
|
|
||||||
@{
|
|
||||||
type = "socks"
|
|
||||||
tag = "socks-in"
|
|
||||||
listen = "0.0.0.0"
|
|
||||||
listen_port = $Port
|
|
||||||
}
|
|
||||||
)
|
|
||||||
outbounds = @(
|
|
||||||
$Outbound,
|
|
||||||
@{ type = "direct"; tag = "direct" }
|
|
||||||
)
|
|
||||||
route = @{
|
|
||||||
final = $Outbound.tag
|
|
||||||
auto_detect_interface = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Parse-VlessUrl {
|
|
||||||
param([string]$Url)
|
|
||||||
|
|
||||||
if (-not $Url.StartsWith("vless://")) { throw "URL должен начинаться с vless://" }
|
|
||||||
|
|
||||||
# Remove scheme
|
|
||||||
$raw = $Url.Substring(8)
|
|
||||||
|
|
||||||
# Split fragment
|
|
||||||
$tag = "reality"
|
|
||||||
if ($raw -match "#(.*)$") {
|
|
||||||
$tag = [System.Web.HttpUtility]::UrlDecode($matches[1])
|
|
||||||
$raw = $raw -replace "#.*$", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Split query
|
|
||||||
$queryStr = ""
|
|
||||||
if ($raw -match "\?(.*)$") {
|
|
||||||
$queryStr = $matches[1]
|
|
||||||
$raw = $raw -replace "\?.*$", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse UUID@HOST:PORT
|
|
||||||
if ($raw -notmatch "([^@]+)@([^:]+):(\d+)") { throw "Неверный формат vless (ожидается uuid@host:port)" }
|
|
||||||
$uuid = $matches[1][0]
|
|
||||||
$serverHost = $matches[2][0]
|
|
||||||
$port = [int]$matches[3][0] # Fix for regex object access in PS
|
|
||||||
|
|
||||||
if (-not $uuid) {
|
|
||||||
# Fallback if regex returns match info differently in different PS versions
|
|
||||||
$uuid = $matches[1]
|
|
||||||
$serverHost = $matches[2]
|
|
||||||
$port = [int]$matches[3]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Parse Query
|
|
||||||
$params = @{}
|
|
||||||
if ($queryStr) {
|
|
||||||
$parts = $queryStr -split "&"
|
|
||||||
foreach ($p in $parts) {
|
|
||||||
$kv = $p -split "="
|
|
||||||
if ($kv.Count -eq 2) {
|
|
||||||
$params[[System.Web.HttpUtility]::UrlDecode($kv[0])] = [System.Web.HttpUtility]::UrlDecode($kv[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract
|
|
||||||
$pbk = if ($params["pbk"]) { $params["pbk"] } else { throw "Отсутствует параметр pbk (Public Key)" }
|
|
||||||
$sid = if ($params["sid"]) { $params["sid"] } else { throw "Отсутствует параметр sid (Short ID)" }
|
|
||||||
$sni = if ($params["sni"]) { $params["sni"] } else { $serverHost }
|
|
||||||
$fp = if ($params["fp"]) { $params["fp"] } else { "chrome" }
|
|
||||||
$flow = if ($params["flow"]) { $params["flow"] } else { "" }
|
|
||||||
|
|
||||||
return @{
|
|
||||||
uuid = $uuid
|
|
||||||
server = $serverHost
|
|
||||||
server_port = $port
|
|
||||||
tag = $tag
|
|
||||||
public_key = $pbk
|
|
||||||
short_id = $sid
|
|
||||||
server_name = $sni
|
|
||||||
fingerprint = $fp
|
|
||||||
flow = $flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- MAIN ---
|
|
||||||
|
|
||||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
|
||||||
|
|
||||||
Write-Header "NATIVE SING-BOX (UDP ПОДДЕРЖКА)" -ClearScreen
|
|
||||||
|
|
||||||
$taskStatus = Get-TaskStatus -Name $TaskName
|
|
||||||
|
|
||||||
if ($taskStatus -and -not $Force) {
|
|
||||||
Write-Info "Sing-box уже установлен."
|
|
||||||
Write-Host " Статус: $taskStatus" -ForegroundColor ($taskStatus -eq "Running" ? "Green" : "Red")
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$opts = [Ordered]@{
|
|
||||||
"1" = "Сменить сервер (из подписки)"
|
|
||||||
"2" = "Ввести новую ссылку на подписку"
|
|
||||||
"3" = "Перезапустить службу"
|
|
||||||
"4" = "Остановить службу"
|
|
||||||
"5" = "Показать конфиг"
|
|
||||||
"6" = "Переустановить"
|
|
||||||
"b" = "Назад"
|
|
||||||
}
|
|
||||||
|
|
||||||
$act = Show-Menu -Options $opts
|
|
||||||
|
|
||||||
switch ($act) {
|
|
||||||
"1" {
|
|
||||||
# Reload existing sub logic could be added here, currently just re-runs install flow partially
|
|
||||||
# Simplification: treat as new setup but try to load saved sub url
|
|
||||||
$Force = $true
|
|
||||||
}
|
|
||||||
"2" { $SubscriptionUrl = ""; $Force = $true }
|
|
||||||
"3" { Manage-ScheduledTask -Name $TaskName -Action "Start"; Write-Success "Запущено!"; exit }
|
|
||||||
"4" { Manage-ScheduledTask -Name $TaskName -Action "Stop"; Write-Success "Остановлено!"; exit }
|
|
||||||
"5" { Get-Content "$InstallDir\config.json"; exit }
|
|
||||||
"6" { $Force = $true }
|
|
||||||
"b" { exit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Force -or -not $taskStatus) {
|
|
||||||
# 1. Загрузка
|
|
||||||
Write-Step "Установка Sing-box..."
|
|
||||||
if (!(Test-Path "$InstallDir\sing-box.exe")) {
|
|
||||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
|
||||||
$zipCtx = "$env:TEMP\sing-box.zip"
|
|
||||||
if (Download-File -Url $SingboxUrl -Destination $zipCtx) {
|
|
||||||
Expand-Archive -Path $zipCtx -DestinationPath $env:TEMP -Force
|
|
||||||
$extracted = Get-ChildItem "$env:TEMP\sing-box-*" -Directory | Select -First 1
|
|
||||||
Copy-Item "$($extracted.FullName)\sing-box.exe" "$InstallDir\sing-box.exe" -Force
|
|
||||||
Remove-Item $zipCtx; Remove-Item $extracted.FullName -Recurse -Force
|
|
||||||
Write-Success "Sing-box скачан"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Read-Host "Нажмите Enter для выхода..."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Подписка
|
|
||||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
|
||||||
# Try load saved
|
|
||||||
$savedSub = "$InstallDir\sub_info.json"
|
|
||||||
if (Test-Path $savedSub) {
|
|
||||||
try {
|
|
||||||
$json = Get-Content $savedSub -Raw | ConvertFrom-Json
|
|
||||||
if ($json.url) {
|
|
||||||
Write-Info "Найдена сохраненная подписка: $($json.url)"
|
|
||||||
if ((Read-Host "Использовать? (y/n)") -eq 'y') { $SubscriptionUrl = $json.url }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
|
||||||
$SubscriptionUrl = Read-Host "`n🔗 Введите URL подписки (VLESS)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($SubscriptionUrl)) {
|
|
||||||
Write-Error "Url не указан"
|
|
||||||
Read-Host "Нажмите Enter для выхода..."
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- PARSING ---
|
|
||||||
$data = @{ success = $false; config = $null; error = "" }
|
|
||||||
|
|
||||||
if ($SubscriptionUrl.StartsWith("vless://")) {
|
|
||||||
try {
|
|
||||||
$p = Parse-VlessUrl -Url $SubscriptionUrl
|
|
||||||
$outbound = [Ordered]@{
|
|
||||||
type = "vless"
|
|
||||||
tag = $p.tag
|
|
||||||
server = $p.server
|
|
||||||
server_port = $p.server_port
|
|
||||||
uuid = $p.uuid
|
|
||||||
flow = $p.flow
|
|
||||||
tls = @{
|
|
||||||
enabled = $true
|
|
||||||
server_name = $p.server_name
|
|
||||||
utls = @{ enabled = $true; fingerprint = $p.fingerprint }
|
|
||||||
reality = @{
|
|
||||||
enabled = $true
|
|
||||||
public_key = $p.public_key
|
|
||||||
short_id = $p.short_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packet_encoding = "xudp"
|
|
||||||
}
|
|
||||||
$data.success = $true
|
|
||||||
$data.config = @{ outbounds = @($outbound) }
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
$data.error = $_.Exception.Message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$data = Get-SubscriptionData -Url $SubscriptionUrl -Headers (Get-SubscriptionHeaders)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- PARSING LOGIC ENHANCEMENT ---
|
|
||||||
if (-not $data.success) {
|
|
||||||
# Fallback: Try to handle non-JSON body (Base64 or Plain Text)
|
|
||||||
try {
|
|
||||||
Write-Info "JSON парсинг не удался, пробую как список ссылок..."
|
|
||||||
$content = $data.rawContent
|
|
||||||
|
|
||||||
# Base64 decode if needed
|
|
||||||
if ($content -match "^[A-Za-z0-9+/=]+$") {
|
|
||||||
try {
|
|
||||||
$bytes = [System.Convert]::FromBase64String($content)
|
|
||||||
$content = [System.Text.Encoding]::UTF8.GetString($bytes)
|
|
||||||
}
|
|
||||||
catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to find vless:// links
|
|
||||||
$links = $content -split "[\r\n]+" | Where-Object { $_ -match "^vless://" }
|
|
||||||
|
|
||||||
if ($links.Count -gt 0) {
|
|
||||||
Write-Success "Найдено ссылок: $($links.Count)"
|
|
||||||
|
|
||||||
# Mock a config object with these links as "outbounds"
|
|
||||||
# Note: We can't fully parsing VLESS query params in pure PS easily without a lot of regex
|
|
||||||
# So we will try a simpler approach: Let sing-box do it? No, sing-box needs config.
|
|
||||||
|
|
||||||
# WORKAROUND: Create a minimal outbound for each link
|
|
||||||
# Parsing `vless://UUID@HOST:PORT?security=reality&...#NAME`
|
|
||||||
$parsedOutbounds = @()
|
|
||||||
|
|
||||||
foreach ($link in $links) {
|
|
||||||
if ($link -match "vless://([^@]+)@([^:]+):(\d+)(\?.*)?(#.*)?") {
|
|
||||||
$uuid = $matches[1]
|
|
||||||
$server = $matches[2]
|
|
||||||
$port = [int]$matches[3]
|
|
||||||
$query = $matches[4]
|
|
||||||
$hash = $matches[5]
|
|
||||||
|
|
||||||
$tag = if ($hash) { $hash.Substring(1) } else { "${server}:${port}" }
|
|
||||||
$tag = [System.Web.HttpUtility]::UrlDecode($tag)
|
|
||||||
|
|
||||||
# Parse Query Params
|
|
||||||
$flow = ""; $fp = ""; $pbk = ""; $sid = ""; $sni = ""; $serviceName = ""
|
|
||||||
|
|
||||||
if ($query) {
|
|
||||||
if ($query -match "flow=([^&]+)") { $flow = $matches[1] }
|
|
||||||
if ($query -match "fp=([^&]+)") { $fp = $matches[1] }
|
|
||||||
if ($query -match "pbk=([^&]+)") { $pbk = $matches[1] }
|
|
||||||
if ($query -match "sid=([^&]+)") { $sid = $matches[1] }
|
|
||||||
if ($query -match "sni=([^&]+)") { $sni = $matches[1] }
|
|
||||||
if ($query -match "serviceName=([^&]+)") { $serviceName = $matches[1] }
|
|
||||||
}
|
|
||||||
|
|
||||||
# Construct Sing-box outbound (REALITY based assumption for modern vless)
|
|
||||||
$out = [Ordered]@{
|
|
||||||
type = "vless"
|
|
||||||
tag = $tag
|
|
||||||
server = $server
|
|
||||||
server_port = $port
|
|
||||||
uuid = $uuid
|
|
||||||
flow = $flow
|
|
||||||
tls = @{
|
|
||||||
enabled = $true
|
|
||||||
server_name = $sni
|
|
||||||
utls = @{ enabled = $true; fingerprint = $fp }
|
|
||||||
reality = @{
|
|
||||||
enabled = $true
|
|
||||||
public_key = $pbk
|
|
||||||
short_id = $sid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packet_encoding = "xudp"
|
|
||||||
}
|
|
||||||
$parsedOutbounds += $out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parsedOutbounds.Count -gt 0) {
|
|
||||||
$data.success = $true
|
|
||||||
$data.config = @{ outbounds = $parsedOutbounds }
|
|
||||||
$data.error = $null
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
throw "Не удалось распарсить VLESS ссылки"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
throw $data.error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Error "Ошибка обработки подписки: $_"
|
|
||||||
Write-Host " Скрипт поддерживает: SIP008 (JSON) или список VLESS+Reality ссылок." -ForegroundColor Yellow
|
|
||||||
Read-Host "Нажмите Enter для выхода..."
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Save sub info
|
|
||||||
@{ url = $SubscriptionUrl } | ConvertTo-Json | Set-Content "$InstallDir\sub_info.json"
|
|
||||||
|
|
||||||
# 3. Выбор сервера
|
|
||||||
$server = Select-Server -Config $data.config
|
|
||||||
if (!$server) {
|
|
||||||
Read-Host "Нажмите Enter для выхода..."
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
|
|
||||||
# 4. Конфиг
|
|
||||||
$cfg = New-SingboxConfig -Outbound $server.outbound -Port $LocalProxyPort
|
|
||||||
$cfg | ConvertTo-Json -Depth 10 | Set-Content "$InstallDir\config.json" -Encoding UTF8
|
|
||||||
|
|
||||||
# 5. Задача
|
|
||||||
Manage-ScheduledTask -Name $TaskName -ExePath "$InstallDir\sing-box.exe" -Arguments "run -c `"$InstallDir\config.json`"" -WorkDir $InstallDir -Action "Install"
|
|
||||||
Manage-ScheduledTask -Name $TaskName -Action "Start"
|
|
||||||
|
|
||||||
# 6. Firewall
|
|
||||||
if (Ensure-FirewallPort -Port $LocalProxyPort -Name "SingBox-Proxy-Port") {
|
|
||||||
Write-Success "Правило Firewall создано (порт $LocalProxyPort)"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Success "Успешно установлено и запущено!"
|
|
||||||
Write-Info "Локальный прокси: 127.0.0.1:$LocalProxyPort"
|
|
||||||
|
|
||||||
$ips = Get-LocalIPs
|
|
||||||
if ($ips) {
|
|
||||||
Write-Info "Доступно из сети по адресам:"
|
|
||||||
foreach ($ip in $ips) {
|
|
||||||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🗑️ UNINSTALL ALL (CLEANUP)
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
param([switch]$Debug)
|
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
. "$ScriptDir\lib\Common.ps1"
|
|
||||||
. "$ScriptDir\lib\System.ps1"
|
|
||||||
|
|
||||||
if ($Debug) { Set-DebugMode -Enabled $true }
|
|
||||||
|
|
||||||
Write-Header "ПОЛНОЕ УДАЛЕНИЕ" -ClearScreen
|
|
||||||
|
|
||||||
Ensure-Admin
|
|
||||||
|
|
||||||
Write-Warning "Это действие удалит весь установленный софт:"
|
|
||||||
Write-Host " - Sing-box (Служба и файлы)" -ForegroundColor Gray
|
|
||||||
Write-Host " - ProxiFyre (Служба и файлы)" -ForegroundColor Gray
|
|
||||||
Write-Host " - Драйвер WinPacketFilter" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
if ((Read-Host "Вы уверены? (y/n)") -ne 'y') { exit }
|
|
||||||
|
|
||||||
Write-Step "Удаление Sing-box..."
|
|
||||||
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Stop"
|
|
||||||
Manage-ScheduledTask -Name "SingBoxProxy" -Action "Uninstall"
|
|
||||||
|
|
||||||
if (Test-Path "C:\Tools\sing-box") {
|
|
||||||
Remove-Item "C:\Tools\sing-box" -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Write-Success "Файлы удалены"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Step "Удаление Discord Proxy (ProxiFyre)..."
|
|
||||||
$pfDir = "C:\Tools\ProxiFyre"
|
|
||||||
if (Test-Path "$pfDir\ProxiFyre.exe") {
|
|
||||||
if (Get-DebugMode) {
|
|
||||||
& "$pfDir\ProxiFyre.exe" uninstall
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
& "$pfDir\ProxiFyre.exe" uninstall 2>&1 | Out-Null
|
|
||||||
}
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
Write-Success "Служба удалена"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Test-Path $pfDir) {
|
|
||||||
Remove-Item $pfDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Write-Success "Файлы удалены"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Step "Удаление драйвера..."
|
|
||||||
# Тут сложно удалить MSI тихо без GUID, но попробуем через known path или пропустим, т.к. драйвер может быть нужен другим
|
|
||||||
Write-Info "Драйвер WinPacketFilter оставлен (он может использоваться другим ПО)."
|
|
||||||
Write-Info "Если нужно, удалите его через 'Установка и удаление программ'."
|
|
||||||
|
|
||||||
Write-Success "Очистка завершена!"
|
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']);
|
||||||
|
const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
|
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||||
|
|
||||||
|
interface NeighborEntry extends Record<string, unknown> {
|
||||||
|
state?: unknown;
|
||||||
|
lladdr?: unknown;
|
||||||
|
dev?: unknown;
|
||||||
|
dst?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborObservation {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
active: boolean;
|
||||||
|
observedAt: string;
|
||||||
|
source: 'neighbor';
|
||||||
|
}
|
||||||
|
|
||||||
|
function neighborEntry(value: unknown): NeighborEntry {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as NeighborEntry
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeviceInterface(value: unknown) {
|
||||||
|
const name = String(value || '');
|
||||||
|
return INTERFACE_PATTERN.test(name)
|
||||||
|
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNeighborSnapshot(value: unknown, observedAt = new Date().toISOString()): NeighborObservation[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.flatMap((value) => {
|
||||||
|
const entry = neighborEntry(value);
|
||||||
|
const states = (Array.isArray(entry.state) ? entry.state : [entry.state])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((state: unknown) => String(state).toUpperCase());
|
||||||
|
const mac = String(entry.lladdr || '').toLowerCase();
|
||||||
|
const deviceInterface = String(entry.dev || '');
|
||||||
|
if (!entry.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||||
|
|| states.some((state) => IGNORED_STATES.has(state))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
ip: String(entry.dst),
|
||||||
|
mac,
|
||||||
|
interface: deviceInterface,
|
||||||
|
active: states.some((state) => ACTIVE_STATES.has(state)),
|
||||||
|
observedAt,
|
||||||
|
source: 'neighbor',
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNeighborSnapshot(run: typeof spawnSync = spawnSync, now = () => new Date()) {
|
||||||
|
const observedAt = now().toISOString();
|
||||||
|
const result = run('ip', ['-j', 'neigh', 'show'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1500,
|
||||||
|
});
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
return {
|
||||||
|
observedAt,
|
||||||
|
observations: [],
|
||||||
|
error: result.error?.message || String(result.stderr || 'ip neigh завершился с ошибкой').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
observedAt,
|
||||||
|
observations: parseNeighborSnapshot(JSON.parse(result.stdout || '[]'), observedAt),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
const parsePort = (value: string | undefined, fallback: number) => {
|
||||||
|
const parsed = Number.parseInt(value || '', 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
const proxyPort = parsePort(
|
||||||
|
process.env.PROXY_PORT,
|
||||||
|
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
|
port: parsePort(process.env.PORT, 3456),
|
||||||
|
proxyPort,
|
||||||
|
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
||||||
|
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
||||||
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||||
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
|
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
|
||||||
|
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
|
||||||
|
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
||||||
|
bypassCidrs: (process.env.BYPASS_CIDRS
|
||||||
|
|| "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4")
|
||||||
|
.trim().split(/\s+/).filter(Boolean),
|
||||||
|
dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock",
|
||||||
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
|
dataDir,
|
||||||
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
|
configPath:
|
||||||
|
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||||
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
|
statePath: path.join(dataDir, "state.json"),
|
||||||
|
deviceStatePath: path.join(dataDir, "devices.json"),
|
||||||
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
|
hostNetworkStatePath:
|
||||||
|
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
|
||||||
|
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
|
||||||
|
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
|
||||||
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
|
appName: "VPN Proxy Gateway",
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||||
|
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import {
|
||||||
|
createDomainTrafficService,
|
||||||
|
readSingboxConnections,
|
||||||
|
} from './services/domainTrafficService.js';
|
||||||
|
|
||||||
|
const socketPath = settings.dataplaneSocket;
|
||||||
|
const runtime = createSingboxRuntime({
|
||||||
|
configPath: settings.configPath,
|
||||||
|
gateway: true,
|
||||||
|
tproxyChain: settings.tproxyChain,
|
||||||
|
});
|
||||||
|
const versionInfo = buildVersionInfo('gateway');
|
||||||
|
const traffic = createDeviceTrafficService({
|
||||||
|
observe: () => readNeighborSnapshot(),
|
||||||
|
uploadChain: settings.trafficUploadChain,
|
||||||
|
downloadChain: settings.trafficDownloadChain,
|
||||||
|
bypassCidrs: settings.bypassCidrs,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
});
|
||||||
|
const devicePolicy = createDevicePolicyService({
|
||||||
|
chain: settings.devicePolicyChain,
|
||||||
|
tproxyPort: settings.tproxyPort,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
});
|
||||||
|
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||||
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
|
});
|
||||||
|
const domainTraffic = createDomainTrafficService({
|
||||||
|
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||||
|
devices: () => traffic.snapshot().devices,
|
||||||
|
});
|
||||||
|
let ready = false;
|
||||||
|
let trafficTimer: NodeJS.Timeout | null = null;
|
||||||
|
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||||
|
const MAX_POLICY_BODY_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk: Buffer | string) => {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
reject(new Error('Device policy request слишком большой'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tooLarge) chunks.push(buffer);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Device policy request содержит невалидный JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
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,
|
||||||
|
devicePolicy: devicePolicy.snapshot(),
|
||||||
|
ready,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/devices') {
|
||||||
|
return sendJson(res, 200, readNeighborSnapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/device-traffic') {
|
||||||
|
return sendJson(res, 200, traffic.snapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
||||||
|
return sendJson(res, 200, domainTraffic.snapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||||
|
return sendJson(res, 200, devicePolicy.snapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'PUT' && req.url === '/device-policy') {
|
||||||
|
const body = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||||
|
const { services = [], target = null } = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||||
|
vpnAvailable: runtime.running,
|
||||||
|
services,
|
||||||
|
target,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
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: errorMessage(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 не запущен: ${errorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
ready = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
traffic.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||||
|
});
|
||||||
|
trafficTimer = setInterval(() => {
|
||||||
|
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||||
|
}, 15_000);
|
||||||
|
trafficTimer.unref();
|
||||||
|
setImmediate(() => {
|
||||||
|
domainTraffic.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||||
|
});
|
||||||
|
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
|
||||||
|
domainTrafficTimer = setInterval(() => {
|
||||||
|
domainTraffic.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||||
|
}, 2_000);
|
||||||
|
domainTrafficTimer.unref();
|
||||||
|
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function shutdown() {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
ready = false;
|
||||||
|
if (trafficTimer) clearInterval(trafficTimer);
|
||||||
|
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||||
|
await runtime.shutdown();
|
||||||
|
server.close(() => {
|
||||||
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
|
||||||
|
type SendDataplaneRequest = (
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method?: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs?: number,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(
|
||||||
|
socketPath: string,
|
||||||
|
pathname: string,
|
||||||
|
method = 'GET',
|
||||||
|
body: unknown = null,
|
||||||
|
timeoutMs = 6000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const encoded = body == null ? null : JSON.stringify(body);
|
||||||
|
const req = http.request({
|
||||||
|
socketPath,
|
||||||
|
path: pathname,
|
||||||
|
method,
|
||||||
|
headers: encoded ? {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': Buffer.byteLength(encoded),
|
||||||
|
} : {},
|
||||||
|
}, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
let body: unknown = {};
|
||||||
|
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(String(record(body).error || `Dataplane HTTP ${res.statusCode}`)));
|
||||||
|
}
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Dataplane не ответил за ${Math.ceil(timeoutMs / 1000)} секунд`)));
|
||||||
|
req.end(encoded);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataplaneClient(socketPath: string, send: SendDataplaneRequest = request) {
|
||||||
|
let current: Record<string, unknown> = { running: false, startedAt: null };
|
||||||
|
const update = async (pathname: string, method: string) => {
|
||||||
|
try {
|
||||||
|
current = record(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'),
|
||||||
|
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||||
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
|
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||||
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
|
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
|
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||||
|
try {
|
||||||
|
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apply: () => update('/apply', 'POST'),
|
||||||
|
restart: () => update('/restart', 'POST'),
|
||||||
|
stop: () => update('/stop', 'POST'),
|
||||||
|
shutdown: async () => current,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface ConnectionServiceDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: {
|
||||||
|
readConfig(): unknown | null;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
exists(): boolean;
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
stopCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restartCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeCommandResult =
|
||||||
|
| { ok: true; mutationStarted: true }
|
||||||
|
| { ok: false; mutationStarted: boolean; error: unknown };
|
||||||
|
|
||||||
|
export async function captureRuntimeCommand(
|
||||||
|
command: () => Promise<unknown>,
|
||||||
|
{ preMutationErrorCodes = [] }: { preMutationErrorCodes?: readonly string[] } = {},
|
||||||
|
): Promise<RuntimeCommandResult> {
|
||||||
|
try {
|
||||||
|
await command();
|
||||||
|
return { ok: true, mutationStarted: true };
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
return { ok: false, mutationStarted: !preMutationErrorCodes.includes(code), error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionService(dependencies: ConnectionServiceDependencies) {
|
||||||
|
const apply = (serverId: unknown, selectedTag: unknown) => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const requestedId = String(serverId).trim();
|
||||||
|
const requestedTag = String(selectedTag).trim();
|
||||||
|
const resolvedId = requestedId || (() => {
|
||||||
|
const matches = previousState.servers.filter((server) => server.label === requestedTag);
|
||||||
|
return matches.length === 1 ? matches[0].id : '';
|
||||||
|
})();
|
||||||
|
const selectedServer = previousState.servers.find((server) => server.id === resolvedId);
|
||||||
|
if (!selectedServer) throw new HarborError('SERVER_NOT_FOUND');
|
||||||
|
|
||||||
|
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||||
|
if (!subscriptionConfig) throw new HarborError('CONFIG_INVALID');
|
||||||
|
const nextConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedServer.id,
|
||||||
|
previousState.routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let desiredCommitStarted = false;
|
||||||
|
let configMutationStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
desiredCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
selectedServerId: selectedServer.id,
|
||||||
|
connectionDesired: 'running',
|
||||||
|
}));
|
||||||
|
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(nextConfig);
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
appliedServerId: selectedServer.id,
|
||||||
|
appliedAt: dependencies.now().toISOString(),
|
||||||
|
appliedRouteRules: state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(desiredCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverId: selectedServer.id, selectedTag: selectedServer.label };
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
let wasRunning: boolean | null = null;
|
||||||
|
try {
|
||||||
|
wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
} catch {}
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.stopCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({ ...state, connectionDesired: 'stopped' }));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const restart = () => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (!dependencies.config.exists()) throw new HarborError('CONFIG_INVALID');
|
||||||
|
let wasRunning: boolean | null = null;
|
||||||
|
try {
|
||||||
|
wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
} catch {}
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = await dependencies.runtime.restartCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
appliedServerId: state.selectedServerId,
|
||||||
|
connectionDesired: 'running',
|
||||||
|
appliedRouteRules: state.routeRules,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(runtimeMutationStarted && wasRunning !== null ? [{
|
||||||
|
run: () => wasRunning ? dependencies.runtime.start() : dependencies.runtime.stop(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Connection rollback failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { apply, stop, restart };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionService = ReturnType<typeof createConnectionService>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
captureRuntimeCommand,
|
||||||
|
createConnectionService,
|
||||||
|
type RuntimeCommandResult,
|
||||||
|
type ConnectionService,
|
||||||
|
} from './connectionService.js';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
interface DiagnosticServer {
|
||||||
|
id: unknown;
|
||||||
|
label: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticState {
|
||||||
|
appliedServerId?: unknown;
|
||||||
|
selectedServerId?: unknown;
|
||||||
|
servers?: DiagnosticServer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiagnosticsResult extends Record<string, unknown> {
|
||||||
|
vpn?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsDependencies {
|
||||||
|
readState(): DiagnosticState;
|
||||||
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new TypeError('Diagnostics adapter returned an invalid result');
|
||||||
|
}
|
||||||
|
return value as DiagnosticsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsUseCase(
|
||||||
|
dependencies: ConnectivityDiagnosticsDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async run(services: unknown, target: unknown) {
|
||||||
|
const state = dependencies.readState();
|
||||||
|
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||||
|
const selected = (Array.isArray(state.servers) ? state.servers : [])
|
||||||
|
.find((server) => server.id === appliedServerId);
|
||||||
|
const server = selected ? { id: selected.id, label: selected.label } : null;
|
||||||
|
const result = diagnosticsResult(await dependencies.runDiagnostics(services, target));
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
vpn: {
|
||||||
|
...result.vpn,
|
||||||
|
server,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectivityDiagnosticsUseCase = ReturnType<
|
||||||
|
typeof createConnectivityDiagnosticsUseCase
|
||||||
|
>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
createConnectivityDiagnosticsUseCase,
|
||||||
|
type ConnectivityDiagnosticsUseCase,
|
||||||
|
} from './connectivityDiagnosticsUseCase.js';
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import type { GatewayAutoState, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface HostNetworkState {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface GatewayAutoServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(
|
||||||
|
subscriptionConfig: unknown,
|
||||||
|
selectedServerId: string,
|
||||||
|
routeRules: StoredState['routeRules'],
|
||||||
|
gatewayAuto: GatewayAutoState,
|
||||||
|
): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): boolean;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
discovery: {
|
||||||
|
readHostNetwork(): HostNetworkState | null;
|
||||||
|
probeGateway(input: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: string;
|
||||||
|
}): Promise<VerifiedGateway>;
|
||||||
|
};
|
||||||
|
transition: {
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
applyPreference(state: GatewayAutoState, enabled: boolean): GatewayAutoState;
|
||||||
|
next(
|
||||||
|
current: GatewayAutoState,
|
||||||
|
input: {
|
||||||
|
network: HostNetworkState | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
): GatewayAutoState;
|
||||||
|
sameRoute(
|
||||||
|
previous: GatewayAutoState['gateway'] | HostNetworkState | null | undefined,
|
||||||
|
current: HostNetworkState | null | undefined,
|
||||||
|
): boolean;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(timer: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRouteChange(state: GatewayAutoState): void;
|
||||||
|
onDiscoveryWarning(reason: string): void;
|
||||||
|
onTimerError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommitOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
persistEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshOptions {
|
||||||
|
reconfigure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error && typeof error === 'object' && 'message' in error && error.message
|
||||||
|
? String(error.message)
|
||||||
|
: 'Gateway presence check failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoService(dependencies: GatewayAutoServiceDependencies) {
|
||||||
|
let current = dependencies.transition.createInitial();
|
||||||
|
let refreshPromise: Promise<GatewayAutoState> | null = null;
|
||||||
|
let discoveryTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCandidate = async (
|
||||||
|
candidate: GatewayAutoState,
|
||||||
|
{ reconfigure = true, persistEnabled }: CommitOptions = {},
|
||||||
|
) => {
|
||||||
|
const previousGatewayAuto = current;
|
||||||
|
const stateChanged = !isDeepStrictEqual(previousGatewayAuto, candidate);
|
||||||
|
const modeChanged = previousGatewayAuto.mode !== candidate.mode;
|
||||||
|
if (!stateChanged && persistEnabled === undefined) return current;
|
||||||
|
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
const subscriptionConfig = modeChanged
|
||||||
|
? dependencies.subscription.readConfig()
|
||||||
|
: null;
|
||||||
|
const candidateConfig = modeChanged && previousState.selectedServerId && subscriptionConfig
|
||||||
|
? dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
previousState.selectedServerId,
|
||||||
|
previousState.routeRules,
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const previousConfig = candidateConfig === null ? null : dependencies.config.read();
|
||||||
|
const wasRunning = candidateConfig === null ? false : dependencies.runtime.isRunning();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let gatewayAutoPublished = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (candidateConfig !== null) {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
if (reconfigure && wasRunning) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateChanged) {
|
||||||
|
current = candidate;
|
||||||
|
gatewayAutoPublished = true;
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => state);
|
||||||
|
}
|
||||||
|
if (persistEnabled !== undefined) {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
gatewayAutoEnabled: persistEnabled,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(gatewayAutoPublished ? [{ run: () => { current = previousGatewayAuto; } }] : []),
|
||||||
|
...(configMutationStarted ? [{ run: () => restoreConfig(previousConfig) }] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Gateway auto rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeChanged) dependencies.onRouteChange(candidate);
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRefresh = async ({ reconfigure = true }: RefreshOptions) => {
|
||||||
|
const state = dependencies.state.read();
|
||||||
|
const network = state.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!network) {
|
||||||
|
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||||
|
const discoveredState = dependencies.transition.next(current, {
|
||||||
|
network: null,
|
||||||
|
error: discoveryError,
|
||||||
|
});
|
||||||
|
const candidate = dependencies.transition.applyPreference(
|
||||||
|
state.subscriptionUrl
|
||||||
|
? { ...discoveredState, lastError: discoveryError }
|
||||||
|
: discoveredState,
|
||||||
|
state.gatewayAutoEnabled !== false,
|
||||||
|
);
|
||||||
|
return commitCandidate(candidate, { reconfigure });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.mode === 'gateway-direct' &&
|
||||||
|
!dependencies.transition.sameRoute(current.gateway, network)
|
||||||
|
) {
|
||||||
|
await commitCandidate(
|
||||||
|
dependencies.transition.next(current, { network }),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verifiedGateway: VerifiedGateway;
|
||||||
|
try {
|
||||||
|
verifiedGateway = await dependencies.discovery.probeGateway({
|
||||||
|
gateway: network.gateway,
|
||||||
|
subscriptionUrl: String(state.subscriptionUrl),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = errorMessage(error);
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestNetwork = latestState.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
if (current.lastError !== reason) dependencies.onDiscoveryWarning(reason);
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, {
|
||||||
|
network: latestNetwork,
|
||||||
|
error: reason,
|
||||||
|
}),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestState = dependencies.state.read();
|
||||||
|
const latestNetwork = latestState.subscriptionUrl
|
||||||
|
? dependencies.discovery.readHostNetwork()
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||||
|
!dependencies.transition.sameRoute(network, latestNetwork)
|
||||||
|
) {
|
||||||
|
return commitCandidate(dependencies.transition.createInitial(), { reconfigure });
|
||||||
|
}
|
||||||
|
return commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(
|
||||||
|
dependencies.transition.next(current, { network: latestNetwork, verifiedGateway }),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
|
{ reconfigure },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = (options: RefreshOptions = {}) => {
|
||||||
|
if (dependencies.appMode !== 'client') return Promise.resolve(current);
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
refreshPromise = dependencies.serialize(() => runRefresh(options)).finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
return refreshPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEnabled = (enabled: boolean) => dependencies.serialize(() => commitCandidate(
|
||||||
|
dependencies.transition.applyPreference(current, enabled),
|
||||||
|
{ persistEnabled: enabled },
|
||||||
|
));
|
||||||
|
|
||||||
|
const startDiscovery = (intervalMs: number) => {
|
||||||
|
if (discoveryTimer) return;
|
||||||
|
discoveryTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
void refresh().catch(dependencies.onTimerError);
|
||||||
|
}, intervalMs);
|
||||||
|
discoveryTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDiscovery = () => {
|
||||||
|
if (!discoveryTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(discoveryTimer);
|
||||||
|
discoveryTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
read: () => current,
|
||||||
|
set: (value: GatewayAutoState) => { current = value; },
|
||||||
|
createInitial: dependencies.transition.createInitial,
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
startDiscovery,
|
||||||
|
stopDiscovery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayAutoService = ReturnType<typeof createGatewayAutoService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createRouteRulesService,
|
||||||
|
type RouteRulesService,
|
||||||
|
} from './routeRulesService.js';
|
||||||
|
export {
|
||||||
|
createGatewayAutoService,
|
||||||
|
type GatewayAutoService,
|
||||||
|
} from './gatewayAutoService.js';
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import type { RouteRule, StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { normalizeRouteRules } from '../../../shared/routingRules.js';
|
||||||
|
import type { RuntimeCommandResult } from '../connection/index.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface RouteRulesDependencies {
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
subscription: { readConfig(): unknown | null };
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: RouteRule[]): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
applyCommand(): Promise<RuntimeCommandResult>;
|
||||||
|
restoreRunning(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
runOperation<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
|
||||||
|
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
|
||||||
|
const subscriptionConfig = dependencies.subscription.readConfig();
|
||||||
|
if (!previousState.selectedServerId || !subscriptionConfig) {
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
try {
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateConfig = dependencies.config.build(
|
||||||
|
subscriptionConfig,
|
||||||
|
previousState.selectedServerId,
|
||||||
|
routeRules,
|
||||||
|
);
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let configMutationStarted = false;
|
||||||
|
let runtimeMutationStarted = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
configMutationStarted = true;
|
||||||
|
dependencies.config.write(candidateConfig);
|
||||||
|
if (wasRunning) {
|
||||||
|
const command = await dependencies.runtime.applyCommand();
|
||||||
|
runtimeMutationStarted = command.mutationStarted;
|
||||||
|
if (!command.ok) throw command.error;
|
||||||
|
}
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...state,
|
||||||
|
routeRules,
|
||||||
|
...(wasRunning ? { appliedRouteRules: routeRules } : {}),
|
||||||
|
routeRulesRevision: state.routeRulesRevision + 1,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(configMutationStarted ? [{
|
||||||
|
run: () => previousConfig === null
|
||||||
|
? dependencies.config.remove()
|
||||||
|
: dependencies.config.restore(previousConfig),
|
||||||
|
}] : []),
|
||||||
|
...(wasRunning && runtimeMutationStarted ? [{
|
||||||
|
run: () => dependencies.runtime.restoreRunning(),
|
||||||
|
runtime: true,
|
||||||
|
}] : []),
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
], 'Route rules rollback failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => {
|
||||||
|
let routeRules: RouteRule[];
|
||||||
|
try {
|
||||||
|
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
const rulesRevision = expectedRulesRevision ?? expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dependencies.serialize(async () => {
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
const currentRevision = expectedRulesRevision == null
|
||||||
|
? current.revision
|
||||||
|
: current.routeRulesRevision;
|
||||||
|
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
|
||||||
|
await dependencies.runOperation(() => applyRules(current, routeRules));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteRulesService = ReturnType<typeof createRouteRulesService>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
checkServerHealth,
|
||||||
|
createServerHealthService,
|
||||||
|
SERVER_HEALTH_CONCURRENCY,
|
||||||
|
SERVER_HEALTH_MAX_COUNT,
|
||||||
|
type ServerHealthService,
|
||||||
|
} from './serverHealth.js';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { HarborServer } from '../../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
export const SERVER_HEALTH_MAX_COUNT = 30;
|
||||||
|
export const SERVER_HEALTH_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
type HealthServer = Pick<HarborServer, 'id' | 'label' | 'host' | 'port'>;
|
||||||
|
type Ping = (host: string, port: number) => Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
export async function checkServerHealth(
|
||||||
|
servers: HealthServer[],
|
||||||
|
ping: Ping,
|
||||||
|
{
|
||||||
|
maxCount = SERVER_HEALTH_MAX_COUNT,
|
||||||
|
concurrency = SERVER_HEALTH_CONCURRENCY,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const queue = servers.slice(0, maxCount);
|
||||||
|
const results: Array<Record<string, unknown>> = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerHealthDependencies {
|
||||||
|
readServers(): HarborServer[];
|
||||||
|
ping: Ping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthService(dependencies: ServerHealthDependencies) {
|
||||||
|
return {
|
||||||
|
check(serverIds: unknown) {
|
||||||
|
const requestedIds = new Set(Array.isArray(serverIds) ? serverIds.map(String) : []);
|
||||||
|
const servers = dependencies.readServers();
|
||||||
|
return checkServerHealth(
|
||||||
|
requestedIds.size
|
||||||
|
? servers.filter((server) => requestedIds.has(server.id))
|
||||||
|
: servers,
|
||||||
|
dependencies.ping,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerHealthService = ReturnType<typeof createServerHealthService>;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
createStateSnapshot,
|
||||||
|
normalizeStoredState,
|
||||||
|
type GatewayAutoState,
|
||||||
|
type OperationState,
|
||||||
|
type StateSnapshot,
|
||||||
|
type StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
|
||||||
|
interface RuntimeState {
|
||||||
|
running?: boolean;
|
||||||
|
startedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateReadResult {
|
||||||
|
snapshot: StateSnapshot;
|
||||||
|
storedState: StoredState;
|
||||||
|
gatewayAuto: GatewayAutoState;
|
||||||
|
configExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateServiceDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readStoredState: () => unknown;
|
||||||
|
refreshRuntime: () => Promise<RuntimeState>;
|
||||||
|
getGatewayAutoState: () => GatewayAutoState;
|
||||||
|
getOperationState: () => OperationState;
|
||||||
|
configExists: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionHost(value: unknown) {
|
||||||
|
try {
|
||||||
|
return `${new URL(String(value)).host}/…`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateService(dependencies: StateServiceDependencies) {
|
||||||
|
return {
|
||||||
|
async read(): Promise<StateReadResult> {
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
const storedState = normalizeStoredState(dependencies.readStoredState());
|
||||||
|
const gatewayAuto = dependencies.getGatewayAutoState();
|
||||||
|
const configExists = dependencies.configExists();
|
||||||
|
const snapshot = createStateSnapshot({
|
||||||
|
storedState,
|
||||||
|
runtime,
|
||||||
|
gatewayAuto,
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
configExists,
|
||||||
|
subscriptionHost: subscriptionHost(storedState.subscriptionUrl),
|
||||||
|
operation: dependencies.getOperationState(),
|
||||||
|
});
|
||||||
|
return { snapshot, storedState, gatewayAuto, configExists };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateService = ReturnType<typeof createStateService>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
createValidateSubscription,
|
||||||
|
type ValidateSubscription,
|
||||||
|
} from './validateSubscription.js';
|
||||||
|
export {
|
||||||
|
createSubscriptionService,
|
||||||
|
type SubscriptionService,
|
||||||
|
} from './subscriptionService.js';
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import type {
|
||||||
|
GatewayAutoState,
|
||||||
|
HarborServer,
|
||||||
|
StoredState,
|
||||||
|
} from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { finishRollback } from '../../services/rollback.js';
|
||||||
|
|
||||||
|
interface ParsedSubscription {
|
||||||
|
config: unknown;
|
||||||
|
sourceConfig?: unknown;
|
||||||
|
servers: HarborServer[];
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimerHandle = NodeJS.Timeout;
|
||||||
|
|
||||||
|
interface SubscriptionServiceDependencies {
|
||||||
|
provider: {
|
||||||
|
fetchSubscription(url: string): Promise<ParsedSubscription>;
|
||||||
|
selectRefreshedServer(
|
||||||
|
currentServerId: string,
|
||||||
|
currentServers: HarborServer[],
|
||||||
|
nextServers: HarborServer[],
|
||||||
|
): string;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
read(): StoredState;
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
read(): unknown;
|
||||||
|
write(value: unknown): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
build(subscriptionConfig: unknown, selectedServerId: string, routeRules: StoredState['routeRules']): unknown;
|
||||||
|
read(): string | null;
|
||||||
|
write(value: unknown): void;
|
||||||
|
restore(value: string): void;
|
||||||
|
remove(): void;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
isRunning(): Promise<boolean>;
|
||||||
|
stop(): Promise<unknown>;
|
||||||
|
start(): Promise<unknown>;
|
||||||
|
};
|
||||||
|
gatewayAuto: {
|
||||||
|
read(): GatewayAutoState;
|
||||||
|
set(value: GatewayAutoState): void;
|
||||||
|
createInitial(): GatewayAutoState;
|
||||||
|
};
|
||||||
|
serialize<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
scheduler: {
|
||||||
|
setInterval(callback: () => void, intervalMs: number): TimerHandle;
|
||||||
|
clearInterval(handle: TimerHandle): void;
|
||||||
|
};
|
||||||
|
onRefreshError(error: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetOptions {
|
||||||
|
stopRuntime?: boolean;
|
||||||
|
expectedSubscription?: {
|
||||||
|
url: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionMutationResult extends Record<string, unknown> {
|
||||||
|
success: true;
|
||||||
|
servers: HarborServer[];
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string;
|
||||||
|
selectedServerId: string;
|
||||||
|
selectedTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_SUBSCRIPTION_CODES = new Set([
|
||||||
|
'SUBSCRIPTION_EXPIRED',
|
||||||
|
'SUBSCRIPTION_DISABLED',
|
||||||
|
'SUBSCRIPTION_REJECTED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createSubscriptionService(dependencies: SubscriptionServiceDependencies) {
|
||||||
|
let refreshPromise: Promise<SubscriptionMutationResult> | null = null;
|
||||||
|
let refreshTimer: TimerHandle | null = null;
|
||||||
|
let subscriptionGeneration = 0;
|
||||||
|
|
||||||
|
const restoreCache = (previous: unknown) => {
|
||||||
|
if (previous !== null) dependencies.cache.write(previous);
|
||||||
|
else dependencies.cache.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreConfig = (previous: string | null) => {
|
||||||
|
if (previous === null) dependencies.config.remove();
|
||||||
|
else dependencies.config.restore(previous);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitSubscription = (
|
||||||
|
subscriptionUrl: string,
|
||||||
|
parsed: ParsedSubscription,
|
||||||
|
{ resetSelection = false, expectedGeneration }: {
|
||||||
|
resetSelection?: boolean;
|
||||||
|
expectedGeneration?: number;
|
||||||
|
} = {},
|
||||||
|
) => dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (
|
||||||
|
subscriptionGeneration !== expectedGeneration ||
|
||||||
|
(!resetSelection && previousState.subscriptionUrl !== subscriptionUrl)
|
||||||
|
) {
|
||||||
|
throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedServerId = resetSelection
|
||||||
|
? ''
|
||||||
|
: dependencies.provider.selectRefreshedServer(
|
||||||
|
previousState.selectedServerId,
|
||||||
|
previousState.servers,
|
||||||
|
parsed.servers,
|
||||||
|
);
|
||||||
|
const candidateConfig = selectedServerId
|
||||||
|
? dependencies.config.build(parsed.config, selectedServerId, previousState.routeRules)
|
||||||
|
: null;
|
||||||
|
const previousCache = dependencies.cache.read();
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||||
|
const wasRunning = await dependencies.runtime.isRunning();
|
||||||
|
let restoreRuntime = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((resetSelection || !candidateConfig) && wasRunning) {
|
||||||
|
restoreRuntime = true;
|
||||||
|
await dependencies.runtime.stop();
|
||||||
|
}
|
||||||
|
if (candidateConfig) dependencies.config.write(candidateConfig);
|
||||||
|
else dependencies.config.remove();
|
||||||
|
dependencies.cache.write({
|
||||||
|
url: subscriptionUrl,
|
||||||
|
config: parsed.sourceConfig || parsed.config,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
});
|
||||||
|
if (!resetSelection && wasRunning && candidateConfig) {
|
||||||
|
restoreRuntime = true;
|
||||||
|
await dependencies.runtime.start();
|
||||||
|
}
|
||||||
|
if (resetSelection) dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||||
|
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update((state) => ({
|
||||||
|
...(resetSelection ? {
|
||||||
|
routeRules: state.routeRules,
|
||||||
|
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||||
|
connectionDesired: 'stopped',
|
||||||
|
} : state),
|
||||||
|
subscriptionUrl,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
selectedServerId,
|
||||||
|
appliedServerId: selectedServerId,
|
||||||
|
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||||
|
}));
|
||||||
|
subscriptionGeneration += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||||
|
{ run: () => restoreCache(previousCache) },
|
||||||
|
{ run: () => restoreConfig(previousConfig) },
|
||||||
|
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||||
|
], 'Subscription rollback failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
selectedServerId,
|
||||||
|
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const importSubscription = async (subscriptionUrl: string) => {
|
||||||
|
const expectedGeneration = subscriptionGeneration;
|
||||||
|
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||||
|
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true, expectedGeneration });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSavedSubscription = ({
|
||||||
|
stopRuntime = true,
|
||||||
|
expectedSubscription,
|
||||||
|
}: ResetOptions = {}) => (
|
||||||
|
dependencies.serialize(async () => {
|
||||||
|
const previousState = dependencies.state.read();
|
||||||
|
if (expectedSubscription && (
|
||||||
|
previousState.subscriptionUrl !== expectedSubscription.url ||
|
||||||
|
subscriptionGeneration !== expectedSubscription.generation
|
||||||
|
)) return false;
|
||||||
|
const previousCache = dependencies.cache.read();
|
||||||
|
const previousConfig = dependencies.config.read();
|
||||||
|
const previousGatewayAuto = dependencies.gatewayAuto.read();
|
||||||
|
const wasRunning = stopRuntime ? await dependencies.runtime.isRunning() : false;
|
||||||
|
let restoreRuntime = false;
|
||||||
|
let stateCommitStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (stopRuntime) {
|
||||||
|
restoreRuntime = wasRunning;
|
||||||
|
await dependencies.runtime.stop();
|
||||||
|
}
|
||||||
|
dependencies.config.remove();
|
||||||
|
dependencies.cache.remove();
|
||||||
|
dependencies.gatewayAuto.set(dependencies.gatewayAuto.createInitial());
|
||||||
|
stateCommitStarted = true;
|
||||||
|
dependencies.state.update(() => ({ routeRules: previousState.routeRules }));
|
||||||
|
subscriptionGeneration += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await finishRollback(error, [
|
||||||
|
...(stateCommitStarted ? [{ run: () => dependencies.state.update(() => previousState) }] : []),
|
||||||
|
{ run: () => dependencies.gatewayAuto.set(previousGatewayAuto) },
|
||||||
|
{ run: () => restoreCache(previousCache) },
|
||||||
|
{ run: () => restoreConfig(previousConfig) },
|
||||||
|
...(restoreRuntime ? [{ run: () => dependencies.runtime.start(), runtime: true }] : []),
|
||||||
|
], 'Subscription rollback failed');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshSavedSubscription = () => {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
|
const subscriptionUrl = dependencies.state.read().subscriptionUrl;
|
||||||
|
const expectedGeneration = subscriptionGeneration;
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||||
|
const parsed = await dependencies.provider.fetchSubscription(subscriptionUrl);
|
||||||
|
return await commitSubscription(subscriptionUrl, parsed, { expectedGeneration });
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error
|
||||||
|
? String(error.code)
|
||||||
|
: '';
|
||||||
|
if (subscriptionUrl && TERMINAL_SUBSCRIPTION_CODES.has(code)) {
|
||||||
|
const reset = await resetSavedSubscription({
|
||||||
|
expectedSubscription: { url: subscriptionUrl, generation: expectedGeneration },
|
||||||
|
});
|
||||||
|
if (!reset) throw new HarborError('STATE_CONFLICT');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
refreshPromise = operation;
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoRefresh = (intervalMs: number) => {
|
||||||
|
if (refreshTimer) return;
|
||||||
|
refreshTimer = dependencies.scheduler.setInterval(() => {
|
||||||
|
if (!dependencies.state.read().subscriptionUrl) return;
|
||||||
|
void refreshSavedSubscription().catch(dependencies.onRefreshError);
|
||||||
|
}, intervalMs);
|
||||||
|
refreshTimer.unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (!refreshTimer) return;
|
||||||
|
dependencies.scheduler.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
importSubscription,
|
||||||
|
refreshSavedSubscription,
|
||||||
|
resetSavedSubscription,
|
||||||
|
startAutoRefresh,
|
||||||
|
stopAutoRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubscriptionService = ReturnType<typeof createSubscriptionService>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
interface SubscriptionResult {
|
||||||
|
servers: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchSubscription = (url: string) => Promise<SubscriptionResult>;
|
||||||
|
|
||||||
|
export function createValidateSubscription(fetchSubscription: FetchSubscription) {
|
||||||
|
return async (url: unknown) => {
|
||||||
|
const parsed = await fetchSubscription(String(url).trim());
|
||||||
|
return { servers: parsed.servers.length };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateSubscription = ReturnType<typeof createValidateSubscription>;
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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']);
|
||||||
|
|
||||||
|
interface GatewayRoute {
|
||||||
|
gateway: string;
|
||||||
|
interface: string;
|
||||||
|
mac: string;
|
||||||
|
observedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifiedGateway {
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin?: string;
|
||||||
|
verifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayAutoRuntimeState {
|
||||||
|
mode: 'local-vpn' | 'gateway-direct';
|
||||||
|
failures: number;
|
||||||
|
gateway: GatewayRoute | null;
|
||||||
|
gatewayId: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
lastVerifiedAt: string | null;
|
||||||
|
lastError: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: unknown) {
|
||||||
|
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: unknown) {
|
||||||
|
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: unknown, nonce: unknown, gatewayId: unknown) {
|
||||||
|
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 }: {
|
||||||
|
appMode: unknown;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
gatewayId: unknown;
|
||||||
|
nonce: unknown;
|
||||||
|
}) {
|
||||||
|
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(
|
||||||
|
value: unknown,
|
||||||
|
{ subscriptionUrl, nonce }: { subscriptionUrl: unknown; nonce: unknown },
|
||||||
|
) {
|
||||||
|
const payload = record(value);
|
||||||
|
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(String(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'),
|
||||||
|
}: {
|
||||||
|
gateway: string;
|
||||||
|
subscriptionUrl: unknown;
|
||||||
|
port?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
timeoutMs?: number;
|
||||||
|
nonce?: string;
|
||||||
|
}): Promise<VerifiedGateway> {
|
||||||
|
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 = record(await response.json().catch(() => ({})));
|
||||||
|
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
||||||
|
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gatewayId: String(payload.gatewayId),
|
||||||
|
uiOrigin: new URL(presenceUrl).origin,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHostNetworkState(value: unknown, {
|
||||||
|
now = Date.now(),
|
||||||
|
maxAgeMs = 15_000,
|
||||||
|
}: { now?: number; maxAgeMs?: number } = {}): GatewayRoute | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const gateway = String(candidate.gateway || '').trim();
|
||||||
|
const networkInterface = String(candidate.interface || '').trim();
|
||||||
|
const mac = String(candidate.mac || '').trim().toLowerCase();
|
||||||
|
const observedAt = Date.parse(String(candidate.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: string, options?: { now?: number; maxAgeMs?: number }) {
|
||||||
|
try {
|
||||||
|
return normalizeHostNetworkState(
|
||||||
|
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameGatewayRoute(previous: GatewayRoute | null, current: GatewayRoute | null) {
|
||||||
|
return Boolean(
|
||||||
|
previous &&
|
||||||
|
current &&
|
||||||
|
previous.gateway === current.gateway &&
|
||||||
|
previous.interface === current.interface &&
|
||||||
|
previous.mac === current.mac,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoState(): GatewayAutoRuntimeState {
|
||||||
|
return {
|
||||||
|
mode: 'local-vpn',
|
||||||
|
failures: 0,
|
||||||
|
gateway: null,
|
||||||
|
gatewayId: '',
|
||||||
|
uiOrigin: '',
|
||||||
|
lastVerifiedAt: null,
|
||||||
|
lastError: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGatewayPreference(state: GatewayAutoRuntimeState, enabled: boolean): GatewayAutoRuntimeState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextGatewayAutoState(current: GatewayAutoRuntimeState, {
|
||||||
|
network,
|
||||||
|
verifiedGateway = null,
|
||||||
|
error = 'Gateway presence check failed',
|
||||||
|
}: {
|
||||||
|
network: GatewayRoute | null;
|
||||||
|
verifiedGateway?: VerifiedGateway | null;
|
||||||
|
error?: unknown;
|
||||||
|
}): GatewayAutoRuntimeState {
|
||||||
|
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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const options = { encoding: 'utf8' as const };
|
||||||
|
|
||||||
|
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeHarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
|
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactLogDetails(value: unknown) {
|
||||||
|
return String(value || '').replace(/https?:\/\/\S+/gi, '[redacted-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendError(res: ServerResponse, error: unknown) {
|
||||||
|
const harborError = normalizeHarborError(error);
|
||||||
|
const correlationId = crypto.randomUUID();
|
||||||
|
const technical = harborError.cause instanceof Error
|
||||||
|
? harborError.cause.message
|
||||||
|
: harborError.details || error;
|
||||||
|
const technicalMessage = technical instanceof Error
|
||||||
|
? technical.message
|
||||||
|
: technical;
|
||||||
|
console.error(
|
||||||
|
`[control] request failed [${correlationId}] ${harborError.code}: ${redactLogDetails(technicalMessage)}`,
|
||||||
|
);
|
||||||
|
return sendJson(res, harborError.status, {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: harborError.code,
|
||||||
|
message: harborError.message,
|
||||||
|
retryable: harborError.retryable,
|
||||||
|
correlationId,
|
||||||
|
...(harborError.details ? { details: harborError.details } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ConnectionRuntimeRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'stop' | 'restart'>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { singboxRunning: boolean }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionRuntimeRoute(dependencies: ConnectionRuntimeRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await dependencies.withOperation('stop', () => dependencies.connection.stop());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
await dependencies.withOperation('start', () => dependencies.connection.restart());
|
||||||
|
await dependencies.sendState(res, { singboxRunning: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/index.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsRoute(
|
||||||
|
dependencies: ConnectivityDiagnosticsRouteDependencies,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false;
|
||||||
|
const { services = [], target = null } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.diagnostics.run(services, target);
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DeviceInventoryPort {
|
||||||
|
snapshot(): unknown;
|
||||||
|
refresh(): Promise<unknown>;
|
||||||
|
update(deviceId: string, patch: Record<string, unknown>, expectedRevision: unknown): unknown;
|
||||||
|
setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInventoryRouteDependencies {
|
||||||
|
deviceInventory: DeviceInventoryPort | null;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})$/;
|
||||||
|
const DEVICE_POLICY_PATH = /^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/;
|
||||||
|
|
||||||
|
export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
|
||||||
|
if (pathname === '/api/devices') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, dependencies.deviceInventory.snapshot());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/devices/refresh') {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'POST') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendJson(res, 200, await dependencies.deviceInventory.refresh());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceMatch = pathname.match(DEVICE_PATH);
|
||||||
|
if (deviceMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const { expectedRevision, ...patch } = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
dependencies.deviceInventory.update(deviceMatch[1], patch, expectedRevision),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyMatch = pathname.match(DEVICE_POLICY_PATH);
|
||||||
|
if (policyMatch) {
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'PUT') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await dependencies.readBody(req);
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
await dependencies.deviceInventory.setPolicy(
|
||||||
|
policyMatch[1],
|
||||||
|
body.mode,
|
||||||
|
body.expectedRevision,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { GatewayAutoService } from '../../features/routing/index.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayAutoRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
gatewayAuto: Pick<GatewayAutoService, 'setEnabled'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
readStatePayload(): Promise<Record<string, unknown> & { gatewayAuto?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayAutoRoute(dependencies: GatewayAutoRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/gateway-auto') return false;
|
||||||
|
if (dependencies.appMode !== 'client') throw new HarborError('REQUEST_INVALID');
|
||||||
|
const { enabled } = await dependencies.readBody(req);
|
||||||
|
if (typeof enabled !== 'boolean') throw new HarborError('REQUEST_INVALID');
|
||||||
|
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'gateway-auto',
|
||||||
|
() => dependencies.gatewayAuto.setEnabled(enabled),
|
||||||
|
);
|
||||||
|
const state = await dependencies.readStatePayload();
|
||||||
|
sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayPresence } from '../../gatewayPresence.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface GatewayPresenceState {
|
||||||
|
subscriptionUrl?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayPresenceRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
readState(): GatewayPresenceState;
|
||||||
|
getHwid(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGatewayPresenceRoute(dependencies: GatewayPresenceRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (req.method !== 'GET' || requestUrl.pathname !== '/api/gateway-presence') return false;
|
||||||
|
|
||||||
|
const state = dependencies.readState();
|
||||||
|
const gatewayId = dependencies.getHwid();
|
||||||
|
sendJson(res, 200, buildGatewayPresence({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
subscriptionUrl: state.subscriptionUrl,
|
||||||
|
gatewayId,
|
||||||
|
nonce: requestUrl.searchParams.get('nonce'),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { sendPrometheusMetrics } from '../../prometheusMetrics.js';
|
||||||
|
|
||||||
|
interface MetricsSnapshotPort {
|
||||||
|
metricsSnapshot(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrometheusMetricsRouteDependencies {
|
||||||
|
deviceInventory: MetricsSnapshotPort | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrometheusMetricsRoute(dependencies: PrometheusMetricsRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (pathname !== '/metrics') return false;
|
||||||
|
if (!dependencies.deviceInventory || req.method !== 'GET') {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
sendPrometheusMetrics(res, dependencies.deviceInventory.metricsSnapshot());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { RouteRulesService } from '../../features/routing/index.js';
|
||||||
|
|
||||||
|
interface RouteRulesRouteDependencies {
|
||||||
|
routeRules: RouteRulesService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false;
|
||||||
|
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req);
|
||||||
|
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ConnectionService } from '../../features/connection/index.js';
|
||||||
|
|
||||||
|
interface ServerApplyRouteDependencies {
|
||||||
|
connection: Pick<ConnectionService, 'apply'>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra: { serverId: string; selectedTag: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerApplyRoute(dependencies: ServerApplyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/apply') return false;
|
||||||
|
const { serverId = '', selectedTag = '' } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'apply-server',
|
||||||
|
() => dependencies.connection.apply(serverId, selectedTag),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ServerHealthService } from '../../features/servers/index.js';
|
||||||
|
|
||||||
|
interface ServerHealthRouteDependencies {
|
||||||
|
serverHealth: ServerHealthService;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
sendState(res: ServerResponse, extra: { results: Array<Record<string, unknown>> }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerHealthRoute(dependencies: ServerHealthRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/servers/ping-all') return false;
|
||||||
|
const { serverIds = [] } = await dependencies.readBody(req);
|
||||||
|
const results = await dependencies.serverHealth.check(serverIds);
|
||||||
|
await dependencies.sendState(res, { results });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildSharedProxyInfo } from '../../sharedProxy.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface SharedProxyRouteDependencies {
|
||||||
|
appMode: string;
|
||||||
|
proxyPort: unknown;
|
||||||
|
sharedProxyHost: unknown;
|
||||||
|
refreshRuntime(): Promise<{ running?: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedProxyRoute(dependencies: SharedProxyRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/shared-proxy') return false;
|
||||||
|
|
||||||
|
const runtime = await dependencies.refreshRuntime();
|
||||||
|
sendJson(res, 200, buildSharedProxyInfo({
|
||||||
|
appMode: dependencies.appMode,
|
||||||
|
proxyPort: dependencies.proxyPort,
|
||||||
|
running: runtime.running,
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: dependencies.sharedProxyHost,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { normalizeStoredState, type GatewayAutoState, type StateSnapshot } from '../../../shared/contracts/state.js';
|
||||||
|
import type { StateReadResult, StateService } from '../../features/state/stateService.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export interface LegacyStatePayload extends StateSnapshot, Record<string, unknown> {
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
configExists: boolean;
|
||||||
|
singboxRunning: boolean;
|
||||||
|
singboxStartedAt: string | null;
|
||||||
|
subscriptionHost: string;
|
||||||
|
hasSubscription: boolean;
|
||||||
|
selectedTag: string;
|
||||||
|
userInfo: Record<string, unknown>;
|
||||||
|
fetchedAt: string | null;
|
||||||
|
gatewayAuto: {
|
||||||
|
mode: string;
|
||||||
|
enabled: boolean;
|
||||||
|
available: boolean;
|
||||||
|
address: string;
|
||||||
|
uiOrigin: string;
|
||||||
|
interface: string;
|
||||||
|
failures: number;
|
||||||
|
lastError: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateRouteDependencies {
|
||||||
|
stateService: StateService;
|
||||||
|
port: number;
|
||||||
|
proxyPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStateV0Compatibility(
|
||||||
|
{ snapshot, storedState, gatewayAuto, configExists }: StateReadResult,
|
||||||
|
{ port, proxyPort }: Pick<StateRouteDependencies, 'port' | 'proxyPort'>,
|
||||||
|
): LegacyStatePayload {
|
||||||
|
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'
|
||||||
|
? legacyGatewayAuto(gatewayAuto, stored.gatewayAutoEnabled !== false)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyGatewayAuto(gatewayAuto: GatewayAutoState, enabled: boolean) {
|
||||||
|
return {
|
||||||
|
mode: gatewayAuto?.mode || 'local-vpn',
|
||||||
|
enabled,
|
||||||
|
available: Boolean(gatewayAuto?.gatewayId),
|
||||||
|
address: gatewayAuto?.gateway?.gateway || '',
|
||||||
|
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||||
|
interface: gatewayAuto?.gateway?.interface || '',
|
||||||
|
failures: Number(gatewayAuto?.failures) || 0,
|
||||||
|
lastError: gatewayAuto?.lastError || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStateRoute(dependencies: StateRouteDependencies) {
|
||||||
|
const readPayload = async () => withStateV0Compatibility(
|
||||||
|
await dependencies.stateService.read(),
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readPayload,
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/state') return false;
|
||||||
|
sendJson(res, 200, await readPayload());
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async send(res: ServerResponse, extra: Record<string, unknown> = {}) {
|
||||||
|
sendJson(res, 200, { success: true, ...extra, state: await readPayload() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateRoute = ReturnType<typeof createStateRoute>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { SubscriptionService } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface SubscriptionMutationRouteDependencies {
|
||||||
|
subscriptionService: Pick<
|
||||||
|
SubscriptionService,
|
||||||
|
'importSubscription' | 'refreshSavedSubscription' | 'resetSavedSubscription'
|
||||||
|
>;
|
||||||
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
|
withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
sendState(res: ServerResponse, extra?: Record<string, unknown>): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionMutationRoute(dependencies: SubscriptionMutationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
const result = await dependencies.withOperation(
|
||||||
|
'subscription-import',
|
||||||
|
() => dependencies.subscriptionService.importSubscription(String(url).trim()),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||||
|
const { success: _success, ...result } = await dependencies.withOperation(
|
||||||
|
'subscription-refresh',
|
||||||
|
() => dependencies.subscriptionService.refreshSavedSubscription(),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res, result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
|
await dependencies.withOperation(
|
||||||
|
'subscription-forget',
|
||||||
|
() => dependencies.subscriptionService.resetSavedSubscription(),
|
||||||
|
);
|
||||||
|
await dependencies.sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { ValidateSubscription } from '../../features/subscription/index.js';
|
||||||
|
|
||||||
|
interface SubscriptionValidationRouteDependencies {
|
||||||
|
validateSubscription: ValidateSubscription;
|
||||||
|
readBody: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
||||||
|
sendState: (res: ServerResponse, extra: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionValidationRoute(dependencies: SubscriptionValidationRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'POST' || req.url !== '/api/subscription/validate') return false;
|
||||||
|
const { url = '' } = await dependencies.readBody(req);
|
||||||
|
await dependencies.sendState(res, await dependencies.validateSubscription(url));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { buildGatewayVersionInfo } from '../../version.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface DataplaneVersionState {
|
||||||
|
gatewayBackendVersion?: unknown;
|
||||||
|
singBoxVersion?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionRouteDependencies {
|
||||||
|
versionInfo: Record<string, unknown>;
|
||||||
|
refreshDataplaneRuntime: (() => Promise<DataplaneVersionState>) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVersionRoute(dependencies: VersionRouteDependencies) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.method !== 'GET' || req.url !== '/api/version') return false;
|
||||||
|
|
||||||
|
const payload = dependencies.refreshDataplaneRuntime
|
||||||
|
? buildGatewayVersionInfo(
|
||||||
|
dependencies.versionInfo,
|
||||||
|
await dependencies.refreshDataplaneRuntime(),
|
||||||
|
)
|
||||||
|
: dependencies.versionInfo;
|
||||||
|
sendJson(res, 200, payload);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { createDataplaneClient } from './dataplaneClient.js';
|
||||||
|
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
import {
|
||||||
|
applyGatewayPreference,
|
||||||
|
createGatewayAutoState,
|
||||||
|
nextGatewayAutoState,
|
||||||
|
probeGatewayPresence,
|
||||||
|
readHostNetworkState,
|
||||||
|
sameGatewayRoute,
|
||||||
|
} from './gatewayPresence.js';
|
||||||
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
|
import { tcpPing } from './ping.js';
|
||||||
|
import {
|
||||||
|
buildGatewayConfig,
|
||||||
|
removeSingboxConfig,
|
||||||
|
restoreSingboxConfig,
|
||||||
|
writeSingboxConfig,
|
||||||
|
} from './singbox.js';
|
||||||
|
import {
|
||||||
|
fetchSubscription,
|
||||||
|
getHwid,
|
||||||
|
normalizeSubscriptionConfig,
|
||||||
|
selectRefreshedServer,
|
||||||
|
} from './subscription.js';
|
||||||
|
import {
|
||||||
|
normalizeStoredState,
|
||||||
|
type OperationState,
|
||||||
|
type RouteRule,
|
||||||
|
type StoredState,
|
||||||
|
} from '../shared/contracts/state.js';
|
||||||
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
|
import {
|
||||||
|
createDeviceInventoryService,
|
||||||
|
createVendorLookup,
|
||||||
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
|
migrateDeviceInventoryState,
|
||||||
|
type InventoryState,
|
||||||
|
} from './services/deviceInventoryService.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createStateService } from './features/state/stateService.js';
|
||||||
|
import { createStateRoute } from './http/routes/stateRoute.js';
|
||||||
|
import { sendError } from './http/response.js';
|
||||||
|
import {
|
||||||
|
createSubscriptionService,
|
||||||
|
createValidateSubscription,
|
||||||
|
} from './features/subscription/index.js';
|
||||||
|
import { createSubscriptionValidationRoute } from './http/routes/subscriptionValidationRoute.js';
|
||||||
|
import { createSubscriptionMutationRoute } from './http/routes/subscriptionMutationRoute.js';
|
||||||
|
import { createServerHealthService } from './features/servers/index.js';
|
||||||
|
import { createServerHealthRoute } from './http/routes/serverHealthRoute.js';
|
||||||
|
import {
|
||||||
|
captureRuntimeCommand,
|
||||||
|
createConnectionService,
|
||||||
|
} from './features/connection/index.js';
|
||||||
|
import { createServerApplyRoute } from './http/routes/serverApplyRoute.js';
|
||||||
|
import { createConnectionRuntimeRoute } from './http/routes/connectionRuntimeRoute.js';
|
||||||
|
import {
|
||||||
|
createGatewayAutoService,
|
||||||
|
createRouteRulesService,
|
||||||
|
} from './features/routing/index.js';
|
||||||
|
import { createRouteRulesRoute } from './http/routes/routeRulesRoute.js';
|
||||||
|
import { createGatewayAutoRoute } from './http/routes/gatewayAutoRoute.js';
|
||||||
|
import { createDeviceInventoryRoute } from './http/routes/deviceInventoryRoute.js';
|
||||||
|
import { createPrometheusMetricsRoute } from './http/routes/prometheusMetricsRoute.js';
|
||||||
|
import { createConnectivityDiagnosticsUseCase } from './features/diagnostics/index.js';
|
||||||
|
import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDiagnosticsRoute.js';
|
||||||
|
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
||||||
|
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||||
|
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||||
|
const DEVICE_DISCOVERY_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const stateStore = createStateStore(settings.statePath);
|
||||||
|
const subscriptionCacheStore = createJsonStore({
|
||||||
|
filePath: settings.subscriptionCachePath,
|
||||||
|
defaultValue: null,
|
||||||
|
});
|
||||||
|
const deviceStore = createJsonStore<InventoryState>({
|
||||||
|
filePath: settings.deviceStatePath,
|
||||||
|
defaultValue: migrateDeviceInventoryState({}),
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
initializeMissing: true,
|
||||||
|
backupWhen: () => true,
|
||||||
|
});
|
||||||
|
deviceStore.read();
|
||||||
|
if (deviceStore.migration) {
|
||||||
|
console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`);
|
||||||
|
}
|
||||||
|
if (deviceStore.recovery) {
|
||||||
|
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
|
||||||
|
}
|
||||||
|
let cacheRecoveryLogged = false;
|
||||||
|
|
||||||
|
function readRawSubscriptionCache() {
|
||||||
|
const cached = subscriptionCacheStore.read();
|
||||||
|
if (subscriptionCacheStore.recovery && !cacheRecoveryLogged) {
|
||||||
|
cacheRecoveryLogged = true;
|
||||||
|
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||||
|
}
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSubscriptionCache() {
|
||||||
|
const raw = readRawSubscriptionCache();
|
||||||
|
const cached = record(raw);
|
||||||
|
return cached.config
|
||||||
|
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: raw }
|
||||||
|
: raw && typeof raw === 'object' && !Array.isArray(raw) ? cached : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialStoredState = stateStore.read();
|
||||||
|
if (stateStore.migration) {
|
||||||
|
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
||||||
|
}
|
||||||
|
if (stateStore.recovery) {
|
||||||
|
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||||
|
const versionInfo = buildVersionInfo(settings.appMode);
|
||||||
|
const remoteRuntime = remoteDataplane ? createDataplaneClient(settings.dataplaneSocket) : null;
|
||||||
|
const localRuntime = remoteDataplane ? null : createSingboxRuntime({
|
||||||
|
configPath: settings.configPath,
|
||||||
|
gateway: settings.appMode === 'gateway',
|
||||||
|
tproxyChain: settings.tproxyChain,
|
||||||
|
});
|
||||||
|
function selectRuntime() {
|
||||||
|
if (remoteRuntime) return remoteRuntime;
|
||||||
|
if (localRuntime) return localRuntime;
|
||||||
|
throw new Error('Harbor runtime is not configured');
|
||||||
|
}
|
||||||
|
const singboxRuntime = selectRuntime();
|
||||||
|
|
||||||
|
function requireRemoteRuntime() {
|
||||||
|
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
||||||
|
return remoteRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLocalDevicePolicy() {
|
||||||
|
if (!localDevicePolicy) throw new Error('Harbor local device policy is not configured');
|
||||||
|
return localDevicePolicy;
|
||||||
|
}
|
||||||
|
const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane
|
||||||
|
? createDevicePolicyService({
|
||||||
|
chain: settings.devicePolicyChain,
|
||||||
|
tproxyPort: settings.tproxyPort,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const deviceInventory = settings.appMode === 'gateway'
|
||||||
|
? createDeviceInventoryService({
|
||||||
|
store: deviceStore,
|
||||||
|
observe: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDevices()
|
||||||
|
: () => readNeighborSnapshot(),
|
||||||
|
observeTraffic: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeTraffic()
|
||||||
|
: null,
|
||||||
|
observeDomainTraffic: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDomainTraffic()
|
||||||
|
: null,
|
||||||
|
observePolicy: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().observeDevicePolicy()
|
||||||
|
: () => requireLocalDevicePolicy().snapshot(),
|
||||||
|
applyPolicies: remoteDataplane
|
||||||
|
? (devices) => requireRemoteRuntime().applyDevicePolicies(devices)
|
||||||
|
: (devices) => requireLocalDevicePolicy().apply(devices),
|
||||||
|
vendor: createVendorLookup(),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const localConnectivityDiagnostics = !remoteDataplane
|
||||||
|
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function requireLocalConnectivityDiagnostics() {
|
||||||
|
if (!localConnectivityDiagnostics) throw new Error('Harbor local diagnostics are not configured');
|
||||||
|
return localConnectivityDiagnostics;
|
||||||
|
}
|
||||||
|
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
||||||
|
let controlOperation: Promise<unknown> = Promise.resolve();
|
||||||
|
let operationState: OperationState = stateStore.recovery ? {
|
||||||
|
kind: 'storage-recovery',
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: stateStore.recovery.recoveredAt,
|
||||||
|
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
||||||
|
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
||||||
|
let revision = normalizeStoredState(initialStoredState).revision;
|
||||||
|
const gatewayAutoService = createGatewayAutoService({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules, gatewayAuto) => (
|
||||||
|
buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||||
|
clientDirect: settings.appMode === 'client' && gatewayAuto.mode === 'gateway-direct',
|
||||||
|
routeRules,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: () => Boolean(singboxRuntime.running),
|
||||||
|
applyCommand: () => captureRuntimeCommand(
|
||||||
|
() => startSingbox(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
restoreRunning: () => startSingbox(),
|
||||||
|
},
|
||||||
|
discovery: {
|
||||||
|
readHostNetwork: () => readHostNetworkState(settings.hostNetworkStatePath),
|
||||||
|
probeGateway: ({ gateway, subscriptionUrl }) => probeGatewayPresence({
|
||||||
|
gateway,
|
||||||
|
port: settings.gatewayPresencePort,
|
||||||
|
subscriptionUrl,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
createInitial: createGatewayAutoState,
|
||||||
|
applyPreference: applyGatewayPreference,
|
||||||
|
next: nextGatewayAutoState,
|
||||||
|
sameRoute: sameGatewayRoute,
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
scheduler: {
|
||||||
|
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
||||||
|
clearInterval: (timer) => clearInterval(timer),
|
||||||
|
},
|
||||||
|
onRouteChange: (state) => {
|
||||||
|
const route = state.gateway?.gateway ? ` (${state.gateway.gateway})` : '';
|
||||||
|
console.log(`[control] client route: ${state.mode}${route}`);
|
||||||
|
},
|
||||||
|
onDiscoveryWarning: (reason) => console.warn(`[control] Gateway не используется: ${reason}`),
|
||||||
|
onTimerError: (error) => console.warn(`[control] Gateway detection failed: ${errorMessage(error)}`),
|
||||||
|
});
|
||||||
|
const stateService = createStateService({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
readStoredState: () => stateStore.read(),
|
||||||
|
refreshRuntime: () => singboxRuntime.refresh(),
|
||||||
|
getGatewayAutoState: gatewayAutoService.read,
|
||||||
|
getOperationState: () => operationState,
|
||||||
|
configExists: () => fs.existsSync(settings.configPath),
|
||||||
|
});
|
||||||
|
const stateRoute = createStateRoute({
|
||||||
|
stateService,
|
||||||
|
port: settings.port,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
});
|
||||||
|
const gatewayAutoRoute = createGatewayAutoRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
gatewayAuto: gatewayAutoService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
readStatePayload: stateRoute.readPayload,
|
||||||
|
});
|
||||||
|
const deviceInventoryRoute = createDeviceInventoryRoute({
|
||||||
|
deviceInventory,
|
||||||
|
readBody,
|
||||||
|
});
|
||||||
|
const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory });
|
||||||
|
const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
||||||
|
readState: () => stateStore.read(),
|
||||||
|
runDiagnostics: async (services, target) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().runConnectivityDiagnostics(services, target)
|
||||||
|
: requireLocalConnectivityDiagnostics().run({
|
||||||
|
vpnAvailable: Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
services,
|
||||||
|
target,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
|
diagnostics: connectivityDiagnostics,
|
||||||
|
readBody,
|
||||||
|
});
|
||||||
|
const gatewayPresenceRoute = createGatewayPresenceRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
readState: () => stateStore.read(),
|
||||||
|
getHwid,
|
||||||
|
});
|
||||||
|
const sharedProxyRoute = createSharedProxyRoute({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
sharedProxyHost: settings.sharedProxyHost,
|
||||||
|
refreshRuntime: () => singboxRuntime.refresh(),
|
||||||
|
});
|
||||||
|
const versionRoute = createVersionRoute({
|
||||||
|
versionInfo,
|
||||||
|
refreshDataplaneRuntime: remoteDataplane
|
||||||
|
? () => requireRemoteRuntime().refresh()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||||
|
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||||
|
readBody,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const subscriptionService = createSubscriptionService({
|
||||||
|
provider: { fetchSubscription, selectRefreshedServer },
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
read: readRawSubscriptionCache,
|
||||||
|
write: (value) => { subscriptionCacheStore.write(value); },
|
||||||
|
remove: () => subscriptionCacheStore.remove(),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
stop: () => stopSingbox(),
|
||||||
|
start: () => startSingbox(),
|
||||||
|
},
|
||||||
|
gatewayAuto: {
|
||||||
|
read: gatewayAutoService.read,
|
||||||
|
set: gatewayAutoService.set,
|
||||||
|
createInitial: gatewayAutoService.createInitial,
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
scheduler: {
|
||||||
|
setInterval: (callback, intervalMs) => setInterval(callback, intervalMs),
|
||||||
|
clearInterval: (timer) => clearInterval(timer),
|
||||||
|
},
|
||||||
|
onRefreshError: (error) => console.warn(`[control] подписка не обновлена: ${errorMessage(error)}`),
|
||||||
|
});
|
||||||
|
const subscriptionMutationRoute = createSubscriptionMutationRoute({
|
||||||
|
subscriptionService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const serverHealthRoute = createServerHealthRoute({
|
||||||
|
serverHealth: createServerHealthService({
|
||||||
|
readServers: () => normalizeStoredState(stateStore.read()).servers,
|
||||||
|
ping: tcpPing,
|
||||||
|
}),
|
||||||
|
readBody,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const connectionService = createConnectionService({
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
exists: () => fs.existsSync(settings.configPath),
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
start: () => startSingbox(),
|
||||||
|
stop: () => stopSingbox(),
|
||||||
|
stopCommand: () => captureRuntimeCommand(() => stopSingbox()),
|
||||||
|
restartCommand: () => captureRuntimeCommand(
|
||||||
|
() => singboxRuntime.restart(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
now: () => new Date(),
|
||||||
|
});
|
||||||
|
const serverApplyRoute = createServerApplyRoute({
|
||||||
|
connection: connectionService,
|
||||||
|
readBody,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const connectionRuntimeRoute = createConnectionRuntimeRoute({
|
||||||
|
connection: connectionService,
|
||||||
|
withOperation,
|
||||||
|
sendState: (res, extra) => stateRoute.send(res, extra),
|
||||||
|
});
|
||||||
|
const routeRulesService = createRouteRulesService({
|
||||||
|
state: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
readConfig: () => readSubscriptionCache()?.config || null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
build: (subscriptionConfig, selectedServerId, routeRules) => (
|
||||||
|
buildActiveConfig(subscriptionConfig, selectedServerId, routeRules)
|
||||||
|
),
|
||||||
|
read: () => fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null,
|
||||||
|
write: writeSingboxConfig,
|
||||||
|
restore: restoreSingboxConfig,
|
||||||
|
remove: removeSingboxConfig,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
isRunning: async () => Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
applyCommand: () => captureRuntimeCommand(
|
||||||
|
() => startSingbox(),
|
||||||
|
{ preMutationErrorCodes: remoteDataplane ? [] : ['CONFIG_INVALID'] },
|
||||||
|
),
|
||||||
|
restoreRunning: () => startSingbox(),
|
||||||
|
},
|
||||||
|
serialize: serializeControl,
|
||||||
|
runOperation: (operation) => withOperation('route-rules', operation),
|
||||||
|
});
|
||||||
|
const routeRulesRoute = createRouteRulesRoute({
|
||||||
|
routeRules: routeRulesService,
|
||||||
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStoredState(update: (state: StoredState) => Record<string, unknown>) {
|
||||||
|
return stateStore.update((stored) => {
|
||||||
|
const current = normalizeStoredState(stored);
|
||||||
|
const schemaVersion = stored.schemaVersion;
|
||||||
|
const next = normalizeStoredState({ schemaVersion, ...update(current) });
|
||||||
|
revision = Math.max(revision, current.revision) + 1;
|
||||||
|
next.revision = revision;
|
||||||
|
return { ...next, schemaVersion };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withOperation<T>(kind: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
operationState = {
|
||||||
|
kind,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
operationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const harborError = normalizeHarborError(error);
|
||||||
|
operationState = {
|
||||||
|
...operationState,
|
||||||
|
status: 'failed',
|
||||||
|
error: harborError.message,
|
||||||
|
};
|
||||||
|
updateStoredState((state) => state);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeControl<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = controlOperation.then(() => operation(), () => operation());
|
||||||
|
// The caller observes result; this settled tail only keeps the next operation runnable.
|
||||||
|
controlOperation = result.then(() => undefined, () => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk: Buffer | string) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
reject(new HarborError('REQUEST_INVALID'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
if (!chunks.length) return resolve({});
|
||||||
|
try {
|
||||||
|
resolve(record(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
|
||||||
|
} catch (cause) {
|
||||||
|
reject(new HarborError('REQUEST_INVALID', { cause }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveConfig(
|
||||||
|
subscriptionConfig: unknown,
|
||||||
|
selectedServerId: string,
|
||||||
|
routeRules: RouteRule[] = stateStore.read().routeRules,
|
||||||
|
) {
|
||||||
|
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||||
|
clientDirect: settings.appMode === 'client' && gatewayAutoService.read().mode === 'gateway-direct',
|
||||||
|
routeRules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopSingbox = () => singboxRuntime.stop();
|
||||||
|
const startSingbox = () => singboxRuntime.apply();
|
||||||
|
|
||||||
|
function writeCurrentConfig() {
|
||||||
|
const state = stateStore.read();
|
||||||
|
const cached = readSubscriptionCache();
|
||||||
|
if (!state.selectedServerId || !cached?.config) return false;
|
||||||
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (await stateRoute.handle(req, res)) return;
|
||||||
|
if (await subscriptionValidationRoute.handle(req, res)) return;
|
||||||
|
if (await subscriptionMutationRoute.handle(req, res)) return;
|
||||||
|
if (await serverHealthRoute.handle(req, res)) return;
|
||||||
|
if (await serverApplyRoute.handle(req, res)) return;
|
||||||
|
if (await connectionRuntimeRoute.handle(req, res)) return;
|
||||||
|
if (await routeRulesRoute.handle(req, res)) return;
|
||||||
|
if (await gatewayAutoRoute.handle(req, res)) return;
|
||||||
|
if (await connectivityDiagnosticsRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await versionRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await sharedProxyRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await deviceInventoryRoute.handle(req, res)) return;
|
||||||
|
if (await gatewayPresenceRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
return sendError(res, new HarborError('ENDPOINT_NOT_FOUND'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', `http://localhost:${settings.port}`).pathname;
|
||||||
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||||
|
const filePath = path.resolve(settings.distDir, requested);
|
||||||
|
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
return res.end('Forbidden');
|
||||||
|
}
|
||||||
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: path.join(settings.distDir, 'index.html');
|
||||||
|
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
||||||
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (await prometheusMetricsRoute.handle(req, res)) return;
|
||||||
|
const requestUrl = new URL(req.url || '/', `http://localhost:${settings.port}`);
|
||||||
|
return requestUrl.pathname.startsWith('/api/')
|
||||||
|
? await handleApi(req, res)
|
||||||
|
: serveStatic(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
subscriptionService.stopAutoRefresh();
|
||||||
|
gatewayAutoService.stopDiscovery();
|
||||||
|
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||||
|
await serializeControl(() => singboxRuntime.shutdown());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
await gatewayAutoService.refresh({ reconfigure: false })
|
||||||
|
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
|
||||||
|
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||||
|
try {
|
||||||
|
writeCurrentConfig();
|
||||||
|
} catch (error) {
|
||||||
|
const candidate = record(error);
|
||||||
|
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||||
|
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||||
|
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await startSingbox()
|
||||||
|
.then(() => {
|
||||||
|
if (fs.existsSync(settings.configPath)) {
|
||||||
|
updateStoredState((state: StoredState) => ({ ...state, appliedRouteRules: state.routeRules }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => console.warn(`[control] sing-box не запущен: ${errorMessage(error)}`));
|
||||||
|
|
||||||
|
if (deviceInventory) {
|
||||||
|
await deviceInventory.reconcilePolicies()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriptionService.startAutoRefresh(SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
gatewayAutoService.startDiscovery(GATEWAY_DISCOVERY_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (deviceInventory) {
|
||||||
|
deviceInventory.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`));
|
||||||
|
deviceDiscoveryTimer = setInterval(() => {
|
||||||
|
deviceInventory.refresh()
|
||||||
|
.catch((error: unknown) => console.warn(`[control] device inventory не обновлён: ${errorMessage(error)}`));
|
||||||
|
}, DEVICE_DISCOVERY_INTERVAL_MS);
|
||||||
|
deviceDiscoveryTimer.unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
process.env.DIST_DIR ||= path.resolve('dist');
|
||||||
|
|
||||||
|
if (process.env.APP_COMPONENT === 'dataplane') {
|
||||||
|
await import('./dataplane.js');
|
||||||
|
} else {
|
||||||
|
await import('./index.js');
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||||
|
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export interface PingResult {
|
||||||
|
ok: boolean;
|
||||||
|
latency: number | null;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tcpPing(host: string, port: number, timeout = DEFAULT_TIMEOUT): Promise<PingResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
return new Promise<PingResult>((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (result: PingResult) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.once("connect", () =>
|
||||||
|
finish({ ok: true, latency: Date.now() - start }),
|
||||||
|
);
|
||||||
|
socket.once("timeout", () =>
|
||||||
|
finish({ ok: false, latency: null, error: "timeout" }),
|
||||||
|
);
|
||||||
|
socket.once("error", (err: NodeJS.ErrnoException) =>
|
||||||
|
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.connect(port, host);
|
||||||
|
} catch (err) {
|
||||||
|
finish({ ok: false, latency: null, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveHost(host: string): Promise<string | null> {
|
||||||
|
if (net.isIP(host)) return host;
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(host);
|
||||||
|
return result.address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
|
||||||
|
const labelValue = (value: unknown) => String(value ?? '')
|
||||||
|
.replaceAll('\\', '\\\\')
|
||||||
|
.replaceAll('\n', '\\n')
|
||||||
|
.replaceAll('"', '\\"');
|
||||||
|
|
||||||
|
const labels = (values: Record<string, unknown>) => Object.entries(values)
|
||||||
|
.map(([key, value]) => `${key}="${labelValue(value)}"`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(value: unknown) {
|
||||||
|
const decimal = String(value ?? '');
|
||||||
|
if (!COUNTER_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus counter: ${decimal}`);
|
||||||
|
return decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: unknown) {
|
||||||
|
const milliseconds = Date.parse(String(value ?? ''));
|
||||||
|
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(
|
||||||
|
lines: string[],
|
||||||
|
name: string,
|
||||||
|
metricLabels: Record<string, unknown>,
|
||||||
|
value: unknown,
|
||||||
|
) {
|
||||||
|
lines.push(`${name}{${labels(metricLabels)}} ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPrometheusMetrics(value: unknown) {
|
||||||
|
const snapshot = record(value);
|
||||||
|
const traffic = record(snapshot.traffic);
|
||||||
|
const lines = [
|
||||||
|
'# HELP harbor_traffic_bytes_total Total traffic accounted by Harbor.',
|
||||||
|
'# TYPE harbor_traffic_bytes_total counter',
|
||||||
|
];
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'gateway' }, counter(traffic.gatewayBytes));
|
||||||
|
metric(lines, 'harbor_traffic_bytes_total', { source: 'proxy' }, counter(traffic.proxyBytes));
|
||||||
|
|
||||||
|
const globalFreshness = [
|
||||||
|
['gateway', traffic.gatewayObservedAt],
|
||||||
|
['proxy', traffic.proxyObservedAt],
|
||||||
|
].map(([source, observedAt]) => [source, timestamp(observedAt)] as const).filter(([, observedAt]) => observedAt);
|
||||||
|
if (globalFreshness.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful Harbor traffic observation.',
|
||||||
|
'# TYPE harbor_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const [source, observedAt] of globalFreshness) {
|
||||||
|
metric(lines, 'harbor_traffic_last_observed_timestamp_seconds', { source }, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = Array.isArray(snapshot.devices) ? snapshot.devices.map(record) : [];
|
||||||
|
if (devices.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_info Current Harbor device identity metadata.',
|
||||||
|
'# TYPE harbor_device_info gauge',
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
metric(lines, 'harbor_device_info', {
|
||||||
|
device_id: device.id,
|
||||||
|
name: device.alias || device.hostname || device.ip || device.id,
|
||||||
|
ip: device.ip || '',
|
||||||
|
}, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
|
timestamp(device.trafficObservedAt)
|
||||||
|
? { device, source: 'gateway', observedAt: timestamp(device.trafficObservedAt), uploadBytes: device.uploadBytes, downloadBytes: device.downloadBytes }
|
||||||
|
: null,
|
||||||
|
timestamp(device.proxyTrafficObservedAt)
|
||||||
|
? { device, source: 'proxy', observedAt: timestamp(device.proxyTrafficObservedAt), uploadBytes: device.proxyUploadBytes, downloadBytes: device.proxyDownloadBytes }
|
||||||
|
: null,
|
||||||
|
].filter((entry): entry is NonNullable<typeof entry> => entry !== null));
|
||||||
|
if (deviceTraffic.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_bytes_total Total traffic accounted by Harbor for a device.',
|
||||||
|
'# TYPE harbor_device_traffic_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const { device, source, uploadBytes, downloadBytes } of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'download',
|
||||||
|
}, counter(downloadBytes));
|
||||||
|
metric(lines, 'harbor_device_traffic_bytes_total', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
direction: 'upload',
|
||||||
|
}, counter(uploadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful device traffic observation.',
|
||||||
|
'# TYPE harbor_device_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
for (const { device, source, observedAt } of deviceTraffic) {
|
||||||
|
metric(lines, 'harbor_device_traffic_last_observed_timestamp_seconds', {
|
||||||
|
device_id: device.id,
|
||||||
|
source,
|
||||||
|
}, observedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainTraffic = record(snapshot.domainTraffic);
|
||||||
|
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
|
||||||
|
if (domainSeries.length) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_domain_traffic_bytes_total Traffic observed by sing-box for a device and domain.',
|
||||||
|
'# TYPE harbor_device_domain_traffic_bytes_total counter',
|
||||||
|
);
|
||||||
|
for (const series of domainSeries) {
|
||||||
|
for (const [direction, value] of [
|
||||||
|
['download', series.downloadBytes],
|
||||||
|
['upload', series.uploadBytes],
|
||||||
|
]) {
|
||||||
|
metric(lines, 'harbor_device_domain_traffic_bytes_total', {
|
||||||
|
device_id: series.deviceId,
|
||||||
|
domain: series.domain,
|
||||||
|
service: series.service,
|
||||||
|
source: series.source,
|
||||||
|
direction,
|
||||||
|
}, counter(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const domainObservedAt = timestamp(domainTraffic.observedAt);
|
||||||
|
if (domainObservedAt) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box connection observation.',
|
||||||
|
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
|
||||||
|
);
|
||||||
|
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
|
||||||
|
}
|
||||||
|
if (domainTraffic.overflowConnections != null) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_overflow_connections_total Connections aggregated after the domain series limit was reached.',
|
||||||
|
'# TYPE harbor_domain_traffic_overflow_connections_total counter',
|
||||||
|
);
|
||||||
|
lines.push(`harbor_domain_traffic_overflow_connections_total ${counter(domainTraffic.overflowConnections)}`);
|
||||||
|
}
|
||||||
|
const attributionEvents = record(domainTraffic.attributionEvents);
|
||||||
|
if (domainTraffic.attributionEvents) {
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_domain_traffic_attribution_events_total Connections with incomplete Harbor domain attribution.',
|
||||||
|
'# TYPE harbor_domain_traffic_attribution_events_total counter',
|
||||||
|
);
|
||||||
|
for (const outcome of ['unresolved_host', 'unknown_device', 'unsupported_source']) {
|
||||||
|
metric(
|
||||||
|
lines,
|
||||||
|
'harbor_domain_traffic_attribution_events_total',
|
||||||
|
{ outcome },
|
||||||
|
counter(attributionEvents[outcome]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPrometheusMetrics(res: ServerResponse, snapshot: unknown) {
|
||||||
|
const body = renderPrometheusMetrics(snapshot);
|
||||||
|
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' });
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import {
|
||||||
|
assessConnectivity,
|
||||||
|
CONNECTIVITY_IP_SOURCES,
|
||||||
|
CONNECTIVITY_SITES,
|
||||||
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
type PathKind = 'direct' | 'vpn';
|
||||||
|
|
||||||
|
interface CurlExecution {
|
||||||
|
exitCode: number | null;
|
||||||
|
error: string;
|
||||||
|
stderr: string;
|
||||||
|
stdout: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
|
||||||
|
type DnsLookup = typeof dnsLookup;
|
||||||
|
|
||||||
|
interface BaseProbe {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbe extends BaseProbe {
|
||||||
|
family: 4 | 6;
|
||||||
|
address: (body: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbe extends BaseProbe {
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string;
|
||||||
|
validationError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
body?: boolean;
|
||||||
|
ipv4?: boolean;
|
||||||
|
follow?: boolean;
|
||||||
|
resolve?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestResult {
|
||||||
|
ok: boolean;
|
||||||
|
body: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IpProbeResult {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
family: 4 | 6;
|
||||||
|
address: string | null;
|
||||||
|
attempts: number;
|
||||||
|
latencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteProbeResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
httpStatus: number | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
totalMs: number | null;
|
||||||
|
stage: string;
|
||||||
|
error: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiagnosticTarget =
|
||||||
|
| { kind: 'ip'; probe: IpProbe }
|
||||||
|
| { kind: 'site'; probe: SiteProbe };
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
||||||
|
|
||||||
|
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||||
|
...probe,
|
||||||
|
family: probe.family === 6 ? 6 : 4,
|
||||||
|
address: probe.id === 'cloudflare'
|
||||||
|
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||||
|
: probe.id === 'yandex-internet'
|
||||||
|
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||||
|
: (body: string) => body.trim(),
|
||||||
|
}));
|
||||||
|
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
|
||||||
|
const TARGET_SAMPLE_COUNT = 3;
|
||||||
|
|
||||||
|
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||||
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
||||||
|
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||||
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||||
|
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
||||||
|
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
|
function runCurl(args: string[]): Promise<CurlExecution> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
|
||||||
|
error: error?.message || '',
|
||||||
|
stderr: stderr || '',
|
||||||
|
stdout: stdout || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFor(exitCode: number | null) {
|
||||||
|
if (exitCode === 6) return 'dns';
|
||||||
|
if (exitCode === 7) return 'tcp';
|
||||||
|
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
|
||||||
|
if (exitCode === 28) return 'timeout';
|
||||||
|
return 'request';
|
||||||
|
}
|
||||||
|
|
||||||
|
function milliseconds(value: unknown) {
|
||||||
|
const seconds = Number(value);
|
||||||
|
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: Array<number | null>) {
|
||||||
|
const numbers = values.filter((value): value is number => Number.isFinite(value));
|
||||||
|
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostCommon<T>(values: T[]): T | null {
|
||||||
|
const counts = new Map<T, number>();
|
||||||
|
let selected: T | null = null;
|
||||||
|
let selectedCount = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
const count = (counts.get(value) || 0) + 1;
|
||||||
|
counts.set(value, count);
|
||||||
|
if (count >= selectedCount) {
|
||||||
|
selected = value;
|
||||||
|
selectedCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
|
||||||
|
body = false,
|
||||||
|
ipv4 = false,
|
||||||
|
follow = true,
|
||||||
|
resolve = null,
|
||||||
|
}: RequestOptions = {}): Promise<RequestResult> {
|
||||||
|
const args = [
|
||||||
|
'--silent',
|
||||||
|
'--show-error',
|
||||||
|
...(follow ? ['--location'] : []),
|
||||||
|
'--proto',
|
||||||
|
'=https',
|
||||||
|
'--proto-redir',
|
||||||
|
'=https',
|
||||||
|
'--connect-timeout',
|
||||||
|
'3',
|
||||||
|
'--max-time',
|
||||||
|
'6',
|
||||||
|
'--user-agent',
|
||||||
|
'Harbor-Diagnostics/1',
|
||||||
|
'--output',
|
||||||
|
body ? '-' : '/dev/null',
|
||||||
|
'--write-out',
|
||||||
|
`${CURL_META_MARKER}%{json}`,
|
||||||
|
...(path === 'vpn'
|
||||||
|
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
|
||||||
|
: ['--noproxy', '*']),
|
||||||
|
...(ipv4 ? ['--ipv4'] : []),
|
||||||
|
...(resolve ? ['--resolve', resolve] : []),
|
||||||
|
probe.url,
|
||||||
|
];
|
||||||
|
const result = await execute(args);
|
||||||
|
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
|
||||||
|
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
|
||||||
|
let meta: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
|
||||||
|
} catch {
|
||||||
|
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
|
||||||
|
}
|
||||||
|
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
|
||||||
|
? meta.exitcode
|
||||||
|
: result.exitCode;
|
||||||
|
const ok = exitCode === 0;
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
body: responseBody,
|
||||||
|
exitCode,
|
||||||
|
httpStatus: Number(meta.http_code) || null,
|
||||||
|
latencyMs: milliseconds(meta.time_starttransfer),
|
||||||
|
totalMs: milliseconds(meta.time_total),
|
||||||
|
stage: ok ? 'complete' : stageFor(exitCode),
|
||||||
|
error: ok ? null : String(meta.errormsg || result.stderr || result.error || 'request failed').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ipProbe(
|
||||||
|
probe: IpProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<IpProbeResult> {
|
||||||
|
const samples: Array<RequestResult & { address: string | null }> = [];
|
||||||
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
|
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||||
|
const parsed = result.ok ? probe.address(result.body) : null;
|
||||||
|
samples.push({
|
||||||
|
...result,
|
||||||
|
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
|
||||||
|
const matching = samples.filter((sample) => sample.address === address);
|
||||||
|
return {
|
||||||
|
source: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
family: probe.family,
|
||||||
|
address,
|
||||||
|
attempts: samples.length,
|
||||||
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
|
error: address ? null : samples.at(-1)?.error || 'invalid IP response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
|
||||||
|
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
||||||
|
const ipv4 = probes.filter((probe) => probe.family === 4);
|
||||||
|
const ipv6 = probes.find((probe) => probe.family === 6);
|
||||||
|
return {
|
||||||
|
ipv4: {
|
||||||
|
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
|
||||||
|
sources: ipv4,
|
||||||
|
},
|
||||||
|
ipv6: ipv6?.address || null,
|
||||||
|
ipv6Source: ipv6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicAddress(address: string, family: number) {
|
||||||
|
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||||
|
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||||
|
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
|
||||||
|
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
||||||
|
return Promise.all(requested.map(async (service, index) => {
|
||||||
|
const value = record(service);
|
||||||
|
const requestedId = String(value.id || '');
|
||||||
|
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(String(value.url || '').trim());
|
||||||
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
||||||
|
throw new Error('Разрешены только публичные HTTPS-адреса');
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const resolved = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
const addresses = Array.isArray(resolved) ? resolved : [resolved];
|
||||||
|
if (!addresses.length || addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
|
||||||
|
throw new Error('Адрес ведёт во внутреннюю или служебную сеть');
|
||||||
|
}
|
||||||
|
const target = addresses.find(({ family }) => family === 4) || addresses[0];
|
||||||
|
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(value.label || '').trim().slice(0, 40) || hostname,
|
||||||
|
url: parsed.href,
|
||||||
|
follow: false,
|
||||||
|
resolve: `${hostname}:443:${pinned}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||||
|
url: '',
|
||||||
|
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteStatus(result: RequestResult) {
|
||||||
|
if (!result.ok) return 'unavailable';
|
||||||
|
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
|
||||||
|
? 'available'
|
||||||
|
: 'responded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function siteProbe(
|
||||||
|
probe: SiteProbe,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sampleCount = 1,
|
||||||
|
): Promise<SiteProbeResult> {
|
||||||
|
if (probe.validationError) return {
|
||||||
|
id: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
status: 'unavailable',
|
||||||
|
attempts: 0,
|
||||||
|
httpStatus: null,
|
||||||
|
latencyMs: null,
|
||||||
|
totalMs: null,
|
||||||
|
stage: 'validation',
|
||||||
|
error: probe.validationError,
|
||||||
|
};
|
||||||
|
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
||||||
|
const samples = [];
|
||||||
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
|
}
|
||||||
|
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||||
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
|
}
|
||||||
|
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||||
|
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||||
|
const representative = matching.at(-1) || samples.at(-1);
|
||||||
|
if (!representative) throw new Error('Diagnostic probe produced no samples');
|
||||||
|
return {
|
||||||
|
id: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
status,
|
||||||
|
attempts: samples.length,
|
||||||
|
httpStatus: mostCommon(matching.map((sample) => sample.httpStatus)),
|
||||||
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
|
totalMs: average(matching.map((sample) => sample.totalMs)),
|
||||||
|
stage: representative.stage,
|
||||||
|
error: representative.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probePath(
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
sites: SiteProbe[],
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
|
const [ip, siteResults] = await Promise.all([
|
||||||
|
publicIps(path, proxyPort, execute),
|
||||||
|
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
internetAvailable: Boolean(
|
||||||
|
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
|
||||||
|
),
|
||||||
|
...ip,
|
||||||
|
sites: siteResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailablePath(): ConnectivityPathResult {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: 'vpn-off',
|
||||||
|
internetAvailable: false,
|
||||||
|
ipv4: { addresses: [], sources: [] },
|
||||||
|
ipv6: null,
|
||||||
|
ipv6Source: null,
|
||||||
|
sites: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
|
||||||
|
if (typeof targetId !== 'string') return null;
|
||||||
|
if (targetId.startsWith('ip:')) {
|
||||||
|
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||||
|
return probe ? { kind: 'ip', probe } : null;
|
||||||
|
}
|
||||||
|
if (targetId.startsWith('site:')) {
|
||||||
|
const probe = sites.find(({ id }) => id === targetId.slice(5));
|
||||||
|
return probe ? { kind: 'site', probe } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeTarget(
|
||||||
|
target: DiagnosticTarget,
|
||||||
|
path: PathKind,
|
||||||
|
proxyPort: number,
|
||||||
|
execute: CurlExecutor,
|
||||||
|
): Promise<ConnectivityPathResult> {
|
||||||
|
const ip = target.kind === 'ip'
|
||||||
|
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||||
|
: null;
|
||||||
|
const site = target.kind === 'site'
|
||||||
|
? await siteProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||||
|
: null;
|
||||||
|
const ipv4Sources = ip?.family === 4 ? [ip] : [];
|
||||||
|
const ipv6Source = ip?.family === 6 ? ip : null;
|
||||||
|
const sites = site ? [site] : [];
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
||||||
|
ipv4: {
|
||||||
|
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
|
||||||
|
sources: ipv4Sources,
|
||||||
|
},
|
||||||
|
ipv6: ipv6Source?.address || null,
|
||||||
|
ipv6Source,
|
||||||
|
sites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { assessConnectivity };
|
||||||
|
|
||||||
|
export function createConnectivityDiagnosticsService({
|
||||||
|
proxyPort,
|
||||||
|
execute = runCurl,
|
||||||
|
lookup = dnsLookup,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
proxyPort: number;
|
||||||
|
execute?: CurlExecutor;
|
||||||
|
lookup?: DnsLookup;
|
||||||
|
now?: () => string;
|
||||||
|
}) {
|
||||||
|
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
|
||||||
|
vpnAvailable: boolean;
|
||||||
|
services?: unknown;
|
||||||
|
target?: unknown;
|
||||||
|
}) {
|
||||||
|
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||||
|
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||||
|
: targetId ? [] : services;
|
||||||
|
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||||
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
|
const target = resolveTarget(targetId, siteProbes);
|
||||||
|
if (targetId && !target) throw new Error('Unknown diagnostic target');
|
||||||
|
const directPromise = target
|
||||||
|
? probeTarget(target, 'direct', proxyPort, execute)
|
||||||
|
: probePath('direct', proxyPort, execute, siteProbes);
|
||||||
|
const vpnPromise = vpnAvailable
|
||||||
|
? target
|
||||||
|
? probeTarget(target, 'vpn', proxyPort, execute)
|
||||||
|
: probePath('vpn', proxyPort, execute, siteProbes)
|
||||||
|
: Promise.resolve(unavailablePath());
|
||||||
|
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
||||||
|
return {
|
||||||
|
checkedAt: now(),
|
||||||
|
direct,
|
||||||
|
vpn,
|
||||||
|
assessment: assessConnectivity(direct, vpn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
run: runOnce,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
||||||
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
|
const COMMAND_OPTIONS = { encoding: 'utf8' as const, timeout: 2_000, killSignal: 'SIGKILL' as const };
|
||||||
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
|
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
||||||
|
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
||||||
|
const MAX_DEVICES = 512;
|
||||||
|
|
||||||
|
export interface DirectDevice {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicySnapshot {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
fingerprint: string;
|
||||||
|
observedAt: string;
|
||||||
|
appliedIds: string[];
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||||
|
const fingerprint = (devices: readonly DirectDevice[]) => crypto.createHash('sha256')
|
||||||
|
.update(JSON.stringify(devices))
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
function commandError(command: string, result: SpawnSyncReturns<string>) {
|
||||||
|
return new Error(String(
|
||||||
|
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||||
|
).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDirectDevices(value: unknown): DirectDevice[] {
|
||||||
|
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
|
||||||
|
throw new Error('Некорректный набор device policy');
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const tuples = new Set<string>();
|
||||||
|
const devices = value.map((value) => {
|
||||||
|
const device = record(value);
|
||||||
|
const normalized = {
|
||||||
|
id: String(device?.id || ''),
|
||||||
|
ip: String(device?.ip || ''),
|
||||||
|
mac: String(device?.mac || '').toLowerCase(),
|
||||||
|
interface: String(device?.interface || ''),
|
||||||
|
};
|
||||||
|
const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`;
|
||||||
|
if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip)
|
||||||
|
|| !MAC_PATTERN.test(normalized.mac) || !isDeviceInterface(normalized.interface)
|
||||||
|
|| ids.has(normalized.id) || tuples.has(tuple)) {
|
||||||
|
throw new Error('Некорректная или повторяющаяся device policy identity');
|
||||||
|
}
|
||||||
|
ids.add(normalized.id);
|
||||||
|
tuples.add(tuple);
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
return devices.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fingerprintDirectDevices = (value: unknown) => fingerprint(normalizeDirectDevices(value));
|
||||||
|
|
||||||
|
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }: {
|
||||||
|
devices: readonly DirectDevice[];
|
||||||
|
chain: string;
|
||||||
|
slot: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
}) {
|
||||||
|
const child = childChain(chain, slot);
|
||||||
|
const rules = ['*mangle', `-F ${child}`];
|
||||||
|
for (const device of devices) {
|
||||||
|
rules.push(`-A ${child} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-policy:${device.id}:direct -j RETURN`);
|
||||||
|
}
|
||||||
|
rules.push(
|
||||||
|
`-A ${child} -p tcp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
|
||||||
|
`-A ${child} -p udp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
|
||||||
|
'COMMIT',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return rules.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDevicePolicyService({
|
||||||
|
chain,
|
||||||
|
tproxyPort,
|
||||||
|
tproxyMark,
|
||||||
|
run = spawnSync,
|
||||||
|
now = () => new Date(),
|
||||||
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}: {
|
||||||
|
chain: string;
|
||||||
|
tproxyPort: number;
|
||||||
|
tproxyMark: string;
|
||||||
|
run?: typeof spawnSync;
|
||||||
|
now?: () => Date;
|
||||||
|
nextGeneration?: () => string;
|
||||||
|
}) {
|
||||||
|
if (!CHAIN_PATTERN.test(String(chain || ''))
|
||||||
|
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|
||||||
|
|| !MARK_PATTERN.test(String(tproxyMark || ''))) {
|
||||||
|
throw new Error('Некорректная конфигурация device policy');
|
||||||
|
}
|
||||||
|
const epoch = nextGeneration();
|
||||||
|
let activeSlot: 'A' | 'B' = 'A';
|
||||||
|
let activeSignature = JSON.stringify([]);
|
||||||
|
let generation = epoch;
|
||||||
|
let appliedDevices: DirectDevice[] = [];
|
||||||
|
let observedAt = now().toISOString();
|
||||||
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function execute(command: string, args: string[], input?: string) {
|
||||||
|
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
|
||||||
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(changed = false): PolicySnapshot {
|
||||||
|
return {
|
||||||
|
epoch,
|
||||||
|
generation,
|
||||||
|
fingerprint: fingerprint(appliedDevices),
|
||||||
|
observedAt,
|
||||||
|
appliedIds: appliedDevices.map(({ id }) => id),
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function performApply(value: unknown) {
|
||||||
|
const devices = normalizeDirectDevices(value);
|
||||||
|
const signature = JSON.stringify(devices);
|
||||||
|
if (signature === activeSignature) return snapshot(false);
|
||||||
|
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||||
|
execute('iptables-restore', ['-w', '1', '--noflush'], buildDevicePolicyRestore({
|
||||||
|
devices,
|
||||||
|
chain,
|
||||||
|
slot: nextSlot,
|
||||||
|
tproxyPort,
|
||||||
|
tproxyMark,
|
||||||
|
}));
|
||||||
|
execute('iptables', [
|
||||||
|
'-w', '1', '-t', 'mangle', '-R', chain, '1', '-j', childChain(chain, nextSlot),
|
||||||
|
]);
|
||||||
|
activeSlot = nextSlot;
|
||||||
|
activeSignature = signature;
|
||||||
|
appliedDevices = devices;
|
||||||
|
generation = nextGeneration();
|
||||||
|
observedAt = now().toISOString();
|
||||||
|
return snapshot(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(devices: unknown): Promise<PolicySnapshot> {
|
||||||
|
const result = queue.then(() => performApply(devices));
|
||||||
|
queue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, snapshot: () => snapshot(false) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import { isDeviceInterface, type NeighborObservation } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
|
interface CommandOptions {
|
||||||
|
encoding: BufferEncoding;
|
||||||
|
timeout: number;
|
||||||
|
killSignal: NodeJS.Signals;
|
||||||
|
input?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandResult {
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficDevice {
|
||||||
|
ip: string;
|
||||||
|
mac: string;
|
||||||
|
interface: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
|
||||||
|
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
|
||||||
|
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
|
||||||
|
type CounterValues = Record<CounterField, bigint>;
|
||||||
|
|
||||||
|
interface RetiredCounters {
|
||||||
|
slot: 'A' | 'B';
|
||||||
|
devices: TrafficDevice[];
|
||||||
|
counters: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficSnapshot {
|
||||||
|
epoch: string;
|
||||||
|
generation: string;
|
||||||
|
observedAt: string | null;
|
||||||
|
source: { error: string | null };
|
||||||
|
devices: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunCommand = (command: string, args: string[], options?: CommandOptions) => Promise<CommandResult>;
|
||||||
|
|
||||||
|
const COMMAND_OPTIONS: CommandOptions = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||||
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
|
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
||||||
|
const COUNTERS = [
|
||||||
|
['upload', 'upload', 'uploadBytes'],
|
||||||
|
['download', 'download', 'downloadBytes'],
|
||||||
|
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
||||||
|
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
||||||
|
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
|
||||||
|
|
||||||
|
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
|
||||||
|
const proxyChildChain = (chain: string, slot: string) => `${childChain(chain, slot)}_P`;
|
||||||
|
const counterKey = ({ ip, mac, interface: deviceInterface }: Omit<TrafficDevice, 'key'>) => crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${ip}|${mac}|${deviceInterface}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16);
|
||||||
|
|
||||||
|
function commandError(command: string, result: CommandResult) {
|
||||||
|
const cause = result.error instanceof Error ? result.error.message : result.error;
|
||||||
|
return new Error(String(
|
||||||
|
result.stderr || result.stdout || cause || `${command} завершился с ошибкой`,
|
||||||
|
).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS): Promise<CommandResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let child: ChildProcessWithoutNullStreams;
|
||||||
|
try {
|
||||||
|
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ status: null, stdout: '', stderr: '', error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
let settled = false;
|
||||||
|
let timedOut = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = (result: Pick<CommandResult, 'status'> & { error?: unknown }) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({
|
||||||
|
stdout: Buffer.concat(stdout).toString(options.encoding || 'utf8'),
|
||||||
|
stderr: Buffer.concat(stderr).toString(options.encoding || 'utf8'),
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||||
|
child.on('error', (error) => finish({ status: null, error }));
|
||||||
|
child.on('close', (status) => finish({
|
||||||
|
status,
|
||||||
|
error: timedOut ? new Error(`${command} превысил ${options.timeout} мс`) : null,
|
||||||
|
}));
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill(options.killSignal || 'SIGKILL');
|
||||||
|
}, options.timeout);
|
||||||
|
child.stdin.on('error', () => {});
|
||||||
|
child.stdin.end(options.input == null ? undefined : options.input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4Cidr(value: unknown) {
|
||||||
|
const [address, prefix, extra] = String(value).split('/');
|
||||||
|
const size = Number(prefix);
|
||||||
|
return extra === undefined && net.isIPv4(address)
|
||||||
|
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTrafficDevices(observations: unknown): TrafficDevice[] {
|
||||||
|
const candidates = new Map<string, Omit<TrafficDevice, 'key'>>();
|
||||||
|
const ipsByMac = new Map<string, Set<string>>();
|
||||||
|
const locationsByIp = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const value of Array.isArray(observations) ? observations : []) {
|
||||||
|
const observation = record(value);
|
||||||
|
const ip = String(observation.ip || '');
|
||||||
|
const mac = String(observation.mac || '').toLowerCase();
|
||||||
|
const deviceInterface = String(observation.interface || '');
|
||||||
|
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
|
||||||
|
|
||||||
|
const location = `${mac}|${deviceInterface}`;
|
||||||
|
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
||||||
|
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||||
|
ipsByMac.get(mac)?.add(ip);
|
||||||
|
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
|
||||||
|
locationsByIp.get(ip)?.add(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates.values()]
|
||||||
|
.filter(({ ip, mac }) => ipsByMac.get(mac)?.size === 1 && locationsByIp.get(ip)?.size === 1)
|
||||||
|
.map((device) => ({ ...device, key: counterKey(device) }))
|
||||||
|
.sort((left, right) => (
|
||||||
|
left.ip.localeCompare(right.ip)
|
||||||
|
|| left.mac.localeCompare(right.mac)
|
||||||
|
|| left.interface.localeCompare(right.interface)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrafficRestore({
|
||||||
|
devices,
|
||||||
|
bypassCidrs,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
slot,
|
||||||
|
proxyPort,
|
||||||
|
}: {
|
||||||
|
devices: readonly TrafficDevice[];
|
||||||
|
bypassCidrs: readonly string[];
|
||||||
|
uploadChain: string;
|
||||||
|
downloadChain: string;
|
||||||
|
slot: string;
|
||||||
|
proxyPort: number;
|
||||||
|
}) {
|
||||||
|
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
||||||
|
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
||||||
|
|| proxyPort < 1 || proxyPort > 65_535
|
||||||
|
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) {
|
||||||
|
throw new Error('Некорректная конфигурация traffic accounting');
|
||||||
|
}
|
||||||
|
const uploadChild = childChain(uploadChain, slot);
|
||||||
|
const downloadChild = childChain(downloadChain, slot);
|
||||||
|
const proxyUploadChild = proxyChildChain(uploadChain, slot);
|
||||||
|
const proxyDownloadChild = proxyChildChain(downloadChain, slot);
|
||||||
|
const raw = [
|
||||||
|
'*raw',
|
||||||
|
`-F ${uploadChild}`,
|
||||||
|
`-F ${proxyUploadChild}`,
|
||||||
|
`-A ${uploadChild} -i br-+ -j RETURN`,
|
||||||
|
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
|
||||||
|
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
|
||||||
|
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
|
||||||
|
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
|
||||||
|
];
|
||||||
|
const mangle = [
|
||||||
|
'*mangle',
|
||||||
|
`-F ${downloadChild}`,
|
||||||
|
`-F ${proxyDownloadChild}`,
|
||||||
|
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||||
|
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
|
||||||
|
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||||
|
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
for (const protocol of ['tcp', 'udp']) {
|
||||||
|
raw.push(`-A ${proxyUploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-upload -j RETURN`);
|
||||||
|
mangle.push(`-A ${proxyDownloadChild} -o ${device.interface} -d ${device.ip} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-download -j RETURN`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cidr of bypassCidrs) {
|
||||||
|
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
|
||||||
|
mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`);
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
raw.push(`-A ${uploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-traffic:${device.key}:upload -j RETURN`);
|
||||||
|
mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m comment --comment harbor-traffic:${device.key}:download -j RETURN`);
|
||||||
|
}
|
||||||
|
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
|
||||||
|
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const linePattern = new RegExp(
|
||||||
|
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
||||||
|
);
|
||||||
|
const counters = new Map<string, string>();
|
||||||
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
|
const match = line.match(linePattern);
|
||||||
|
if (!match) continue;
|
||||||
|
const key = `${match[3]}:${match[4]}`;
|
||||||
|
counters.set(key, (BigInt(counters.get(key) || '0') + BigInt(match[2])).toString());
|
||||||
|
}
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceTrafficService({
|
||||||
|
observe,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
bypassCidrs,
|
||||||
|
proxyPort,
|
||||||
|
run = runCommand,
|
||||||
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}: {
|
||||||
|
observe: () => Promise<unknown> | unknown;
|
||||||
|
uploadChain: string;
|
||||||
|
downloadChain: string;
|
||||||
|
bypassCidrs: string[];
|
||||||
|
proxyPort: number;
|
||||||
|
run?: RunCommand;
|
||||||
|
nextGeneration?: () => string;
|
||||||
|
}) {
|
||||||
|
const epoch = nextGeneration();
|
||||||
|
let activeSlot: 'A' | 'B' | null = null;
|
||||||
|
let activeDevices: TrafficDevice[] = [];
|
||||||
|
let activeSignature = '';
|
||||||
|
let activeCounters = new Map<string, string>();
|
||||||
|
let pendingRetired: RetiredCounters | null = null;
|
||||||
|
let refreshPromise: Promise<TrafficSnapshot> | null = null;
|
||||||
|
const finalized = new Map<string, CounterValues>();
|
||||||
|
const devicesByKey = new Map<string, TrafficDevice>();
|
||||||
|
let current: TrafficSnapshot = {
|
||||||
|
epoch,
|
||||||
|
generation: epoch,
|
||||||
|
observedAt: null,
|
||||||
|
source: { error: null },
|
||||||
|
devices: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function execute(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS) {
|
||||||
|
const result = await run(command, args, options);
|
||||||
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
|
return String(result.stdout || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepare(slot: 'A' | 'B', devices: TrafficDevice[]) {
|
||||||
|
const input = buildTrafficRestore({
|
||||||
|
devices,
|
||||||
|
bypassCidrs,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
slot,
|
||||||
|
proxyPort,
|
||||||
|
});
|
||||||
|
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTo(slot: 'A' | 'B') {
|
||||||
|
const uploadChild = childChain(uploadChain, slot);
|
||||||
|
const downloadChild = childChain(downloadChain, slot);
|
||||||
|
const replace = activeSlot ? '-R' : '-A';
|
||||||
|
const uploadArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
|
||||||
|
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
|
||||||
|
const downloadArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
|
||||||
|
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
|
||||||
|
|
||||||
|
await execute('iptables', uploadArgs);
|
||||||
|
try {
|
||||||
|
await execute('iptables', downloadArgs);
|
||||||
|
} catch (error) {
|
||||||
|
const rollbackArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
|
||||||
|
: ['-w', '1', '-t', 'raw', '-F', uploadChain];
|
||||||
|
await execute('iptables', rollbackArgs);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCounters(devices: TrafficDevice[], slot: 'A' | 'B' | null): Promise<Map<string, string>> {
|
||||||
|
if (!slot) return new Map<string, string>();
|
||||||
|
const [raw, mangle] = await Promise.all([
|
||||||
|
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||||
|
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||||
|
]);
|
||||||
|
const parsed = {
|
||||||
|
upload: parseTrafficCounters(raw, childChain(uploadChain, slot)),
|
||||||
|
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
|
||||||
|
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
||||||
|
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
||||||
|
};
|
||||||
|
const counters = new Map<string, string>();
|
||||||
|
for (const { key } of devices) {
|
||||||
|
for (const [kind, field] of COUNTERS) {
|
||||||
|
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function counter(counters: Map<string, string>, key: string, direction: CounterKind) {
|
||||||
|
return BigInt(counters.get(`${key}:${direction}`) || '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(devices: TrafficDevice[]) {
|
||||||
|
for (const device of devices) devicesByKey.set(device.key, device);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeRetired() {
|
||||||
|
if (!pendingRetired) return false;
|
||||||
|
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||||
|
for (const { key } of pendingRetired.devices) {
|
||||||
|
const previous = finalized.get(key) || zeroCounters();
|
||||||
|
const next: CounterValues = { ...previous };
|
||||||
|
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
||||||
|
finalized.set(key, next);
|
||||||
|
}
|
||||||
|
pendingRetired = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTotals() {
|
||||||
|
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
||||||
|
const totalsByMac = new Map<string, TrafficDevice & CounterValues>();
|
||||||
|
for (const [key, remembered] of devicesByKey) {
|
||||||
|
const base = finalized.get(key) || zeroCounters();
|
||||||
|
const pending = pendingRetired?.counters || new Map();
|
||||||
|
const previous = totalsByMac.get(remembered.mac) || { ...remembered, ...zeroCounters() };
|
||||||
|
const total: TrafficDevice & CounterValues = {
|
||||||
|
...(activeByMac.get(remembered.mac) || remembered),
|
||||||
|
...zeroCounters(),
|
||||||
|
};
|
||||||
|
for (const [kind, field] of COUNTERS) {
|
||||||
|
total[field] = previous[field] + base[field]
|
||||||
|
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
|
||||||
|
}
|
||||||
|
totalsByMac.set(remembered.mac, total);
|
||||||
|
}
|
||||||
|
return [...totalsByMac.values()]
|
||||||
|
.map((total) => {
|
||||||
|
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
uploadBytes: upload.toString(),
|
||||||
|
downloadBytes: download.toString(),
|
||||||
|
proxyUploadBytes: proxyUpload.toString(),
|
||||||
|
proxyDownloadBytes: proxyDownload.toString(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh() {
|
||||||
|
let observed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
observed = record(await observe());
|
||||||
|
} catch (error) {
|
||||||
|
observed = { observedAt: new Date().toISOString(), observations: [], error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceError = observed.error ? String(observed.error) : null;
|
||||||
|
const nextDevices = sourceError
|
||||||
|
? activeDevices
|
||||||
|
: selectTrafficDevices(observed?.observations);
|
||||||
|
const nextSignature = JSON.stringify(nextDevices);
|
||||||
|
let countersRead = false;
|
||||||
|
|
||||||
|
if (pendingRetired) {
|
||||||
|
try {
|
||||||
|
countersRead = await finalizeRetired() || countersRead;
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingRetired && !sourceError && nextSignature !== activeSignature) {
|
||||||
|
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||||
|
try {
|
||||||
|
await prepare(nextSlot, nextDevices);
|
||||||
|
await switchTo(nextSlot);
|
||||||
|
const retired = activeSlot ? {
|
||||||
|
slot: activeSlot,
|
||||||
|
devices: activeDevices,
|
||||||
|
counters: activeCounters,
|
||||||
|
} : null;
|
||||||
|
activeSlot = nextSlot;
|
||||||
|
activeDevices = nextDevices;
|
||||||
|
activeSignature = nextSignature;
|
||||||
|
activeCounters = new Map();
|
||||||
|
pendingRetired = retired;
|
||||||
|
remember(nextDevices);
|
||||||
|
current.generation = nextGeneration();
|
||||||
|
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||||
|
countersRead = true;
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
epoch,
|
||||||
|
generation: current.generation,
|
||||||
|
observedAt: countersRead && typeof observed.observedAt === 'string'
|
||||||
|
? observed.observedAt
|
||||||
|
: current.observedAt,
|
||||||
|
source: { error: sourceError },
|
||||||
|
devices: countersRead ? processTotals() : current.devices,
|
||||||
|
};
|
||||||
|
return structuredClone(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh,
|
||||||
|
snapshot: () => structuredClone(current),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { domainToASCII } from 'node:url';
|
||||||
|
import { deviceId } from './deviceInventoryService.js';
|
||||||
|
|
||||||
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
|
const DEFAULT_MAX_SERIES = 4096;
|
||||||
|
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
|
||||||
|
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
|
||||||
|
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
|
||||||
|
const SERVICE_DOMAINS = [
|
||||||
|
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
|
||||||
|
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ParsedBaseConnection {
|
||||||
|
id: string;
|
||||||
|
upload: bigint;
|
||||||
|
download: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedConnection =
|
||||||
|
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
|
||||||
|
| (ParsedBaseConnection & {
|
||||||
|
outcome: 'classified' | 'unresolved_host';
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
source: 'gateway' | 'proxy';
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PreviousConnection {
|
||||||
|
outcome: AttributionOutcome | 'classified';
|
||||||
|
key?: string;
|
||||||
|
requestedKey?: string;
|
||||||
|
countedUpload: bigint | null;
|
||||||
|
countedDownload: bigint | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainSeriesTotal {
|
||||||
|
deviceId: string;
|
||||||
|
domain: string;
|
||||||
|
service: string;
|
||||||
|
source: string;
|
||||||
|
uploadBytes: bigint;
|
||||||
|
downloadBytes: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainTrafficSnapshot {
|
||||||
|
epoch: string;
|
||||||
|
observedAt: string | null;
|
||||||
|
source: { error: string | null };
|
||||||
|
overflowConnections: string;
|
||||||
|
attributionEvents: Record<AttributionOutcome, string>;
|
||||||
|
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||||
|
|
||||||
|
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||||
|
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||||
|
if (domain.startsWith('www.')) domain = domain.slice(4);
|
||||||
|
const labels = domain.split('.');
|
||||||
|
if (!domain || domain.length > 253 || net.isIP(domain) || labels.length < 2
|
||||||
|
|| labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) return null;
|
||||||
|
for (const [service, suffixes] of SERVICE_DOMAINS) {
|
||||||
|
const suffix = suffixes.find((candidate) => matchesDomain(domain, candidate));
|
||||||
|
if (suffix) return { domain: suffix, service };
|
||||||
|
}
|
||||||
|
return { domain, service: domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFor(type: string): 'gateway' | 'proxy' | null {
|
||||||
|
if (type === 'tproxy/tproxy-in') return 'gateway';
|
||||||
|
if (type === 'mixed/mixed-in') return 'proxy';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
|
||||||
|
const connection = record(value);
|
||||||
|
const id = String(connection.id || '');
|
||||||
|
const metadata = record(connection.metadata);
|
||||||
|
const upload = connection.upload;
|
||||||
|
const download = connection.download;
|
||||||
|
if (!id || typeof upload !== 'number' || !Number.isSafeInteger(upload) || upload < 0
|
||||||
|
|| typeof download !== 'number' || !Number.isSafeInteger(download) || download < 0) {
|
||||||
|
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
||||||
|
}
|
||||||
|
const parsed = {
|
||||||
|
id,
|
||||||
|
upload: BigInt(upload),
|
||||||
|
download: BigInt(download),
|
||||||
|
};
|
||||||
|
const source = sourceFor(String(metadata.type || ''));
|
||||||
|
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||||
|
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
|
||||||
|
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||||
|
const classifiedDomain = classifyDomain(metadata.host);
|
||||||
|
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||||
|
deviceId: currentDeviceId,
|
||||||
|
...domain,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
response.on('data', (chunk: Buffer) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_RESPONSE_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
request.destroy(new Error('Sing-box connections response слишком большой'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
if ((response.statusCode || 500) >= 400) {
|
||||||
|
reject(new Error(`Sing-box connections HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||||||
|
} catch (cause) {
|
||||||
|
reject(new Error('Sing-box вернул невалидный connections JSON', { cause }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.setTimeout(timeoutMs, () => request.destroy(new Error('Sing-box connections timeout')));
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDomainTrafficService({
|
||||||
|
observe,
|
||||||
|
devices,
|
||||||
|
now = () => new Date(),
|
||||||
|
maxSeries = DEFAULT_MAX_SERIES,
|
||||||
|
}: {
|
||||||
|
observe: () => Promise<unknown> | unknown;
|
||||||
|
devices: () => unknown;
|
||||||
|
now?: () => Date;
|
||||||
|
maxSeries?: number;
|
||||||
|
}) {
|
||||||
|
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
|
||||||
|
const epoch = crypto.randomUUID();
|
||||||
|
const totals = new Map<string, DomainSeriesTotal>();
|
||||||
|
const normalSeriesLimit = maxSeries - 2;
|
||||||
|
let normalSeries = 0;
|
||||||
|
let previousConnections = new Map<string, PreviousConnection>();
|
||||||
|
let overflowConnections = 0n;
|
||||||
|
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||||
|
unresolved_host: 0n,
|
||||||
|
unknown_device: 0n,
|
||||||
|
unsupported_source: 0n,
|
||||||
|
};
|
||||||
|
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||||
|
let current: DomainTrafficSnapshot = {
|
||||||
|
epoch,
|
||||||
|
observedAt: null,
|
||||||
|
source: { error: null },
|
||||||
|
overflowConnections: '0',
|
||||||
|
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||||
|
series: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSnapshot(error: string | null = null): DomainTrafficSnapshot {
|
||||||
|
return {
|
||||||
|
epoch,
|
||||||
|
observedAt: current.observedAt,
|
||||||
|
source: { error },
|
||||||
|
overflowConnections: overflowConnections.toString(),
|
||||||
|
attributionEvents: Object.fromEntries(
|
||||||
|
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||||
|
) as Record<AttributionOutcome, string>,
|
||||||
|
series: [...totals.values()]
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
uploadBytes: entry.uploadBytes.toString(),
|
||||||
|
downloadBytes: entry.downloadBytes.toString(),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (
|
||||||
|
left.deviceId.localeCompare(right.deviceId)
|
||||||
|
|| left.service.localeCompare(right.service)
|
||||||
|
|| left.domain.localeCompare(right.domain)
|
||||||
|
|| left.source.localeCompare(right.source)
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh() {
|
||||||
|
try {
|
||||||
|
const response = record(await observe());
|
||||||
|
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||||
|
const devicesByIp = new Map<string, string | null>();
|
||||||
|
const observedDevices = devices();
|
||||||
|
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||||
|
const device = record(value);
|
||||||
|
const ip = String(device.ip || '');
|
||||||
|
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||||
|
if (!net.isIPv4(ip) || !id) continue;
|
||||||
|
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||||
|
}
|
||||||
|
const activeConnections = new Map<string, PreviousConnection>();
|
||||||
|
for (const rawConnection of response.connections) {
|
||||||
|
const connection = parseConnection(rawConnection, devicesByIp);
|
||||||
|
const previous = previousConnections.get(connection.id);
|
||||||
|
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||||
|
attributionEvents[connection.outcome] += 1n;
|
||||||
|
}
|
||||||
|
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||||
|
activeConnections.set(connection.id, {
|
||||||
|
outcome: connection.outcome,
|
||||||
|
countedUpload: previous?.countedUpload ?? null,
|
||||||
|
countedDownload: previous?.countedDownload ?? null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||||
|
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||||
|
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||||
|
let domain = connection.domain;
|
||||||
|
let service = connection.service;
|
||||||
|
if (key !== requestedKey) {
|
||||||
|
domain = '_other';
|
||||||
|
service = 'Другие домены';
|
||||||
|
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||||
|
overflowConnections += 1n;
|
||||||
|
domain = '_other';
|
||||||
|
service = 'Другие домены';
|
||||||
|
key = `_other\0${domain}\0${connection.source}`;
|
||||||
|
} else if (!totals.has(key)) {
|
||||||
|
normalSeries += 1;
|
||||||
|
}
|
||||||
|
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||||
|
? connection.upload - previous.countedUpload
|
||||||
|
: connection.upload;
|
||||||
|
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||||
|
? connection.download - previous.countedDownload
|
||||||
|
: connection.download;
|
||||||
|
const total = totals.get(key) || {
|
||||||
|
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||||
|
domain,
|
||||||
|
service,
|
||||||
|
source: connection.source,
|
||||||
|
uploadBytes: 0n,
|
||||||
|
downloadBytes: 0n,
|
||||||
|
};
|
||||||
|
total.uploadBytes += uploadDelta;
|
||||||
|
total.downloadBytes += downloadDelta;
|
||||||
|
totals.set(key, total);
|
||||||
|
activeConnections.set(connection.id, {
|
||||||
|
outcome: connection.outcome,
|
||||||
|
key,
|
||||||
|
requestedKey,
|
||||||
|
countedUpload: connection.upload,
|
||||||
|
countedDownload: connection.download,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
previousConnections = activeConnections;
|
||||||
|
current = { ...current, observedAt: now().toISOString() };
|
||||||
|
current = buildSnapshot();
|
||||||
|
return current;
|
||||||
|
} catch (error) {
|
||||||
|
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { snapshot: () => current, refresh };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user