Compare commits
80 Commits
1.0.0
...
7dbf786c56
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.vpn-proxy
|
||||||
|
_archive
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
PORT=3456
|
||||||
|
APP_MODE=gateway
|
||||||
|
CLIENT_UI_PORT=3456
|
||||||
|
CLIENT_PROXY_PORT_START=8080
|
||||||
|
CLIENT_PROXY_PORT_END=8090
|
||||||
|
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
|
||||||
|
ROUTING_RU_DIRECT=true
|
||||||
|
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
|
||||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,2 +1,26 @@
|
|||||||
data
|
# Local archive with the previous implementation and runtime secrets
|
||||||
_legacy
|
_archive/
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
data/
|
||||||
|
.vpn-proxy/
|
||||||
|
.superpowers/
|
||||||
|
.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=8080 \
|
||||||
|
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 8080
|
||||||
|
|
||||||
|
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*
|
||||||
672
README.md
672
README.md
@@ -1,353 +1,519 @@
|
|||||||
# 🌐 VPN Proxy — Домашний VPN в одной программе
|
# VPN Proxy
|
||||||
|
|
||||||
> **Простыми словами:** ваш компьютер подключается к удалённому VPN-серверу, и весь интернет-трафик идёт через него. Это нужно для доступа к заблокированным сайтам или для защиты данных в публичных Wi-Fi сетях.
|
Локальный Docker-клиент для Mac и прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||||
|
|
||||||
---
|
## macOS: локальный Docker-клиент
|
||||||
|
|
||||||
## 📖 Что это такое?
|
Самый простой режим: контейнер работает как обычный локальный HTTP/SOCKS proxy без TProxy, iptables, `network_mode: host` и прав `NET_ADMIN`.
|
||||||
|
|
||||||
Это набор инструментов, который позволяет:
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | bash
|
||||||
1. **Запустить VPN-прокси** на вашем компьютере
|
|
||||||
2. **Управлять через удобное меню** — всё настраивается автоматически
|
|
||||||
3. **Подключить браузер или приложения** (например, VS Code, Discord) через этот прокси
|
|
||||||
4. **Работает с UDP** — голосовые звонки и игры тоже работают!
|
|
||||||
|
|
||||||
### 🎯 Для кого это?
|
|
||||||
|
|
||||||
- Пользователи, которым нужен VPN для работы или доступа к заблокированным ресурсам
|
|
||||||
- Разработчики, которые хотят направить трафик VS Code или других программ через VPN
|
|
||||||
- Геймеры, которым нужно запустить игры или Discord через VPN
|
|
||||||
- Люди, которые получили VLESS ссылку от VPN-провайдера
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧩 Как это работает?
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ Ваш браузер │────▶│ VPN Proxy │────▶│ VPN Сервер │────▶ Интернет
|
|
||||||
│ или Discord │ │ (порт 1080) │ │ (в другой стране)│
|
|
||||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
После запуска по умолчанию:
|
||||||
|
|
||||||
## 🔧 Перед началом: Требования
|
- UI: `http://127.0.0.1:3456`
|
||||||
|
- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его
|
||||||
|
|
||||||
### ✅ PowerShell 7 (Обязательно!)
|
Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся:
|
||||||
|
|
||||||
> ⚠️ **Важно:** Скрипты требуют PowerShell 7. Стандартный Windows PowerShell 5.1 **не подойдёт!**
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_PORT=18080 bash
|
||||||
|
```
|
||||||
|
|
||||||
#### Проверьте вашу версию
|
Если старый gateway/client уже занимает `3456` или выбранный proxy-порт, можно не трогать старый контейнер и поставить новый клиент на другие порты:
|
||||||
|
|
||||||
Откройте любой PowerShell и выполните:
|
```bash
|
||||||
|
curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-macos-client.sh | VPN_PROXY_CLIENT_UI_PORT=3457 VPN_PROXY_CLIENT_PORT=18080 bash
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска скрипт проверяет, что UI реально ответил на `/api/state`. Если контейнер сразу упал или порт занят, он покажет `docker compose ps` и последние логи вместо ложного сообщения о готовности.
|
||||||
|
|
||||||
|
В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют выбранный локальный proxy-порт, но весь proxy-трафик идёт напрямую без VPN.
|
||||||
|
|
||||||
|
Также Mac-клиент можно связать с серверным gateway. На gateway доступна ручка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET http://<gateway-ui-host>:3456/api/shared-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Если gateway запущен и его mixed proxy работает, ручка вернёт `available: true` и SOCKS5 endpoint общего proxy. В Mac UI укажите адрес gateway UI, например `http://192.168.50.111:3456`. Клиент проверит ручку и переключит локальный `127.0.0.1:<proxy-port>` в режим upstream: весь proxy-трафик пойдёт через общий gateway, локальная VPN-подписка на Mac для этого режима не нужна.
|
||||||
|
|
||||||
|
Ручной запуск из checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.client.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Перезапуск и логи:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.vpn-proxy-client
|
||||||
|
docker compose -f docker-compose.client.yml logs -f
|
||||||
|
docker compose -f docker-compose.client.yml restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows: standalone desktop client direction
|
||||||
|
|
||||||
|
Windows app routing lives in a separate Tauri 2 desktop utility, not as
|
||||||
|
`APP_MODE=windows` inside the current Node gateway/client server. The active
|
||||||
|
workspace slice is `apps/windows-client`.
|
||||||
|
|
||||||
|
Active design documents:
|
||||||
|
|
||||||
|
- Product/tech brief: `docs/windows-client-product-tech-brief.md`
|
||||||
|
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`
|
||||||
|
- Windows client README: `apps/windows-client/README.md`
|
||||||
|
|
||||||
|
Target shape:
|
||||||
|
|
||||||
|
- Control App: compact Windows UI for status, profiles, targets, components,
|
||||||
|
logs, and diagnostics.
|
||||||
|
- Proxyfier Layer: adapter boundary with ProxiFyre as the first engine for
|
||||||
|
per-app TCP/UDP routing.
|
||||||
|
- Local sing-box: optional local runtime; external SOCKS5/HTTP targets must
|
||||||
|
work without it.
|
||||||
|
|
||||||
|
Development checks:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$PSVersionTable.PSVersion.Major
|
cd apps/windows-client
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run tauri -- info
|
||||||
|
|
||||||
|
cd src-tauri
|
||||||
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
- Если результат **7 или выше** — всё хорошо, переходите к установке ✅
|
Native Tauri build requires WebView2, Rust/rustup, and Visual Studio Build
|
||||||
- Если **5 или ниже** — нужно установить PowerShell 7 👇
|
Tools with MSVC and Windows SDK components. In the current checkpoint, frontend
|
||||||
|
builds pass, while native Rust/Tauri tests require that Windows toolchain.
|
||||||
|
|
||||||
#### Установка PowerShell 7
|
The three Windows pieces are installed and operated separately:
|
||||||
|
|
||||||
**Способ 1: Через winget (самый простой)**
|
|
||||||
|
|
||||||
Откройте обычный PowerShell или Командную строку и выполните:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
winget install Microsoft.PowerShell
|
cd apps/windows-client
|
||||||
|
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||||
```
|
```
|
||||||
|
|
||||||
После установки закройте окно и откройте **PowerShell 7** (он появится в меню Пуск).
|
`-PlanOnly` returns structured JSON without install side effects. Real install
|
||||||
|
or service operations must be explicit; profile apply must not silently install
|
||||||
|
Proxyfier or Local sing-box.
|
||||||
|
|
||||||
**Способ 2: Скачать вручную**
|
Windows source configuration is owned by JSON under
|
||||||
|
`C:\ProgramData\VpnProxy\config`. Generated ProxiFyre and sing-box files under
|
||||||
1. Перейдите: https://github.com/PowerShell/PowerShell/releases/latest
|
`C:\ProgramData\VpnProxy\generated` are derived artifacts.
|
||||||
2. Скачайте файл `PowerShell-7.x.x-win-x64.msi` (где x.x.x — версия)
|
|
||||||
3. Запустите установщик и следуйте инструкциям
|
|
||||||
4. После установки используйте **PowerShell 7** из меню Пуск
|
|
||||||
|
|
||||||
> 💡 **Как отличить?** PowerShell 7 имеет чёрный фон и надпись "pwsh" или "PowerShell 7". Старый PowerShell — синий фон.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### ✅ URL Подписки или VLESS-ссылка
|
# VPN Proxy Gateway
|
||||||
|
|
||||||
Получите от вашего VPN-провайдера:
|
Самохостируемый прозрачный VPN-шлюз на базе [sing-box](https://sing-box.sagernet.org/).
|
||||||
- **Подписку**: URL, который начинается с `http://` или `https://`
|
Разворачивается в Docker (LXC, VPS), перехватывает трафик всей локальной сети через iptables TProxy — без клиентов на устройствах.
|
||||||
- **VLESS-ссылку**: начинается с `vless://...`
|
|
||||||
|
Веб-интерфейс на React даёт полное управление: подписки, выбор сервера, кастомные правила маршрутизации, просмотр трафика в реальном времени.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Установка на Windows
|
## Архитектура
|
||||||
|
|
||||||
### ⚡ Быстрая установка (Одной командой)
|
```
|
||||||
|
Клиент (ПК/телефон)
|
||||||
Самый быстрый способ — использовать наш автоматический установщик. Он сам скачает проект и распакует его в `C:\Tools\vpn-proxy`.
|
│ TCP/UDP трафик
|
||||||
|
▼
|
||||||
1. Откройте **PowerShell 7** от имени **Администратора**
|
[Роутер] → маршрут по умолчанию → LXC/VPS (gateway)
|
||||||
2. Скопируйте и вставьте команду:
|
│
|
||||||
|
▼
|
||||||
```powershell
|
iptables mangle PREROUTING → цепочка VPN_PROXY_TPROXY
|
||||||
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
|
│
|
||||||
|
├─ source bypass chain → ACCEPT ← устройства мимо sing-box
|
||||||
|
│ └─ FORWARD + MASQUERADE → обычный internet path
|
||||||
|
├─ ipset vpn_direct_bypass (dst IP) → RETURN ← опциональный bypass-кэш
|
||||||
|
├─ приватные CIDR (RFC1918, ...) → RETURN
|
||||||
|
└─ TCP/UDP → TPROXY :7895
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
sing-box (tproxy inbound :7895)
|
||||||
|
│
|
||||||
|
роутинг по правилам
|
||||||
|
│
|
||||||
|
┌──────────┼──────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
direct VPN out block
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 Если команда выдаст ошибку 404, попробуйте заменить `master` на `main` в ссылке, или используйте ручную установку ниже.
|
ПК-приложения, которым нужен VPN явно:
|
||||||
|
|
||||||
|
```
|
||||||
|
Windows app → ProxiFyre/Proxifier → gateway:8080 → sing-box mixed-in → global rules → default VPN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Node.js API-сервер** (`src/server/index.js`) работает внутри того же контейнера:
|
||||||
|
управляет процессом sing-box, парсит его логи, экспортирует REST API и SSE-стримы для веб-интерфейса.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 📦 Ручная установка (если авто-установка не работает)
|
## Стек
|
||||||
|
|
||||||
Если вы предпочитаете всё делать сами:
|
| Слой | Технология |
|
||||||
|
| ---------------- | ------------------------------------------------------------- |
|
||||||
#### Шаг 1: Скачайте проект
|
| Контейнер | Docker, `network_mode: host`, `CAP_NET_ADMIN` + `CAP_NET_RAW` |
|
||||||
|
| Перехват трафика | iptables TProxy + iproute2 policy routing |
|
||||||
Мы рекомендуем использовать папку `C:\Tools`.
|
| Bypass-кэш | опциональный ipset `hash:ip` с TTL |
|
||||||
|
| VPN-ядро | sing-box (VLESS/VLESS-Reality/VMess/Trojan/Hysteria2/SS) |
|
||||||
```powershell
|
| API-сервер | Node.js 18, plain `http` (без фреймворков) |
|
||||||
# 1. Создаем папку и переходим
|
| Веб-интерфейс | React 18 + Vite 7, SPA |
|
||||||
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. TProxy и policy routing
|
||||||
|
|
||||||
```
|
При старте контейнера `entrypoint.sh` настраивает ядро:
|
||||||
[1] 📦 VPN Клиент (Sing-box) [РАБОТАЕТ]
|
|
||||||
Основной способ. Поддерживает UDP и игры.
|
|
||||||
|
|
||||||
📡 ПОДКЛЮЧЕНИЕ К ПРОКСИ
|
```bash
|
||||||
─────────────────────────────
|
# Policy routing: пакеты с меткой TPROXY_MARK уходят через loopback
|
||||||
Локально: 127.0.0.1:1080
|
ip rule add fwmark 1 table 100
|
||||||
Из сети:
|
ip route replace local 0.0.0.0/0 dev lo table 100
|
||||||
192.168.1.100:1080
|
|
||||||
|
# Цепочка iptables (порядок правил — критичен)
|
||||||
|
iptables -t mangle -N VPN_PROXY_TPROXY
|
||||||
|
iptables -t mangle -N VPN_PROXY_SRC_BYPASS
|
||||||
|
iptables -N VPN_PROXY_FWD_BYPASS
|
||||||
|
iptables -t nat -N VPN_PROXY_NAT_BYPASS
|
||||||
|
-m addrtype --dst-type LOCAL → RETURN # ответы самого sing-box
|
||||||
|
-m mark --mark 1 → RETURN # уже помеченные пакеты
|
||||||
|
-j VPN_PROXY_SRC_BYPASS → ACCEPT # source bypass до sing-box
|
||||||
|
-m set --match-set vpn_direct_bypass → RETURN # только если DIRECT_BYPASS_CACHE=true
|
||||||
|
-d 10.0.0.0/8, 192.168.0.0/16, ... → RETURN # приватные адреса
|
||||||
|
-p tcp → TPROXY :7895 mark 1
|
||||||
|
-p udp → TPROXY :7895 mark 1
|
||||||
|
iptables -t mangle -A PREROUTING -j VPN_PROXY_TPROXY
|
||||||
```
|
```
|
||||||
|
|
||||||
### Проверка через терминал
|
При остановке контейнера (`SIGTERM`) все правила iptables удаляются идемпотентно.
|
||||||
|
ipset-кэш намеренно **не** очищается — записи истекают по TTL.
|
||||||
|
|
||||||
```powershell
|
Устройства можно исключить из transparent-перехвата в интерфейсе: **Routing → Устройства → Mode → bypass TProxy**.
|
||||||
# Без прокси — покажет ваш домашний IP
|
Такой source IP обходит `tproxy-in` и не попадает в `sing-box`; для него gateway включает обычный kernel forwarding + `MASQUERADE`.
|
||||||
Invoke-WebRequest -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
Ручной HTTP/SOCKS proxy на `gateway:8080` остаётся доступен для выбранных программ.
|
||||||
|
|
||||||
# Через прокси — должен показать IP VPN-сервера
|
### 2. Маршрутизация внутри sing-box
|
||||||
Invoke-WebRequest -Proxy "http://127.0.0.1:1080" -Uri "https://ipinfo.io/ip" | Select-Object -ExpandProperty Content
|
|
||||||
```
|
|
||||||
|
|
||||||
Если IP-адреса разные — VPN работает! 🎉
|
Каждый пакет проходит правила в порядке приоритета — **первое совпадение побеждает**:
|
||||||
|
|
||||||
|
| Приоритет | Условие | Действие |
|
||||||
|
| --------- | ------------------------------------------- | ---------------------------------------- |
|
||||||
|
| 1 | `ip_is_private: true` | `direct` (защита LAN) |
|
||||||
|
| 2 | Global custom rules | `direct` / VPN / `block` для всех inbound |
|
||||||
|
| 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` |
|
||||||
|
| 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` |
|
||||||
|
| 5 | Proxy default для `mixed-in` | по умолчанию VPN |
|
||||||
|
| 6 | Transparent default для unknown devices | по умолчанию VPN |
|
||||||
|
| 7 | Всё остальное (`final`) | `direct` |
|
||||||
|
|
||||||
|
Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`.
|
||||||
|
|
||||||
|
### 3. Bypass Mode (весь трафик напрямую)
|
||||||
|
|
||||||
|
Кнопка "Весь трафик напрямую" в дашборде. При активации `buildGatewayConfig()` вызывается с `{ bypassAll: true }` — в конфиге убираются все rule_set, `final: "direct"`. Удобно для диагностики или когда VPN не нужен.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎮 Настройка Discord / Vesktop
|
## Direct Bypass Cache (ipset)
|
||||||
|
|
||||||
Discord не поддерживает системные настройки прокси, поэтому нужна дополнительная настройка.
|
Оптимизация выключена по умолчанию: `DIRECT_BYPASS_CACHE=false`. Причина — dst-IP cache обходит sing-box до проверки global rules, а значит может нарушить требования вида `AI → VPN` или `blocked → block`.
|
||||||
|
|
||||||
### Требования
|
Если явно включить `DIRECT_BYPASS_CACHE=true`, IP-адреса, которые sing-box уже отправил напрямую, кэшируются в ядре и больше не проходят через userspace.
|
||||||
|
|
||||||
- ✅ Установленный VPN клиент (пункт [1] в меню)
|
**Цепочка событий:**
|
||||||
- ✅ VPN клиент должен быть запущен (статус "РАБОТАЕТ")
|
|
||||||
|
|
||||||
### Установка
|
1. sing-box маршрутизирует соединение как `direct`, пишет в лог:
|
||||||
|
`[TCP] 192.168.1.5:54321 --> 203.0.113.10:443 outbound/direct[direct]`
|
||||||
|
|
||||||
1. Запустите `.\manage.ps1`
|
2. Node.js парсит строку (regex `-->` + `outbound/`). Если `category === "direct"` и назначение — IPv4-адрес:
|
||||||
2. Выберите пункт **[2] — Настройка Discord/Vesktop**
|
|
||||||
3. Выберите какое приложение настроить:
|
|
||||||
- Discord
|
|
||||||
- Vesktop
|
|
||||||
- Оба
|
|
||||||
|
|
||||||
**Что устанавливается:**
|
```bash
|
||||||
- Windows Packet Filter — драйвер для перехвата трафика
|
ipset add vpn_direct_bypass 203.0.113.10 timeout 3600 -exist
|
||||||
- ProxiFyre — служба, которая направляет трафик Discord через прокси
|
```
|
||||||
|
|
||||||
После установки Discord/Vesktop будут автоматически работать через VPN!
|
3. Следующий пакет к `203.0.113.10` обрабатывается iptables **до** передачи в sing-box:
|
||||||
|
|
||||||
---
|
```
|
||||||
|
-m set --match-set vpn_direct_bypass dst → RETURN
|
||||||
|
```
|
||||||
|
|
||||||
## ⚙️ Настройка приложений
|
Пакет уходит напрямую на уровне ядра — нулевые накладные расходы userspace sing-box.
|
||||||
|
|
||||||
### Для VS Code
|
4. Запись истекает через TTL (по умолчанию 1 час).
|
||||||
|
|
||||||
Откройте настройки (Ctrl + ,), найдите "proxy" и добавьте:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
http.proxy: http://127.0.0.1:1080
|
DIRECT_BYPASS_CACHE=false # безопасное значение по умолчанию
|
||||||
|
DIRECT_BYPASS_SET=vpn_direct_bypass # имя ipset
|
||||||
|
DIRECT_BYPASS_TTL=3600 # TTL в секундах
|
||||||
```
|
```
|
||||||
|
|
||||||
Или добавьте в `settings.json`:
|
## Профили устройств
|
||||||
|
|
||||||
|
Управляются из UI на вкладке **Маршрутизация** и сохраняются в `devices.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"http.proxy": "http://127.0.0.1:1080",
|
"defaultTransparentMode": "vpn",
|
||||||
"http.proxyStrictSSL": true
|
"proxyDefaultMode": "vpn",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"id": "gaming-pc",
|
||||||
|
"name": "Gaming PC",
|
||||||
|
"ip": "192.168.1.50",
|
||||||
|
"mac": "",
|
||||||
|
"mode": "direct",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phone",
|
||||||
|
"name": "Phone",
|
||||||
|
"ip": "192.168.1.60",
|
||||||
|
"mode": "vpn",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Для браузера
|
| Mode | Что делает |
|
||||||
|
| -------- | ----------------------------------------------------------------- |
|
||||||
|
| `direct` | fallback устройства после global rules → `direct` |
|
||||||
|
| `vpn` | fallback устройства после global rules → выбранный VPN |
|
||||||
|
| `block` | fallback устройства после global rules → `block` |
|
||||||
|
| `rules` | не задаёт fallback устройства; используется transparent default |
|
||||||
|
|
||||||
В настройках прокси вашего браузера укажите:
|
`mixed-in` не зависит от режима устройства: если приложение явно пошло на `gateway:8080`, сначала применяются global rules, затем `proxyDefaultMode` (по умолчанию VPN).
|
||||||
|
|
||||||
- **Тип**: 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` скрипт покажет меню управления:
|
Управляются из вкладки **Маршрутизация**. Сохраняются в `custom-rules.json`.
|
||||||
|
Правила применяются в порядке отображения в UI — **first match wins**. Custom rules являются global rules: они применяются для `tproxy-in`, `mixed-in`, ПК, телефона и unknown devices до любых fallback-режимов.
|
||||||
|
|
||||||
| Действие | Как сделать |
|
| Поле | Тип | Описание |
|
||||||
|----------|-------------|
|
| ---------------- | ---------------------------- | ------------------------------------------- |
|
||||||
| Посмотреть статус | Запустить `.\manage.ps1` |
|
| `name` | string | Название правила |
|
||||||
| Сменить сервер | Пункт [1] → "Сменить VLESS/Подписку" |
|
| `enabled` | bool | Вкл/выкл |
|
||||||
| Перезапустить | Пункт [1] → "Перезапустить" |
|
| `outbound` | `direct` \| `vpn` \| `block` | Куда отправить трафик |
|
||||||
| Остановить | Пункт [1] → "Остановить" |
|
| `domains` | string[] | Точные домены (`example.com`) |
|
||||||
| Полностью удалить | Пункт [U] |
|
| `domainSuffixes` | string[] | Суффикс домена (`.example.com` + поддомены) |
|
||||||
|
| `domainKeywords` | string[] | Keyword в имени хоста |
|
||||||
|
| `ipCidrs` | string[] | IP-диапазоны CIDR |
|
||||||
|
| `ports` | string[] | Порты или диапазоны (`443`, `8000-9000`) |
|
||||||
|
| `networks` | `tcp` \| `udp` | Протокол |
|
||||||
|
| `ruleSets` | string[] | Ссылки на remote rule-set |
|
||||||
|
|
||||||
---
|
UI автоматически детектирует конфликты — когда правило полностью перекрывается предыдущим.
|
||||||
|
|
||||||
## 🌍 Подключение из локальной сети
|
### Remote Rule Sets
|
||||||
|
|
||||||
Если вы хотите использовать прокси с других устройств (телефон, планшет):
|
В **Настройках** можно добавить произвольные rule-set:
|
||||||
|
|
||||||
1. Посмотрите IP-адрес в меню (раздел "Из сети:")
|
```json
|
||||||
2. На другом устройстве настройте прокси: `IP_ВАШЕГО_ПК:1080`
|
{ "tag": "gaming-servers", "url": "https://...", "format": "binary" }
|
||||||
|
|
||||||
Например: `192.168.1.100:1080`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Часто задаваемые вопросы
|
|
||||||
|
|
||||||
### Ошибка "Файл не может быть загружен, так как выполнение сценариев отключено"
|
|
||||||
|
|
||||||
**Решение:** Включите выполнение скриптов:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ошибка при запуске — непонятные символы или синтаксис
|
sing-box скачивает их при старте, кэширует в `cache.db`. Ключ кэша — SHA-1 от URL.
|
||||||
|
|
||||||
**Причина:** Вы используете старый 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 с веб-интерфейсом
|
Поддерживаемые форматы:
|
||||||
|
|
||||||
Если вы предпочитаете управлять через браузер с красивым интерфейсом:
|
- **JSON-конфиг sing-box** — объект с полем `outbounds[]`
|
||||||
|
- **Base64-список VLESS-ссылок** — декодируется, каждая ссылка парсится
|
||||||
|
- **Прямые VLESS URI** (`vless://uuid@host:port?...#tag`)
|
||||||
|
|
||||||
> ⚠️ **Внимание:** В этом режиме **Discord работать не будет**!
|
После загрузки пользователь выбирает сервер → генерируется конфиг → `sing-box check` → перезапуск.
|
||||||
> Docker на Windows не поддерживает UDP-проксирование, которое необходимо для голосовых чатов. Если вам нужен рабочий Discord — используйте **основной способ** (пункт [1] в меню).
|
|
||||||
|
|
||||||
📖 **[Инструкция по Docker](docs/DOCKER.md)**
|
Подписка кэшируется в `subscription-cache.json` — при рестарте контейнера конфиг автоматически пересоздаётся из кэша без повторного скачивания.
|
||||||
|
|
||||||
### Установка на удалённый сервер (VPS)
|
|
||||||
|
|
||||||
Если вы хотите развернуть прокси на своём сервере в другой стране:
|
|
||||||
|
|
||||||
📖 **[Инструкция по установке на сервер](docs/SERVER.md)**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Словарь терминов
|
## Просмотр трафика
|
||||||
|
|
||||||
| Термин | Объяснение |
|
Вкладка **Трафик** в разделе Логи. Данные приходят через SSE (`/api/traffic/stream`).
|
||||||
|--------|------------|
|
|
||||||
| **Прокси** | Программа-посредник, которая передаёт ваши запросы в интернет от своего имени |
|
### Парсинг логов sing-box
|
||||||
| **VPN** | Зашифрованный туннель между вашим компьютером и удалённым сервером |
|
|
||||||
| **VLESS** | Современный протокол VPN-соединения |
|
Node.js читает stderr sing-box и извлекает трафик двумя шагами:
|
||||||
| **sing-box** | Программа-клиент для подключения к VPN |
|
|
||||||
| **SOCKS5** | Тип прокси, поддерживающий любой трафик (включая UDP для игр) |
|
```
|
||||||
| **Порт** | "Номер двери" для сетевых соединений |
|
[router] match[2][my-rule] => outbound/direct[direct] ← имя правила
|
||||||
|
[TCP] 192.168.1.5:PORT --> example.com:443 outbound/vpn[tag] ← соединение
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `[router]`-строка → имя правила сохраняется с TTL 500 мс
|
||||||
|
2. Следующая строка с `-->` подхватывает имя в поле `matchedRule`
|
||||||
|
3. Тип трафика: `direct` / `vpn` / `block` по outbound
|
||||||
|
4. Direct + IPv4 → добавление в ipset bypass-кэш, только если `DIRECT_BYPASS_CACHE=true`
|
||||||
|
|
||||||
|
### Группировка и сортировка
|
||||||
|
|
||||||
|
`(category, host, port, matchedRule)` объединяются в группу с счётчиком:
|
||||||
|
|
||||||
|
- **По частоте** — самые частые наверху (по умолчанию)
|
||||||
|
- **По времени** — последние наверху
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🆘 Нужна помощь?
|
## Проверка маршрута
|
||||||
|
|
||||||
Если что-то не работает:
|
Вкладка **Проверка** позволяет узнать, по какому правилу пойдёт трафик к хосту/IP/порту — без реального подключения. Node.js (`routeMatcher.js`) симулирует ту же логику, что и sing-box:
|
||||||
|
|
||||||
1. Убедитесь что используете **PowerShell 7**
|
1. private IP → direct
|
||||||
2. Запустите от имени **Администратора**
|
2. global custom rules
|
||||||
3. Проверьте статус в главном меню
|
3. geoip-ru / geosite-category-ru → direct
|
||||||
4. Попробуйте переустановить: пункт [U], затем пункт [1]
|
4. `tproxy-in` + device default
|
||||||
|
5. `mixed-in` + proxy default
|
||||||
|
6. final → direct
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Создано для простого и безопасного доступа в интернет_ 🛡️
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка фронтенда
|
||||||
|
npm install && npm run build
|
||||||
|
|
||||||
|
# Запуск контейнера
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если Docker Hub отвечает таймаутом на `debian:bookworm-slim`, можно собрать через read-through mirror:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim \
|
||||||
|
docker compose -f docker-compose.gateway.yml build
|
||||||
|
|
||||||
|
docker compose -f docker-compose.gateway.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если сборку нужно выполнять на контейнере/хосте, который уже ходит через рабочий gateway, а запускать image на другом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BUILD_HOST=107 DEPLOY_HOST=111 ./scripts/build-on-107-deploy-111.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт собирает image на `BUILD_HOST`, переносит его на `DEPLOY_HOST` через `docker save | docker load` и запускает без `docker pull`. Если `107`/`111` не являются SSH-алиасами, укажите реальные адреса, например `BUILD_HOST=root@192.168.1.107 DEPLOY_HOST=root@192.168.1.111`.
|
||||||
|
|
||||||
|
Чтобы не получать циклическую зависимость "собрать gateway можно только через уже работающий gateway", подготовьте runtime base на `107` один раз:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-runtime-base.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого CI и `build-on-107-deploy-111.sh` используют локальный `vpn-proxy-runtime-base:bookworm-slim`: основная сборка gateway больше не делает `apt-get`, не качает sing-box и не обращается к Docker Hub за base image.
|
||||||
|
|
||||||
|
UI доступен на `http://<gateway-ip>:3456`.
|
||||||
|
|
||||||
|
На роутере указать шлюз по умолчанию (или нужные подсети) на IP контейнера.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
| ------------------- | -------------------- | -------------------------------------- |
|
||||||
|
| `APP_MODE` | `gateway` | `gateway` или `client`; compose клиента задаёт `client` автоматически |
|
||||||
|
| `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` |
|
||||||
|
| `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` |
|
||||||
|
| `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` |
|
||||||
|
| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` |
|
||||||
|
| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом |
|
||||||
|
| `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон |
|
||||||
|
| `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса |
|
||||||
|
| `PORT` | `3456` | Порт веб-интерфейса |
|
||||||
|
| `BASE_IMAGE` | `debian:bookworm-slim` | Базовый Docker image для сборки; можно заменить на mirror |
|
||||||
|
| `SINGBOX_VERSION` | `1.12.13` | Версия sing-box для Docker build |
|
||||||
|
| `INSTALL_RUNTIME_DEPS` | `true` | Устанавливать runtime-пакеты в Docker build; `false` для подготовленного runtime base |
|
||||||
|
| `INSTALL_SINGBOX` | `true` | Скачивать sing-box в Docker build; `false` для подготовленного runtime base |
|
||||||
|
| `PROXY_PORT` | `8080` | HTTP/SOCKS mixed inbound |
|
||||||
|
| `TPROXY_PORT` | `7895` | TProxy inbound sing-box |
|
||||||
|
| `TPROXY_BYPASS_SOURCE_CIDRS` | unset | Source CIDR устройств, которые должны идти напрямую мимо TProxy/sing-box, например `192.168.50.25/32` |
|
||||||
|
| `TPROXY_SOURCE_BYPASS_CHAIN` | `VPN_PROXY_SRC_BYPASS` | Управляемая iptables-цепочка для UI source-bypass |
|
||||||
|
| `TPROXY_SOURCE_FORWARD_CHAIN` | `VPN_PROXY_FWD_BYPASS` | Управляемая filter/FORWARD цепочка для UI source-bypass |
|
||||||
|
| `TPROXY_SOURCE_NAT_CHAIN` | `VPN_PROXY_NAT_BYPASS` | Управляемая nat/POSTROUTING цепочка для UI source-bypass |
|
||||||
|
| `DATA_DIR` | `/var/lib/vpn-proxy` | Директория данных (volume) |
|
||||||
|
| `ROUTING_RU_DIRECT` | `true` | geoip-ru/geosite-ru → direct |
|
||||||
|
| `LOG_LEVEL` | `info` | Уровень логов sing-box |
|
||||||
|
| `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен |
|
||||||
|
| `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша |
|
||||||
|
| `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) |
|
||||||
|
| `RULE_SET_DOWNLOAD_DETOUR` | `vpn` | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер |
|
||||||
|
| `PROXY_BIND_IP` | `0.0.0.0` | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway |
|
||||||
|
| `PROXY_FIREWALL` | `true` | Закрыть `PROXY_PORT` не из allowed CIDR |
|
||||||
|
| `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
| --------- | ---------------------- | ------------------------------------ |
|
||||||
|
| `GET` | `/api/state` | Полное состояние системы |
|
||||||
|
| `GET` | `/api/shared-proxy` | Проверка и параметры общего gateway proxy |
|
||||||
|
| `POST` | `/api/subscription` | Загрузить подписку по URL |
|
||||||
|
| `POST` | `/api/apply` | Применить сервер (`{ selectedTag }`) |
|
||||||
|
| `GET` | `/api/servers` | Список серверов из кэша |
|
||||||
|
| `GET/PUT` | `/api/rules` | Кастомные правила |
|
||||||
|
| `GET/PUT` | `/api/devices` | Профили устройств и default fallback |
|
||||||
|
| `GET/PUT` | `/api/rule-sets` | Кастомные remote rule-set |
|
||||||
|
| `POST` | `/api/singbox/start` | Запустить sing-box |
|
||||||
|
| `POST` | `/api/singbox/stop` | Остановить sing-box |
|
||||||
|
| `POST` | `/api/singbox/restart` | Перезапустить sing-box |
|
||||||
|
| `POST` | `/api/bypass` | `{ enabled }` — bypass mode |
|
||||||
|
| `GET` | `/api/direct-cache` | Состояние ipset bypass-кэша |
|
||||||
|
| `DELETE` | `/api/direct-cache` | Сбросить bypass-кэш |
|
||||||
|
| `POST` | `/api/route/check` | Симулировать маршрут |
|
||||||
|
| `POST` | `/api/servers/ping` | TCP-пинг до хоста |
|
||||||
|
| `GET` | `/api/logs/stream` | SSE системных логов |
|
||||||
|
| `GET` | `/api/traffic/stream` | SSE трафика |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
├── Dockerfile # debian + sing-box + ipset + node
|
||||||
|
├── entrypoint.sh # iptables/ipset setup → запуск node
|
||||||
|
├── docker-compose.gateway.yml
|
||||||
|
├── src/
|
||||||
|
│ ├── server/
|
||||||
|
│ │ ├── index.js # HTTP-сервер, управление sing-box, SSE
|
||||||
|
│ │ ├── singbox.js # генерация конфига sing-box
|
||||||
|
│ │ ├── subscription.js # парсинг подписок (JSON/VLESS/base64)
|
||||||
|
│ │ ├── routeMatcher.js # симулятор маршрутизации
|
||||||
|
│ │ ├── ping.js # TCP-пинг и DNS-resolve
|
||||||
|
│ │ └── config.js # настройки из env
|
||||||
|
│ └── web/
|
||||||
|
│ ├── App.jsx # корневой компонент, глобальный state
|
||||||
|
│ ├── api.js # обёртка fetch для API
|
||||||
|
│ └── components/
|
||||||
|
│ ├── OverviewPage.jsx # дашборд, bypass-toggle
|
||||||
|
│ ├── LogsPage.jsx # трафик + системные логи
|
||||||
|
│ ├── RoutingPage.jsx # кастомные правила
|
||||||
|
│ ├── ServersPage.jsx # подписка и выбор сервера
|
||||||
|
│ ├── SettingsPage.jsx # rule-sets и настройки
|
||||||
|
│ └── RouteChecker.jsx # проверка маршрута
|
||||||
|
└── docs/
|
||||||
|
└── roadmap.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- TProxy только IPv4. IPv6 — в roadmap.
|
||||||
|
- DNS-перехват не включён; выдавайте клиентам DNS через DHCP роутера.
|
||||||
|
- Gateway не видит имя процесса на клиентском ПК — правила для игр задаются через домены, CIDR и порты.
|
||||||
|
|||||||
4
apps/windows-client/.gitignore
vendored
Normal file
4
apps/windows-client/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
|
||||||
116
apps/windows-client/README.md
Normal file
116
apps/windows-client/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# VPN Proxy Windows Client
|
||||||
|
|
||||||
|
Standalone Windows desktop utility for app-level proxy routing. This app is
|
||||||
|
separate from the current Docker gateway/client runtime and must not be wired
|
||||||
|
through `APP_MODE=windows`.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- Control App: Tauri 2 + React/TypeScript UI and Rust command layer.
|
||||||
|
- Proxyfier Layer: ProxiFyre adapter for per-application routing.
|
||||||
|
- Local sing-box: optional local runtime, used only by targets that explicitly
|
||||||
|
require `singbox`.
|
||||||
|
|
||||||
|
External SOCKS5 targets are the MVP path and do not require Local sing-box.
|
||||||
|
|
||||||
|
## Source And Generated Files
|
||||||
|
|
||||||
|
Source configuration is owned by Rust domain models and JSON files under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\config\profiles.json
|
||||||
|
C:\ProgramData\VpnProxy\config\targets.json
|
||||||
|
C:\ProgramData\VpnProxy\config\components.json
|
||||||
|
C:\ProgramData\VpnProxy\state\activity.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated artifacts are derived and can be recreated:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\sing-box-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the browser preview shell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev -- --host 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Tauri checks when the native Windows toolchain is installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run tauri -- info
|
||||||
|
npm run tauri -- dev
|
||||||
|
npm run tauri -- build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run Rust tests when Rust/Cargo are installed:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd apps/windows-client/src-tauri
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Native Tauri build requires WebView2, Rust via rustup, and Visual Studio Build
|
||||||
|
Tools with MSVC and Windows SDK components.
|
||||||
|
|
||||||
|
## Explicit Installer Boundaries
|
||||||
|
|
||||||
|
Installer scripts are explicit per component and return structured JSON in
|
||||||
|
`-PlanOnly` mode:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& .\scripts\install-control-app.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-proxyfier.ps1 -PlanOnly
|
||||||
|
& .\scripts\install-singbox.ps1 -PlanOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
Installers must be launched intentionally by the user or by a future narrow
|
||||||
|
helper permission. Profile apply must not silently install Control App,
|
||||||
|
Proxyfier, or Local sing-box.
|
||||||
|
|
||||||
|
## Existing Proxyfier Detection
|
||||||
|
|
||||||
|
The app detects an already installed Proxyfier layer before showing component
|
||||||
|
status or applying profiles. Detection checks:
|
||||||
|
|
||||||
|
- uninstall registry entries for `ProxiFyre` and `Proxifier`;
|
||||||
|
- common install folders such as `C:\Tools\ProxiFyre`,
|
||||||
|
`%ProgramFiles%\ProxiFyre`, and `%ProgramFiles%\Proxifier`;
|
||||||
|
- running `ProxiFyre` / `Proxifier` processes and the `ProxiFyreService`
|
||||||
|
service.
|
||||||
|
|
||||||
|
For portable installs, set an override before launching the app:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:VPN_PROXY_PROXIFYRE_ROOT = 'D:\Tools\ProxiFyre'
|
||||||
|
npm run tauri -- dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProxiFyre` installs are compatible with the current generated
|
||||||
|
`app-config.json` apply path. Plain `Proxifier` installs are detected and shown,
|
||||||
|
but automatic profile apply is not enabled for them yet because they use a
|
||||||
|
different profile format.
|
||||||
|
|
||||||
|
## MVP Verification Flow
|
||||||
|
|
||||||
|
1. Start the Control App or browser preview.
|
||||||
|
2. Confirm Components shows Control App, Proxyfier Layer, and optional Local
|
||||||
|
sing-box separately.
|
||||||
|
3. Add or keep an external SOCKS5 target.
|
||||||
|
4. Add a process/folder/exe profile such as Discord.
|
||||||
|
5. Apply profiles and verify generated ProxiFyre config plus activity entry.
|
||||||
|
6. Install Proxyfier separately before applying to a real service.
|
||||||
|
7. Install and start Local sing-box only when using a local target.
|
||||||
|
|
||||||
|
Task evidence is recorded in
|
||||||
|
`docs/goals/windows-modular-client/EVIDENCE.md`.
|
||||||
12
apps/windows-client/index.html
Normal file
12
apps/windows-client/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 Proxy для Windows</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2126
apps/windows-client/package-lock.json
generated
Normal file
2126
apps/windows-client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
apps/windows-client/package.json
Normal file
28
apps/windows-client/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "vpn-proxy-windows-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Standalone Windows desktop proxy management app for VPN Proxy.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
apps/windows-client/scripts/install-control-app.ps1
Normal file
79
apps/windows-client/scripts/install-control-app.ps1
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Program Files\VpnProxy\ControlApp",
|
||||||
|
[string]$DataRoot = "C:\ProgramData\VpnProxy",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Directory {
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
dataRoot = $DataRoot
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
$changed = (Ensure-Directory -Path $InstallRoot) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed
|
||||||
|
$changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-control-app.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{ component = "control-app"; installedAt = (Get-Date).ToString("o") } |
|
||||||
|
ConvertTo-Json -Depth 4 |
|
||||||
|
Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
96
apps/windows-client/scripts/install-proxyfier.ps1
Normal file
96
apps/windows-client/scripts/install-proxyfier.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Tools\ProxiFyre",
|
||||||
|
[string]$PackagePath = "",
|
||||||
|
[string]$ServiceName = "ProxiFyreService",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backup-File {
|
||||||
|
param([string]$Path)
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
$backup = "$Path.bak"
|
||||||
|
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||||
|
return $backup
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
packagePath = $PackagePath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $InstallRoot "app-config.json"
|
||||||
|
$backupPath = Backup-File -Path $configPath
|
||||||
|
if ($backupPath) {
|
||||||
|
$details.backupPath = $backupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{
|
||||||
|
component = "proxyfier"
|
||||||
|
packagePath = $PackagePath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
installedAt = (Get-Date).ToString("o")
|
||||||
|
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
96
apps/windows-client/scripts/install-singbox.ps1
Normal file
96
apps/windows-client/scripts/install-singbox.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
param(
|
||||||
|
[string]$InstallRoot = "C:\Program Files\VpnProxy\sing-box",
|
||||||
|
[string]$BinaryPath = "",
|
||||||
|
[string]$ServiceName = "VpnProxySingBox",
|
||||||
|
[switch]$PlanOnly,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function New-Result {
|
||||||
|
param(
|
||||||
|
[bool]$Success,
|
||||||
|
[string]$Action,
|
||||||
|
[bool]$Changed,
|
||||||
|
[string]$Message,
|
||||||
|
[hashtable]$Details = @{}
|
||||||
|
)
|
||||||
|
|
||||||
|
[ordered]@{
|
||||||
|
success = $Success
|
||||||
|
action = $Action
|
||||||
|
changed = $Changed
|
||||||
|
message = $Message
|
||||||
|
details = $Details
|
||||||
|
} | ConvertTo-Json -Depth 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsAdministrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backup-File {
|
||||||
|
param([string]$Path)
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
$backup = "$Path.bak"
|
||||||
|
Copy-Item -LiteralPath $Path -Destination $backup -Force
|
||||||
|
return $backup
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = @{
|
||||||
|
installRoot = $InstallRoot
|
||||||
|
binaryPath = $BinaryPath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
planOnly = [bool]$PlanOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PlanOnly) {
|
||||||
|
New-Result -Success $true -Action "install-singbox" -Changed $false -Message "Local sing-box install plan is ready." -Details $details
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-IsAdministrator)) {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BinaryPath) -or -not (Test-Path -LiteralPath $BinaryPath)) {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message "BinaryPath is required and must point to sing-box.exe." -Details $details
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
$changed = $false
|
||||||
|
if (-not (Test-Path -LiteralPath $InstallRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $InstallRoot "config.json"
|
||||||
|
$backupPath = Backup-File -Path $configPath
|
||||||
|
if ($backupPath) {
|
||||||
|
$details.backupPath = $backupPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$markerPath = Join-Path $InstallRoot "install-singbox.marker.json"
|
||||||
|
if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) {
|
||||||
|
@{
|
||||||
|
component = "singbox"
|
||||||
|
binaryPath = $BinaryPath
|
||||||
|
serviceName = $ServiceName
|
||||||
|
installedAt = (Get-Date).ToString("o")
|
||||||
|
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8
|
||||||
|
$changed = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$details.markerPath = $markerPath
|
||||||
|
New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box install boundary completed." -Details $details
|
||||||
|
} catch {
|
||||||
|
New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
4548
apps/windows-client/src-tauri/Cargo.lock
generated
Normal file
4548
apps/windows-client/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
apps/windows-client/src-tauri/Cargo.toml
Normal file
20
apps/windows-client/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "vpn-proxy-windows-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Standalone Windows desktop proxy management app for VPN Proxy."
|
||||||
|
authors = ["VPN Proxy"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "vpn_proxy_windows_client_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tauri-plugin-dialog = "2.7.1"
|
||||||
|
|
||||||
3
apps/windows-client/src-tauri/build.rs
Normal file
3
apps/windows-client/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
7
apps/windows-client/src-tauri/capabilities/default.json
Normal file
7
apps/windows-client/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "dialog:allow-open"]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default capability for the main VPN Proxy Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||||
2358
apps/windows-client/src-tauri/gen/schemas/desktop-schema.json
Normal file
2358
apps/windows-client/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2358
apps/windows-client/src-tauri/gen/schemas/windows-schema.json
Normal file
2358
apps/windows-client/src-tauri/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/windows-client/src-tauri/icons/128x128.png
Normal file
BIN
apps/windows-client/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
apps/windows-client/src-tauri/icons/128x128@2x.png
Normal file
BIN
apps/windows-client/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
apps/windows-client/src-tauri/icons/32x32.png
Normal file
BIN
apps/windows-client/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 940 B |
BIN
apps/windows-client/src-tauri/icons/icon.ico
Normal file
BIN
apps/windows-client/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
23
apps/windows-client/src-tauri/src/activity.rs
Normal file
23
apps/windows-client/src-tauri/src/activity.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use crate::models::ActivityEntry;
|
||||||
|
|
||||||
|
pub const DEFAULT_ACTIVITY_LIMIT: usize = 200;
|
||||||
|
|
||||||
|
pub fn sort_activity_desc(mut entries: Vec<ActivityEntry>) -> Vec<ActivityEntry> {
|
||||||
|
entries.sort_by(|left, right| right.at.cmp(&left.at).then_with(|| right.id.cmp(&left.id)));
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cap_activity(entries: Vec<ActivityEntry>, limit: usize) -> Vec<ActivityEntry> {
|
||||||
|
let mut entries = sort_activity_desc(entries);
|
||||||
|
entries.truncate(limit);
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_activity(
|
||||||
|
mut entries: Vec<ActivityEntry>,
|
||||||
|
entry: ActivityEntry,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<ActivityEntry> {
|
||||||
|
entries.push(entry);
|
||||||
|
cap_activity(entries, limit)
|
||||||
|
}
|
||||||
245
apps/windows-client/src-tauri/src/adapters/proxifyre.rs
Normal file
245
apps/windows-client/src-tauri/src/adapters/proxifyre.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
#[cfg(not(test))]
|
||||||
|
use crate::adapters::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
use crate::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target,
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::proxy_router::{
|
||||||
|
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||||
|
ProxyRouterRequest,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const PROXIFYRE_ADAPTER_ID: &str = "proxifyre";
|
||||||
|
pub const PROXIFYRE_OUTPUT_FILE: &str = "proxifyre-app-config.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxiFyreAdapter {
|
||||||
|
log_level: String,
|
||||||
|
bypass_lan: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxiFyreAdapter {
|
||||||
|
pub fn new(log_level: impl Into<String>, bypass_lan: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: log_level.into(),
|
||||||
|
bypass_lan,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_proxifyre_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxiFyreConfig, ProxyRouterError> {
|
||||||
|
let mut proxies = Vec::new();
|
||||||
|
|
||||||
|
for profile in request.profiles.iter().filter(|profile| profile.enabled) {
|
||||||
|
let target = find_target(profile, request.targets)?;
|
||||||
|
ensure_target_supported(profile, target, request.components)?;
|
||||||
|
|
||||||
|
let app_names = app_names_for_profile(profile);
|
||||||
|
if app_names.is_empty() {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::EmptyProfileItems,
|
||||||
|
format!(
|
||||||
|
"В профиле '{}' нет приложений для маршрутизации",
|
||||||
|
profile.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proxies.push(ProxiFyreProxy {
|
||||||
|
app_names,
|
||||||
|
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
|
||||||
|
supported_protocols: protocols_for_profile(profile),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ProxiFyreConfig {
|
||||||
|
log_level: self.log_level.clone(),
|
||||||
|
bypass_lan: self.bypass_lan,
|
||||||
|
proxies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxiFyreAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("Info", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyRouterAdapter for ProxiFyreAdapter {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
PROXIFYRE_ADAPTER_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_file_name(&self) -> &'static str {
|
||||||
|
PROXIFYRE_OUTPUT_FILE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError> {
|
||||||
|
let config = self.generate_proxifyre_config(request)?;
|
||||||
|
let enabled_profiles = config.proxies.len();
|
||||||
|
let routed_apps = config
|
||||||
|
.proxies
|
||||||
|
.iter()
|
||||||
|
.map(|proxy| proxy.app_names.len())
|
||||||
|
.sum();
|
||||||
|
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||||
|
ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::Serialization,
|
||||||
|
format!("Не удалось сериализовать конфиг ProxiFyre: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ProxyRouterGeneratedConfig {
|
||||||
|
adapter_id: self.id().to_string(),
|
||||||
|
output_file_name: self.output_file_name().to_string(),
|
||||||
|
contents,
|
||||||
|
enabled_profiles,
|
||||||
|
routed_apps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProxiFyreConfig {
|
||||||
|
#[serde(rename = "logLevel")]
|
||||||
|
pub log_level: String,
|
||||||
|
#[serde(rename = "bypassLan")]
|
||||||
|
pub bypass_lan: bool,
|
||||||
|
pub proxies: Vec<ProxiFyreProxy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProxiFyreProxy {
|
||||||
|
#[serde(rename = "appNames")]
|
||||||
|
pub app_names: Vec<String>,
|
||||||
|
#[serde(rename = "socks5ProxyEndpoint")]
|
||||||
|
pub socks5_proxy_endpoint: String,
|
||||||
|
#[serde(rename = "supportedProtocols")]
|
||||||
|
pub supported_protocols: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_target<'a>(
|
||||||
|
profile: &Profile,
|
||||||
|
targets: &'a [Target],
|
||||||
|
) -> Result<&'a Target, ProxyRouterError> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| target.id == profile.target_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::MissingTarget,
|
||||||
|
format!(
|
||||||
|
"Профиль '{}' ссылается на отсутствующую цель '{}'",
|
||||||
|
profile.id, profile.target_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_target_supported(
|
||||||
|
profile: &Profile,
|
||||||
|
target: &Target,
|
||||||
|
components: &[ComponentStatus],
|
||||||
|
) -> Result<(), ProxyRouterError> {
|
||||||
|
if target.protocol != ProxyProtocol::Socks5 {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::UnsupportedTargetProtocol,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' использует HTTP, но ProxiFyre требует SOCKS5",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(required_component) = &target.requires_component {
|
||||||
|
let Some(status) = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| &component.id == required_component)
|
||||||
|
else {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::MissingRequiredComponent,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' профиля '{}' требует отсутствующий компонент '{}'",
|
||||||
|
target.id,
|
||||||
|
profile.id,
|
||||||
|
component_id_label(required_component)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !component_is_running(status) {
|
||||||
|
return Err(ProxyRouterError::new(
|
||||||
|
ProxyRouterErrorKind::RequiredComponentNotRunning,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' профиля '{}' требует запущенный компонент '{}'",
|
||||||
|
target.id,
|
||||||
|
profile.id,
|
||||||
|
component_id_label(required_component)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||||
|
status.installed && status.running && status.state == ComponentState::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_names_for_profile(profile: &Profile) -> Vec<String> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
|
||||||
|
for item in &profile.items {
|
||||||
|
let value = item.value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_name = match item.item_type {
|
||||||
|
ProfileItemType::Process | ProfileItemType::Folder | ProfileItemType::Exe => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !names.iter().any(|existing| existing == app_name) {
|
||||||
|
names.push(app_name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocols_for_profile(profile: &Profile) -> Vec<String> {
|
||||||
|
let mut protocols = Vec::new();
|
||||||
|
|
||||||
|
for protocol in &profile.protocols {
|
||||||
|
let value = match protocol {
|
||||||
|
Protocol::Tcp => "TCP",
|
||||||
|
Protocol::Udp => "UDP",
|
||||||
|
};
|
||||||
|
|
||||||
|
if !protocols.iter().any(|existing| existing == value) {
|
||||||
|
protocols.push(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_id_label(component_id: &ComponentId) -> &'static str {
|
||||||
|
match component_id {
|
||||||
|
ComponentId::ControlApp => "control-app",
|
||||||
|
ComponentId::Proxyfier => "proxyfier",
|
||||||
|
ComponentId::Singbox => "singbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/windows-client/src-tauri/src/adapters/proxy_router.rs
Normal file
67
apps/windows-client/src-tauri/src/adapters/proxy_router.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::models::{ComponentStatus, Profile, Target};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ProxyRouterRequest<'a> {
|
||||||
|
pub profiles: &'a [Profile],
|
||||||
|
pub targets: &'a [Target],
|
||||||
|
pub components: &'a [ComponentStatus],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProxyRouterRequest<'a> {
|
||||||
|
pub fn new(
|
||||||
|
profiles: &'a [Profile],
|
||||||
|
targets: &'a [Target],
|
||||||
|
components: &'a [ComponentStatus],
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
profiles,
|
||||||
|
targets,
|
||||||
|
components,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxyRouterGeneratedConfig {
|
||||||
|
pub adapter_id: String,
|
||||||
|
pub output_file_name: String,
|
||||||
|
pub contents: String,
|
||||||
|
pub enabled_profiles: usize,
|
||||||
|
pub routed_apps: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProxyRouterError {
|
||||||
|
pub kind: ProxyRouterErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyRouterError {
|
||||||
|
pub fn new(kind: ProxyRouterErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProxyRouterErrorKind {
|
||||||
|
EmptyProfileItems,
|
||||||
|
MissingTarget,
|
||||||
|
MissingRequiredComponent,
|
||||||
|
RequiredComponentNotRunning,
|
||||||
|
UnsupportedTargetProtocol,
|
||||||
|
Serialization,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProxyRouterAdapter {
|
||||||
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn output_file_name(&self) -> &'static str;
|
||||||
|
|
||||||
|
fn generate_config(
|
||||||
|
&self,
|
||||||
|
request: ProxyRouterRequest<'_>,
|
||||||
|
) -> Result<ProxyRouterGeneratedConfig, ProxyRouterError>;
|
||||||
|
}
|
||||||
367
apps/windows-client/src-tauri/src/adapters/singbox.rs
Normal file
367
apps/windows-client/src-tauri/src/adapters/singbox.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
use crate::models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
|
||||||
|
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
|
||||||
|
pub const DEFAULT_MIXED_INBOUND_TAG: &str = "vpn-proxy-mixed-in";
|
||||||
|
pub const DEFAULT_DIRECT_OUTBOUND_TAG: &str = "direct";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxAdapter {
|
||||||
|
log_level: String,
|
||||||
|
inbound_tag: String,
|
||||||
|
outbound_tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxAdapter {
|
||||||
|
pub fn new(
|
||||||
|
log_level: impl Into<String>,
|
||||||
|
inbound_tag: impl Into<String>,
|
||||||
|
outbound_tag: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: log_level.into(),
|
||||||
|
inbound_tag: inbound_tag.into(),
|
||||||
|
outbound_tag: outbound_tag.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_config<C>(
|
||||||
|
&self,
|
||||||
|
request: SingBoxGenerationRequest<'_>,
|
||||||
|
checker: &C,
|
||||||
|
) -> Result<SingBoxGeneratedConfig, SingBoxConfigError>
|
||||||
|
where
|
||||||
|
C: SingBoxConfigChecker,
|
||||||
|
{
|
||||||
|
let target = find_local_singbox_target(request.targets)?;
|
||||||
|
ensure_local_singbox_target(target, request.components)?;
|
||||||
|
|
||||||
|
let config = SingBoxConfig {
|
||||||
|
log: SingBoxLog {
|
||||||
|
disabled: false,
|
||||||
|
level: self.log_level.clone(),
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
inbounds: vec![SingBoxInbound {
|
||||||
|
inbound_type: "mixed".to_string(),
|
||||||
|
tag: self.inbound_tag.clone(),
|
||||||
|
listen: target.host.clone(),
|
||||||
|
listen_port: target.port,
|
||||||
|
users: Vec::new(),
|
||||||
|
set_system_proxy: false,
|
||||||
|
}],
|
||||||
|
outbounds: vec![SingBoxOutbound {
|
||||||
|
outbound_type: "direct".to_string(),
|
||||||
|
tag: self.outbound_tag.clone(),
|
||||||
|
}],
|
||||||
|
route: SingBoxRoute {
|
||||||
|
final_outbound: self.outbound_tag.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let contents = serde_json::to_string_pretty(&config).map_err(|error| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::Serialization,
|
||||||
|
format!("Не удалось сериализовать конфиг sing-box: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let check = match request.binary_path {
|
||||||
|
Some(binary_path) => Some(checker.check_config(binary_path, &contents)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SingBoxGeneratedConfig {
|
||||||
|
adapter_id: SINGBOX_ADAPTER_ID.to_string(),
|
||||||
|
output_file_name: SINGBOX_OUTPUT_FILE.to_string(),
|
||||||
|
contents,
|
||||||
|
local_target_id: target.id.clone(),
|
||||||
|
listen: target.host.clone(),
|
||||||
|
listen_port: target.port,
|
||||||
|
check,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SingBoxAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(
|
||||||
|
"info",
|
||||||
|
DEFAULT_MIXED_INBOUND_TAG,
|
||||||
|
DEFAULT_DIRECT_OUTBOUND_TAG,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SingBoxGenerationRequest<'a> {
|
||||||
|
pub targets: &'a [Target],
|
||||||
|
pub components: &'a [ComponentStatus],
|
||||||
|
pub binary_path: Option<&'a Path>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SingBoxGenerationRequest<'a> {
|
||||||
|
pub fn new(
|
||||||
|
targets: &'a [Target],
|
||||||
|
components: &'a [ComponentStatus],
|
||||||
|
binary_path: Option<&'a Path>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
targets,
|
||||||
|
components,
|
||||||
|
binary_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxGeneratedConfig {
|
||||||
|
pub adapter_id: String,
|
||||||
|
pub output_file_name: String,
|
||||||
|
pub contents: String,
|
||||||
|
pub local_target_id: String,
|
||||||
|
pub listen: String,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub check: Option<SingBoxCheckResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxCheckResult {
|
||||||
|
pub checked: bool,
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SingBoxConfigError {
|
||||||
|
pub kind: SingBoxConfigErrorKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxConfigError {
|
||||||
|
pub fn new(kind: SingBoxConfigErrorKind, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SingBoxConfigErrorKind {
|
||||||
|
MissingLocalTarget,
|
||||||
|
MissingRequiredComponent,
|
||||||
|
RequiredComponentNotRunning,
|
||||||
|
UnsupportedTarget,
|
||||||
|
Serialization,
|
||||||
|
CheckFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait SingBoxConfigChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SingBoxCommandChecker;
|
||||||
|
|
||||||
|
impl SingBoxConfigChecker for SingBoxCommandChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||||
|
let config_path = env::temp_dir().join(format!(
|
||||||
|
"vpn-proxy-sing-box-{}-{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
now_millis()
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(&config_path, config_json).map_err(|error| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!(
|
||||||
|
"Не удалось записать временный конфиг sing-box '{}': {error}",
|
||||||
|
config_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = Command::new(binary_path)
|
||||||
|
.arg("check")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(&config_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
let _ = fs::remove_file(&config_path);
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!(
|
||||||
|
"Не удалось выполнить '{} check': {error}",
|
||||||
|
binary_path.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let _ = fs::remove_file(&config_path);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let message = command_message(&stdout, &stderr);
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
format!("Проверка sing-box не прошла: {message}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: if message.is_empty() {
|
||||||
|
"Проверка sing-box прошла успешно".to_string()
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxConfig {
|
||||||
|
pub log: SingBoxLog,
|
||||||
|
pub inbounds: Vec<SingBoxInbound>,
|
||||||
|
pub outbounds: Vec<SingBoxOutbound>,
|
||||||
|
pub route: SingBoxRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxLog {
|
||||||
|
pub disabled: bool,
|
||||||
|
pub level: String,
|
||||||
|
pub timestamp: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxInbound {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub inbound_type: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub listen: String,
|
||||||
|
#[serde(rename = "listen_port")]
|
||||||
|
pub listen_port: u16,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub users: Vec<SingBoxUser>,
|
||||||
|
#[serde(rename = "set_system_proxy")]
|
||||||
|
pub set_system_proxy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxUser {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxOutbound {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub outbound_type: String,
|
||||||
|
pub tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SingBoxRoute {
|
||||||
|
#[serde(rename = "final")]
|
||||||
|
pub final_outbound: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_local_singbox_target(targets: &[Target]) -> Result<&Target, SingBoxConfigError> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.find(|target| {
|
||||||
|
target.kind == TargetKind::Local
|
||||||
|
&& target.requires_component.as_ref() == Some(&ComponentId::Singbox)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::MissingLocalTarget,
|
||||||
|
"Локальная цель, требующая sing-box, не настроена",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_local_singbox_target(
|
||||||
|
target: &Target,
|
||||||
|
components: &[ComponentStatus],
|
||||||
|
) -> Result<(), SingBoxConfigError> {
|
||||||
|
if target.kind != TargetKind::Local
|
||||||
|
|| target.protocol != ProxyProtocol::Socks5
|
||||||
|
|| target.requires_component.as_ref() != Some(&ComponentId::Singbox)
|
||||||
|
{
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::UnsupportedTarget,
|
||||||
|
format!(
|
||||||
|
"Цель '{}' должна быть локальной SOCKS5-целью, требующей sing-box",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(status) = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| component.id == ComponentId::Singbox)
|
||||||
|
else {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::MissingRequiredComponent,
|
||||||
|
format!(
|
||||||
|
"Локальная цель '{}' требует состояние компонента sing-box",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !component_is_running(status) {
|
||||||
|
return Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::RequiredComponentNotRunning,
|
||||||
|
format!(
|
||||||
|
"Локальная цель '{}' требует установленный и запущенный sing-box",
|
||||||
|
target.id
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_is_running(status: &ComponentStatus) -> bool {
|
||||||
|
status.installed && status.running && status.state == ComponentState::Running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_message(stdout: &str, stderr: &str) -> String {
|
||||||
|
let stdout = stdout.trim();
|
||||||
|
let stderr = stderr.trim();
|
||||||
|
|
||||||
|
match (stdout.is_empty(), stderr.is_empty()) {
|
||||||
|
(true, true) => String::new(),
|
||||||
|
(false, true) => stdout.to_string(),
|
||||||
|
(true, false) => stderr.to_string(),
|
||||||
|
(false, false) => format!("{stdout}\n{stderr}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
1510
apps/windows-client/src-tauri/src/commands.rs
Normal file
1510
apps/windows-client/src-tauri/src/commands.rs
Normal file
File diff suppressed because it is too large
Load Diff
413
apps/windows-client/src-tauri/src/component_detection.rs
Normal file
413
apps/windows-client/src-tauri/src/component_detection.rs
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
use crate::models::{ComponentId, ComponentState, ComponentStatus};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProxyfierEngine {
|
||||||
|
ProxiFyre,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedProxyfier {
|
||||||
|
pub engine: ProxyfierEngine,
|
||||||
|
pub name: String,
|
||||||
|
pub install_dir: PathBuf,
|
||||||
|
pub executable_path: PathBuf,
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
pub running: bool,
|
||||||
|
pub service_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RegistryInstallEntry {
|
||||||
|
pub display_name: String,
|
||||||
|
pub install_location: Option<PathBuf>,
|
||||||
|
pub display_icon: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ProxyfierDetectionHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String>;
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool;
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool;
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool;
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
pub struct SystemProxyfierDetectionHost;
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for SystemProxyfierDetectionHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String> {
|
||||||
|
env::var(name).ok().filter(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
path.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool {
|
||||||
|
let process_name = process_name.trim_end_matches(".exe");
|
||||||
|
let script = format!(
|
||||||
|
"if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}",
|
||||||
|
escape_powershell_single(process_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
powershell_bool(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool {
|
||||||
|
let script = format!(
|
||||||
|
"$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s -and $s.Status -eq 'Running') {{ 'true' }} else {{ 'false' }}",
|
||||||
|
escape_powershell_single(service_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
powershell_bool(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
read_registry_install_entries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_proxyfier_install() -> Option<DetectedProxyfier> {
|
||||||
|
detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_proxyfier_install_with_host(
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
) -> Option<DetectedProxyfier> {
|
||||||
|
let proxifyre_running = host.process_running("ProxiFyre.exe")
|
||||||
|
|| host.service_running("ProxiFyreService")
|
||||||
|
|| host.service_running("ProxiFyre");
|
||||||
|
|
||||||
|
proxyfier_candidates(host)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|candidate| candidate.into_detected(host, proxifyre_running))
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus {
|
||||||
|
match detected {
|
||||||
|
Some(proxyfier) => detected_proxyfier_component(proxyfier),
|
||||||
|
None => missing_proxyfier_component(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus {
|
||||||
|
let state = if proxyfier.running {
|
||||||
|
ComponentState::Running
|
||||||
|
} else {
|
||||||
|
ComponentState::Installed
|
||||||
|
};
|
||||||
|
let actions = match proxyfier.engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => {
|
||||||
|
if proxyfier.running {
|
||||||
|
vec![
|
||||||
|
"Применить сгенерированный конфиг".to_string(),
|
||||||
|
"Открыть папку конфига".to_string(),
|
||||||
|
"Остановить".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
"Применить сгенерированный конфиг".to_string(),
|
||||||
|
"Открыть папку конфига".to_string(),
|
||||||
|
"Запустить".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state,
|
||||||
|
installed: true,
|
||||||
|
running: proxyfier.running,
|
||||||
|
version: Some(match proxyfier.engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => "ProxiFyre найден".to_string(),
|
||||||
|
}),
|
||||||
|
path: Some(proxyfier.install_dir.display().to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_proxyfier_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["ProxiFyre нужен для маршрутизации выбранных приложений".to_string()],
|
||||||
|
actions: vec!["Установить ProxiFyre".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ProxyfierCandidate {
|
||||||
|
engine: ProxyfierEngine,
|
||||||
|
name: String,
|
||||||
|
install_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierCandidate {
|
||||||
|
fn into_detected(
|
||||||
|
self,
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
proxifyre_running: bool,
|
||||||
|
) -> Option<DetectedProxyfier> {
|
||||||
|
let executable_path = self.install_dir.join(executable_name(&self.engine));
|
||||||
|
let config_path = config_path(&self.engine, &self.install_dir);
|
||||||
|
let exists = host.path_exists(&self.install_dir)
|
||||||
|
|| host.path_exists(&executable_path)
|
||||||
|
|| config_path
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|path| host.path_exists(path));
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(DetectedProxyfier {
|
||||||
|
service_name: service_name(&self.engine).map(str::to_string),
|
||||||
|
engine: self.engine,
|
||||||
|
name: self.name,
|
||||||
|
install_dir: self.install_dir,
|
||||||
|
executable_path,
|
||||||
|
config_path,
|
||||||
|
running: proxifyre_running,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec<ProxyfierCandidate> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
push_env_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
host,
|
||||||
|
ProxyfierEngine::ProxiFyre,
|
||||||
|
"ProxiFyre",
|
||||||
|
"VPN_PROXY_PROXIFYRE_ROOT",
|
||||||
|
);
|
||||||
|
for entry in host.registry_install_entries() {
|
||||||
|
if let Some(engine) = engine_from_name(&entry.display_name) {
|
||||||
|
let install_dir = entry
|
||||||
|
.install_location
|
||||||
|
.or_else(|| entry.display_icon.and_then(|path| executable_parent(&path)));
|
||||||
|
if let Some(install_dir) = install_dir {
|
||||||
|
push_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
name: entry.display_name,
|
||||||
|
engine,
|
||||||
|
install_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for install_dir in common_install_dirs(host, "ProxiFyre") {
|
||||||
|
push_candidate(
|
||||||
|
&mut candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
engine: ProxyfierEngine::ProxiFyre,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
install_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_env_candidate(
|
||||||
|
candidates: &mut Vec<ProxyfierCandidate>,
|
||||||
|
host: &impl ProxyfierDetectionHost,
|
||||||
|
engine: ProxyfierEngine,
|
||||||
|
name: &str,
|
||||||
|
env_name: &str,
|
||||||
|
) {
|
||||||
|
if let Some(path) = host.env_var(env_name) {
|
||||||
|
push_candidate(
|
||||||
|
candidates,
|
||||||
|
ProxyfierCandidate {
|
||||||
|
engine,
|
||||||
|
name: name.to_string(),
|
||||||
|
install_dir: PathBuf::from(path),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_candidate(candidates: &mut Vec<ProxyfierCandidate>, candidate: ProxyfierCandidate) {
|
||||||
|
if !candidates.iter().any(|existing| {
|
||||||
|
existing.engine == candidate.engine
|
||||||
|
&& same_path(&existing.install_dir, &candidate.install_dir)
|
||||||
|
}) {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec<PathBuf> {
|
||||||
|
let mut dirs = vec![PathBuf::from(format!(r"C:\Tools\{folder_name}"))];
|
||||||
|
|
||||||
|
for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||||
|
if let Some(root) = host.env_var(env_name) {
|
||||||
|
dirs.push(PathBuf::from(root).join(folder_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executable_name(engine: &ProxyfierEngine) -> &'static str {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => "ProxiFyre.exe",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option<PathBuf> {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> {
|
||||||
|
match engine {
|
||||||
|
ProxyfierEngine::ProxiFyre => Some("ProxiFyreService"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn engine_from_name(name: &str) -> Option<ProxyfierEngine> {
|
||||||
|
let normalized = name.to_ascii_lowercase();
|
||||||
|
if normalized.contains("proxifyre") {
|
||||||
|
Some(ProxyfierEngine::ProxiFyre)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executable_parent(path: &Path) -> Option<PathBuf> {
|
||||||
|
path.parent().map(Path::to_path_buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_path(left: &Path, right: &Path) -> bool {
|
||||||
|
left.to_string_lossy()
|
||||||
|
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn powershell_bool(script: &str) -> bool {
|
||||||
|
Command::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||||
|
.is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct RegistryInstallJson {
|
||||||
|
display_name: Option<String>,
|
||||||
|
install_location: Option<String>,
|
||||||
|
display_icon: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_registry_install_entries() -> Vec<RegistryInstallEntry> {
|
||||||
|
let script = r#"
|
||||||
|
$paths = @(
|
||||||
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||||
|
)
|
||||||
|
$items = foreach ($path in $paths) {
|
||||||
|
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
$items |
|
||||||
|
Where-Object { $_.DisplayName -match 'ProxiFyre' } |
|
||||||
|
Select-Object DisplayName,InstallLocation,DisplayIcon |
|
||||||
|
ConvertTo-Json -Compress
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let Ok(output) = Command::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(stdout) = String::from_utf8(output.stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let stdout = stdout.trim();
|
||||||
|
if stdout.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_registry_json(stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_registry_json(json: &str) -> Vec<RegistryInstallEntry> {
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Array(entries) => entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(registry_entry_from_value)
|
||||||
|
.collect(),
|
||||||
|
entry => registry_entry_from_value(entry).into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_entry_from_value(value: serde_json::Value) -> Option<RegistryInstallEntry> {
|
||||||
|
let parsed = serde_json::from_value::<RegistryInstallJson>(value).ok()?;
|
||||||
|
let display_name = parsed.display_name?;
|
||||||
|
Some(RegistryInstallEntry {
|
||||||
|
display_name,
|
||||||
|
install_location: parsed
|
||||||
|
.install_location
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(PathBuf::from),
|
||||||
|
display_icon: parsed
|
||||||
|
.display_icon
|
||||||
|
.and_then(|value| display_icon_path(&value)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_icon_path(value: &str) -> Option<PathBuf> {
|
||||||
|
let trimmed = value.trim().trim_matches('"');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let without_icon_index = trimmed
|
||||||
|
.split_once(',')
|
||||||
|
.map(|(path, _)| path)
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"');
|
||||||
|
|
||||||
|
Some(PathBuf::from(without_icon_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_powershell_single(value: &str) -> String {
|
||||||
|
value.replace('\'', "''")
|
||||||
|
}
|
||||||
184
apps/windows-client/src-tauri/src/helper.rs
Normal file
184
apps/windows-client/src-tauri/src/helper.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
use crate::models::ComponentId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum HelperAction {
|
||||||
|
#[serde(rename = "install-control-app")]
|
||||||
|
InstallControlApp,
|
||||||
|
#[serde(rename = "install-proxyfier")]
|
||||||
|
InstallProxyfier,
|
||||||
|
#[serde(rename = "install-singbox")]
|
||||||
|
InstallSingbox,
|
||||||
|
#[serde(rename = "proxyfier.apply")]
|
||||||
|
ProxyfierApply,
|
||||||
|
#[serde(rename = "service.status")]
|
||||||
|
ServiceStatus,
|
||||||
|
#[serde(rename = "service.start")]
|
||||||
|
ServiceStart,
|
||||||
|
#[serde(rename = "service.stop")]
|
||||||
|
ServiceStop,
|
||||||
|
#[serde(rename = "service.restart")]
|
||||||
|
ServiceRestart,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperRequest {
|
||||||
|
pub action: HelperAction,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub component: Option<ComponentId>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub action: HelperAction,
|
||||||
|
pub changed: bool,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub details: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HelperCommandSpec {
|
||||||
|
pub program: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub stdin: String,
|
||||||
|
pub requires_elevation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HelperCommandOutput {
|
||||||
|
pub status_code: i32,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HelperError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HelperError {
|
||||||
|
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HelperCommandRunner {
|
||||||
|
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StructuredHelper<R> {
|
||||||
|
helper_program: PathBuf,
|
||||||
|
runner: R,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> StructuredHelper<R>
|
||||||
|
where
|
||||||
|
R: HelperCommandRunner,
|
||||||
|
{
|
||||||
|
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
|
||||||
|
Self {
|
||||||
|
helper_program: helper_program.into(),
|
||||||
|
runner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runner(&self) -> &R {
|
||||||
|
&self.runner
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
|
||||||
|
let stdin = serde_json::to_string(request)
|
||||||
|
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
|
||||||
|
let spec = HelperCommandSpec {
|
||||||
|
program: self.helper_program.clone(),
|
||||||
|
args: vec!["--json".to_string()],
|
||||||
|
stdin,
|
||||||
|
requires_elevation: helper_action_requires_elevation(&request.action),
|
||||||
|
};
|
||||||
|
let output = self.runner.run(&spec)?;
|
||||||
|
|
||||||
|
if output.status_code != 0 {
|
||||||
|
return Err(HelperError::new(
|
||||||
|
"helper_exit",
|
||||||
|
format!(
|
||||||
|
"Помощник завершился с кодом {}: {}",
|
||||||
|
output.status_code, output.stderr
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_helper_response(&output.stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
|
||||||
|
serde_json::from_str(stdout).map_err(|error| {
|
||||||
|
HelperError::new(
|
||||||
|
"helper_response_decode",
|
||||||
|
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_request(component: ComponentId) -> HelperRequest {
|
||||||
|
let action = match component {
|
||||||
|
ComponentId::ControlApp => HelperAction::InstallControlApp,
|
||||||
|
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
|
||||||
|
ComponentId::Singbox => HelperAction::InstallSingbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
HelperRequest {
|
||||||
|
action,
|
||||||
|
component: Some(component),
|
||||||
|
payload: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
|
||||||
|
HelperRequest {
|
||||||
|
action,
|
||||||
|
component: Some(component),
|
||||||
|
payload: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxifyre_apply_request(
|
||||||
|
config_path: impl AsRef<Path>,
|
||||||
|
service_name: impl Into<String>,
|
||||||
|
) -> HelperRequest {
|
||||||
|
HelperRequest {
|
||||||
|
action: HelperAction::ProxyfierApply,
|
||||||
|
component: Some(ComponentId::Proxyfier),
|
||||||
|
payload: json!({
|
||||||
|
"configPath": config_path.as_ref().display().to_string(),
|
||||||
|
"serviceName": service_name.into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
|
||||||
|
matches!(
|
||||||
|
action,
|
||||||
|
HelperAction::InstallControlApp
|
||||||
|
| HelperAction::InstallProxyfier
|
||||||
|
| HelperAction::InstallSingbox
|
||||||
|
| HelperAction::ProxyfierApply
|
||||||
|
| HelperAction::ServiceStart
|
||||||
|
| HelperAction::ServiceStop
|
||||||
|
| HelperAction::ServiceRestart
|
||||||
|
)
|
||||||
|
}
|
||||||
5
apps/windows-client/src-tauri/src/lib.rs
Normal file
5
apps/windows-client/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||||
|
}
|
||||||
46
apps/windows-client/src-tauri/src/main.rs
Normal file
46
apps/windows-client/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod activity;
|
||||||
|
mod commands;
|
||||||
|
mod component_detection;
|
||||||
|
mod models;
|
||||||
|
mod storage;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
mod adapters {
|
||||||
|
pub mod proxifyre;
|
||||||
|
pub mod proxy_router;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod proxifyre {
|
||||||
|
pub use crate::adapters::proxifyre::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod proxy_router {
|
||||||
|
pub use crate::adapters::proxy_router::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(commands::CommandState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::get_status,
|
||||||
|
commands::get_profiles,
|
||||||
|
commands::get_saved_state,
|
||||||
|
commands::save_profile,
|
||||||
|
commands::get_targets,
|
||||||
|
commands::save_target,
|
||||||
|
commands::get_components,
|
||||||
|
commands::resolve_profile_preview,
|
||||||
|
commands::apply_profiles,
|
||||||
|
commands::get_logs,
|
||||||
|
commands::open_config_location,
|
||||||
|
commands::start_proxifyre_service,
|
||||||
|
commands::stop_proxifyre_service
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("не удалось запустить клиент VPN Proxy для Windows");
|
||||||
|
}
|
||||||
167
apps/windows-client/src-tauri/src/models.rs
Normal file
167
apps/windows-client/src-tauri/src/models.rs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum Protocol {
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ProfileItemType {
|
||||||
|
Process,
|
||||||
|
Folder,
|
||||||
|
Exe,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TargetKind {
|
||||||
|
Local,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ProxyProtocol {
|
||||||
|
Socks5,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ComponentId {
|
||||||
|
ControlApp,
|
||||||
|
Proxyfier,
|
||||||
|
Singbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ComponentState {
|
||||||
|
Installed,
|
||||||
|
Missing,
|
||||||
|
Stopped,
|
||||||
|
Running,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItemInput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recursive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileInput {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_target_id")]
|
||||||
|
pub target_id: String,
|
||||||
|
#[serde(default = "default_protocols")]
|
||||||
|
pub protocols: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Vec<ProfileItemInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileItem {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: ProfileItemType,
|
||||||
|
pub value: String,
|
||||||
|
pub recursive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub target_id: String,
|
||||||
|
pub protocols: Vec<Protocol>,
|
||||||
|
pub items: Vec<ProfileItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TargetInput {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default = "default_target_kind")]
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default = "default_proxy_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requires_component: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Target {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: TargetKind,
|
||||||
|
pub protocol: ProxyProtocol,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub requires_component: Option<ComponentId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ComponentStatus {
|
||||||
|
pub id: ComponentId,
|
||||||
|
pub name: String,
|
||||||
|
pub state: ComponentState,
|
||||||
|
pub installed: bool,
|
||||||
|
pub running: bool,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub problems: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ActivityEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub at: String,
|
||||||
|
pub level: ActivityLevel,
|
||||||
|
pub title: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ActivityLevel {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_id() -> String {
|
||||||
|
"local-singbox".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocols() -> Vec<String> {
|
||||||
|
vec!["TCP".to_string(), "UDP".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_kind() -> String {
|
||||||
|
"external".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_proxy_protocol() -> String {
|
||||||
|
"socks5".to_string()
|
||||||
|
}
|
||||||
187
apps/windows-client/src-tauri/src/storage.rs
Normal file
187
apps/windows-client/src-tauri/src/storage.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
|
||||||
|
use crate::models::{ActivityEntry, ComponentStatus, Profile, Target};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, ErrorKind};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn default_config_root() -> PathBuf {
|
||||||
|
PathBuf::from(r"C:\ProgramData\VpnProxy")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StoragePaths {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
pub state_dir: PathBuf,
|
||||||
|
pub generated_dir: PathBuf,
|
||||||
|
pub profiles_file: PathBuf,
|
||||||
|
pub targets_file: PathBuf,
|
||||||
|
pub components_file: PathBuf,
|
||||||
|
pub activity_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoragePaths {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
let root = root.into();
|
||||||
|
let config_dir = root.join("config");
|
||||||
|
let state_dir = root.join("state");
|
||||||
|
let generated_dir = root.join("generated");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
profiles_file: config_dir.join("profiles.json"),
|
||||||
|
targets_file: config_dir.join("targets.json"),
|
||||||
|
components_file: config_dir.join("components.json"),
|
||||||
|
activity_file: state_dir.join("activity.json"),
|
||||||
|
config_dir,
|
||||||
|
state_dir,
|
||||||
|
generated_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StoragePaths {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(default_config_root())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct JsonStorage {
|
||||||
|
paths: StoragePaths,
|
||||||
|
activity_limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonStorage {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self::with_activity_limit(root, DEFAULT_ACTIVITY_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_activity_limit(root: impl Into<PathBuf>, activity_limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
paths: StoragePaths::new(root),
|
||||||
|
activity_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paths(&self) -> &StoragePaths {
|
||||||
|
&self.paths
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_dirs(&self) -> io::Result<()> {
|
||||||
|
fs::create_dir_all(&self.paths.config_dir)?;
|
||||||
|
fs::create_dir_all(&self.paths.state_dir)?;
|
||||||
|
fs::create_dir_all(&self.paths.generated_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_profiles(&self) -> io::Result<Vec<Profile>> {
|
||||||
|
self.read_json_or_default(&self.paths.profiles_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_profiles(&self, profiles: &[Profile]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.profiles_file, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_targets(&self) -> io::Result<Vec<Target>> {
|
||||||
|
self.read_json_or_default(&self.paths.targets_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_targets(&self, targets: &[Target]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.targets_file, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
|
||||||
|
self.read_json_or_default(&self.paths.components_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_components(&self, components: &[ComponentStatus]) -> io::Result<()> {
|
||||||
|
self.write_json(&self.paths.components_file, components)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
|
||||||
|
let entries = self.read_json_or_default(&self.paths.activity_file)?;
|
||||||
|
Ok(cap_activity(entries, self.activity_limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_activity(&self, entries: &[ActivityEntry]) -> io::Result<()> {
|
||||||
|
let entries = cap_activity(entries.to_vec(), self.activity_limit);
|
||||||
|
self.write_json(&self.paths.activity_file, &entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_activity(&self, entry: ActivityEntry) -> io::Result<Vec<ActivityEntry>> {
|
||||||
|
let entries = self.read_activity()?;
|
||||||
|
let entries = append_activity(entries, entry, self.activity_limit);
|
||||||
|
self.write_json(&self.paths.activity_file, &entries)?;
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_or_default<T>(&self, path: &Path) -> io::Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Default,
|
||||||
|
{
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(contents) => match serde_json::from_str(&contents) {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(_) => Ok(T::default()),
|
||||||
|
},
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json<T>(&self, path: &Path, value: &T) -> io::Result<()>
|
||||||
|
where
|
||||||
|
T: Serialize + ?Sized,
|
||||||
|
{
|
||||||
|
let contents = serde_json::to_vec_pretty(value)
|
||||||
|
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
|
||||||
|
write_atomic(path, &contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JsonStorage {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(default_config_root())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup_path(path: &Path) -> PathBuf {
|
||||||
|
sibling_with_suffix(path, "bak")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_path(path: &Path) -> PathBuf {
|
||||||
|
sibling_with_suffix(path, "tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("storage.json");
|
||||||
|
|
||||||
|
path.with_file_name(format!("{file_name}.{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_path = temp_path(path);
|
||||||
|
fs::write(&temp_path, contents)?;
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
fs::copy(path, backup_path(path))?;
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::rename(&temp_path, path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) => {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
apps/windows-client/src-tauri/src/validation.rs
Normal file
223
apps/windows-client/src-tauri/src/validation.rs
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
use crate::models::{
|
||||||
|
ComponentId, Profile, ProfileInput, ProfileItem, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
|
Target, TargetInput, TargetKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub field: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ValidationResult<T> = Result<T, Vec<ValidationError>>;
|
||||||
|
|
||||||
|
fn error(field: impl Into<String>, message: impl Into<String>) -> ValidationError {
|
||||||
|
ValidationError {
|
||||||
|
field: field.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean(value: &str) -> String {
|
||||||
|
value.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug(value: &str, fallback: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut previous_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().to_lowercase().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
output.push(ch);
|
||||||
|
previous_dash = false;
|
||||||
|
} else if !previous_dash {
|
||||||
|
output.push('-');
|
||||||
|
previous_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = output.trim_matches('-').to_string();
|
||||||
|
if output.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_name(value: &str) -> String {
|
||||||
|
let base = value.trim().rsplit(['\\', '/']).next().unwrap_or("").trim();
|
||||||
|
base.strip_suffix(".exe")
|
||||||
|
.or_else(|| base.strip_suffix(".EXE"))
|
||||||
|
.unwrap_or(base)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_protocol(value: &str) -> Result<Protocol, ValidationError> {
|
||||||
|
match value.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"TCP" => Ok(Protocol::Tcp),
|
||||||
|
"UDP" => Ok(Protocol::Udp),
|
||||||
|
_ => Err(error(
|
||||||
|
"protocols",
|
||||||
|
format!("Неподдерживаемый протокол: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_profile_item_type(value: &str) -> Result<ProfileItemType, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"process" => Ok(ProfileItemType::Process),
|
||||||
|
"folder" => Ok(ProfileItemType::Folder),
|
||||||
|
"exe" => Ok(ProfileItemType::Exe),
|
||||||
|
_ => Err(error(
|
||||||
|
"items.type",
|
||||||
|
format!("Неподдерживаемый тип элемента: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_target_kind(value: &str) -> Result<TargetKind, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"local" => Ok(TargetKind::Local),
|
||||||
|
"external" => Ok(TargetKind::External),
|
||||||
|
_ => Err(error("kind", format!("Неподдерживаемый тип цели: {value}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_proxy_protocol(value: &str) -> Result<ProxyProtocol, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"socks5" => Ok(ProxyProtocol::Socks5),
|
||||||
|
"http" => Ok(ProxyProtocol::Http),
|
||||||
|
_ => Err(error(
|
||||||
|
"protocol",
|
||||||
|
format!("Неподдерживаемый протокол прокси: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_component_id(value: &str) -> Result<ComponentId, ValidationError> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"control-app" | "controlapp" => Ok(ComponentId::ControlApp),
|
||||||
|
"proxyfier" => Ok(ComponentId::Proxyfier),
|
||||||
|
"singbox" | "sing-box" => Ok(ComponentId::Singbox),
|
||||||
|
_ => Err(error(
|
||||||
|
"requires_component",
|
||||||
|
format!("Неподдерживаемый компонент: {value}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_profile(input: ProfileInput) -> ValidationResult<Profile> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let name = clean(&input.name);
|
||||||
|
if name.is_empty() {
|
||||||
|
errors.push(error("name", "Укажите название профиля"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_id = clean(&input.target_id);
|
||||||
|
if target_id.is_empty() {
|
||||||
|
errors.push(error("target_id", "Укажите цель профиля"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut protocols = Vec::new();
|
||||||
|
for value in input.protocols {
|
||||||
|
match parse_protocol(&value) {
|
||||||
|
Ok(protocol) if !protocols.contains(&protocol) => protocols.push(protocol),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => errors.push(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if protocols.is_empty() {
|
||||||
|
errors.push(error("protocols", "Выберите хотя бы один протокол"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for raw_item in input.items {
|
||||||
|
let item_type = match parse_profile_item_type(&raw_item.item_type) {
|
||||||
|
Ok(item_type) => item_type,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = match item_type {
|
||||||
|
ProfileItemType::Process => process_name(&raw_item.value),
|
||||||
|
ProfileItemType::Folder | ProfileItemType::Exe => clean(&raw_item.value),
|
||||||
|
};
|
||||||
|
if value.is_empty() {
|
||||||
|
errors.push(error("items.value", "Укажите значение элемента профиля"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recursive =
|
||||||
|
matches!(item_type, ProfileItemType::Folder) && raw_item.recursive.unwrap_or(true);
|
||||||
|
items.push(ProfileItem {
|
||||||
|
item_type,
|
||||||
|
value,
|
||||||
|
recursive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Profile {
|
||||||
|
id: slug(input.id.as_deref().unwrap_or(&name), "profile"),
|
||||||
|
name,
|
||||||
|
enabled: input.enabled,
|
||||||
|
target_id,
|
||||||
|
protocols,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_target(input: TargetInput) -> ValidationResult<Target> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let name = clean(&input.name);
|
||||||
|
let host = clean(&input.host);
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
errors.push(error("name", "Укажите название цели"));
|
||||||
|
}
|
||||||
|
if host.is_empty() {
|
||||||
|
errors.push(error("host", "Укажите хост цели"));
|
||||||
|
}
|
||||||
|
if input.port == 0 || input.port > u16::MAX as u32 {
|
||||||
|
errors.push(error("port", "Порт цели должен быть от 1 до 65535"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = parse_target_kind(&input.kind).unwrap_or_else(|err| {
|
||||||
|
errors.push(err);
|
||||||
|
TargetKind::External
|
||||||
|
});
|
||||||
|
let protocol = parse_proxy_protocol(&input.protocol).unwrap_or_else(|err| {
|
||||||
|
errors.push(err);
|
||||||
|
ProxyProtocol::Socks5
|
||||||
|
});
|
||||||
|
let requires_component = match input.requires_component {
|
||||||
|
Some(value) if !value.trim().is_empty() => match parse_component_id(&value) {
|
||||||
|
Ok(component) => Some(component),
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(err);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Target {
|
||||||
|
id: slug(input.id.as_deref().unwrap_or(&name), "target"),
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
protocol,
|
||||||
|
host,
|
||||||
|
port: input.port as u16,
|
||||||
|
requires_component,
|
||||||
|
})
|
||||||
|
}
|
||||||
37
apps/windows-client/src-tauri/tauri.conf.json
Normal file
37
apps/windows-client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "VPN Proxy для Windows",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "ru.dokops.vpn-proxy.windows",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "VPN Proxy для Windows",
|
||||||
|
"width": 1120,
|
||||||
|
"height": 760,
|
||||||
|
"minWidth": 760,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
447
apps/windows-client/src-tauri/tests/command_tests.rs
Normal file
447
apps/windows-client/src-tauri/tests/command_tests.rs
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
#[path = "../src/activity.rs"]
|
||||||
|
mod activity;
|
||||||
|
#[path = "../src/commands.rs"]
|
||||||
|
mod commands;
|
||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/storage.rs"]
|
||||||
|
mod storage;
|
||||||
|
#[path = "../src/validation.rs"]
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use commands::{
|
||||||
|
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
|
||||||
|
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
|
||||||
|
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
|
||||||
|
TargetInputDto,
|
||||||
|
};
|
||||||
|
use component_detection::{
|
||||||
|
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::ProxiFyreAdapter;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use storage::JsonStorage;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_commands_normalize_and_persist_profile_and_target() {
|
||||||
|
let root = test_root("save");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let target = save_target_to_storage(
|
||||||
|
&storage,
|
||||||
|
TargetInputDto {
|
||||||
|
id: Some("Home Gateway".to_string()),
|
||||||
|
name: " Home Gateway ".to_string(),
|
||||||
|
kind: Some("external".to_string()),
|
||||||
|
protocol: Some("socks5".to_string()),
|
||||||
|
host: " 192.168.50.111 ".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("target command should normalize");
|
||||||
|
let profile = save_profile_to_storage(
|
||||||
|
&storage,
|
||||||
|
ProfileInputDto {
|
||||||
|
id: Some("Discord".to_string()),
|
||||||
|
name: " Discord ".to_string(),
|
||||||
|
enabled: Some(true),
|
||||||
|
target_id: Some("home-gateway".to_string()),
|
||||||
|
protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]),
|
||||||
|
items: Some(vec![ProfileItemInputDto {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("profile command should normalize");
|
||||||
|
|
||||||
|
assert_eq!(target.id, "home-gateway");
|
||||||
|
assert_eq!(profile.id, "discord");
|
||||||
|
assert_eq!(profile.target_id, "home-gateway");
|
||||||
|
assert_eq!(profile.items[0].value, "Discord");
|
||||||
|
|
||||||
|
let status = build_status(&storage).expect("status command should read stored state");
|
||||||
|
assert_eq!(
|
||||||
|
status.route_line,
|
||||||
|
"Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080"
|
||||||
|
);
|
||||||
|
assert_eq!(status.active_profile_count, 1);
|
||||||
|
assert_eq!(status.routed_app_count, 1);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
|
||||||
|
let preview = resolve_preview(ProfileInputDto {
|
||||||
|
id: Some("Game".to_string()),
|
||||||
|
name: "Game".to_string(),
|
||||||
|
enabled: Some(true),
|
||||||
|
target_id: Some("home-gateway".to_string()),
|
||||||
|
protocols: Some(vec!["TCP".to_string()]),
|
||||||
|
items: Some(vec![
|
||||||
|
ProfileItemInputDto {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
},
|
||||||
|
ProfileItemInputDto {
|
||||||
|
item_type: "folder".to_string(),
|
||||||
|
value: r"C:\Games\Launcher".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
.expect("preview should normalize profile input");
|
||||||
|
|
||||||
|
assert_eq!(preview.profile_id, "game");
|
||||||
|
assert_eq!(preview.apps.len(), 2);
|
||||||
|
assert_eq!(preview.apps[0].app_name, "Discord");
|
||||||
|
assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder);
|
||||||
|
assert!(preview
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|warning| warning.contains("Сканирование папок отложено")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||||
|
let root = test_root("apply");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_profiles(&[discord_profile("home-gateway")])
|
||||||
|
.expect("write profiles");
|
||||||
|
storage
|
||||||
|
.write_targets(&[external_socks5_target()])
|
||||||
|
.expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&[proxyfier_running(), singbox_missing()])
|
||||||
|
.expect("write components");
|
||||||
|
|
||||||
|
let response = apply_profiles_with_services(
|
||||||
|
&storage,
|
||||||
|
&ProxiFyreAdapter::default(),
|
||||||
|
&MockApplyHelper,
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect("apply command should generate config and call helper");
|
||||||
|
|
||||||
|
let generated_path = PathBuf::from(&response.generated_config_path);
|
||||||
|
let generated_contents = fs::read_to_string(&generated_path).expect("read generated config");
|
||||||
|
let activity = storage.read_activity().expect("read activity");
|
||||||
|
|
||||||
|
assert!(response.success);
|
||||||
|
assert!(response.changed);
|
||||||
|
assert_eq!(response.adapter_id, "proxifyre");
|
||||||
|
assert_eq!(response.enabled_profiles, 1);
|
||||||
|
assert_eq!(response.routed_apps, 1);
|
||||||
|
assert_eq!(response.helper.action, "proxyfier.apply.mock");
|
||||||
|
assert!(generated_contents.contains("\"appNames\""));
|
||||||
|
assert!(generated_contents.contains("Discord"));
|
||||||
|
assert!(generated_path.ends_with("proxifyre-app-config.json"));
|
||||||
|
assert_eq!(activity.len(), 1);
|
||||||
|
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
|
||||||
|
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||||
|
let root = test_root("missing-singbox");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage
|
||||||
|
.write_profiles(&[discord_profile("local-singbox")])
|
||||||
|
.expect("write profiles");
|
||||||
|
storage
|
||||||
|
.write_targets(&[local_singbox_target()])
|
||||||
|
.expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&[singbox_missing()])
|
||||||
|
.expect("write components");
|
||||||
|
|
||||||
|
let error = apply_profiles_with_services(
|
||||||
|
&storage,
|
||||||
|
&ProxiFyreAdapter::default(),
|
||||||
|
&MockApplyHelper,
|
||||||
|
&FixedClock,
|
||||||
|
)
|
||||||
|
.expect_err("missing sing-box should block local target apply");
|
||||||
|
let activity = storage.read_activity().expect("read blocked activity");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "required_component_not_running");
|
||||||
|
assert_eq!(activity.len(), 1);
|
||||||
|
assert_eq!(activity[0].level, models::ActivityLevel::Error);
|
||||||
|
assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано");
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_status_merges_detected_existing_proxifyre() {
|
||||||
|
let components = resolve_component_statuses(
|
||||||
|
Vec::new(),
|
||||||
|
Some(DetectedProxyfier {
|
||||||
|
engine: ProxyfierEngine::ProxiFyre,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||||
|
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
|
||||||
|
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
|
||||||
|
running: true,
|
||||||
|
service_name: Some("ProxiFyreService".to_string()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let proxyfier = components
|
||||||
|
.iter()
|
||||||
|
.find(|component| component.id == ComponentId::Proxyfier)
|
||||||
|
.expect("proxyfier component");
|
||||||
|
|
||||||
|
assert_eq!(proxyfier.state, ComponentState::Running);
|
||||||
|
assert!(proxyfier.installed);
|
||||||
|
assert!(proxyfier.running);
|
||||||
|
assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||||
|
assert!(proxyfier.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||||
|
let root = test_root("detected-proxifyre");
|
||||||
|
let install_dir = root.join("ProxiFyre");
|
||||||
|
fs::create_dir_all(&install_dir).expect("install dir");
|
||||||
|
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
|
||||||
|
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
|
||||||
|
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||||
|
let host = DetectionHost::new()
|
||||||
|
.with_registry("ProxiFyre", &install_dir)
|
||||||
|
.with_path(&install_dir)
|
||||||
|
.with_path(&install_dir.join("ProxiFyre.exe"));
|
||||||
|
let helper = DetectedProxyApplyHelper::new(host);
|
||||||
|
|
||||||
|
let result = helper
|
||||||
|
.apply_proxy_config(HelperApplyRequest {
|
||||||
|
adapter_id: "proxifyre",
|
||||||
|
config_path: &generated_config,
|
||||||
|
config_contents: r#"{"proxies":[]}"#,
|
||||||
|
})
|
||||||
|
.expect("detected helper should apply");
|
||||||
|
|
||||||
|
let applied =
|
||||||
|
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.changed);
|
||||||
|
assert_eq!(result.action, "proxifyre.apply-detected-config");
|
||||||
|
assert_eq!(applied, r#"{"proxies":[]}"#);
|
||||||
|
assert!(install_dir.join("app-config.json.bak").exists());
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detected_proxy_apply_helper_ignores_plain_proxifier_install() {
|
||||||
|
let root = test_root("detected-proxifier");
|
||||||
|
let install_dir = root.join("Proxifier");
|
||||||
|
fs::create_dir_all(&install_dir).expect("install dir");
|
||||||
|
fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe");
|
||||||
|
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||||
|
let host = DetectionHost::new()
|
||||||
|
.with_registry("Proxifier", &install_dir)
|
||||||
|
.with_path(&install_dir)
|
||||||
|
.with_path(&install_dir.join("Proxifier.exe"));
|
||||||
|
let helper = DetectedProxyApplyHelper::new(host);
|
||||||
|
|
||||||
|
let result = helper
|
||||||
|
.apply_proxy_config(HelperApplyRequest {
|
||||||
|
adapter_id: "proxifyre",
|
||||||
|
config_path: &generated_config,
|
||||||
|
config_contents: r#"{"proxies":[]}"#,
|
||||||
|
})
|
||||||
|
.expect("plain Proxifier should be ignored and config should be staged");
|
||||||
|
|
||||||
|
assert!(result.success);
|
||||||
|
assert!(result.changed);
|
||||||
|
assert_eq!(result.action, "proxifyre.stage-generated-config");
|
||||||
|
assert!(result
|
||||||
|
.message
|
||||||
|
.contains("совместимая установка ProxiFyre не найдена"));
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockApplyHelper;
|
||||||
|
|
||||||
|
impl ProxyApplyHelper for MockApplyHelper {
|
||||||
|
fn apply_proxy_config(
|
||||||
|
&self,
|
||||||
|
request: HelperApplyRequest<'_>,
|
||||||
|
) -> Result<HelperApplyResult, CommandError> {
|
||||||
|
assert_eq!(request.adapter_id, "proxifyre");
|
||||||
|
assert!(request.config_contents.contains("Discord"));
|
||||||
|
assert!(request.config_path.ends_with("proxifyre-app-config.json"));
|
||||||
|
|
||||||
|
Ok(HelperApplyResult {
|
||||||
|
success: true,
|
||||||
|
changed: true,
|
||||||
|
action: "proxyfier.apply.mock".to_string(),
|
||||||
|
message: "Mock helper accepted generated ProxiFyre config".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedClock;
|
||||||
|
|
||||||
|
impl Clock for FixedClock {
|
||||||
|
fn now(&self) -> String {
|
||||||
|
"2026-07-03T00:00:00Z".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DetectionHost {
|
||||||
|
paths: HashSet<String>,
|
||||||
|
registry: Vec<RegistryInstallEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectionHost {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_path(mut self, path: &Path) -> Self {
|
||||||
|
self.paths.insert(normalize_path(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self {
|
||||||
|
self.registry.push(RegistryInstallEntry {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
install_location: Some(install_location.to_path_buf()),
|
||||||
|
display_icon: None,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for DetectionHost {
|
||||||
|
fn env_var(&self, _name: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
self.paths.contains(&normalize_path(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, _process_name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, _service_name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
self.registry.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &Path) -> String {
|
||||||
|
path.display()
|
||||||
|
.to_string()
|
||||||
|
.replace('/', "\\")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_root(name: &str) -> PathBuf {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system clock before unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
|
||||||
|
std::env::temp_dir().join(format!("vpn-proxy-commands-{name}-{timestamp}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(root: &Path) {
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Домашний шлюз".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Локальный sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxyfier_running() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("2.2.1".to_string()),
|
||||||
|
path: Some(r"C:\Tools\ProxiFyre".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn singbox_missing() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Локальный sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Локальный sing-box не установлен".to_string()],
|
||||||
|
actions: vec!["Установить локальный sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apps/windows-client/src-tauri/tests/component_detection_tests.rs
Normal file
146
apps/windows-client/src-tauri/tests/component_detection_tests.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#[path = "../src/component_detection.rs"]
|
||||||
|
mod component_detection;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use component_detection::{
|
||||||
|
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
|
||||||
|
ProxyfierEngine, RegistryInstallEntry,
|
||||||
|
};
|
||||||
|
use models::ComponentState;
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_existing_proxifyre_from_registry_install_location() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||||
|
.with_path(r"C:\Tools\ProxiFyre")
|
||||||
|
.with_service("ProxiFyreService");
|
||||||
|
|
||||||
|
let detected = detect_proxyfier_install_with_host(&host)
|
||||||
|
.expect("existing ProxiFyre install should be detected");
|
||||||
|
|
||||||
|
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||||
|
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
|
||||||
|
assert_eq!(
|
||||||
|
detected.config_path,
|
||||||
|
Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json"))
|
||||||
|
);
|
||||||
|
assert!(detected.running);
|
||||||
|
|
||||||
|
let component = proxyfier_component_from_detection(Some(&detected));
|
||||||
|
assert_eq!(component.state, ComponentState::Running);
|
||||||
|
assert!(component.installed);
|
||||||
|
assert!(component.running);
|
||||||
|
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||||
|
assert!(component.problems.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_plain_proxifier_install() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
|
||||||
|
.with_path(r"C:\Program Files\Proxifier")
|
||||||
|
.with_process("Proxifier.exe");
|
||||||
|
|
||||||
|
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_can_point_to_portable_proxifyre_install() {
|
||||||
|
let host = MockHost::new()
|
||||||
|
.with_env("VPN_PROXY_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
|
||||||
|
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
|
||||||
|
|
||||||
|
let detected = detect_proxyfier_install_with_host(&host)
|
||||||
|
.expect("env override should be checked before common paths");
|
||||||
|
|
||||||
|
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||||
|
assert_eq!(
|
||||||
|
detected.executable_path,
|
||||||
|
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_proxyfier_returns_install_action_status() {
|
||||||
|
let component = proxyfier_component_from_detection(None);
|
||||||
|
|
||||||
|
assert_eq!(component.state, ComponentState::Missing);
|
||||||
|
assert!(!component.installed);
|
||||||
|
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MockHost {
|
||||||
|
env: HashMap<String, String>,
|
||||||
|
paths: HashSet<String>,
|
||||||
|
processes: HashSet<String>,
|
||||||
|
services: HashSet<String>,
|
||||||
|
registry: Vec<RegistryInstallEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockHost {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_env(mut self, name: &str, value: &str) -> Self {
|
||||||
|
self.env.insert(name.to_string(), value.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_path(mut self, path: &str) -> Self {
|
||||||
|
self.paths.insert(normalize_path(path));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_process(mut self, process: &str) -> Self {
|
||||||
|
self.processes.insert(process.to_ascii_lowercase());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_service(mut self, service: &str) -> Self {
|
||||||
|
self.services.insert(service.to_ascii_lowercase());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
|
||||||
|
self.registry.push(RegistryInstallEntry {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
install_location: Some(PathBuf::from(install_location)),
|
||||||
|
display_icon: None,
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyfierDetectionHost for MockHost {
|
||||||
|
fn env_var(&self, name: &str) -> Option<String> {
|
||||||
|
self.env.get(name).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_exists(&self, path: &Path) -> bool {
|
||||||
|
self.paths
|
||||||
|
.contains(&normalize_path(&path.display().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_running(&self, process_name: &str) -> bool {
|
||||||
|
self.processes.contains(&process_name.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_running(&self, service_name: &str) -> bool {
|
||||||
|
self.services.contains(&service_name.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||||
|
self.registry.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_path(path: &str) -> String {
|
||||||
|
path.replace('/', "\\").to_ascii_lowercase()
|
||||||
|
}
|
||||||
128
apps/windows-client/src-tauri/tests/domain_tests.rs
Normal file
128
apps/windows-client/src-tauri/tests/domain_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/validation.rs"]
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
|
||||||
|
TargetInput, TargetKind,
|
||||||
|
};
|
||||||
|
use validation::{normalize_profile, normalize_target};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_profile_source_items() {
|
||||||
|
let profile = normalize_profile(ProfileInput {
|
||||||
|
id: Some("Discord + Vesktop".to_string()),
|
||||||
|
name: " Discord + Vesktop ".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: " home-gateway ".to_string(),
|
||||||
|
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
|
||||||
|
items: vec![
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord.exe".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
},
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "folder".to_string(),
|
||||||
|
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
ProfileItemInput {
|
||||||
|
item_type: "exe".to_string(),
|
||||||
|
value: "C:\\Games\\Game\\game.exe".to_string(),
|
||||||
|
recursive: Some(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect("profile should normalize");
|
||||||
|
|
||||||
|
assert_eq!(profile.id, "discord-vesktop");
|
||||||
|
assert_eq!(profile.name, "Discord + Vesktop");
|
||||||
|
assert_eq!(profile.target_id, "home-gateway");
|
||||||
|
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
|
||||||
|
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
|
||||||
|
assert_eq!(profile.items[0].value, "Discord");
|
||||||
|
assert!(!profile.items[0].recursive);
|
||||||
|
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
|
||||||
|
assert!(profile.items[1].recursive);
|
||||||
|
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
|
||||||
|
assert!(!profile.items[2].recursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_profile_protocols() {
|
||||||
|
let error = normalize_profile(ProfileInput {
|
||||||
|
id: None,
|
||||||
|
name: "Bad protocol".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: "home-gateway".to_string(),
|
||||||
|
protocols: vec!["icmp".to_string()],
|
||||||
|
items: vec![ProfileItemInput {
|
||||||
|
item_type: "process".to_string(),
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: None,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.expect_err("unsupported protocol should fail");
|
||||||
|
|
||||||
|
assert!(error.iter().any(|item| item.field == "protocols"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_external_target_without_local_singbox() {
|
||||||
|
let target = normalize_target(TargetInput {
|
||||||
|
id: Some("Home Gateway".to_string()),
|
||||||
|
name: " Home Gateway ".to_string(),
|
||||||
|
kind: "external".to_string(),
|
||||||
|
protocol: "socks5".to_string(),
|
||||||
|
host: " 192.168.50.111 ".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
})
|
||||||
|
.expect("external target should normalize");
|
||||||
|
|
||||||
|
assert_eq!(target.id, "home-gateway");
|
||||||
|
assert_eq!(target.kind, TargetKind::External);
|
||||||
|
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
||||||
|
assert_eq!(target.host, "192.168.50.111");
|
||||||
|
assert_eq!(target.port, 8080);
|
||||||
|
assert_eq!(target.requires_component, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_singbox_target_can_exist_before_component_is_installed() {
|
||||||
|
let target = normalize_target(TargetInput {
|
||||||
|
id: Some("local-singbox".to_string()),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: "local".to_string(),
|
||||||
|
protocol: "socks5".to_string(),
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some("singbox".to_string()),
|
||||||
|
})
|
||||||
|
.expect("local target definition should not require installed component");
|
||||||
|
|
||||||
|
assert_eq!(target.kind, TargetKind::Local);
|
||||||
|
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_target_fields() {
|
||||||
|
let error = normalize_target(TargetInput {
|
||||||
|
id: None,
|
||||||
|
name: "".to_string(),
|
||||||
|
kind: "external".to_string(),
|
||||||
|
protocol: "ftp".to_string(),
|
||||||
|
host: "".to_string(),
|
||||||
|
port: 70_000,
|
||||||
|
requires_component: Some("unknown".to_string()),
|
||||||
|
})
|
||||||
|
.expect_err("invalid target should fail");
|
||||||
|
|
||||||
|
assert!(error.iter().any(|item| item.field == "name"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "host"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "port"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||||
|
assert!(error.iter().any(|item| item.field == "requires_component"));
|
||||||
|
}
|
||||||
139
apps/windows-client/src-tauri/tests/helper_tests.rs
Normal file
139
apps/windows-client/src-tauri/tests/helper_tests.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
#[path = "../src/helper.rs"]
|
||||||
|
mod helper;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use helper::{
|
||||||
|
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||||
|
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||||
|
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||||
|
};
|
||||||
|
use models::ComponentId;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_helper_serializes_request_and_parses_json_response() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 0,
|
||||||
|
stdout: serde_json::to_string(&HelperResponse {
|
||||||
|
success: true,
|
||||||
|
action: HelperAction::ProxyfierApply,
|
||||||
|
changed: true,
|
||||||
|
message: "Applied".to_string(),
|
||||||
|
details: json!({ "serviceName": "ProxiFyreService" }),
|
||||||
|
})
|
||||||
|
.expect("response json"),
|
||||||
|
stderr: String::new(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
|
||||||
|
let response = helper
|
||||||
|
.execute(&proxifyre_apply_request(
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
|
||||||
|
"ProxiFyreService",
|
||||||
|
))
|
||||||
|
.expect("helper response");
|
||||||
|
|
||||||
|
assert!(response.success);
|
||||||
|
assert_eq!(response.action, HelperAction::ProxyfierApply);
|
||||||
|
assert_eq!(response.details["serviceName"], "ProxiFyreService");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn helper_runner_receives_json_stdin_and_elevation_flag() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 0,
|
||||||
|
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
|
||||||
|
stderr: String::new(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
|
||||||
|
|
||||||
|
let _ = helper.execute(&request).expect("helper response");
|
||||||
|
let seen = helper.runner().seen.borrow();
|
||||||
|
let spec = seen.first().expect("runner should be called");
|
||||||
|
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
|
||||||
|
|
||||||
|
assert_eq!(spec.program, PathBuf::from("vpn-proxy-helper.exe"));
|
||||||
|
assert_eq!(spec.args, vec!["--json"]);
|
||||||
|
assert!(spec.requires_elevation);
|
||||||
|
assert_eq!(stdin["action"], "service.restart");
|
||||||
|
assert_eq!(stdin["component"], "proxyfier");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_requests_are_explicit_component_actions() {
|
||||||
|
let control = install_request(ComponentId::ControlApp);
|
||||||
|
let proxyfier = install_request(ComponentId::Proxyfier);
|
||||||
|
let singbox = install_request(ComponentId::Singbox);
|
||||||
|
|
||||||
|
assert_eq!(control.action, HelperAction::InstallControlApp);
|
||||||
|
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
|
||||||
|
assert_eq!(singbox.action, HelperAction::InstallSingbox);
|
||||||
|
assert!(helper_action_requires_elevation(&proxyfier.action));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_request_does_not_encode_installer_action() {
|
||||||
|
let request = proxifyre_apply_request(
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
|
||||||
|
"ProxiFyreService",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(request.action, HelperAction::ProxyfierApply);
|
||||||
|
assert_eq!(request.component, Some(ComponentId::Proxyfier));
|
||||||
|
assert_eq!(
|
||||||
|
request.payload["configPath"],
|
||||||
|
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_json_helper_stdout_is_rejected() {
|
||||||
|
let error = parse_helper_response("Proxyfier restarted successfully")
|
||||||
|
.expect_err("raw stdout should not be accepted");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "helper_response_decode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_helper_exit_is_structured_error() {
|
||||||
|
let runner = MockRunner {
|
||||||
|
output: HelperCommandOutput {
|
||||||
|
status_code: 5,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: "Access denied".to_string(),
|
||||||
|
},
|
||||||
|
seen: RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
|
||||||
|
let error = helper
|
||||||
|
.execute(&service_request(
|
||||||
|
ComponentId::Proxyfier,
|
||||||
|
HelperAction::ServiceRestart,
|
||||||
|
))
|
||||||
|
.expect_err("failed exit should become helper error");
|
||||||
|
|
||||||
|
assert_eq!(error.code, "helper_exit");
|
||||||
|
assert!(error.message.contains("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockRunner {
|
||||||
|
output: HelperCommandOutput,
|
||||||
|
seen: RefCell<Vec<HelperCommandSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HelperCommandRunner for MockRunner {
|
||||||
|
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
|
||||||
|
self.seen.borrow_mut().push(spec.clone());
|
||||||
|
Ok(self.output.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
207
apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
207
apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
|
||||||
|
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_proxifyre_config_for_discord_external_socks5_target() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("home-gateway")];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = vec![missing_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("external socks5 target should not require sing-box");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(generated.adapter_id, "proxifyre");
|
||||||
|
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
|
||||||
|
assert_eq!(generated.enabled_profiles, 1);
|
||||||
|
assert_eq!(generated.routed_apps, 1);
|
||||||
|
assert_eq!(config.log_level, "Info");
|
||||||
|
assert!(config.bypass_lan);
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
|
||||||
|
assert_eq!(
|
||||||
|
config.proxies[0].socks5_proxy_endpoint,
|
||||||
|
"192.168.50.111:8080"
|
||||||
|
);
|
||||||
|
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_disabled_profiles_when_generating_proxifyre_config() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let mut disabled = discord_profile("home-gateway");
|
||||||
|
disabled.enabled = false;
|
||||||
|
let profiles = vec![disabled];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("disabled profiles should produce empty config");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(generated.enabled_profiles, 0);
|
||||||
|
assert_eq!(generated.routed_apps, 0);
|
||||||
|
assert!(config.proxies.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn includes_folder_paths_when_generating_proxifyre_config() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let mut profile = discord_profile("home-gateway");
|
||||||
|
profile.items.push(ProfileItem {
|
||||||
|
item_type: ProfileItemType::Folder,
|
||||||
|
value: r"C:\Games\MyGame".to_string(),
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
let profiles = vec![profile];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("folder paths should be accepted by ProxiFyre config generation");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(generated.routed_apps, 2);
|
||||||
|
assert_eq!(
|
||||||
|
config.proxies[0].app_names,
|
||||||
|
vec!["Discord", r"C:\Games\MyGame"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_target_when_required_component_is_missing() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("local-singbox")];
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect_err("local sing-box target should require installed running sing-box");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_singbox_target_generates_when_required_component_is_running() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("local-singbox")];
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("running sing-box should satisfy local target dependency");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||||
|
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("office-http")];
|
||||||
|
let targets = vec![Target {
|
||||||
|
id: "office-http".to_string(),
|
||||||
|
name: "Office HTTP".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Http,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 3128,
|
||||||
|
requires_component: None,
|
||||||
|
}];
|
||||||
|
let components = Vec::new();
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect_err("ProxiFyre should reject HTTP targets");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Local sing-box is not installed".to_string()],
|
||||||
|
actions: vec!["Install Local sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
284
apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs
Normal file
284
apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/adapters/proxifyre.rs"]
|
||||||
|
mod proxifyre;
|
||||||
|
#[path = "../src/adapters/proxy_router.rs"]
|
||||||
|
mod proxy_router;
|
||||||
|
#[path = "../src/adapters/singbox.rs"]
|
||||||
|
mod singbox;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||||
|
ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||||
|
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||||
|
use singbox::{
|
||||||
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
|
||||||
|
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("configuration OK");
|
||||||
|
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect("running local sing-box should generate config");
|
||||||
|
let config: SingBoxConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated sing-box json");
|
||||||
|
|
||||||
|
assert_eq!(generated.adapter_id, "singbox");
|
||||||
|
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
|
||||||
|
assert_eq!(generated.local_target_id, "local-singbox");
|
||||||
|
assert_eq!(generated.listen, "127.0.0.1");
|
||||||
|
assert_eq!(generated.listen_port, 1080);
|
||||||
|
assert_eq!(
|
||||||
|
generated.check,
|
||||||
|
Some(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: "configuration OK".to_string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(config.log.level, "info");
|
||||||
|
assert_eq!(config.inbounds.len(), 1);
|
||||||
|
assert_eq!(config.inbounds[0].inbound_type, "mixed");
|
||||||
|
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
|
||||||
|
assert_eq!(config.inbounds[0].listen_port, 1080);
|
||||||
|
assert!(!config.inbounds[0].set_system_proxy);
|
||||||
|
assert_eq!(config.outbounds[0].outbound_type, "direct");
|
||||||
|
assert_eq!(config.route.final_outbound, "direct");
|
||||||
|
let calls = checker.calls.borrow();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].0.as_path(), binary_path);
|
||||||
|
assert!(calls[0].1.contains(r#""type": "mixed""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect("binary path is optional");
|
||||||
|
|
||||||
|
assert_eq!(generated.check, None);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_config_when_required_component_is_missing() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = Vec::new();
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("local sing-box target requires component state");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_local_singbox_config_when_component_is_not_running() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![stopped_singbox_component()];
|
||||||
|
let checker = RecordingChecker::ok("should not run");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, None),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("local sing-box target requires running component");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.kind,
|
||||||
|
SingBoxConfigErrorKind::RequiredComponentNotRunning
|
||||||
|
);
|
||||||
|
assert!(checker.calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn propagates_failed_singbox_check_as_structured_error() {
|
||||||
|
let adapter = SingBoxAdapter::default();
|
||||||
|
let targets = vec![local_singbox_target()];
|
||||||
|
let components = vec![running_singbox_component()];
|
||||||
|
let checker = RecordingChecker::err("invalid config");
|
||||||
|
|
||||||
|
let error = adapter
|
||||||
|
.generate_config(
|
||||||
|
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
|
||||||
|
&checker,
|
||||||
|
)
|
||||||
|
.expect_err("failed sing-box check should block generated config");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
|
||||||
|
assert!(error.message.contains("invalid config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_proxifyre_apply_does_not_require_singbox_component() {
|
||||||
|
let adapter = ProxiFyreAdapter::default();
|
||||||
|
let profiles = vec![discord_profile("home-gateway")];
|
||||||
|
let targets = vec![external_socks5_target()];
|
||||||
|
let components = vec![missing_singbox_component()];
|
||||||
|
|
||||||
|
let generated = adapter
|
||||||
|
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||||
|
.expect("external SOCKS5 target should not require local sing-box");
|
||||||
|
let config: ProxiFyreConfig =
|
||||||
|
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
|
||||||
|
|
||||||
|
assert_eq!(config.proxies.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
config.proxies[0].socks5_proxy_endpoint,
|
||||||
|
"192.168.50.111:8080"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordingChecker {
|
||||||
|
calls: RefCell<Vec<(PathBuf, String)>>,
|
||||||
|
result: Result<SingBoxCheckResult, SingBoxConfigError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingChecker {
|
||||||
|
fn ok(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
result: Ok(SingBoxCheckResult {
|
||||||
|
checked: true,
|
||||||
|
success: true,
|
||||||
|
message: message.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
result: Err(SingBoxConfigError::new(
|
||||||
|
SingBoxConfigErrorKind::CheckFailed,
|
||||||
|
message,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingBoxConfigChecker for RecordingChecker {
|
||||||
|
fn check_config(
|
||||||
|
&self,
|
||||||
|
binary_path: &Path,
|
||||||
|
config_json: &str,
|
||||||
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||||
|
self.calls
|
||||||
|
.borrow_mut()
|
||||||
|
.push((binary_path.to_path_buf(), config_json.to_string()));
|
||||||
|
self.result.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discord_profile(target_id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: "discord".to_string(),
|
||||||
|
name: "Discord".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
target_id: target_id.to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_socks5_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "home-gateway".to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_singbox_target() -> Target {
|
||||||
|
Target {
|
||||||
|
id: "local-singbox".to_string(),
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
kind: TargetKind::Local,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 1080,
|
||||||
|
requires_component: Some(ComponentId::Singbox),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Running,
|
||||||
|
installed: true,
|
||||||
|
running: true,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: Vec::new(),
|
||||||
|
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopped_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Stopped,
|
||||||
|
installed: true,
|
||||||
|
running: false,
|
||||||
|
version: Some("1.11.0".to_string()),
|
||||||
|
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
|
||||||
|
problems: vec!["Service is stopped".to_string()],
|
||||||
|
actions: vec!["Start".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_singbox_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Singbox,
|
||||||
|
name: "Local sing-box".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["Local sing-box is not installed".to_string()],
|
||||||
|
actions: vec!["Install Local sing-box".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
198
apps/windows-client/src-tauri/tests/storage_tests.rs
Normal file
198
apps/windows-client/src-tauri/tests/storage_tests.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
#[path = "../src/activity.rs"]
|
||||||
|
mod activity;
|
||||||
|
#[path = "../src/models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[path = "../src/storage.rs"]
|
||||||
|
mod storage;
|
||||||
|
|
||||||
|
use models::{
|
||||||
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
|
||||||
|
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_defaults_to_programdata_root() {
|
||||||
|
let expected = PathBuf::from(r"C:\ProgramData\VpnProxy");
|
||||||
|
|
||||||
|
assert_eq!(default_config_root(), expected);
|
||||||
|
assert_eq!(StoragePaths::default().root, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrips_profiles_targets_components_and_activity() {
|
||||||
|
let root = test_root("roundtrip");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
|
||||||
|
let profiles = vec![sample_profile("discord")];
|
||||||
|
let targets = vec![sample_target("home-gateway")];
|
||||||
|
let components = vec![sample_component()];
|
||||||
|
let activity = vec![sample_activity(
|
||||||
|
"created",
|
||||||
|
"2026-01-01T10:00:00Z",
|
||||||
|
ActivityLevel::Success,
|
||||||
|
)];
|
||||||
|
|
||||||
|
storage.write_profiles(&profiles).expect("write profiles");
|
||||||
|
storage.write_targets(&targets).expect("write targets");
|
||||||
|
storage
|
||||||
|
.write_components(&components)
|
||||||
|
.expect("write components");
|
||||||
|
storage.write_activity(&activity).expect("write activity");
|
||||||
|
|
||||||
|
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
|
||||||
|
assert_eq!(storage.read_targets().expect("read targets"), targets);
|
||||||
|
assert_eq!(
|
||||||
|
storage.read_components().expect("read components"),
|
||||||
|
components
|
||||||
|
);
|
||||||
|
assert_eq!(storage.read_activity().expect("read activity"), activity);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_json_falls_back_to_empty_collection() {
|
||||||
|
let root = test_root("invalid-json");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
storage.ensure_dirs().expect("create storage dirs");
|
||||||
|
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.read_profiles().expect("invalid profiles fallback"),
|
||||||
|
Vec::<Profile>::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_creates_backup_before_overwriting_source_file() {
|
||||||
|
let root = test_root("backup");
|
||||||
|
let storage = JsonStorage::new(root.clone());
|
||||||
|
let first = vec![sample_profile("first")];
|
||||||
|
let second = vec![sample_profile("second")];
|
||||||
|
|
||||||
|
storage.write_profiles(&first).expect("first write");
|
||||||
|
storage.write_profiles(&second).expect("second write");
|
||||||
|
|
||||||
|
let backup = backup_path(&storage.paths().profiles_file);
|
||||||
|
assert!(backup.exists(), "backup file should exist");
|
||||||
|
|
||||||
|
let backup_contents = fs::read_to_string(backup).expect("read backup");
|
||||||
|
let backup_profiles: Vec<Profile> =
|
||||||
|
serde_json::from_str(&backup_contents).expect("backup json");
|
||||||
|
|
||||||
|
assert_eq!(backup_profiles, first);
|
||||||
|
assert_eq!(storage.read_profiles().expect("current profiles"), second);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_entries_are_sorted_and_capped() {
|
||||||
|
let root = test_root("activity");
|
||||||
|
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
|
||||||
|
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"old",
|
||||||
|
"2026-01-01T10:00:00Z",
|
||||||
|
ActivityLevel::Info,
|
||||||
|
))
|
||||||
|
.expect("append old");
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"new",
|
||||||
|
"2026-01-03T10:00:00Z",
|
||||||
|
ActivityLevel::Success,
|
||||||
|
))
|
||||||
|
.expect("append new");
|
||||||
|
storage
|
||||||
|
.append_activity(sample_activity(
|
||||||
|
"middle",
|
||||||
|
"2026-01-02T10:00:00Z",
|
||||||
|
ActivityLevel::Warning,
|
||||||
|
))
|
||||||
|
.expect("append middle");
|
||||||
|
|
||||||
|
let entries = storage.read_activity().expect("read capped activity");
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["new", "middle"]
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_root(name: &str) -> PathBuf {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system clock before unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
|
||||||
|
std::env::temp_dir().join(format!("vpn-proxy-storage-{name}-{timestamp}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup(root: &Path) {
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_profile(id: &str) -> Profile {
|
||||||
|
Profile {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: format!("Profile {id}"),
|
||||||
|
enabled: true,
|
||||||
|
target_id: "home-gateway".to_string(),
|
||||||
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
||||||
|
items: vec![ProfileItem {
|
||||||
|
item_type: ProfileItemType::Process,
|
||||||
|
value: "Discord".to_string(),
|
||||||
|
recursive: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_target(id: &str) -> Target {
|
||||||
|
Target {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: "Home Gateway".to_string(),
|
||||||
|
kind: TargetKind::External,
|
||||||
|
protocol: ProxyProtocol::Socks5,
|
||||||
|
host: "192.168.50.111".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
requires_component: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_component() -> ComponentStatus {
|
||||||
|
ComponentStatus {
|
||||||
|
id: ComponentId::Proxyfier,
|
||||||
|
name: "ProxiFyre".to_string(),
|
||||||
|
state: ComponentState::Missing,
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
version: None,
|
||||||
|
path: None,
|
||||||
|
problems: vec!["ProxiFyre не установлен".to_string()],
|
||||||
|
actions: vec!["Установить ProxiFyre".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
id: id.to_string(),
|
||||||
|
at: at.to_string(),
|
||||||
|
level,
|
||||||
|
title: format!("Activity {id}"),
|
||||||
|
message: "Storage test activity".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/windows-client/src/api/tauriCommands.ts
Normal file
97
apps/windows-client/src/api/tauriCommands.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type {
|
||||||
|
ActivityEntry,
|
||||||
|
ComponentStatus,
|
||||||
|
Profile,
|
||||||
|
ProfileInput,
|
||||||
|
Target,
|
||||||
|
TargetInput,
|
||||||
|
} from '../domain/types';
|
||||||
|
|
||||||
|
export interface CommandError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Array<{
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
routeLine: string;
|
||||||
|
activeProfileCount: number;
|
||||||
|
routedAppCount: number;
|
||||||
|
activeTarget?: Target;
|
||||||
|
components: ComponentStatus[];
|
||||||
|
recentActivity: ActivityEntry[];
|
||||||
|
generatedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedStateResponse {
|
||||||
|
profiles: Profile[];
|
||||||
|
targets: Target[];
|
||||||
|
generatedConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelperApplyResult {
|
||||||
|
success: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
action: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyProfilesResponse {
|
||||||
|
success: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
message: string;
|
||||||
|
adapterId: string;
|
||||||
|
generatedConfigPath: string;
|
||||||
|
enabledProfiles: number;
|
||||||
|
routedApps: number;
|
||||||
|
helper: HelperApplyResult;
|
||||||
|
activity: ActivityEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatus(): Promise<StatusResponse> {
|
||||||
|
return invoke<StatusResponse>('get_status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedState(): Promise<SavedStateResponse> {
|
||||||
|
return invoke<SavedStateResponse>('get_saved_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfiles(): Promise<Profile[]> {
|
||||||
|
return invoke<Profile[]>('get_profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfile(input: ProfileInput): Promise<Profile> {
|
||||||
|
return invoke<Profile>('save_profile', { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargets(): Promise<Target[]> {
|
||||||
|
return invoke<Target[]>('get_targets');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTarget(input: TargetInput): Promise<Target> {
|
||||||
|
return invoke<Target>('save_target', { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponents(): Promise<ComponentStatus[]> {
|
||||||
|
return invoke<ComponentStatus[]>('get_components');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyProfiles(): Promise<ApplyProfilesResponse> {
|
||||||
|
return invoke<ApplyProfilesResponse>('apply_profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openConfigLocation(): Promise<string> {
|
||||||
|
return invoke<string>('open_config_location');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startProxiFyreService(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('start_proxifyre_service');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopProxiFyreService(): Promise<ComponentStatus> {
|
||||||
|
return invoke<ComponentStatus>('stop_proxifyre_service');
|
||||||
|
}
|
||||||
770
apps/windows-client/src/app/App.tsx
Normal file
770
apps/windows-client/src/app/App.tsx
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { Cpu, FileCode2, FolderOpen } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
applyProfiles,
|
||||||
|
getComponents,
|
||||||
|
getSavedState,
|
||||||
|
openConfigLocation,
|
||||||
|
saveProfile,
|
||||||
|
saveTarget,
|
||||||
|
startProxiFyreService,
|
||||||
|
stopProxiFyreService,
|
||||||
|
type ApplyProfilesResponse,
|
||||||
|
} from '../api/tauriCommands';
|
||||||
|
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, Target } from '../domain/types';
|
||||||
|
|
||||||
|
type DraftItemType = Extract<ProfileItemType, 'process' | 'folder' | 'exe'>;
|
||||||
|
type ServiceVisualState = 'active' | 'settling' | null;
|
||||||
|
|
||||||
|
interface DraftItem {
|
||||||
|
id: string;
|
||||||
|
type: DraftItemType;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Notice {
|
||||||
|
kind: 'success' | 'error' | 'info';
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntry extends Notice {
|
||||||
|
id: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN_TARGET_ID = 'main-proxy';
|
||||||
|
const MAIN_PROFILE_ID = 'main-profile';
|
||||||
|
const LOG_VISIBLE_MS = 6500;
|
||||||
|
|
||||||
|
const fallbackComponents: ComponentStatus[] = [
|
||||||
|
{
|
||||||
|
id: 'proxyfier',
|
||||||
|
name: 'ProxiFyre',
|
||||||
|
state: 'missing',
|
||||||
|
installed: false,
|
||||||
|
running: false,
|
||||||
|
problems: ['ProxiFyre не найден'],
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [proxyInput, setProxyInput] = useState('');
|
||||||
|
const [profileId, setProfileId] = useState(MAIN_PROFILE_ID);
|
||||||
|
const [targetId, setTargetId] = useState(MAIN_TARGET_ID);
|
||||||
|
const [items, setItems] = useState<DraftItem[]>([]);
|
||||||
|
const [loadedProfiles, setLoadedProfiles] = useState<Profile[]>([]);
|
||||||
|
const [isProcessInputOpen, setIsProcessInputOpen] = useState(false);
|
||||||
|
const [processInput, setProcessInput] = useState('');
|
||||||
|
const [pickerAction, setPickerAction] = useState<'exe' | 'folder' | null>(null);
|
||||||
|
const [components, setComponents] = useState<ComponentStatus[]>(fallbackComponents);
|
||||||
|
const [generatedConfigPath, setGeneratedConfigPath] = useState('');
|
||||||
|
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
|
||||||
|
const [activeLogId, setActiveLogId] = useState<string | null>(null);
|
||||||
|
const [isLogOpen, setIsLogOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isDetectingComponents, setIsDetectingComponents] = useState(true);
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
const [isOpeningConfig, setIsOpeningConfig] = useState(false);
|
||||||
|
const [serviceAction, setServiceAction] = useState<'start' | 'stop' | null>(null);
|
||||||
|
const [serviceVisualState, setServiceVisualState] = useState<ServiceVisualState>(null);
|
||||||
|
const serviceVisualTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const proxyfier = useMemo(
|
||||||
|
() => components.find((component) => component.id === 'proxyfier'),
|
||||||
|
[components],
|
||||||
|
);
|
||||||
|
const activeLog = useMemo(
|
||||||
|
() => logEntries.find((entry) => entry.id === activeLogId) ?? null,
|
||||||
|
[activeLogId, logEntries],
|
||||||
|
);
|
||||||
|
const finderStateClass = isDetectingComponents ? 'checking' : proxyfier?.installed ? 'found' : 'missing';
|
||||||
|
const finderVisualClass =
|
||||||
|
serviceVisualState === 'active' ? 'working' : serviceVisualState === 'settling' ? 'settling' : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (serviceVisualTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(serviceVisualTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeLogId) return undefined;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setActiveLogId((current) => (current === activeLogId ? null : current));
|
||||||
|
}, LOG_VISIBLE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [activeLogId]);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const saved = await getSavedState();
|
||||||
|
applySavedState(saved.profiles, saved.targets, saved.generatedConfigPath);
|
||||||
|
} catch {
|
||||||
|
showNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Режим предпросмотра',
|
||||||
|
text: 'Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshComponents() {
|
||||||
|
setIsDetectingComponents(true);
|
||||||
|
try {
|
||||||
|
const detectedComponents = await getComponents();
|
||||||
|
setComponents(detectedComponents);
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'ProxiFyre не проверен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDetectingComponents(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySavedState(profiles: Profile[], targets: Target[], generatedPath: string) {
|
||||||
|
const activeProfiles = profiles.filter((profile) => profile.enabled);
|
||||||
|
const mainProfile = profiles.find((profile) => profile.id === MAIN_PROFILE_ID);
|
||||||
|
const activeProfile = mainProfile ?? activeProfiles[0];
|
||||||
|
const activeTarget = targetForUi(targets, activeProfile);
|
||||||
|
const editableProfiles = mainProfile ? [mainProfile] : activeProfiles;
|
||||||
|
|
||||||
|
if (activeTarget) setProxyInput(formatProxy(activeTarget));
|
||||||
|
setItems(itemsForProfiles(editableProfiles));
|
||||||
|
setLoadedProfiles(profiles);
|
||||||
|
setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID);
|
||||||
|
setTargetId(activeTarget?.id ?? activeProfile?.targetId ?? MAIN_TARGET_ID);
|
||||||
|
setGeneratedConfigPath(generatedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem(type: DraftItemType, rawValue: string) {
|
||||||
|
const value = normalizeItemValue(rawValue, type);
|
||||||
|
if (!value) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Нечего добавить',
|
||||||
|
text: emptyItemMessage(type),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.some((item) => item.type === type && sameValue(item.value, value))) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Уже добавлено',
|
||||||
|
text: value,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `${type}-${Date.now()}`,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProcess() {
|
||||||
|
if (addItem('process', processInput)) {
|
||||||
|
setProcessInput('');
|
||||||
|
setIsProcessInputOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(id: string) {
|
||||||
|
setItems((current) => current.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickAndAddItem(type: Extract<DraftItemType, 'exe' | 'folder'>) {
|
||||||
|
setPickerAction(type);
|
||||||
|
try {
|
||||||
|
const selectedPath = await pickPath(type);
|
||||||
|
if (selectedPath) {
|
||||||
|
addItem(type, selectedPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: type === 'exe' ? 'EXE не выбран' : 'Папка не выбрана',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPickerAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
let parsedProxy: ParsedProxy;
|
||||||
|
try {
|
||||||
|
parsedProxy = parseProxy(proxyInput);
|
||||||
|
if (!items.length) throw new Error('Добавь хотя бы один процесс, EXE-файл или папку.');
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Проверь данные',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApplying(true);
|
||||||
|
try {
|
||||||
|
await saveTarget({
|
||||||
|
id: targetId,
|
||||||
|
name: 'Основной прокси',
|
||||||
|
kind: 'external',
|
||||||
|
protocol: parsedProxy.protocol,
|
||||||
|
host: parsedProxy.host,
|
||||||
|
port: parsedProxy.port,
|
||||||
|
});
|
||||||
|
await saveProfile({
|
||||||
|
id: profileId,
|
||||||
|
name: 'Приложения через прокси',
|
||||||
|
enabled: true,
|
||||||
|
targetId,
|
||||||
|
protocols: ['TCP', 'UDP'],
|
||||||
|
items: items.map(profileItemInput),
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
loadedProfiles
|
||||||
|
.filter((profile) => profile.enabled && profile.id !== profileId)
|
||||||
|
.map((profile) => saveProfile(profileInputFromProfile(profile, false))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await applyProfiles();
|
||||||
|
const [saved, detectedComponents] = await Promise.all([
|
||||||
|
getSavedState(),
|
||||||
|
getComponents(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
applySavedState(saved.profiles, saved.targets, result.generatedConfigPath);
|
||||||
|
setComponents(detectedComponents);
|
||||||
|
showNotice(noticeFromApply(result));
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Конфиг не обновлен',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig() {
|
||||||
|
setIsOpeningConfig(true);
|
||||||
|
try {
|
||||||
|
const openedPath = await openConfigLocation();
|
||||||
|
showNotice({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Конфиг открыт',
|
||||||
|
text: openedPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Не удалось открыть конфиг',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsOpeningConfig(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setProxiFyreServiceRunning(shouldRun: boolean) {
|
||||||
|
const action = shouldRun ? 'start' : 'stop';
|
||||||
|
setServiceAction(action);
|
||||||
|
startServiceVisual();
|
||||||
|
try {
|
||||||
|
await nextFrame();
|
||||||
|
const component = shouldRun
|
||||||
|
? await startProxiFyreService()
|
||||||
|
: await stopProxiFyreService();
|
||||||
|
|
||||||
|
setComponents((current) => upsertComponent(current, component));
|
||||||
|
showNotice({
|
||||||
|
kind: 'success',
|
||||||
|
title: shouldRun ? 'Служба запущена' : 'Служба остановлена',
|
||||||
|
text: proxyfierDetails(component, false),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showNotice({
|
||||||
|
kind: 'error',
|
||||||
|
title: shouldRun ? 'Служба не запущена' : 'Служба не остановлена',
|
||||||
|
text: errorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setServiceAction(null);
|
||||||
|
settleServiceVisual();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServiceVisual() {
|
||||||
|
if (serviceVisualTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(serviceVisualTimerRef.current);
|
||||||
|
serviceVisualTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setServiceVisualState('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleServiceVisual() {
|
||||||
|
if (serviceVisualTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(serviceVisualTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setServiceVisualState('settling');
|
||||||
|
serviceVisualTimerRef.current = window.setTimeout(() => {
|
||||||
|
setServiceVisualState(null);
|
||||||
|
serviceVisualTimerRef.current = null;
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotice(notice: Notice) {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
...notice,
|
||||||
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setLogEntries((current) => [entry, ...current].slice(0, 40));
|
||||||
|
setActiveLogId(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="simple-shell">
|
||||||
|
<section className="simple-panel">
|
||||||
|
<header className="simple-header">
|
||||||
|
<div>
|
||||||
|
<small>VPN Proxy</small>
|
||||||
|
<h1>Прокси для приложений</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={isLoading || isDetectingComponents}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Загружаю...' : isDetectingComponents ? 'Проверяю...' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={`finder-card ${finderStateClass} ${finderVisualClass}`.trim()}>
|
||||||
|
<span className="finder-border-glow" aria-hidden="true">
|
||||||
|
<span className="finder-border-glow-segment top" />
|
||||||
|
<span className="finder-border-glow-segment right" />
|
||||||
|
<span className="finder-border-glow-segment bottom" />
|
||||||
|
<span className="finder-border-glow-segment left" />
|
||||||
|
</span>
|
||||||
|
<span className="status-light" />
|
||||||
|
<div className="finder-text">
|
||||||
|
<strong>{proxyfierTitle(proxyfier, isDetectingComponents)}</strong>
|
||||||
|
<span>{proxyfierDetails(proxyfier, isDetectingComponents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="service-actions" aria-label="Управление службой ProxiFyre">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-button"
|
||||||
|
onClick={() => setProxiFyreServiceRunning(true)}
|
||||||
|
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || Boolean(proxyfier?.running)}
|
||||||
|
>
|
||||||
|
{serviceAction === 'start' ? '...' : 'Запустить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-button stop"
|
||||||
|
onClick={() => setProxiFyreServiceRunning(false)}
|
||||||
|
disabled={isDetectingComponents || Boolean(serviceAction) || !proxyfier?.installed || !proxyfier?.running}
|
||||||
|
>
|
||||||
|
{serviceAction === 'stop' ? '...' : 'Остановить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="simple-field">
|
||||||
|
<span>Прокси</span>
|
||||||
|
<input
|
||||||
|
value={proxyInput}
|
||||||
|
onChange={(event) => setProxyInput(event.target.value)}
|
||||||
|
placeholder="socks5://127.0.0.1:1080"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className="apps-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<h2>Приложения</h2>
|
||||||
|
<span>{items.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProcessInputOpen ? (
|
||||||
|
<div className="process-add-line">
|
||||||
|
<input
|
||||||
|
value={processInput}
|
||||||
|
onChange={(event) => setProcessInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') addProcess();
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setProcessInput('');
|
||||||
|
setIsProcessInputOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Discord"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={addProcess}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="process-cancel-button"
|
||||||
|
onClick={() => {
|
||||||
|
setProcessInput('');
|
||||||
|
setIsProcessInputOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="add-toolbar" aria-label="Добавить приложение">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-tile"
|
||||||
|
onClick={() => setIsProcessInputOpen(true)}
|
||||||
|
aria-label="Добавить процесс"
|
||||||
|
title="Процесс"
|
||||||
|
>
|
||||||
|
<Cpu size={22} strokeWidth={1.8} />
|
||||||
|
<span className="sr-only">Процесс</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-tile"
|
||||||
|
onClick={() => void pickAndAddItem('exe')}
|
||||||
|
disabled={Boolean(pickerAction)}
|
||||||
|
aria-label="Добавить EXE-файл"
|
||||||
|
title="EXE-файл"
|
||||||
|
>
|
||||||
|
{pickerAction === 'exe' ? <span className="tile-loading">...</span> : <FileCode2 size={22} strokeWidth={1.8} />}
|
||||||
|
<span className="sr-only">EXE-файл</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="add-tile"
|
||||||
|
onClick={() => void pickAndAddItem('folder')}
|
||||||
|
disabled={Boolean(pickerAction)}
|
||||||
|
aria-label="Добавить папку"
|
||||||
|
title="Папка"
|
||||||
|
>
|
||||||
|
{pickerAction === 'folder' ? <span className="tile-loading">...</span> : <FolderOpen size={22} strokeWidth={1.8} />}
|
||||||
|
<span className="sr-only">Папка</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="app-list">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="list-skeleton" aria-label="Загрузка приложений">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
) : items.length ? (
|
||||||
|
items.map((item) => (
|
||||||
|
<div className="app-row" key={item.id}>
|
||||||
|
<div className="app-row-main">
|
||||||
|
<span className="item-icon" aria-hidden="true">
|
||||||
|
{itemIcon(item.type)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<span>{itemTypeLabel(item.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => removeItem(item.id)} aria-label={`Удалить ${item.value}`}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">Добавь процесс, папку или путь к EXE-файлу.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="command-row">
|
||||||
|
<button type="button" className="apply-button" onClick={updateConfig} disabled={isApplying}>
|
||||||
|
{isApplying ? 'Обновляю...' : 'Обновить конфиг'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="open-config-button"
|
||||||
|
onClick={openConfig}
|
||||||
|
disabled={isOpeningConfig}
|
||||||
|
>
|
||||||
|
{isOpeningConfig ? '...' : 'Открыть'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedConfigPath ? <p className="config-path">{generatedConfigPath}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{logEntries.length ? (
|
||||||
|
<aside className={`log-dock ${activeLog?.kind ?? 'idle'}`} aria-live="polite">
|
||||||
|
<div className={`log-current ${activeLog ? 'visible' : 'hidden'}`}>
|
||||||
|
{activeLog ? (
|
||||||
|
<>
|
||||||
|
<strong>{activeLog.title}</strong>
|
||||||
|
<span>{activeLog.text}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="log-muted">Журнал событий</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="log-toggle" onClick={() => setIsLogOpen((current) => !current)}>
|
||||||
|
{isLogOpen ? 'Скрыть' : 'Посмотреть'}
|
||||||
|
<span>{logEntries.length}</span>
|
||||||
|
</button>
|
||||||
|
{isLogOpen ? (
|
||||||
|
<div className="log-history">
|
||||||
|
{logEntries.map((entry) => (
|
||||||
|
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||||
|
<time>{formatLogTime(entry.at)}</time>
|
||||||
|
<div>
|
||||||
|
<strong>{entry.title}</strong>
|
||||||
|
<span>{entry.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedProxy {
|
||||||
|
protocol: 'socks5';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProxy(rawValue: string): ParsedProxy {
|
||||||
|
const value = rawValue.trim();
|
||||||
|
if (!value) throw new Error('Введи адрес прокси.');
|
||||||
|
|
||||||
|
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `socks5://${value}`;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(withProtocol);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Формат: socks5://host:port или host:port.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||||||
|
if (protocol !== 'socks5') {
|
||||||
|
throw new Error('Сейчас поддерживается только SOCKS5.');
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
throw new Error('Прокси с логином и паролем пока не поддерживаются.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const port = Number(parsed.port);
|
||||||
|
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error('Укажи хост и порт прокси.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { protocol: 'socks5', host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetForUi(targets: Target[], profile: Profile | undefined) {
|
||||||
|
if (profile) return targets.find((target) => target.id === profile.targetId);
|
||||||
|
return targets.find((target) => target.id === MAIN_TARGET_ID) ?? targets.find((target) => target.kind === 'external');
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsForProfiles(profiles: Profile[]): DraftItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: DraftItem[] = [];
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
for (const item of profile.items) {
|
||||||
|
if (item.type !== 'process' && item.type !== 'folder' && item.type !== 'exe') continue;
|
||||||
|
|
||||||
|
const key = `${item.type}:${item.value.trim().toLowerCase()}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
items.push({
|
||||||
|
id: `${item.type}-${items.length}-${item.value}`,
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProxy(target: Target) {
|
||||||
|
return target.protocol === 'socks5'
|
||||||
|
? `${target.host}:${target.port}`
|
||||||
|
: `${target.protocol}://${target.host}:${target.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemValue(value: string, type: DraftItemType) {
|
||||||
|
const clean = value.trim().replace(/^"|"$/g, '');
|
||||||
|
if (!clean) return '';
|
||||||
|
if (type === 'folder' || type === 'exe') return clean;
|
||||||
|
|
||||||
|
return clean
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.replace(/\.exe$/i, '')
|
||||||
|
.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickPath(type: Extract<DraftItemType, 'exe' | 'folder'>) {
|
||||||
|
const selected = await open(
|
||||||
|
type === 'folder'
|
||||||
|
? {
|
||||||
|
title: 'Выбери папку',
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: 'Выбери EXE-файл',
|
||||||
|
directory: false,
|
||||||
|
multiple: false,
|
||||||
|
filters: [{ name: 'EXE-файлы', extensions: ['exe'] }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(selected)) return selected[0] ?? null;
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFrame() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileItemInput(item: DraftItem): ProfileItemInput {
|
||||||
|
return {
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
recursive: item.type === 'folder',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyItemMessage(type: DraftItemType) {
|
||||||
|
if (type === 'process') return 'Введи имя процесса.';
|
||||||
|
if (type === 'folder') return 'Введи путь к папке.';
|
||||||
|
return 'Введи путь к EXE-файлу.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTypeLabel(type: DraftItemType) {
|
||||||
|
if (type === 'process') return 'процесс';
|
||||||
|
if (type === 'folder') return 'папка';
|
||||||
|
return 'EXE-файл';
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIcon(type: DraftItemType) {
|
||||||
|
if (type === 'process') return <Cpu size={18} strokeWidth={1.9} />;
|
||||||
|
if (type === 'folder') return <FolderOpen size={18} strokeWidth={1.9} />;
|
||||||
|
return <FileCode2 size={18} strokeWidth={1.9} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileInputFromProfile(profile: Profile, enabled: boolean) {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
enabled,
|
||||||
|
targetId: profile.targetId,
|
||||||
|
protocols: profile.protocols,
|
||||||
|
items: profile.items.map((item) => ({
|
||||||
|
type: item.type,
|
||||||
|
value: item.value,
|
||||||
|
recursive: item.recursive,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyfierTitle(component: ComponentStatus | undefined, checking: boolean) {
|
||||||
|
if (checking) return 'Проверяю ProxiFyre';
|
||||||
|
if (!component) return 'ProxiFyre не проверен';
|
||||||
|
if (component.running) return 'ProxiFyre найден и запущен';
|
||||||
|
if (component.installed) return 'ProxiFyre найден';
|
||||||
|
return 'ProxiFyre не найден';
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyfierDetails(component: ComponentStatus | undefined, checking: boolean) {
|
||||||
|
if (checking) return 'Ищу установленный клиент и состояние службы.';
|
||||||
|
if (!component) return 'Нажми «Обновить», чтобы проверить компьютер.';
|
||||||
|
if (component.path) return component.path;
|
||||||
|
return component.problems[0] ?? 'Путь установки не найден.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeFromApply(result: ApplyProfilesResponse): Notice {
|
||||||
|
return {
|
||||||
|
kind: result.success ? 'success' : 'error',
|
||||||
|
title: result.success ? 'Конфиг обновлен' : 'Конфиг создан, но не применен',
|
||||||
|
text: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertComponent(components: ComponentStatus[], component: ComponentStatus) {
|
||||||
|
const index = components.findIndex((current) => current.id === component.id);
|
||||||
|
if (index === -1) return [...components, component];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...components.slice(0, index),
|
||||||
|
component,
|
||||||
|
...components.slice(index + 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameValue(left: string, right: string) {
|
||||||
|
return left.trim().toLowerCase() === right.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogTime(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
return String((error as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return 'Неизвестная ошибка.';
|
||||||
|
}
|
||||||
77
apps/windows-client/src/domain/types.ts
Normal file
77
apps/windows-client/src/domain/types.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export type Protocol = 'TCP' | 'UDP';
|
||||||
|
export type ProfileItemType = 'process' | 'folder' | 'exe';
|
||||||
|
export type TargetKind = 'local' | 'external';
|
||||||
|
export type ProxyProtocol = 'socks5' | 'http';
|
||||||
|
export type ComponentId = 'control-app' | 'proxyfier' | 'singbox';
|
||||||
|
export type ComponentState = 'installed' | 'missing' | 'stopped' | 'running' | 'error';
|
||||||
|
export type ActivityLevel = 'info' | 'warning' | 'error' | 'success';
|
||||||
|
|
||||||
|
export interface ProfileItemInput {
|
||||||
|
type: ProfileItemType | string;
|
||||||
|
value: string;
|
||||||
|
recursive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
targetId?: string;
|
||||||
|
protocols?: string[];
|
||||||
|
items?: ProfileItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileItem {
|
||||||
|
type: ProfileItemType;
|
||||||
|
value: string;
|
||||||
|
recursive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
targetId: string;
|
||||||
|
protocols: Protocol[];
|
||||||
|
items: ProfileItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
kind?: TargetKind | string;
|
||||||
|
protocol?: ProxyProtocol | string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
requiresComponent?: ComponentId | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: TargetKind;
|
||||||
|
protocol: ProxyProtocol;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
requiresComponent?: ComponentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentStatus {
|
||||||
|
id: ComponentId;
|
||||||
|
name: string;
|
||||||
|
state: ComponentState;
|
||||||
|
installed: boolean;
|
||||||
|
running: boolean;
|
||||||
|
version?: string;
|
||||||
|
path?: string;
|
||||||
|
problems: string[];
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
level: ActivityLevel;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
11
apps/windows-client/src/main.tsx
Normal file
11
apps/windows-client/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './app/App';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
776
apps/windows-client/src/styles/app.css
Normal file
776
apps/windows-client/src/styles/app.css
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
color: #e5e7eb;
|
||||||
|
background: #101216;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #101216;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-shell {
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #101216;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-panel {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #101216;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 18px 18px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header,
|
||||||
|
.finder-card,
|
||||||
|
.section-head,
|
||||||
|
.add-toolbar,
|
||||||
|
.process-add-line,
|
||||||
|
.app-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header {
|
||||||
|
margin: -18px -18px 16px;
|
||||||
|
border-bottom: 1px solid #2a2f3a;
|
||||||
|
background: #181b22;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header small,
|
||||||
|
.simple-field span,
|
||||||
|
.app-row-main > div > span,
|
||||||
|
.config-path,
|
||||||
|
.finder-card span {
|
||||||
|
color: #8d99ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header h1,
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header h1 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button,
|
||||||
|
.add-toolbar button,
|
||||||
|
.process-add-line button,
|
||||||
|
.app-row button,
|
||||||
|
.open-config-button,
|
||||||
|
.service-button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #242a35;
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button:hover,
|
||||||
|
.add-toolbar button:hover,
|
||||||
|
.process-add-line button:hover,
|
||||||
|
.app-row button:hover,
|
||||||
|
.open-config-button:hover,
|
||||||
|
.service-button:hover {
|
||||||
|
background: #2d3543;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
justify-content: stretch;
|
||||||
|
min-height: 56px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #151923;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card > :not(.finder-border-glow) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.checking .finder-border-glow,
|
||||||
|
.finder-card.working .finder-border-glow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.checking .finder-border-glow {
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.settling .finder-border-glow {
|
||||||
|
opacity: 0;
|
||||||
|
transition-duration: 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
background: #93c5fd;
|
||||||
|
box-shadow:
|
||||||
|
0 0 8px rgba(96, 165, 250, 0.95),
|
||||||
|
0 0 16px rgba(34, 197, 94, 0.36);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.top,
|
||||||
|
.finder-border-glow-segment.bottom {
|
||||||
|
width: 108px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.right,
|
||||||
|
.finder-border-glow-segment.left {
|
||||||
|
width: 2px;
|
||||||
|
height: 64px;
|
||||||
|
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.top {
|
||||||
|
top: 0;
|
||||||
|
animation: finder-border-top 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.right {
|
||||||
|
right: 0;
|
||||||
|
animation: finder-border-right 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.bottom {
|
||||||
|
bottom: 0;
|
||||||
|
animation: finder-border-bottom 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-border-glow-segment.left {
|
||||||
|
left: 0;
|
||||||
|
animation: finder-border-left 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-text,
|
||||||
|
.app-row > div,
|
||||||
|
.app-row-main > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card strong,
|
||||||
|
.finder-card span,
|
||||||
|
.app-row-main strong,
|
||||||
|
.app-row-main > div > span {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-light {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.found .status-light {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.missing .status-light {
|
||||||
|
background: #f59e0b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card.checking .status-light {
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
border-top-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-button {
|
||||||
|
min-width: 102px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-button.stop {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field input,
|
||||||
|
.process-add-line input {
|
||||||
|
min-height: 42px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1016;
|
||||||
|
color: #f8fafc;
|
||||||
|
outline: none;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-field input:focus,
|
||||||
|
.process-add-line input:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head span {
|
||||||
|
min-width: 28px;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1b202b;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 3px 9px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-toolbar {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tile {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 74px;
|
||||||
|
height: 48px;
|
||||||
|
border-color: #2b3342;
|
||||||
|
background: #131720;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tile svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tile:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #182033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tile[aria-pressed="true"] {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-loading {
|
||||||
|
color: #dbeafe;
|
||||||
|
font-weight: 800;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 72px 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line button {
|
||||||
|
border-color: #166534;
|
||||||
|
background: #14532d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line .process-cancel-button {
|
||||||
|
border-color: #343b49;
|
||||||
|
background: #242a35;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line button:hover {
|
||||||
|
background: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line .process-cancel-button:hover {
|
||||||
|
background: #2d3543;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row .item-icon {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1016;
|
||||||
|
color: #dbeafe;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row .item-icon svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row,
|
||||||
|
.empty-state {
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #131720;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row button {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #8d99ae;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-skeleton span {
|
||||||
|
min-height: 56px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent),
|
||||||
|
#131720;
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 132px;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button {
|
||||||
|
min-height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #16a34a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #22c55e;
|
||||||
|
color: #04130a;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-button:hover {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-config-button {
|
||||||
|
min-height: 46px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-path {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dock {
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(19, 23, 32, 0.98);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34);
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dock.error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dock.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dock.info {
|
||||||
|
border-color: rgba(59, 130, 246, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0 8px;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current.hidden {
|
||||||
|
color: #8d99ae;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current strong,
|
||||||
|
.log-current span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-muted {
|
||||||
|
color: #8d99ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
border: 1px solid #343b49;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #242a35;
|
||||||
|
color: #eef2ff;
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle:hover {
|
||||||
|
background: #2d3543;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle span {
|
||||||
|
min-width: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1b202b;
|
||||||
|
color: #dbeafe;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 230px;
|
||||||
|
overflow: auto;
|
||||||
|
border-top: 1px solid #2b3342;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid #2b3342;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1016;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row.error {
|
||||||
|
border-color: rgba(239, 68, 68, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row.success {
|
||||||
|
border-color: rgba(34, 197, 94, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row.info {
|
||||||
|
border-color: rgba(59, 130, 246, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row time {
|
||||||
|
color: #8d99ae;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row strong,
|
||||||
|
.log-history-row span {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row span {
|
||||||
|
color: #b6c2d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finder-border-top {
|
||||||
|
0% {
|
||||||
|
left: -116px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
4%,
|
||||||
|
23% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
25%,
|
||||||
|
100% {
|
||||||
|
left: calc(100% + 8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finder-border-right {
|
||||||
|
0%,
|
||||||
|
25% {
|
||||||
|
top: -72px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
29%,
|
||||||
|
48% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finder-border-bottom {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
right: -116px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
54%,
|
||||||
|
73% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
75%,
|
||||||
|
100% {
|
||||||
|
right: calc(100% + 8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes finder-border-left {
|
||||||
|
0%,
|
||||||
|
75% {
|
||||||
|
bottom: -72px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
79%,
|
||||||
|
98% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from {
|
||||||
|
background-position: 220% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
background-position: -220% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.simple-shell {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-panel {
|
||||||
|
padding: 14px 14px 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-header,
|
||||||
|
.app-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tile {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-add-line {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finder-card {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-dock {
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-current {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-history-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/windows-client/tsconfig.json
Normal file
22
apps/windows-client/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
|
||||||
18
apps/windows-client/vite.config.ts
Normal file
18
apps/windows-client/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
host: host || false,
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
46
docker-compose.client.yml
Normal file
46
docker-compose.client.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||||
|
CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||||
|
CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}
|
||||||
|
PROXY_BIND_IP: 0.0.0.0
|
||||||
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
ROUTING_RU_DIRECT: ${ROUTING_RU_DIRECT:-true}
|
||||||
|
RULE_SET_DOWNLOAD_DETOUR: ${RULE_SET_DOWNLOAD_DETOUR:-vpn}
|
||||||
|
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:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}"
|
||||||
|
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:
|
||||||
34
docker-compose.gateway.yml
Normal file
34
docker-compose.gateway.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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:
|
||||||
|
- .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:
|
||||||
@@ -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:
|
volumes:
|
||||||
- ./data:/app/data
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
|
- sing-box-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 256m
|
|
||||||
|
|
||||||
# Порты при network_mode: host не нужно пробрасывать,
|
volumes:
|
||||||
# они автоматически доступны на хосте:
|
vpn-proxy-data:
|
||||||
# - 3456: Веб-интерфейс (PORT)
|
sing-box-cache:
|
||||||
# - 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)
|
|
||||||
1752
docs/design/open-design/vpn-proxy-full-mock-prototype.html
Normal file
1752
docs/design/open-design/vpn-proxy-full-mock-prototype.html
Normal file
File diff suppressed because it is too large
Load Diff
774
docs/design/open-design/vpn-proxy-route-console-redesign.html
Normal file
774
docs/design/open-design/vpn-proxy-route-console-redesign.html
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>VPN Proxy Route Console Redesign</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: oklch(0.965 0.008 232);
|
||||||
|
--surface: oklch(0.986 0.006 232);
|
||||||
|
--surface-2: oklch(0.948 0.009 232);
|
||||||
|
--surface-3: oklch(0.918 0.014 232);
|
||||||
|
--ink: oklch(0.238 0.028 238);
|
||||||
|
--muted: oklch(0.47 0.028 238);
|
||||||
|
--subtle: oklch(0.62 0.022 238);
|
||||||
|
--line: oklch(0.835 0.018 232);
|
||||||
|
--line-strong: oklch(0.72 0.032 232);
|
||||||
|
--blue: oklch(0.56 0.14 244);
|
||||||
|
--blue-soft: oklch(0.915 0.045 244);
|
||||||
|
--green: oklch(0.61 0.13 153);
|
||||||
|
--green-soft: oklch(0.915 0.052 153);
|
||||||
|
--amber: oklch(0.72 0.13 74);
|
||||||
|
--amber-soft: oklch(0.93 0.07 74);
|
||||||
|
--red: oklch(0.58 0.15 27);
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 1320px;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: oklch(0.978 0.007 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, oklch(0.52 0.13 244), oklch(0.62 0.12 153));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border: 2px solid oklch(0.985 0.005 232);
|
||||||
|
border-left-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid oklch(0.73 0.05 153);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--green-soft);
|
||||||
|
color: oklch(0.34 0.08 153);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
border-color: oklch(0.49 0.13 244);
|
||||||
|
background: var(--blue);
|
||||||
|
color: oklch(0.985 0.005 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 264px minmax(0, 1fr) 312px;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
padding: 18px 14px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: oklch(0.956 0.009 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title,
|
||||||
|
.panel-label,
|
||||||
|
.field-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 11px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode.active {
|
||||||
|
border-color: oklch(0.68 0.08 244);
|
||||||
|
background: var(--blue-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-dot {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode.active .mode-dot {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-head p {
|
||||||
|
max-width: 68ch;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
min-width: 210px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid oklch(0.76 0.07 153);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health span {
|
||||||
|
color: oklch(0.38 0.07 153);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
min-height: 126px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.active {
|
||||||
|
border-color: oklch(0.72 0.075 153);
|
||||||
|
background: var(--green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.pending {
|
||||||
|
border-color: oklch(0.8 0.09 74);
|
||||||
|
background: var(--amber-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 9px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 13px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-line b {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-line span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 50px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: oklch(0.992 0.004 232);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 94px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
padding: 18px 14px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: oklch(0.956 0.009 232);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-row button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--blue);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel {
|
||||||
|
margin-top: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-row span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-strip,
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.route-head,
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-strip,
|
||||||
|
.settings-grid,
|
||||||
|
.control-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main,
|
||||||
|
.side {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="shell" aria-label="VPN Proxy redesign preview">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="mark" aria-hidden="true"></div>
|
||||||
|
<div>
|
||||||
|
<h1>VPN Proxy Client</h1>
|
||||||
|
<span>Local control panel, macOS Docker mode</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span class="status-pill">sing-box running</span>
|
||||||
|
<button class="btn">Restart</button>
|
||||||
|
<button class="btn primary">Apply route</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<aside class="rail" aria-label="Connection modes">
|
||||||
|
<p class="nav-title">Режим подключения</p>
|
||||||
|
<div class="mode-list">
|
||||||
|
<button class="mode" type="button">
|
||||||
|
<span class="mode-dot"></span>
|
||||||
|
<span>
|
||||||
|
<strong>Общий gateway</strong>
|
||||||
|
<span>192.168.50.111:8080</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="mode active" type="button">
|
||||||
|
<span class="mode-dot"></span>
|
||||||
|
<span>
|
||||||
|
<strong>Локальный VPN</strong>
|
||||||
|
<span>Finland 02 selected</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button class="mode" type="button">
|
||||||
|
<span class="mode-dot"></span>
|
||||||
|
<span>
|
||||||
|
<strong>Напрямую</strong>
|
||||||
|
<span>без VPN и gateway</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rail-section">
|
||||||
|
<p class="nav-title">Проверки</p>
|
||||||
|
<div class="mini-list">
|
||||||
|
<div class="mini-row"><strong>Docker</strong><span>ready</span></div>
|
||||||
|
<div class="mini-row"><strong>Ports</strong><span>8082 open</span></div>
|
||||||
|
<div class="mini-row"><strong>Config</strong><span>valid</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main">
|
||||||
|
<div class="route-head">
|
||||||
|
<div>
|
||||||
|
<h2>Текущий маршрут: приложения Mac идут через локальный VPN</h2>
|
||||||
|
<p>
|
||||||
|
Главный экран показывает не настройки как список, а фактический путь трафика:
|
||||||
|
от приложения до интернета, с портом, выбранным режимом и состоянием сервиса.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="health">
|
||||||
|
<strong>42 ms</strong>
|
||||||
|
<span>последняя проверка маршрута</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-strip" aria-label="Active route">
|
||||||
|
<div class="node">
|
||||||
|
<small>Источник</small>
|
||||||
|
<strong>Mac apps</strong>
|
||||||
|
<span>браузер, Discord, Telegram</span>
|
||||||
|
</div>
|
||||||
|
<div class="node active">
|
||||||
|
<small>Локальный proxy</small>
|
||||||
|
<strong>127.0.0.1:8082</strong>
|
||||||
|
<span>HTTP и SOCKS5</span>
|
||||||
|
</div>
|
||||||
|
<div class="node active">
|
||||||
|
<small>Режим</small>
|
||||||
|
<strong>Local VPN</strong>
|
||||||
|
<span>Finland 02</span>
|
||||||
|
</div>
|
||||||
|
<div class="node pending">
|
||||||
|
<small>Выход</small>
|
||||||
|
<strong>Internet</strong>
|
||||||
|
<span>проверка 11:04</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-line" aria-label="Route path">
|
||||||
|
<b>Mac apps</b><span>></span><b>127.0.0.1:8082</b><span>></span><b>sing-box</b><span>></span><b>Finland 02</b><span>></span><b>Internet</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Настройка выбранного режима</h3>
|
||||||
|
<button class="btn">Load subscription</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Подписка или VLESS</span>
|
||||||
|
<div class="control-row">
|
||||||
|
<input class="input" value="https://provider.example/subscription" />
|
||||||
|
<button class="btn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">VPN-сервер</span>
|
||||||
|
<div class="control-row">
|
||||||
|
<select class="select">
|
||||||
|
<option>Finland 02, 42 ms, 18 GB left</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn primary">Connect</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Сводка</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="summary-list">
|
||||||
|
<div class="summary-row"><small>Service</small><strong>running since 10:52</strong></div>
|
||||||
|
<div class="summary-row"><small>Config</small><strong>applied 2 minutes ago</strong></div>
|
||||||
|
<div class="summary-row"><small>Fallback</small><strong>VPN by default</strong></div>
|
||||||
|
<div class="summary-row"><small>Quota</small><strong>18 GB left</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side" aria-label="Local proxy details">
|
||||||
|
<p class="panel-label">Адреса для приложений</p>
|
||||||
|
<div class="copy-stack">
|
||||||
|
<div class="copy-row"><span>http://127.0.0.1:8082</span><button>Copy</button></div>
|
||||||
|
<div class="copy-row"><span>socks5://127.0.0.1:8082</span><button>Copy</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="side-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Порт proxy</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="control-row">
|
||||||
|
<input class="input" value="8082" />
|
||||||
|
<button class="btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="side-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Активность</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="activity">
|
||||||
|
<div class="activity-row">
|
||||||
|
<time>11:04</time>
|
||||||
|
<span><strong>Route check passed</strong><span>Finland 02 returned 42 ms</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-row">
|
||||||
|
<time>11:02</time>
|
||||||
|
<span><strong>Port changed</strong><span>8080 was busy, 8082 selected</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-row">
|
||||||
|
<time>10:59</time>
|
||||||
|
<span><strong>Subscription refreshed</strong><span>12 servers available</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1342
docs/goals/windows-modular-client/EVIDENCE.md
Normal file
1342
docs/goals/windows-modular-client/EVIDENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
16
docs/goals/windows-modular-client/GOAL.md
Normal file
16
docs/goals/windows-modular-client/GOAL.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Goal: Windows Tauri Proxy Client
|
||||||
|
|
||||||
|
Use Krypton Execution to execute `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
|
||||||
|
Core rules:
|
||||||
|
- Treat `PLAN.md` as the source plan.
|
||||||
|
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
|
||||||
|
- Build a separate Tauri 2 Windows desktop app under `apps/windows-client`.
|
||||||
|
- Do not implement Windows by extending the current Node gateway/client server.
|
||||||
|
- Keep Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
|
||||||
|
- Make external proxy target + ProxiFyre profile apply the MVP.
|
||||||
|
- Keep Local sing-box optional; it must not be required for external target profiles.
|
||||||
|
- Keep generated ProxiFyre and sing-box configs derived from source models.
|
||||||
|
- Capture acceptance evidence from the target user's perspective and record it in `EVIDENCE.md`.
|
||||||
|
- Say "implemented but unproven" if Windows-only privileged evidence cannot be captured.
|
||||||
|
|
||||||
506
docs/goals/windows-modular-client/PLAN.md
Normal file
506
docs/goals/windows-modular-client/PLAN.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
# Windows Tauri Proxy Client Implementation Plan
|
||||||
|
|
||||||
|
**Intent:** Build a separate Windows desktop proxy management app using Tauri 2, React, TypeScript, and Rust. The app manages three independent components: Control App, Proxyfier Layer, and optional Local sing-box.
|
||||||
|
**Current Behavior:** The repo contains a gateway/client Node + React web application and planning documents for a Windows mode inside that app. A newer product/technology brief now targets a standalone Windows desktop utility instead of extending the existing web control panel.
|
||||||
|
**Expected Outcome:** A compact Windows desktop utility lets the user configure app-level proxy routing through external SOCKS5/HTTP targets first, then optionally install and use local sing-box. The app remains useful when sing-box is absent.
|
||||||
|
**Target-Perspective Output:** A Windows user opens the desktop app, sees Overview, Profiles, Targets, Components, and Logs, adds Discord or another process/folder/exe profile, selects an external proxy target, applies changes to the proxyfier layer, and sees component status plus recent activity. Later, installing Local sing-box adds a local target without changing the profile model.
|
||||||
|
**Truth Owner:** Source configuration lives in the Tauri app's Rust domain model and JSON files under `C:\ProgramData\VpnProxy`. Generated ProxiFyre and sing-box configs are derived artifacts. Privileged install/service operations are owned by explicit helper/installer flows, not by React UI state.
|
||||||
|
**Contract Boundary:** React UI calls typed Tauri commands. Tauri Rust backend validates and persists profiles/targets/components. Proxy routing is behind a `ProxyRouterAdapter` boundary, with ProxiFyre as the first adapter. Privileged operations go through explicit helper/install commands returning structured JSON.
|
||||||
|
**Cutover:** Supersede the prior Node `APP_MODE=windows` implementation direction. Keep existing gateway/client code intact. New Windows work lives under a separate Tauri app slice.
|
||||||
|
**Displaced Path:** The old plan to add Windows mode into `src/server`/`src/web` is demoted to historical context. Do not add a third app mode to the current Node server for this product.
|
||||||
|
**Value Density:** The smallest high-value slice is the desktop app MVP with external SOCKS5 target + ProxiFyre profile apply. Local sing-box is optional and comes after the proxyfier MVP is proven.
|
||||||
|
**Evidence Gate:** Evidence must include target-perspective app proof: built Tauri app or dev window screenshot/state, generated proxyfier config artifact, mocked or real helper response, and manual Windows checklist when privileged components are involved.
|
||||||
|
**Acceptance Evidence:** Automated tests pass for Rust/TypeScript domain logic, app build succeeds, the MVP can create a profile and generate/apply ProxiFyre config against an external target, and Windows manual evidence proves independent component behavior.
|
||||||
|
**Evidence Lane:** Record command output, app screenshots/state payloads, generated configs, and manual verification in `docs/goals/windows-modular-client/EVIDENCE.md`.
|
||||||
|
**Kill Criteria:** No Windows implementation inside current Node gateway/client server; no mandatory sing-box dependency; no generated config as source truth; no hidden installation during profile apply; no direct UI parsing of raw PowerShell/stdout.
|
||||||
|
**Architecture Slice:** New standalone Tauri app under `apps/windows-client`, plus docs updates that point from older Windows plans to this plan.
|
||||||
|
**Plan Review Gate:** Requires PRE review before implementation execution.
|
||||||
|
|
||||||
|
## Source Brief
|
||||||
|
|
||||||
|
Product and technology source brief:
|
||||||
|
|
||||||
|
- `docs/windows-client-product-tech-brief.md`
|
||||||
|
|
||||||
|
This plan turns that brief into an execution-ready implementation sequence.
|
||||||
|
|
||||||
|
## Outcome Contract
|
||||||
|
|
||||||
|
Plan title: Windows Tauri Proxy Client
|
||||||
|
|
||||||
|
Intent: Build a native-feeling Windows utility that manages app-level proxy routing while keeping Control App, Proxyfier Layer, and Local sing-box separately installable and operable.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
- Existing runtime code is a Node HTTP server and Vite/React web UI for gateway and Mac-style client modes.
|
||||||
|
- Earlier Windows docs describe adding Windows mode to that existing app.
|
||||||
|
- The selected direction is now Tauri 2 + React/TypeScript + Rust as a separate Windows desktop app.
|
||||||
|
|
||||||
|
Expected outcome:
|
||||||
|
- `apps/windows-client` contains a Tauri 2 app.
|
||||||
|
- The app has Overview, Profiles, Targets, Components, and Logs surfaces.
|
||||||
|
- Profiles store process/folder/exe source items.
|
||||||
|
- Targets store external proxy endpoints and optional local sing-box.
|
||||||
|
- ProxiFyre is the first proxy router adapter.
|
||||||
|
- Local sing-box is optional and never required for external target profiles.
|
||||||
|
|
||||||
|
Target-perspective output:
|
||||||
|
- User can install/run only the Control App.
|
||||||
|
- User can see Proxyfier and Local sing-box as separate components.
|
||||||
|
- User can add an external SOCKS5 target.
|
||||||
|
- User can add a Discord process profile.
|
||||||
|
- User can apply the profile to generated ProxiFyre config.
|
||||||
|
- User sees activity confirming whether apply succeeded or why it was blocked.
|
||||||
|
|
||||||
|
Truth owner:
|
||||||
|
- Rust core domain crate owns normalized models and validation.
|
||||||
|
- JSON source files under `C:\ProgramData\VpnProxy\config` own persisted profiles/targets/component preferences.
|
||||||
|
- `ProxyRouterAdapter` owns conversion from source models to proxy-router generated config.
|
||||||
|
- `SingBoxAdapter` owns generated local sing-box config and service contract.
|
||||||
|
- React UI owns only transient UI state.
|
||||||
|
|
||||||
|
Contract boundary:
|
||||||
|
- UI -> Tauri commands with typed request/response DTOs.
|
||||||
|
- Tauri commands -> Rust core services.
|
||||||
|
- Core services -> adapter traits.
|
||||||
|
- Adapter traits -> helper/install/service commands when privileged operations are needed.
|
||||||
|
- Helper/install commands return structured JSON, never unstructured text for app logic.
|
||||||
|
|
||||||
|
Cutover:
|
||||||
|
- Add superseded notes to old Windows Node-mode docs.
|
||||||
|
- Keep `docs/windows-client-product-tech-brief.md` as product brief.
|
||||||
|
- Make this `PLAN.md` the execution plan.
|
||||||
|
- Do not implement Windows by adding `APP_MODE=windows` to the current Node server.
|
||||||
|
|
||||||
|
Displaced path:
|
||||||
|
- Displace old "Windows mode in current web app" implementation.
|
||||||
|
- Displace "full install vs ProxiFyre-only" as dominant architecture; those become recipes composed from separate components.
|
||||||
|
|
||||||
|
Value density:
|
||||||
|
- MVP must prove app-level routing with external proxy target and ProxiFyre before local sing-box work expands scope.
|
||||||
|
|
||||||
|
Evidence gate:
|
||||||
|
- Tests and build are not enough.
|
||||||
|
- Capture app-visible state and generated config.
|
||||||
|
- Capture Windows manual evidence for service/helper actions when those tasks execute.
|
||||||
|
|
||||||
|
Acceptance evidence:
|
||||||
|
- `cargo test` or equivalent Rust tests for domain/adapters.
|
||||||
|
- frontend typecheck/test/build for React.
|
||||||
|
- Tauri dev/build command result.
|
||||||
|
- Screenshot or state dump showing Windows app surfaces.
|
||||||
|
- Generated ProxiFyre config from a sample profile.
|
||||||
|
- Manual Windows checklist when privileged components are present.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
- No Electron.
|
||||||
|
- No extension of the current Node gateway/client UI for Windows MVP.
|
||||||
|
- No global Windows system proxy changes.
|
||||||
|
- No transparent routing without a proxy router.
|
||||||
|
- No mandatory local sing-box.
|
||||||
|
- No direct coupling of UI to ProxiFyre-specific config shape.
|
||||||
|
|
||||||
|
Risk if wrong:
|
||||||
|
- If built inside the current Node app, the product will inherit gateway/client assumptions and conflict with the selected Tauri direction.
|
||||||
|
- If ProxiFyre is not behind an adapter, licensing or engine changes will force UI/data rewrites.
|
||||||
|
- If privileged work is hidden behind apply, users lose control and failures become hard to diagnose.
|
||||||
|
|
||||||
|
## Architecture Slice
|
||||||
|
|
||||||
|
Files/directories to create:
|
||||||
|
- `apps/windows-client/package.json`
|
||||||
|
- `apps/windows-client/vite.config.ts`
|
||||||
|
- `apps/windows-client/tsconfig.json`
|
||||||
|
- `apps/windows-client/src/main.tsx`
|
||||||
|
- `apps/windows-client/src/app/App.tsx`
|
||||||
|
- `apps/windows-client/src/app/routes.tsx`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src/features/overview/*`
|
||||||
|
- `apps/windows-client/src/features/profiles/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/logs/*`
|
||||||
|
- `apps/windows-client/src/styles/*`
|
||||||
|
- `apps/windows-client/src-tauri/Cargo.toml`
|
||||||
|
- `apps/windows-client/src-tauri/tauri.conf.json`
|
||||||
|
- `apps/windows-client/src-tauri/capabilities/default.json`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/activity.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/helper.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/*`
|
||||||
|
- `apps/windows-client/scripts/install-control-app.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-proxyfier.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
|
||||||
|
Files to modify:
|
||||||
|
- `README.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-05-21-windows-client.md`
|
||||||
|
- `docs/goals/windows-modular-client/GOAL.md`
|
||||||
|
- `docs/goals/windows-modular-client/EVIDENCE.md`
|
||||||
|
|
||||||
|
Files to avoid:
|
||||||
|
- `src/server/*` except if a later explicit migration asks for shared code extraction.
|
||||||
|
- `src/web/*` for Windows MVP.
|
||||||
|
- Docker, entrypoint, and compose files.
|
||||||
|
- macOS installer.
|
||||||
|
|
||||||
|
Source of truth:
|
||||||
|
- `C:\ProgramData\VpnProxy\config\profiles.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\targets.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\config\components.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\state\activity.json`
|
||||||
|
|
||||||
|
Derived artifacts:
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json`
|
||||||
|
- `C:\ProgramData\VpnProxy\generated\sing-box-config.json`
|
||||||
|
- ProxiFyre runtime config copied/backed up by helper/apply operation.
|
||||||
|
|
||||||
|
Read path:
|
||||||
|
- React UI calls Tauri commands.
|
||||||
|
- Tauri commands read JSON source via Rust storage service.
|
||||||
|
- Component status combines source preferences, filesystem checks, service checks, and helper responses.
|
||||||
|
|
||||||
|
Write path:
|
||||||
|
- React UI sends typed mutations.
|
||||||
|
- Rust validates with domain models.
|
||||||
|
- Rust writes source JSON atomically with backups.
|
||||||
|
- Apply generates derived config and invokes adapter/helper.
|
||||||
|
|
||||||
|
Integration points:
|
||||||
|
- ProxiFyre adapter emits `app-config.json` compatible output.
|
||||||
|
- Local sing-box adapter emits `sing-box` JSON config and validates via `sing-box check` when binary exists.
|
||||||
|
- Tauri sidecar/helper permissions are declared explicitly.
|
||||||
|
- Installer scripts may be launched or displayed explicitly, never silently during apply.
|
||||||
|
|
||||||
|
Migration/cutover:
|
||||||
|
- Older Windows docs point to this plan and source brief.
|
||||||
|
- Existing Node app remains gateway/client only.
|
||||||
|
- If shared subscription parsing is needed later, extract it intentionally into a shared package rather than importing server internals.
|
||||||
|
|
||||||
|
Acceptance evidence gate:
|
||||||
|
- MVP evidence must show external-target flow works without local sing-box.
|
||||||
|
- Optional sing-box evidence must show the same profile model can switch targets after installing sing-box.
|
||||||
|
|
||||||
|
## Task Board
|
||||||
|
|
||||||
|
### Task 1: Supersede Old Windows Node Plan
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- `docs/windows-client-product-tech-brief.md`
|
||||||
|
- old Windows docs/plans
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `docs/superpowers/specs/2026-05-21-windows-client-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-05-21-windows-client.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Runtime source files.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Old Windows documents clearly point to this Tauri plan and no longer read as implementation authority.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `rg -n "Tauri|superseded|windows-client-product-tech-brief|apps/windows-client" README.md docs`
|
||||||
|
|
||||||
|
Depends on: none
|
||||||
|
|
||||||
|
Parallel safe: yes
|
||||||
|
|
||||||
|
### Task 2: Scaffold Tauri App Shell
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Tauri 2 app structure
|
||||||
|
- Product brief UI surfaces
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/package.json`
|
||||||
|
- `apps/windows-client/vite.config.ts`
|
||||||
|
- `apps/windows-client/tsconfig.json`
|
||||||
|
- `apps/windows-client/index.html`
|
||||||
|
- `apps/windows-client/src/*`
|
||||||
|
- `apps/windows-client/src-tauri/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Current root `src/server/*`
|
||||||
|
- Current root `src/web/*`
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Tauri app starts with empty shell and five navigation surfaces.
|
||||||
|
- No business logic yet.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `cd apps/windows-client && npm install && npm run build`
|
||||||
|
- `cd apps/windows-client/src-tauri && cargo test` if Rust tests exist
|
||||||
|
|
||||||
|
Depends on: Task 1
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 3: Define Domain Models And Validation
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Profile/Target/Component models from brief
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/models.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/validation.rs`
|
||||||
|
- `apps/windows-client/src/domain/types.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/domain_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Adapter/helper code except trait references.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Typed Rust models for `Profile`, `ProfileItem`, `Target`, `ComponentStatus`, `ActivityEntry`.
|
||||||
|
- TypeScript DTOs mirror Rust command responses.
|
||||||
|
- Validation rejects malformed ports/protocols but allows missing local sing-box.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Rust tests showing process/folder/exe normalization and external target validation.
|
||||||
|
|
||||||
|
Depends on: Task 2
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 4: Implement JSON Storage And Activity Log
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Domain models from Task 3
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/storage.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/activity.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/storage_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- UI screens except command wiring stubs.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Atomic JSON read/write for profiles, targets, components, and activity.
|
||||||
|
- Backups before overwriting source files.
|
||||||
|
- Config root defaults to `C:\ProgramData\VpnProxy`, with test override.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Tests prove roundtrip, invalid JSON fallback behavior, backup creation, activity cap/sort.
|
||||||
|
|
||||||
|
Depends on: Task 3
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 5: Add Proxy Router Adapter Boundary And ProxiFyre Adapter
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Domain models and storage
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxy_router.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/proxifyre.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/proxifyre_adapter_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Direct UI coupling to ProxiFyre config fields.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- `ProxyRouterAdapter` trait.
|
||||||
|
- `ProxiFyreAdapter` generates config from enabled profiles and targets.
|
||||||
|
- External target flow does not require sing-box.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Test generates ProxiFyre config for Discord + external SOCKS5 target.
|
||||||
|
- Test blocks local-singbox target only when target requires missing component.
|
||||||
|
|
||||||
|
Depends on: Task 4
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 6: Add Tauri Commands
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Storage and adapter services
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/commands.rs`
|
||||||
|
- `apps/windows-client/src-tauri/src/main.rs`
|
||||||
|
- `apps/windows-client/src/api/tauriCommands.ts`
|
||||||
|
- `apps/windows-client/src-tauri/tests/command_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Full UI implementation beyond command call wrappers.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Commands for status, profiles, targets, components, scan/resolve preview, apply, logs.
|
||||||
|
- Commands return structured responses only.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Command tests or integration tests prove apply generates derived config and records activity using a mock adapter/helper.
|
||||||
|
|
||||||
|
Depends on: Task 5
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 7: Build MVP UI
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Tauri command API
|
||||||
|
- Product brief layout
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src/app/*`
|
||||||
|
- `apps/windows-client/src/features/overview/*`
|
||||||
|
- `apps/windows-client/src/features/profiles/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/logs/*`
|
||||||
|
- `apps/windows-client/src/styles/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Rust adapter behavior except fixing DTO mismatches.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Compact utility UI with Overview, Profiles, Targets, Components, Logs.
|
||||||
|
- User can create/edit profile, external target, and trigger apply.
|
||||||
|
- Missing sing-box is shown as valid optional state.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `npm run build`
|
||||||
|
- Screenshot or browser/app state showing missing sing-box and usable external target flow.
|
||||||
|
|
||||||
|
Depends on: Task 6
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 8: Implement Helper And Explicit Installer Boundary
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Component model
|
||||||
|
- Security model from brief
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/helper.rs`
|
||||||
|
- `apps/windows-client/src-tauri/capabilities/default.json`
|
||||||
|
- `apps/windows-client/scripts/install-control-app.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-proxyfier.ps1`
|
||||||
|
- `apps/windows-client/scripts/install-singbox.ps1`
|
||||||
|
- `apps/windows-client/src-tauri/tests/helper_tests.rs`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Hidden installer invocation inside profile apply.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Helper command abstraction for status/service/apply.
|
||||||
|
- Installer scripts are explicit and idempotent.
|
||||||
|
- Tauri sidecar/shell permissions are narrow and documented.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Helper tests with mock command runner.
|
||||||
|
- PowerShell parser checks for installer scripts.
|
||||||
|
- Capability file shows limited sidecar permissions.
|
||||||
|
|
||||||
|
Depends on: Task 6
|
||||||
|
|
||||||
|
Parallel safe: partly, after command DTOs are stable
|
||||||
|
|
||||||
|
### Task 9: Add Optional Local Sing-Box Adapter
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- sing-box target model
|
||||||
|
- service/helper boundary
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/src-tauri/src/adapters/singbox.rs`
|
||||||
|
- `apps/windows-client/src-tauri/tests/singbox_adapter_tests.rs`
|
||||||
|
- `apps/windows-client/src/features/components/*`
|
||||||
|
- `apps/windows-client/src/features/targets/*`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Making sing-box mandatory for external targets.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Generate local sing-box config.
|
||||||
|
- Validate via `sing-box check` when binary exists.
|
||||||
|
- Local target appears only when installed/configured or as an explicit install prompt.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- Tests show external target apply works without sing-box.
|
||||||
|
- Tests show local-singbox target requires installed/running component.
|
||||||
|
|
||||||
|
Depends on: Tasks 5 and 8
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
### Task 10: Package, Verify, And Record Evidence
|
||||||
|
|
||||||
|
Owner: main agent
|
||||||
|
|
||||||
|
Input:
|
||||||
|
- Completed MVP implementation
|
||||||
|
|
||||||
|
Files allowed:
|
||||||
|
- `apps/windows-client/*`
|
||||||
|
- `README.md`
|
||||||
|
- `docs/roadmap.md`
|
||||||
|
- `docs/goals/windows-modular-client/EVIDENCE.md`
|
||||||
|
|
||||||
|
Files forbidden:
|
||||||
|
- Unrelated app code.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Build/test commands documented.
|
||||||
|
- README explains separate Control App, Proxyfier, and Local sing-box install flows.
|
||||||
|
- Evidence file captures automated and target-perspective proof.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
- `npm run build`
|
||||||
|
- Rust tests
|
||||||
|
- Tauri build/dev proof
|
||||||
|
- generated ProxiFyre config summary
|
||||||
|
- UI screenshot/state
|
||||||
|
- Windows manual checklist, or clearly mark `implemented but unproven` for Windows-only service behavior if not run on a Windows host.
|
||||||
|
|
||||||
|
Depends on: all previous tasks
|
||||||
|
|
||||||
|
Parallel safe: no
|
||||||
|
|
||||||
|
## Manual Windows Verification Checklist
|
||||||
|
|
||||||
|
1. Install/run only Control App.
|
||||||
|
2. Verify Proxyfier and Local sing-box show missing as separate components.
|
||||||
|
3. Add external SOCKS5 target.
|
||||||
|
4. Add Discord process profile.
|
||||||
|
5. Apply profile; verify generated ProxiFyre config and activity entry.
|
||||||
|
6. Install Proxyfier separately; verify status changes.
|
||||||
|
7. Apply profile to real Proxyfier service.
|
||||||
|
8. Install Local sing-box separately.
|
||||||
|
9. Import subscription or config, select outbound, and start Local sing-box.
|
||||||
|
10. Switch existing profile from external target to Local sing-box and apply.
|
||||||
|
11. Stop/restart Proxyfier and Local sing-box separately.
|
||||||
|
12. Copy diagnostics and verify secrets are redacted.
|
||||||
|
|
||||||
122
docs/roadmap.md
Normal file
122
docs/roadmap.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Roadmap: VPN Proxy rebuild
|
||||||
|
|
||||||
|
## Целевая модель
|
||||||
|
|
||||||
|
Проект должен стать multi-mode системой вокруг `sing-box`:
|
||||||
|
|
||||||
|
| Режим | Назначение | Runtime | Статус |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `gateway` | LXC/VPS как gateway для роутера и всей сети | Docker `network_mode: host` + TProxy | делаем первым |
|
||||||
|
| `desktop-proxy` | Mac/Linux локальный HTTP/SOCKS proxy с fallback | Docker bridged ports | позже переносим из старой реализации |
|
||||||
|
| `windows-gaming` | Windows для игр/Discord/Vesktop | standalone Tauri 2 app + ProxiFyre adapter + optional native `sing-box.exe` | активное направление: `docs/goals/windows-modular-client/PLAN.md` |
|
||||||
|
|
||||||
|
## Gateway mode
|
||||||
|
|
||||||
|
Цель: контейнер, который становится прозрачным gateway для сети.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- `sing-box` внутри контейнера.
|
||||||
|
- `network_mode: host`.
|
||||||
|
- `CAP_NET_ADMIN` и `CAP_NET_RAW`.
|
||||||
|
- TProxy inbound на `7895`.
|
||||||
|
- Mixed HTTP/SOCKS inbound на `8080`.
|
||||||
|
- Web UI на `3456`.
|
||||||
|
- Subscription URL вводится в UI, парсится, пользователь выбирает сервер.
|
||||||
|
- Пользовательские routing lists управляются из UI.
|
||||||
|
- Генерируется `/etc/sing-box/config.json`.
|
||||||
|
- `sing-box check` перед применением.
|
||||||
|
- Restart `sing-box` после применения.
|
||||||
|
- Idempotent iptables setup.
|
||||||
|
- Cleanup iptables/ip rule/ip route при остановке контейнера.
|
||||||
|
|
||||||
|
Маршрутизация v1:
|
||||||
|
|
||||||
|
- private IP ranges -> `direct`.
|
||||||
|
- пользовательские списки -> `direct`, `vpn` или `block`.
|
||||||
|
- `geoip-ru` -> `direct`.
|
||||||
|
- `geosite-category-ru` -> `direct`.
|
||||||
|
- все остальное -> выбранный VPN outbound.
|
||||||
|
|
||||||
|
Порядок правил:
|
||||||
|
|
||||||
|
1. safety private-direct, чтобы не ломать LAN.
|
||||||
|
2. custom routing lists из UI.
|
||||||
|
3. RU direct rules.
|
||||||
|
4. default VPN outbound.
|
||||||
|
|
||||||
|
Формат пользовательского списка:
|
||||||
|
|
||||||
|
- `name`.
|
||||||
|
- `enabled`.
|
||||||
|
- `outbound`: `direct`, `vpn`, `block`.
|
||||||
|
- `domains`: exact domains.
|
||||||
|
- `domainSuffixes`: доменные suffix, удобно для игр/сервисов.
|
||||||
|
- `domainKeywords`: keyword matching.
|
||||||
|
- `ipCidrs`: CIDR ranges.
|
||||||
|
- `ports`: TCP/UDP ports.
|
||||||
|
- `networks`: `tcp`, `udp`.
|
||||||
|
- UI должен автосохранять списки с debounce, чтобы polling state не затирал незавершенное редактирование.
|
||||||
|
|
||||||
|
Важно: gateway не видит process name на клиентском ПК. Для сценария вроде "League of Legends всегда direct" нужны домены, CIDR и порты Riot, а не имя процесса.
|
||||||
|
|
||||||
|
Отдельно решить позже:
|
||||||
|
|
||||||
|
- DNS strategy: DHCP DNS, DNS redirect или local DNS inbound.
|
||||||
|
- IPv6 TProxy.
|
||||||
|
- nftables backend.
|
||||||
|
- health checks и smoke diagnostics.
|
||||||
|
- secret storage через Infisical/Vault/env.
|
||||||
|
|
||||||
|
## Desktop proxy mode
|
||||||
|
|
||||||
|
Цель: сохранить удобный Docker-сценарий для Mac/Linux без TProxy.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- UI на `3456`.
|
||||||
|
- Mixed inbound на `8080`.
|
||||||
|
- Subscription parser.
|
||||||
|
- Выбор сервера.
|
||||||
|
- Fallback proxy через `urltest`.
|
||||||
|
- Direct mode toggle.
|
||||||
|
- Не требует `NET_ADMIN`.
|
||||||
|
|
||||||
|
## Windows gaming mode
|
||||||
|
|
||||||
|
Цель: отдельное Windows desktop-приложение для Discord/Vesktop/игр, где Control App, Proxyfier Layer и Local sing-box являются независимыми компонентами.
|
||||||
|
|
||||||
|
Current checkpoint:
|
||||||
|
|
||||||
|
- MVP slice exists under `apps/windows-client`.
|
||||||
|
- Frontend build passes with `npm run build`.
|
||||||
|
- Rust/Tauri native verification requires installing Rust/rustup and Visual Studio Build Tools with MSVC/Windows SDK.
|
||||||
|
- Local sing-box is optional; external SOCKS5 targets remain the first verified path.
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- Standalone Tauri 2 + React/TypeScript + Rust app under `apps/windows-client`.
|
||||||
|
- Profiles for process/folder/exe app routing.
|
||||||
|
- External SOCKS5/HTTP targets first; local `sing-box` is optional.
|
||||||
|
- Proxyfier adapter boundary with ProxiFyre as the first engine.
|
||||||
|
- Explicit installers for Control App, Proxyfier Layer, and Local sing-box.
|
||||||
|
- Privileged helper/install operations return structured JSON.
|
||||||
|
|
||||||
|
Source docs:
|
||||||
|
|
||||||
|
- Product/tech brief: `docs/windows-client-product-tech-brief.md`.
|
||||||
|
- Execution plan: `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
|
||||||
|
Superseded:
|
||||||
|
|
||||||
|
- The old Node `APP_MODE=windows` plan in `docs/superpowers/plans/2026-05-21-windows-client.md` is historical context, not the active implementation path.
|
||||||
|
|
||||||
|
## Рабочий порядок
|
||||||
|
|
||||||
|
1. Сделать новый gateway root.
|
||||||
|
2. Реализовать Docker image + entrypoint TProxy lifecycle.
|
||||||
|
3. Реализовать маленький control-server.
|
||||||
|
4. Реализовать Vite + React UI для subscription -> server select -> apply.
|
||||||
|
5. Добавить gateway docs/install script.
|
||||||
|
6. Потом переносить desktop-proxy.
|
||||||
|
7. Потом реализовать standalone Windows Tauri client по `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
74
docs/superpowers/plans/2026-05-19-macos-client.md
Normal file
74
docs/superpowers/plans/2026-05-19-macos-client.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# macOS Docker Client Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a macOS Docker client mode that exposes a local HTTP/SOCKS proxy on `127.0.0.1:8080` with a friendlier UI and a curl installer.
|
||||||
|
|
||||||
|
**Architecture:** Reuse the current Node API, React UI, subscription parser, sing-box process manager, and routing rule generator. Add `APP_MODE=client` so the same backend emits a proxy-only sing-box config without TProxy, and use a dedicated Dockerfile/compose path for Mac installation.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM, React/Vite, sing-box, Docker Compose, POSIX shell, `node:test`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Client Mode Config Contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/server/singbox-client-mode.test.js`
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `src/server/config.js`
|
||||||
|
- Modify: `src/server/singbox.js`
|
||||||
|
- Modify: `src/server/index.js`
|
||||||
|
|
||||||
|
- [ ] Add `node:test` coverage that proves `APP_MODE=client` config has `mixed-in`, no `tproxy-in`, no transparent fallback, and a VPN proxy fallback.
|
||||||
|
- [ ] Add `npm test` script.
|
||||||
|
- [ ] Add `settings.appMode`.
|
||||||
|
- [ ] Make `buildGatewayConfig()` conditionally emit client-only inbounds and route rules.
|
||||||
|
- [ ] Expose `mode` and hide irrelevant tproxy fields in public state.
|
||||||
|
|
||||||
|
### Task 2: macOS Client Docker Runtime
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `entrypoint.client.sh`
|
||||||
|
- Create: `Dockerfile.client`
|
||||||
|
- Create: `docker-compose.client.yml`
|
||||||
|
|
||||||
|
- [ ] Add an entrypoint that starts only the Node control server.
|
||||||
|
- [ ] Add a Dockerfile that builds the Vite frontend inside Docker and installs only client runtime dependencies plus sing-box.
|
||||||
|
- [ ] Add compose with loopback-only port publishing for UI and proxy.
|
||||||
|
|
||||||
|
### Task 3: User-Friendly Client UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/web/components/ClientOverviewPage.jsx`
|
||||||
|
- Modify: `src/web/App.jsx`
|
||||||
|
- Modify: `src/web/components/Sidebar.jsx`
|
||||||
|
- Modify: `src/web/components/Topbar.jsx`
|
||||||
|
- Modify: `src/web/components/StatusPane.jsx`
|
||||||
|
- Modify: `src/web/components/RouteChecker.jsx`
|
||||||
|
- Modify: `src/web/styles.css`
|
||||||
|
|
||||||
|
- [ ] Add a client overview with status, active server, copyable proxy URLs, and macOS setup commands.
|
||||||
|
- [ ] Hide gateway-only navigation and side status pane in client mode.
|
||||||
|
- [ ] Rename topbar brand to match current mode.
|
||||||
|
- [ ] Keep servers, logs, and settings reachable in client mode.
|
||||||
|
|
||||||
|
### Task 4: curl Installer and Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/install-macos-client.sh`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
|
||||||
|
- [ ] Add curl-friendly installer with Docker/Git checks and update-or-clone behavior.
|
||||||
|
- [ ] Document one-line install command and manual compose command.
|
||||||
|
- [ ] Add client mode environment examples.
|
||||||
|
|
||||||
|
### Task 5: Verification
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `npm test`
|
||||||
|
- `npm run build`
|
||||||
|
- `docker compose -f docker-compose.client.yml config`
|
||||||
|
|
||||||
|
- [ ] Run all commands and fix any failures.
|
||||||
|
- [ ] Inspect the diff to confirm existing CI/runtime-base edits remain untouched.
|
||||||
2197
docs/superpowers/plans/2026-05-21-windows-client.md
Normal file
2197
docs/superpowers/plans/2026-05-21-windows-client.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
|||||||
|
# VPN Proxy Client Route Console Redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the current macOS client overview with a route-first console that makes the active traffic path, local proxy address, selected mode, and next action obvious at a glance.
|
||||||
|
|
||||||
|
**Architecture:** Keep `resolveClientRoute()` as the single source of truth and keep `ClientOverviewPage` as the orchestrator. Split the screen into small presentational components inside `src/web/components/ClientOverviewPage.jsx`, then replace only the client-mode CSS block in `src/web/styles.css` so gateway and Windows work stay untouched.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Vite, Node.js `node:test`, existing CSS variables, Open Design static HTML artifact.
|
||||||
|
|
||||||
|
**Design Artifact:** `docs/design/open-design/vpn-proxy-route-console-redesign.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Findings
|
||||||
|
|
||||||
|
- `src/web/components/ClientOverviewPage.jsx` already has the right model: one overview screen, mutually exclusive `Gateway`, `VPN`, and `Direct` modes, and route state from `resolveClientRoute()`.
|
||||||
|
- `src/web/styles.css` makes the client screen visually separate, but it uses a dark blue-green palette that reads as a monitoring dashboard rather than a macOS setup tool.
|
||||||
|
- The current status panel, route line, mode grid, and proxy panel have similar visual weight. The user must scan several boxes to answer the primary question: where does my traffic go right now?
|
||||||
|
- Copyable proxy addresses sit in the side panel. They are useful, but they are visually separated from the route story.
|
||||||
|
- The three mode buttons look like cards. They work, but they do not communicate that mode selection changes the middle segment of the route.
|
||||||
|
|
||||||
|
## Target Design
|
||||||
|
|
||||||
|
Use a light, restrained operational UI for a normal macOS desktop context: a user has Docker running, a browser open, and is checking why an app uses a certain proxy path. The interface should feel closer to a compact network control console than a server dashboard.
|
||||||
|
|
||||||
|
The first viewport should show:
|
||||||
|
|
||||||
|
- top status: service running, restart, apply route;
|
||||||
|
- left mode rail: Gateway, Local VPN, Direct;
|
||||||
|
- main route strip: `Mac apps > local proxy > selected route > Internet`;
|
||||||
|
- right utility panel: copy proxy addresses, proxy port, recent activity;
|
||||||
|
- settings below route: only the form for the selected mode.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/web/components/ClientOverviewPage.jsx`: reorganize render structure into route console subcomponents while preserving props and handlers.
|
||||||
|
- Modify `src/web/styles.css`: replace `.client-*` layout styles from `.client-mode .app-main` through the final client media query.
|
||||||
|
- Test `test/web/client-route.test.js`: extend route state coverage so UI changes do not hide incorrect mode/status combinations.
|
||||||
|
- Keep `docs/design/open-design/vpn-proxy-route-console-redesign.html`: reference artifact for visual decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock Route Contract Before UI Changes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `test/web/client-route.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add tests for all user-visible route statuses**
|
||||||
|
|
||||||
|
Add these cases to `test/web/client-route.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
test('resolves running local VPN route', () => {
|
||||||
|
const route = resolveClientRoute({
|
||||||
|
state: {
|
||||||
|
singboxRunning: true,
|
||||||
|
configExists: true,
|
||||||
|
proxyPort: 8082,
|
||||||
|
selectedTag: 'finland-02',
|
||||||
|
clientSettings: { homeBypassEnabled: false, sharedProxyEnabled: false },
|
||||||
|
},
|
||||||
|
activeServer: { tag: 'finland-02' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(route.mode, 'vpn');
|
||||||
|
assert.equal(route.status, 'connected');
|
||||||
|
assert.equal(route.localProxy, '127.0.0.1:8082');
|
||||||
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'VPN finland-02', 'Internet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves gateway route when shared proxy is enabled', () => {
|
||||||
|
const route = resolveClientRoute({
|
||||||
|
state: {
|
||||||
|
singboxRunning: true,
|
||||||
|
configExists: true,
|
||||||
|
proxyPort: 8082,
|
||||||
|
clientSettings: {
|
||||||
|
sharedProxyEnabled: true,
|
||||||
|
sharedProxy: { host: '192.168.50.111', port: 8080 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(route.mode, 'gateway');
|
||||||
|
assert.equal(route.status, 'connected');
|
||||||
|
assert.equal(route.target, '192.168.50.111:8080');
|
||||||
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8082', 'Gateway 192.168.50.111:8080', 'Internet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves direct route when home bypass is enabled', () => {
|
||||||
|
const route = resolveClientRoute({
|
||||||
|
state: {
|
||||||
|
singboxRunning: true,
|
||||||
|
configExists: true,
|
||||||
|
clientSettings: { homeBypassEnabled: true, sharedProxyEnabled: false, proxyPort: 8084 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(route.mode, 'direct');
|
||||||
|
assert.equal(route.status, 'connected');
|
||||||
|
assert.equal(route.localProxy, '127.0.0.1:8084');
|
||||||
|
assert.deepEqual(route.path, ['Mac apps', '127.0.0.1:8084', 'Direct', 'Internet']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the route tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- test/web/client-route.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all existing and new route tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add test/web/client-route.test.js
|
||||||
|
git commit -m "test: lock client route display contract"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Restructure Client Overview Markup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/components/ClientOverviewPage.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the route line with route nodes**
|
||||||
|
|
||||||
|
Replace `RouteLine` with:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function RouteStrip({ route }) {
|
||||||
|
const nodes = [
|
||||||
|
{ label: 'Источник', value: route.path[0], detail: 'приложения Mac' },
|
||||||
|
{ label: 'Локальный proxy', value: route.localProxy, detail: 'HTTP и SOCKS5' },
|
||||||
|
{ label: 'Режим', value: route.target, detail: route.targetDetail, active: route.status === 'connected' },
|
||||||
|
{ label: 'Выход', value: 'Internet', detail: route.status === 'connected' ? 'маршрут активен' : 'ожидает запуска' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="client-route-strip">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<div className={`client-route-node ${node.active ? 'active' : ''}`} key={node.label}>
|
||||||
|
<small>{node.label}</small>
|
||||||
|
<strong>{node.value}</strong>
|
||||||
|
<span>{node.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutePath({ route }) {
|
||||||
|
return (
|
||||||
|
<div className="client-route-path">
|
||||||
|
{route.path.map((item, index) => (
|
||||||
|
<React.Fragment key={`${item}-${index}`}>
|
||||||
|
<strong>{item}</strong>
|
||||||
|
{index < route.path.length - 1 && <span>{'>'}</span>}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a mode rail component**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function ModeRail({ route, setupMode, clientSettings, state, busy, onGateway, onVpn, onDirect }) {
|
||||||
|
const modes = [
|
||||||
|
{
|
||||||
|
id: 'gateway',
|
||||||
|
title: 'Общий gateway',
|
||||||
|
subtitle: clientSettings?.sharedProxy
|
||||||
|
? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}`
|
||||||
|
: 'серверная proxy',
|
||||||
|
onClick: onGateway,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vpn',
|
||||||
|
title: 'Локальный VPN',
|
||||||
|
subtitle: state?.selectedTag || 'выбрать сервер',
|
||||||
|
onClick: onVpn,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'direct',
|
||||||
|
title: 'Напрямую',
|
||||||
|
subtitle: 'без VPN',
|
||||||
|
onClick: onDirect,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="client-mode-rail">
|
||||||
|
<div className="client-section-label">Режим подключения</div>
|
||||||
|
<div className="client-mode-list">
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
className={`client-rail-mode ${setupMode === mode.id ? 'selected' : ''} ${route.mode === mode.id ? 'active' : ''}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={mode.onClick}
|
||||||
|
>
|
||||||
|
<span className="client-mode-dot" />
|
||||||
|
<span>
|
||||||
|
<strong>{mode.title}</strong>
|
||||||
|
<small>{mode.subtitle}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the top-level JSX**
|
||||||
|
|
||||||
|
Use this layout in `ClientOverviewPage`:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
return (
|
||||||
|
<div className="client-console">
|
||||||
|
<ModeRail
|
||||||
|
route={route}
|
||||||
|
setupMode={setupMode}
|
||||||
|
clientSettings={clientSettings}
|
||||||
|
state={state}
|
||||||
|
busy={busy}
|
||||||
|
onGateway={selectGateway}
|
||||||
|
onVpn={selectVpn}
|
||||||
|
onDirect={() => {
|
||||||
|
setSetupMode('direct');
|
||||||
|
enableDirect();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="client-route-workspace">
|
||||||
|
<StatusPanel route={route} state={state} />
|
||||||
|
<RouteStrip route={route} />
|
||||||
|
<RoutePath route={route} />
|
||||||
|
|
||||||
|
<section className="client-mode-panel">
|
||||||
|
{setupMode === 'gateway' && (
|
||||||
|
<GatewaySettings settings={clientSettings} busy={busy} onCheck={onCheckSharedProxy} />
|
||||||
|
)}
|
||||||
|
{setupMode === 'vpn' && (
|
||||||
|
<VpnSettings
|
||||||
|
state={state}
|
||||||
|
servers={servers}
|
||||||
|
subscriptionUrl={subscriptionUrl}
|
||||||
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
|
pendingTag={pendingTag}
|
||||||
|
setPendingTag={setPendingTag}
|
||||||
|
busy={busy}
|
||||||
|
onFetchSubscription={onFetchSubscription}
|
||||||
|
onApply={onApply}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{setupMode === 'direct' && <DirectSettings busy={busy} onEnable={enableDirect} />}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ProxySettings state={state} settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Vite build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/components/ClientOverviewPage.jsx
|
||||||
|
git commit -m "refactor: reshape client overview around route console"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Replace Client Visual System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/web/styles.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace only the client CSS block**
|
||||||
|
|
||||||
|
Replace the CSS from `.client-mode .app-main` through the client media query with the style direction from `docs/design/open-design/vpn-proxy-route-console-redesign.html`. Keep selectors scoped to `.client-*` so gateway screens keep the existing palette.
|
||||||
|
|
||||||
|
Use these token values for the client block:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.app-body.client-mode {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
background: oklch(0.965 0.008 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-mode .topbar {
|
||||||
|
background: oklch(0.978 0.007 232);
|
||||||
|
border-bottom-color: oklch(0.835 0.018 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-mode .app-main {
|
||||||
|
max-width: 1320px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 18px;
|
||||||
|
color: oklch(0.238 0.028 238);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-console {
|
||||||
|
min-height: calc(100vh - var(--topbar-h) - 36px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 264px minmax(0, 1fr) 312px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(0.986 0.006 232);
|
||||||
|
border: 1px solid oklch(0.835 0.018 232);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 18px 42px oklch(0.36 0.035 238 / 0.13);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add responsive behavior**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.client-console {
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-side-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid oklch(0.835 0.018 232);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-strip {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.client-console,
|
||||||
|
.client-route-strip,
|
||||||
|
.client-inline-form,
|
||||||
|
.client-port-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-mode-rail {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid oklch(0.835 0.018 232);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no banned patterns were introduced**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "background-clip:\\s*text|border-left:\\s*[2-9]|border-right:\\s*[2-9]|backdrop-filter|letter-spacing:\\s*-" src/web/styles.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Vite build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/styles.css
|
||||||
|
git commit -m "style: apply light route console client theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Browser Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No file changes expected.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the dev server**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- --host 127.0.0.1 --port 4567
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Vite listens on `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open client mode with representative state**
|
||||||
|
|
||||||
|
Use the browser to open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:4567
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the first viewport shows the mode rail, route strip, route path, selected-mode form, and copyable proxy addresses without overlap at desktop width.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check mobile width**
|
||||||
|
|
||||||
|
Resize to 390px wide.
|
||||||
|
|
||||||
|
Expected: rail, route workspace, and proxy panel stack vertically; long proxy URLs truncate inside their containers; action buttons remain readable.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run final verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/web/components/ClientOverviewPage.jsx src/web/styles.css test/web/client-route.test.js
|
||||||
|
git commit -m "feat: redesign client overview as route console"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
Spec coverage:
|
||||||
|
|
||||||
|
- Current UX assessment is captured in `Current Findings`.
|
||||||
|
- New design direction is captured in `Target Design`.
|
||||||
|
- Open Design artifact is referenced explicitly.
|
||||||
|
- Implementation tasks cover route contract, markup, scoped CSS, and browser verification.
|
||||||
|
|
||||||
|
Placeholder scan:
|
||||||
|
|
||||||
|
- No `TBD`, `TODO`, or unspecified validation steps remain.
|
||||||
|
|
||||||
|
Type consistency:
|
||||||
|
|
||||||
|
- Route fields match `resolveClientRoute()`: `mode`, `status`, `localProxy`, `target`, `targetDetail`, `path`.
|
||||||
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
48
docs/superpowers/specs/2026-05-19-macos-client-design.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# macOS Docker Client Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a simple macOS-friendly Docker client that behaves like the previous local proxy product: the user runs one container, opens a web UI, loads a subscription, chooses a server, and points macOS apps at `127.0.0.1:8080`.
|
||||||
|
|
||||||
|
## Product Shape
|
||||||
|
|
||||||
|
The client is not a transparent gateway. It must not require router changes, host networking, `NET_ADMIN`, iptables, ipset, or TProxy. The first-screen UI should explain the current proxy state, active server, and exact local proxy addresses. Gateway-only controls remain available only when the app runs in gateway mode.
|
||||||
|
|
||||||
|
## Runtime Architecture
|
||||||
|
|
||||||
|
`APP_MODE=client` switches the config generator to proxy-only sing-box config:
|
||||||
|
|
||||||
|
- one `mixed` inbound on `PROXY_PORT`;
|
||||||
|
- no `tproxy` inbound;
|
||||||
|
- custom routing rules still apply before fallback;
|
||||||
|
- `proxyDefaultMode` controls the mixed proxy fallback and defaults to VPN;
|
||||||
|
- generated configs still pass `sing-box check` before restart.
|
||||||
|
|
||||||
|
The client Docker image builds the React frontend inside Docker so macOS installation does not require local Node.js. Docker publishes only loopback ports:
|
||||||
|
|
||||||
|
- `127.0.0.1:3456` for the UI;
|
||||||
|
- `127.0.0.1:8080` for HTTP/SOCKS proxy.
|
||||||
|
|
||||||
|
## Installer
|
||||||
|
|
||||||
|
The macOS installer is a curl-friendly shell script. It checks macOS, Docker, Docker Compose, and Git, clones or updates the repository under `~/.vpn-proxy-client`, then runs the client compose file with `--build`. It prints the UI URL, proxy URLs, and optional `networksetup` commands, but does not change system proxy settings automatically.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Client mode gets a user-facing overview based on the old workflow:
|
||||||
|
|
||||||
|
- status: ready, stopped, not configured, applying, error;
|
||||||
|
- active server and traffic quota;
|
||||||
|
- copyable HTTP and SOCKS5 proxy URLs;
|
||||||
|
- short macOS setup commands;
|
||||||
|
- primary actions: load subscription, choose server, restart, stop.
|
||||||
|
|
||||||
|
Gateway terminology such as TProxy, devices, router, transparent fallback, and direct bypass cache is hidden in client mode.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use `node:test` for server config behavior, then run:
|
||||||
|
|
||||||
|
- `npm test`;
|
||||||
|
- `npm run build`;
|
||||||
|
- `docker compose -f docker-compose.client.yml config`.
|
||||||
236
docs/superpowers/specs/2026-05-21-windows-client-design.md
Normal file
236
docs/superpowers/specs/2026-05-21-windows-client-design.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Windows Client Design
|
||||||
|
|
||||||
|
> Superseded: this document describes the earlier Node/web-control Windows direction.
|
||||||
|
> The active Windows direction is a standalone Tauri 2 desktop app under
|
||||||
|
> `apps/windows-client`, driven by `docs/windows-client-product-tech-brief.md`
|
||||||
|
> and `docs/goals/windows-modular-client/PLAN.md`.
|
||||||
|
> Content below is retained for historical context and may contradict the active
|
||||||
|
> Tauri plan.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restore the old Windows workflow in a cleaner product shape: a one-command PowerShell installer can install either a full local `sing-box` + ProxiFyre setup or ProxiFyre-only routing to an existing proxy, then expose a small local web UI for profiles, folders, executable files, status, and logs.
|
||||||
|
|
||||||
|
## Product Shape
|
||||||
|
|
||||||
|
The Windows mode is script-first and UI-assisted. The installer remains the durable entrypoint because Windows driver/service setup needs administrator rights and must stay easy to debug from PowerShell. The web UI is a local control surface on top of the same scripts, not a separate desktop app in the first version.
|
||||||
|
|
||||||
|
The installer supports two paths:
|
||||||
|
|
||||||
|
- **Full install:** install native `sing-box.exe`, configure a local SOCKS/HTTP proxy on `127.0.0.1:1080`, install WinPacketFilter and ProxiFyre, then route selected Windows apps through the local proxy.
|
||||||
|
- **ProxiFyre only:** install WinPacketFilter and ProxiFyre, then point selected Windows apps to an existing proxy target such as `127.0.0.1:8080`, `192.168.50.111:8080`, or another reachable SOCKS5 endpoint.
|
||||||
|
|
||||||
|
The default UI direction is the approved cleaner mockup: one route status, profiles as the main object, selected profile details on the right, and a short recent activity/log section below. The first screen should answer: what is the active proxy target, whether services are running, and which profiles are currently enabled.
|
||||||
|
|
||||||
|
## User Flows
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
The user opens PowerShell 7 as Administrator and runs:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/install-windows-client.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer checks administrator rights, PowerShell version, architecture, internet access, and required paths. It installs under `C:\Tools\vpn-proxy-windows` and keeps third-party runtime files in focused subdirectories:
|
||||||
|
|
||||||
|
- `C:\Tools\vpn-proxy-windows\app` for this project checkout or archive.
|
||||||
|
- `C:\Tools\vpn-proxy-windows\runtime\node` for portable Node.js when no suitable Node is installed.
|
||||||
|
- `C:\Tools\vpn-proxy-windows\runtime\sing-box` for `sing-box.exe`, config, and logs.
|
||||||
|
- `C:\Tools\ProxiFyre` for ProxiFyre, matching the legacy script path.
|
||||||
|
|
||||||
|
If the user chooses Full install, the installer asks for a subscription or VLESS link, parses it through the existing subscription logic where possible, lets the user select a server, writes the native `sing-box` config, installs a scheduled task for `sing-box`, and starts it.
|
||||||
|
|
||||||
|
If the user chooses ProxiFyre only, the installer asks for a SOCKS5 proxy target and verifies TCP connectivity before writing ProxiFyre config.
|
||||||
|
|
||||||
|
After setup, the installer starts the local control UI on `http://127.0.0.1:3456` and prints recovery commands:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Tools\vpn-proxy-windows\manage.ps1"
|
||||||
|
& "C:\Tools\vpn-proxy-windows\manage.ps1" -OpenUi
|
||||||
|
& "C:\Tools\vpn-proxy-windows\manage.ps1" -Status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Management
|
||||||
|
|
||||||
|
Profiles are the central unit. A profile contains a name, enabled flag, proxy target, protocol list, and app items. Supported item types:
|
||||||
|
|
||||||
|
- `process`: process name such as `Discord`, `Update`, or `Vesktop`.
|
||||||
|
- `folder`: folder path; the system scans `.exe` files and converts them to routable entries.
|
||||||
|
- `exe`: explicit executable file path; the system resolves it to the executable name for ProxiFyre and keeps the full path for display and diagnostics.
|
||||||
|
|
||||||
|
Folder and exe entries are intentionally stored as user-facing source items, while the generated ProxiFyre config is derived. This keeps the UI understandable and makes future ProxiFyre/Proxifier adapter changes possible without changing the profile model.
|
||||||
|
|
||||||
|
When a profile changes, the UI marks it as pending. The user applies changes once. Apply regenerates ProxiFyre `app-config.json`, restarts the ProxiFyre service, then writes an activity entry showing what changed.
|
||||||
|
|
||||||
|
### Runtime Operations
|
||||||
|
|
||||||
|
The UI exposes these actions:
|
||||||
|
|
||||||
|
- start, stop, restart `sing-box` when local mode is installed;
|
||||||
|
- start, stop, restart ProxiFyre;
|
||||||
|
- switch a profile between `local-singbox` and an external proxy target;
|
||||||
|
- add process, folder, or exe entries;
|
||||||
|
- scan folder entries again;
|
||||||
|
- copy diagnostics for support/debugging;
|
||||||
|
- open logs.
|
||||||
|
|
||||||
|
The UI does not auto-change global Windows proxy settings. Routing happens only through ProxiFyre profiles.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The active project already has a plain Node API server, React/Vite UI, subscription parser, `sing-box` config generator, logs, traffic parsing, and client/gateway modes. Windows mode should reuse those pieces and add a Windows helper boundary.
|
||||||
|
|
||||||
|
### App Mode
|
||||||
|
|
||||||
|
Add `APP_MODE=windows`. In Windows mode:
|
||||||
|
|
||||||
|
- the HTTP server binds to `127.0.0.1`;
|
||||||
|
- the UI uses Windows labels and hides gateway-only TProxy/device controls;
|
||||||
|
- config generation is proxy-only like client mode, but it targets native `sing-box.exe` rather than Docker;
|
||||||
|
- service and driver actions go through the PowerShell helper, not direct Node assumptions.
|
||||||
|
|
||||||
|
### Windows Helper Boundary
|
||||||
|
|
||||||
|
Create a PowerShell helper module that owns privileged Windows operations:
|
||||||
|
|
||||||
|
- install/update `sing-box.exe`;
|
||||||
|
- install/start/stop scheduled tasks;
|
||||||
|
- install/update WinPacketFilter;
|
||||||
|
- install/update ProxiFyre;
|
||||||
|
- write ProxiFyre config;
|
||||||
|
- query service/task status;
|
||||||
|
- read recent log files;
|
||||||
|
- test proxy connectivity;
|
||||||
|
- manage firewall rules for local proxy ports.
|
||||||
|
|
||||||
|
The Node server calls the helper with explicit command names and JSON input/output. The helper returns structured JSON for every operation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"action": "proxies.apply",
|
||||||
|
"changed": true,
|
||||||
|
"message": "ProxiFyre config applied and service restarted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors use the same shape with `success: false`, `error`, and optional `details`. The UI never parses raw PowerShell text.
|
||||||
|
|
||||||
|
### Data Files
|
||||||
|
|
||||||
|
Windows mode stores state under `C:\Tools\vpn-proxy-windows\data`:
|
||||||
|
|
||||||
|
- `windows-profiles.json` for profile source data.
|
||||||
|
- `proxy-targets.json` for `local-singbox` and external proxy targets.
|
||||||
|
- `windows-state.json` for last applied profile revision and UI status.
|
||||||
|
- `subscription-cache.json` and `state.json` stay compatible with existing subscription/server selection logic.
|
||||||
|
|
||||||
|
Profile shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "discord-vesktop",
|
||||||
|
"name": "Discord + Vesktop",
|
||||||
|
"enabled": true,
|
||||||
|
"proxyTargetId": "local-singbox",
|
||||||
|
"protocols": ["TCP", "UDP"],
|
||||||
|
"items": [
|
||||||
|
{ "type": "process", "value": "Discord" },
|
||||||
|
{ "type": "process", "value": "Update" },
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "%LOCALAPPDATA%\\vesktop",
|
||||||
|
"recursive": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "exe",
|
||||||
|
"value": "C:\\Games\\SomeGame\\game.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated ProxiFyre config is not edited directly. It is derived from enabled profiles and proxy targets, then written to `C:\Tools\ProxiFyre\app-config.json`.
|
||||||
|
|
||||||
|
### API Surface
|
||||||
|
|
||||||
|
Add Windows-specific endpoints:
|
||||||
|
|
||||||
|
- `GET /api/windows/status`: returns install mode, `sing-box` status, ProxiFyre status, active target, pending changes, and recent activity.
|
||||||
|
- `GET /api/windows/profiles`: returns profile source data with resolved executable counts.
|
||||||
|
- `PUT /api/windows/profiles`: saves profiles without applying.
|
||||||
|
- `POST /api/windows/profiles/apply`: generates ProxiFyre config and restarts service.
|
||||||
|
- `POST /api/windows/profiles/scan`: resolves folder and exe entries for preview.
|
||||||
|
- `GET /api/windows/targets`: returns `local-singbox` and external proxy targets.
|
||||||
|
- `PUT /api/windows/targets`: saves external proxy targets after validation.
|
||||||
|
- `POST /api/windows/service`: start, stop, or restart `sing-box`, ProxiFyre, or the UI service.
|
||||||
|
- `GET /api/windows/logs`: returns recent helper, `sing-box`, and ProxiFyre logs.
|
||||||
|
|
||||||
|
Existing generic endpoints for subscription fetch, server selection, apply, logs, and config validation should be reused where the behavior matches Windows mode.
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
The approved direction is a restrained operational UI:
|
||||||
|
|
||||||
|
- top bar: product name, restart/stop/add profile actions;
|
||||||
|
- left nav: Overview, Profiles, Targets, Logs, Settings;
|
||||||
|
- main status panel: one sentence describing the active route, plus a compact route line such as `Selected apps -> ProxiFyre -> sing-box -> VPN`;
|
||||||
|
- main workspace: profile list on the left, selected profile details on the right;
|
||||||
|
- profile details: target selector, add process/folder/exe input, resolved items list, save/apply controls;
|
||||||
|
- activity panel: recent traffic/service events, not a full noisy log dump.
|
||||||
|
|
||||||
|
Avoid duplicate status blocks. Avoid showing raw implementation concepts like WinPacketFilter unless the user is in diagnostics/settings. The primary terms should be `Profile`, `Proxy target`, `Local sing-box`, `Existing proxy`, `App/folder/exe`, and `Apply changes`.
|
||||||
|
|
||||||
|
## Safety And Constraints
|
||||||
|
|
||||||
|
The UI binds only to `127.0.0.1`. Windows actions that require elevation stay in the installer/helper path. The installer must not delete existing `C:\Tools\vpn-proxy` legacy folders without confirmation.
|
||||||
|
|
||||||
|
Folder and exe routing needs a clear diagnostic note: ProxiFyre routing ultimately depends on what the installed ProxiFyre version accepts. The first implementation should resolve folders and exe paths to executable names for compatibility, while preserving full paths in profile data and diagnostics. If direct path matching is verified in ProxiFyre, the adapter can emit full paths without changing the UI model.
|
||||||
|
|
||||||
|
The installer should be idempotent:
|
||||||
|
|
||||||
|
- re-running it updates project files;
|
||||||
|
- existing subscriptions and profiles are preserved unless the user chooses reset;
|
||||||
|
- existing ProxiFyre config is backed up before overwrite;
|
||||||
|
- failed applies leave the previous generated config available for rollback.
|
||||||
|
|
||||||
|
## Testing And Verification
|
||||||
|
|
||||||
|
Use focused tests for pure logic:
|
||||||
|
|
||||||
|
- profile normalization;
|
||||||
|
- folder/exe item resolution;
|
||||||
|
- ProxiFyre config generation;
|
||||||
|
- proxy target validation;
|
||||||
|
- Windows helper JSON command contract;
|
||||||
|
- `APP_MODE=windows` public state and config generation.
|
||||||
|
|
||||||
|
Use manual Windows verification for privileged operations:
|
||||||
|
|
||||||
|
- fresh Full install;
|
||||||
|
- fresh ProxiFyre-only install;
|
||||||
|
- re-run installer over existing install;
|
||||||
|
- add process profile;
|
||||||
|
- add folder profile;
|
||||||
|
- add explicit exe profile;
|
||||||
|
- switch a profile from local sing-box to external proxy;
|
||||||
|
- restart ProxiFyre and verify service status;
|
||||||
|
- copy diagnostics after a failed proxy target check.
|
||||||
|
|
||||||
|
Local non-Windows development should still run `npm test` and `npm run build`. Windows-only helper commands should have dry-run or mockable modes so logic can be tested without installing drivers on the development machine.
|
||||||
|
|
||||||
|
## Non-Goals For First Version
|
||||||
|
|
||||||
|
- No Electron or Tauri wrapper.
|
||||||
|
- No global Windows system proxy changes.
|
||||||
|
- No transparent routing without ProxiFyre.
|
||||||
|
- No remote multi-device management.
|
||||||
|
- No automatic uninstall of unrelated WinPacketFilter users.
|
||||||
|
- No Proxifier support until ProxiFyre behavior is stable.
|
||||||
|
|
||||||
|
## Implementation Defaults
|
||||||
|
|
||||||
|
- The UI server runs when opened by `manage.ps1 -OpenUi` in the first version. An at-logon scheduled UI task can be added later after the helper and UI are stable.
|
||||||
|
- Full install uses portable Node/npm when the machine has no suitable Node.js. The installer builds the React UI locally for MVP; a prebuilt release artifact can replace that later without changing user-facing behavior.
|
||||||
|
- ProxiFyre generation emits process names in the first version for compatibility. Full folder and exe paths remain in profile data and diagnostics; the adapter can start emitting full paths later if ProxiFyre path matching is verified.
|
||||||
635
docs/windows-client-product-tech-brief.md
Normal file
635
docs/windows-client-product-tech-brief.md
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
# Windows Proxy Client: Product And Technology Brief
|
||||||
|
|
||||||
|
Дата: 2026-07-03
|
||||||
|
|
||||||
|
Цель документа: описать, как должно выглядеть и работать Windows-приложение для управления proxy/VPN-маршрутизацией приложений, и какой стек лучше использовать для реализации.
|
||||||
|
|
||||||
|
Этот документ можно отдать другой модели или команде как исходное ТЗ.
|
||||||
|
|
||||||
|
## Коротко
|
||||||
|
|
||||||
|
Нужно Windows-приложение, которое разделяет систему на три независимые части:
|
||||||
|
|
||||||
|
1. **Control App**: маленькое desktop-приложение для настройки, статуса, профилей, логов и запуска операций.
|
||||||
|
2. **Proxyfier Layer**: отдельный компонент, который заставляет выбранные Windows-приложения ходить через SOCKS5/HTTP proxy, даже если они сами не умеют proxy.
|
||||||
|
3. **Local sing-box**: опциональный локальный VPN/proxy runtime. Его можно установить, не устанавливать, остановить, заменить внешним proxy target.
|
||||||
|
|
||||||
|
Главный принцип: пользователь не обязан ставить все сразу. Если у него уже есть proxy, ему нужны только Control App + Proxyfier. Если нужен локальный VPN-клиент, он отдельно ставит `sing-box`.
|
||||||
|
|
||||||
|
## Как это должно выглядеть
|
||||||
|
|
||||||
|
Приложение должно выглядеть как компактная системная утилита, а не как сайт.
|
||||||
|
|
||||||
|
Главный экран:
|
||||||
|
|
||||||
|
- верхняя строка: общий статус маршрута;
|
||||||
|
- три карточки компонентов: `Control App`, `Proxyfier`, `Local sing-box`;
|
||||||
|
- список активных профилей;
|
||||||
|
- кнопка `Apply changes`;
|
||||||
|
- короткая лента последних событий.
|
||||||
|
|
||||||
|
Пример главного статуса:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Selected apps -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> VPN
|
||||||
|
```
|
||||||
|
|
||||||
|
или:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Selected apps -> ProxiFyre -> Existing proxy 192.168.50.111:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `sing-box` не установлен, это не ошибка. Карточка должна показывать:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Local sing-box
|
||||||
|
Not installed
|
||||||
|
Install if you want this PC to run its own local VPN proxy.
|
||||||
|
```
|
||||||
|
|
||||||
|
Если Proxyfier не установлен, профили можно редактировать, но apply должен быть заблокирован:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Proxyfier is required to route selected apps.
|
||||||
|
Install Proxyfier
|
||||||
|
```
|
||||||
|
|
||||||
|
## Основные экраны
|
||||||
|
|
||||||
|
### 1. Overview
|
||||||
|
|
||||||
|
Показывает:
|
||||||
|
|
||||||
|
- текущий route line;
|
||||||
|
- статус Control App;
|
||||||
|
- статус Proxyfier;
|
||||||
|
- статус Local sing-box;
|
||||||
|
- активный proxy target;
|
||||||
|
- сколько приложений сейчас включено в routing;
|
||||||
|
- последние 5-10 событий.
|
||||||
|
|
||||||
|
Действия:
|
||||||
|
|
||||||
|
- restart Proxyfier;
|
||||||
|
- restart local sing-box, если установлен;
|
||||||
|
- open logs;
|
||||||
|
- copy diagnostics.
|
||||||
|
|
||||||
|
### 2. Profiles
|
||||||
|
|
||||||
|
Профиль - главный объект настройки.
|
||||||
|
|
||||||
|
Профиль содержит:
|
||||||
|
|
||||||
|
- название;
|
||||||
|
- enabled/disabled;
|
||||||
|
- proxy target;
|
||||||
|
- протоколы: TCP, UDP;
|
||||||
|
- список приложений.
|
||||||
|
|
||||||
|
Типы элементов:
|
||||||
|
|
||||||
|
- `process`: имя процесса, например `Discord`, `Telegram`, `Code`;
|
||||||
|
- `folder`: папка, приложение сканирует `.exe` внутри;
|
||||||
|
- `exe`: конкретный путь к `.exe`.
|
||||||
|
|
||||||
|
UI профиля:
|
||||||
|
|
||||||
|
- слева список профилей;
|
||||||
|
- справа детали выбранного профиля;
|
||||||
|
- поле выбора target;
|
||||||
|
- кнопки добавления: `Process`, `Folder`, `EXE`;
|
||||||
|
- preview resolved apps;
|
||||||
|
- `Save`;
|
||||||
|
- `Apply changes`.
|
||||||
|
|
||||||
|
Важно: пользователь должен видеть понятные исходные элементы, а не только сгенерированный конфиг Proxyfier.
|
||||||
|
|
||||||
|
### 3. Targets
|
||||||
|
|
||||||
|
Proxy target - это куда Proxyfier отправляет трафик выбранных приложений.
|
||||||
|
|
||||||
|
Типы targets:
|
||||||
|
|
||||||
|
- `Local sing-box`: `127.0.0.1:1080`, доступен только если local sing-box установлен и запущен;
|
||||||
|
- `Existing SOCKS5 proxy`: например `127.0.0.1:8080` или `192.168.50.111:8080`;
|
||||||
|
- `Existing HTTP proxy`, если выбранный proxyfier поддерживает HTTP.
|
||||||
|
|
||||||
|
На экране targets:
|
||||||
|
|
||||||
|
- список targets;
|
||||||
|
- проверка соединения;
|
||||||
|
- имя, host, port, protocol;
|
||||||
|
- статус last checked;
|
||||||
|
- кнопка set default.
|
||||||
|
|
||||||
|
### 4. Components
|
||||||
|
|
||||||
|
Отдельный экран или часть Overview.
|
||||||
|
|
||||||
|
Компоненты:
|
||||||
|
|
||||||
|
- Control App;
|
||||||
|
- Proxyfier;
|
||||||
|
- Local sing-box.
|
||||||
|
|
||||||
|
Для каждого:
|
||||||
|
|
||||||
|
- installed / not installed;
|
||||||
|
- running / stopped;
|
||||||
|
- version;
|
||||||
|
- path;
|
||||||
|
- service/task status;
|
||||||
|
- actions.
|
||||||
|
|
||||||
|
Actions должны быть явными:
|
||||||
|
|
||||||
|
- `Install`;
|
||||||
|
- `Repair`;
|
||||||
|
- `Start`;
|
||||||
|
- `Stop`;
|
||||||
|
- `Restart`;
|
||||||
|
- `Open folder`;
|
||||||
|
- `View logs`.
|
||||||
|
|
||||||
|
Нельзя делать скрытую установку `sing-box` при сохранении профиля.
|
||||||
|
|
||||||
|
### 5. Logs / Diagnostics
|
||||||
|
|
||||||
|
Должно быть две зоны:
|
||||||
|
|
||||||
|
- activity: действия пользователя и результат apply;
|
||||||
|
- runtime logs: proxyfier logs, sing-box logs, helper logs.
|
||||||
|
|
||||||
|
Кнопка `Copy diagnostics` должна собирать:
|
||||||
|
|
||||||
|
- версии компонентов;
|
||||||
|
- paths;
|
||||||
|
- running status;
|
||||||
|
- активные profiles;
|
||||||
|
- targets без секретов;
|
||||||
|
- последние ошибки;
|
||||||
|
- путь к сгенерированному proxyfier config.
|
||||||
|
|
||||||
|
## Пользовательские сценарии
|
||||||
|
|
||||||
|
### Сценарий A: у пользователя уже есть proxy
|
||||||
|
|
||||||
|
1. Пользователь устанавливает Control App.
|
||||||
|
2. Открывает приложение.
|
||||||
|
3. Видит, что Proxyfier не установлен, а sing-box отсутствует.
|
||||||
|
4. Нажимает `Install Proxyfier`.
|
||||||
|
5. Добавляет target `192.168.50.111:8080`.
|
||||||
|
6. Создает профиль `Discord`.
|
||||||
|
7. Добавляет process `Discord`.
|
||||||
|
8. Нажимает `Apply changes`.
|
||||||
|
9. Приложение генерирует config для Proxyfier и перезапускает proxyfier service.
|
||||||
|
|
||||||
|
Результат: Discord ходит через внешний proxy. Local sing-box не нужен.
|
||||||
|
|
||||||
|
### Сценарий B: пользователь хочет локальный VPN proxy
|
||||||
|
|
||||||
|
1. Пользователь устанавливает Control App.
|
||||||
|
2. Устанавливает Proxyfier.
|
||||||
|
3. Устанавливает Local sing-box.
|
||||||
|
4. Вводит subscription/VLESS link.
|
||||||
|
5. Выбирает сервер.
|
||||||
|
6. Local sing-box поднимает SOCKS5/HTTP endpoint на `127.0.0.1:1080`.
|
||||||
|
7. Профили используют target `Local sing-box`.
|
||||||
|
|
||||||
|
Результат: выбранные приложения ходят через локальный sing-box.
|
||||||
|
|
||||||
|
### Сценарий C: временно отключить VPN
|
||||||
|
|
||||||
|
1. Пользователь открывает профиль.
|
||||||
|
2. Меняет target с `Local sing-box` на внешний proxy или `Direct/Disabled`.
|
||||||
|
3. Нажимает `Apply changes`.
|
||||||
|
|
||||||
|
Результат: Proxyfier перегенерирован, local sing-box можно остановить отдельно.
|
||||||
|
|
||||||
|
## Рекомендуемый стек
|
||||||
|
|
||||||
|
### Desktop shell: Tauri 2
|
||||||
|
|
||||||
|
Рекомендация: **Tauri 2 + React + TypeScript + Rust backend**.
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
|
||||||
|
- Tauri ориентирован на маленькие desktop-приложения и использует системный web renderer, поэтому приложение легче Electron.
|
||||||
|
- Можно писать UI на обычном web stack: React/TypeScript/Vite.
|
||||||
|
- Backend-часть на Rust хорошо подходит для Windows APIs, файлов, процессов, sidecar binaries и безопасных команд.
|
||||||
|
- Tauri поддерживает sidecar binaries, но требует явно выдать permissions на запуск sidecar, что полезно для security boundary.
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- React;
|
||||||
|
- TypeScript;
|
||||||
|
- Vite;
|
||||||
|
- TanStack Query для загрузки/кэша status/API;
|
||||||
|
- Zustand или Jotai для локального UI state;
|
||||||
|
- Zod для валидации JSON-моделей;
|
||||||
|
- CSS modules или Tailwind. Для этой утилиты лучше сдержанный Windows-like UI, без тяжелой дизайн-системы.
|
||||||
|
|
||||||
|
Backend внутри Tauri:
|
||||||
|
|
||||||
|
- Rust commands для простых операций;
|
||||||
|
- отдельный `core` crate с доменной логикой;
|
||||||
|
- отдельный `windows-helper` binary для elevated/privileged действий.
|
||||||
|
|
||||||
|
Не рекомендую начинать с Electron, если нет жесткой причины. Electron проще для web-команды, но тяжелее по размеру и памяти. Для маленькой системной утилиты Tauri подходит лучше.
|
||||||
|
|
||||||
|
### Privileged helper
|
||||||
|
|
||||||
|
Нужно отделить обычное приложение от операций администратора.
|
||||||
|
|
||||||
|
Рекомендуемая модель:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tauri UI
|
||||||
|
-> Rust app backend
|
||||||
|
-> unprivileged status/read operations
|
||||||
|
-> explicit elevated helper for install/repair/service operations
|
||||||
|
```
|
||||||
|
|
||||||
|
Privileged helper может быть:
|
||||||
|
|
||||||
|
- Rust CLI, который запускается elevated только для конкретной операции;
|
||||||
|
- Rust Windows service/helper, если нужен постоянный privileged agent;
|
||||||
|
- PowerShell scripts только как thin installer layer, не как основная бизнес-логика.
|
||||||
|
|
||||||
|
Для MVP можно сделать проще:
|
||||||
|
|
||||||
|
- installers запускаются отдельно от имени администратора;
|
||||||
|
- Control App работает обычным пользователем;
|
||||||
|
- service start/stop/restart идет через helper command;
|
||||||
|
- helper возвращает JSON, UI не парсит текст PowerShell.
|
||||||
|
|
||||||
|
Контракт helper:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"payload": {
|
||||||
|
"configPath": "C:\\Tools\\ProxiFyre\\app-config.json",
|
||||||
|
"config": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"changed": true,
|
||||||
|
"message": "Proxyfier config applied and service restarted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"action": "proxyfier.apply",
|
||||||
|
"error": "Proxyfier service is not installed",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service/runtime management
|
||||||
|
|
||||||
|
Для `sing-box` как background runtime:
|
||||||
|
|
||||||
|
- использовать `sing-box check` перед применением config;
|
||||||
|
- хранить config отдельно;
|
||||||
|
- запускать как Windows service или scheduled task;
|
||||||
|
- для service wrapper можно использовать WinSW, если не хочется писать собственный Windows service wrapper.
|
||||||
|
|
||||||
|
Практичный вариант:
|
||||||
|
|
||||||
|
- v1: WinSW wraps `sing-box.exe`;
|
||||||
|
- v2: собственный Rust service/helper, если понадобится полный контроль.
|
||||||
|
|
||||||
|
Control App не должен напрямую владеть процессом `sing-box`. Он должен управлять service/task через helper.
|
||||||
|
|
||||||
|
### Local sing-box
|
||||||
|
|
||||||
|
`sing-box` - опциональный runtime.
|
||||||
|
|
||||||
|
Его роль:
|
||||||
|
|
||||||
|
- принять subscription/VLESS/sing-box config;
|
||||||
|
- поднять локальный mixed SOCKS/HTTP inbound;
|
||||||
|
- слушать только `127.0.0.1`, например `127.0.0.1:1080`;
|
||||||
|
- маршрутизировать трафик через выбранный outbound.
|
||||||
|
|
||||||
|
Config генерируется из source state приложения и проверяется:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sing-box check -c C:\Tools\VpnProxy\sing-box\config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Local sing-box не должен быть обязательным. Если profile target указывает на внешний proxy, `sing-box` может отсутствовать.
|
||||||
|
|
||||||
|
### Proxyfier layer
|
||||||
|
|
||||||
|
Рекомендуемый стартовый backend: **ProxiFyre**.
|
||||||
|
|
||||||
|
Почему:
|
||||||
|
|
||||||
|
- open-source;
|
||||||
|
- Windows-focused;
|
||||||
|
- маршрутизирует TCP и UDP;
|
||||||
|
- работает per-application;
|
||||||
|
- использует `app-config.json`;
|
||||||
|
- может работать как Windows Service.
|
||||||
|
|
||||||
|
Важное ограничение: ProxiFyre лицензируется как AGPL-3.0. Если продукт должен быть закрытым коммерческим приложением, нужно заранее решить юридический вопрос или сделать adapter layer, чтобы можно было заменить engine на:
|
||||||
|
|
||||||
|
- коммерческий Proxifier;
|
||||||
|
- ProxyBridge;
|
||||||
|
- собственный WinDivert/NDIS/WFP-based engine;
|
||||||
|
- другой per-app proxy router.
|
||||||
|
|
||||||
|
Интерфейс должен называться не `ProxiFyreConfig`, а шире:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProxyRouterAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый adapter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ProxiFyreAdapter
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволит поменять engine без переделки UI и профилей.
|
||||||
|
|
||||||
|
### Data storage
|
||||||
|
|
||||||
|
Для MVP лучше использовать простые JSON-файлы с schema validation.
|
||||||
|
|
||||||
|
Причина:
|
||||||
|
|
||||||
|
- настройки легко читать и бэкапить;
|
||||||
|
- можно быстро отлаживать;
|
||||||
|
- config portable;
|
||||||
|
- подходит для profile/target/source state.
|
||||||
|
|
||||||
|
Рекомендуемые файлы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\VpnProxy\config\profiles.json
|
||||||
|
C:\ProgramData\VpnProxy\config\targets.json
|
||||||
|
C:\ProgramData\VpnProxy\config\components.json
|
||||||
|
C:\ProgramData\VpnProxy\state\activity.json
|
||||||
|
C:\ProgramData\VpnProxy\state\last-status.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json
|
||||||
|
C:\ProgramData\VpnProxy\generated\sing-box-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Если нужна большая история событий, статистика трафика или сложные миграции, тогда добавить SQLite:
|
||||||
|
|
||||||
|
- `rusqlite` или `sqlx` в Rust;
|
||||||
|
- миграции;
|
||||||
|
- таблицы `activity`, `component_status`, `traffic_events`.
|
||||||
|
|
||||||
|
Но source of truth для профилей можно оставить JSON даже при наличии SQLite.
|
||||||
|
|
||||||
|
### Installer strategy
|
||||||
|
|
||||||
|
Нужны три явных installer entrypoints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install Control App
|
||||||
|
Install Proxyfier Layer
|
||||||
|
Install Local sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Они могут быть кнопками в UI, но каждая операция должна быть отдельной и понятной.
|
||||||
|
|
||||||
|
CLI/script names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install-control-app.ps1
|
||||||
|
install-proxyfier.ps1
|
||||||
|
install-singbox.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Или в packaged app:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VpnProxySetup.exe /component control-app
|
||||||
|
VpnProxySetup.exe /component proxyfier
|
||||||
|
VpnProxySetup.exe /component sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый installer:
|
||||||
|
|
||||||
|
- idempotent;
|
||||||
|
- делает backup перед overwrite;
|
||||||
|
- не удаляет чужие файлы без подтверждения;
|
||||||
|
- проверяет admin rights;
|
||||||
|
- пишет machine-readable install result;
|
||||||
|
- не трогает остальные компоненты без явного выбора.
|
||||||
|
|
||||||
|
### Security model
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- UI работает без admin rights.
|
||||||
|
- Admin elevation только для install/repair/service/config apply, если это реально нужно.
|
||||||
|
- Local API, если будет, слушает только `127.0.0.1`.
|
||||||
|
- Лучше использовать Tauri commands / named pipe, чем открытый HTTP port.
|
||||||
|
- Если нужен loopback HTTP, включить token или origin check.
|
||||||
|
- Секреты subscription URLs не показывать в diagnostics.
|
||||||
|
- Generated configs не редактируются вручную из UI.
|
||||||
|
- Every apply creates backup.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```text
|
||||||
|
+-------------------------------+
|
||||||
|
| Tauri Control App |
|
||||||
|
| React/TypeScript UI |
|
||||||
|
+---------------+---------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+-------------------------------+
|
||||||
|
| Rust App Backend |
|
||||||
|
| profiles, targets, validation |
|
||||||
|
| component status aggregation |
|
||||||
|
+-------+---------------+-------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
| Proxy Router | | Local sing-box |
|
||||||
|
| Adapter | | Adapter |
|
||||||
|
| ProxiFyre v1 | | config + service |
|
||||||
|
+-------+-------+ +---------+---------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
| ProxiFyre | | sing-box.exe |
|
||||||
|
| Windows svc | | Windows svc/task |
|
||||||
|
+---------------+ +-------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модель данных
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"name": "Discord",
|
||||||
|
"enabled": true,
|
||||||
|
"targetId": "local-singbox",
|
||||||
|
"protocols": ["TCP", "UDP"],
|
||||||
|
"items": [
|
||||||
|
{ "type": "process", "value": "Discord" },
|
||||||
|
{ "type": "folder", "value": "%LOCALAPPDATA%\\Discord", "recursive": true },
|
||||||
|
{ "type": "exe", "value": "C:\\Games\\Game\\game.exe" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "local-singbox",
|
||||||
|
"name": "Local sing-box",
|
||||||
|
"type": "local",
|
||||||
|
"protocol": "socks5",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 1080,
|
||||||
|
"requiresComponent": "singbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
External target:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "home-gateway",
|
||||||
|
"name": "Home gateway",
|
||||||
|
"type": "external",
|
||||||
|
"protocol": "socks5",
|
||||||
|
"host": "192.168.50.111",
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component status
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "proxyfier",
|
||||||
|
"name": "Proxyfier",
|
||||||
|
"installed": true,
|
||||||
|
"running": true,
|
||||||
|
"version": "2.3.0",
|
||||||
|
"path": "C:\\Tools\\ProxiFyre",
|
||||||
|
"serviceName": "ProxiFyreService",
|
||||||
|
"problems": [],
|
||||||
|
"actions": ["restart", "repair", "openLogs"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply behavior
|
||||||
|
|
||||||
|
Apply должен делать одно понятное действие:
|
||||||
|
|
||||||
|
1. Прочитать profiles.
|
||||||
|
2. Прочитать targets.
|
||||||
|
3. Проверить, что выбранные targets доступны.
|
||||||
|
4. Проверить, что Proxyfier установлен.
|
||||||
|
5. Разрешить folder/exe в process names.
|
||||||
|
6. Сгенерировать proxyfier config.
|
||||||
|
7. Сделать backup старого config.
|
||||||
|
8. Записать новый config.
|
||||||
|
9. Перезапустить Proxyfier service.
|
||||||
|
10. Записать activity entry.
|
||||||
|
|
||||||
|
Если profile использует `local-singbox`, дополнительно:
|
||||||
|
|
||||||
|
- проверить, что `sing-box` установлен;
|
||||||
|
- проверить, что service running;
|
||||||
|
- проверить, что `127.0.0.1:1080` отвечает.
|
||||||
|
|
||||||
|
Если `local-singbox` не установлен, но profile target внешний, apply должен работать.
|
||||||
|
|
||||||
|
## Что не делать
|
||||||
|
|
||||||
|
- Не делать глобальную смену Windows proxy settings.
|
||||||
|
- Не делать `sing-box` обязательным.
|
||||||
|
- Не смешивать installer и profile apply.
|
||||||
|
- Не хранить generated ProxiFyre config как source of truth.
|
||||||
|
- Не привязывать UI напрямую к ProxiFyre, нужен adapter layer.
|
||||||
|
- Не запускать privileged операции без явного согласия пользователя.
|
||||||
|
- Не делать большой dashboard с лишней статистикой в первой версии.
|
||||||
|
|
||||||
|
## MVP
|
||||||
|
|
||||||
|
Самый правильный первый slice:
|
||||||
|
|
||||||
|
1. Tauri app shell.
|
||||||
|
2. Profiles UI.
|
||||||
|
3. Targets UI.
|
||||||
|
4. Component status UI.
|
||||||
|
5. ProxiFyre adapter.
|
||||||
|
6. External SOCKS5 target.
|
||||||
|
7. Apply profile -> generate ProxiFyre config -> restart service.
|
||||||
|
|
||||||
|
В MVP `sing-box` может быть только карточкой `Not installed / Install`.
|
||||||
|
|
||||||
|
После этого добавить:
|
||||||
|
|
||||||
|
1. Local sing-box installer.
|
||||||
|
2. Subscription import.
|
||||||
|
3. Server selection.
|
||||||
|
4. Generate sing-box config.
|
||||||
|
5. Start/stop/restart local sing-box service.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Приложение считается успешным, если:
|
||||||
|
|
||||||
|
- можно установить только Control App;
|
||||||
|
- можно установить Proxyfier отдельно;
|
||||||
|
- можно не устанавливать sing-box;
|
||||||
|
- можно добавить внешний SOCKS5 target;
|
||||||
|
- можно создать профиль для Discord;
|
||||||
|
- можно применить профиль;
|
||||||
|
- generated ProxiFyre config не редактируется пользователем вручную;
|
||||||
|
- UI показывает, что local sing-box отсутствует, но это не ломает внешний proxy flow;
|
||||||
|
- после установки sing-box появляется target `Local sing-box`;
|
||||||
|
- пользователь может переключить профиль с внешнего target на local sing-box.
|
||||||
|
|
||||||
|
## Prompt For Another AI
|
||||||
|
|
||||||
|
Build a Windows desktop proxy management app.
|
||||||
|
|
||||||
|
Use Tauri 2 with React, TypeScript, Vite, and a Rust backend. The app must manage three independent components: the Control App, a proxyfier layer, and optional local sing-box. Do not make sing-box mandatory.
|
||||||
|
|
||||||
|
The UI must be a compact Windows utility with these screens: Overview, Profiles, Targets, Components, Logs. Profiles contain process/folder/exe entries and choose a proxy target. Targets can be local sing-box or external SOCKS5/HTTP proxies. Proxyfier is the layer that routes selected apps through the chosen target.
|
||||||
|
|
||||||
|
Start with ProxiFyre as the first proxy router adapter, but design an adapter boundary so it can later be replaced. Store source configuration as JSON with schema validation. Generated ProxiFyre and sing-box configs are derived artifacts, not source truth.
|
||||||
|
|
||||||
|
Privileged operations must be isolated in an explicit helper/installer flow. The main UI should run without admin rights. Install Control App, Install Proxyfier, and Install Local sing-box must be separate operations. Applying a profile must not silently install missing components.
|
||||||
|
|
||||||
|
MVP: external SOCKS5 target + ProxiFyre profile apply. Then add optional local sing-box installation, subscription import, server selection, and local sing-box service control.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Tauri 2: https://v2.tauri.app/
|
||||||
|
- Tauri sidecar permissions: https://v2.tauri.app/develop/sidecar/
|
||||||
|
- sing-box configuration: https://sing-box.sagernet.org/configuration/
|
||||||
|
- ProxiFyre repository: https://github.com/wiresock/proxifyre
|
||||||
|
- WinSW service wrapper: https://github.com/winsw/winsw
|
||||||
|
- Microsoft Windows Service with Worker Service: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service
|
||||||
|
|
||||||
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:-8080}"
|
||||||
|
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
|
||||||
165
entrypoint.sh
Normal file
165
entrypoint.sh
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/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}"
|
||||||
|
TPROXY_SOURCE_BYPASS_CHAIN="${TPROXY_SOURCE_BYPASS_CHAIN:-VPN_PROXY_SRC_BYPASS}"
|
||||||
|
TPROXY_SOURCE_FORWARD_CHAIN="${TPROXY_SOURCE_FORWARD_CHAIN:-VPN_PROXY_FWD_BYPASS}"
|
||||||
|
TPROXY_SOURCE_NAT_CHAIN="${TPROXY_SOURCE_NAT_CHAIN:-VPN_PROXY_NAT_BYPASS}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
|
TPROXY_BYPASS_SOURCE_CIDRS="${TPROXY_BYPASS_SOURCE_CIDRS:-}"
|
||||||
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
||||||
|
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
||||||
|
# TTL записи в ipset (секунды). По умолчанию 1 час.
|
||||||
|
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
|
||||||
|
# Direct bypass cache выключен по умолчанию, потому что он обходит global rules.
|
||||||
|
DIRECT_BYPASS_CACHE="${DIRECT_BYPASS_CACHE:-false}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
log "cleanup tproxy rules"
|
||||||
|
ipt -t mangle -D PREROUTING -j "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -D FORWARD -j "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -D POSTROUTING -j "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -F "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "$TPROXY_SOURCE_BYPASS_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$TPROXY_SOURCE_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -F "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -X "$TPROXY_SOURCE_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
# ipset не чистим при завершении — TTL сам истечёт
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_ip_forwarding() {
|
||||||
|
log "enable IPv4 forwarding for source bypass"
|
||||||
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v sysctl >/dev/null 2>&1; then
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_direct_bypass_set() {
|
||||||
|
if [[ "$DIRECT_BYPASS_CACHE" != "true" ]]; then
|
||||||
|
export DIRECT_BYPASS_CACHE
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
|
||||||
|
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
|
||||||
|
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
|
||||||
|
# Экспортируем имя для использования в Node.js через env
|
||||||
|
export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL DIRECT_BYPASS_CACHE
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_proxy_firewall() {
|
||||||
|
if [[ "$PROXY_FIREWALL" != "true" || "$PROXY_BIND_IP" == "127.0.0.1" || "$PROXY_BIND_IP" == "::1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "setup proxy firewall for :${PROXY_PORT} (${PROXY_ALLOWED_CIDRS})"
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
|
||||||
|
ipt -N "$PROXY_INPUT_CHAIN"
|
||||||
|
for cidr in $PROXY_ALLOWED_CIDRS; do
|
||||||
|
ipt -A "$PROXY_INPUT_CHAIN" -s "$cidr" -j RETURN
|
||||||
|
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}, mark ${TPROXY_MARK}, table ${TPROXY_TABLE}"
|
||||||
|
cleanup_tproxy
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
ip route replace local 0.0.0.0/0 dev lo table "$TPROXY_TABLE"
|
||||||
|
|
||||||
|
ipt -t mangle -N "$TPROXY_CHAIN"
|
||||||
|
ipt -t mangle -N "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
|
ipt -N "$TPROXY_SOURCE_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -N "$TPROXY_SOURCE_NAT_CHAIN"
|
||||||
|
# Пропускаем пакеты, адресованные самому хосту (ответы на исходящие соединения sing-box)
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$TPROXY_SOURCE_BYPASS_CHAIN"
|
||||||
|
ipt -I FORWARD 1 -j "$TPROXY_SOURCE_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -I POSTROUTING 1 -j "$TPROXY_SOURCE_NAT_CHAIN"
|
||||||
|
|
||||||
|
for cidr in $BYPASS_CIDRS; do
|
||||||
|
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -d "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
|
||||||
|
for cidr in $TPROXY_BYPASS_SOURCE_CIDRS; do
|
||||||
|
ipt -t mangle -A "$TPROXY_SOURCE_BYPASS_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$TPROXY_SOURCE_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
ipt -t nat -A "$TPROXY_SOURCE_NAT_CHAIN" -s "$cidr" -j MASQUERADE
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$DIRECT_BYPASS_CACHE" == "true" ]]; then
|
||||||
|
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box.
|
||||||
|
# Включайте только если готовы к тому, что global rules для этих dst IP не будут проверяться.
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN
|
||||||
|
fi
|
||||||
|
|
||||||
|
for cidr in $BYPASS_CIDRS; do
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
|
done
|
||||||
|
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_direct_bypass_set
|
||||||
|
setup_tproxy
|
||||||
|
setup_proxy_firewall
|
||||||
|
|
||||||
|
node /app/src/server/index.js &
|
||||||
|
APP_PID=$!
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
log "shutdown requested"
|
||||||
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
|
cleanup_proxy_firewall
|
||||||
|
cleanup_tproxy
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Proxy Gateway</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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1726
package-lock.json
generated
Normal file
1726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
284
scripts/install-macos-client.sh
Executable file
284
scripts/install-macos-client.sh
Executable file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/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="8080"
|
||||||
|
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"
|
||||||
|
PROXY_PORT_END="$PROXY_PORT"
|
||||||
|
UI_PORT="${REQUESTED_UI_PORT:-$(get_env_value CLIENT_UI_PORT)}"
|
||||||
|
UI_PORT="${UI_PORT:-3456}"
|
||||||
|
UI_PORT="$(choose_ui_port "$UI_PORT")"
|
||||||
|
assert_ui_outside_proxy_range
|
||||||
|
|
||||||
|
set_env_value APP_MODE client
|
||||||
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT_START "$PROXY_PORT"
|
||||||
|
set_env_value CLIENT_PROXY_PORT_END "$PROXY_PORT_END"
|
||||||
|
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||||
|
|
||||||
|
log "UI port: http://127.0.0.1:${UI_PORT}"
|
||||||
|
log "proxy port: 127.0.0.1:${PROXY_PORT}"
|
||||||
|
|
||||||
|
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
|
|
||||||
101
src/server/clientSettings.js
Normal file
101
src/server/clientSettings.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_SETTINGS = {
|
||||||
|
homeBypassEnabled: false,
|
||||||
|
sharedProxyEnabled: false,
|
||||||
|
sharedProxyControlUrl: "",
|
||||||
|
sharedProxy: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeProxyPort(value, fallback = settings.proxyPort) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
const min = Number.isInteger(settings.clientProxyPortStart)
|
||||||
|
? settings.clientProxyPortStart
|
||||||
|
: settings.proxyPort;
|
||||||
|
const max = Number.isInteger(settings.clientProxyPortEnd)
|
||||||
|
? settings.clientProxyPortEnd
|
||||||
|
: min;
|
||||||
|
const fallbackPort =
|
||||||
|
Number.isInteger(fallback) && fallback >= min && fallback <= max
|
||||||
|
? fallback
|
||||||
|
: min;
|
||||||
|
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||||
|
return fallbackPort;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) return "";
|
||||||
|
url.hash = "";
|
||||||
|
url.search = "";
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSharedProxy(value) {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const host = String(value.host || "").trim();
|
||||||
|
const port = Number.parseInt(value.port, 10);
|
||||||
|
const protocol = value.protocol === "http" ? "http" : "socks5";
|
||||||
|
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
checkedAt: value.checkedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeClientSettings(input = {}) {
|
||||||
|
const sharedProxy = normalizeSharedProxy(input.sharedProxy);
|
||||||
|
const sharedProxyEnabled = Boolean(input.sharedProxyEnabled && sharedProxy);
|
||||||
|
return {
|
||||||
|
homeBypassEnabled: Boolean(input.homeBypassEnabled),
|
||||||
|
proxyPort: normalizeProxyPort(input.proxyPort),
|
||||||
|
sharedProxyEnabled,
|
||||||
|
sharedProxyControlUrl: normalizeUrl(input.sharedProxyControlUrl),
|
||||||
|
sharedProxy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readClientSettings() {
|
||||||
|
return normalizeClientSettings({
|
||||||
|
...DEFAULT_CLIENT_SETTINGS,
|
||||||
|
proxyPort: settings.proxyPort,
|
||||||
|
...readJson(settings.clientSettingsPath, {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeClientSettings(input) {
|
||||||
|
const normalized = normalizeClientSettings({
|
||||||
|
...readClientSettings(),
|
||||||
|
...(input && typeof input === "object" ? input : {}),
|
||||||
|
});
|
||||||
|
writeJson(settings.clientSettingsPath, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
51
src/server/config.js
Normal file
51
src/server/config.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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, 8080);
|
||||||
|
const clientProxyPortStart = parsePort(
|
||||||
|
process.env.CLIENT_PROXY_PORT_START,
|
||||||
|
proxyPort,
|
||||||
|
);
|
||||||
|
const clientProxyPortEnd = parsePort(
|
||||||
|
process.env.CLIENT_PROXY_PORT_END,
|
||||||
|
clientProxyPortStart,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||||
|
port: parsePort(process.env.PORT, 3456),
|
||||||
|
proxyPort,
|
||||||
|
clientProxyPortStart,
|
||||||
|
clientProxyPortEnd,
|
||||||
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
|
tproxySourceBypassChain:
|
||||||
|
process.env.TPROXY_SOURCE_BYPASS_CHAIN || "VPN_PROXY_SRC_BYPASS",
|
||||||
|
tproxySourceForwardChain:
|
||||||
|
process.env.TPROXY_SOURCE_FORWARD_CHAIN || "VPN_PROXY_FWD_BYPASS",
|
||||||
|
tproxySourceNatChain:
|
||||||
|
process.env.TPROXY_SOURCE_NAT_CHAIN || "VPN_PROXY_NAT_BYPASS",
|
||||||
|
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"),
|
||||||
|
customRulesPath: path.join(dataDir, "custom-rules.json"),
|
||||||
|
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
|
||||||
|
clientSettingsPath: path.join(dataDir, "client-settings.json"),
|
||||||
|
devicesPath: path.join(dataDir, "devices.json"),
|
||||||
|
deviceRulesPath: path.join(dataDir, "device-rules.json"),
|
||||||
|
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||||
|
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||||
|
hwidPath: path.join(dataDir, "hwid"),
|
||||||
|
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",
|
||||||
|
ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn",
|
||||||
|
logLevel: process.env.LOG_LEVEL || "info",
|
||||||
|
appName: "VPN Proxy Gateway",
|
||||||
|
};
|
||||||
152
src/server/devices.js
Normal file
152
src/server/devices.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
|
||||||
|
export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block", "bypass"]);
|
||||||
|
export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]);
|
||||||
|
export const DEFAULT_DEVICE_MODE = "vpn";
|
||||||
|
export const DEFAULT_PROXY_MODE = "vpn";
|
||||||
|
export const TPROXY_INBOUND = "tproxy-in";
|
||||||
|
export const MIXED_INBOUND = "mixed-in";
|
||||||
|
|
||||||
|
const IPISH_RE = /^[\.\d:/]+$/;
|
||||||
|
|
||||||
|
function readJson(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceMode(mode, fallback = "rules") {
|
||||||
|
const value = String(mode || "").trim().toLowerCase();
|
||||||
|
return DEVICE_MODES.has(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDefaultMode(mode) {
|
||||||
|
const value = String(mode || "").trim().toLowerCase();
|
||||||
|
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_DEVICE_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyMode(mode) {
|
||||||
|
const value = String(mode || "").trim().toLowerCase();
|
||||||
|
return DEFAULT_DEVICE_MODES.has(value) ? value : DEFAULT_PROXY_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
const value = String(ip || "").trim();
|
||||||
|
return value && IPISH_RE.test(value) ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMac(mac) {
|
||||||
|
return String(mac || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLegacyDeviceRules(input) {
|
||||||
|
const rules = Array.isArray(input) ? input : [];
|
||||||
|
const devices = [];
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const sourceIps = Array.isArray(rule?.sourceIps) ? rule.sourceIps : [];
|
||||||
|
const mode = normalizeDeviceMode(rule?.outbound, "direct");
|
||||||
|
sourceIps.forEach((sourceIp, ipIndex) => {
|
||||||
|
const ip = normalizeIp(sourceIp);
|
||||||
|
if (!ip) return;
|
||||||
|
devices.push({
|
||||||
|
id: String(rule.id || `dev-${devices.length}`) + `-${ipIndex}`,
|
||||||
|
name: String(rule.name || `Устройство ${devices.length + 1}`).trim(),
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
ip,
|
||||||
|
mac: "",
|
||||||
|
mode,
|
||||||
|
lastSeen: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||||
|
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||||
|
devices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDeviceProfiles(input) {
|
||||||
|
const raw =
|
||||||
|
input && typeof input === "object" && !Array.isArray(input)
|
||||||
|
? input
|
||||||
|
: { devices: input };
|
||||||
|
const rawDevices = Array.isArray(raw.devices) ? raw.devices : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultTransparentMode: normalizeDefaultMode(
|
||||||
|
raw.defaultTransparentMode || raw.defaultMode,
|
||||||
|
),
|
||||||
|
proxyDefaultMode: normalizeProxyMode(raw.proxyDefaultMode),
|
||||||
|
devices: rawDevices.map((device, index) => ({
|
||||||
|
id: String(device.id || `dev-${Date.now()}-${index}`),
|
||||||
|
name: String(device.name || `Устройство ${index + 1}`).trim(),
|
||||||
|
enabled: device.enabled !== false,
|
||||||
|
ip: normalizeIp(device.ip || device.sourceIp),
|
||||||
|
mac: normalizeMac(device.mac),
|
||||||
|
mode: normalizeDeviceMode(device.mode || device.outbound, "rules"),
|
||||||
|
lastSeen: device.lastSeen || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDeviceProfiles() {
|
||||||
|
if (fs.existsSync(settings.devicesPath)) {
|
||||||
|
return normalizeDeviceProfiles(readJson(settings.devicesPath, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(settings.deviceRulesPath)) {
|
||||||
|
return normalizeDeviceProfiles(
|
||||||
|
fromLegacyDeviceRules(readJson(settings.deviceRulesPath, [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultTransparentMode: DEFAULT_DEVICE_MODE,
|
||||||
|
proxyDefaultMode: DEFAULT_PROXY_MODE,
|
||||||
|
devices: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDeviceProfiles(value) {
|
||||||
|
const normalized = normalizeDeviceProfiles(value);
|
||||||
|
writeJson(settings.devicesPath, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCidr(ip) {
|
||||||
|
const value = normalizeIp(ip);
|
||||||
|
if (!value) return "";
|
||||||
|
return value.includes("/") ? value : `${value}/32`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceCidrs(devices, modes) {
|
||||||
|
const allowedModes = new Set(Array.isArray(modes) ? modes : [modes]);
|
||||||
|
return (Array.isArray(devices) ? devices : [])
|
||||||
|
.filter((device) => device.enabled !== false && allowedModes.has(device.mode))
|
||||||
|
.map((device) => normalizeCidr(device.ip))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyDeviceRulesFromProfiles(profiles) {
|
||||||
|
const { devices } = normalizeDeviceProfiles(profiles);
|
||||||
|
return devices.map((device) => ({
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
enabled: device.enabled,
|
||||||
|
sourceIps: device.ip ? [device.ip] : [],
|
||||||
|
outbound: device.mode === "rules" ? "direct" : device.mode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
1607
src/server/index.js
Normal file
1607
src/server/index.js
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/server/routeMatcher.js
Normal file
340
src/server/routeMatcher.js
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
// Простой симулятор роутинга sing-box.
|
||||||
|
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
|
||||||
|
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
|
||||||
|
// мы не можем точно сказать, попадает ли IP/домен в RU.
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
import { TPROXY_INBOUND, MIXED_INBOUND } from "./devices.js";
|
||||||
|
|
||||||
|
function ipv4ToInt(ip) {
|
||||||
|
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
|
||||||
|
if (
|
||||||
|
parts.length !== 4 ||
|
||||||
|
parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipInCidr(ip, cidr) {
|
||||||
|
if (!net.isIP(ip)) return false;
|
||||||
|
const [addr, maskStr] = String(cidr).split("/");
|
||||||
|
if (!addr) return false;
|
||||||
|
|
||||||
|
if (net.isIPv4(ip) && net.isIPv4(addr)) {
|
||||||
|
const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10);
|
||||||
|
if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false;
|
||||||
|
const ipInt = ipv4ToInt(ip);
|
||||||
|
const cidrInt = ipv4ToInt(addr);
|
||||||
|
if (ipInt === null || cidrInt === null) return false;
|
||||||
|
if (mask === 0) return true;
|
||||||
|
const m = (~0 << (32 - mask)) >>> 0;
|
||||||
|
return (ipInt & m) === (cidrInt & m);
|
||||||
|
}
|
||||||
|
// IPv6 — упрощённо: точное сравнение строк (без полноценной обработки)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIVATE_CIDRS = [
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPrivateIp(ip) {
|
||||||
|
if (!ip) return false;
|
||||||
|
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCidr(ip) {
|
||||||
|
const value = String(ip || "").trim();
|
||||||
|
if (!value) return "";
|
||||||
|
return value.includes("/") ? value : `${value}/32`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceMatchesSourceIp(device, sourceIp) {
|
||||||
|
if (!device?.ip || !sourceIp) return false;
|
||||||
|
return ipInCidr(sourceIp, normalizeCidr(device.ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOutbound(mode, vpnTag) {
|
||||||
|
if (mode === "vpn") return `${vpnTag} (VPN)`;
|
||||||
|
if (mode === "direct" || mode === "block") return mode;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function likelyRuHost(host) {
|
||||||
|
const value = String(host || "").toLowerCase();
|
||||||
|
return value === "ru" || value.endsWith(".ru");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostMatchesDomain(host, domain) {
|
||||||
|
if (!host || !domain) return false;
|
||||||
|
return host.toLowerCase() === domain.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostMatchesSuffix(host, suffix) {
|
||||||
|
if (!host || !suffix) return false;
|
||||||
|
const h = host.toLowerCase();
|
||||||
|
const s = suffix.toLowerCase();
|
||||||
|
return h === s || h.endsWith("." + s) || h.endsWith(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostMatchesKeyword(host, keyword) {
|
||||||
|
if (!host || !keyword) return false;
|
||||||
|
return host.toLowerCase().includes(keyword.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleMatches(rule, target) {
|
||||||
|
const { host = "", ip = "", port, network } = target;
|
||||||
|
|
||||||
|
if (!rule?.enabled) return false;
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
if (rule.domains?.length) {
|
||||||
|
checks.push(rule.domains.some((d) => hostMatchesDomain(host, d)));
|
||||||
|
}
|
||||||
|
if (rule.domainSuffixes?.length) {
|
||||||
|
checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d)));
|
||||||
|
}
|
||||||
|
if (rule.domainKeywords?.length) {
|
||||||
|
checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d)));
|
||||||
|
}
|
||||||
|
if (rule.ipCidrs?.length) {
|
||||||
|
if (!ip) return false;
|
||||||
|
checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr)));
|
||||||
|
}
|
||||||
|
if (rule.ports?.length) {
|
||||||
|
if (port === undefined || port === null || port === "") return false;
|
||||||
|
const p = Number(port);
|
||||||
|
checks.push(
|
||||||
|
rule.ports.some((portStr) => {
|
||||||
|
const s = String(portStr).trim();
|
||||||
|
if (s.includes("-")) {
|
||||||
|
const [from, to] = s.split("-").map((x) => Number(x));
|
||||||
|
return p >= from && p <= to;
|
||||||
|
}
|
||||||
|
return p === Number(s);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rule.networks?.length) {
|
||||||
|
if (!network) return false;
|
||||||
|
checks.push(rule.networks.includes(network));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checks.length) return false;
|
||||||
|
return checks.every(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Симулирует роутинг и возвращает результат.
|
||||||
|
* @param {object} target { host, ip, port, network }
|
||||||
|
* @param {Array} customRules
|
||||||
|
* @param {object} options { routingRuDirect, vpnTag }
|
||||||
|
*/
|
||||||
|
export function matchRoute(target, customRules, options = {}) {
|
||||||
|
const {
|
||||||
|
routingRuDirect = true,
|
||||||
|
vpnTag = "vpn-out",
|
||||||
|
deviceProfiles = {
|
||||||
|
defaultTransparentMode: "vpn",
|
||||||
|
proxyDefaultMode: "vpn",
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
} = options;
|
||||||
|
const rules = Array.isArray(customRules) ? customRules : [];
|
||||||
|
const inbound = target.inbound || TPROXY_INBOUND;
|
||||||
|
const sourceIp = target.sourceIp || "";
|
||||||
|
const devices = Array.isArray(deviceProfiles.devices)
|
||||||
|
? deviceProfiles.devices
|
||||||
|
: [];
|
||||||
|
const matchedDevice = devices.find(
|
||||||
|
(device) =>
|
||||||
|
device.enabled !== false && deviceMatchesSourceIp(device, sourceIp),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
inbound === TPROXY_INBOUND &&
|
||||||
|
matchedDevice &&
|
||||||
|
matchedDevice.mode === "bypass"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
matched: "kernel-bypass",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleId: matchedDevice.id,
|
||||||
|
ruleName: `${matchedDevice.name} -> bypass TProxy`,
|
||||||
|
outbound: "direct",
|
||||||
|
reason: "Source IP исключён на уровне iptables до попадания в sing-box",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. private IP → direct
|
||||||
|
if (target.ip && isPrivateIp(target.ip)) {
|
||||||
|
return {
|
||||||
|
matched: "system",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleName: "private IP → direct",
|
||||||
|
outbound: "direct",
|
||||||
|
reason: `IP ${target.ip} приватный`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. global custom rules apply to every inbound before fallbacks.
|
||||||
|
for (let i = 0; i < rules.length; i += 1) {
|
||||||
|
const rule = rules[i];
|
||||||
|
if (ruleMatches(rule, target)) {
|
||||||
|
const outbound =
|
||||||
|
rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
|
||||||
|
return {
|
||||||
|
matched: "custom",
|
||||||
|
ruleIndex: i,
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
outbound,
|
||||||
|
reason: "Совпадение по global custom rule",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. RU direct is global. Without a local rule-set DB we only detect obvious .ru hosts.
|
||||||
|
if (routingRuDirect && likelyRuHost(target.host)) {
|
||||||
|
return {
|
||||||
|
matched: "geo",
|
||||||
|
ruleIndex: -2,
|
||||||
|
ruleName: "geosite-category-ru → direct",
|
||||||
|
outbound: "direct",
|
||||||
|
reason: "Домен выглядит как RU; точное попадание в rule-set проверит sing-box",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. transparent device defaults.
|
||||||
|
if (inbound === TPROXY_INBOUND && matchedDevice) {
|
||||||
|
const outbound = modeOutbound(matchedDevice.mode, vpnTag);
|
||||||
|
if (outbound) {
|
||||||
|
return {
|
||||||
|
matched: "device-default",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleId: matchedDevice.id,
|
||||||
|
ruleName: `${matchedDevice.name} → ${matchedDevice.mode}`,
|
||||||
|
outbound,
|
||||||
|
reason: "Fallback устройства после global rules",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. explicit proxy default.
|
||||||
|
if (inbound === MIXED_INBOUND) {
|
||||||
|
const mode = deviceProfiles.proxyDefaultMode || "vpn";
|
||||||
|
return {
|
||||||
|
matched: "proxy-default",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleName: `mixed-in default → ${mode}`,
|
||||||
|
outbound: modeOutbound(mode, vpnTag) || `${vpnTag} (VPN)`,
|
||||||
|
reason: "Fallback explicit HTTP/SOCKS proxy после global rules",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. unknown transparent device default.
|
||||||
|
if (inbound === TPROXY_INBOUND) {
|
||||||
|
const mode = deviceProfiles.defaultTransparentMode || "vpn";
|
||||||
|
return {
|
||||||
|
matched: "transparent-default",
|
||||||
|
ruleIndex: -1,
|
||||||
|
ruleName: `transparent default → ${mode}`,
|
||||||
|
outbound: modeOutbound(mode, vpnTag) || "direct",
|
||||||
|
reason: "Fallback unknown transparent device после global rules",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. final → direct
|
||||||
|
return {
|
||||||
|
matched: "final",
|
||||||
|
ruleIndex: -3,
|
||||||
|
ruleName: "final",
|
||||||
|
outbound: "direct",
|
||||||
|
reason: "Не сработало ни одно правило — итоговый final отправляет напрямую",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
|
||||||
|
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
|
||||||
|
* по доменам/суффиксам/CIDR — отмечаем конфликт.
|
||||||
|
*/
|
||||||
|
export function detectRuleConflicts(rules) {
|
||||||
|
const list = Array.isArray(rules) ? rules : [];
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < list.length; i += 1) {
|
||||||
|
const cur = list[i];
|
||||||
|
if (!cur?.enabled) continue;
|
||||||
|
|
||||||
|
for (let j = 0; j < i; j += 1) {
|
||||||
|
const prev = list[j];
|
||||||
|
if (!prev?.enabled) continue;
|
||||||
|
|
||||||
|
// Если outbound одинаковый — это не "конфликт", это дубликат
|
||||||
|
const sameOutbound = prev.outbound === cur.outbound;
|
||||||
|
|
||||||
|
// Проверка перекрытия доменов
|
||||||
|
const overlaps = [];
|
||||||
|
|
||||||
|
// Точные домены покрываются prev.suffix
|
||||||
|
for (const d of cur.domains || []) {
|
||||||
|
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
|
||||||
|
overlaps.push({
|
||||||
|
kind: "domain",
|
||||||
|
value: d,
|
||||||
|
by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((prev.domains || []).includes(d)) {
|
||||||
|
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Суффиксы покрываются более общим суффиксом prev
|
||||||
|
for (const s of cur.domainSuffixes || []) {
|
||||||
|
if (
|
||||||
|
(prev.domainSuffixes || []).some(
|
||||||
|
(ps) => hostMatchesSuffix(s, ps) && ps !== s,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
overlaps.push({
|
||||||
|
kind: "suffix",
|
||||||
|
value: s,
|
||||||
|
by: "более общий суффикс",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIDR
|
||||||
|
for (const c of cur.ipCidrs || []) {
|
||||||
|
if ((prev.ipCidrs || []).includes(c)) {
|
||||||
|
overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlaps.length) {
|
||||||
|
conflicts.push({
|
||||||
|
ruleId: cur.id,
|
||||||
|
ruleIndex: i,
|
||||||
|
ruleName: cur.name,
|
||||||
|
conflictWithId: prev.id,
|
||||||
|
conflictWithIndex: j,
|
||||||
|
conflictWithName: prev.name,
|
||||||
|
severity: sameOutbound ? "info" : "warning",
|
||||||
|
overlaps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
377
src/server/singbox.js
Normal file
377
src/server/singbox.js
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { settings } from "./config.js";
|
||||||
|
import {
|
||||||
|
MIXED_INBOUND,
|
||||||
|
TPROXY_INBOUND,
|
||||||
|
normalizeCidr,
|
||||||
|
readDeviceProfiles,
|
||||||
|
} from "./devices.js";
|
||||||
|
import { readClientSettings } from "./clientSettings.js";
|
||||||
|
|
||||||
|
const PROXY_TYPES = new Set([
|
||||||
|
"vless",
|
||||||
|
"vmess",
|
||||||
|
"trojan",
|
||||||
|
"shadowsocks",
|
||||||
|
"hysteria2",
|
||||||
|
]);
|
||||||
|
const CUSTOM_OUTBOUNDS = new Set(["direct", "vpn", "block"]);
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOutbound(subscriptionConfig, selectedTag) {
|
||||||
|
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
|
||||||
|
? subscriptionConfig.outbounds
|
||||||
|
: [];
|
||||||
|
const exact = outbounds.find(
|
||||||
|
(outbound) =>
|
||||||
|
outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type),
|
||||||
|
);
|
||||||
|
if (exact) return exact;
|
||||||
|
|
||||||
|
const trimmedTag = String(selectedTag || "").trim();
|
||||||
|
return outbounds.find(
|
||||||
|
(outbound) =>
|
||||||
|
String(outbound.tag || "").trim() === trimmedTag &&
|
||||||
|
PROXY_TYPES.has(outbound.type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCustomRuleSets() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(settings.customRuleSetsPath)) return [];
|
||||||
|
const data = JSON.parse(
|
||||||
|
fs.readFileSync(settings.customRuleSetsPath, "utf8"),
|
||||||
|
);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleSetDownloadDetour(vpnTag) {
|
||||||
|
const detour = String(settings.ruleSetDownloadDetour || "vpn").trim();
|
||||||
|
if (!detour || detour === "vpn") return vpnTag;
|
||||||
|
return detour;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleSets(customRuleSets = [], vpnTag = "direct") {
|
||||||
|
const downloadDetour = ruleSetDownloadDetour(vpnTag);
|
||||||
|
const builtIn = settings.routingRuDirect
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "remote",
|
||||||
|
tag: "geoip-ru",
|
||||||
|
format: "binary",
|
||||||
|
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs",
|
||||||
|
download_detour: downloadDetour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "remote",
|
||||||
|
tag: "geosite-category-ru",
|
||||||
|
format: "binary",
|
||||||
|
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs",
|
||||||
|
download_detour: downloadDetour,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const custom = (Array.isArray(customRuleSets) ? customRuleSets : [])
|
||||||
|
.filter((rs) => rs.tag && rs.url)
|
||||||
|
.map((rs) => ({
|
||||||
|
type: "remote",
|
||||||
|
tag: String(rs.tag).trim(),
|
||||||
|
format: rs.format || "binary",
|
||||||
|
url: String(rs.url).trim(),
|
||||||
|
download_detour: downloadDetour,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Пользовательские rule-sets не должны дублировать встроенные
|
||||||
|
const builtInTags = new Set(builtIn.map((rs) => rs.tag));
|
||||||
|
const merged = [
|
||||||
|
...builtIn,
|
||||||
|
...custom.filter((rs) => !builtInTags.has(rs.tag)),
|
||||||
|
];
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueClean(values) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
(Array.isArray(values) ? values : [])
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePorts(values) {
|
||||||
|
return uniqueClean(values)
|
||||||
|
.map((value) => Number.parseInt(value, 10))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingboxRule(customRule, vpnTag, baseRule = {}) {
|
||||||
|
if (!customRule?.enabled) return null;
|
||||||
|
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
||||||
|
|
||||||
|
const rule = { ...baseRule };
|
||||||
|
const domains = uniqueClean(customRule.domains);
|
||||||
|
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
|
||||||
|
const domainKeywords = uniqueClean(customRule.domainKeywords);
|
||||||
|
const ipCidrs = uniqueClean(customRule.ipCidrs);
|
||||||
|
const ports = parsePorts(customRule.ports);
|
||||||
|
const networks = uniqueClean(customRule.networks).filter((network) =>
|
||||||
|
["tcp", "udp"].includes(network),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (domains.length) rule.domain = domains;
|
||||||
|
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
|
||||||
|
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
|
||||||
|
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
|
||||||
|
if (ports.length) rule.port = ports;
|
||||||
|
if (networks.length) rule.network = networks;
|
||||||
|
|
||||||
|
const ruleSetsRef = uniqueClean(customRule.ruleSets);
|
||||||
|
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!rule.domain &&
|
||||||
|
!rule.domain_suffix &&
|
||||||
|
!rule.domain_keyword &&
|
||||||
|
!rule.ip_cidr &&
|
||||||
|
!rule.port &&
|
||||||
|
!rule.network &&
|
||||||
|
!rule.rule_set
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.outbound = customRule.outbound === "vpn" ? vpnTag : customRule.outbound;
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customRouteRules(customRules, vpnTag, baseRule = {}) {
|
||||||
|
return (Array.isArray(customRules) ? customRules : [])
|
||||||
|
.map((rule) => toSingboxRule(rule, vpnTag, baseRule))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Device rules (маршрутизация по source IP) ──────────────────────────────
|
||||||
|
|
||||||
|
function modeOutbound(mode, vpnTag) {
|
||||||
|
if (mode === "vpn") return vpnTag;
|
||||||
|
if (mode === "direct" || mode === "block") return mode;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceDefaultRouteRule(device, vpnTag) {
|
||||||
|
if (!device?.enabled) return null;
|
||||||
|
const outbound = modeOutbound(device.mode, vpnTag);
|
||||||
|
if (!outbound) return null;
|
||||||
|
|
||||||
|
const cidr = normalizeCidr(device.ip);
|
||||||
|
if (!cidr) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
inbound: [TPROXY_INBOUND],
|
||||||
|
source_ip_cidr: [cidr],
|
||||||
|
outbound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceDefaultRouteRules(devices, vpnTag) {
|
||||||
|
return (Array.isArray(devices) ? devices : [])
|
||||||
|
.map((device) => deviceDefaultRouteRule(device, vpnTag))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboundDefaultRule(inbound, mode, vpnTag) {
|
||||||
|
const outbound = modeOutbound(mode, vpnTag);
|
||||||
|
if (!outbound) return null;
|
||||||
|
return { inbound: [inbound], outbound };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruDirectRule() {
|
||||||
|
if (!settings.routingRuDirect) return null;
|
||||||
|
return {
|
||||||
|
rule_set: ["geoip-ru", "geosite-category-ru"],
|
||||||
|
outbound: "direct",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeRules(customRules, vpnTag, { includeTransparent = true } = {}) {
|
||||||
|
const deviceProfiles = readDeviceProfiles();
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
ip_is_private: true,
|
||||||
|
outbound: "direct",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Global rules apply to every inbound before contextual fallbacks.
|
||||||
|
rules.push(...customRouteRules(customRules, vpnTag));
|
||||||
|
|
||||||
|
const ruRule = ruDirectRule();
|
||||||
|
if (ruRule) rules.push(ruRule);
|
||||||
|
|
||||||
|
if (includeTransparent) {
|
||||||
|
// Device defaults are only transparent-gateway fallbacks after global rules.
|
||||||
|
rules.push(...deviceDefaultRouteRules(deviceProfiles.devices, vpnTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyFallback = inboundDefaultRule(
|
||||||
|
MIXED_INBOUND,
|
||||||
|
deviceProfiles.proxyDefaultMode,
|
||||||
|
vpnTag,
|
||||||
|
);
|
||||||
|
if (proxyFallback) rules.push(proxyFallback);
|
||||||
|
|
||||||
|
if (includeTransparent) {
|
||||||
|
const transparentFallback = inboundDefaultRule(
|
||||||
|
TPROXY_INBOUND,
|
||||||
|
deviceProfiles.defaultTransparentMode,
|
||||||
|
vpnTag,
|
||||||
|
);
|
||||||
|
if (transparentFallback) rules.push(transparentFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedProxyOutbound(sharedProxy) {
|
||||||
|
if (!sharedProxy?.host || !sharedProxy?.port) return null;
|
||||||
|
if (sharedProxy.protocol === "http") {
|
||||||
|
return {
|
||||||
|
type: "http",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: sharedProxy.host,
|
||||||
|
server_port: sharedProxy.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "socks",
|
||||||
|
tag: "shared-proxy",
|
||||||
|
server: sharedProxy.host,
|
||||||
|
server_port: sharedProxy.port,
|
||||||
|
version: "5",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayConfig(
|
||||||
|
subscriptionConfig,
|
||||||
|
selectedTag,
|
||||||
|
{ bypassAll = false } = {},
|
||||||
|
) {
|
||||||
|
const customRuleSets = readCustomRuleSets();
|
||||||
|
const clientMode = settings.appMode === "client";
|
||||||
|
const clientSettings = clientMode ? readClientSettings() : null;
|
||||||
|
const sharedOutbound =
|
||||||
|
clientMode && clientSettings?.sharedProxyEnabled
|
||||||
|
? sharedProxyOutbound(clientSettings.sharedProxy)
|
||||||
|
: null;
|
||||||
|
const directOnlyClient = clientMode && clientSettings?.homeBypassEnabled;
|
||||||
|
const selectedOutbound = sharedOutbound
|
||||||
|
? null
|
||||||
|
: findOutbound(subscriptionConfig, selectedTag);
|
||||||
|
if (!sharedOutbound && !directOnlyClient && !selectedOutbound) {
|
||||||
|
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vpnOutbound = selectedOutbound ? clone(selectedOutbound) : null;
|
||||||
|
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = "vpn-out";
|
||||||
|
if (vpnOutbound?.type === "vless" && !vpnOutbound.packet_encoding) {
|
||||||
|
vpnOutbound.packet_encoding = "xudp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOutbound = sharedOutbound
|
||||||
|
? sharedOutbound.tag
|
||||||
|
: clientSettings?.homeBypassEnabled
|
||||||
|
? "direct"
|
||||||
|
: vpnOutbound.tag;
|
||||||
|
const mixedProxyPort = clientSettings?.proxyPort || settings.proxyPort;
|
||||||
|
const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }];
|
||||||
|
const inbounds = [
|
||||||
|
...(clientMode
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
type: "tproxy",
|
||||||
|
tag: "tproxy-in",
|
||||||
|
listen: "::",
|
||||||
|
listen_port: settings.tproxyPort,
|
||||||
|
sniff: true,
|
||||||
|
sniff_override_destination: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
type: "mixed",
|
||||||
|
tag: "mixed-in",
|
||||||
|
listen: settings.bindIp,
|
||||||
|
listen_port: mixedProxyPort,
|
||||||
|
sniff: true,
|
||||||
|
set_system_proxy: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
log: {
|
||||||
|
level: settings.logLevel,
|
||||||
|
timestamp: true,
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
cache_file: {
|
||||||
|
enabled: true,
|
||||||
|
path: settings.cachePath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dns: {
|
||||||
|
independent_cache: true,
|
||||||
|
},
|
||||||
|
inbounds,
|
||||||
|
outbounds: [
|
||||||
|
...(sharedOutbound ? [sharedOutbound] : vpnOutbound ? [vpnOutbound] : []),
|
||||||
|
{ type: "direct", tag: "direct" },
|
||||||
|
{ type: "block", tag: "block" },
|
||||||
|
],
|
||||||
|
route: {
|
||||||
|
rule_set: bypassAll || clientMode ? [] : ruleSets(customRuleSets, vpnOutbound.tag),
|
||||||
|
rules: bypassAll
|
||||||
|
? [{ ip_is_private: true, outbound: "direct" }]
|
||||||
|
: clientMode
|
||||||
|
? proxyOnlyRules
|
||||||
|
: routeRules(subscriptionConfig.customRules, vpnOutbound.tag, {
|
||||||
|
includeTransparent: !clientMode,
|
||||||
|
}),
|
||||||
|
final: "direct",
|
||||||
|
...(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 readSingboxConfig() {
|
||||||
|
if (!fs.existsSync(settings.configPath)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(settings.configPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSingboxConfig() {
|
||||||
|
if (fs.existsSync(settings.configPath)) {
|
||||||
|
fs.rmSync(settings.configPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user