feat: add network module and service for TCP latency measurement and proxy performance
This commit is contained in:
104
.github/copilot-instructions.md
vendored
Normal file
104
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
# Project Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
VPN-Proxy is a self-hosted VPN/proxy management system using **sing-box** as the core proxy engine (VLESS + REALITY TLS). It consists of a NestJS (TypeScript) backend, a vanilla HTML/JS frontend, PowerShell scripts for Windows management, and Docker for deployment. Documentation and UI are in **Russian**.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → NestJS web server (PORT, default 3456)
|
||||
├─ Serves index.html with SSI-like includes (<!-- include "components/X.html" -->)
|
||||
└─ API endpoints in web/api/src/proxy/proxy.controller.ts
|
||||
↓ writes config
|
||||
data/client.json → sing-box binary (PROXY_PORT, default 8080)
|
||||
↓ reload via HTTP to RELOAD_PORT (9090, internal)
|
||||
↓
|
||||
VPN traffic out
|
||||
```
|
||||
|
||||
### Key layers
|
||||
|
||||
| Layer | Location | Notes |
|
||||
|-------|----------|-------|
|
||||
| Frontend | `web/index.html`, `web/components/`, `web/static/` | Tailwind via CDN, no build step |
|
||||
| Backend | `web/api/` | NestJS + TypeScript, minimal deps |
|
||||
| Proxy core | `docker/entrypoint.sh` + sing-box binary | Config in `data/client.json` |
|
||||
| Windows client | `manage.ps1`, `scripts/` | PowerShell 7+ required, runs as Admin |
|
||||
| Docker | `docker-compose.yml` (dev), `docker-compose.server.yml` (prod, host network) |
|
||||
|
||||
### State files (`data/`)
|
||||
|
||||
All JSON. Do not change their structure without updating both backend and JS consumers:
|
||||
- `client.json` — active sing-box config
|
||||
- `subscription.json` — subscription URL + selected server
|
||||
- `fallback.json` — fallback proxy settings
|
||||
- `proxy_enabled.json` — on/off toggle
|
||||
- `start_time.json` — uptime timestamp
|
||||
- `hwid` — immutable device ID (16-char hex), generated once
|
||||
|
||||
## Build and Run
|
||||
|
||||
```powershell
|
||||
# Docker (dev, bridged network)
|
||||
docker compose up -d # starts on localhost:3456 + 8080
|
||||
docker compose up -d --build # rebuild after changes
|
||||
|
||||
# Docker (Linux VPS, host network for UDP)
|
||||
docker compose -f docker-compose.server.yml up -d
|
||||
|
||||
# Logs
|
||||
docker logs -f sing-proxy
|
||||
|
||||
# Windows native (PowerShell 7, Admin)
|
||||
.\manage.ps1
|
||||
|
||||
# Backend dev (local)
|
||||
cd web/api
|
||||
npm install
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
Environment variables: `PORT` (3456), `PROXY_PORT` (8080), `RELOAD_PORT` (9090), `PROXY_BIND_IP` (0.0.0.0).
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code style
|
||||
- **TypeScript**: NestJS conventions — modules, controllers, services. `camelCase` for methods, `PascalCase` for classes
|
||||
- **PowerShell**: `PascalCase` functions (e.g., `Write-Success`, `Manage-ScheduledTask`)
|
||||
- **JSON keys**: `camelCase` (e.g., `serverPort`, `selectedServer`)
|
||||
- **HTML element IDs**: `camelCase` (e.g., `subUrlInput`, `fallbackToggle`)
|
||||
|
||||
### Adding features
|
||||
- New API endpoint → controller in `web/api/src/proxy/proxy.controller.ts` + JS call in `web/static/js/app.js`
|
||||
- Business logic → `web/api/src/proxy/proxy.service.ts`
|
||||
- VLESS config changes → `web/api/src/vless/vless.service.ts`
|
||||
- Persistent state → `web/api/src/storage/storage.service.ts` (JSON file I/O)
|
||||
- Network utilities → `web/api/src/network/network.service.ts`
|
||||
- Windows scripts → `scripts/setup-*.ps1`, shared helpers in `scripts/lib/`
|
||||
|
||||
### Backend module structure
|
||||
```
|
||||
web/api/src/
|
||||
main.ts — Bootstrap & static assets
|
||||
app.module.ts — Root module
|
||||
config/config.ts — Environment configuration
|
||||
storage/ — JSON file persistence + HWID
|
||||
vless/ — VLESS URL parsing + sing-box config generation
|
||||
network/ — TCP latency + proxy performance measurement
|
||||
proxy/ — API controller + business logic service
|
||||
```
|
||||
|
||||
### VLESS handling
|
||||
- Parsing is strict: requires `vless://uuid@host:port?pbk=...&sid=...` format (REALITY params mandatory)
|
||||
- Subscription URLs must be `http://` or `https://` only
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Windows Docker cannot use `network_mode: host`** — UDP (Discord voice, games) won't work in Docker on Windows. Use native sing-box via `manage.ps1` instead.
|
||||
- **Port 9090 is internal only** — used for reload triggers via netcat, never expose externally.
|
||||
- **`hwid` is immutable** — after first generation, changing it requires manual file deletion.
|
||||
- **DOS line endings** — the Dockerfile runs `dos2unix` on shell scripts. Keep this in place.
|
||||
- **sing-box needs a config before starting** — apply config via the web UI first; it won't bootstrap empty.
|
||||
- **No test suite exists** — validate changes manually via Docker.
|
||||
- **NestJS build required** — the Dockerfile runs `npm ci && npm run build` during image build. For local dev use `npm run start:dev`.
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
RUN apk add --no-cache curl ca-certificates tar jq bash coreutils netcat-openbsd nodejs npm dos2unix && update-ca-certificates
|
||||
|
||||
# Автоматическое определение архитектуры и установка sing-box
|
||||
RUN ARCH=$(uname -m) && \
|
||||
@@ -18,6 +18,11 @@ RUN ARCH=$(uname -m) && \
|
||||
COPY --chown=suser:suser docker/entrypoint.sh /app/
|
||||
COPY --chown=suser:suser web/ /app/web/
|
||||
|
||||
# Собираем NestJS бэкенд
|
||||
WORKDIR /app/web/api
|
||||
RUN npm ci && npm run build && npm prune --omit=dev
|
||||
WORKDIR /app
|
||||
|
||||
# Исправляем окончания строк (важно для Windows пользователей) и даем права на запуск
|
||||
RUN dos2unix /app/*.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ 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 &
|
||||
PORT=$PORT PROXY_PORT=$PROXY_PORT node /app/web/api/dist/main.js &
|
||||
WEBUI_PID=$!
|
||||
|
||||
# HTTP Control Server (Simple Netcat loop)
|
||||
|
||||
2
web/api/.gitignore
vendored
Normal file
2
web/api/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
8
web/api/nest-cli.json
Normal file
8
web/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
6294
web/api/package-lock.json
generated
Normal file
6294
web/api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
web/api/package.json
Normal file
25
web/api/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "vpn-proxy-api",
|
||||
"version": "1.0.0",
|
||||
"description": "VPN-Proxy backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
7
web/api/src/app.module.ts
Normal file
7
web/api/src/app.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProxyModule } from './proxy/proxy.module';
|
||||
|
||||
@Module({
|
||||
imports: [ProxyModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
33
web/api/src/config/config.ts
Normal file
33
web/api/src/config/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as path from 'path';
|
||||
|
||||
// After compilation: dist/config/config.js
|
||||
// __dirname = <web/api>/dist/config
|
||||
// Go up: dist/config -> dist -> api -> web -> project root
|
||||
const API_DIR = path.resolve(__dirname, '..', '..');
|
||||
const WEB_DIR = path.resolve(API_DIR, '..');
|
||||
const BASE_DIR = path.resolve(WEB_DIR, '..');
|
||||
const DATA_DIR = path.join(BASE_DIR, 'data');
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3456', 10),
|
||||
proxyPort: parseInt(process.env.PROXY_PORT || '8080', 10),
|
||||
reloadPort: parseInt(process.env.RELOAD_PORT || '9090', 10),
|
||||
proxyBindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
||||
appName: 'VPN-Proxy-Control by Dokril',
|
||||
|
||||
webDir: WEB_DIR,
|
||||
baseDir: BASE_DIR,
|
||||
dataDir: DATA_DIR,
|
||||
configFile: path.join(DATA_DIR, 'client.json'),
|
||||
hwidFile: path.join(DATA_DIR, 'hwid'),
|
||||
subscriptionFile: path.join(DATA_DIR, 'subscription.json'),
|
||||
fallbackFile: path.join(DATA_DIR, 'fallback.json'),
|
||||
proxyEnabledFile: path.join(DATA_DIR, 'proxy_enabled.json'),
|
||||
startTimeFile: path.join(DATA_DIR, 'start_time.json'),
|
||||
|
||||
defaultFallback: {
|
||||
enabled: false,
|
||||
host: '192.168.50.111',
|
||||
port: 8080,
|
||||
},
|
||||
};
|
||||
23
web/api/src/main.ts
Normal file
23
web/api/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as path from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { config } from './config/config';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Serve static files from web/static/
|
||||
app.useStaticAssets(path.join(config.webDir, 'static'), {
|
||||
prefix: '/static/',
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
|
||||
await app.listen(config.port);
|
||||
console.log(`[WebUI] Server started on port ${config.port}`);
|
||||
console.log(
|
||||
`[WebUI] Open http://localhost:${config.port} in your browser`,
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
8
web/api/src/network/network.module.ts
Normal file
8
web/api/src/network/network.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NetworkService } from './network.service';
|
||||
|
||||
@Module({
|
||||
providers: [NetworkService],
|
||||
exports: [NetworkService],
|
||||
})
|
||||
export class NetworkModule {}
|
||||
294
web/api/src/network/network.service.ts
Normal file
294
web/api/src/network/network.service.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as net from 'net';
|
||||
import * as http from 'http';
|
||||
import * as tls from 'tls';
|
||||
import { config } from '../config/config';
|
||||
|
||||
@Injectable()
|
||||
export class NetworkService {
|
||||
measureTcpLatency(
|
||||
host: string,
|
||||
port: number,
|
||||
timeout = 2000,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(timeout);
|
||||
|
||||
socket.on('connect', () => {
|
||||
const latency = Date.now() - start;
|
||||
socket.destroy();
|
||||
resolve(latency);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
socket.destroy();
|
||||
resolve(-1);
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve(-1);
|
||||
});
|
||||
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async measureProxyPerformance(
|
||||
enableSpeedTest = false,
|
||||
): Promise<Record<string, any>> {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// 1. Measure Latency
|
||||
try {
|
||||
const start = Date.now();
|
||||
await this.httpViaProxy('http://www.gstatic.com/generate_204', {
|
||||
headers: { 'User-Agent': 'singbox-test' },
|
||||
timeout: 5000,
|
||||
});
|
||||
result.latency = `${Date.now() - start}ms`;
|
||||
} catch {
|
||||
result.latency = 'Error';
|
||||
}
|
||||
|
||||
// 2. Get Public IP (IPv4)
|
||||
try {
|
||||
const ipRes = await this.httpViaProxy('http://v4.ident.me', {
|
||||
headers: { 'User-Agent': 'curl/7.68.0' },
|
||||
timeout: 5000,
|
||||
});
|
||||
result.ip = ipRes.body.trim();
|
||||
} catch {
|
||||
try {
|
||||
const ipRes = await this.httpViaProxy('http://api.ipify.org', {
|
||||
headers: { 'User-Agent': 'curl/7.68.0' },
|
||||
timeout: 5000,
|
||||
});
|
||||
result.ip = ipRes.body.trim();
|
||||
} catch {
|
||||
result.ip = 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Speed test
|
||||
if (enableSpeedTest) {
|
||||
const testFiles = [
|
||||
{ url: 'https://speedtest.selectel.ru/100MB', sizeMb: 100 },
|
||||
{ url: 'https://speedtest.selectel.ru/1GB', sizeMb: 1000 },
|
||||
];
|
||||
|
||||
let speedMbps = 0;
|
||||
|
||||
for (const testFile of testFiles) {
|
||||
try {
|
||||
console.log(`[WebUI] Testing speed with: ${testFile.url}`);
|
||||
const start = Date.now();
|
||||
const maxBytes = 25 * 1024 * 1024;
|
||||
const minDurationMs = 2000;
|
||||
|
||||
const downloaded = await this.downloadViaProxy(
|
||||
testFile.url,
|
||||
maxBytes,
|
||||
minDurationMs,
|
||||
);
|
||||
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
if (duration > 0.1 && downloaded > 0) {
|
||||
speedMbps =
|
||||
Math.round(
|
||||
((downloaded * 8) / (1000 * 1000) / duration) * 10,
|
||||
) / 10;
|
||||
console.log(
|
||||
`[WebUI] Speed test: downloaded ${(downloaded / (1024 * 1024)).toFixed(1)}MB in ${duration.toFixed(1)}s = ${speedMbps} Mbps`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`[WebUI] Speed test failed for ${testFile.url}: ${e}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.speed = `${speedMbps} Mbps`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Send HTTP request through local HTTP proxy (for http:// URLs) */
|
||||
private httpViaProxy(
|
||||
targetUrl: string,
|
||||
options: {
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(targetUrl);
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: config.proxyPort,
|
||||
path: targetUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Host: parsed.host,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
timeout: options.timeout || 5000,
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||||
res.on('end', () =>
|
||||
resolve({ statusCode: res.statusCode, body }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Download through proxy via CONNECT tunnel (for https:// URLs) */
|
||||
private downloadViaProxy(
|
||||
targetUrl: string,
|
||||
maxBytes: number,
|
||||
minDurationMs: number,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(targetUrl);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
|
||||
if (!isHttps) {
|
||||
// HTTP download through proxy
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: config.proxyPort,
|
||||
path: targetUrl,
|
||||
method: 'GET',
|
||||
headers: { Host: parsed.host },
|
||||
timeout: 30000,
|
||||
},
|
||||
(res) => {
|
||||
let downloaded = 0;
|
||||
const start = Date.now();
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
downloaded += chunk.length;
|
||||
const elapsed = Date.now() - start;
|
||||
if (
|
||||
downloaded >= maxBytes ||
|
||||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
|
||||
) {
|
||||
res.destroy();
|
||||
resolve(downloaded);
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => resolve(downloaded));
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTPS: use CONNECT tunnel through proxy
|
||||
const proxyReq = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: config.proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: `${parsed.hostname}:${parsed.port || 443}`,
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const finish = (bytes: number) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(bytes);
|
||||
}
|
||||
};
|
||||
const fail = (err: Error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
proxyReq.on('connect', (proxyRes, socket) => {
|
||||
if (proxyRes.statusCode !== 200) {
|
||||
socket.destroy();
|
||||
fail(new Error(`CONNECT failed: ${proxyRes.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const tlsSocket = tls.connect(
|
||||
{ socket, servername: parsed.hostname },
|
||||
() => {
|
||||
// Send raw HTTP request over TLS tunnel
|
||||
tlsSocket.write(
|
||||
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\n` +
|
||||
`Host: ${parsed.hostname}\r\n` +
|
||||
`User-Agent: singbox-speedtest\r\n` +
|
||||
`Connection: close\r\n\r\n`,
|
||||
);
|
||||
|
||||
let downloaded = 0;
|
||||
let headersParsed = false;
|
||||
let headerBuffer = '';
|
||||
const start = Date.now();
|
||||
|
||||
tlsSocket.on('data', (chunk: Buffer) => {
|
||||
if (!headersParsed) {
|
||||
headerBuffer += chunk.toString('binary');
|
||||
const headerEnd = headerBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd !== -1) {
|
||||
headersParsed = true;
|
||||
const bodyStart = headerEnd + 4;
|
||||
downloaded +=
|
||||
Buffer.byteLength(headerBuffer.slice(bodyStart), 'binary');
|
||||
}
|
||||
} else {
|
||||
downloaded += chunk.length;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
if (
|
||||
downloaded >= maxBytes ||
|
||||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
|
||||
) {
|
||||
tlsSocket.destroy();
|
||||
finish(downloaded);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => finish(downloaded));
|
||||
tlsSocket.on('error', fail);
|
||||
},
|
||||
);
|
||||
|
||||
tlsSocket.on('error', fail);
|
||||
});
|
||||
|
||||
proxyReq.on('error', fail);
|
||||
proxyReq.setTimeout(30000, () => {
|
||||
proxyReq.destroy();
|
||||
fail(new Error('CONNECT timeout'));
|
||||
});
|
||||
proxyReq.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
315
web/api/src/proxy/proxy.controller.ts
Normal file
315
web/api/src/proxy/proxy.controller.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
Res,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { config } from '../config/config';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { VlessService } from '../vless/vless.service';
|
||||
import { NetworkService } from '../network/network.service';
|
||||
import { ProxyService } from './proxy.service';
|
||||
|
||||
@Controller()
|
||||
export class ProxyController {
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly vless: VlessService,
|
||||
private readonly network: NetworkService,
|
||||
private readonly proxyService: ProxyService,
|
||||
) {}
|
||||
|
||||
// --- Static / Index ---
|
||||
|
||||
@Get('/')
|
||||
serveIndex(@Res() res: Response) {
|
||||
const indexPath = path.join(config.webDir, 'index.html');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
return res.status(404).send('index.html not found');
|
||||
}
|
||||
|
||||
try {
|
||||
let content = fs.readFileSync(indexPath, 'utf-8');
|
||||
const normalizedWebDir = path.resolve(config.webDir);
|
||||
|
||||
content = content.replace(
|
||||
/<!-- include "([^"]+)" -->/g,
|
||||
(_, includePath: string) => {
|
||||
const fullPath = path.resolve(normalizedWebDir, includePath);
|
||||
// Path traversal protection
|
||||
if (
|
||||
!fullPath.startsWith(normalizedWebDir + path.sep) &&
|
||||
fullPath !== normalizedWebDir
|
||||
) {
|
||||
return `<!-- Include failed: ${includePath} -->`;
|
||||
}
|
||||
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
|
||||
return `<!-- Include failed: ${includePath} -->`;
|
||||
}
|
||||
return fs.readFileSync(fullPath, 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
res.type('html').send(content);
|
||||
} catch (e) {
|
||||
res.status(500).send(`Error serving index: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Status ---
|
||||
|
||||
@Get('/status')
|
||||
getStatus() {
|
||||
const configExists = this.storage.configFileExists();
|
||||
let currentTag: string | null = null;
|
||||
let currentServer: string | null = null;
|
||||
const proxyEnabled = this.storage.loadProxyEnabled();
|
||||
|
||||
if (configExists) {
|
||||
try {
|
||||
const cfg = this.storage.readConfigFile();
|
||||
for (const outbound of cfg.outbounds || []) {
|
||||
if (outbound.type === 'vless') {
|
||||
currentTag = outbound.tag || 'unknown';
|
||||
currentServer = outbound.server || 'unknown';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
active: configExists && proxyEnabled,
|
||||
tag: currentTag,
|
||||
server: currentServer,
|
||||
proxyPort: config.proxyPort,
|
||||
proxyEnabled,
|
||||
startTime:
|
||||
configExists && proxyEnabled ? this.storage.loadStartTime() : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Subscription ---
|
||||
|
||||
@Get('/subscription')
|
||||
getSubscription() {
|
||||
const sub = this.storage.loadSubscription();
|
||||
if (sub) {
|
||||
return {
|
||||
saved: true,
|
||||
url: sub.url,
|
||||
selectedServer: sub.selectedServer,
|
||||
userInfo: sub.userInfo,
|
||||
};
|
||||
}
|
||||
return { saved: false };
|
||||
}
|
||||
|
||||
// --- Connection Test ---
|
||||
|
||||
@Get('/test-connection')
|
||||
async testConnection(@Query('speed') speed?: string) {
|
||||
const enableSpeed = speed?.toLowerCase() === 'true';
|
||||
return this.network.measureProxyPerformance(enableSpeed);
|
||||
}
|
||||
|
||||
// --- Fallback Config ---
|
||||
|
||||
@Get('/fallback-config')
|
||||
getFallbackConfig() {
|
||||
const fallback = this.storage.loadFallbackConfig();
|
||||
return {
|
||||
enabled: fallback.enabled ?? false,
|
||||
host: fallback.host ?? '192.168.50.111',
|
||||
port: fallback.port ?? 8080,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/fallback-config')
|
||||
saveFallbackConfig(@Body() body: any) {
|
||||
const enabled = body.enabled ?? false;
|
||||
const host = (body.host || '').trim();
|
||||
const port = parseInt(body.port, 10) || 8080;
|
||||
|
||||
if (enabled && !host) {
|
||||
throw new HttpException(
|
||||
{ success: false, error: 'Host is required' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
this.storage.saveFallbackConfig(enabled, host, port);
|
||||
const regenerated = this.proxyService.regenerateCurrentConfig();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Fallback config saved',
|
||||
regenerated,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Active Proxy ---
|
||||
|
||||
@Get('/active-proxy')
|
||||
async getActiveProxy() {
|
||||
return this.proxyService.getActiveProxy();
|
||||
}
|
||||
|
||||
// --- Proxy Enabled ---
|
||||
|
||||
@Get('/proxy-enabled')
|
||||
getProxyEnabled() {
|
||||
return { enabled: this.storage.loadProxyEnabled() };
|
||||
}
|
||||
|
||||
@Post('/proxy-enabled')
|
||||
setProxyEnabled(@Body() body: any) {
|
||||
const enabled = body.enabled ?? true;
|
||||
this.storage.saveProxyEnabled(enabled);
|
||||
|
||||
let regenerated = false;
|
||||
|
||||
if (enabled) {
|
||||
regenerated = this.proxyService.regenerateCurrentConfig();
|
||||
if (regenerated) {
|
||||
this.storage.saveStartTime(Date.now() / 1000);
|
||||
}
|
||||
} else {
|
||||
regenerated = this.proxyService.applyDirectConfig();
|
||||
this.storage.saveStartTime(0);
|
||||
}
|
||||
|
||||
return { success: true, enabled, regenerated };
|
||||
}
|
||||
|
||||
// --- Apply VLESS URL ---
|
||||
|
||||
@Post('/apply')
|
||||
applyConfig(@Body() body: any) {
|
||||
const url = (body.url || '').trim();
|
||||
|
||||
if (!url) {
|
||||
throw new HttpException(
|
||||
{ success: false, error: 'URL не указан' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (!url.startsWith('vless://')) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Неверный формат. Поддерживаются только vless:// ссылки',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
let vlessParams;
|
||||
try {
|
||||
vlessParams = this.vless.parseVlessUrl(url);
|
||||
} catch (e: any) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: `Ошибка парсинга URL: ${e.message}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const cfg = this.vless.generateVlessConfig(vlessParams);
|
||||
this.storage.writeConfigFile(cfg);
|
||||
this.proxyService.triggerReload();
|
||||
this.storage.saveStartTime(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Конфигурация '${vlessParams.tag}' успешно применена!`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fetch Subscription ---
|
||||
|
||||
@Post('/fetch-subscription')
|
||||
async fetchSubscription(@Body() body: any) {
|
||||
const url = (body.url || '').trim();
|
||||
|
||||
if (!url) {
|
||||
throw new HttpException(
|
||||
{ success: false, error: 'URL подписки не указан' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.proxyService.fetchSubscriptionFromUrl(url);
|
||||
|
||||
if (!result.success) {
|
||||
throw new HttpException(result, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Apply Subscription ---
|
||||
|
||||
@Post('/apply-subscription')
|
||||
applySubscription(@Body() body: any) {
|
||||
const subConfig = body.config;
|
||||
const selectedTag = body.selectedServer;
|
||||
const subUrl = body.subUrl;
|
||||
const userInfo = body.userInfo;
|
||||
|
||||
if (!subConfig) {
|
||||
throw new HttpException(
|
||||
{ success: false, error: 'Конфигурация не указана' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedTag) {
|
||||
throw new HttpException(
|
||||
{ success: false, error: 'Сервер не выбран' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const result = this.proxyService.applySubscriptionConfig(
|
||||
subConfig,
|
||||
selectedTag,
|
||||
subUrl,
|
||||
userInfo,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new HttpException(result, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Ping Target ---
|
||||
|
||||
@Post('/ping-target')
|
||||
async pingTarget(@Body() body: any) {
|
||||
const server = body.server;
|
||||
const port = parseInt(body.port, 10) || 443;
|
||||
|
||||
if (!server) {
|
||||
throw new HttpException(
|
||||
{ error: 'No server specified' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const latency = await this.network.measureTcpLatency(server, port);
|
||||
return { latency };
|
||||
}
|
||||
}
|
||||
13
web/api/src/proxy/proxy.module.ts
Normal file
13
web/api/src/proxy/proxy.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { VlessModule } from '../vless/vless.module';
|
||||
import { NetworkModule } from '../network/network.module';
|
||||
import { ProxyController } from './proxy.controller';
|
||||
import { ProxyService } from './proxy.service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, VlessModule, NetworkModule],
|
||||
controllers: [ProxyController],
|
||||
providers: [ProxyService],
|
||||
})
|
||||
export class ProxyModule {}
|
||||
507
web/api/src/proxy/proxy.service.ts
Normal file
507
web/api/src/proxy/proxy.service.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { config } from '../config/config';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { VlessService } from '../vless/vless.service';
|
||||
import { NetworkService } from '../network/network.service';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService {
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly vless: VlessService,
|
||||
private readonly network: NetworkService,
|
||||
) {}
|
||||
|
||||
triggerReload(): void {
|
||||
try {
|
||||
const req = http.get(
|
||||
`http://127.0.0.1:${config.reloadPort}/reload`,
|
||||
{ timeout: 3000 },
|
||||
() => {},
|
||||
);
|
||||
req.on('error', () => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Regenerate current config with updated fallback settings */
|
||||
regenerateCurrentConfig(): boolean {
|
||||
if (!this.storage.configFileExists()) return false;
|
||||
|
||||
try {
|
||||
const cfg = this.storage.readConfigFile();
|
||||
const outbounds: any[] = cfg.outbounds || [];
|
||||
|
||||
let vpnOutbound: any = null;
|
||||
const utilityOutbounds: any[] = [];
|
||||
|
||||
for (const outbound of outbounds) {
|
||||
if (
|
||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
||||
outbound.type,
|
||||
)
|
||||
) {
|
||||
vpnOutbound = outbound;
|
||||
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
|
||||
utilityOutbounds.push(outbound);
|
||||
}
|
||||
}
|
||||
|
||||
if (!vpnOutbound) return false;
|
||||
|
||||
const selectedTag = vpnOutbound.tag;
|
||||
const fallback = this.storage.loadFallbackConfig();
|
||||
const fallbackEnabled = fallback.enabled;
|
||||
const fallbackHost = fallback.host;
|
||||
const fallbackPort = fallback.port;
|
||||
|
||||
const finalOutbounds: any[] = [];
|
||||
let finalTag = selectedTag;
|
||||
|
||||
if (fallbackEnabled && fallbackHost) {
|
||||
finalOutbounds.push({
|
||||
type: 'urltest',
|
||||
tag: 'auto-select',
|
||||
outbounds: ['fallback-proxy', selectedTag],
|
||||
url: 'http://www.gstatic.com/generate_204',
|
||||
interval: '30s',
|
||||
tolerance: 9999,
|
||||
});
|
||||
|
||||
finalOutbounds.push({
|
||||
type: 'http',
|
||||
tag: 'fallback-proxy',
|
||||
server: fallbackHost,
|
||||
server_port: fallbackPort,
|
||||
});
|
||||
|
||||
finalTag = 'auto-select';
|
||||
}
|
||||
|
||||
finalOutbounds.push(vpnOutbound);
|
||||
finalOutbounds.push(...utilityOutbounds);
|
||||
|
||||
cfg.outbounds = finalOutbounds;
|
||||
cfg.route.final = finalTag;
|
||||
|
||||
this.storage.writeConfigFile(cfg);
|
||||
this.triggerReload();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[WebUI] Failed to regenerate config: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate and apply direct config (bypass proxy) */
|
||||
applyDirectConfig(): boolean {
|
||||
try {
|
||||
const directCfg = this.vless.generateDirectConfig();
|
||||
this.storage.writeConfigFile(directCfg);
|
||||
this.triggerReload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[WebUI] Failed to generate direct config: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get active proxy chain info */
|
||||
async getActiveProxy(): Promise<Record<string, any>> {
|
||||
const result: Record<string, any> = {
|
||||
configured: false,
|
||||
fallbackEnabled: false,
|
||||
fallbackHost: null,
|
||||
vpnTag: null,
|
||||
vpnServer: null,
|
||||
activeOutbound: null,
|
||||
};
|
||||
|
||||
if (!this.storage.configFileExists()) return result;
|
||||
|
||||
try {
|
||||
const cfg = this.storage.readConfigFile();
|
||||
const outbounds: any[] = cfg.outbounds || [];
|
||||
const routeFinal = cfg.route?.final;
|
||||
|
||||
result.configured = true;
|
||||
|
||||
for (const outbound of outbounds) {
|
||||
const outType = outbound.type;
|
||||
|
||||
if (outType === 'urltest') {
|
||||
result.fallbackEnabled = true;
|
||||
} else if (outType === 'http' && outbound.tag === 'fallback-proxy') {
|
||||
result.fallbackHost = `${outbound.server}:${outbound.server_port}`;
|
||||
} else if (
|
||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
||||
outType,
|
||||
)
|
||||
) {
|
||||
result.vpnTag = outbound.tag;
|
||||
result.vpnServer = outbound.server;
|
||||
}
|
||||
}
|
||||
|
||||
result.activeOutbound = routeFinal;
|
||||
|
||||
// Check fallback proxy reachability
|
||||
if (result.fallbackEnabled && result.fallbackHost) {
|
||||
try {
|
||||
const [host, portStr] = result.fallbackHost.split(':');
|
||||
const latency = await this.network.measureTcpLatency(
|
||||
host,
|
||||
parseInt(portStr, 10),
|
||||
1000,
|
||||
);
|
||||
result.fallbackReachable = latency > 0;
|
||||
result.fallbackLatency = latency > 0 ? latency : null;
|
||||
} catch {
|
||||
result.fallbackReachable = false;
|
||||
result.fallbackLatency = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
result.error = String(e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fetch subscription from URL */
|
||||
async fetchSubscriptionFromUrl(url: string): Promise<Record<string, any>> {
|
||||
// Validate URL scheme (SSRF protection)
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Недопустимый протокол (только http/https)',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: 'Некорректный URL' };
|
||||
}
|
||||
|
||||
const sysInfo = this.storage.getSystemInfo();
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'singbox',
|
||||
'x-hwid': this.storage.getHwid(),
|
||||
'x-device-os': sysInfo.os,
|
||||
'x-ver-os': sysInfo.version,
|
||||
'x-device-model': config.appName,
|
||||
};
|
||||
|
||||
let configText: string;
|
||||
let userInfo: Record<string, number> = {};
|
||||
|
||||
try {
|
||||
const response = await this.fetchUrl(url, headers);
|
||||
|
||||
configText = response.body;
|
||||
|
||||
// Parse subscription-userinfo header
|
||||
const userInfoHeader =
|
||||
response.headers['subscription-userinfo'] || '';
|
||||
if (userInfoHeader) {
|
||||
const parts = (
|
||||
Array.isArray(userInfoHeader)
|
||||
? userInfoHeader[0]
|
||||
: userInfoHeader
|
||||
).split(';');
|
||||
for (const part of parts) {
|
||||
if (part.includes('=')) {
|
||||
const [key, value] = part.trim().split('=', 2);
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num)) {
|
||||
userInfo[key] = num;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e.message || String(e);
|
||||
if (msg.includes('HTTP Error')) {
|
||||
return { success: false, error: `Ошибка HTTP: ${msg}` };
|
||||
}
|
||||
return { success: false, error: `Ошибка подключения: ${msg}` };
|
||||
}
|
||||
|
||||
// Try to parse as JSON first
|
||||
let parsedConfig: any = null;
|
||||
|
||||
try {
|
||||
parsedConfig = JSON.parse(configText);
|
||||
} catch {
|
||||
// Not JSON — try Base64 decode or plain VLESS links
|
||||
let content = configText.trim();
|
||||
|
||||
try {
|
||||
if (/^[A-Za-z0-9+/=\s]+$/.test(content)) {
|
||||
const decoded = Buffer.from(content, 'base64').toString('utf-8');
|
||||
content = decoded;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Parse VLESS links
|
||||
const lines = content.split('\n');
|
||||
const vlessLinks = lines
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('vless://'));
|
||||
|
||||
if (vlessLinks.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Не найдены VLESS ссылки в ответе',
|
||||
};
|
||||
}
|
||||
|
||||
const outbounds: any[] = [];
|
||||
for (const link of vlessLinks) {
|
||||
try {
|
||||
const params = this.vless.parseVlessUrl(link);
|
||||
outbounds.push({
|
||||
type: 'vless',
|
||||
tag: params.tag,
|
||||
server: params.server,
|
||||
server_port: params.server_port,
|
||||
uuid: params.uuid,
|
||||
flow: params.flow,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: params.server_name,
|
||||
utls: {
|
||||
enabled: true,
|
||||
fingerprint: params.fingerprint,
|
||||
},
|
||||
reality: {
|
||||
enabled: true,
|
||||
public_key: params.public_key,
|
||||
short_id: params.short_id,
|
||||
},
|
||||
},
|
||||
packet_encoding: 'xudp',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[WebUI] Failed to parse VLESS link: ${e}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (outbounds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Не удалось распарсить VLESS ссылки',
|
||||
};
|
||||
}
|
||||
|
||||
parsedConfig = { outbounds };
|
||||
}
|
||||
|
||||
// Extract servers
|
||||
const outbounds: any[] = parsedConfig.outbounds || [];
|
||||
const servers: any[] = [];
|
||||
|
||||
for (const outbound of outbounds) {
|
||||
if (
|
||||
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
|
||||
outbound.type,
|
||||
)
|
||||
) {
|
||||
servers.push({
|
||||
tag: outbound.tag || 'unknown',
|
||||
type: outbound.type,
|
||||
server: outbound.server || 'unknown',
|
||||
server_port: outbound.server_port || 443,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Серверы не найдены в подписке',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers,
|
||||
config: parsedConfig,
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply subscription config with selected server */
|
||||
applySubscriptionConfig(
|
||||
subConfig: any,
|
||||
selectedTag: string,
|
||||
subUrl?: string,
|
||||
userInfoData?: any,
|
||||
): { success: boolean; message?: string; error?: string } {
|
||||
const outbounds: any[] = subConfig.outbounds || [];
|
||||
const newOutbounds: any[] = [];
|
||||
let selectedOutbound: any = null;
|
||||
|
||||
for (const outbound of outbounds) {
|
||||
if (outbound.tag === selectedTag) {
|
||||
selectedOutbound = outbound;
|
||||
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
|
||||
newOutbounds.push(outbound);
|
||||
}
|
||||
// Skip selector type
|
||||
}
|
||||
|
||||
if (!selectedOutbound) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Сервер '${selectedTag}' не найден`,
|
||||
};
|
||||
}
|
||||
|
||||
// Load fallback configuration
|
||||
const fallback = this.storage.loadFallbackConfig();
|
||||
const finalOutbounds: any[] = [];
|
||||
let finalTag = selectedTag;
|
||||
|
||||
if (fallback.enabled && fallback.host) {
|
||||
finalOutbounds.push({
|
||||
type: 'urltest',
|
||||
tag: 'auto-select',
|
||||
outbounds: ['fallback-proxy', selectedTag],
|
||||
url: 'http://www.gstatic.com/generate_204',
|
||||
interval: '30s',
|
||||
tolerance: 9999,
|
||||
});
|
||||
|
||||
finalOutbounds.push({
|
||||
type: 'http',
|
||||
tag: 'fallback-proxy',
|
||||
server: fallback.host,
|
||||
server_port: fallback.port,
|
||||
});
|
||||
|
||||
finalTag = 'auto-select';
|
||||
}
|
||||
|
||||
// Add selected VPN server
|
||||
finalOutbounds.push(selectedOutbound);
|
||||
// Add utility outbounds
|
||||
finalOutbounds.push(...newOutbounds);
|
||||
|
||||
// Build config
|
||||
subConfig.dns = { independent_cache: true };
|
||||
delete subConfig.platform;
|
||||
delete subConfig.experimental;
|
||||
|
||||
subConfig.inbounds = [
|
||||
{
|
||||
tag: 'mixed-in',
|
||||
type: 'mixed',
|
||||
sniff: true,
|
||||
users: [],
|
||||
listen: config.proxyBindIp,
|
||||
listen_port: config.proxyPort,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
];
|
||||
|
||||
subConfig.outbounds = finalOutbounds;
|
||||
subConfig.route = {
|
||||
final: finalTag,
|
||||
auto_detect_interface: true,
|
||||
};
|
||||
|
||||
// Write config
|
||||
this.storage.writeConfigFile(subConfig);
|
||||
|
||||
// Save subscription for persistence
|
||||
if (subUrl) {
|
||||
this.storage.saveSubscription(subUrl, selectedTag, userInfoData);
|
||||
}
|
||||
|
||||
// Reload sing-box
|
||||
this.triggerReload();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Сервер '${selectedTag}' успешно применён!`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch a URL directly (not through proxy) with redirect support */
|
||||
private fetchUrl(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
maxRedirects = 5,
|
||||
): Promise<{
|
||||
body: string;
|
||||
headers: Record<string, string | string[]>;
|
||||
}> {
|
||||
if (maxRedirects <= 0) {
|
||||
return Promise.reject(new Error('Too many redirects'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
reject(new Error('Invalid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = mod.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port:
|
||||
parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: 15000,
|
||||
},
|
||||
(res) => {
|
||||
// Handle redirects
|
||||
if (
|
||||
res.statusCode >= 300 &&
|
||||
res.statusCode < 400 &&
|
||||
res.headers.location
|
||||
) {
|
||||
this.fetchUrl(res.headers.location, headers, maxRedirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(`HTTP Error: ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve({
|
||||
body,
|
||||
headers: res.headers as Record<string, string | string[]>,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
8
web/api/src/storage/storage.module.ts
Normal file
8
web/api/src/storage/storage.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
151
web/api/src/storage/storage.service.ts
Normal file
151
web/api/src/storage/storage.service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import * as os from 'os';
|
||||
import { config } from '../config/config';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private ensureDataDir(): void {
|
||||
fs.mkdirSync(config.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// --- Subscription ---
|
||||
|
||||
saveSubscription(
|
||||
url: string,
|
||||
selectedServer?: string,
|
||||
userInfo?: any,
|
||||
): void {
|
||||
this.ensureDataDir();
|
||||
const data = { url, selectedServer, userInfo };
|
||||
fs.writeFileSync(
|
||||
config.subscriptionFile,
|
||||
JSON.stringify(data, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
loadSubscription(): any {
|
||||
try {
|
||||
if (fs.existsSync(config.subscriptionFile)) {
|
||||
return JSON.parse(fs.readFileSync(config.subscriptionFile, 'utf-8'));
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Fallback Config ---
|
||||
|
||||
saveFallbackConfig(enabled: boolean, host: string, port: number): void {
|
||||
this.ensureDataDir();
|
||||
const data = { enabled, host, port };
|
||||
fs.writeFileSync(
|
||||
config.fallbackFile,
|
||||
JSON.stringify(data, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
loadFallbackConfig(): { enabled: boolean; host: string; port: number } {
|
||||
try {
|
||||
if (fs.existsSync(config.fallbackFile)) {
|
||||
return JSON.parse(fs.readFileSync(config.fallbackFile, 'utf-8'));
|
||||
}
|
||||
} catch {}
|
||||
return { ...config.defaultFallback };
|
||||
}
|
||||
|
||||
// --- Proxy Enabled ---
|
||||
|
||||
saveProxyEnabled(enabled: boolean): void {
|
||||
this.ensureDataDir();
|
||||
fs.writeFileSync(
|
||||
config.proxyEnabledFile,
|
||||
JSON.stringify({ enabled }),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
loadProxyEnabled(): boolean {
|
||||
try {
|
||||
if (fs.existsSync(config.proxyEnabledFile)) {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(config.proxyEnabledFile, 'utf-8'),
|
||||
);
|
||||
return data.enabled ?? true;
|
||||
}
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Start Time ---
|
||||
|
||||
saveStartTime(startTime: number): void {
|
||||
this.ensureDataDir();
|
||||
fs.writeFileSync(
|
||||
config.startTimeFile,
|
||||
JSON.stringify({ startTime }),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
loadStartTime(): number {
|
||||
try {
|
||||
if (fs.existsSync(config.startTimeFile)) {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(config.startTimeFile, 'utf-8'),
|
||||
);
|
||||
return data.startTime ?? 0;
|
||||
}
|
||||
} catch {}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- HWID ---
|
||||
|
||||
getHwid(): string {
|
||||
this.ensureDataDir();
|
||||
try {
|
||||
if (fs.existsSync(config.hwidFile)) {
|
||||
return fs.readFileSync(config.hwidFile, 'utf-8').trim();
|
||||
}
|
||||
} catch {}
|
||||
const hwid = crypto.randomBytes(8).toString('hex');
|
||||
fs.writeFileSync(config.hwidFile, hwid, 'utf-8');
|
||||
return hwid;
|
||||
}
|
||||
|
||||
// --- System Info ---
|
||||
|
||||
getSystemInfo(): { os: string; version: string } {
|
||||
return {
|
||||
os: os.platform(),
|
||||
version: os.release(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Config File ---
|
||||
|
||||
readConfigFile(): any {
|
||||
try {
|
||||
if (fs.existsSync(config.configFile)) {
|
||||
return JSON.parse(fs.readFileSync(config.configFile, 'utf-8'));
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
writeConfigFile(data: any): void {
|
||||
this.ensureDataDir();
|
||||
fs.writeFileSync(
|
||||
config.configFile,
|
||||
JSON.stringify(data, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
configFileExists(): boolean {
|
||||
return fs.existsSync(config.configFile);
|
||||
}
|
||||
}
|
||||
8
web/api/src/vless/vless.module.ts
Normal file
8
web/api/src/vless/vless.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VlessService } from './vless.service';
|
||||
|
||||
@Module({
|
||||
providers: [VlessService],
|
||||
exports: [VlessService],
|
||||
})
|
||||
export class VlessModule {}
|
||||
158
web/api/src/vless/vless.service.ts
Normal file
158
web/api/src/vless/vless.service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { config } from '../config/config';
|
||||
|
||||
export interface VlessParams {
|
||||
uuid: string;
|
||||
server: string;
|
||||
server_port: number;
|
||||
tag: string;
|
||||
public_key: string;
|
||||
short_id: string;
|
||||
server_name: string;
|
||||
fingerprint: string;
|
||||
flow: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VlessService {
|
||||
parseVlessUrl(url: string): VlessParams {
|
||||
if (!url.startsWith('vless://')) {
|
||||
throw new Error('URL must start with vless://');
|
||||
}
|
||||
|
||||
let urlNoScheme = url.slice(8);
|
||||
|
||||
// Split by fragment (#tag)
|
||||
let tag = 'reality';
|
||||
const hashIndex = urlNoScheme.indexOf('#');
|
||||
if (hashIndex !== -1) {
|
||||
tag = decodeURIComponent(urlNoScheme.slice(hashIndex + 1));
|
||||
urlNoScheme = urlNoScheme.slice(0, hashIndex);
|
||||
}
|
||||
|
||||
// Split by query (?)
|
||||
const qIndex = urlNoScheme.indexOf('?');
|
||||
if (qIndex === -1) {
|
||||
throw new Error('Missing query parameters');
|
||||
}
|
||||
|
||||
const uuidHostPort = urlNoScheme.slice(0, qIndex);
|
||||
const queryString = urlNoScheme.slice(qIndex + 1);
|
||||
|
||||
// Parse UUID@host:port
|
||||
const atIndex = uuidHostPort.indexOf('@');
|
||||
if (atIndex === -1) {
|
||||
throw new Error('Missing @ separator');
|
||||
}
|
||||
|
||||
const uuid = uuidHostPort.slice(0, atIndex);
|
||||
const hostPort = uuidHostPort.slice(atIndex + 1);
|
||||
|
||||
const lastColon = hostPort.lastIndexOf(':');
|
||||
if (lastColon === -1) {
|
||||
throw new Error('Missing port');
|
||||
}
|
||||
|
||||
const host = hostPort.slice(0, lastColon);
|
||||
const port = parseInt(hostPort.slice(lastColon + 1), 10);
|
||||
|
||||
// Parse query parameters
|
||||
const params: Record<string, string> = {};
|
||||
for (const param of queryString.split('&')) {
|
||||
const eqIndex = param.indexOf('=');
|
||||
if (eqIndex !== -1) {
|
||||
params[param.slice(0, eqIndex)] = decodeURIComponent(
|
||||
param.slice(eqIndex + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pbk = params.pbk || '';
|
||||
const sid = params.sid || '';
|
||||
const sni = params.sni || host;
|
||||
const fp = params.fp || 'chrome';
|
||||
const flow = params.flow || '';
|
||||
|
||||
if (!pbk || !sid) {
|
||||
throw new Error('Missing required parameters: pbk or sid');
|
||||
}
|
||||
|
||||
return {
|
||||
uuid,
|
||||
server: host,
|
||||
server_port: port,
|
||||
tag,
|
||||
public_key: pbk,
|
||||
short_id: sid,
|
||||
server_name: sni,
|
||||
fingerprint: fp,
|
||||
flow,
|
||||
};
|
||||
}
|
||||
|
||||
generateVlessConfig(vlessParams: VlessParams): any {
|
||||
return {
|
||||
dns: { independent_cache: true },
|
||||
log: { level: 'debug', disabled: true, timestamp: true },
|
||||
route: {
|
||||
final: vlessParams.tag,
|
||||
auto_detect_interface: true,
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
tag: 'mixed-in',
|
||||
type: 'mixed',
|
||||
sniff: true,
|
||||
users: [],
|
||||
listen: '0.0.0.0',
|
||||
listen_port: config.proxyPort,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
],
|
||||
outbounds: [
|
||||
{
|
||||
type: 'vless',
|
||||
tag: vlessParams.tag,
|
||||
server: vlessParams.server,
|
||||
server_port: vlessParams.server_port,
|
||||
flow: vlessParams.flow,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: vlessParams.server_name,
|
||||
reality: {
|
||||
enabled: true,
|
||||
public_key: vlessParams.public_key,
|
||||
short_id: vlessParams.short_id,
|
||||
},
|
||||
utls: {
|
||||
enabled: true,
|
||||
fingerprint: vlessParams.fingerprint,
|
||||
},
|
||||
},
|
||||
uuid: vlessParams.uuid,
|
||||
},
|
||||
{ tag: 'direct', type: 'direct' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
generateDirectConfig(): any {
|
||||
return {
|
||||
dns: { independent_cache: true },
|
||||
log: { level: 'debug', disabled: true, timestamp: true },
|
||||
route: { final: 'direct', auto_detect_interface: true },
|
||||
inbounds: [
|
||||
{
|
||||
tag: 'mixed-in',
|
||||
type: 'mixed',
|
||||
sniff: true,
|
||||
users: [],
|
||||
listen: '0.0.0.0',
|
||||
listen_port: config.proxyPort,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
],
|
||||
outbounds: [{ tag: 'direct', type: 'direct' }],
|
||||
};
|
||||
}
|
||||
}
|
||||
4
web/api/tsconfig.build.json
Normal file
4
web/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
22
web/api/tsconfig.json
Normal file
22
web/api/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
742
web/app/api.py
742
web/app/api.py
@@ -1,742 +0,0 @@
|
||||
import http.server
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import (
|
||||
WEB_DIR, CONFIG_FILE, PROXY_PORT, DATA_DIR, APP_NAME,
|
||||
RELOAD_PORT, PROXY_BIND_IP
|
||||
)
|
||||
from .utils import get_hwid, get_system_info
|
||||
from .storage import (
|
||||
load_subscription, save_subscription,
|
||||
load_fallback_config, save_fallback_config,
|
||||
load_proxy_enabled, save_proxy_enabled,
|
||||
load_start_time, save_start_time
|
||||
)
|
||||
from .network import measure_proxy_performance, measure_tcp_latency
|
||||
from .vless import (
|
||||
parse_vless_url, generate_vless_config, generate_direct_config
|
||||
)
|
||||
|
||||
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP Request Handler for Proxy Control"""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Override to add timestamp prefix"""
|
||||
print(f"[WebUI] {args[0]}")
|
||||
|
||||
def send_json(self, data: dict, status: int = 200):
|
||||
"""Send JSON response"""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def send_html(self, content: bytes):
|
||||
"""Send HTML response"""
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests"""
|
||||
if self.path == "/" or self.path == "/index.html":
|
||||
self.serve_index()
|
||||
elif self.path == "/status":
|
||||
self.get_status()
|
||||
elif self.path == "/subscription":
|
||||
self.get_subscription()
|
||||
elif self.path.startswith("/test-connection"):
|
||||
self.test_connection()
|
||||
elif self.path.startswith("/static/"):
|
||||
self.serve_static()
|
||||
elif self.path == "/fallback-config":
|
||||
self.get_fallback_config()
|
||||
elif self.path == "/active-proxy":
|
||||
self.get_active_proxy()
|
||||
elif self.path == "/proxy-enabled":
|
||||
self.get_proxy_enabled()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests"""
|
||||
if self.path == "/apply":
|
||||
self.apply_config()
|
||||
elif self.path == "/fetch-subscription":
|
||||
self.fetch_subscription()
|
||||
elif self.path == "/apply-subscription":
|
||||
self.apply_subscription()
|
||||
elif self.path == "/ping-target":
|
||||
self.ping_target()
|
||||
elif self.path == "/fallback-config":
|
||||
self.save_fallback_config_endpoint()
|
||||
elif self.path == "/proxy-enabled":
|
||||
self.set_proxy_enabled()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def serve_index(self):
|
||||
"""Serve main HTML page with SSI-like includes"""
|
||||
index_path = WEB_DIR / "index.html"
|
||||
if not index_path.exists():
|
||||
self.send_error(404, "index.html not found")
|
||||
return
|
||||
|
||||
try:
|
||||
content = index_path.read_text(encoding="utf-8")
|
||||
|
||||
# Process includes: <!-- include "path/to/file.html" -->
|
||||
import re
|
||||
|
||||
def replace_include(match):
|
||||
path = match.group(1)
|
||||
full_path = WEB_DIR / path
|
||||
if full_path.exists() and full_path.is_file():
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
return f"<!-- Include failed: {path} -->"
|
||||
|
||||
# Simple one-pass replacement
|
||||
processed_content = re.sub(r'<!-- include "([^"]+)" -->', replace_include, content)
|
||||
|
||||
self.send_html(processed_content.encode("utf-8"))
|
||||
|
||||
except Exception as e:
|
||||
self.send_error(500, f"Error serving index: {str(e)}")
|
||||
|
||||
def serve_static(self):
|
||||
"""Serve static files"""
|
||||
# Map /static/... to WEB_DIR/static/...
|
||||
path_clean = self.path.split('?')[0] # Remove query params
|
||||
file_path = WEB_DIR / path_clean.lstrip('/')
|
||||
if file_path.exists() and file_path.is_file():
|
||||
content_type = "text/css" if str(file_path).endswith(".css") else "application/javascript"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(file_path.read_bytes())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def get_fallback_config(self):
|
||||
"""Get fallback proxy configuration"""
|
||||
fallback = load_fallback_config()
|
||||
self.send_json({
|
||||
"enabled": fallback.get("enabled", False),
|
||||
"host": fallback.get("host", "192.168.50.111"),
|
||||
"port": fallback.get("port", 8080)
|
||||
})
|
||||
|
||||
def get_active_proxy(self):
|
||||
"""Get information about current active proxy chain"""
|
||||
result = {
|
||||
"configured": False,
|
||||
"fallbackEnabled": False,
|
||||
"fallbackHost": None,
|
||||
"vpnTag": None,
|
||||
"vpnServer": None,
|
||||
"activeOutbound": None
|
||||
}
|
||||
|
||||
if not CONFIG_FILE.exists():
|
||||
self.send_json(result)
|
||||
return
|
||||
|
||||
try:
|
||||
config = json.loads(CONFIG_FILE.read_text())
|
||||
outbounds = config.get("outbounds", [])
|
||||
route_final = config.get("route", {}).get("final")
|
||||
|
||||
result["configured"] = True
|
||||
|
||||
for outbound in outbounds:
|
||||
out_type = outbound.get("type")
|
||||
|
||||
if out_type == "urltest":
|
||||
result["fallbackEnabled"] = True
|
||||
elif out_type == "http" and outbound.get("tag") == "fallback-proxy":
|
||||
result["fallbackHost"] = f"{outbound.get('server')}:{outbound.get('server_port')}"
|
||||
elif out_type in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
||||
result["vpnTag"] = outbound.get("tag")
|
||||
result["vpnServer"] = outbound.get("server")
|
||||
|
||||
# Determine which is actually active
|
||||
# For now, we show the configured route
|
||||
result["activeOutbound"] = route_final
|
||||
|
||||
# Check fallback proxy reachability (quick TCP check)
|
||||
if result["fallbackEnabled"] and result["fallbackHost"]:
|
||||
try:
|
||||
host, port = result["fallbackHost"].split(":")
|
||||
latency = measure_tcp_latency(host, int(port), timeout=1.0)
|
||||
result["fallbackReachable"] = latency > 0
|
||||
result["fallbackLatency"] = latency if latency > 0 else None
|
||||
except Exception:
|
||||
result["fallbackReachable"] = False
|
||||
result["fallbackLatency"] = None
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
self.send_json(result)
|
||||
|
||||
def save_fallback_config_endpoint(self):
|
||||
"""Save fallback proxy configuration and regenerate config"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
host = data.get("host", "").strip()
|
||||
port = int(data.get("port", 8080))
|
||||
|
||||
if enabled and not host:
|
||||
self.send_json({"success": False, "error": "Host is required"}, 400)
|
||||
return
|
||||
|
||||
save_fallback_config(enabled, host, port)
|
||||
|
||||
# Regenerate current config if it exists
|
||||
regenerated = self.regenerate_current_config()
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": "Fallback config saved",
|
||||
"regenerated": regenerated
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def regenerate_current_config(self) -> bool:
|
||||
"""Regenerate current config with updated fallback settings"""
|
||||
if not CONFIG_FILE.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
config = json.loads(CONFIG_FILE.read_text())
|
||||
outbounds = config.get("outbounds", [])
|
||||
|
||||
# Find the VPN outbound (vless, vmess, etc.)
|
||||
vpn_outbound = None
|
||||
utility_outbounds = []
|
||||
|
||||
for outbound in outbounds:
|
||||
if outbound.get("type") in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
||||
vpn_outbound = outbound
|
||||
elif outbound.get("type") in ["direct", "block", "dns"]:
|
||||
utility_outbounds.append(outbound)
|
||||
|
||||
if not vpn_outbound:
|
||||
return False
|
||||
|
||||
selected_tag = vpn_outbound.get("tag")
|
||||
|
||||
# Load fallback config
|
||||
fallback = load_fallback_config()
|
||||
fallback_enabled = fallback.get("enabled", False)
|
||||
fallback_host = fallback.get("host", "")
|
||||
fallback_port = fallback.get("port", 8080)
|
||||
|
||||
# Build new outbounds
|
||||
final_outbounds = []
|
||||
final_tag = selected_tag
|
||||
|
||||
if fallback_enabled and fallback_host:
|
||||
urltest_outbound = {
|
||||
"type": "urltest",
|
||||
"tag": "auto-select",
|
||||
"outbounds": ["fallback-proxy", selected_tag],
|
||||
"url": "http://www.gstatic.com/generate_204",
|
||||
"interval": "30s",
|
||||
"tolerance": 9999
|
||||
}
|
||||
|
||||
fallback_outbound = {
|
||||
"type": "http",
|
||||
"tag": "fallback-proxy",
|
||||
"server": fallback_host,
|
||||
"server_port": fallback_port
|
||||
}
|
||||
|
||||
final_outbounds.append(urltest_outbound)
|
||||
final_outbounds.append(fallback_outbound)
|
||||
final_tag = "auto-select"
|
||||
|
||||
final_outbounds.append(vpn_outbound)
|
||||
final_outbounds.extend(utility_outbounds)
|
||||
|
||||
config["outbounds"] = final_outbounds
|
||||
config["route"]["final"] = final_tag
|
||||
|
||||
# Write config
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Reload sing-box
|
||||
try:
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{RELOAD_PORT}/reload", timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Failed to regenerate config: {e}")
|
||||
return False
|
||||
|
||||
def get_status(self):
|
||||
"""Get current proxy status"""
|
||||
config_exists = CONFIG_FILE.exists()
|
||||
current_tag = None
|
||||
current_server = None
|
||||
proxy_enabled = load_proxy_enabled()
|
||||
|
||||
if config_exists:
|
||||
try:
|
||||
config = json.loads(CONFIG_FILE.read_text())
|
||||
for outbound in config.get("outbounds", []):
|
||||
if outbound.get("type") == "vless":
|
||||
current_tag = outbound.get("tag", "unknown")
|
||||
current_server = outbound.get("server", "unknown")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.send_json({
|
||||
"active": config_exists and proxy_enabled,
|
||||
"tag": current_tag,
|
||||
"server": current_server,
|
||||
"proxyPort": PROXY_PORT,
|
||||
"proxyEnabled": proxy_enabled,
|
||||
"startTime": load_start_time() if config_exists and proxy_enabled else 0
|
||||
})
|
||||
|
||||
def get_proxy_enabled(self):
|
||||
"""Get proxy enabled state"""
|
||||
enabled = load_proxy_enabled()
|
||||
self.send_json({"enabled": enabled})
|
||||
|
||||
def set_proxy_enabled(self):
|
||||
"""Set proxy enabled state and regenerate config"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
enabled = data.get("enabled", True)
|
||||
save_proxy_enabled(enabled)
|
||||
|
||||
# Regenerate config based on state
|
||||
if enabled:
|
||||
# Restore normal VPN config
|
||||
regenerated = self.regenerate_current_config()
|
||||
if regenerated:
|
||||
# Only update start time if actually enabling VPN
|
||||
save_start_time(datetime.now(timezone.utc).timestamp())
|
||||
else:
|
||||
# Generate direct config (bypass proxy)
|
||||
regenerated = generate_direct_config()
|
||||
save_start_time(0)
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"enabled": enabled,
|
||||
"regenerated": regenerated
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def get_subscription(self):
|
||||
"""Get saved subscription info"""
|
||||
sub = load_subscription()
|
||||
if sub:
|
||||
self.send_json({
|
||||
"saved": True,
|
||||
"url": sub.get("url"),
|
||||
"selectedServer": sub.get("selectedServer"),
|
||||
"userInfo": sub.get("userInfo")
|
||||
})
|
||||
else:
|
||||
self.send_json({"saved": False})
|
||||
|
||||
def test_connection(self):
|
||||
"""Test active proxy connection"""
|
||||
query_components = {}
|
||||
if '?' in self.path:
|
||||
_, query = self.path.split('?', 1)
|
||||
query_components = parse_qs(query)
|
||||
|
||||
enable_speed = query_components.get('speed', ['false'])[0].lower() == 'true'
|
||||
|
||||
result = measure_proxy_performance(enable_speed_test=enable_speed)
|
||||
self.send_json(result)
|
||||
|
||||
def ping_target(self):
|
||||
"""Ping a specific target"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
server = data.get("server")
|
||||
port = int(data.get("port", 443))
|
||||
|
||||
if not server:
|
||||
self.send_json({"error": "No server specified"}, 400)
|
||||
return
|
||||
|
||||
latency = measure_tcp_latency(server, port)
|
||||
self.send_json({"latency": latency})
|
||||
|
||||
except Exception as e:
|
||||
self.send_json({"error": str(e)}, 500)
|
||||
|
||||
def apply_config(self):
|
||||
"""Apply new config from VLESS URL"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
url = data.get("url", "").strip()
|
||||
|
||||
if not url:
|
||||
self.send_json({"success": False, "error": "URL не указан"}, 400)
|
||||
return
|
||||
|
||||
if not url.startswith("vless://"):
|
||||
self.send_json({"success": False, "error": "Неверный формат. Поддерживаются только vless:// ссылки"}, 400)
|
||||
return
|
||||
|
||||
# Parse VLESS URL
|
||||
try:
|
||||
vless_params = parse_vless_url(url)
|
||||
except ValueError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка парсинга URL: {str(e)}"}, 400)
|
||||
return
|
||||
|
||||
# Generate config
|
||||
config = generate_vless_config(vless_params)
|
||||
|
||||
# Ensure data directory exists
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config file
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Trigger reload via internal control port
|
||||
try:
|
||||
urllib.request.urlopen(f"http://localhost:{RELOAD_PORT}/reload", timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": f"Конфигурация '{vless_params['tag']}' успешно применена!"
|
||||
})
|
||||
|
||||
# Save new start time as connection is reset
|
||||
save_start_time(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def fetch_subscription(self):
|
||||
"""Fetch servers list from subscription URL"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
url = data.get("url", "").strip()
|
||||
|
||||
if not url:
|
||||
self.send_json({"success": False, "error": "URL подписки не указан"}, 400)
|
||||
return
|
||||
|
||||
# Validate URL scheme to prevent SSRF
|
||||
try:
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.scheme not in ('http', 'https'):
|
||||
self.send_json({"success": False, "error": "Недопустимый протокол (только http/https)"}, 400)
|
||||
return
|
||||
except Exception:
|
||||
self.send_json({"success": False, "error": "Некорректный URL"}, 400)
|
||||
return
|
||||
|
||||
# Fetch subscription config
|
||||
sys_info = get_system_info()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "singbox",
|
||||
"x-hwid": get_hwid(),
|
||||
"x-device-os": sys_info["os"],
|
||||
"x-ver-os": sys_info["version"],
|
||||
"x-device-model": APP_NAME
|
||||
}
|
||||
)
|
||||
|
||||
config = None
|
||||
config_text = ""
|
||||
user_info = {}
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
config_text = response.read().decode("utf-8")
|
||||
|
||||
# Parse User Info header
|
||||
user_info_header = response.headers.get("subscription-userinfo", "")
|
||||
if user_info_header:
|
||||
parts = user_info_header.split(';')
|
||||
for part in parts:
|
||||
if '=' in part:
|
||||
key, value = part.strip().split('=', 1)
|
||||
try:
|
||||
user_info[key] = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
||||
return
|
||||
except urllib.error.URLError as e:
|
||||
self.send_json({"success": False, "error": f"Ошибка подключения: {e.reason}"}, 400)
|
||||
return
|
||||
|
||||
# Try to parse as JSON first
|
||||
try:
|
||||
config = json.loads(config_text)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - try Base64 decode or plain text VLESS links
|
||||
content = config_text.strip()
|
||||
|
||||
# Try Base64 decode
|
||||
import base64
|
||||
import re
|
||||
try:
|
||||
# Check if it looks like Base64
|
||||
if re.match(r'^[A-Za-z0-9+/=\s]+$', content):
|
||||
decoded = base64.b64decode(content).decode('utf-8')
|
||||
content = decoded
|
||||
except Exception:
|
||||
pass # Not Base64, continue with original content
|
||||
|
||||
# Parse VLESS links
|
||||
lines = content.strip().split('\n')
|
||||
vless_links = [line.strip() for line in lines if line.strip().startswith('vless://')]
|
||||
|
||||
if not vless_links:
|
||||
self.send_json({"success": False, "error": "Не найдены VLESS ссылки в ответе"}, 400)
|
||||
return
|
||||
|
||||
# Parse each VLESS link and create outbounds
|
||||
outbounds = []
|
||||
for link in vless_links:
|
||||
try:
|
||||
params = parse_vless_url(link)
|
||||
outbound = {
|
||||
"type": "vless",
|
||||
"tag": params['tag'],
|
||||
"server": params['server'],
|
||||
"server_port": params['server_port'],
|
||||
"uuid": params['uuid'],
|
||||
"flow": params['flow'],
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": params['server_name'],
|
||||
"utls": {"enabled": True, "fingerprint": params['fingerprint']},
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"public_key": params['public_key'],
|
||||
"short_id": params['short_id']
|
||||
}
|
||||
},
|
||||
"packet_encoding": "xudp"
|
||||
}
|
||||
outbounds.append(outbound)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Failed to parse VLESS link: {e}")
|
||||
continue
|
||||
|
||||
if not outbounds:
|
||||
self.send_json({"success": False, "error": "Не удалось распарсить VLESS ссылки"}, 400)
|
||||
return
|
||||
|
||||
# Create a mock config with parsed outbounds
|
||||
config = {"outbounds": outbounds}
|
||||
|
||||
# Extract outbound servers
|
||||
outbounds = config.get("outbounds", [])
|
||||
servers = []
|
||||
|
||||
for outbound in outbounds:
|
||||
if outbound.get("type") in ["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]:
|
||||
servers.append({
|
||||
"tag": outbound.get("tag", "unknown"),
|
||||
"type": outbound.get("type"),
|
||||
"server": outbound.get("server", "unknown"),
|
||||
"server_port": outbound.get("server_port", 443)
|
||||
})
|
||||
|
||||
if not servers:
|
||||
self.send_json({"success": False, "error": "Серверы не найдены в подписке"}, 400)
|
||||
return
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"servers": servers,
|
||||
"config": config,
|
||||
"userInfo": user_info
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON в ответе"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
def apply_subscription(self):
|
||||
"""Apply config from subscription with selected server"""
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
data = json.loads(body)
|
||||
|
||||
config = data.get("config")
|
||||
selected_tag = data.get("selectedServer")
|
||||
sub_url = data.get("subUrl") # URL подписки для сохранения
|
||||
user_info = data.get("userInfo")
|
||||
|
||||
if not config:
|
||||
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
||||
return
|
||||
|
||||
if not selected_tag:
|
||||
self.send_json({"success": False, "error": "Сервер не выбран"}, 400)
|
||||
return
|
||||
|
||||
# Modify config to use only selected server
|
||||
outbounds = config.get("outbounds", [])
|
||||
new_outbounds = []
|
||||
selected_outbound = None
|
||||
|
||||
for outbound in outbounds:
|
||||
if outbound.get("tag") == selected_tag:
|
||||
selected_outbound = outbound
|
||||
elif outbound.get("type") in ["direct", "block", "dns"]:
|
||||
new_outbounds.append(outbound)
|
||||
elif outbound.get("type") == "selector":
|
||||
# Skip selector, we'll add selected server directly
|
||||
pass
|
||||
|
||||
if not selected_outbound:
|
||||
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
||||
return
|
||||
|
||||
# Load fallback configuration
|
||||
fallback = load_fallback_config()
|
||||
fallback_enabled = fallback.get("enabled", False)
|
||||
fallback_host = fallback.get("host", "")
|
||||
fallback_port = fallback.get("port", 8080)
|
||||
|
||||
# Build outbounds list
|
||||
final_outbounds = []
|
||||
final_tag = selected_tag
|
||||
|
||||
if fallback_enabled and fallback_host:
|
||||
# Add URLTest for automatic fallback selection
|
||||
# High tolerance (9999ms) ensures first working proxy is preferred
|
||||
urltest_outbound = {
|
||||
"type": "urltest",
|
||||
"tag": "auto-select",
|
||||
"outbounds": ["fallback-proxy", selected_tag],
|
||||
"url": "http://www.gstatic.com/generate_204",
|
||||
"interval": "30s",
|
||||
"tolerance": 9999 # Use first working proxy, not fastest
|
||||
}
|
||||
|
||||
# Add HTTP fallback proxy
|
||||
fallback_outbound = {
|
||||
"type": "http",
|
||||
"tag": "fallback-proxy",
|
||||
"server": fallback_host,
|
||||
"server_port": fallback_port
|
||||
}
|
||||
|
||||
final_outbounds.append(urltest_outbound)
|
||||
final_outbounds.append(fallback_outbound)
|
||||
final_tag = "auto-select"
|
||||
|
||||
# Add selected VPN server
|
||||
final_outbounds.append(selected_outbound)
|
||||
|
||||
# Add utility outbounds (direct, block, dns)
|
||||
final_outbounds.extend(new_outbounds)
|
||||
|
||||
# Update route
|
||||
routes = {
|
||||
"final": final_tag,
|
||||
"auto_detect_interface": True
|
||||
}
|
||||
|
||||
# Simplify DNS configuration to match client.json format
|
||||
config["dns"] = {
|
||||
"independent_cache": True
|
||||
}
|
||||
|
||||
# Remove platform-specific and experimental fields from root config
|
||||
config.pop("platform", None)
|
||||
config.pop("experimental", None)
|
||||
|
||||
# Replace TUN inbounds with mixed proxy
|
||||
config["inbounds"] = [
|
||||
{
|
||||
"tag": "mixed-in",
|
||||
"type": "mixed",
|
||||
"sniff": True,
|
||||
"users": [],
|
||||
"listen": PROXY_BIND_IP,
|
||||
"listen_port": PROXY_PORT,
|
||||
"set_system_proxy": False
|
||||
}
|
||||
]
|
||||
|
||||
config["outbounds"] = final_outbounds
|
||||
config["route"] = routes
|
||||
|
||||
# Ensure data directory exists
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write config file
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Save subscription URL for persistence
|
||||
if sub_url:
|
||||
save_subscription(sub_url, selected_tag, user_info)
|
||||
|
||||
# Trigger reload via internal control port
|
||||
try:
|
||||
urllib.request.urlopen(f"http://localhost:{RELOAD_PORT}/reload", timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": f"Сервер '{selected_tag}' успешно применён!"
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
@@ -1,31 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Environment Configuration
|
||||
PORT = int(os.environ.get("PORT", 3456))
|
||||
PROXY_PORT = int(os.environ.get("PROXY_PORT", 8080))
|
||||
RELOAD_PORT = int(os.environ.get("RELOAD_PORT", 9090))
|
||||
PROXY_BIND_IP = os.environ.get("PROXY_BIND_IP", "0.0.0.0")
|
||||
APP_NAME = "VPN-Proxy-Control by Dokril"
|
||||
|
||||
# Path Configuration
|
||||
# web/app/config.py -> web/app -> web -> base
|
||||
APP_DIR = Path(__file__).parent.parent
|
||||
BASE_DIR = APP_DIR.parent
|
||||
WEB_DIR = APP_DIR
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
|
||||
# File Paths
|
||||
CONFIG_FILE = DATA_DIR / "client.json"
|
||||
HWID_FILE = DATA_DIR / "hwid"
|
||||
SUBSCRIPTION_FILE = DATA_DIR / "subscription.json"
|
||||
FALLBACK_FILE = DATA_DIR / "fallback.json"
|
||||
PROXY_ENABLED_FILE = DATA_DIR / "proxy_enabled.json"
|
||||
START_TIME_FILE = DATA_DIR / "start_time.json"
|
||||
|
||||
# Default fallback proxy settings
|
||||
DEFAULT_FALLBACK = {
|
||||
"enabled": False,
|
||||
"host": "192.168.50.111",
|
||||
"port": 8080
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import socket
|
||||
import time
|
||||
import urllib.request
|
||||
from .config import PROXY_PORT
|
||||
|
||||
def measure_tcp_latency(host: str, port: int, timeout: float = 2.0) -> int:
|
||||
"""Measure TCP latency to a host:port in milliseconds"""
|
||||
start_time = time.time()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
latency = (time.time() - start_time) * 1000
|
||||
return int(latency)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
|
||||
def measure_proxy_performance(enable_speed_test: bool = False) -> dict:
|
||||
"""Measure proxy latency, speed and public IP via local proxy"""
|
||||
proxy_url = f"http://127.0.0.1:{PROXY_PORT}"
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
# 1. Measure Latency (Ping)
|
||||
latency = "Timeout"
|
||||
try:
|
||||
start_time = time.time()
|
||||
# Use a reliable endpoint for ping
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
req = urllib.request.Request("http://www.gstatic.com/generate_204", headers={"User-Agent": "singbox-test"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
lat_ms = int((time.time() - start_time) * 1000)
|
||||
latency = f"{lat_ms}ms"
|
||||
except Exception as e:
|
||||
latency = "Error"
|
||||
|
||||
# 2. Get Public IP (IPv4)
|
||||
ip = "Unknown"
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
# Use v4.ident.me to force IPv4
|
||||
req = urllib.request.Request("http://v4.ident.me", headers={"User-Agent": "curl/7.68.0"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
ip = response.read().decode('utf-8').strip()
|
||||
except Exception:
|
||||
# Fallback to ipify if ident.me fails or returns garbage
|
||||
try:
|
||||
req = urllib.request.Request("http://api.ipify.org", headers={"User-Agent": "curl/7.68.0"})
|
||||
with opener.open(req, timeout=5) as response:
|
||||
ip = response.read().decode('utf-8').strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Measure Download Speed
|
||||
speed_mbps = 0.0
|
||||
if enable_speed_test:
|
||||
test_files = [
|
||||
# Tele2 Speedtest (Usually very reliable and fast)
|
||||
("https://speedtest.selectel.ru/100MB", 100),
|
||||
# ThinkBroadband (Reliable backup)
|
||||
("https://speedtest.selectel.ru/1GB", 1000)
|
||||
]
|
||||
|
||||
for url, size_mb in test_files:
|
||||
try:
|
||||
print(f"[WebUI] Testing speed with: {url}")
|
||||
start_time = time.time()
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
|
||||
# Set a longer timeout for speed tests
|
||||
with opener.open(url, timeout=30) as response:
|
||||
downloaded = 0
|
||||
# Larger chunk size for better throughput measurement
|
||||
chunk_size = 1024 * 256 # 256KB chunks
|
||||
|
||||
# Download for at least 2 seconds or up to 25MB for accurate measurement
|
||||
min_test_duration = 2.0 # seconds
|
||||
max_download_bytes = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
downloaded += len(chunk)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
# Stop if we've downloaded enough AND tested for minimum duration
|
||||
if downloaded >= max_download_bytes or (elapsed >= min_test_duration and downloaded >= 2 * 1024 * 1024):
|
||||
break
|
||||
|
||||
duration = time.time() - start_time
|
||||
if duration > 0.1 and downloaded > 0:
|
||||
# Calculate speed in Mbps (megabits per second)
|
||||
# downloaded bytes * 8 bits/byte / 1,000,000 / seconds
|
||||
speed_mbps = round((downloaded * 8) / (1000 * 1000) / duration, 1)
|
||||
print(f"[WebUI] Speed test: downloaded {downloaded / (1024*1024):.1f}MB in {duration:.1f}s = {speed_mbps} Mbps")
|
||||
break # Stop if successful
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Speed test failed for {url}: {e}")
|
||||
continue
|
||||
|
||||
result = {
|
||||
"latency": latency,
|
||||
"ip": ip
|
||||
}
|
||||
|
||||
if enable_speed_test:
|
||||
# If speed is still 0.0 but we tried, return Error or 0.0
|
||||
result["speed"] = f"{speed_mbps} Mbps"
|
||||
|
||||
return result
|
||||
@@ -1,80 +0,0 @@
|
||||
import json
|
||||
from .config import (
|
||||
DATA_DIR, SUBSCRIPTION_FILE, FALLBACK_FILE, PROXY_ENABLED_FILE,
|
||||
START_TIME_FILE, DEFAULT_FALLBACK
|
||||
)
|
||||
|
||||
def save_subscription(url: str, selected_server: str = None, user_info: dict = None):
|
||||
"""Save subscription URL, selected server and user info to file"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"url": url,
|
||||
"selectedServer": selected_server,
|
||||
"userInfo": user_info
|
||||
}
|
||||
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def load_subscription() -> dict:
|
||||
"""Load subscription from file"""
|
||||
if SUBSCRIPTION_FILE.exists():
|
||||
try:
|
||||
return json.loads(SUBSCRIPTION_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def save_fallback_config(enabled: bool, host: str, port: int):
|
||||
"""Save fallback proxy configuration to file"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"enabled": enabled,
|
||||
"host": host,
|
||||
"port": port
|
||||
}
|
||||
FALLBACK_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def load_fallback_config() -> dict:
|
||||
"""Load fallback proxy configuration from file"""
|
||||
if FALLBACK_FILE.exists():
|
||||
try:
|
||||
return json.loads(FALLBACK_FILE.read_text())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return DEFAULT_FALLBACK.copy()
|
||||
|
||||
|
||||
def save_proxy_enabled(enabled: bool):
|
||||
"""Save proxy enabled state to file"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROXY_ENABLED_FILE.write_text(json.dumps({"enabled": enabled}))
|
||||
|
||||
|
||||
def load_proxy_enabled() -> bool:
|
||||
"""Load proxy enabled state from file"""
|
||||
if PROXY_ENABLED_FILE.exists():
|
||||
try:
|
||||
data = json.loads(PROXY_ENABLED_FILE.read_text())
|
||||
return data.get("enabled", True)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return True # Default: proxy enabled
|
||||
|
||||
|
||||
def save_start_time(start_time: float):
|
||||
"""Save VPN start time to file"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
START_TIME_FILE.write_text(json.dumps({"startTime": start_time}))
|
||||
|
||||
|
||||
def load_start_time() -> float:
|
||||
"""Load VPN start time from file"""
|
||||
if START_TIME_FILE.exists():
|
||||
try:
|
||||
data = json.loads(START_TIME_FILE.read_text())
|
||||
return data.get("startTime", 0.0)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return 0.0
|
||||
@@ -1,26 +0,0 @@
|
||||
import platform
|
||||
import uuid
|
||||
from .config import DATA_DIR, HWID_FILE
|
||||
|
||||
def get_hwid() -> str:
|
||||
"""Get or generate hardware ID"""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if HWID_FILE.exists():
|
||||
return HWID_FILE.read_text().strip()
|
||||
|
||||
# Generate new random HWID
|
||||
hwid = uuid.uuid4().hex[:16]
|
||||
HWID_FILE.write_text(hwid)
|
||||
return hwid
|
||||
|
||||
|
||||
def get_system_info() -> dict:
|
||||
"""Get system information for headers"""
|
||||
system = platform.system().lower() # windows, linux, darwin
|
||||
version = platform.release() # 10, 5.15.0, 22.0.0
|
||||
|
||||
return {
|
||||
"os": system,
|
||||
"version": version
|
||||
}
|
||||
176
web/app/vless.py
176
web/app/vless.py
@@ -1,176 +0,0 @@
|
||||
from urllib.parse import unquote
|
||||
import json
|
||||
import urllib.request
|
||||
from .config import PROXY_PORT, DATA_DIR, CONFIG_FILE
|
||||
|
||||
def parse_vless_url(url: str) -> dict:
|
||||
"""Parse VLESS URL and extract connection parameters"""
|
||||
if not url.startswith("vless://"):
|
||||
raise ValueError("URL must start with vless://")
|
||||
|
||||
# Remove scheme
|
||||
url_no_scheme = url[8:]
|
||||
|
||||
# Split by fragment (#tag)
|
||||
if '#' in url_no_scheme:
|
||||
url_part, tag = url_no_scheme.split('#', 1)
|
||||
tag = unquote(tag)
|
||||
else:
|
||||
url_part = url_no_scheme
|
||||
tag = "reality"
|
||||
|
||||
# Split by query (?)
|
||||
if '?' in url_part:
|
||||
uuid_host_port, query_string = url_part.split('?', 1)
|
||||
else:
|
||||
raise ValueError("Missing query parameters")
|
||||
|
||||
# Parse UUID@host:port
|
||||
if '@' not in uuid_host_port:
|
||||
raise ValueError("Missing @ separator")
|
||||
|
||||
uuid_str, host_port = uuid_host_port.split('@', 1)
|
||||
|
||||
if ':' not in host_port:
|
||||
raise ValueError("Missing port")
|
||||
|
||||
host, port_str = host_port.rsplit(':', 1)
|
||||
port = int(port_str)
|
||||
|
||||
# Parse query parameters
|
||||
params = {}
|
||||
for param in query_string.split('&'):
|
||||
if '=' in param:
|
||||
key, value = param.split('=', 1)
|
||||
params[key] = unquote(value)
|
||||
|
||||
# Extract required parameters
|
||||
pbk = params.get('pbk', '')
|
||||
sid = params.get('sid', '')
|
||||
sni = params.get('sni', host)
|
||||
fp = params.get('fp', 'chrome')
|
||||
flow = params.get('flow', '')
|
||||
|
||||
if not pbk or not sid:
|
||||
raise ValueError("Missing required parameters: pbk or sid")
|
||||
|
||||
return {
|
||||
'uuid': uuid_str,
|
||||
'server': host,
|
||||
'server_port': port,
|
||||
'tag': tag,
|
||||
'public_key': pbk,
|
||||
'short_id': sid,
|
||||
'server_name': sni,
|
||||
'fingerprint': fp,
|
||||
'flow': flow
|
||||
}
|
||||
|
||||
|
||||
def generate_vless_config(vless_params: dict) -> dict:
|
||||
"""Generate sing-box configuration from VLESS parameters"""
|
||||
config = {
|
||||
"dns": {
|
||||
"independent_cache": True
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"disabled": True,
|
||||
"timestamp": True
|
||||
},
|
||||
"route": {
|
||||
"final": vless_params['tag'],
|
||||
"auto_detect_interface": True
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "mixed-in",
|
||||
"type": "mixed",
|
||||
"sniff": True,
|
||||
"users": [],
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": PROXY_PORT,
|
||||
"set_system_proxy": False
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": vless_params['tag'],
|
||||
"server": vless_params['server'],
|
||||
"server_port": vless_params['server_port'],
|
||||
"flow": vless_params['flow'],
|
||||
"tls": {
|
||||
"enabled": True,
|
||||
"server_name": vless_params['server_name'],
|
||||
"reality": {
|
||||
"enabled": True,
|
||||
"public_key": vless_params['public_key'],
|
||||
"short_id": vless_params['short_id']
|
||||
},
|
||||
"utls": {
|
||||
"enabled": True,
|
||||
"fingerprint": vless_params['fingerprint']
|
||||
}
|
||||
},
|
||||
"uuid": vless_params['uuid']
|
||||
},
|
||||
{
|
||||
"tag": "direct",
|
||||
"type": "direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def generate_direct_config() -> bool:
|
||||
"""Generate a direct connection config (bypass all proxies)"""
|
||||
try:
|
||||
config = {
|
||||
"dns": {
|
||||
"independent_cache": True
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"disabled": True,
|
||||
"timestamp": True
|
||||
},
|
||||
"route": {
|
||||
"final": "direct",
|
||||
"auto_detect_interface": True
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "mixed-in",
|
||||
"type": "mixed",
|
||||
"sniff": True,
|
||||
"users": [],
|
||||
"listen": "0.0.0.0",
|
||||
"listen_port": PROXY_PORT,
|
||||
"set_system_proxy": False
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "direct",
|
||||
"type": "direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
|
||||
# Reload sing-box
|
||||
try:
|
||||
urllib.request.urlopen("http://127.0.0.1:9090/reload", timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Failed to generate direct config: {e}")
|
||||
return False
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple HTTP Web Server for VPN Proxy Control
|
||||
Provides a web UI to manage sing-box subscriptions
|
||||
"""
|
||||
|
||||
import socketserver
|
||||
from app.config import PORT
|
||||
from app.api import ProxyControlHandler
|
||||
|
||||
class ThreadingHTTPServer(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def main():
|
||||
"""Start the web server"""
|
||||
# Use ThreadingTCPServer for concurrent requests
|
||||
with ThreadingHTTPServer(("", PORT), ProxyControlHandler) as httpd:
|
||||
print(f"[WebUI] Server started on port {PORT}")
|
||||
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -296,6 +296,7 @@ async function checkServerLatencies(nodes) {
|
||||
try {
|
||||
const res = await fetch('/ping-target', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server: node.server, port: node.server_port || 443 })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
Reference in New Issue
Block a user