feat: добавлены компоненты для управления конфигурацией и логами
Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации. Refs: None
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vpn-proxy
|
||||||
|
_archive
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
IMAGE="${REGISTRY_HOST}/${{ gitea.repository }}/gateway"
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY_HOST" -u "${{ gitea.actor }}" --password-stdin
|
||||||
docker build \
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
--network host \
|
--network host \
|
||||||
-t "${IMAGE}:latest" \
|
-t "${IMAGE}:latest" \
|
||||||
-t "${IMAGE}:${{ gitea.sha }}" \
|
-t "${IMAGE}:${{ gitea.sha }}" \
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -1,7 +1,8 @@
|
|||||||
FROM node:22-bookworm-slim AS ui-build
|
FROM node:22-bookworm-slim AS ui-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --no-audit --no-fund --network-timeout=300000
|
||||||
COPY index.html vite.config.js ./
|
COPY index.html vite.config.js ./
|
||||||
COPY src/web ./src/web
|
COPY src/web ./src/web
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -17,9 +18,9 @@ RUN apt-get update \
|
|||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
arch="$(dpkg --print-architecture)"; \
|
arch="$(dpkg --print-architecture)"; \
|
||||||
case "$arch" in \
|
case "$arch" in \
|
||||||
amd64) sb_arch="amd64" ;; \
|
amd64) sb_arch="amd64" ;; \
|
||||||
arm64) sb_arch="arm64" ;; \
|
arm64) sb_arch="arm64" ;; \
|
||||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
|
||||||
esac; \
|
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; \
|
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; \
|
tar -xzf /tmp/sing-box.tgz -C /tmp; \
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -1,20 +1,25 @@
|
|||||||
# VPN Proxy Gateway
|
# VPN Proxy Gateway
|
||||||
|
|
||||||
Новая версия проекта начинается с `gateway`-режима: контейнер поднимается в `network_mode: host`, применяет TProxy-правила на хосте и запускает `sing-box` как прозрачный gateway для устройств в локальной сети.
|
Контейнер запускается в `network_mode: host`, применяет TProxy-правила на хосте и
|
||||||
|
запускает `sing-box` как прозрачный gateway для устройств в локальной сети.
|
||||||
|
|
||||||
## Что уже заложено
|
## Возможности
|
||||||
|
|
||||||
- Web UI на Vite + React.
|
- Web UI на Vite + React, всё на русском.
|
||||||
- Один простой Node control-server вместо отдельного backend framework.
|
- Один Node control-server без отдельного backend framework.
|
||||||
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links.
|
- Парсинг subscription URL: JSON config, base64 список, plain-text VLESS links.
|
||||||
- Routing lists управляются из UI: можно отправлять отдельные домены/CIDR/порты в `direct`, `vpn` или `block`.
|
- Подписка маскируется в UI после загрузки, кнопка «Забыть подписку» — стирает
|
||||||
- Генерация `sing-box` config для gateway:
|
кэш, останавливает sing-box и удаляет конфиг.
|
||||||
- `tproxy` inbound на `7895`;
|
- Управление жизненным циклом sing-box из UI: остановить, перезапустить, сбросить
|
||||||
- `mixed` inbound на `8080`;
|
конфиг, посмотреть сгенерированный `config.json` (read-only).
|
||||||
- private IP ranges напрямую;
|
- Live-логи sing-box через SSE (фильтр по уровню, пауза, очистка).
|
||||||
- RU rule sets напрямую;
|
- Routing lists с автосохранением, drag-n-drop порядка (first match wins),
|
||||||
- остальное через выбранный outbound.
|
валидацией CIDR/портов/доменов и шаблонами (LoL, Discord, Telegram, YouTube,
|
||||||
|
Steam, реклама).
|
||||||
|
- Генерация sing-box config с safety private-direct, кастомными правилами и
|
||||||
|
RU geosite/geoip direct.
|
||||||
- Docker entrypoint с idempotent TProxy setup/cleanup.
|
- Docker entrypoint с idempotent TProxy setup/cleanup.
|
||||||
|
- Healthcheck в compose: `curl http://127.0.0.1:${PORT}/api/state`.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -25,10 +30,28 @@ docker compose -f docker-compose.gateway.yml up -d --build
|
|||||||
|
|
||||||
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
UI будет доступен на хосте по `http://<gateway-host>:3456`.
|
||||||
|
|
||||||
## Важные ограничения v0.1
|
## REST API
|
||||||
|
|
||||||
|
| Метод | Путь | Назначение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/state` | состояние, список серверов, кастомные правила, masked subscription |
|
||||||
|
| GET | `/api/config` | текущий sing-box config |
|
||||||
|
| GET | `/api/logs` | последние 200 строк логов |
|
||||||
|
| GET | `/api/logs/stream` | SSE-поток логов sing-box |
|
||||||
|
| GET / PUT | `/api/rules` | список кастомных правил |
|
||||||
|
| POST | `/api/subscription/fetch` | загрузить подписку |
|
||||||
|
| DELETE | `/api/subscription` | удалить подписку, остановить sing-box |
|
||||||
|
| POST | `/api/apply` | применить выбранный сервер |
|
||||||
|
| POST | `/api/singbox/{stop,restart,clear}` | управление процессом |
|
||||||
|
|
||||||
|
## Важные ограничения
|
||||||
|
|
||||||
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
- IPv4 TProxy first. IPv6 routing будет отдельным этапом.
|
||||||
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать клиентам DNS через роутер/DHCP.
|
- DNS-перехват пока не включен. Для корректного gateway-сценария лучше выдать
|
||||||
|
клиентам DNS через роутер/DHCP.
|
||||||
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
- Контейнер должен запускаться с `network_mode: host`, `NET_ADMIN`, `NET_RAW`.
|
||||||
|
- Mixed proxy по умолчанию слушает `127.0.0.1` (для дома). Чтобы открыть для LAN,
|
||||||
|
установи `PROXY_BIND_IP=0.0.0.0` в `.env`.
|
||||||
|
- Gateway не видит process name на клиентском ПК, поэтому правила для игр
|
||||||
|
задаются через домены, suffix, IP CIDR и порты.
|
||||||
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
- `_archive/` игнорируется git, потому что там лежит старая реализация и runtime state.
|
||||||
- Gateway не видит process name на клиентском ПК, поэтому правила для игр задаются через домены, suffix, IP CIDR и порты.
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ services:
|
|||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
- sing-box-cache:/var/lib/sing-box
|
- sing-box-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
vpn-proxy-data:
|
vpn-proxy-data:
|
||||||
|
|||||||
1726
package-lock.json
generated
Normal file
1726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -10,10 +10,12 @@
|
|||||||
"start": "node src/server/index.js"
|
"start": "node src/server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"vite": "^7.0.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
},
|
"vite": "^7.0.0"
|
||||||
"devDependencies": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ services:
|
|||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
- sing-box-cache:/var/lib/sing-box
|
- sing-box-cache:/var/lib/sing-box
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
vpn-proxy-data:
|
vpn-proxy-data:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const settings = {
|
|||||||
port: Number(process.env.PORT || 3456),
|
port: Number(process.env.PORT || 3456),
|
||||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||||
bindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
bindIp: process.env.PROXY_BIND_IP || '127.0.0.1',
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || '/app/dist',
|
distDir: process.env.DIST_DIR || '/app/dist',
|
||||||
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json',
|
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json',
|
||||||
|
|||||||
@@ -4,12 +4,53 @@ import path from 'node:path';
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { fetchSubscription } from './subscription.js';
|
import { fetchSubscription } from './subscription.js';
|
||||||
import { buildGatewayConfig, writeSingboxConfig } from './singbox.js';
|
import {
|
||||||
|
buildGatewayConfig,
|
||||||
|
writeSingboxConfig,
|
||||||
|
readSingboxConfig,
|
||||||
|
removeSingboxConfig,
|
||||||
|
} from './singbox.js';
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
let singboxProcess = null;
|
let singboxProcess = null;
|
||||||
let singboxStartedAt = null;
|
let singboxStartedAt = null;
|
||||||
|
const LOG_BUFFER_SIZE = 500;
|
||||||
|
const logBuffer = [];
|
||||||
|
const logSubscribers = new Set();
|
||||||
|
|
||||||
|
function pushLog(level, line) {
|
||||||
|
const entry = { ts: new Date().toISOString(), level, line };
|
||||||
|
logBuffer.push(entry);
|
||||||
|
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
|
||||||
|
for (const subscriber of logSubscribers) {
|
||||||
|
try {
|
||||||
|
subscriber(entry);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureStream(stream, level) {
|
||||||
|
let remainder = '';
|
||||||
|
stream.setEncoding('utf8');
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
const data = remainder + chunk;
|
||||||
|
const lines = data.split(/\r?\n/);
|
||||||
|
remainder = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
process.stdout.write(`[sing-box:${level}] ${line}\n`);
|
||||||
|
pushLog(level, line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (remainder) {
|
||||||
|
process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
|
||||||
|
pushLog(level, remainder);
|
||||||
|
}
|
||||||
|
remainder = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(filePath, fallback) {
|
function readJson(filePath, fallback) {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +66,16 @@ function writeJson(filePath, value) {
|
|||||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maskSubscriptionUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return `${parsed.hostname}/...`;
|
||||||
|
} catch {
|
||||||
|
return url.length > 32 ? `${url.slice(0, 32)}...` : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
function sendJson(res, statusCode, payload) {
|
||||||
const body = JSON.stringify(payload, null, 2);
|
const body = JSON.stringify(payload, null, 2);
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
@@ -43,7 +94,7 @@ function readBody(req) {
|
|||||||
try {
|
try {
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error('Invalid JSON body'));
|
reject(new Error('Невалидный JSON в теле запроса'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
@@ -62,10 +113,14 @@ function checkSingboxConfig() {
|
|||||||
|
|
||||||
function stopSingbox() {
|
function stopSingbox() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!singboxProcess) return resolve();
|
if (!singboxProcess) {
|
||||||
|
singboxStartedAt = null;
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const current = singboxProcess;
|
const current = singboxProcess;
|
||||||
singboxProcess = null;
|
singboxProcess = null;
|
||||||
|
singboxStartedAt = null;
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
current.kill('SIGKILL');
|
current.kill('SIGKILL');
|
||||||
@@ -88,13 +143,18 @@ async function startSingbox() {
|
|||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
|
|
||||||
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
|
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
singboxStartedAt = new Date().toISOString();
|
singboxStartedAt = new Date().toISOString();
|
||||||
|
pushLog('info', `sing-box запущен (pid=${singboxProcess.pid})`);
|
||||||
|
|
||||||
|
captureStream(singboxProcess.stdout, 'info');
|
||||||
|
captureStream(singboxProcess.stderr, 'error');
|
||||||
|
|
||||||
singboxProcess.once('exit', (code, signal) => {
|
singboxProcess.once('exit', (code, signal) => {
|
||||||
console.log(`[control] sing-box exited: code=${code} signal=${signal}`);
|
pushLog('info', `sing-box завершён: code=${code} signal=${signal}`);
|
||||||
if (singboxProcess?.exitCode === code) singboxProcess = null;
|
singboxProcess = null;
|
||||||
|
singboxStartedAt = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -103,6 +163,7 @@ async function startSingbox() {
|
|||||||
function publicState() {
|
function publicState() {
|
||||||
const state = readJson(settings.statePath, {});
|
const state = readJson(settings.statePath, {});
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
const customRules = readJson(settings.customRulesPath, []);
|
||||||
|
const { subscriptionUrl, ...rest } = state;
|
||||||
return {
|
return {
|
||||||
mode: 'gateway',
|
mode: 'gateway',
|
||||||
port: settings.port,
|
port: settings.port,
|
||||||
@@ -112,8 +173,10 @@ function publicState() {
|
|||||||
configExists: fs.existsSync(settings.configPath),
|
configExists: fs.existsSync(settings.configPath),
|
||||||
singboxRunning: Boolean(singboxProcess),
|
singboxRunning: Boolean(singboxProcess),
|
||||||
singboxStartedAt,
|
singboxStartedAt,
|
||||||
|
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
|
||||||
|
hasSubscription: Boolean(subscriptionUrl),
|
||||||
customRules,
|
customRules,
|
||||||
...state,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +194,7 @@ function normalizeCustomRules(input) {
|
|||||||
const rules = Array.isArray(input) ? input : [];
|
const rules = Array.isArray(input) ? input : [];
|
||||||
return rules.map((rule, index) => ({
|
return rules.map((rule, index) => ({
|
||||||
id: String(rule.id || `rule-${Date.now()}-${index}`),
|
id: String(rule.id || `rule-${Date.now()}-${index}`),
|
||||||
name: String(rule.name || `Rule ${index + 1}`).trim(),
|
name: String(rule.name || `Правило ${index + 1}`).trim(),
|
||||||
enabled: rule.enabled !== false,
|
enabled: rule.enabled !== false,
|
||||||
outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct',
|
outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct',
|
||||||
domains: normalizeList(rule.domains),
|
domains: normalizeList(rule.domains),
|
||||||
@@ -143,11 +206,70 @@ function normalizeCustomRules(input) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applySelectedServer(selectedTag) {
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (!cached?.config) {
|
||||||
|
throw new Error('Сначала загрузите подписку');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRules = readJson(settings.customRulesPath, []);
|
||||||
|
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
|
||||||
|
writeSingboxConfig(generated);
|
||||||
|
await startSingbox();
|
||||||
|
|
||||||
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
writeJson(settings.statePath, {
|
||||||
|
...prevState,
|
||||||
|
selectedTag,
|
||||||
|
appliedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogsStream(req, res) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': 'text/event-stream; charset=utf-8',
|
||||||
|
'cache-control': 'no-cache, no-transform',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
'x-accel-buffering': 'no',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of logBuffer.slice(-200)) {
|
||||||
|
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriber = (entry) => {
|
||||||
|
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||||
|
};
|
||||||
|
logSubscribers.add(subscriber);
|
||||||
|
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
try { res.write(': ping\n\n'); } catch {}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(keepalive);
|
||||||
|
logSubscribers.delete(subscriber);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApi(req, res) {
|
async function handleApi(req, res) {
|
||||||
if (req.method === 'GET' && req.url === '/api/state') {
|
if (req.method === 'GET' && req.url === '/api/state') {
|
||||||
return sendJson(res, 200, publicState());
|
return sendJson(res, 200, publicState());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/config') {
|
||||||
|
const config = readSingboxConfig();
|
||||||
|
return sendJson(res, 200, { success: true, config });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/logs') {
|
||||||
|
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/logs/stream') {
|
||||||
|
return handleLogsStream(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/rules') {
|
if (req.method === 'GET' && req.url === '/api/rules') {
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -165,7 +287,7 @@ async function handleApi(req, res) {
|
|||||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const url = String(body.url || '').trim();
|
const url = String(body.url || '').trim();
|
||||||
if (!url) return sendJson(res, 400, { success: false, error: 'Subscription URL is required' });
|
if (!url) return sendJson(res, 400, { success: false, error: 'Укажите subscription URL' });
|
||||||
|
|
||||||
const parsed = await fetchSubscription(url);
|
const parsed = await fetchSubscription(url);
|
||||||
writeJson(settings.subscriptionCachePath, { url, ...parsed });
|
writeJson(settings.subscriptionCachePath, { url, ...parsed });
|
||||||
@@ -182,28 +304,28 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
|
if (fs.existsSync(settings.subscriptionCachePath)) fs.rmSync(settings.subscriptionCachePath);
|
||||||
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
delete prevState.subscriptionUrl;
|
||||||
|
delete prevState.servers;
|
||||||
|
delete prevState.userInfo;
|
||||||
|
delete prevState.fetchedAt;
|
||||||
|
delete prevState.selectedTag;
|
||||||
|
delete prevState.appliedAt;
|
||||||
|
writeJson(settings.statePath, prevState);
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
|
pushLog('info', 'Подписка удалена, sing-box остановлен');
|
||||||
|
return sendJson(res, 200, { success: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const selectedTag = String(body.selectedTag || '');
|
const selectedTag = String(body.selectedTag || '').trim();
|
||||||
if (!selectedTag.trim()) return sendJson(res, 400, { success: false, error: 'selectedTag is required' });
|
if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag обязателен' });
|
||||||
|
|
||||||
const cached = readJson(settings.subscriptionCachePath, null);
|
|
||||||
if (!cached?.config) {
|
|
||||||
return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const customRules = readJson(settings.customRulesPath, []);
|
|
||||||
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
|
|
||||||
writeSingboxConfig(generated);
|
|
||||||
await startSingbox();
|
|
||||||
|
|
||||||
const prevState = readJson(settings.statePath, {});
|
|
||||||
writeJson(settings.statePath, {
|
|
||||||
...prevState,
|
|
||||||
selectedTag,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
await applySelectedServer(selectedTag);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
@@ -212,7 +334,33 @@ async function handleApi(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendJson(res, 404, { success: false, error: 'Not found' });
|
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||||
|
await stopSingbox();
|
||||||
|
pushLog('info', 'sing-box остановлен пользователем');
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||||
|
if (!fs.existsSync(settings.configPath)) {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Конфиг отсутствует — сначала примените сервер' });
|
||||||
|
}
|
||||||
|
await startSingbox();
|
||||||
|
pushLog('info', 'sing-box перезапущен пользователем');
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: Boolean(singboxProcess) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/singbox/clear') {
|
||||||
|
await stopSingbox();
|
||||||
|
removeSingboxConfig();
|
||||||
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
delete prevState.selectedTag;
|
||||||
|
delete prevState.appliedAt;
|
||||||
|
writeJson(settings.statePath, prevState);
|
||||||
|
pushLog('info', 'Конфиг sing-box удалён, процесс остановлен');
|
||||||
|
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { success: false, error: 'Не найдено' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mime = {
|
const mime = {
|
||||||
@@ -266,9 +414,10 @@ process.on('SIGINT', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await startSingbox().catch((error) => {
|
await startSingbox().catch((error) => {
|
||||||
console.warn(`[control] sing-box was not started: ${error.message}`);
|
console.warn(`[control] sing-box не запущен: ${error.message}`);
|
||||||
|
pushLog('error', `sing-box не запущен при старте: ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] gateway UI listening on :${settings.port}`);
|
console.log(`[control] gateway UI слушает :${settings.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function routeRules(customRules, vpnTag) {
|
|||||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||||
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
||||||
if (!selectedOutbound) {
|
if (!selectedOutbound) {
|
||||||
throw new Error(`Selected outbound not found: ${selectedTag}`);
|
throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const vpnOutbound = clone(selectedOutbound);
|
const vpnOutbound = clone(selectedOutbound);
|
||||||
@@ -177,3 +177,18 @@ export function writeSingboxConfig(config) {
|
|||||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||||
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
453
src/web/App.jsx
453
src/web/App.jsx
@@ -1,32 +1,18 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import { api } from './api.js';
|
||||||
function formatBytes(value) {
|
import { SubscriptionPanel } from './components/SubscriptionPanel.jsx';
|
||||||
if (!value) return '0 B';
|
import { ServerList } from './components/ServerList.jsx';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
import { RuntimePanel } from './components/RuntimePanel.jsx';
|
||||||
let size = value;
|
import { RulesPanel } from './components/RulesPanel.jsx';
|
||||||
let index = 0;
|
import { LogsPanel } from './components/LogsPanel.jsx';
|
||||||
while (size >= 1024 && index < units.length - 1) {
|
import { ConfigViewer } from './components/ConfigViewer.jsx';
|
||||||
size /= 1024;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskUrl(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return `${url.hostname}/...`;
|
|
||||||
} catch {
|
|
||||||
return value.length > 48 ? `${value.slice(0, 48)}...` : value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [state, setState] = useState(null);
|
const [state, setState] = useState(null);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
|
const [editingSubscription, setEditingSubscription] = useState(false);
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [customRules, setCustomRules] = useState([]);
|
const [customRules, setCustomRules] = useState([]);
|
||||||
const [selectedTag, setSelectedTag] = useState('');
|
const [selectedTag, setSelectedTag] = useState('');
|
||||||
@@ -34,33 +20,22 @@ function App() {
|
|||||||
const [log, setLog] = useState([]);
|
const [log, setLog] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const rulesDirtyRef = useRef(false);
|
const rulesDirtyRef = useRef(false);
|
||||||
const rulesSaveTimerRef = useRef(null);
|
const rulesSaveTimerRef = useRef(null);
|
||||||
const rulesRevisionRef = useRef(0);
|
const rulesRevisionRef = useRef(0);
|
||||||
|
|
||||||
const userTraffic = useMemo(() => {
|
|
||||||
const info = state?.userInfo;
|
|
||||||
if (!info) return 'нет данных';
|
|
||||||
const used = formatBytes((info.upload || 0) + (info.download || 0));
|
|
||||||
const total = info.total ? formatBytes(info.total) : 'без лимита';
|
|
||||||
return `${used} / ${total}`;
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
function addLog(message) {
|
function addLog(message) {
|
||||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||||||
setLog((items) => [{ time, message }, ...items].slice(0, 8));
|
setLog((items) => [{ time, message }, ...items].slice(0, 8));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
const response = await fetch('/api/state');
|
const data = await api.state();
|
||||||
const data = await response.json();
|
|
||||||
setState(data);
|
setState(data);
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
if (!rulesDirtyRef.current) {
|
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||||
setCustomRules(data.customRules || []);
|
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||||
}
|
|
||||||
setSelectedTag(data.selectedTag || '');
|
|
||||||
if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,93 +44,75 @@ function App() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => {
|
||||||
return () => {
|
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
||||||
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchServers() {
|
async function withBusy(label, fn) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
addLog(`SYNC ${maskUrl(subscriptionUrl)}`);
|
if (label) addLog(label);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/subscription/fetch', {
|
await fn();
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: subscriptionUrl }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'sync failed');
|
|
||||||
|
|
||||||
setServers(data.servers || []);
|
|
||||||
setSelectedTag(data.servers?.[0]?.tag || '');
|
|
||||||
addLog(`FOUND ${data.servers.length} servers`);
|
|
||||||
await loadState();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
addLog(`ERROR ${err.message}`);
|
addLog(`ОШИБКА: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchServers() {
|
||||||
|
await withBusy('Загрузка подписки', async () => {
|
||||||
|
const data = await api.subscription.fetch(subscriptionUrl);
|
||||||
|
setServers(data.servers || []);
|
||||||
|
setSelectedTag(data.servers?.[0]?.tag || '');
|
||||||
|
addLog(`Найдено серверов: ${data.servers.length}`);
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetSubscription() {
|
||||||
|
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||||
|
await withBusy('Удаление подписки', async () => {
|
||||||
|
await api.subscription.forget();
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setServers([]);
|
||||||
|
setSelectedTag('');
|
||||||
|
setEditingSubscription(true);
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function applyServer() {
|
async function applyServer() {
|
||||||
setBusy(true);
|
await withBusy(`Применяем ${selectedTag}`, async () => {
|
||||||
setError('');
|
const data = await api.apply(selectedTag);
|
||||||
addLog(`APPLY ${selectedTag}`);
|
addLog(`sing-box: ${data.singboxRunning ? 'работает' : 'не запущен'}`);
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/apply', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ selectedTag }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'apply failed');
|
|
||||||
|
|
||||||
addLog(`SING-BOX ${data.singboxRunning ? 'RUNNING' : 'STOPPED'}`);
|
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (err) {
|
});
|
||||||
setError(err.message);
|
|
||||||
addLog(`ERROR ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyRule() {
|
async function stopSingbox() {
|
||||||
return {
|
if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return;
|
||||||
id: `rule-${Date.now()}`,
|
await withBusy('Остановка sing-box', async () => {
|
||||||
name: 'Новый список',
|
await api.singbox.stop();
|
||||||
enabled: true,
|
await loadState();
|
||||||
outbound: 'direct',
|
});
|
||||||
domains: [],
|
|
||||||
domainSuffixes: [],
|
|
||||||
domainKeywords: [],
|
|
||||||
ipCidrs: [],
|
|
||||||
ports: [],
|
|
||||||
networks: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function listToText(value) {
|
async function restartSingbox() {
|
||||||
return Array.isArray(value) ? value.join('\n') : '';
|
await withBusy('Перезапуск sing-box', async () => {
|
||||||
|
await api.singbox.restart();
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function textToList(value) {
|
async function clearConfig() {
|
||||||
return value
|
if (!confirm('Сбросить config sing-box и остановить процесс?')) return;
|
||||||
.split(/\r?\n|,/)
|
await withBusy('Сброс конфига', async () => {
|
||||||
.map((item) => item.trim())
|
await api.singbox.clear();
|
||||||
.filter(Boolean);
|
setSelectedTag('');
|
||||||
}
|
await loadState();
|
||||||
|
|
||||||
function updateRule(id, patch) {
|
|
||||||
setCustomRules((rules) => {
|
|
||||||
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
|
|
||||||
queueRulesSave(nextRules);
|
|
||||||
return nextRules;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,23 +132,16 @@ function App() {
|
|||||||
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
|
||||||
if (!silent) setBusy(true);
|
if (!silent) setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
if (!silent) addLog('SAVE ROUTING RULES');
|
if (!silent) addLog('Сохранение правил');
|
||||||
setRulesSaveStatus('saving');
|
setRulesSaveStatus('saving');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/rules', {
|
const data = await api.rules.save(nextRules);
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ rules: nextRules }),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok || !data.success) throw new Error(data.error || 'rules save failed');
|
|
||||||
|
|
||||||
if (rulesRevisionRef.current === revision) {
|
if (rulesRevisionRef.current === revision) {
|
||||||
rulesDirtyRef.current = false;
|
rulesDirtyRef.current = false;
|
||||||
setCustomRules(data.rules || []);
|
setCustomRules(data.rules || []);
|
||||||
setRulesSaveStatus('saved');
|
setRulesSaveStatus('saved');
|
||||||
addLog(`RULES SAVED ${data.rules.length}`);
|
addLog(`Правил сохранено: ${data.rules.length}`);
|
||||||
await loadState();
|
await loadState();
|
||||||
} else {
|
} else {
|
||||||
setRulesSaveStatus('pending');
|
setRulesSaveStatus('pending');
|
||||||
@@ -199,7 +149,7 @@ function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setRulesSaveStatus('error');
|
setRulesSaveStatus('error');
|
||||||
addLog(`ERROR ${err.message}`);
|
addLog(`ОШИБКА: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setBusy(false);
|
if (!silent) setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -213,6 +163,29 @@ function App() {
|
|||||||
saveRules(customRules, { silent: false, revision });
|
saveRules(customRules, { silent: false, revision });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyRule() {
|
||||||
|
return {
|
||||||
|
id: `rule-${Date.now()}`,
|
||||||
|
name: 'Новый список',
|
||||||
|
enabled: true,
|
||||||
|
outbound: 'direct',
|
||||||
|
domains: [],
|
||||||
|
domainSuffixes: [],
|
||||||
|
domainKeywords: [],
|
||||||
|
ipCidrs: [],
|
||||||
|
ports: [],
|
||||||
|
networks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRule(id, patch) {
|
||||||
|
setCustomRules((rules) => {
|
||||||
|
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
|
||||||
|
queueRulesSave(nextRules);
|
||||||
|
return nextRules;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addRule() {
|
function addRule() {
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = [emptyRule(), ...rules];
|
const nextRules = [emptyRule(), ...rules];
|
||||||
@@ -221,6 +194,14 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addRuleFromTemplate(template) {
|
||||||
|
setCustomRules((rules) => {
|
||||||
|
const nextRules = [template, ...rules];
|
||||||
|
queueRulesSave(nextRules);
|
||||||
|
return nextRules;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function removeRule(id) {
|
function removeRule(id) {
|
||||||
setCustomRules((rules) => {
|
setCustomRules((rules) => {
|
||||||
const nextRules = rules.filter((rule) => rule.id !== id);
|
const nextRules = rules.filter((rule) => rule.id !== id);
|
||||||
@@ -229,20 +210,25 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reorderRules(nextRules) {
|
||||||
|
setCustomRules(nextRules);
|
||||||
|
queueRulesSave(nextRules);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<section className="hero panel">
|
<section className="hero panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">VPN Proxy / Gateway Mode</p>
|
<p className="eyebrow">VPN Proxy / Gateway</p>
|
||||||
<h1>Transparent gateway for the whole network</h1>
|
<h1>Прозрачный VPN-шлюз для всей сети</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
Загрузи подписку, выбери сервер — контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-card">
|
<div className="status-card">
|
||||||
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
|
<strong>{state?.singboxRunning ? 'sing-box работает' : 'sing-box остановлен'}</strong>
|
||||||
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
<small>{state?.selectedTag || 'сервер не выбран'}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,200 +236,53 @@ function App() {
|
|||||||
|
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
<div className="panel primary-flow">
|
<div className="panel primary-flow">
|
||||||
<div className="section-title">
|
<SubscriptionPanel
|
||||||
<span>1</span>
|
subscriptionUrl={subscriptionUrl}
|
||||||
<h2>Subscription</h2>
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
</div>
|
hasSubscription={Boolean(state?.hasSubscription)}
|
||||||
|
subscriptionHost={state?.subscriptionHost}
|
||||||
<label className="field">
|
busy={busy}
|
||||||
<span>Subscription URL</span>
|
onFetch={fetchServers}
|
||||||
<input
|
onForget={forgetSubscription}
|
||||||
value={subscriptionUrl}
|
editing={editingSubscription || !state?.hasSubscription}
|
||||||
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
setEditing={setEditingSubscription}
|
||||||
placeholder="https://provider.example/sub/..."
|
/>
|
||||||
/>
|
<ServerList
|
||||||
</label>
|
servers={servers}
|
||||||
|
selectedTag={selectedTag}
|
||||||
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}>
|
setSelectedTag={setSelectedTag}
|
||||||
{busy ? 'Working...' : 'Parse subscription'}
|
busy={busy}
|
||||||
</button>
|
onApply={applyServer}
|
||||||
|
/>
|
||||||
<div className="section-title compact">
|
|
||||||
<span>2</span>
|
|
||||||
<h2>Servers</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="server-list">
|
|
||||||
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
|
|
||||||
{servers.map((server) => (
|
|
||||||
<button
|
|
||||||
key={server.tag}
|
|
||||||
className={server.tag === selectedTag ? 'server active' : 'server'}
|
|
||||||
onClick={() => setSelectedTag(server.tag)}
|
|
||||||
>
|
|
||||||
<strong>{server.tag}</strong>
|
|
||||||
<small>{server.type} / {server.server}:{server.server_port}</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
|
|
||||||
Apply selected gateway route
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="panel details">
|
<RuntimePanel
|
||||||
<div className="section-title">
|
state={state}
|
||||||
<span>3</span>
|
log={log}
|
||||||
<h2>Gateway runtime</h2>
|
busy={busy}
|
||||||
</div>
|
onStop={stopSingbox}
|
||||||
|
onRestart={restartSingbox}
|
||||||
<dl>
|
onClear={clearConfig}
|
||||||
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
|
/>
|
||||||
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
|
|
||||||
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
|
|
||||||
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="route-card">
|
|
||||||
<span>Routing policy</span>
|
|
||||||
<p>private IP -> direct</p>
|
|
||||||
<p>geoip-ru/geosite-category-ru -> direct</p>
|
|
||||||
<p>everything else -> selected VPN outbound</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="logs">
|
|
||||||
{log.length === 0 && <p>Waiting for actions...</p>}
|
|
||||||
{log.map((entry) => (
|
|
||||||
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel rules-panel">
|
<RulesPanel
|
||||||
<div className="rules-header">
|
rules={customRules}
|
||||||
<div className="section-title">
|
saveStatus={rulesSaveStatus}
|
||||||
<span>4</span>
|
busy={busy}
|
||||||
<h2>Routing lists</h2>
|
onAdd={addRule}
|
||||||
</div>
|
onAddTemplate={addRuleFromTemplate}
|
||||||
<div className="rules-actions">
|
onUpdate={updateRule}
|
||||||
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
|
onRemove={removeRule}
|
||||||
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
|
onSaveNow={saveRulesNow}
|
||||||
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
|
onReorder={reorderRules}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="rules-note">
|
<LogsPanel />
|
||||||
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="rule-grid">
|
<ConfigViewer open={configOpen} onClose={() => setConfigOpen(false)} />
|
||||||
{customRules.length === 0 && (
|
|
||||||
<div className="empty rule-empty">
|
|
||||||
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{customRules.map((rule) => (
|
|
||||||
<article className="rule-card" key={rule.id}>
|
|
||||||
<div className="rule-top">
|
|
||||||
<input
|
|
||||||
value={rule.name}
|
|
||||||
onChange={(event) => updateRule(rule.id, { name: event.target.value })}
|
|
||||||
placeholder="Название списка"
|
|
||||||
/>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rule.enabled}
|
|
||||||
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
enabled
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Route to</span>
|
|
||||||
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}>
|
|
||||||
<option value="direct">direct</option>
|
|
||||||
<option value="vpn">vpn</option>
|
|
||||||
<option value="block">block</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="rule-fields">
|
|
||||||
<label className="field">
|
|
||||||
<span>Domains exact</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domains)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
|
|
||||||
placeholder="riotgames.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Domain suffixes</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.domainSuffixes)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
|
||||||
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>IP CIDR</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ipCidrs)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
|
|
||||||
placeholder="104.160.128.0/19"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Ports</span>
|
|
||||||
<textarea
|
|
||||||
value={listToText(rule.ports)}
|
|
||||||
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
|
|
||||||
placeholder={'5000\n5223'}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rule-footer">
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('tcp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
|
||||||
updateRule(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
tcp
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(rule.networks || []).includes('udp')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const set = new Set(rule.networks || []);
|
|
||||||
event.target.checked ? set.add('udp') : set.delete('udp');
|
|
||||||
updateRule(rule.id, { networks: Array.from(set) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
udp
|
|
||||||
</label>
|
|
||||||
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/web/api.js
Normal file
33
src/web/api.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
async function request(url, options = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || (data && data.success === false)) {
|
||||||
|
throw new Error(data?.error || `Запрос ${url} завершился ошибкой ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
state: () => request('/api/state'),
|
||||||
|
config: () => request('/api/config'),
|
||||||
|
rules: {
|
||||||
|
get: () => request('/api/rules'),
|
||||||
|
save: (rules) => request('/api/rules', { method: 'PUT', body: JSON.stringify({ rules }) }),
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
fetch: (url) => request('/api/subscription/fetch', { method: 'POST', body: JSON.stringify({ url }) }),
|
||||||
|
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
apply: (selectedTag) => request('/api/apply', { method: 'POST', body: JSON.stringify({ selectedTag }) }),
|
||||||
|
singbox: {
|
||||||
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
clear: () => request('/api/singbox/clear', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
63
src/web/components/ConfigViewer.jsx
Normal file
63
src/web/components/ConfigViewer.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export function ConfigViewer({ open, onClose }) {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.config()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setConfig(data.config);
|
||||||
|
})
|
||||||
|
.catch((err) => !cancelled && setError(err.message));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const text = config ? JSON.stringify(config, null, 2) : '';
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sing-box-config.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Текущий конфиг sing-box</h3>
|
||||||
|
<div className="rules-actions">
|
||||||
|
<button className="ghost-button" type="button" disabled={!config} onClick={copy}>
|
||||||
|
Скопировать
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" disabled={!config} onClick={download}>
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button solid" type="button" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
{!error && !config && <p>Конфиг ещё не сгенерирован.</p>}
|
||||||
|
{config && <pre className="config-view">{text}</pre>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/web/components/LogsPanel.jsx
Normal file
75
src/web/components/LogsPanel.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { formatTime } from '../utils/format.js';
|
||||||
|
|
||||||
|
export function LogsPanel() {
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const pausedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pausedRef.current = paused;
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource('/api/logs/stream');
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
if (pausedRef.current) return;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(event.data);
|
||||||
|
setEntries((prev) => {
|
||||||
|
const next = [...prev, entry];
|
||||||
|
if (next.length > 500) next.splice(0, next.length - 500);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
// EventSource сам делает реконнект
|
||||||
|
};
|
||||||
|
return () => source.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || !containerRef.current) return;
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}, [entries, paused]);
|
||||||
|
|
||||||
|
const filtered = entries.filter((entry) => filter === 'all' || entry.level === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel logs-panel">
|
||||||
|
<div className="rules-header">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>5</span>
|
||||||
|
<h2>Логи sing-box</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rules-actions">
|
||||||
|
<select value={filter} onChange={(event) => setFilter(event.target.value)}>
|
||||||
|
<option value="all">все уровни</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="error">error</option>
|
||||||
|
</select>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setPaused((p) => !p)}>
|
||||||
|
{paused ? 'Возобновить' : 'Пауза'}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setEntries([])}>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={containerRef} className="logs-stream">
|
||||||
|
{filtered.length === 0 && <p className="empty">Логов пока нет.</p>}
|
||||||
|
{filtered.map((entry, index) => (
|
||||||
|
<p key={`${entry.ts}-${index}`} className={`log-line log-${entry.level}`}>
|
||||||
|
<span className="log-time">{formatTime(entry.ts)}</span>
|
||||||
|
<span className="log-level">{entry.level}</span>
|
||||||
|
<span className="log-text">{entry.line}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/web/components/RuleCard.jsx
Normal file
127
src/web/components/RuleCard.jsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { ruleErrors, hasErrors } from '../utils/validation.js';
|
||||||
|
|
||||||
|
function listToText(value) {
|
||||||
|
return Array.isArray(value) ? value.join('\n') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToList(value) {
|
||||||
|
return value
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleCard({ rule, index, total, onUpdate, onRemove }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
};
|
||||||
|
const errors = ruleErrors(rule);
|
||||||
|
const errored = hasErrors(errors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article ref={setNodeRef} style={style} className={errored ? 'rule-card invalid' : 'rule-card'}>
|
||||||
|
<div className="rule-top">
|
||||||
|
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">
|
||||||
|
⠿ #{index + 1}/{total}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={rule.name}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { name: event.target.value })}
|
||||||
|
placeholder="Название списка"
|
||||||
|
/>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.enabled}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
включено
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Outbound</span>
|
||||||
|
<select value={rule.outbound} onChange={(event) => onUpdate(rule.id, { outbound: event.target.value })}>
|
||||||
|
<option value="direct">direct (напрямую)</option>
|
||||||
|
<option value="vpn">vpn (через выбранный сервер)</option>
|
||||||
|
<option value="block">block (заблокировать)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="rule-fields">
|
||||||
|
<label className={errors.domains.length ? 'field has-error' : 'field'}>
|
||||||
|
<span>Домены (точное совпадение)</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.domains)}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { domains: textToList(event.target.value) })}
|
||||||
|
placeholder="riotgames.com"
|
||||||
|
/>
|
||||||
|
{errors.domains.length > 0 && <small className="error">Невалидно: {errors.domains.join(', ')}</small>}
|
||||||
|
</label>
|
||||||
|
<label className={errors.domainSuffixes.length ? 'field has-error' : 'field'}>
|
||||||
|
<span>Суффиксы доменов</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.domainSuffixes)}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { domainSuffixes: textToList(event.target.value) })}
|
||||||
|
placeholder={'leagueoflegends.com\nriotcdn.net'}
|
||||||
|
/>
|
||||||
|
{errors.domainSuffixes.length > 0 && <small className="error">Невалидно: {errors.domainSuffixes.join(', ')}</small>}
|
||||||
|
</label>
|
||||||
|
<label className={errors.ipCidrs.length ? 'field has-error' : 'field'}>
|
||||||
|
<span>IP CIDR</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.ipCidrs)}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { ipCidrs: textToList(event.target.value) })}
|
||||||
|
placeholder="104.160.128.0/19"
|
||||||
|
/>
|
||||||
|
{errors.ipCidrs.length > 0 && <small className="error">Невалидно: {errors.ipCidrs.join(', ')}</small>}
|
||||||
|
</label>
|
||||||
|
<label className={errors.ports.length ? 'field has-error' : 'field'}>
|
||||||
|
<span>Порты</span>
|
||||||
|
<textarea
|
||||||
|
value={listToText(rule.ports)}
|
||||||
|
onChange={(event) => onUpdate(rule.id, { ports: textToList(event.target.value) })}
|
||||||
|
placeholder={'5000\n5223'}
|
||||||
|
/>
|
||||||
|
{errors.ports.length > 0 && <small className="error">Невалидно: {errors.ports.join(', ')}</small>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rule-footer">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(rule.networks || []).includes('tcp')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const set = new Set(rule.networks || []);
|
||||||
|
event.target.checked ? set.add('tcp') : set.delete('tcp');
|
||||||
|
onUpdate(rule.id, { networks: Array.from(set) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
tcp
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(rule.networks || []).includes('udp')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const set = new Set(rule.networks || []);
|
||||||
|
event.target.checked ? set.add('udp') : set.delete('udp');
|
||||||
|
onUpdate(rule.id, { networks: Array.from(set) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
udp
|
||||||
|
</label>
|
||||||
|
<button className="danger-button" type="button" onClick={() => onRemove(rule.id)}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/web/components/RulesPanel.jsx
Normal file
112
src/web/components/RulesPanel.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { RuleCard } from './RuleCard.jsx';
|
||||||
|
import { ruleTemplates } from '../templates/ruleTemplates.js';
|
||||||
|
|
||||||
|
export function RulesPanel({ rules, saveStatus, busy, onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder }) {
|
||||||
|
const [templateKey, setTemplateKey] = useState('');
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleDragEnd(event) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = rules.findIndex((rule) => rule.id === active.id);
|
||||||
|
const newIndex = rules.findIndex((rule) => rule.id === over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
onReorder(arrayMove(rules, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddTemplate() {
|
||||||
|
const tpl = ruleTemplates.find((t) => t.key === templateKey);
|
||||||
|
if (!tpl) return;
|
||||||
|
onAddTemplate(tpl.build());
|
||||||
|
setTemplateKey('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLabel =
|
||||||
|
saveStatus === 'saving'
|
||||||
|
? 'Сохраняем…'
|
||||||
|
: saveStatus === 'pending'
|
||||||
|
? 'Сохранить сейчас'
|
||||||
|
: saveStatus === 'error'
|
||||||
|
? 'Повторить сохранение'
|
||||||
|
: 'Сохранено';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel rules-panel">
|
||||||
|
<div className="rules-header">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>4</span>
|
||||||
|
<h2>Правила маршрутизации</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rules-actions">
|
||||||
|
<select value={templateKey} onChange={(event) => setTemplateKey(event.target.value)}>
|
||||||
|
<option value="">Шаблон…</option>
|
||||||
|
{ruleTemplates.map((tpl) => (
|
||||||
|
<option key={tpl.key} value={tpl.key}>
|
||||||
|
{tpl.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="ghost-button" type="button" disabled={!templateKey} onClick={handleAddTemplate}>
|
||||||
|
Добавить шаблон
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={onAdd}>
|
||||||
|
Пустое правило
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button solid"
|
||||||
|
type="button"
|
||||||
|
disabled={busy || saveStatus === 'saving'}
|
||||||
|
onClick={onSaveNow}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rules-note">
|
||||||
|
Правила применяются <strong>сверху вниз</strong> (first match wins). Перетаскивай за «⠿» чтобы менять порядок.
|
||||||
|
Они вставляются после safety private-direct и до RU-direct. Для игр указывай домены, суффиксы, CIDR или порты.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="rule-grid">
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<div className="empty rule-empty">
|
||||||
|
Нет правил. Добавь шаблон (например «League of Legends → direct») или пустое правило.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<RuleCard
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
index={index}
|
||||||
|
total={rules.length}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/web/components/RuntimePanel.jsx
Normal file
86
src/web/components/RuntimePanel.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||||
|
|
||||||
|
export function RuntimePanel({ state, log, busy, onStop, onRestart, onClear, onShowConfig }) {
|
||||||
|
const userInfo = state?.userInfo;
|
||||||
|
const traffic = userInfo
|
||||||
|
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${
|
||||||
|
userInfo.total ? formatBytes(userInfo.total) : 'без лимита'
|
||||||
|
}`
|
||||||
|
: 'нет данных';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="panel details">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>3</span>
|
||||||
|
<h2>Шлюз</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>UI</dt>
|
||||||
|
<dd>:{state?.port || 3456}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Mixed proxy</dt>
|
||||||
|
<dd>:{state?.proxyPort || 8080}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>TProxy</dt>
|
||||||
|
<dd>:{state?.tproxyPort || 7895}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>RU direct</dt>
|
||||||
|
<dd>{state?.routingRuDirect ? 'включено' : 'выключено'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Трафик</dt>
|
||||||
|
<dd>{traffic}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>sing-box</dt>
|
||||||
|
<dd>
|
||||||
|
{state?.singboxRunning
|
||||||
|
? `работает${state.singboxStartedAt ? ` (${formatRelative(state.singboxStartedAt)})` : ''}`
|
||||||
|
: 'остановлен'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Применено</dt>
|
||||||
|
<dd>{state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="runtime-actions">
|
||||||
|
<button className="ghost-button" type="button" disabled={busy || !state?.singboxRunning} onClick={onStop}>
|
||||||
|
Остановить
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onRestart}>
|
||||||
|
Перезапустить
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onClear}>
|
||||||
|
Сбросить конфиг
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" disabled={!state?.configExists} onClick={onShowConfig}>
|
||||||
|
Показать config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="route-card">
|
||||||
|
<span>Политика роутинга</span>
|
||||||
|
<p>private IP → direct</p>
|
||||||
|
<p>geoip-ru / geosite-category-ru → direct</p>
|
||||||
|
<p>остальное → выбранный VPN outbound</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="logs">
|
||||||
|
{log.length === 0 && <p>Ожидание действий…</p>}
|
||||||
|
{log.map((entry, index) => (
|
||||||
|
<p key={`${entry.time}-${index}`}>
|
||||||
|
<span>{entry.time}</span> {entry.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/web/components/ServerList.jsx
Normal file
32
src/web/components/ServerList.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function ServerList({ servers, selectedTag, setSelectedTag, busy, onApply }) {
|
||||||
|
return (
|
||||||
|
<div className="primary-block">
|
||||||
|
<div className="section-title compact">
|
||||||
|
<span>2</span>
|
||||||
|
<h2>Серверы</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="server-list">
|
||||||
|
{servers.length === 0 && <div className="empty">Серверы ещё не загружены</div>}
|
||||||
|
{servers.map((server) => (
|
||||||
|
<button
|
||||||
|
key={server.tag}
|
||||||
|
className={server.tag === selectedTag ? 'server active' : 'server'}
|
||||||
|
onClick={() => setSelectedTag(server.tag)}
|
||||||
|
>
|
||||||
|
<strong>{server.tag}</strong>
|
||||||
|
<small>
|
||||||
|
{server.type} / {server.server}:{server.server_port}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="button apply" disabled={busy || !selectedTag} onClick={onApply}>
|
||||||
|
Применить выбранный сервер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/web/components/SubscriptionPanel.jsx
Normal file
58
src/web/components/SubscriptionPanel.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function SubscriptionPanel({
|
||||||
|
subscriptionUrl,
|
||||||
|
setSubscriptionUrl,
|
||||||
|
hasSubscription,
|
||||||
|
subscriptionHost,
|
||||||
|
busy,
|
||||||
|
onFetch,
|
||||||
|
onForget,
|
||||||
|
editing,
|
||||||
|
setEditing,
|
||||||
|
}) {
|
||||||
|
const masked = hasSubscription && !editing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="primary-block">
|
||||||
|
<div className="section-title">
|
||||||
|
<span>1</span>
|
||||||
|
<h2>Подписка</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Subscription URL</span>
|
||||||
|
{masked ? (
|
||||||
|
<div className="masked-row">
|
||||||
|
<code className="masked">{subscriptionHost}</code>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setEditing(true)}>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
<button className="danger-button" type="button" disabled={busy} onClick={onForget}>
|
||||||
|
Забыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={subscriptionUrl}
|
||||||
|
onChange={(event) => setSubscriptionUrl(event.target.value)}
|
||||||
|
placeholder="https://provider.example/sub/..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!masked && (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy || !subscriptionUrl}
|
||||||
|
onClick={() => {
|
||||||
|
onFetch();
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? 'Загрузка…' : 'Загрузить серверы'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -438,3 +438,93 @@ dd {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Дополнения для новых компонентов */
|
||||||
|
.primary-block { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.masked-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.masked-row .masked {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
font-family: 'Space Grotesk', monospace;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.runtime-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.runtime-actions button { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
.rule-card.invalid { border-color: rgba(255, 107, 107, 0.55); }
|
||||||
|
.field.has-error textarea, .field.has-error input { border-color: var(--red); }
|
||||||
|
.field small.error { color: var(--red); margin-top: 4px; display: block; }
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'Space Grotesk', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.drag-handle:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.logs-panel .logs-stream {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Space Grotesk', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.log-line { display: flex; gap: 10px; margin: 0 0 2px; }
|
||||||
|
.log-time { color: var(--muted); flex: 0 0 auto; }
|
||||||
|
.log-level { color: var(--amber); flex: 0 0 50px; text-transform: uppercase; font-size: 10px; padding-top: 2px; }
|
||||||
|
.log-error .log-level { color: var(--red); }
|
||||||
|
.log-text { flex: 1; word-break: break-all; }
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(3, 8, 6, 0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
width: min(900px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-header h3 { margin: 0; font-family: 'Space Grotesk', sans-serif; }
|
||||||
|
.config-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-family: 'Space Grotesk', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|||||||
85
src/web/templates/ruleTemplates.js
Normal file
85
src/web/templates/ruleTemplates.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Готовые шаблоны правил роутинга. domains/suffixes/cidr/ports собраны из публичных
|
||||||
|
// reference-конфигов sing-box. Это пресеты «на старт», а не исчерпывающие списки.
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
function id(prefix) {
|
||||||
|
counter += 1;
|
||||||
|
return `${prefix}-${Date.now()}-${counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function template(name, outbound, fields) {
|
||||||
|
return {
|
||||||
|
id: id('tpl'),
|
||||||
|
name,
|
||||||
|
enabled: true,
|
||||||
|
outbound,
|
||||||
|
domains: [],
|
||||||
|
domainSuffixes: [],
|
||||||
|
domainKeywords: [],
|
||||||
|
ipCidrs: [],
|
||||||
|
ports: [],
|
||||||
|
networks: [],
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ruleTemplates = [
|
||||||
|
{
|
||||||
|
key: 'lol-direct',
|
||||||
|
label: 'League of Legends → direct',
|
||||||
|
description: 'Riot/LoL домены и порты — играть напрямую без VPN.',
|
||||||
|
build: () =>
|
||||||
|
template('League of Legends', 'direct', {
|
||||||
|
domainSuffixes: ['leagueoflegends.com', 'riotgames.com', 'riotcdn.net', 'dyn.riotcdn.net'],
|
||||||
|
ports: ['5000', '5223', '5222', '8088'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discord-direct',
|
||||||
|
label: 'Discord/Vesktop → direct',
|
||||||
|
description: 'Discord voice/video и WebSocket напрямую.',
|
||||||
|
build: () =>
|
||||||
|
template('Discord', 'direct', {
|
||||||
|
domainSuffixes: ['discord.com', 'discord.gg', 'discord.media', 'discordapp.com', 'discordapp.net'],
|
||||||
|
ports: ['50000-65535'],
|
||||||
|
networks: ['udp'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'telegram-vpn',
|
||||||
|
label: 'Telegram → VPN',
|
||||||
|
description: 'Telegram через выбранный VPN outbound.',
|
||||||
|
build: () =>
|
||||||
|
template('Telegram', 'vpn', {
|
||||||
|
domainSuffixes: ['telegram.org', 't.me', 'telegram.me', 'telegra.ph', 'tdesktop.com'],
|
||||||
|
ipCidrs: ['149.154.160.0/20', '91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.56.0/22'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'youtube-vpn',
|
||||||
|
label: 'YouTube → VPN',
|
||||||
|
description: 'YouTube/Google Video через VPN.',
|
||||||
|
build: () =>
|
||||||
|
template('YouTube', 'vpn', {
|
||||||
|
domainSuffixes: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com', 'youtube-nocookie.com'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'steam-direct',
|
||||||
|
label: 'Steam → direct',
|
||||||
|
description: 'Загрузка/обновления Steam напрямую.',
|
||||||
|
build: () =>
|
||||||
|
template('Steam', 'direct', {
|
||||||
|
domainSuffixes: ['steampowered.com', 'steamcontent.com', 'steamcommunity.com', 'steamserver.net', 'cm.steampowered.com'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ads-block',
|
||||||
|
label: 'Реклама → block',
|
||||||
|
description: 'Базовый набор рекламных доменов — заблокировать.',
|
||||||
|
build: () =>
|
||||||
|
template('Реклама (block)', 'block', {
|
||||||
|
domainSuffixes: ['doubleclick.net', 'googlesyndication.com', 'googleadservices.com', 'adservice.google.com', 'adnxs.com'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
31
src/web/utils/format.js
Normal file
31
src/web/utils/format.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function formatBytes(value) {
|
||||||
|
if (!value) return '0 Б';
|
||||||
|
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
|
||||||
|
let size = value;
|
||||||
|
let index = 0;
|
||||||
|
while (size >= 1024 && index < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const ts = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(ts)) return '';
|
||||||
|
const diff = Math.max(0, Date.now() - ts);
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec} с назад`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min} мин назад`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr} ч назад`;
|
||||||
|
const days = Math.floor(hr / 24);
|
||||||
|
return `${days} дн назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleTimeString('ru-RU', { hour12: false });
|
||||||
|
}
|
||||||
54
src/web/utils/validation.js
Normal file
54
src/web/utils/validation.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Простые валидаторы для полей правил роутинга. Возвращают массив ошибочных строк.
|
||||||
|
|
||||||
|
const IPV4 = /^((25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(25[0-5]|2[0-4]\d|[01]?\d?\d)$/;
|
||||||
|
const IPV6 = /^[0-9a-f:]+$/i;
|
||||||
|
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
|
||||||
|
|
||||||
|
export function invalidCidrs(values) {
|
||||||
|
return (values || []).filter((value) => !isValidCidr(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidCidr(value) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const [addr, mask] = trimmed.split('/');
|
||||||
|
if (!addr) return false;
|
||||||
|
|
||||||
|
if (IPV4.test(addr)) {
|
||||||
|
if (mask === undefined) return true;
|
||||||
|
const m = Number(mask);
|
||||||
|
return Number.isInteger(m) && m >= 0 && m <= 32;
|
||||||
|
}
|
||||||
|
if (IPV6.test(addr) && addr.includes(':')) {
|
||||||
|
if (mask === undefined) return true;
|
||||||
|
const m = Number(mask);
|
||||||
|
return Number.isInteger(m) && m >= 0 && m <= 128;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidPorts(values) {
|
||||||
|
return (values || []).filter((value) => !isValidPort(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPort(value) {
|
||||||
|
const n = Number.parseInt(String(value).trim(), 10);
|
||||||
|
return Number.isInteger(n) && n > 0 && n <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidDomains(values) {
|
||||||
|
return (values || []).filter((value) => !DOMAIN.test(String(value).trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleErrors(rule) {
|
||||||
|
return {
|
||||||
|
domains: invalidDomains(rule.domains),
|
||||||
|
domainSuffixes: invalidDomains(rule.domainSuffixes),
|
||||||
|
ipCidrs: invalidCidrs(rule.ipCidrs),
|
||||||
|
ports: invalidPorts(rule.ports),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasErrors(errors) {
|
||||||
|
return Object.values(errors).some((arr) => arr.length > 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user