feat: Добавлена начальная реализация веб-интерфейса VPN-клиента с управлением подписками и отображением серверов.

This commit is contained in:
2025-12-27 21:46:14 +03:00
parent 560c4b8661
commit 957608b0f0
2 changed files with 758 additions and 812 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,9 @@ import socketserver
import urllib.request
import urllib.error
import uuid
from urllib.parse import parse_qs
import socket
import time
from urllib.parse import parse_qs, unquote
from pathlib import Path
PORT = 3456
@@ -50,12 +52,13 @@ def get_system_info() -> dict:
}
def save_subscription(url: str, selected_server: str = None):
"""Save subscription URL and selected server to file"""
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
"selectedServer": selected_server,
"userInfo": user_info
}
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
@@ -70,10 +73,113 @@ def load_subscription() -> dict:
return None
def measure_tcp_latency(host: str, port: int, timeout: float = 2.0) -> int:
"""Measure TCP latency to a host:port in milliseconds"""
start_time = time.time()
try:
with socket.create_connection((host, port), timeout=timeout):
latency = (time.time() - start_time) * 1000
return int(latency)
except Exception:
return -1
def measure_proxy_performance(enable_speed_test: bool = False) -> dict:
"""Measure proxy latency, speed and public IP via local proxy"""
proxy_url = "http://127.0.0.1:8082"
proxies = {"http": proxy_url, "https": proxy_url}
# 1. Measure Latency (Ping)
latency = "Timeout"
try:
start_time = time.time()
# Use a reliable endpoint for ping
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
req = urllib.request.Request("http://www.gstatic.com/generate_204", headers={"User-Agent": "singbox-test"})
with opener.open(req, timeout=5) as response:
lat_ms = int((time.time() - start_time) * 1000)
latency = f"{lat_ms}ms"
except Exception as e:
latency = "Error"
# 2. Get Public IP (IPv4)
ip = "Unknown"
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
# Use v4.ident.me to force IPv4
req = urllib.request.Request("http://v4.ident.me", headers={"User-Agent": "curl/7.68.0"})
with opener.open(req, timeout=5) as response:
ip = response.read().decode('utf-8').strip()
except Exception:
# Fallback to ipify if ident.me fails or returns garbage
try:
req = urllib.request.Request("http://api.ipify.org", headers={"User-Agent": "curl/7.68.0"})
with opener.open(req, timeout=5) as response:
ip = response.read().decode('utf-8').strip()
except Exception:
pass
# 3. Measure Download Speed
speed_mbps = 0.0
if enable_speed_test:
test_files = [
# Tele2 Speedtest (Usually very reliable and fast)
("https://speedtest.selectel.ru/100MB", 100),
# ThinkBroadband (Reliable backup)
("https://speedtest.selectel.ru/1GB", 1000)
]
for url, size_mb in test_files:
try:
print(f"[WebUI] Testing speed with: {url}")
start_time = time.time()
opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
# Set a longer timeout for speed tests
with opener.open(url, timeout=30) as response:
downloaded = 0
# Larger chunk size for better throughput measurement
chunk_size = 1024 * 256 # 256KB chunks
# Download for at least 2 seconds or up to 25MB for accurate measurement
min_test_duration = 2.0 # seconds
max_download_bytes = 25 * 1024 * 1024 # 25MB
while True:
chunk = response.read(chunk_size)
if not chunk:
break
downloaded += len(chunk)
elapsed = time.time() - start_time
# Stop if we've downloaded enough AND tested for minimum duration
if downloaded >= max_download_bytes or (elapsed >= min_test_duration and downloaded >= 2 * 1024 * 1024):
break
duration = time.time() - start_time
if duration > 0.1 and downloaded > 0:
# Calculate speed in Mbps (megabits per second)
# downloaded bytes * 8 bits/byte / 1,000,000 / seconds
speed_mbps = round((downloaded * 8) / (1000 * 1000) / duration, 1)
print(f"[WebUI] Speed test: downloaded {downloaded / (1024*1024):.1f}MB in {duration:.1f}s = {speed_mbps} Mbps")
break # Stop if successful
except Exception as e:
print(f"[WebUI] Speed test failed for {url}: {e}")
continue
result = {
"latency": latency,
"ip": ip
}
if enable_speed_test:
# If speed is still 0.0 but we tried, return Error or 0.0
result["speed"] = f"{speed_mbps} Mbps"
return result
def parse_vless_url(url: str) -> dict:
"""Parse VLESS URL and extract connection parameters"""
from urllib.parse import urlparse, parse_qs, unquote
if not url.startswith("vless://"):
raise ValueError("URL must start with vless://")
@@ -194,6 +300,10 @@ def generate_vless_config(vless_params: dict) -> dict:
return config
class ThreadingHTTPServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
"""HTTP Request Handler for Proxy Control"""
@@ -224,6 +334,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
self.get_status()
elif self.path == "/subscription":
self.get_subscription()
elif self.path.startswith("/test-connection"):
self.test_connection()
elif self.path.startswith("/static/"):
self.serve_static()
else:
@@ -237,6 +349,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
self.fetch_subscription()
elif self.path == "/apply-subscription":
self.apply_subscription()
elif self.path == "/ping-target":
self.ping_target()
else:
self.send_error(404)
@@ -290,10 +404,43 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
self.send_json({
"saved": True,
"url": sub.get("url"),
"selectedServer": sub.get("selectedServer")
"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"""
@@ -372,6 +519,20 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
with urllib.request.urlopen(req, timeout=15) as response:
config_text = response.read().decode("utf-8")
config = json.loads(config_text)
# Parse User Info header
user_info_header = response.headers.get("subscription-userinfo", "")
user_info = {}
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
@@ -389,7 +550,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
"tag": outbound.get("tag", "unknown"),
"type": outbound.get("type"),
"server": outbound.get("server", "unknown"),
"port": outbound.get("server_port", 443)
"server_port": outbound.get("server_port", 443)
})
if not servers:
@@ -399,7 +560,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
self.send_json({
"success": True,
"servers": servers,
"config": config
"config": config,
"userInfo": user_info
})
except json.JSONDecodeError:
@@ -417,6 +579,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
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)
@@ -448,7 +611,6 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
new_outbounds.insert(0, selected_outbound)
# Update route - remove incompatible fields and set only final
# Some subscription configs have route.rules with "action" field which is not supported
routes = {
"final": selected_tag,
"auto_detect_interface": True
@@ -463,8 +625,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
config.pop("platform", None)
config.pop("experimental", None)
# Replace TUN inbounds with mixed proxy (TUN requires privileges in Docker)
# Use mixed proxy on 127.0.0.1:2412 instead
# Replace TUN inbounds with mixed proxy
config["inbounds"] = [
{
"tag": "mixed-in",
@@ -488,7 +649,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
# Save subscription URL for persistence
if sub_url:
save_subscription(sub_url, selected_tag)
save_subscription(sub_url, selected_tag, user_info)
# Trigger reload via internal control port
try:
@@ -509,7 +670,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
def main():
"""Start the web server"""
with socketserver.TCPServer(("", PORT), ProxyControlHandler) as httpd:
# 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()