Compare commits
90 Commits
1.0.0
...
codex/spli
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
43
.codex/skills/design-vpn-client-ui/SKILL.md
Normal file
43
.codex/skills/design-vpn-client-ui/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
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, restrained green state color, and motion that feels slow, fluid, and deliberate without moving layout.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read `PRODUCT.md` and the complete client component and styles before editing.
|
||||||
|
2. Inspect the supplied screenshot or live UI. Trace the real DOM and state change that causes the visual issue.
|
||||||
|
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`. Visually inspect when a runnable client is available.
|
||||||
|
|
||||||
|
## 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 green circle.
|
||||||
|
- Use green only for active, selected, successful, or refreshed states. Keep inactive power gray, including hover.
|
||||||
|
- 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.
|
||||||
|
- Let visible cycles finish. Never stop a spinner mid-turn or remount a list before its exit animation completes.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Refresh and copy feedback cannot change element width or alignment.
|
||||||
|
- Server separators are compact and only slightly wider than their content.
|
||||||
|
- Repeated polling does not replay decorative list animations.
|
||||||
|
- Manual refresh has an obvious but non-jarring response.
|
||||||
|
- Keyboard focus remains visible even when the text caret is intentionally hidden.
|
||||||
|
- Narrow screens return to a simple single-column layout.
|
||||||
4
.codex/skills/design-vpn-client-ui/agents/openai.yaml
Normal file
4
.codex/skills/design-vpn-client-ui/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Design VPN Client UI"
|
||||||
|
short_description: "Design the repo's calm animated VPN client UI."
|
||||||
|
default_prompt: "Use $design-vpn-client-ui to design or refine the VPN client interface in this repository."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Power state
|
||||||
|
|
||||||
|
- Transition gray to green slowly when connecting and green 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 opacity plus light blur, never vertical layout movement.
|
||||||
|
- Do not reanimate the timer every second; animate only its initial appearance.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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 green flare.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 green 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.
|
||||||
|
|
||||||
|
## 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 green `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,49 @@
|
|||||||
|
# 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 cards and enclosing frames. Use spacing, type, thin rules, and state color for hierarchy.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Inactive power stays neutral gray even on hover; active power becomes green.
|
||||||
|
- Use green for active VPN, selected server underline, progress, copy success, refresh success, and focus.
|
||||||
|
- Prefer localized `drop-shadow`, `text-shadow`, or a soft radial light layer over filled green containers.
|
||||||
|
- Let glow support state recognition. Do not leave every element glowing continuously.
|
||||||
|
|
||||||
|
## 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.
|
||||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.vpn-proxy
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT=8082
|
||||||
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
|
SINGBOX_VERSION=1.12.13
|
||||||
|
INSTALL_RUNTIME_DEPS=true
|
||||||
|
INSTALL_SINGBOX=true
|
||||||
|
PROXY_PORT=8080
|
||||||
|
PROXY_BIND_IP=0.0.0.0
|
||||||
|
TPROXY_PORT=7895
|
||||||
|
TPROXY_MARK=1
|
||||||
|
TPROXY_TABLE=100
|
||||||
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
LOG_LEVEL=info
|
||||||
107
.gitea/workflows/gateway-build.yml
Normal file
107
.gitea/workflows/gateway-build.yml
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
name: Build and Deploy Gateway
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
|
SINGBOX_VERSION: 1.12.13
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Clone repository
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
rm -rf repo
|
||||||
|
git clone --depth 2 "http://${{ gitea.actor }}:${GIT_TOKEN}@${SERVER_HOST}/${{ gitea.repository }}.git" repo
|
||||||
|
cd repo
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build and push gateway image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
|
||||||
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
|
||||||
|
echo "Build runner: $(hostname)"
|
||||||
|
echo "Base image: ${{ env.BASE_IMAGE }}"
|
||||||
|
echo "Docker context: $(docker context show 2>/dev/null || true)"
|
||||||
|
docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'
|
||||||
|
|
||||||
|
if ! docker image inspect "${{ env.BASE_IMAGE }}" >/dev/null 2>&1 \
|
||||||
|
|| ! docker run --rm "${{ env.BASE_IMAGE }}" sh -lc 'command -v npm >/dev/null'; then
|
||||||
|
echo "Runtime base image ${{ env.BASE_IMAGE }} is missing npm; building it now."
|
||||||
|
BASE_IMAGE="${{ env.RUNTIME_BASE_SOURCE_IMAGE }}" \
|
||||||
|
RUNTIME_BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
./scripts/build-runtime-base.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
npm ci --no-audit --no-fund
|
||||||
|
npm run build
|
||||||
|
else
|
||||||
|
echo "Host npm not found; building frontend inside ${{ env.BASE_IMAGE }}"
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
"${{ env.BASE_IMAGE }}" \
|
||||||
|
sh -lc 'npm ci --no-audit --no-fund && npm run build'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--network host \
|
||||||
|
--pull=false \
|
||||||
|
--build-arg BASE_IMAGE="${{ env.BASE_IMAGE }}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
--build-arg INSTALL_RUNTIME_DEPS=false \
|
||||||
|
--build-arg INSTALL_SINGBOX=false \
|
||||||
|
-t "${IMAGE}:latest" \
|
||||||
|
-t "${IMAGE}:${{ gitea.sha }}" \
|
||||||
|
.
|
||||||
|
docker push "${IMAGE}:latest"
|
||||||
|
docker push "${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"
|
||||||
|
|
||||||
|
echo "Deploy runner: $(hostname)"
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
DEPLOY_PATH="${{ env.DEPLOY_PATH }}" GATEWAY_IMAGE="${IMAGE}:${{ gitea.sha }}" bash scripts/deploy-gateway.sh
|
||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,2 +1,22 @@
|
|||||||
data
|
# Runtime state
|
||||||
_legacy
|
.env
|
||||||
|
*.env.local
|
||||||
|
data/
|
||||||
|
.vpn-proxy/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Node/Vite
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS/editors
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
|
ARG INSTALL_SINGBOX=true
|
||||||
|
COPY dist /app/dist
|
||||||
|
|
||||||
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
|
apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
|
else \
|
||||||
|
command -v dumb-init >/dev/null \
|
||||||
|
&& command -v node >/dev/null \
|
||||||
|
&& command -v iptables >/dev/null \
|
||||||
|
&& command -v ipset >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||||
|
set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*; \
|
||||||
|
else \
|
||||||
|
command -v sing-box >/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY src/server /app/src/server
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV PORT=3456 \
|
||||||
|
PROXY_PORT=8080 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
TPROXY_PORT=7895 \
|
||||||
|
DIRECT_BYPASS_CACHE=false \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||||
54
Dockerfile.client
Normal file
54
Dockerfile.client
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
ARG NODE_BUILD_IMAGE=node:20-alpine
|
||||||
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS web-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY index.html vite.config.js ./
|
||||||
|
COPY src/web ./src/web
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=web-build /src/dist /app/dist
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY src/server /app/src/server
|
||||||
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.client.sh \
|
||||||
|
&& mkdir -p /etc/sing-box /var/lib/vpn-proxy /var/lib/sing-box
|
||||||
|
|
||||||
|
ENV APP_MODE=client \
|
||||||
|
PORT=3456 \
|
||||||
|
PROXY_PORT=8082 \
|
||||||
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
|
ROUTING_RU_DIRECT=true \
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 3456 8082
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"]
|
||||||
52
Dockerfile.runtime-base
Normal file
52
Dockerfile.runtime-base
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
ARG SINGBOX_VERSION=1.12.13
|
||||||
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
|
ARG HTTP_PROXY
|
||||||
|
ARG HTTPS_PROXY
|
||||||
|
ARG NO_PROXY
|
||||||
|
ARG http_proxy
|
||||||
|
ARG https_proxy
|
||||||
|
ARG no_proxy
|
||||||
|
|
||||||
|
RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
||||||
|
&& export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}" \
|
||||||
|
&& export no_proxy="${no_proxy:-${NO_PROXY:-}}" \
|
||||||
|
&& for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do \
|
||||||
|
[ -f "$file" ] || continue; \
|
||||||
|
sed -i \
|
||||||
|
-e "s|http://deb.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://security.debian.org/debian-security|${APT_SECURITY_MIRROR}|g" \
|
||||||
|
-e "s|http://deb.debian.org/debian|${APT_MIRROR}|g" \
|
||||||
|
"$file"; \
|
||||||
|
done \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
update \
|
||||||
|
&& apt-get \
|
||||||
|
-o Acquire::Retries=3 \
|
||||||
|
-o Acquire::http::Timeout=20 \
|
||||||
|
-o Acquire::https::Timeout=20 \
|
||||||
|
-o Acquire::ForceIPv4=true \
|
||||||
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs npm dumb-init \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
export http_proxy="${http_proxy:-${HTTP_PROXY:-}}"; \
|
||||||
|
export https_proxy="${https_proxy:-${HTTPS_PROXY:-}}"; \
|
||||||
|
export no_proxy="${no_proxy:-${NO_PROXY:-}}"; \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64) sb_arch="amd64" ;; \
|
||||||
|
arm64) sb_arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
curl -fsSL "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}.tar.gz" -o /tmp/sing-box.tgz; \
|
||||||
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
mv "/tmp/sing-box-${SINGBOX_VERSION}-linux-${sb_arch}/sing-box" /usr/local/bin/sing-box; \
|
||||||
|
chmod +x /usr/local/bin/sing-box; \
|
||||||
|
rm -rf /tmp/sing-box*
|
||||||
35
PRODUCT.md
Normal file
35
PRODUCT.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
People running either a local macOS proxy client or a small Linux VPN gateway. They open the client only to add a subscription, choose a server, turn the VPN on or off, and copy the connection address.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Provide one small, dependable control surface for the macOS client and the system gateway. Success means the connection state is obvious, while the gateway address and proxy URLs are ready to copy from the same screen.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and routing-rule administration.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- One screen, one primary action.
|
||||||
|
- Use plain language and hide implementation details.
|
||||||
|
- Make connection state unmistakable without relying on color alone.
|
||||||
|
- Prefer native controls and predictable macOS behavior.
|
||||||
|
- Show saved subscriptions as a domain, not as a credential-like URL.
|
||||||
|
- Make servers directly selectable instead of hiding them in a dropdown.
|
||||||
|
- Keep advanced and server-only features out of the client path.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences.
|
||||||
360
README.md
360
README.md
@@ -1,353 +1,43 @@
|
|||||||
# 🌐 VPN Proxy — Домашний VPN в одной программе
|
# VPN Proxy
|
||||||
|
|
||||||
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
|
Один компактный VPN-клиент в двух режимах:
|
||||||
|
|
||||||
---
|
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy;
|
||||||
|
- `client` — локальный proxy-клиент для macOS.
|
||||||
|
|
||||||
## 📖 Что это такое?
|
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
||||||
|
|
||||||
Это набор инструментов, который позволяет:
|
## Gateway
|
||||||
|
|
||||||
1. **Запустить VPN-прокси** на вашем компьютере
|
```bash
|
||||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
cp .env.example .env
|
||||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
|
||||||
|
|
||||||
### 🎯 Для кого это?
|
|
||||||
|
|
||||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
|
||||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
|
||||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
|
||||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 Как это работает?
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
|
|
||||||
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
|
|
||||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Интерфейс: `http://<gateway-ip>:3456`.
|
||||||
|
|
||||||
## 🔧 Перед началом: Требования
|
После подключения экран показывает:
|
||||||
|
|
||||||
### ✅ PowerShell 7 (Обязательно!)
|
- `Gateway` — адрес, который можно назначить устройству как основной шлюз;
|
||||||
|
- `Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`.
|
||||||
|
|
||||||
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
|
Весь перехваченный публичный TCP/UDP и весь proxy-трафик идут через выбранный VPN. Приватные и локальные сети не перехватываются, чтобы сохранить доступ к Gateway и LAN.
|
||||||
|
|
||||||
#### Проверьте вашу версию
|
Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`.
|
||||||
|
|
||||||
Откройте любой PowerShell и выполните:
|
## macOS client
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
$PSVersionTable.PSVersion.Major
|
./scripts/install-macos-client.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
- Если результат **7 или выше** — всё хорошо, переходите к установке ✅
|
По умолчанию интерфейс доступен на `http://127.0.0.1:3456`, локальный HTTP/SOCKS5 proxy — на `127.0.0.1:8082`.
|
||||||
- Если **5 или ниже** — нужно установить PowerShell 7 👇
|
|
||||||
|
|
||||||
#### Установка PowerShell 7
|
## Проверка
|
||||||
|
|
||||||
**Способ 1: Через winget (самый простой)**
|
```bash
|
||||||
|
npm test
|
||||||
Откройте обычный PowerShell или Командную строку и выполните:
|
npm run build
|
||||||
|
docker compose -f docker-compose.gateway.yml config
|
||||||
```powershell
|
docker compose -f docker-compose.client.yml config
|
||||||
winget install Microsoft.PowerShell
|
|
||||||
```
|
```
|
||||||
|
|
||||||
После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск).
|
|
||||||
|
|
||||||
**Способ 2: Скачать вручную**
|
|
||||||
|
|
||||||
1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest
|
|
||||||
2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия)
|
|
||||||
3. Запустите установщик и следуйте инструкциям
|
|
||||||
4. После установки используйте **PowerShell 7** из меню Пуск
|
|
||||||
|
|
||||||
> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### ✅ URL Подписки или VLESS-ссылка
|
|
||||||
|
|
||||||
Получите от вашего VPN-провайдера:
|
|
||||||
- **Подписку**: URL, который начинается с `http://` или `https://`
|
|
||||||
- **VLESS-ссылку**: начинается с `vless://...`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Установка на Windows
|
|
||||||
|
|
||||||
### ⚡ Быстрая установка (Одной командой)
|
|
||||||
|
|
||||||
Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`.
|
|
||||||
|
|
||||||
1. Откройте **PowerShell 7** от имени **Администратора**
|
|
||||||
2. Скопируйте и вставьте команду:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy RemoteSigned -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iwr https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.ps1 | iex
|
|
||||||
```
|
|
||||||
|
|
||||||
> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📦 Ручная установка (если авто-установка не работает)
|
|
||||||
|
|
||||||
Если вы предпочитаете всё делать сами:
|
|
||||||
|
|
||||||
#### Шаг 1: Скачайте проект
|
|
||||||
|
|
||||||
Мы рекомендуем использовать папку `C:\Tools`.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1. Создаем папку и переходим
|
|
||||||
New-Item -ItemType Directory -Force -Path "C:\Tools" | Out-Null
|
|
||||||
cd C:\Tools
|
|
||||||
|
|
||||||
# 2. Клонируем или скачиваем архив
|
|
||||||
git clone https://git.dokops.ru/dokril/vpn-proxy
|
|
||||||
|
|
||||||
# (Или скачайте ZIP вручную и распакуйте в C:\Tools\vpn-proxy)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Шаг 2: Запустите
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\Tools\vpn-proxy
|
|
||||||
.\manage.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 3: Выберите пункт [1] — VPN Клиент
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] 📦 VPN Клиент (Sing-box) [НЕ УСТАНОВЛЕН]
|
|
||||||
Основной способ. Поддерживает UDP и игры.
|
|
||||||
|
|
||||||
[2] 🎮 Настройка Discord/Vesktop [НЕ АКТИВЕН]
|
|
||||||
Маршрутизация приложений через прокси.
|
|
||||||
|
|
||||||
---------------------------------------
|
|
||||||
[3] 🔄 Обновить статус
|
|
||||||
[U] ❌ Удалить всё (Uninstall)
|
|
||||||
[q] Выход
|
|
||||||
|
|
||||||
👉 Ваш выбор: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 4: Введите VLESS-ссылку или URL подписки
|
|
||||||
|
|
||||||
Скрипт попросит ввести ссылку. Вставьте и нажмите Enter.
|
|
||||||
|
|
||||||
**Готово!** 🎉 Прокси запущен на `127.0.0.1:1080`
|
|
||||||
|
|
||||||
### 📂 Где всё хранится?
|
|
||||||
|
|
||||||
Всё организовано в папке `C:\Tools`:
|
|
||||||
|
|
||||||
1. **Сам проект:** `C:\Tools\vpn-proxy`
|
|
||||||
- Скрипты управления и настройки
|
|
||||||
2. **Sing-box (VPN клиент):** `C:\Tools\sing-box`
|
|
||||||
- Здесь лежит `config.json` с вашими настройками и сам исполняемый файл
|
|
||||||
3. **ProxiFyre (для Discord):** `C:\Program Files\ProxiFyre` (системная служба)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Проверка работы
|
|
||||||
|
|
||||||
После установки меню покажет статус и адреса подключения:
|
|
||||||
|
|
||||||
```
|
|
||||||
[1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ]
|
|
||||||
Основной способ. Поддерживает UDP и игры.
|
|
||||||
|
|
||||||
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
|
|
||||||
─────────────────────────────
|
|
||||||
Локально: 127.0.0.1:1080
|
|
||||||
Из сети:
|
|
||||||
192.168.1.100:1080
|
|
||||||
```
|
|
||||||
|
|
||||||
### Проверка через терминал
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Без прокси — покажет ваш домашний IP
|
|
||||||
Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
|
||||||
|
|
||||||
# Через прокси — должен показать IP VPN-сервера
|
|
||||||
Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
|
||||||
```
|
|
||||||
|
|
||||||
Если IP-адреса разные — VPN работает! 🎉
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎮 Настройка Discord / Vesktop
|
|
||||||
|
|
||||||
Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка.
|
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- ✅ Установленный VPN клиент (пункт [1] в меню)
|
|
||||||
- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ")
|
|
||||||
|
|
||||||
### Установка
|
|
||||||
|
|
||||||
1. Запустите `.\manage.ps1`
|
|
||||||
2. Выберите пункт **[2] — Настройка Discord/Vesktop**
|
|
||||||
3. Выберите какое приложение настроить:
|
|
||||||
- Discord
|
|
||||||
- Vesktop
|
|
||||||
- Оба
|
|
||||||
|
|
||||||
**Что устанавливается:**
|
|
||||||
- Windows Packet Filter — драйвер для перехвата трафика
|
|
||||||
- ProxiFyre — служба, которая направляет трафик Discord через прокси
|
|
||||||
|
|
||||||
После установки Discord/Vesktop будут автоматически работать через VPN!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Настройка приложений
|
|
||||||
|
|
||||||
### Для VS Code
|
|
||||||
|
|
||||||
Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте:
|
|
||||||
|
|
||||||
```
|
|
||||||
http.proxy: http://127.0.0.1:1080
|
|
||||||
```
|
|
||||||
|
|
||||||
Или добавьте в `settings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"http.proxy": "http://127.0.0.1:1080",
|
|
||||||
"http.proxyStrictSSL": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Для браузера
|
|
||||||
|
|
||||||
В настройках прокси вашего браузера укажите:
|
|
||||||
|
|
||||||
- **Тип**: HTTP или SOCKS5
|
|
||||||
- **Адрес**: `127.0.0.1`
|
|
||||||
- **Порт**: `1080`
|
|
||||||
|
|
||||||
> 💡 **Совет:** Используйте расширение [Proxy SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif) для удобного переключения прокси в Chrome.
|
|
||||||
|
|
||||||
### Для других программ
|
|
||||||
|
|
||||||
Укажите SOCKS5 прокси: `127.0.0.1:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Управление
|
|
||||||
|
|
||||||
При повторном запуске `.\manage.ps1` скрипт покажет меню управления:
|
|
||||||
|
|
||||||
| Действие | Как сделать |
|
|
||||||
|----------|-------------|
|
|
||||||
| Посмотреть статус | Запустить `.\manage.ps1` |
|
|
||||||
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
|
|
||||||
| Перезапустить | Пункт [1] → "Перезапустить" |
|
|
||||||
| Остановить | Пункт [1] → "Остановить" |
|
|
||||||
| Полностью удалить | Пункт [U] |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌍 Подключение из локальной сети
|
|
||||||
|
|
||||||
Если вы хотите использовать прокси с других устройств (телефон, планшет):
|
|
||||||
|
|
||||||
1. Посмотрите IP-адрес в меню (раздел "Из сети:")
|
|
||||||
2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080`
|
|
||||||
|
|
||||||
Например: `192.168.1.100:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Часто задаваемые вопросы
|
|
||||||
|
|
||||||
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
|
|
||||||
|
|
||||||
**Решение:** Включите выполнение скриптов:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ошибка при запуске — непонятные символы или синтаксис
|
|
||||||
|
|
||||||
**Причина:** Вы используете старый PowerShell 5.1
|
|
||||||
|
|
||||||
**Решение:** Установите PowerShell 7 (см. раздел "Перед началом")
|
|
||||||
|
|
||||||
### Discord не подключается к голосовым каналам
|
|
||||||
|
|
||||||
**Причина:** ProxiFyre не запущен или VPN клиент остановлен
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
1. Запустите `.\manage.ps1`
|
|
||||||
2. Убедитесь что пункт [1] показывает "РАБОТАЕТ"
|
|
||||||
3. Убедитесь что пункт [2] показывает "АКТИВЕН"
|
|
||||||
|
|
||||||
### Как узнать, работает ли VPN?
|
|
||||||
|
|
||||||
1. Откройте https://ipinfo.io в браузере — это ваш реальный IP
|
|
||||||
2. Настройте прокси в браузере
|
|
||||||
3. Откройте https://ipinfo.io снова — должен показать другой IP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Продвинутые варианты
|
|
||||||
|
|
||||||
### Docker с веб-интерфейсом
|
|
||||||
|
|
||||||
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
|
|
||||||
|
|
||||||
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
|
|
||||||
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
|
|
||||||
|
|
||||||
📖 **[Инструкция по Docker](docs/DOCKER.md)**
|
|
||||||
|
|
||||||
### Установка на удалённый сервер (VPS)
|
|
||||||
|
|
||||||
Если вы хотите развернуть прокси на своём сервере в другой стране:
|
|
||||||
|
|
||||||
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Словарь терминов
|
|
||||||
|
|
||||||
| Термин | Объяснение |
|
|
||||||
|--------|------------|
|
|
||||||
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
|
|
||||||
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
|
|
||||||
| **VLESS** | Современный протокол VPN-соединения |
|
|
||||||
| **sing-box** | Программа-клиент для подключения к VPN |
|
|
||||||
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
|
|
||||||
| **Порт** | "Номер двери" для сетевых соединений |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Нужна помощь?
|
|
||||||
|
|
||||||
Если что-то не работает:
|
|
||||||
|
|
||||||
1. Убедитесь что используете **PowerShell 7**
|
|
||||||
2. Запустите от имени **Администратора**
|
|
||||||
3. Проверьте статус в главном меню
|
|
||||||
4. Попробуйте переустановить: пункт [U], затем пункт [1]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
|
||||||
|
|||||||
42
docker-compose.client.yml
Normal file
42
docker-compose.client.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
vpn-proxy-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
args:
|
||||||
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.12.13}
|
||||||
|
container_name: vpn-proxy-client
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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:
|
||||||
35
docker-compose.gateway.yml
Normal file
35
docker-compose.gateway.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
vpn-proxy-gateway:
|
||||||
|
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}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
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:
|
||||||
@@ -1,41 +1,22 @@
|
|||||||
# ==========================================
|
|
||||||
# СЕРВЕРНАЯ КОНФИГУРАЦИЯ (Linux VPS)
|
|
||||||
# ==========================================
|
|
||||||
# Используйте этот файл на удалённом сервере:
|
|
||||||
# docker compose -f docker-compose.server.yml up -d
|
|
||||||
#
|
|
||||||
# network_mode: host решает проблему UDP ASSOCIATE
|
|
||||||
# для SOCKS5 прокси (важно для Discord голоса!)
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
sing-proxy:
|
vpn-proxy-gateway:
|
||||||
container_name: sing-proxy
|
image: ${GATEWAY_IMAGE}
|
||||||
build:
|
container_name: vpn-proxy-gateway
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile.singbox
|
|
||||||
|
|
||||||
# HOST MODE — контейнер использует сеть хоста напрямую
|
|
||||||
# Это решает проблему UDP ASSOCIATE для SOCKS5
|
|
||||||
# ВАЖНО: работает только на Linux, не на Windows/macOS!
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
# Порт веб-интерфейса (по умолчанию 3456)
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
- PORT=${PORT:-3456}
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
# Порт прокси HTTP/SOCKS5 (по умолчанию 8080)
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
- PROXY_PORT=${PROXY_PORT:-8080}
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
vpn-proxy-data:
|
||||||
restart: unless-stopped
|
sing-box-cache:
|
||||||
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
docs/DOCKER.md
178
docs/DOCKER.md
@@ -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
docs/SERVER.md
278
docs/SERVER.md
@@ -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)
|
|
||||||
21
entrypoint.client.sh
Executable file
21
entrypoint.client.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-3456}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8082}"
|
||||||
|
DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}"
|
||||||
|
SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}"
|
||||||
|
SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[client-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$(dirname "$SING_BOX_CONFIG")" "$(dirname "$SING_BOX_CACHE")"
|
||||||
|
|
||||||
|
export APP_MODE=client
|
||||||
|
export PORT PROXY_PORT DATA_DIR SING_BOX_CONFIG SING_BOX_CACHE
|
||||||
|
export PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
|
||||||
|
log "starting VPN proxy client UI on :${PORT}, local proxy on :${PROXY_PORT}"
|
||||||
|
exec node /app/src/server/index.js
|
||||||
100
entrypoint.sh
Executable file
100
entrypoint.sh
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TPROXY_PORT="${TPROXY_PORT:-7895}"
|
||||||
|
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||||
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
ipt() {
|
||||||
|
iptables -w "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_proxy_firewall() {
|
||||||
|
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_tproxy() {
|
||||||
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
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_tproxy() {
|
||||||
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
|
cleanup_tproxy
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
|
||||||
|
# Private/local destinations stay reachable; every intercepted public packet goes to VPN.
|
||||||
|
for cidr in $BYPASS_CIDRS; do
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_tproxy
|
||||||
|
setup_proxy_firewall
|
||||||
|
|
||||||
|
node /app/src/server/index.js &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_tproxy
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||||
|
wait "$APP_PID"
|
||||||
|
STATUS=$?
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_tproxy
|
||||||
|
exit "$STATUS"
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VPN</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/web/App.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
104
install.ps1
104
install.ps1
@@ -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 ""
|
|
||||||
94
manage.ps1
94
manage.ps1
@@ -1,94 +0,0 @@
|
|||||||
# ==========================================
|
|
||||||
# 🚀 VPN PROXY CONTROL CENTER (WINDOWS)
|
|
||||||
# ==========================================
|
|
||||||
# Главный скрипт управления. Запускать от имени Администратора.
|
|
||||||
# Использование: .\manage.ps1 [-Debug]
|
|
||||||
|
|
||||||
param([switch]$Debug)
|
|
||||||
|
|
||||||
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
|
|
||||||
$LibDir = "$ScriptDir\scripts\lib"
|
|
||||||
|
|
||||||
# Проверка библиотек
|
|
||||||
if (!(Test-Path "$LibDir\Common.ps1")) {
|
|
||||||
Write-Host "❌ Ошибка: Не найдены библиотеки в $LibDir" -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
. "$LibDir\Common.ps1"
|
|
||||||
. "$LibDir\System.ps1"
|
|
||||||
|
|
||||||
# Установка режима отладки
|
|
||||||
if ($Debug) {
|
|
||||||
Set-DebugMode -Enabled $true
|
|
||||||
}
|
|
||||||
|
|
||||||
Ensure-Admin
|
|
||||||
|
|
||||||
while ($true) {
|
|
||||||
Write-Header "VPN PROXY CONTROL CENTER" -ClearScreen
|
|
||||||
|
|
||||||
# --- СБОР СТАТУСОВ ---
|
|
||||||
|
|
||||||
# 1. Native Sing-box
|
|
||||||
$sbStatus = Get-TaskStatus -Name "SingBoxProxy"
|
|
||||||
$sbStr = if ($sbStatus -eq "Running") { "РАБОТАЕТ" } else { "ОСТАНОВЛЕН" }
|
|
||||||
$sbColor = if ($sbStatus -eq "Running") { "Green" } else { "Yellow" }
|
|
||||||
if (!$sbStatus) { $sbStr = "НЕ УСТАНОВЛЕН"; $sbColor = "Gray" }
|
|
||||||
|
|
||||||
# 2. Discord Proxy
|
|
||||||
$discSvc = Get-Service -Name "ProxiFyreService" -ErrorAction SilentlyContinue
|
|
||||||
$discStr = if ($discSvc.Status -eq 'Running') { "АКТИВЕН" } else { "НЕ АКТИВЕН" }
|
|
||||||
$discColor = if ($discSvc.Status -eq 'Running') { "Green" } else { "Gray" }
|
|
||||||
|
|
||||||
# --- ОТРИСОВКА МЕНЮ ---
|
|
||||||
|
|
||||||
Write-Host " [1] 📦 VPN Клиент (Sing-box)" -NoNewline -ForegroundColor White
|
|
||||||
Write-Host " [$sbStr]" -ForegroundColor $sbColor
|
|
||||||
Write-Host " Основной способ. Поддерживает UDP и игры." -ForegroundColor Gray
|
|
||||||
|
|
||||||
# Показываем информацию о подключении если sing-box работает
|
|
||||||
if ($sbStatus -eq "Running") {
|
|
||||||
$LocalProxyPort = 1080
|
|
||||||
. "$LibDir\Net.ps1"
|
|
||||||
$ips = Get-LocalIPs
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ" -ForegroundColor Cyan
|
|
||||||
Write-Host " ─────────────────────────────" -ForegroundColor DarkGray
|
|
||||||
Write-Host " Локально: " -NoNewline -ForegroundColor Gray
|
|
||||||
Write-Host "127.0.0.1:$LocalProxyPort" -ForegroundColor Green
|
|
||||||
|
|
||||||
if ($ips) {
|
|
||||||
Write-Host " Из сети:" -ForegroundColor Gray
|
|
||||||
foreach ($ip in $ips) {
|
|
||||||
Write-Host " ${ip}:$LocalProxyPort" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host " [2] 🎮 Настройка Discord/Vesktop" -NoNewline -ForegroundColor White
|
|
||||||
Write-Host " [$discStr]" -ForegroundColor $discColor
|
|
||||||
Write-Host " Маршрутизация приложений через прокси." -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
Write-Host " ---------------------------------------" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
|
|
||||||
Write-Host " [3] 🔄 Обновить статус" -ForegroundColor White
|
|
||||||
Write-Host " [U] ❌ Удалить всё (Uninstall)" -ForegroundColor Red
|
|
||||||
Write-Host " [q] Выход" -ForegroundColor White
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$choice = Read-Host "👉 Ваш выбор"
|
|
||||||
|
|
||||||
switch ($choice) {
|
|
||||||
"1" { & "$ScriptDir\scripts\setup-singbox.ps1" }
|
|
||||||
"2" { & "$ScriptDir\scripts\setup-discord.ps1" }
|
|
||||||
"3" { continue }
|
|
||||||
"u" { & "$ScriptDir\scripts\uninstall-all.ps1" }
|
|
||||||
"q" { exit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1663
package-lock.json
generated
Normal file
1663
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "vpn-proxy-gateway",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "node --test",
|
||||||
|
"start": "node src/server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
scripts/build-on-107-deploy-111.sh
Executable file
112
scripts/build-on-107-deploy-111.sh
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUILD_HOST="${BUILD_HOST:-107}"
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-111}"
|
||||||
|
BUILD_PATH="${BUILD_PATH:-/opt/vpn-proxy-build}"
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-vpn-proxy-gateway}"
|
||||||
|
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
|
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||||
|
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||||
|
AUTO_BUILD_RUNTIME_BASE="${AUTO_BUILD_RUNTIME_BASE:-true}"
|
||||||
|
SSH_CONNECT_TIMEOUT="${SSH_CONNECT_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
echo "Build host: ${BUILD_HOST}"
|
||||||
|
echo "Deploy host: ${DEPLOY_HOST}"
|
||||||
|
echo "Image: ${GATEWAY_IMAGE}"
|
||||||
|
echo "Base image: ${BASE_IMAGE}"
|
||||||
|
echo "Runtime base source: ${RUNTIME_BASE_SOURCE_IMAGE}"
|
||||||
|
|
||||||
|
ensure_known_host() {
|
||||||
|
local host="$1"
|
||||||
|
if [ "${host}" = "local" ]; then return 0; fi
|
||||||
|
local scan_host="${host#*@}"
|
||||||
|
scan_host="${scan_host%%:*}"
|
||||||
|
mkdir -p "${HOME}/.ssh"
|
||||||
|
chmod 700 "${HOME}/.ssh"
|
||||||
|
if ! ssh-keygen -F "${scan_host}" >/dev/null 2>&1; then
|
||||||
|
ssh-keyscan -H "${scan_host}" >> "${HOME}/.ssh/known_hosts"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ssh_cmd() {
|
||||||
|
ssh \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o ConnectTimeout="${SSH_CONNECT_TIMEOUT}" \
|
||||||
|
-o ServerAliveInterval=15 \
|
||||||
|
-o ServerAliveCountMax=4 \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Syncing source to ${BUILD_HOST}:${BUILD_PATH}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
BUILD_PATH="$(pwd)"
|
||||||
|
echo "Using local source at ${BUILD_PATH}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "mkdir -p '${BUILD_PATH}'"
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.vpn-proxy' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
./ "${BUILD_HOST}:${BUILD_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building image on ${BUILD_HOST}"
|
||||||
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
||||||
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
bash -lc "${BUILD_COMMAND}"
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "${BUILD_COMMAND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Loading image into ${DEPLOY_HOST}"
|
||||||
|
if [ "${BUILD_HOST}" = "local" ] && [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
docker image inspect "${GATEWAY_IMAGE}" >/dev/null
|
||||||
|
elif [ "${BUILD_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
echo "Checking SSH access to ${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "true"
|
||||||
|
echo "Transferring image to ${DEPLOY_HOST}"
|
||||||
|
docker save "${GATEWAY_IMAGE}" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
elif [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | docker load
|
||||||
|
else
|
||||||
|
ensure_known_host "${BUILD_HOST}"
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${BUILD_HOST}" "docker save '${GATEWAY_IMAGE}'" | ssh_cmd "${DEPLOY_HOST}" "docker load"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Copying deploy script to ${DEPLOY_HOST}:${DEPLOY_PATH}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
cp scripts/deploy-gateway.sh "${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_PATH}'"
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=${SSH_CONNECT_TIMEOUT} -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \
|
||||||
|
scripts/deploy-gateway.sh "${DEPLOY_HOST}:${DEPLOY_PATH}/deploy-gateway.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway on ${DEPLOY_HOST}"
|
||||||
|
if [ "${DEPLOY_HOST}" = "local" ]; then
|
||||||
|
cd "${DEPLOY_PATH}"
|
||||||
|
chmod +x ./deploy-gateway.sh
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH}" GATEWAY_IMAGE="${GATEWAY_IMAGE}" PULL_IMAGE=false ./deploy-gateway.sh
|
||||||
|
else
|
||||||
|
ensure_known_host "${DEPLOY_HOST}"
|
||||||
|
ssh_cmd "${DEPLOY_HOST}" \
|
||||||
|
"cd '${DEPLOY_PATH}' && chmod +x ./deploy-gateway.sh && DEPLOY_PATH='${DEPLOY_PATH}' GATEWAY_IMAGE='${GATEWAY_IMAGE}' PULL_IMAGE=false ./deploy-gateway.sh"
|
||||||
|
fi
|
||||||
33
scripts/build-runtime-base.sh
Executable file
33
scripts/build-runtime-base.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.12.13}"
|
||||||
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
|
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||||
|
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||||
|
HTTPS_PROXY="${HTTPS_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTPS Proxy:/ {print $2; exit}')}"
|
||||||
|
NO_PROXY="${NO_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/No Proxy:/ {print $2; exit}')}"
|
||||||
|
|
||||||
|
echo "Building runtime base: ${RUNTIME_BASE_IMAGE}"
|
||||||
|
echo "Source base image: ${BASE_IMAGE}"
|
||||||
|
echo "APT mirror: ${APT_MIRROR}"
|
||||||
|
echo "APT security mirror: ${APT_SECURITY_MIRROR}"
|
||||||
|
if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
||||||
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
--build-arg APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR}" \
|
||||||
|
--build-arg HTTP_PROXY="${HTTP_PROXY}" \
|
||||||
|
--build-arg HTTPS_PROXY="${HTTPS_PROXY}" \
|
||||||
|
--build-arg NO_PROXY="${NO_PROXY}" \
|
||||||
|
--build-arg http_proxy="${HTTP_PROXY}" \
|
||||||
|
--build-arg https_proxy="${HTTPS_PROXY}" \
|
||||||
|
--build-arg no_proxy="${NO_PROXY}" \
|
||||||
|
-f Dockerfile.runtime-base \
|
||||||
|
-t "${RUNTIME_BASE_IMAGE}" \
|
||||||
|
.
|
||||||
75
scripts/deploy-gateway.sh
Normal file
75
scripts/deploy-gateway.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vpn-proxy}"
|
||||||
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:?GATEWAY_IMAGE is required}"
|
||||||
|
PULL_IMAGE="${PULL_IMAGE:-true}"
|
||||||
|
|
||||||
|
echo "Preparing deploy directory: ${DEPLOY_PATH}"
|
||||||
|
mkdir -p "${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
cat > "${DEPLOY_PATH}/docker-compose.server.yml" <<EOF
|
||||||
|
services:
|
||||||
|
vpn-proxy-gateway:
|
||||||
|
image: ${GATEWAY_IMAGE}
|
||||||
|
container_name: vpn-proxy-gateway
|
||||||
|
network_mode: host
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
volumes:
|
||||||
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
|
restart: unless-stopped
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
|
||||||
|
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
|
||||||
|
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
|
||||||
|
TPROXY_BYPASS_SOURCE_CIDRS=
|
||||||
|
ROUTING_RU_DIRECT=true
|
||||||
|
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 "Pulling image: ${GATEWAY_IMAGE}"
|
||||||
|
if [ "${PULL_IMAGE}" = "true" ]; then
|
||||||
|
docker compose -f docker-compose.server.yml pull
|
||||||
|
else
|
||||||
|
echo "Skipping image pull; using local image ${GATEWAY_IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting gateway..."
|
||||||
|
docker compose -f docker-compose.server.yml up -d
|
||||||
|
|
||||||
|
echo "Current container:"
|
||||||
|
docker ps --filter "name=vpn-proxy-gateway"
|
||||||
281
scripts/install-macos-client.sh
Executable file
281
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,281 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}"
|
||||||
|
REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}"
|
||||||
|
BRANCH="${VPN_PROXY_BRANCH:-master}"
|
||||||
|
COMPOSE_FILE="docker-compose.client.yml"
|
||||||
|
DEFAULT_PROXY_PORT="8082"
|
||||||
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
|
CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[vpn-proxy-client] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[vpn-proxy-client] 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'*) ;;
|
||||||
|
*) 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 '[vpn-proxy-client] 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 '[vpn-proxy-client] %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[vpn-proxy-client] client did not become ready at %s\n' "$ui_url" >&2
|
||||||
|
printf '[vpn-proxy-client] docker compose status:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
printf '\n[vpn-proxy-client] recent service logs:\n' >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" logs --tail=120 vpn-proxy-client >&2 || true
|
||||||
|
die "client UI is not ready; see Docker status and logs above"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||||
|
die "this installer is intended for macOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
need git
|
||||||
|
need docker
|
||||||
|
need curl
|
||||||
|
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
|
log "updating $INSTALL_DIR"
|
||||||
|
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||||
|
else
|
||||||
|
log "cloning $REPO_URL#$BRANCH to $INSTALL_DIR"
|
||||||
|
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||||
|
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
log "building and starting Docker client"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
wait_for_client_ui
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
VPN Proxy Client 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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
30
src/server/config.js
Normal file
30
src/server/config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
|
const parsePort = (value, fallback) => {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : fallback;
|
||||||
|
};
|
||||||
|
const proxyPort = parsePort(
|
||||||
|
process.env.PROXY_PORT,
|
||||||
|
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
|
port: parsePort(process.env.PORT, 3456),
|
||||||
|
proxyPort,
|
||||||
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
|
dataDir,
|
||||||
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
|
configPath:
|
||||||
|
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||||
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
|
statePath: path.join(dataDir, "state.json"),
|
||||||
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
|
appName: "VPN Proxy Gateway",
|
||||||
|
};
|
||||||
316
src/server/index.js
Normal file
316
src/server/index.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
import { tcpPing } from './ping.js';
|
||||||
|
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||||
|
import {
|
||||||
|
buildGatewayConfig,
|
||||||
|
removeSingboxConfig,
|
||||||
|
writeSingboxConfig,
|
||||||
|
} from './singbox.js';
|
||||||
|
import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js';
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
let singboxProcess = null;
|
||||||
|
let singboxStartedAt = null;
|
||||||
|
|
||||||
|
function readJson(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath)
|
||||||
|
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
: fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, payload) {
|
||||||
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
let tooLarge = false;
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
tooLarge = true;
|
||||||
|
const error = new Error('Тело запроса слишком большое');
|
||||||
|
error.statusCode = 413;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (tooLarge) return;
|
||||||
|
if (!chunks.length) return resolve({});
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
} catch {
|
||||||
|
const error = new Error('Невалидный JSON в теле запроса');
|
||||||
|
error.statusCode = 400;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscriptionHost(url) {
|
||||||
|
try {
|
||||||
|
return `${new URL(url).host}/…`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSingboxConfig() {
|
||||||
|
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSingbox() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!singboxProcess) {
|
||||||
|
singboxStartedAt = null;
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = singboxProcess;
|
||||||
|
singboxProcess = null;
|
||||||
|
singboxStartedAt = null;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
current.kill('SIGKILL');
|
||||||
|
resolve();
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
current.once('exit', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
current.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSingbox() {
|
||||||
|
if (!fs.existsSync(settings.configPath)) return false;
|
||||||
|
checkSingboxConfig();
|
||||||
|
await stopSingbox();
|
||||||
|
|
||||||
|
const child = spawn('sing-box', ['run', '-c', settings.configPath], {
|
||||||
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
|
});
|
||||||
|
singboxProcess = child;
|
||||||
|
singboxStartedAt = new Date().toISOString();
|
||||||
|
child.once('exit', () => {
|
||||||
|
if (singboxProcess === child) {
|
||||||
|
singboxProcess = null;
|
||||||
|
singboxStartedAt = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicState() {
|
||||||
|
const state = readJson(settings.statePath, {});
|
||||||
|
return {
|
||||||
|
mode: settings.appMode,
|
||||||
|
port: settings.port,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
configExists: fs.existsSync(settings.configPath),
|
||||||
|
singboxRunning: Boolean(singboxProcess),
|
||||||
|
singboxStartedAt,
|
||||||
|
subscriptionHost: subscriptionHost(state.subscriptionUrl),
|
||||||
|
hasSubscription: Boolean(state.subscriptionUrl),
|
||||||
|
selectedTag: state.selectedTag || '',
|
||||||
|
userInfo: state.userInfo || {},
|
||||||
|
fetchedAt: state.fetchedAt || null,
|
||||||
|
servers: (state.servers || []).map((server) => ({
|
||||||
|
...server,
|
||||||
|
tag: String(server.tag || '').trim(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySelectedServer(selectedTag) {
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||||
|
|
||||||
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
|
: null;
|
||||||
|
writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag));
|
||||||
|
try {
|
||||||
|
await startSingbox();
|
||||||
|
} catch (error) {
|
||||||
|
if (previousConfig === null) removeSingboxConfig();
|
||||||
|
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
writeJson(settings.statePath, {
|
||||||
|
...readJson(settings.statePath, {}),
|
||||||
|
selectedTag,
|
||||||
|
appliedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApi(req, res) {
|
||||||
|
if (req.method === 'GET' && req.url === '/api/state') {
|
||||||
|
return sendJson(res, 200, publicState());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
||||||
|
return sendJson(res, 200, buildSharedProxyInfo({
|
||||||
|
appMode: settings.appMode,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
running: Boolean(singboxProcess),
|
||||||
|
hostHeader: req.headers.host,
|
||||||
|
sharedProxyHost: settings.sharedProxyHost,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||||
|
const state = readJson(settings.statePath, {});
|
||||||
|
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||||
|
tag: String(server.tag || '').trim(),
|
||||||
|
...await tcpPing(server.server, server.server_port),
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
})));
|
||||||
|
return sendJson(res, 200, { success: true, results });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||||
|
const { url = '' } = await readBody(req);
|
||||||
|
const normalizedUrl = String(url).trim();
|
||||||
|
const parsed = await fetchSubscription(normalizedUrl);
|
||||||
|
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||||
|
writeJson(settings.statePath, {
|
||||||
|
subscriptionUrl: normalizedUrl,
|
||||||
|
servers: parsed.servers,
|
||||||
|
userInfo: parsed.userInfo,
|
||||||
|
fetchedAt: parsed.fetchedAt,
|
||||||
|
});
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') {
|
||||||
|
const state = readJson(settings.statePath, {});
|
||||||
|
if (!state.subscriptionUrl) {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Подписка не настроена' });
|
||||||
|
}
|
||||||
|
const info = await fetchSubscriptionInfo(state.subscriptionUrl);
|
||||||
|
writeJson(settings.statePath, { ...state, ...info });
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||||
|
return sendJson(res, 200, { success: true, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
|
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||||
|
writeJson(settings.statePath, {});
|
||||||
|
return sendJson(res, 200, { success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||||
|
const { selectedTag = '' } = await readBody(req);
|
||||||
|
const tag = String(selectedTag).trim();
|
||||||
|
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||||
|
await applySelectedServer(tag);
|
||||||
|
return sendJson(res, 200, { success: true, selectedTag: tag });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await stopSingbox();
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
if (!fs.existsSync(settings.configPath)) {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Сначала выберите сервер' });
|
||||||
|
}
|
||||||
|
await startSingbox();
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
const pathname = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
||||||
|
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||||
|
const filePath = path.resolve(settings.distDir, requested);
|
||||||
|
const relative = path.relative(path.resolve(settings.distDir), filePath);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
return res.end('Forbidden');
|
||||||
|
}
|
||||||
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: path.join(settings.distDir, 'index.html');
|
||||||
|
res.writeHead(200, { 'content-type': mime[path.extname(finalPath)] || 'application/octet-stream' });
|
||||||
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
return req.url?.startsWith('/api/')
|
||||||
|
? await handleApi(req, res)
|
||||||
|
: serveStatic(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[control] request failed', error);
|
||||||
|
return sendJson(res, error.statusCode || 500, {
|
||||||
|
success: false,
|
||||||
|
error: error.message || String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
await stopSingbox();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
const state = readJson(settings.statePath, {});
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) {
|
||||||
|
writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag));
|
||||||
|
}
|
||||||
|
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||||
|
|
||||||
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
|
});
|
||||||
50
src/server/ping.js
Normal file
50
src/server/ping.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||||
|
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 3000;
|
||||||
|
|
||||||
|
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||||
|
const start = Date.now();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (result) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
socket.once("connect", () =>
|
||||||
|
finish({ ok: true, latency: Date.now() - start }),
|
||||||
|
);
|
||||||
|
socket.once("timeout", () =>
|
||||||
|
finish({ ok: false, latency: null, error: "timeout" }),
|
||||||
|
);
|
||||||
|
socket.once("error", (err) =>
|
||||||
|
finish({ ok: false, latency: null, error: err.code || err.message }),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.connect(port, host);
|
||||||
|
} catch (err) {
|
||||||
|
finish({ ok: false, latency: null, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveHost(host) {
|
||||||
|
if (net.isIP(host)) return host;
|
||||||
|
try {
|
||||||
|
const result = await dns.lookup(host);
|
||||||
|
return result.address;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/server/sharedProxy.js
Normal file
94
src/server/sharedProxy.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
function normalizeControlUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||||
|
const url = new URL(withProtocol);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
throw new Error("Gateway URL must use http or https");
|
||||||
|
}
|
||||||
|
url.hash = "";
|
||||||
|
url.search = "";
|
||||||
|
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyHostFromHeader(hostHeader) {
|
||||||
|
const raw = String(hostHeader || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
if (raw.startsWith("[")) {
|
||||||
|
const end = raw.indexOf("]");
|
||||||
|
return end > 0 ? raw.slice(1, end) : "";
|
||||||
|
}
|
||||||
|
return raw.split(":")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyInfo(proxy) {
|
||||||
|
if (!proxy || typeof proxy !== "object") return null;
|
||||||
|
const host = String(proxy.host || "").trim();
|
||||||
|
const port = Number.parseInt(proxy.port, 10);
|
||||||
|
const protocol = proxy.protocol === "http" ? "http" : "socks5";
|
||||||
|
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { host, port, protocol };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSharedProxyInfo({
|
||||||
|
appMode,
|
||||||
|
proxyPort,
|
||||||
|
running,
|
||||||
|
hostHeader,
|
||||||
|
sharedProxyHost,
|
||||||
|
}) {
|
||||||
|
const host = String(sharedProxyHost || "").trim() || proxyHostFromHeader(hostHeader);
|
||||||
|
const port = Number.parseInt(proxyPort, 10);
|
||||||
|
const available =
|
||||||
|
appMode === "gateway" &&
|
||||||
|
Boolean(running) &&
|
||||||
|
host &&
|
||||||
|
Number.isInteger(port) &&
|
||||||
|
port > 0 &&
|
||||||
|
port <= 65535;
|
||||||
|
|
||||||
|
const proxy = available
|
||||||
|
? {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: `http://${host}:${port}`,
|
||||||
|
socksUrl: `socks5://${host}:${port}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
available,
|
||||||
|
mode: appMode,
|
||||||
|
proxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
|
||||||
|
const baseUrl = normalizeControlUrl(controlUrl);
|
||||||
|
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `Gateway returned ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!data.available) {
|
||||||
|
throw new Error("Gateway shared proxy is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedProxy = normalizeProxyInfo(data.proxy);
|
||||||
|
if (!sharedProxy) {
|
||||||
|
throw new Error("Gateway returned invalid shared proxy settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxyControlUrl: baseUrl,
|
||||||
|
sharedProxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/server/singbox.js
Normal file
81
src/server/singbox.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
|
||||||
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
|
const MIXED_INBOUND = 'mixed-in';
|
||||||
|
const TPROXY_INBOUND = 'tproxy-in';
|
||||||
|
|
||||||
|
function findOutbound(subscriptionConfig, selectedTag) {
|
||||||
|
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||||
|
? subscriptionConfig.outbounds
|
||||||
|
: [];
|
||||||
|
const tag = String(selectedTag || '').trim();
|
||||||
|
return outbounds.find((outbound) => (
|
||||||
|
String(outbound.tag || '').trim() === tag && PROXY_TYPES.has(outbound.type)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||||
|
const clientMode = settings.appMode === 'client';
|
||||||
|
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||||
|
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
|
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||||
|
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||||
|
vpnOutbound.packet_encoding = 'xudp';
|
||||||
|
}
|
||||||
|
|
||||||
|
const inbounds = [
|
||||||
|
...(!clientMode ? [{
|
||||||
|
type: 'tproxy',
|
||||||
|
tag: TPROXY_INBOUND,
|
||||||
|
listen: '::',
|
||||||
|
listen_port: settings.tproxyPort,
|
||||||
|
sniff: true,
|
||||||
|
sniff_override_destination: true,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
type: 'mixed',
|
||||||
|
tag: MIXED_INBOUND,
|
||||||
|
listen: settings.bindIp,
|
||||||
|
listen_port: settings.proxyPort,
|
||||||
|
sniff: true,
|
||||||
|
set_system_proxy: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rules = clientMode
|
||||||
|
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
|
||||||
|
: [
|
||||||
|
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
|
||||||
|
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
log: { level: settings.logLevel, timestamp: true },
|
||||||
|
experimental: {
|
||||||
|
cache_file: { enabled: true, path: settings.cachePath },
|
||||||
|
},
|
||||||
|
dns: { independent_cache: true },
|
||||||
|
inbounds,
|
||||||
|
outbounds: [
|
||||||
|
vpnOutbound,
|
||||||
|
{ type: 'direct', tag: 'direct' },
|
||||||
|
{ type: 'block', tag: 'block' },
|
||||||
|
],
|
||||||
|
route: {
|
||||||
|
rule_set: [],
|
||||||
|
rules,
|
||||||
|
final: vpnOutbound.tag,
|
||||||
|
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSingboxConfig(config) {
|
||||||
|
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSingboxConfig() {
|
||||||
|
fs.rmSync(settings.configPath, { force: true });
|
||||||
|
}
|
||||||
184
src/server/subscription.js
Normal file
184
src/server/subscription.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { settings } from './config.js';
|
||||||
|
|
||||||
|
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
|
|
||||||
|
export function getHwid() {
|
||||||
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
if (fs.existsSync(settings.hwidPath)) {
|
||||||
|
return fs.readFileSync(settings.hwidPath, 'utf8').trim();
|
||||||
|
}
|
||||||
|
const hwid = crypto.randomBytes(8).toString('hex');
|
||||||
|
fs.writeFileSync(settings.hwidPath, hwid, 'utf8');
|
||||||
|
return hwid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionHeaders() {
|
||||||
|
return {
|
||||||
|
'user-agent': 'singbox',
|
||||||
|
'x-hwid': getHwid(),
|
||||||
|
'x-device-os': process.platform,
|
||||||
|
'x-ver-os': process.version,
|
||||||
|
'x-device-model': settings.appName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserInfo(headerValue) {
|
||||||
|
const result = {};
|
||||||
|
if (!headerValue) return result;
|
||||||
|
|
||||||
|
for (const part of String(headerValue).split(';')) {
|
||||||
|
const [key, value] = part.trim().split('=', 2);
|
||||||
|
if (!key || value === undefined) continue;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(parsed)) result[key] = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVlessUrl(rawUrl) {
|
||||||
|
if (!rawUrl.startsWith('vless://')) {
|
||||||
|
throw new Error('VLESS URL must start with vless://');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out');
|
||||||
|
const uuid = decodeURIComponent(parsed.username || '');
|
||||||
|
const server = parsed.hostname;
|
||||||
|
const serverPort = Number.parseInt(parsed.port || '443', 10);
|
||||||
|
const publicKey = parsed.searchParams.get('pbk') || '';
|
||||||
|
const shortId = parsed.searchParams.get('sid') || '';
|
||||||
|
const serverName = parsed.searchParams.get('sni') || server;
|
||||||
|
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
|
||||||
|
const flow = parsed.searchParams.get('flow') || '';
|
||||||
|
|
||||||
|
if (!uuid || !server || !serverPort) {
|
||||||
|
throw new Error('VLESS URL misses uuid, host or port');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKey || !shortId) {
|
||||||
|
throw new Error('VLESS REALITY parameters pbk and sid are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'vless',
|
||||||
|
tag,
|
||||||
|
server,
|
||||||
|
server_port: serverPort,
|
||||||
|
uuid,
|
||||||
|
flow,
|
||||||
|
tls: {
|
||||||
|
enabled: true,
|
||||||
|
server_name: serverName,
|
||||||
|
utls: {
|
||||||
|
enabled: true,
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
reality: {
|
||||||
|
enabled: true,
|
||||||
|
public_key: publicKey,
|
||||||
|
short_id: shortId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
packet_encoding: 'xudp',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeDecodeBase64(content) {
|
||||||
|
const compact = content.trim().replace(/\s+/g, '');
|
||||||
|
if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(compact, 'base64').toString('utf8');
|
||||||
|
if (decoded.includes('vless://') || decoded.includes('{')) return decoded;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionBody(body) {
|
||||||
|
let parsedConfig = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedConfig = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
const decoded = maybeDecodeBase64(body);
|
||||||
|
const links = decoded
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith('vless://'));
|
||||||
|
|
||||||
|
if (!links.length) {
|
||||||
|
throw new Error('Subscription does not contain JSON config or VLESS links');
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedConfig = {
|
||||||
|
outbounds: links.map(parseVlessUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||||
|
const servers = outbounds
|
||||||
|
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||||
|
.map((outbound) => ({
|
||||||
|
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||||
|
type: outbound.type,
|
||||||
|
server: outbound.server || 'unknown',
|
||||||
|
server_port: outbound.server_port || 443,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!servers.length) {
|
||||||
|
throw new Error('No supported proxy outbounds found in subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: parsedConfig, servers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSubscription(url) {
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid subscription URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||||
|
throw new Error('Subscription URL must use http or https');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(parsedUrl, {
|
||||||
|
headers: subscriptionHeaders(),
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscriptionInfo(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
await response.body?.cancel();
|
||||||
|
return {
|
||||||
|
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||||
|
const parsed = parseSubscriptionBody(body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
userInfo,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
98
src/web/App.jsx
Normal file
98
src/web/App.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './styles.css';
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [state, setState] = useState(null);
|
||||||
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
|
const [servers, setServers] = useState([]);
|
||||||
|
const [pendingTag, setPendingTag] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const data = await api.state();
|
||||||
|
setState(data);
|
||||||
|
setServers(data.servers || []);
|
||||||
|
setPendingTag((current) => current || data.selectedTag || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadState().catch((err) => setError(err.message));
|
||||||
|
const timer = setInterval(() => loadState().catch(() => {}), 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function run(action) {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await loadState();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSubscription() {
|
||||||
|
return run(async () => {
|
||||||
|
const data = await api.subscription.fetch(subscriptionUrl);
|
||||||
|
setServers(data.servers || []);
|
||||||
|
setPendingTag('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSubscriptionInfo() {
|
||||||
|
const data = await api.subscription.refreshInfo();
|
||||||
|
setState((current) => current ? {
|
||||||
|
...current,
|
||||||
|
userInfo: data.userInfo,
|
||||||
|
fetchedAt: data.fetchedAt,
|
||||||
|
} : current);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetSubscription() {
|
||||||
|
if (!confirm('Удалить подписку и остановить VPN?')) return;
|
||||||
|
return run(async () => {
|
||||||
|
await api.subscription.forget();
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setServers([]);
|
||||||
|
setPendingTag('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) return <div className="app-loading">VPN</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app client-app">
|
||||||
|
<div className="app-body client-mode">
|
||||||
|
<main className="app-main">
|
||||||
|
<ClientOverviewPage
|
||||||
|
state={state}
|
||||||
|
busy={busy}
|
||||||
|
error={error}
|
||||||
|
subscriptionUrl={subscriptionUrl}
|
||||||
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
|
servers={servers}
|
||||||
|
pendingTag={pendingTag}
|
||||||
|
setPendingTag={setPendingTag}
|
||||||
|
onFetchSubscription={fetchSubscription}
|
||||||
|
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||||
|
onForgetSubscription={forgetSubscription}
|
||||||
|
onApply={(tag) => run(() => api.apply(tag))}
|
||||||
|
onRestart={() => run(api.singbox.restart)}
|
||||||
|
onStop={() => run(api.singbox.stop)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
37
src/web/api.js
Normal file
37
src/web/api.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
async function request(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data?.success === false) {
|
||||||
|
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
state: () => request('/api/state'),
|
||||||
|
subscription: {
|
||||||
|
fetch: (url) => request('/api/subscription/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
}),
|
||||||
|
refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
|
||||||
|
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
apply: (selectedTag) => request('/api/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ selectedTag }),
|
||||||
|
}),
|
||||||
|
singbox: {
|
||||||
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
servers: {
|
||||||
|
pingAll: () => request('/api/servers/ping-all', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
423
src/web/components/ClientOverviewPage.jsx
Normal file
423
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
formatConnectionDuration,
|
||||||
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
|
} from '../utils/clientControls.js';
|
||||||
|
import { formatBytes } from '../utils/format.js';
|
||||||
|
|
||||||
|
export function ClientOverviewPage({
|
||||||
|
state,
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
servers,
|
||||||
|
pendingTag,
|
||||||
|
setPendingTag,
|
||||||
|
onFetchSubscription,
|
||||||
|
onRefreshSubscriptionInfo,
|
||||||
|
onForgetSubscription,
|
||||||
|
onApply,
|
||||||
|
onRestart,
|
||||||
|
onStop,
|
||||||
|
}) {
|
||||||
|
const isGateway = state?.mode === 'gateway';
|
||||||
|
const connected = Boolean(state?.singboxRunning);
|
||||||
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
|
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||||
|
const showPower = hasSubscription && Boolean(selectedTag);
|
||||||
|
const canStart = Boolean(selectedTag || state?.configExists);
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
|
const [pings, setPings] = useState({});
|
||||||
|
const [copiedProxy, setCopiedProxy] = useState('');
|
||||||
|
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||||
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
|
const subscriptionInputRef = useRef(null);
|
||||||
|
const subscriptionRef = useRef(null);
|
||||||
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
|
const hasUsage = Boolean(
|
||||||
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
if (!connected || !state?.singboxStartedAt) return undefined;
|
||||||
|
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [connected, state?.singboxStartedAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!servers.length) {
|
||||||
|
setPings({});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
|
||||||
|
api.servers.pingAll()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPings(Object.fromEntries((data.results || []).map((ping) => [
|
||||||
|
String(ping.tag || '').trim(),
|
||||||
|
ping,
|
||||||
|
])));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [serverKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||||
|
}, [editingSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSubscription) setEditingSubscription(true);
|
||||||
|
}, [hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||||||
|
const timer = setTimeout(() => setEditingSubscription(false), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription || !hasSubscription) return undefined;
|
||||||
|
const closeOnOutsideClick = (event) => {
|
||||||
|
if (subscriptionRef.current?.contains(event.target)) return;
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state?.hasSubscription) return undefined;
|
||||||
|
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [state?.hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = displayedUsed;
|
||||||
|
const to = usage.used;
|
||||||
|
if (from === to) return undefined;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let frame;
|
||||||
|
const tick = (now) => {
|
||||||
|
const progress = Math.min(1, (now - startedAt) / 900);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 4);
|
||||||
|
setDisplayedUsed(from + (to - from) * eased);
|
||||||
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [usage.used]);
|
||||||
|
|
||||||
|
async function toggleConnection() {
|
||||||
|
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||||
|
if (action?.type === 'stop') return onStop();
|
||||||
|
if (action?.type === 'apply') return onApply(action.selectedTag);
|
||||||
|
if (action?.type === 'restart') return onRestart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectServer(tag) {
|
||||||
|
setPendingTag(tag);
|
||||||
|
if (connected && tag) onApply(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSubscription(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!subscriptionUrl.trim()) return;
|
||||||
|
await onFetchSubscription();
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyProxy(kind) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
|
||||||
|
setCopiedProxy(kind);
|
||||||
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
} catch {
|
||||||
|
setCopiedProxy('error');
|
||||||
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInfo() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
setRefreshingInfo(true);
|
||||||
|
try {
|
||||||
|
await onRefreshSubscriptionInfo();
|
||||||
|
setUsageUpdated(false);
|
||||||
|
requestAnimationFrame(() => setUsageUpdated(true));
|
||||||
|
setTimeout(() => setUsageUpdated(false), 900);
|
||||||
|
setServersLeaving(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
|
||||||
|
setServerRevealVersion((version) => version + 1);
|
||||||
|
setServersLeaving(false);
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||||||
|
setRefreshingInfo(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-shell">
|
||||||
|
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||||
|
{showPower && (
|
||||||
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
|
<button
|
||||||
|
className="client-power"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={connected}
|
||||||
|
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
|
||||||
|
disabled={busy || (!connected && !canStart)}
|
||||||
|
onClick={toggleConnection}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="client-state-copy" aria-live="polite">
|
||||||
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
|
{isGateway
|
||||||
|
? connected ? 'Gateway включён' : 'Gateway выключен'
|
||||||
|
: connected ? 'VPN включён' : 'VPN выключен'}
|
||||||
|
</h2>
|
||||||
|
<div className="client-state-detail">
|
||||||
|
{connected ? (
|
||||||
|
<time key="duration" className="client-duration">
|
||||||
|
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||||
|
</time>
|
||||||
|
) : (
|
||||||
|
<p key="hint">
|
||||||
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
|
||||||
|
{isGateway && (
|
||||||
|
<div className="client-access-point">
|
||||||
|
<span className="client-proxy-label">Gateway</span>
|
||||||
|
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||||
|
<button
|
||||||
|
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
||||||
|
onClick={() => copyProxy('gateway')}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
||||||
|
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-access-point">
|
||||||
|
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
|
||||||
|
<strong className="client-proxy-address">
|
||||||
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
|
</strong>
|
||||||
|
<div className="client-proxy-actions">
|
||||||
|
{[
|
||||||
|
['socks5', 'SOCKS5'],
|
||||||
|
['http', 'HTTP'],
|
||||||
|
].map(([kind, label]) => (
|
||||||
|
<button
|
||||||
|
className={copiedProxy === kind ? 'is-copied' : ''}
|
||||||
|
type="button"
|
||||||
|
key={kind}
|
||||||
|
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||||
|
onClick={() => copyProxy(kind)}
|
||||||
|
>
|
||||||
|
<span className="client-copy-label">{label}</span>
|
||||||
|
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="client-error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
<div className="client-form">
|
||||||
|
<div
|
||||||
|
ref={subscriptionRef}
|
||||||
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="client-subscription-summary"
|
||||||
|
aria-hidden={editingSubscription}
|
||||||
|
inert={editingSubscription ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-subscription-heading">
|
||||||
|
<span>Ваша подписка</span>
|
||||||
|
<button
|
||||||
|
className="client-subscription-refresh"
|
||||||
|
type="button"
|
||||||
|
aria-label="Обновить статистику подписки"
|
||||||
|
title="Обновить статистику"
|
||||||
|
disabled={refreshingInfo}
|
||||||
|
onClick={refreshInfo}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="client-subscription-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label="Удалить подписку"
|
||||||
|
title="Удалить подписку"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onForgetSubscription}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M9 7V4h6v3m-9 0 1 13h10l1-13M10 11v5M14 11v5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="client-subscription-domain-button"
|
||||||
|
type="button"
|
||||||
|
tabIndex={editingSubscription ? -1 : 0}
|
||||||
|
onClick={() => setEditingSubscription(true)}
|
||||||
|
>
|
||||||
|
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="client-subscription-edit"
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden={!editingSubscription}
|
||||||
|
inert={!editingSubscription ? true : undefined}
|
||||||
|
onSubmit={submitSubscription}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={subscriptionInputRef}
|
||||||
|
id="subscription-url"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
tabIndex={editingSubscription ? 0 : -1}
|
||||||
|
aria-label="Ссылка подписки"
|
||||||
|
placeholder="Вставьте ссылку подписки"
|
||||||
|
className={subscriptionUrl ? 'has-value' : ''}
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape' && state?.hasSubscription) {
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{subscriptionUrl && (
|
||||||
|
<span className="client-subscription-domain">
|
||||||
|
{subscriptionDomain(subscriptionUrl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionUrl.trim() && (
|
||||||
|
<button type="submit" aria-label="Сохранить подписку" disabled={busy}>✓</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasSubscription && hasUsage && (
|
||||||
|
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||||
|
<span>Использовано</span>
|
||||||
|
<strong>
|
||||||
|
{formatBytes(displayedUsed)}
|
||||||
|
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||||
|
</strong>
|
||||||
|
{usage.percent !== null && (
|
||||||
|
<div
|
||||||
|
className="client-usage-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Использованный трафик"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(usage.percent)}
|
||||||
|
>
|
||||||
|
<i style={{ width: `${usage.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-usage-details">
|
||||||
|
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||||
|
<span>
|
||||||
|
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||||
|
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSubscription && (
|
||||||
|
<section className="client-servers" aria-label="Выберите сервер">
|
||||||
|
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
|
||||||
|
<div
|
||||||
|
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||||||
|
key={`${serverKey}:${serverRevealVersion}`}
|
||||||
|
>
|
||||||
|
{servers.map((server, index) => {
|
||||||
|
const ping = pings[server.tag];
|
||||||
|
const selected = server.tag === selectedTag;
|
||||||
|
const pingText = ping?.checking
|
||||||
|
? 'Проверка…'
|
||||||
|
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
|
||||||
|
const pingClass = ping?.ok
|
||||||
|
? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||||
|
type="button"
|
||||||
|
key={server.tag}
|
||||||
|
disabled={busy}
|
||||||
|
aria-pressed={selected}
|
||||||
|
style={{ '--server-index': index }}
|
||||||
|
onClick={() => selectServer(server.tag)}
|
||||||
|
>
|
||||||
|
<strong>{server.tag}</strong>
|
||||||
|
<small className={pingClass}>{pingText}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
894
src/web/styles.css
Normal file
894
src/web/styles.css
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
|
||||||
|
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Client overview ============ */
|
||||||
|
|
||||||
|
.app.client-app {
|
||||||
|
--client-bg: oklch(0.965 0.006 145);
|
||||||
|
--client-panel: oklch(0.995 0.003 145);
|
||||||
|
--client-control: oklch(0.955 0.006 145);
|
||||||
|
--client-border: oklch(0.86 0.012 145);
|
||||||
|
--client-text: oklch(0.24 0.014 145);
|
||||||
|
--client-muted: oklch(0.53 0.014 145);
|
||||||
|
--client-accent: oklch(0.62 0.14 151);
|
||||||
|
--client-accent-soft: oklch(0.92 0.045 151);
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
color-scheme: light;
|
||||||
|
background: var(--client-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body.client-mode {
|
||||||
|
background: var(--client-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-mode .app-main {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px 24px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell {
|
||||||
|
width: min(100%, 1100px);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 520px;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel.is-setup .client-form {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section p {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
text-align: center;
|
||||||
|
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-power-arrive {
|
||||||
|
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
|
||||||
|
100% { opacity: 1; filter: blur(0); transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-copy {
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.55);
|
||||||
|
transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']::before {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: scale(1.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:hover:not(:disabled) {
|
||||||
|
transform: translateY(-3px) scale(1.08);
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true'] {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.92);
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power svg {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: drop-shadow(0 0 0 transparent);
|
||||||
|
transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1), transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:hover:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power:active:not(:disabled) svg {
|
||||||
|
transform: scale(0.88);
|
||||||
|
filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent));
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:active:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true'] svg {
|
||||||
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section h2 {
|
||||||
|
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail {
|
||||||
|
min-height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration {
|
||||||
|
display: block;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power-section p {
|
||||||
|
min-height: 0;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-state-reveal {
|
||||||
|
0% { opacity: 0; filter: blur(5px); }
|
||||||
|
100% { opacity: 1; filter: blur(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% + 130px);
|
||||||
|
min-width: 0;
|
||||||
|
width: min(360px, calc(50% - 154px));
|
||||||
|
display: grid;
|
||||||
|
gap: 36px;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 88px;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh,
|
||||||
|
.client-subscription-delete {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh svg,
|
||||||
|
.client-subscription-delete svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:disabled {
|
||||||
|
color: var(--client-accent);
|
||||||
|
cursor: wait;
|
||||||
|
animation: client-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-delete:hover:not(:disabled) {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
filter: drop-shadow(0 0 6px oklch(0.68 0.15 28 / 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-delete:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-delete:focus-visible {
|
||||||
|
outline: 2px solid oklch(0.68 0.15 28);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-editing .client-subscription-refresh,
|
||||||
|
.client-subscription.is-editing .client-subscription-delete {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit,
|
||||||
|
.client-subscription-summary {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit {
|
||||||
|
width: min(100%, 380px);
|
||||||
|
min-height: 44px;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(16px);
|
||||||
|
transform: translateY(6px) scale(0.96);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit::before,
|
||||||
|
.client-subscription-edit::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit::before {
|
||||||
|
background: var(--client-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit::after {
|
||||||
|
background: var(--client-accent);
|
||||||
|
opacity: 0.72;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-timing-out .client-subscription-edit::after {
|
||||||
|
animation: client-subscription-timeout 5s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-subscription-timeout {
|
||||||
|
0% {
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.08;
|
||||||
|
box-shadow: 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-editing .client-subscription-edit {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 42px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
caret-color: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input::placeholder {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input.has-value {
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit input:-webkit-autofill,
|
||||||
|
.client-subscription-edit input:-webkit-autofill:hover,
|
||||||
|
.client-subscription-edit input:-webkit-autofill:focus {
|
||||||
|
box-shadow: 0 0 0 1000px var(--client-bg) inset;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 42px;
|
||||||
|
right: 42px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit button {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-summary {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-editing .client-subscription-summary {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(18px);
|
||||||
|
transform: translateY(-4px) scale(1.06);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-heading > span {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
opacity: 0.88;
|
||||||
|
text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24);
|
||||||
|
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button:hover strong {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage {
|
||||||
|
width: min(100%, 250px);
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: -10px auto 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > span {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated {
|
||||||
|
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated > strong {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated .client-usage-bar i {
|
||||||
|
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-usage-glow {
|
||||||
|
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-bar-flare {
|
||||||
|
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong small {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar {
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--client-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--client-accent);
|
||||||
|
box-shadow: 0 0 8px var(--client-accent);
|
||||||
|
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-servers {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-prompt {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(100%, 210px);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 50px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 7px 4px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--client-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving .client-server {
|
||||||
|
animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-leave {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server.is-selected {
|
||||||
|
border-bottom: 2px solid var(--client-accent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server small {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server small.good { color: var(--client-accent); }
|
||||||
|
.client-server small.medium { color: oklch(0.72 0.12 80); }
|
||||||
|
.client-server small.slow { color: oklch(0.65 0.16 30); }
|
||||||
|
|
||||||
|
.client-server:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit button:focus-visible,
|
||||||
|
.client-subscription-domain-button:focus-visible,
|
||||||
|
.client-server:focus-visible,
|
||||||
|
.client-power:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 220px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxy-label {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxy-address {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-access-point {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-access-point + .client-access-point {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxy-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-error {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
color: oklch(0.62 0.2 28);
|
||||||
|
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button {
|
||||||
|
position: relative;
|
||||||
|
width: 86px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.76;
|
||||||
|
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-copy-label {
|
||||||
|
transition: opacity 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button.is-copied .client-copy-label {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-copy-feedback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--client-accent);
|
||||||
|
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-copy-fade {
|
||||||
|
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
||||||
|
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:hover {
|
||||||
|
color: var(--client-text);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:active {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button.is-copied {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.app.client-app {
|
||||||
|
--client-bg: oklch(0.18 0.012 145);
|
||||||
|
--client-panel: oklch(0.22 0.012 145);
|
||||||
|
--client-control: oklch(0.26 0.012 145);
|
||||||
|
--client-border: oklch(0.34 0.015 145);
|
||||||
|
--client-text: oklch(0.93 0.008 145);
|
||||||
|
--client-muted: oklch(0.68 0.012 145);
|
||||||
|
--client-accent: oklch(0.76 0.13 151);
|
||||||
|
--client-accent-soft: oklch(0.32 0.055 151);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.client-mode .app-main {
|
||||||
|
padding: 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel {
|
||||||
|
position: static;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 26px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-panel.is-setup .client-form {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.client-power,
|
||||||
|
.client-power::before,
|
||||||
|
.client-power svg,
|
||||||
|
.client-usage-bar i,
|
||||||
|
.client-subscription-refresh,
|
||||||
|
.client-subscription-delete,
|
||||||
|
.client-power-section,
|
||||||
|
.client-form,
|
||||||
|
.client-usage,
|
||||||
|
.client-usage > strong,
|
||||||
|
.client-server,
|
||||||
|
.client-proxies button,
|
||||||
|
.client-copy-feedback,
|
||||||
|
.client-subscription-edit,
|
||||||
|
.client-subscription-edit::after,
|
||||||
|
.client-subscription-summary,
|
||||||
|
.client-subscription-summary strong {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-copy h2,
|
||||||
|
.client-state-detail > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/web/utils/clientControls.js
Normal file
65
src/web/utils/clientControls.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export function connectionAction({ connected, selectedTag, configExists }) {
|
||||||
|
if (connected) return { type: 'stop' };
|
||||||
|
if (selectedTag) return { type: 'apply', selectedTag };
|
||||||
|
if (configExists) return { type: 'restart' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConnectionDuration(startedAt, now = Date.now()) {
|
||||||
|
const started = Date.parse(startedAt);
|
||||||
|
const totalSeconds = Number.isFinite(started)
|
||||||
|
? Math.max(0, Math.floor((now - started) / 1000))
|
||||||
|
: 0;
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return [hours, minutes, seconds]
|
||||||
|
.map((part) => String(part).padStart(2, '0'))
|
||||||
|
.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDomain(subscriptionHost) {
|
||||||
|
const value = String(subscriptionHost || '');
|
||||||
|
try {
|
||||||
|
return new URL(value).host;
|
||||||
|
} catch {
|
||||||
|
return value.split('/')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
|
||||||
|
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||||
|
return {
|
||||||
|
socks5: `socks5://${urlHost}:${port}`,
|
||||||
|
http: `http://${urlHost}:${port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
const total = Math.max(0, Number(userInfo.total) || 0);
|
||||||
|
const used = upload + download;
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent: total ? Math.min(100, (used / total) * 100) : null,
|
||||||
|
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
|
||||||
|
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
|
||||||
|
if (!Number.isFinite(days)) return '';
|
||||||
|
if (days <= 0) return 'срок истёк';
|
||||||
|
const mod10 = days % 10;
|
||||||
|
const mod100 = days % 100;
|
||||||
|
const unit = mod10 === 1 && mod100 !== 11
|
||||||
|
? 'день'
|
||||||
|
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
|
||||||
|
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
|
||||||
|
}
|
||||||
31
src/web/utils/format.js
Normal file
31
src/web/utils/format.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function formatBytes(value) {
|
||||||
|
if (!value) return "0 Б";
|
||||||
|
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||||
|
let size = value;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const ts = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(ts)) return "";
|
||||||
|
const diff = Math.max(0, Date.now() - ts);
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec} с назад`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min} мин назад`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr} ч назад`;
|
||||||
|
const days = Math.floor(hr / 24);
|
||||||
|
return `${days} дн назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
|
||||||
|
}
|
||||||
15
test/server/entrypoint-tproxy.test.js
Normal file
15
test/server/entrypoint-tproxy.test.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const entrypoint = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../entrypoint.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('gateway intercepts all public TCP and UDP traffic without source bypasses', () => {
|
||||||
|
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
|
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
|
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
||||||
|
});
|
||||||
55
test/server/shared-proxy.test.js
Normal file
55
test/server/shared-proxy.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildSharedProxyInfo,
|
||||||
|
checkSharedProxyGateway,
|
||||||
|
} = await import("../../src/server/sharedProxy.js");
|
||||||
|
|
||||||
|
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||||
|
const info = buildSharedProxyInfo({
|
||||||
|
appMode: "gateway",
|
||||||
|
proxyPort: 8080,
|
||||||
|
running: true,
|
||||||
|
hostHeader: "192.168.50.111:3456",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(info.available, true);
|
||||||
|
assert.deepEqual(info.proxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
httpUrl: "http://192.168.50.111:8080",
|
||||||
|
socksUrl: "socks5://192.168.50.111:8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client shared proxy check normalizes gateway response into settings patch", async () => {
|
||||||
|
const patch = await checkSharedProxyGateway(
|
||||||
|
"http://192.168.50.111:3456",
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, "http://192.168.50.111:3456/api/shared-proxy");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
available: true,
|
||||||
|
proxy: {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(patch.sharedProxyEnabled, true);
|
||||||
|
assert.equal(patch.sharedProxyControlUrl, "http://192.168.50.111:3456");
|
||||||
|
assert.deepEqual(patch.sharedProxy, {
|
||||||
|
host: "192.168.50.111",
|
||||||
|
port: 8080,
|
||||||
|
protocol: "socks5",
|
||||||
|
});
|
||||||
|
});
|
||||||
34
test/server/singbox-client-mode.test.js
Normal file
34
test/server/singbox-client-mode.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
process.env.APP_MODE = 'client';
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-client-test-'));
|
||||||
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||||
|
|
||||||
|
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?client=${Date.now()}`);
|
||||||
|
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [{
|
||||||
|
type: 'vless',
|
||||||
|
tag: 'test-vpn',
|
||||||
|
server: 'vpn.example.test',
|
||||||
|
server_port: 443,
|
||||||
|
uuid: '00000000-0000-4000-8000-000000000000',
|
||||||
|
tls: { enabled: true },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('client exposes one local proxy and routes it through the selected VPN', () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
||||||
|
|
||||||
|
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), ['mixed-in']);
|
||||||
|
assert.equal(config.inbounds[0].listen_port, 8082);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
|
]);
|
||||||
|
assert.equal(config.route.final, 'test-vpn');
|
||||||
|
assert.equal(config.route.auto_detect_interface, undefined);
|
||||||
|
});
|
||||||
33
test/server/singbox-gateway-mode.test.js
Normal file
33
test/server/singbox-gateway-mode.test.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
process.env.APP_MODE = 'gateway';
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
||||||
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||||
|
|
||||||
|
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?gateway=${Date.now()}`);
|
||||||
|
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [{
|
||||||
|
type: 'vless',
|
||||||
|
tag: 'test-vpn',
|
||||||
|
server: 'vpn.example.test',
|
||||||
|
server_port: 443,
|
||||||
|
uuid: '00000000-0000-4000-8000-000000000000',
|
||||||
|
tls: { enabled: true },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('gateway routes transparent and proxy traffic only through the selected VPN', () => {
|
||||||
|
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn');
|
||||||
|
|
||||||
|
assert.deepEqual(config.route.rule_set, []);
|
||||||
|
assert.deepEqual(config.route.rules, [
|
||||||
|
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
||||||
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
|
]);
|
||||||
|
assert.equal(config.route.final, 'test-vpn');
|
||||||
|
});
|
||||||
12
test/server/subscription.test.js
Normal file
12
test/server/subscription.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { parseSubscriptionBody } from '../../src/server/subscription.js';
|
||||||
|
|
||||||
|
test('subscription server tags are trimmed for selection', () => {
|
||||||
|
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||||
|
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||||
|
});
|
||||||
73
test/web/client-controls.test.js
Normal file
73
test/web/client-controls.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
connectionAction,
|
||||||
|
formatConnectionDuration,
|
||||||
|
localProxyUrls,
|
||||||
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
|
} from '../../src/web/utils/clientControls.js';
|
||||||
|
|
||||||
|
test('connection button chooses the only valid client action', () => {
|
||||||
|
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||||
|
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||||
|
type: 'apply',
|
||||||
|
selectedTag: 'nl-amsterdam',
|
||||||
|
});
|
||||||
|
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||||
|
assert.equal(connectionAction({}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection duration is derived from the backend start time', () => {
|
||||||
|
const startedAt = '2026-07-10T10:00:00.000Z';
|
||||||
|
const now = Date.parse('2026-07-11T12:03:04.900Z');
|
||||||
|
|
||||||
|
assert.equal(formatConnectionDuration(startedAt, now), '26:03:04');
|
||||||
|
assert.equal(formatConnectionDuration(null, now), '00:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved subscription is reduced to its public domain', () => {
|
||||||
|
assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com');
|
||||||
|
assert.equal(subscriptionDomain(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local proxy exposes both supported URLs', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(18080), {
|
||||||
|
socks5: 'socks5://127.0.0.1:18080',
|
||||||
|
http: 'http://127.0.0.1:18080',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local proxy defaults to the macOS client port', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(), {
|
||||||
|
socks5: 'socks5://127.0.0.1:8082',
|
||||||
|
http: 'http://127.0.0.1:8082',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gateway proxy URLs use its network address', () => {
|
||||||
|
assert.deepEqual(localProxyUrls(8080, '192.168.50.111'), {
|
||||||
|
socks5: 'socks5://192.168.50.111:8080',
|
||||||
|
http: 'http://192.168.50.111:8080',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
|
upload: 30,
|
||||||
|
download: 80,
|
||||||
|
total: 100,
|
||||||
|
used: 110,
|
||||||
|
percent: 100,
|
||||||
|
expiresAt: new Date(2000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription expiry uses Russian day forms', () => {
|
||||||
|
const now = Date.parse('2026-07-11T00:00:00Z');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-12T00:00:00Z'), now), 'остался 1 день');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
||||||
|
});
|
||||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: '.',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
709
web/index.html
709
web/index.html
@@ -1,709 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>VPN_CLIENT // SECURE_SHELL</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-neon: #00ff41;
|
|
||||||
--color-bg: #050505;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-neon);
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background-color: var(--color-neon);
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar */
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 255, 65, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(0, 255, 65, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes scanline {
|
|
||||||
0% {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scanline {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(to bottom, transparent 50%, rgba(0, 255, 65, 0.02) 50%);
|
|
||||||
background-size: 100% 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crt-flicker {
|
|
||||||
animation: flicker 0.15s infinite;
|
|
||||||
opacity: 0.1;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: radial-gradient(circle at center, transparent 80%, black 100%);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matrix-bg {
|
|
||||||
background-image: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
|
|
||||||
background-size: 100% 2px, 3px 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utilities */
|
|
||||||
.frame-corner {
|
|
||||||
position: absolute;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-color: var(--color-neon);
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="min-h-screen flex flex-col relative selection:bg-[#00ff41] selection:text-black">
|
|
||||||
|
|
||||||
<!-- CRT Effects -->
|
|
||||||
<div class="matrix-bg fixed inset-0 z-0 pointer-events-none"></div>
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-40 pointer-events-none bg-[radial-gradient(circle_at_50%_50%,rgba(0,255,65,0.03)_0%,transparent_100%)]">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="z-30 border-b border-[#00ff41]/20 bg-black/90 backdrop-blur-md sticky top-0">
|
|
||||||
<div class="max-w-[1400px] mx-auto px-4 md:px-6 py-4 flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="relative p-1 border border-[#00ff41]/50 shadow-[0_0_10px_rgba(0,255,65,0.2)] bg-black">
|
|
||||||
<i data-lucide="terminal" class="w-5 h-5 animate-pulse text-[#00ff41]"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-lg font-black tracking-[0.2em] uppercase text-[#00ff41]">
|
|
||||||
VPN<span class="text-white">_</span>CLIENT
|
|
||||||
</h1>
|
|
||||||
<p class="text-[9px] opacity-40 uppercase tracking-widest text-[#00ff41]">Secure Shell v4.2</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-6 text-[10px] uppercase">
|
|
||||||
<div class="flex flex-col items-end border-r border-[#00ff41]/20 pr-6">
|
|
||||||
<span class="opacity-30 text-[#00ff41]">Status</span>
|
|
||||||
<span id="headerStatus" class="text-white font-bold">STANDBY</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="opacity-30 text-[#00ff41]">Traffic_Used</span>
|
|
||||||
<span id="trafficValue" class="text-blue-400 font-bold">-- / --</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main
|
|
||||||
class="flex-grow max-w-[1400px] w-full mx-auto px-4 md:px-6 py-6 grid grid-cols-1 lg:grid-cols-12 gap-6 relative z-10">
|
|
||||||
|
|
||||||
<!-- Left Column -->
|
|
||||||
<div class="lg:col-span-8 flex flex-col gap-6">
|
|
||||||
|
|
||||||
<!-- Subscription Input -->
|
|
||||||
<div class="flex flex-col gap-2 p-4 bg-[#0a0a0a] border border-[#00ff41]/30 relative group">
|
|
||||||
<!-- Frame Corners -->
|
|
||||||
<div class="absolute top-0 left-0 w-2 h-2 border-t border-l border-[#00ff41]"></div>
|
|
||||||
<div class="absolute top-0 right-0 w-2 h-2 border-t border-r border-[#00ff41]"></div>
|
|
||||||
<div class="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-[#00ff41]"></div>
|
|
||||||
<div class="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-[#00ff41]"></div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="text-[10px] uppercase tracking-widest opacity-50 text-[#00ff41]">Subscription_URL_Input</label>
|
|
||||||
<div class="flex flex-col md:flex-row gap-3">
|
|
||||||
<div class="relative flex-grow">
|
|
||||||
<i data-lucide="link"
|
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#00ff41]/40"></i>
|
|
||||||
<input type="text" id="subUrlInput" placeholder="https://provider.com/api/v1/config?key=..."
|
|
||||||
class="w-full bg-black border border-[#00ff41]/20 py-2.5 pl-10 pr-4 text-xs tracking-wider focus:outline-none focus:border-[#00ff41] transition-all placeholder:text-[#00ff41]/20 text-[#00ff41]" />
|
|
||||||
</div>
|
|
||||||
<button id="fetchServersBtn"
|
|
||||||
class="flex items-center justify-center gap-2 bg-[#00ff41] text-black px-6 py-2.5 text-xs font-black uppercase tracking-widest hover:bg-white hover:shadow-[0_0_15px_rgba(0,255,65,0.4)] transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<i data-lucide="download" class="w-4 h-4" id="fetchIcon"></i>
|
|
||||||
<span id="fetchText">Sync</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server List -->
|
|
||||||
<div
|
|
||||||
class="flex-grow bg-[#0a0a0a]/50 border border-[#00ff41]/10 overflow-hidden flex flex-col relative h-[500px] lg:h-auto">
|
|
||||||
<div class="px-5 py-3 border-b border-[#00ff41]/10 bg-black flex justify-between items-center shrink-0">
|
|
||||||
<span
|
|
||||||
class="text-[10px] uppercase tracking-[0.2em] font-bold text-[#00ff41]">Available_Endpoints</span>
|
|
||||||
<span class="text-[9px] opacity-40 italic text-[#00ff41]">Click row to initiate link</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-auto custom-scrollbar flex-grow p-0">
|
|
||||||
<table class="w-full text-left border-collapse">
|
|
||||||
<tbody id="serverListBody" class="divide-y divide-[#00ff41]/5">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
<tr class="text-[#00ff41]/30">
|
|
||||||
<td colspan="4" class="p-8 text-center text-xs uppercase tracking-widest">
|
|
||||||
No_Data_Stream // Awaiting_Sync
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column -->
|
|
||||||
<div class="lg:col-span-4 flex flex-col gap-6">
|
|
||||||
|
|
||||||
<!-- Tunnel Intelligence (Status) -->
|
|
||||||
<div id="statusBox"
|
|
||||||
class="border-2 border-[#00ff41]/10 bg-black/40 p-6 relative transition-all duration-500 min-h-[200px]">
|
|
||||||
<div class="absolute top-2 right-2 flex gap-1">
|
|
||||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-1"></div>
|
|
||||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-2"></div>
|
|
||||||
<div class="w-1 h-3 border border-[#00ff41]/30 bg-[#00ff41]/10 blink-3"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-[9px] uppercase tracking-widest mb-4 text-[#00ff41]">Tunnel_Intelligence</p>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div id="statusIconContainer"
|
|
||||||
class="w-14 h-14 flex items-center justify-center border border-[#00ff41]/20 rounded-sm">
|
|
||||||
<i data-lucide="shield-alert" class="w-6 h-6 text-[#00ff41]/50"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div id="statusTitle" class="text-2xl font-black italic tracking-tighter text-[#00ff41]/50">
|
|
||||||
NO_SIGNAL
|
|
||||||
</div>
|
|
||||||
<div id="statusSubtitle"
|
|
||||||
class="text-[10px] opacity-50 uppercase tracking-widest text-[#00ff41]">
|
|
||||||
Link not initialized
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="connectionDetails"
|
|
||||||
class="grid grid-cols-2 gap-2 opacity-0 transition-opacity duration-300">
|
|
||||||
<div class="p-2 border border-[#00ff41]/20 bg-black">
|
|
||||||
<div class="text-[8px] opacity-40 uppercase flex items-center gap-1 text-[#00ff41]">
|
|
||||||
<i data-lucide="zap" class="w-2 h-2"></i> Speed
|
|
||||||
<button id="testSpeedBtn" class="ml-auto hover:text-white transition-colors"
|
|
||||||
title="Run Speed Test">
|
|
||||||
<i data-lucide="play" class="w-3 h-3"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="speedValue" class="text-xs font-bold tracking-widest text-[#00ff41]">-- Mb/s</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-2 border border-[#00ff41]/20 bg-black">
|
|
||||||
<div class="text-[8px] opacity-40 uppercase flex items-center gap-1 text-[#00ff41]">
|
|
||||||
<i data-lucide="globe" class="w-2 h-2"></i> IP_ADDR
|
|
||||||
</div>
|
|
||||||
<div id="ipValue" class="text-xs font-bold tracking-widest text-[#00ff41]">HIDDEN</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal Logs -->
|
|
||||||
<div
|
|
||||||
class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono h-[300px] lg:h-[400px]">
|
|
||||||
<div
|
|
||||||
class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
|
||||||
<span
|
|
||||||
class="text-[9px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
|
||||||
<div class="w-1 h-1 bg-[#00ff41] rounded-full animate-ping"></div> Connection_Logs
|
|
||||||
</span>
|
|
||||||
<button id="clearLogs"
|
|
||||||
class="text-[8px] opacity-30 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase">Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="logsContainer"
|
|
||||||
class="flex-grow p-4 overflow-y-auto custom-scrollbar text-[10px] space-y-1.5 opacity-80 font-mono">
|
|
||||||
<!-- Logs populated by JS -->
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="opacity-20 text-[#00ff41]">[SYSTEM]</span>
|
|
||||||
<span class="text-[#00ff41] animate-pulse">_</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="z-30 bg-[#0d0d0d] border-t border-[#00ff41]/10 py-2 mt-auto">
|
|
||||||
<div
|
|
||||||
class="max-w-[1400px] mx-auto px-6 flex justify-between items-center text-[9px] uppercase tracking-[0.2em] opacity-40 text-[#00ff41]">
|
|
||||||
<div class="flex gap-6">
|
|
||||||
<span>Core: 4.1.0-Release</span>
|
|
||||||
<span>Proxy: HTTP/8080</span>
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:flex gap-6">
|
|
||||||
<span>AES-256-GCM</span>
|
|
||||||
<span class="text-[#00ff41] opacity-100 font-bold tracking-normal">SESSION: <span
|
|
||||||
id="sessionId">...</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// --- Icons Initialization ---
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const state = {
|
|
||||||
nodes: [],
|
|
||||||
logs: [],
|
|
||||||
activeNode: null,
|
|
||||||
isFetching: false,
|
|
||||||
isConnecting: false,
|
|
||||||
subscriptionUrl: '',
|
|
||||||
sessionId: Math.random().toString(16).substr(2, 8).toUpperCase(),
|
|
||||||
userInfo: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- DOM Elements ---
|
|
||||||
const els = {
|
|
||||||
subUrlInput: document.getElementById('subUrlInput'),
|
|
||||||
fetchServersBtn: document.getElementById('fetchServersBtn'),
|
|
||||||
fetchIcon: document.getElementById('fetchIcon'),
|
|
||||||
fetchText: document.getElementById('fetchText'),
|
|
||||||
serverListBody: document.getElementById('serverListBody'),
|
|
||||||
statusBox: document.getElementById('statusBox'),
|
|
||||||
statusIconContainer: document.getElementById('statusIconContainer'),
|
|
||||||
statusTitle: document.getElementById('statusTitle'),
|
|
||||||
statusSubtitle: document.getElementById('statusSubtitle'),
|
|
||||||
connectionDetails: document.getElementById('connectionDetails'),
|
|
||||||
speedValue: document.getElementById('speedValue'),
|
|
||||||
ipValue: document.getElementById('ipValue'),
|
|
||||||
logsContainer: document.getElementById('logsContainer'),
|
|
||||||
clearLogs: document.getElementById('clearLogs'),
|
|
||||||
headerStatus: document.getElementById('headerStatus'),
|
|
||||||
sessionId: document.getElementById('sessionId'),
|
|
||||||
trafficValue: document.getElementById('trafficValue'),
|
|
||||||
testSpeedBtn: document.getElementById('testSpeedBtn')
|
|
||||||
};
|
|
||||||
|
|
||||||
els.sessionId.textContent = state.sessionId;
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
function formatBytes(bytes, decimals = 1) {
|
|
||||||
if (!+bytes) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Logger ---
|
|
||||||
function addLog(msg, type = 'info') {
|
|
||||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
|
||||||
|
|
||||||
const logEl = document.createElement('div');
|
|
||||||
logEl.className = 'flex gap-2 items-start leading-tight animate-in fade-in slide-in-from-left-1 duration-300';
|
|
||||||
|
|
||||||
let colorClass = 'text-[#00ff41]/70';
|
|
||||||
let prefix = '>>';
|
|
||||||
if (type === 'success') { colorClass = 'text-blue-400'; prefix = 'OK.'; }
|
|
||||||
if (type === 'warning') { colorClass = 'text-yellow-500'; prefix = '!!'; }
|
|
||||||
if (type === 'error') { colorClass = 'text-red-500'; prefix = 'ERR'; }
|
|
||||||
|
|
||||||
logEl.innerHTML = `
|
|
||||||
<span class="opacity-20 shrink-0 tracking-tighter text-[#00ff41]">[${time}]</span>
|
|
||||||
<span class="${colorClass}">${prefix} ${msg}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
els.logsContainer.insertBefore(logEl, els.logsContainer.lastElementChild);
|
|
||||||
els.logsContainer.scrollTop = els.logsContainer.scrollHeight;
|
|
||||||
|
|
||||||
// Limit logs
|
|
||||||
while (els.logsContainer.children.length > 50) {
|
|
||||||
els.logsContainer.removeChild(els.logsContainer.firstElementChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
els.clearLogs.addEventListener('click', () => {
|
|
||||||
const lastChild = els.logsContainer.lastElementChild;
|
|
||||||
els.logsContainer.innerHTML = '';
|
|
||||||
els.logsContainer.appendChild(lastChild);
|
|
||||||
addLog('LOGS_CLEARED_BY_USER', 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- UI Rendering ---
|
|
||||||
function renderNodes() {
|
|
||||||
els.serverListBody.innerHTML = '';
|
|
||||||
if (state.nodes.length === 0) {
|
|
||||||
els.serverListBody.innerHTML = `
|
|
||||||
<tr class="text-[#00ff41]/30">
|
|
||||||
<td colspan="4" class="p-8 text-center text-xs uppercase tracking-widest">
|
|
||||||
No_Nodes_Found // Sync_Required
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.nodes.forEach((node, index) => {
|
|
||||||
const isActive = state.activeNode && state.activeNode.tag === node.tag;
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.className = `group cursor-pointer transition-all border-b border-[#00ff41]/5 hover:bg-[#00ff41]/5 ${isActive ? 'bg-[#00ff41]/10' : ''}`;
|
|
||||||
tr.onclick = () => handleConnect(node);
|
|
||||||
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td class="p-4 w-12">
|
|
||||||
<div class="w-2 h-2 rounded-full bg-[#00ff41] ${isActive ? 'animate-ping' : ''}"></div>
|
|
||||||
</td>
|
|
||||||
<td class="p-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-xs font-bold text-white uppercase tracking-wider">${node.tag}</span>
|
|
||||||
<span class="text-[9px] opacity-40 uppercase text-[#00ff41]">${node.type} // ${node.server}:${node.port}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-4">
|
|
||||||
<div class="flex flex-col items-start">
|
|
||||||
<span class="text-[9px] opacity-40 uppercase text-[#00ff41]/50">Ping</span>
|
|
||||||
<span id="ping-${index}" class="text-xs font-mono text-[#00ff41]">--</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-4 text-right">
|
|
||||||
${isActive
|
|
||||||
? '<span class="text-[10px] font-black animate-pulse text-white">[ CONNECTED ]</span>'
|
|
||||||
: '<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41] opacity-10 group-hover:opacity-100 group-hover:translate-x-1 transition-all inline-block"></i>'
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
els.serverListBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatusUI() {
|
|
||||||
if (state.activeNode) {
|
|
||||||
// Active Styling
|
|
||||||
els.statusBox.classList.remove('border-[#00ff41]/10', 'bg-black/40');
|
|
||||||
els.statusBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/5', 'shadow-[inset_0_0_20px_rgba(0,255,65,0.05)]');
|
|
||||||
|
|
||||||
// Icon
|
|
||||||
if (state.isConnecting) {
|
|
||||||
els.statusIconContainer.innerHTML = '<i data-lucide="refresh-cw" class="w-6 h-6 text-[#00ff41] animate-spin"></i>';
|
|
||||||
els.statusTitle.textContent = 'HANDSHAKE';
|
|
||||||
els.statusSubtitle.textContent = 'Negotiating keys...';
|
|
||||||
els.headerStatus.textContent = 'NEGOTIATING';
|
|
||||||
} else {
|
|
||||||
els.statusIconContainer.innerHTML = '<i data-lucide="shield-check" class="w-6 h-6 text-[#00ff41]"></i>';
|
|
||||||
els.statusIconContainer.classList.add('shadow-[0_0_15px_#00ff4166]', 'border-[#00ff41]');
|
|
||||||
els.statusTitle.textContent = 'ENCRYPTED';
|
|
||||||
els.statusTitle.classList.remove('text-[#00ff41]/50');
|
|
||||||
els.statusTitle.classList.add('text-[#00ff41]');
|
|
||||||
els.statusSubtitle.textContent = `${state.activeNode.tag} // SECURE`;
|
|
||||||
els.connectionDetails.classList.remove('opacity-0');
|
|
||||||
els.headerStatus.textContent = 'TUNNEL_UP';
|
|
||||||
els.headerStatus.classList.add('text-[#00ff41]');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Inactive Styling
|
|
||||||
els.statusBox.classList.add('border-[#00ff41]/10', 'bg-black/40');
|
|
||||||
els.statusBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/5', 'shadow-[inset_0_0_20px_rgba(0,255,65,0.05)]');
|
|
||||||
|
|
||||||
els.statusIconContainer.innerHTML = '<i data-lucide="shield-alert" class="w-6 h-6 text-[#00ff41]/50"></i>';
|
|
||||||
els.statusIconContainer.classList.remove('shadow-[0_0_15px_#00ff4166]', 'border-[#00ff41]');
|
|
||||||
els.statusTitle.textContent = 'NO_SIGNAL';
|
|
||||||
els.statusTitle.classList.add('text-[#00ff41]/50');
|
|
||||||
els.statusTitle.classList.remove('text-[#00ff41]');
|
|
||||||
els.statusSubtitle.textContent = 'Link not initialized';
|
|
||||||
els.connectionDetails.classList.add('opacity-0');
|
|
||||||
els.headerStatus.textContent = 'STANDBY';
|
|
||||||
els.headerStatus.classList.remove('text-[#00ff41]');
|
|
||||||
|
|
||||||
// Reset stats
|
|
||||||
els.speedValue.textContent = '-- Mb/s';
|
|
||||||
els.ipValue.textContent = 'HIDDEN';
|
|
||||||
}
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTrafficUI(info) {
|
|
||||||
if (!info) return;
|
|
||||||
const used = formatBytes((info.download || 0) + (info.upload || 0));
|
|
||||||
const total = info.total ? formatBytes(info.total) : '∞';
|
|
||||||
els.trafficValue.textContent = `${used} / ${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkServerLatencies(nodes) {
|
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
|
||||||
const node = nodes[i];
|
|
||||||
const pingEl = document.getElementById(`ping-${i}`);
|
|
||||||
if (pingEl) pingEl.textContent = '...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/ping-target', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ server: node.server, port: node.server_port || 443 })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (pingEl) {
|
|
||||||
if (data.latency && data.latency !== -1) {
|
|
||||||
pingEl.textContent = data.latency + 'ms';
|
|
||||||
if (data.latency > 300) pingEl.style.color = 'rgb(239, 68, 68)'; // red
|
|
||||||
else if (data.latency < 100) pingEl.style.color = '#00ff41'; // green
|
|
||||||
else pingEl.style.color = 'rgb(234, 179, 8)'; // yellow
|
|
||||||
} else {
|
|
||||||
pingEl.textContent = 'Timeout';
|
|
||||||
pingEl.style.color = 'rgb(239, 68, 68)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (pingEl) pingEl.textContent = 'Err';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkConnectionSpeed(fullTest = false) {
|
|
||||||
if (fullTest) els.speedValue.textContent = 'TESTING...';
|
|
||||||
else els.speedValue.textContent = 'PAUSED';
|
|
||||||
|
|
||||||
els.ipValue.textContent = '...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/test-connection?speed=${fullTest}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
els.ipValue.textContent = 'ERROR';
|
|
||||||
if (fullTest) els.speedValue.textContent = 'ERROR';
|
|
||||||
} else {
|
|
||||||
els.ipValue.textContent = data.ip;
|
|
||||||
if (data.speed) {
|
|
||||||
els.speedValue.textContent = data.speed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (fullTest) els.speedValue.textContent = 'NET_ERR';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
async function handleFetchNodes() {
|
|
||||||
const url = els.subUrlInput.value.trim();
|
|
||||||
if (!url) {
|
|
||||||
addLog('ERROR_MISSING_URL_INPUT', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.isFetching = true;
|
|
||||||
els.fetchIcon.classList.add('hidden');
|
|
||||||
els.fetchText.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 animate-spin"></i>';
|
|
||||||
lucide.createIcons();
|
|
||||||
|
|
||||||
addLog(`FETCHING_CONFIG: ${url.substring(0, 25)}...`, 'info');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/fetch-subscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success && data.servers) {
|
|
||||||
state.nodes = data.servers;
|
|
||||||
state.config = data.config;
|
|
||||||
state.subscriptionUrl = url;
|
|
||||||
state.userInfo = data.userInfo;
|
|
||||||
|
|
||||||
renderNodes();
|
|
||||||
updateTrafficUI(state.userInfo);
|
|
||||||
addLog(`SYNC_COMPLETE: ${state.nodes.length} Endpoints Retrieved`, 'success');
|
|
||||||
|
|
||||||
// Trigger Ping Check
|
|
||||||
addLog('INITIATING_LATENCY_CHECK...', 'info');
|
|
||||||
checkServerLatencies(state.nodes);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'Unknown Error');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
addLog(`SYNC_FAILED: ${e.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
state.isFetching = false;
|
|
||||||
els.fetchIcon.classList.remove('hidden');
|
|
||||||
els.fetchText.textContent = 'Sync';
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConnect(node) {
|
|
||||||
if (state.activeNode && state.activeNode.tag === node.tag && !state.isConnecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.activeNode = node;
|
|
||||||
state.isConnecting = true;
|
|
||||||
updateStatusUI();
|
|
||||||
renderNodes(); // to update active styling
|
|
||||||
|
|
||||||
addLog(`INITIATING_HANDSHAKE: ${node.tag}`, 'warning');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/apply-subscription', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
config: state.config,
|
|
||||||
selectedServer: node.tag,
|
|
||||||
subUrl: state.subscriptionUrl,
|
|
||||||
userInfo: state.userInfo // save user info if needed
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setTimeout(() => {
|
|
||||||
state.isConnecting = false;
|
|
||||||
updateStatusUI();
|
|
||||||
addLog(`ENCRYPTED_SESSION_ESTABLISHED_${node.tag.toUpperCase()}`, 'success');
|
|
||||||
|
|
||||||
// Check IP but skip Speed Test
|
|
||||||
addLog('VERIFYING_TUNNEL_IP...', 'info');
|
|
||||||
checkConnectionSpeed(false);
|
|
||||||
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
state.isConnecting = false;
|
|
||||||
state.activeNode = null;
|
|
||||||
updateStatusUI();
|
|
||||||
renderNodes();
|
|
||||||
addLog(`HANDSHAKE_FAILED: ${e.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/status');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.active && data.tag) {
|
|
||||||
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
|
||||||
|
|
||||||
if (currentTag !== data.tag) {
|
|
||||||
// Find full node info if available
|
|
||||||
const fullNode = state.nodes.find(n => n.tag === data.tag);
|
|
||||||
|
|
||||||
if (fullNode) {
|
|
||||||
state.activeNode = fullNode;
|
|
||||||
} else {
|
|
||||||
state.activeNode = { tag: data.tag, server: data.server, port: '?', type: 'UNKNOWN' };
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatusUI();
|
|
||||||
renderNodes(); // Update list highlighting
|
|
||||||
|
|
||||||
if (els.speedValue.textContent === '-- Mb/s' || els.ipValue.textContent === 'HIDDEN') {
|
|
||||||
checkConnectionSpeed(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSaved() {
|
|
||||||
try {
|
|
||||||
addLog('SYSTEM_BOOT_SEQUENCE_INITIATED...', 'info');
|
|
||||||
const res = await fetch('/subscription');
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.saved && data.url) {
|
|
||||||
els.subUrlInput.value = data.url;
|
|
||||||
state.userInfo = data.userInfo;
|
|
||||||
updateTrafficUI(state.userInfo);
|
|
||||||
|
|
||||||
// Auto-sync
|
|
||||||
await handleFetchNodes();
|
|
||||||
|
|
||||||
if (data.selectedServer) {
|
|
||||||
const savedNode = state.nodes.find(n => n.tag === data.selectedServer);
|
|
||||||
if (savedNode) {
|
|
||||||
// Will be handled by fetchStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addLog('NO_SAVED_CONFIG_FOUND', 'warning');
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchStatus();
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
addLog('SYSTEM_BOOT_ERROR', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Listeners ---
|
|
||||||
els.fetchServersBtn.addEventListener('click', handleFetchNodes);
|
|
||||||
|
|
||||||
els.testSpeedBtn.addEventListener('click', () => {
|
|
||||||
addLog('MANUAL_SPEED_TEST_INITIATED', 'info');
|
|
||||||
checkConnectionSpeed(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Init ---
|
|
||||||
addLog('TERMINAL_READY', 'info');
|
|
||||||
loadSaved();
|
|
||||||
|
|
||||||
// Blink animation style
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.blink-1 { animation: blink 1s infinite; }
|
|
||||||
.blink-2 { animation: blink 1s infinite 0.2s; }
|
|
||||||
.blink-3 { animation: blink 1s infinite 0.4s; }
|
|
||||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
746
web/server.py
746
web/server.py
@@ -1,746 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple HTTP Web Server for VPN Proxy Control
|
|
||||||
Provides a web UI to manage sing-box subscriptions
|
|
||||||
"""
|
|
||||||
|
|
||||||
import http.server
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import socketserver
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
import uuid
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
from urllib.parse import parse_qs, unquote
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PORT = int(os.environ.get("PORT", 3456))
|
|
||||||
PROXY_PORT = int(os.environ.get("PROXY_PORT", 8080))
|
|
||||||
APP_NAME = "VPN-Proxy-Control by Dokril"
|
|
||||||
APP_DIR = Path(__file__).parent
|
|
||||||
BASE_DIR = APP_DIR.parent
|
|
||||||
WEB_DIR = APP_DIR
|
|
||||||
DATA_DIR = BASE_DIR / "data"
|
|
||||||
CONFIG_FILE = DATA_DIR / "client.json"
|
|
||||||
HWID_FILE = DATA_DIR / "hwid"
|
|
||||||
SUBSCRIPTION_FILE = DATA_DIR / "subscription.json"
|
|
||||||
|
|
||||||
|
|
||||||
def get_hwid() -> str:
|
|
||||||
"""Get or generate hardware ID"""
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if HWID_FILE.exists():
|
|
||||||
return HWID_FILE.read_text().strip()
|
|
||||||
|
|
||||||
# Generate new random HWID
|
|
||||||
hwid = uuid.uuid4().hex[:16]
|
|
||||||
HWID_FILE.write_text(hwid)
|
|
||||||
return hwid
|
|
||||||
|
|
||||||
|
|
||||||
def get_system_info() -> dict:
|
|
||||||
"""Get system information for headers"""
|
|
||||||
system = platform.system().lower() # windows, linux, darwin
|
|
||||||
version = platform.release() # 10, 5.15.0, 22.0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"os": system,
|
|
||||||
"version": version
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def save_subscription(url: str, selected_server: str = None, user_info: dict = None):
|
|
||||||
"""Save subscription URL, selected server and user info to file"""
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
data = {
|
|
||||||
"url": url,
|
|
||||||
"selectedServer": selected_server,
|
|
||||||
"userInfo": user_info
|
|
||||||
}
|
|
||||||
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def load_subscription() -> dict:
|
|
||||||
"""Load subscription from file"""
|
|
||||||
if SUBSCRIPTION_FILE.exists():
|
|
||||||
try:
|
|
||||||
return json.loads(SUBSCRIPTION_FILE.read_text())
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def measure_tcp_latency(host: str, port: int, timeout: float = 2.0) -> int:
|
|
||||||
"""Measure TCP latency to a host:port in milliseconds"""
|
|
||||||
start_time = time.time()
|
|
||||||
try:
|
|
||||||
with socket.create_connection((host, port), timeout=timeout):
|
|
||||||
latency = (time.time() - start_time) * 1000
|
|
||||||
return int(latency)
|
|
||||||
except Exception:
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
def measure_proxy_performance(enable_speed_test: bool = False) -> dict:
|
|
||||||
"""Measure proxy latency, speed and public IP via local proxy"""
|
|
||||||
proxy_url = f"http://127.0.0.1:{PROXY_PORT}"
|
|
||||||
proxies = {"http": proxy_url, "https": proxy_url}
|
|
||||||
|
|
||||||
# 1. Measure Latency (Ping)
|
|
||||||
latency = "Timeout"
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
# Use a reliable endpoint for ping
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
|
||||||
req = urllib.request.Request("http://www.gstatic.com/generate_204", headers={"User-Agent": "singbox-test"})
|
|
||||||
with opener.open(req, timeout=5) as response:
|
|
||||||
lat_ms = int((time.time() - start_time) * 1000)
|
|
||||||
latency = f"{lat_ms}ms"
|
|
||||||
except Exception as e:
|
|
||||||
latency = "Error"
|
|
||||||
|
|
||||||
# 2. Get Public IP (IPv4)
|
|
||||||
ip = "Unknown"
|
|
||||||
try:
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
|
||||||
# Use v4.ident.me to force IPv4
|
|
||||||
req = urllib.request.Request("http://v4.ident.me", headers={"User-Agent": "curl/7.68.0"})
|
|
||||||
with opener.open(req, timeout=5) as response:
|
|
||||||
ip = response.read().decode('utf-8').strip()
|
|
||||||
except Exception:
|
|
||||||
# Fallback to ipify if ident.me fails or returns garbage
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request("http://api.ipify.org", headers={"User-Agent": "curl/7.68.0"})
|
|
||||||
with opener.open(req, timeout=5) as response:
|
|
||||||
ip = response.read().decode('utf-8').strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Measure Download Speed
|
|
||||||
speed_mbps = 0.0
|
|
||||||
if enable_speed_test:
|
|
||||||
test_files = [
|
|
||||||
# Tele2 Speedtest (Usually very reliable and fast)
|
|
||||||
("https://speedtest.selectel.ru/100MB", 100),
|
|
||||||
# ThinkBroadband (Reliable backup)
|
|
||||||
("https://speedtest.selectel.ru/1GB", 1000)
|
|
||||||
]
|
|
||||||
|
|
||||||
for url, size_mb in test_files:
|
|
||||||
try:
|
|
||||||
print(f"[WebUI] Testing speed with: {url}")
|
|
||||||
start_time = time.time()
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
|
||||||
# Set a longer timeout for speed tests
|
|
||||||
with opener.open(url, timeout=30) as response:
|
|
||||||
downloaded = 0
|
|
||||||
# Larger chunk size for better throughput measurement
|
|
||||||
chunk_size = 1024 * 256 # 256KB chunks
|
|
||||||
|
|
||||||
# Download for at least 2 seconds or up to 25MB for accurate measurement
|
|
||||||
min_test_duration = 2.0 # seconds
|
|
||||||
max_download_bytes = 25 * 1024 * 1024 # 25MB
|
|
||||||
|
|
||||||
while True:
|
|
||||||
chunk = response.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
downloaded += len(chunk)
|
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
# Stop if we've downloaded enough AND tested for minimum duration
|
|
||||||
if downloaded >= max_download_bytes or (elapsed >= min_test_duration and downloaded >= 2 * 1024 * 1024):
|
|
||||||
break
|
|
||||||
|
|
||||||
duration = time.time() - start_time
|
|
||||||
if duration > 0.1 and downloaded > 0:
|
|
||||||
# Calculate speed in Mbps (megabits per second)
|
|
||||||
# downloaded bytes * 8 bits/byte / 1,000,000 / seconds
|
|
||||||
speed_mbps = round((downloaded * 8) / (1000 * 1000) / duration, 1)
|
|
||||||
print(f"[WebUI] Speed test: downloaded {downloaded / (1024*1024):.1f}MB in {duration:.1f}s = {speed_mbps} Mbps")
|
|
||||||
break # Stop if successful
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WebUI] Speed test failed for {url}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"latency": latency,
|
|
||||||
"ip": ip
|
|
||||||
}
|
|
||||||
|
|
||||||
if enable_speed_test:
|
|
||||||
# If speed is still 0.0 but we tried, return Error or 0.0
|
|
||||||
result["speed"] = f"{speed_mbps} Mbps"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def parse_vless_url(url: str) -> dict:
|
|
||||||
"""Parse VLESS URL and extract connection parameters"""
|
|
||||||
if not url.startswith("vless://"):
|
|
||||||
raise ValueError("URL must start with vless://")
|
|
||||||
|
|
||||||
# Remove scheme
|
|
||||||
url_no_scheme = url[8:]
|
|
||||||
|
|
||||||
# Split by fragment (#tag)
|
|
||||||
if '#' in url_no_scheme:
|
|
||||||
url_part, tag = url_no_scheme.split('#', 1)
|
|
||||||
tag = unquote(tag)
|
|
||||||
else:
|
|
||||||
url_part = url_no_scheme
|
|
||||||
tag = "reality"
|
|
||||||
|
|
||||||
# Split by query (?)
|
|
||||||
if '?' in url_part:
|
|
||||||
uuid_host_port, query_string = url_part.split('?', 1)
|
|
||||||
else:
|
|
||||||
raise ValueError("Missing query parameters")
|
|
||||||
|
|
||||||
# Parse UUID@host:port
|
|
||||||
if '@' not in uuid_host_port:
|
|
||||||
raise ValueError("Missing @ separator")
|
|
||||||
|
|
||||||
uuid_str, host_port = uuid_host_port.split('@', 1)
|
|
||||||
|
|
||||||
if ':' not in host_port:
|
|
||||||
raise ValueError("Missing port")
|
|
||||||
|
|
||||||
host, port_str = host_port.rsplit(':', 1)
|
|
||||||
port = int(port_str)
|
|
||||||
|
|
||||||
# Parse query parameters
|
|
||||||
params = {}
|
|
||||||
for param in query_string.split('&'):
|
|
||||||
if '=' in param:
|
|
||||||
key, value = param.split('=', 1)
|
|
||||||
params[key] = unquote(value)
|
|
||||||
|
|
||||||
# Extract required parameters
|
|
||||||
pbk = params.get('pbk', '')
|
|
||||||
sid = params.get('sid', '')
|
|
||||||
sni = params.get('sni', host)
|
|
||||||
fp = params.get('fp', 'chrome')
|
|
||||||
flow = params.get('flow', '')
|
|
||||||
|
|
||||||
if not pbk or not sid:
|
|
||||||
raise ValueError("Missing required parameters: pbk or sid")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'uuid': uuid_str,
|
|
||||||
'server': host,
|
|
||||||
'server_port': port,
|
|
||||||
'tag': tag,
|
|
||||||
'public_key': pbk,
|
|
||||||
'short_id': sid,
|
|
||||||
'server_name': sni,
|
|
||||||
'fingerprint': fp,
|
|
||||||
'flow': flow
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_vless_config(vless_params: dict) -> dict:
|
|
||||||
"""Generate sing-box configuration from VLESS parameters"""
|
|
||||||
config = {
|
|
||||||
"dns": {
|
|
||||||
"independent_cache": True
|
|
||||||
},
|
|
||||||
"log": {
|
|
||||||
"level": "debug",
|
|
||||||
"disabled": True,
|
|
||||||
"timestamp": True
|
|
||||||
},
|
|
||||||
"route": {
|
|
||||||
"final": vless_params['tag'],
|
|
||||||
"auto_detect_interface": True
|
|
||||||
},
|
|
||||||
"inbounds": [
|
|
||||||
{
|
|
||||||
"tag": "mixed-in",
|
|
||||||
"type": "mixed",
|
|
||||||
"sniff": True,
|
|
||||||
"users": [],
|
|
||||||
"listen": "0.0.0.0",
|
|
||||||
"listen_port": PROXY_PORT,
|
|
||||||
"set_system_proxy": False
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outbounds": [
|
|
||||||
{
|
|
||||||
"type": "vless",
|
|
||||||
"tag": vless_params['tag'],
|
|
||||||
"server": vless_params['server'],
|
|
||||||
"server_port": vless_params['server_port'],
|
|
||||||
"flow": vless_params['flow'],
|
|
||||||
"tls": {
|
|
||||||
"enabled": True,
|
|
||||||
"server_name": vless_params['server_name'],
|
|
||||||
"reality": {
|
|
||||||
"enabled": True,
|
|
||||||
"public_key": vless_params['public_key'],
|
|
||||||
"short_id": vless_params['short_id']
|
|
||||||
},
|
|
||||||
"utls": {
|
|
||||||
"enabled": True,
|
|
||||||
"fingerprint": vless_params['fingerprint']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uuid": vless_params['uuid']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "direct",
|
|
||||||
"type": "direct"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadingHTTPServer(socketserver.ThreadingTCPServer):
|
|
||||||
allow_reuse_address = True
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
"""HTTP Request Handler for Proxy Control"""
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
"""Override to add timestamp prefix"""
|
|
||||||
print(f"[WebUI] {args[0]}")
|
|
||||||
|
|
||||||
def send_json(self, data: dict, status: int = 200):
|
|
||||||
"""Send JSON response"""
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
|
||||||
|
|
||||||
def send_html(self, content: bytes):
|
|
||||||
"""Send HTML response"""
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(content)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
"""Handle GET requests"""
|
|
||||||
if self.path == "/" or self.path == "/index.html":
|
|
||||||
self.serve_index()
|
|
||||||
elif self.path == "/status":
|
|
||||||
self.get_status()
|
|
||||||
elif self.path == "/subscription":
|
|
||||||
self.get_subscription()
|
|
||||||
elif self.path.startswith("/test-connection"):
|
|
||||||
self.test_connection()
|
|
||||||
elif self.path.startswith("/static/"):
|
|
||||||
self.serve_static()
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
"""Handle POST requests"""
|
|
||||||
if self.path == "/apply":
|
|
||||||
self.apply_config()
|
|
||||||
elif self.path == "/fetch-subscription":
|
|
||||||
self.fetch_subscription()
|
|
||||||
elif self.path == "/apply-subscription":
|
|
||||||
self.apply_subscription()
|
|
||||||
elif self.path == "/ping-target":
|
|
||||||
self.ping_target()
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
def serve_index(self):
|
|
||||||
"""Serve main HTML page"""
|
|
||||||
index_path = WEB_DIR / "index.html"
|
|
||||||
if index_path.exists():
|
|
||||||
self.send_html(index_path.read_bytes())
|
|
||||||
else:
|
|
||||||
self.send_error(404, "index.html not found")
|
|
||||||
|
|
||||||
def serve_static(self):
|
|
||||||
"""Serve static files"""
|
|
||||||
file_path = WEB_DIR / self.path[8:] # Remove /static/
|
|
||||||
if file_path.exists() and file_path.is_file():
|
|
||||||
content_type = "text/css" if str(file_path).endswith(".css") else "application/javascript"
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", content_type)
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(file_path.read_bytes())
|
|
||||||
else:
|
|
||||||
self.send_error(404)
|
|
||||||
|
|
||||||
def get_status(self):
|
|
||||||
"""Get current proxy status"""
|
|
||||||
config_exists = CONFIG_FILE.exists()
|
|
||||||
current_tag = None
|
|
||||||
current_server = None
|
|
||||||
|
|
||||||
if config_exists:
|
|
||||||
try:
|
|
||||||
config = json.loads(CONFIG_FILE.read_text())
|
|
||||||
for outbound in config.get("outbounds", []):
|
|
||||||
if outbound.get("type") == "vless":
|
|
||||||
current_tag = outbound.get("tag", "unknown")
|
|
||||||
current_server = outbound.get("server", "unknown")
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.send_json({
|
|
||||||
"active": config_exists,
|
|
||||||
"tag": current_tag,
|
|
||||||
"server": current_server
|
|
||||||
})
|
|
||||||
|
|
||||||
def get_subscription(self):
|
|
||||||
"""Get saved subscription info"""
|
|
||||||
sub = load_subscription()
|
|
||||||
if sub:
|
|
||||||
self.send_json({
|
|
||||||
"saved": True,
|
|
||||||
"url": sub.get("url"),
|
|
||||||
"selectedServer": sub.get("selectedServer"),
|
|
||||||
"userInfo": sub.get("userInfo")
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
self.send_json({"saved": False})
|
|
||||||
|
|
||||||
def test_connection(self):
|
|
||||||
"""Test active proxy connection"""
|
|
||||||
query_components = {}
|
|
||||||
if '?' in self.path:
|
|
||||||
_, query = self.path.split('?', 1)
|
|
||||||
query_components = parse_qs(query)
|
|
||||||
|
|
||||||
enable_speed = query_components.get('speed', ['false'])[0].lower() == 'true'
|
|
||||||
|
|
||||||
result = measure_proxy_performance(enable_speed_test=enable_speed)
|
|
||||||
self.send_json(result)
|
|
||||||
|
|
||||||
def ping_target(self):
|
|
||||||
"""Ping a specific target"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode("utf-8")
|
|
||||||
data = json.loads(body)
|
|
||||||
|
|
||||||
server = data.get("server")
|
|
||||||
port = int(data.get("port", 443))
|
|
||||||
|
|
||||||
if not server:
|
|
||||||
self.send_json({"error": "No server specified"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
latency = measure_tcp_latency(server, port)
|
|
||||||
self.send_json({"latency": latency})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.send_json({"error": str(e)}, 500)
|
|
||||||
|
|
||||||
def apply_config(self):
|
|
||||||
"""Apply new config from VLESS URL"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode("utf-8")
|
|
||||||
data = json.loads(body)
|
|
||||||
url = data.get("url", "").strip()
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
self.send_json({"success": False, "error": "URL не указан"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not url.startswith("vless://"):
|
|
||||||
self.send_json({"success": False, "error": "Неверный формат. Поддерживаются только vless:// ссылки"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse VLESS URL
|
|
||||||
try:
|
|
||||||
vless_params = parse_vless_url(url)
|
|
||||||
except ValueError as e:
|
|
||||||
self.send_json({"success": False, "error": f"Ошибка парсинга URL: {str(e)}"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Generate config
|
|
||||||
config = generate_vless_config(vless_params)
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Write config file
|
|
||||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
# Trigger reload via internal control port
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
|
||||||
|
|
||||||
self.send_json({
|
|
||||||
"success": True,
|
|
||||||
"message": f"Конфигурация '{vless_params['tag']}' успешно применена!"
|
|
||||||
})
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
|
||||||
except Exception as e:
|
|
||||||
self.send_json({"success": False, "error": str(e)}, 500)
|
|
||||||
|
|
||||||
def fetch_subscription(self):
|
|
||||||
"""Fetch servers list from subscription URL"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode("utf-8")
|
|
||||||
data = json.loads(body)
|
|
||||||
url = data.get("url", "").strip()
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
self.send_json({"success": False, "error": "URL подписки не указан"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fetch subscription config
|
|
||||||
sys_info = get_system_info()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
headers={
|
|
||||||
"User-Agent": "singbox",
|
|
||||||
"x-hwid": get_hwid(),
|
|
||||||
"x-device-os": sys_info["os"],
|
|
||||||
"x-ver-os": sys_info["version"],
|
|
||||||
"x-device-model": APP_NAME
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
config = None
|
|
||||||
config_text = ""
|
|
||||||
user_info = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as response:
|
|
||||||
config_text = response.read().decode("utf-8")
|
|
||||||
|
|
||||||
# Parse User Info header
|
|
||||||
user_info_header = response.headers.get("subscription-userinfo", "")
|
|
||||||
if user_info_header:
|
|
||||||
parts = user_info_header.split(';')
|
|
||||||
for part in parts:
|
|
||||||
if '=' in part:
|
|
||||||
key, value = part.strip().split('=', 1)
|
|
||||||
try:
|
|
||||||
user_info[key] = int(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
|
||||||
return
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
self.send_json({"success": False, "error": f"Ошибка подключения: {e.reason}"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to parse as JSON first
|
|
||||||
try:
|
|
||||||
config = json.loads(config_text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Not JSON - try Base64 decode or plain text VLESS links
|
|
||||||
content = config_text.strip()
|
|
||||||
|
|
||||||
# Try Base64 decode
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
try:
|
|
||||||
# Check if it looks like Base64
|
|
||||||
if re.match(r'^[A-Za-z0-9+/=\s]+$', content):
|
|
||||||
decoded = base64.b64decode(content).decode('utf-8')
|
|
||||||
content = decoded
|
|
||||||
except Exception:
|
|
||||||
pass # Not Base64, continue with original content
|
|
||||||
|
|
||||||
# Parse VLESS links
|
|
||||||
lines = content.strip().split('\n')
|
|
||||||
vless_links = [line.strip() for line in lines if line.strip().startswith('vless://')]
|
|
||||||
|
|
||||||
if not vless_links:
|
|
||||||
self.send_json({"success": False, "error": "Не найдены VLESS ссылки в ответе"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse each VLESS link and create outbounds
|
|
||||||
outbounds = []
|
|
||||||
for link in vless_links:
|
|
||||||
try:
|
|
||||||
params = parse_vless_url(link)
|
|
||||||
outbound = {
|
|
||||||
"type": "vless",
|
|
||||||
"tag": params['tag'],
|
|
||||||
"server": params['server'],
|
|
||||||
"server_port": params['server_port'],
|
|
||||||
"uuid": params['uuid'],
|
|
||||||
"flow": params['flow'],
|
|
||||||
"tls": {
|
|
||||||
"enabled": True,
|
|
||||||
"server_name": params['server_name'],
|
|
||||||
"utls": {"enabled": True, "fingerprint": params['fingerprint']},
|
|
||||||
"reality": {
|
|
||||||
"enabled": True,
|
|
||||||
"public_key": params['public_key'],
|
|
||||||
"short_id": params['short_id']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packet_encoding": "xudp"
|
|
||||||
}
|
|
||||||
outbounds.append(outbound)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WebUI] Failed to parse VLESS link: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not outbounds:
|
|
||||||
self.send_json({"success": False, "error": "Не удалось распарсить VLESS ссылки"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a mock config with parsed outbounds
|
|
||||||
config = {"outbounds": outbounds}
|
|
||||||
|
|
||||||
# Extract outbound servers
|
|
||||||
outbounds = config.get("outbounds", [])
|
|
||||||
servers = []
|
|
||||||
|
|
||||||
for outbound in outbounds:
|
|
||||||
if outbound.get("type") in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
|
||||||
servers.append({
|
|
||||||
"tag": outbound.get("tag", "unknown"),
|
|
||||||
"type": outbound.get("type"),
|
|
||||||
"server": outbound.get("server", "unknown"),
|
|
||||||
"server_port": outbound.get("server_port", 443)
|
|
||||||
})
|
|
||||||
|
|
||||||
if not servers:
|
|
||||||
self.send_json({"success": False, "error": "Серверы не найдены в подписке"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_json({
|
|
||||||
"success": True,
|
|
||||||
"servers": servers,
|
|
||||||
"config": config,
|
|
||||||
"userInfo": user_info
|
|
||||||
})
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.send_json({"success": False, "error": "Неверный JSON в ответе"}, 400)
|
|
||||||
except Exception as e:
|
|
||||||
self.send_json({"success": False, "error": str(e)}, 500)
|
|
||||||
|
|
||||||
def apply_subscription(self):
|
|
||||||
"""Apply config from subscription with selected server"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
body = self.rfile.read(content_length).decode("utf-8")
|
|
||||||
data = json.loads(body)
|
|
||||||
|
|
||||||
config = data.get("config")
|
|
||||||
selected_tag = data.get("selectedServer")
|
|
||||||
sub_url = data.get("subUrl") # URL подписки для сохранения
|
|
||||||
user_info = data.get("userInfo")
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not selected_tag:
|
|
||||||
self.send_json({"success": False, "error": "Сервер не выбран"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Modify config to use only selected server
|
|
||||||
outbounds = config.get("outbounds", [])
|
|
||||||
new_outbounds = []
|
|
||||||
selected_outbound = None
|
|
||||||
|
|
||||||
for outbound in outbounds:
|
|
||||||
if outbound.get("tag") == selected_tag:
|
|
||||||
selected_outbound = outbound
|
|
||||||
elif outbound.get("type") in ["direct", "block", "dns"]:
|
|
||||||
new_outbounds.append(outbound)
|
|
||||||
elif outbound.get("type") == "selector":
|
|
||||||
# Skip selector, we'll add selected server directly
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not selected_outbound:
|
|
||||||
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add selected server as main outbound
|
|
||||||
new_outbounds.insert(0, selected_outbound)
|
|
||||||
|
|
||||||
# Update route - remove incompatible fields and set only final
|
|
||||||
routes = {
|
|
||||||
"final": selected_tag,
|
|
||||||
"auto_detect_interface": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Simplify DNS configuration to match client.json format
|
|
||||||
config["dns"] = {
|
|
||||||
"independent_cache": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove platform-specific and experimental fields from root config
|
|
||||||
config.pop("platform", None)
|
|
||||||
config.pop("experimental", None)
|
|
||||||
|
|
||||||
# Replace TUN inbounds with mixed proxy
|
|
||||||
config["inbounds"] = [
|
|
||||||
{
|
|
||||||
"tag": "mixed-in",
|
|
||||||
"type": "mixed",
|
|
||||||
"sniff": True,
|
|
||||||
"users": [],
|
|
||||||
"listen": "0.0.0.0",
|
|
||||||
"listen_port": PROXY_PORT,
|
|
||||||
"set_system_proxy": False
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
config["outbounds"] = new_outbounds
|
|
||||||
config["route"] = routes
|
|
||||||
|
|
||||||
# Ensure data directory exists
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Write config file
|
|
||||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
# Save subscription URL for persistence
|
|
||||||
if sub_url:
|
|
||||||
save_subscription(sub_url, selected_tag, user_info)
|
|
||||||
|
|
||||||
# Trigger reload via internal control port
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
|
||||||
|
|
||||||
self.send_json({
|
|
||||||
"success": True,
|
|
||||||
"message": f"Сервер '{selected_tag}' успешно применён!"
|
|
||||||
})
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
|
||||||
except Exception as e:
|
|
||||||
self.send_json({"success": False, "error": str(e)}, 500)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Start the web server"""
|
|
||||||
# Use ThreadingTCPServer for concurrent requests
|
|
||||||
with ThreadingHTTPServer(("", PORT), ProxyControlHandler) as httpd:
|
|
||||||
print(f"[WebUI] Server started on port {PORT}")
|
|
||||||
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
|
||||||
httpd.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user