feat: Добавлена начальная реализация веб-интерфейса VPN-клиента с управлением подписками и отображением серверов.
This commit is contained in:
1330
web/index.html
1330
web/index.html
File diff suppressed because it is too large
Load Diff
190
web/server.py
190
web/server.py
@@ -12,7 +12,9 @@ import socketserver
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import parse_qs
|
import socket
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, unquote
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PORT = 3456
|
PORT = 3456
|
||||||
@@ -50,12 +52,13 @@ def get_system_info() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_subscription(url: str, selected_server: str = None):
|
def save_subscription(url: str, selected_server: str = None, user_info: dict = None):
|
||||||
"""Save subscription URL and selected server to file"""
|
"""Save subscription URL, selected server and user info to file"""
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
data = {
|
data = {
|
||||||
"url": url,
|
"url": url,
|
||||||
"selectedServer": selected_server
|
"selectedServer": selected_server,
|
||||||
|
"userInfo": user_info
|
||||||
}
|
}
|
||||||
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
SUBSCRIPTION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
@@ -70,10 +73,113 @@ def load_subscription() -> dict:
|
|||||||
return None
|
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:
|
def parse_vless_url(url: str) -> dict:
|
||||||
"""Parse VLESS URL and extract connection parameters"""
|
"""Parse VLESS URL and extract connection parameters"""
|
||||||
from urllib.parse import urlparse, parse_qs, unquote
|
|
||||||
|
|
||||||
if not url.startswith("vless://"):
|
if not url.startswith("vless://"):
|
||||||
raise ValueError("URL must start with vless://")
|
raise ValueError("URL must start with vless://")
|
||||||
|
|
||||||
@@ -194,6 +300,10 @@ def generate_vless_config(vless_params: dict) -> dict:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadingHTTPServer(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
||||||
"""HTTP Request Handler for Proxy Control"""
|
"""HTTP Request Handler for Proxy Control"""
|
||||||
|
|
||||||
@@ -224,6 +334,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.get_status()
|
self.get_status()
|
||||||
elif self.path == "/subscription":
|
elif self.path == "/subscription":
|
||||||
self.get_subscription()
|
self.get_subscription()
|
||||||
|
elif self.path.startswith("/test-connection"):
|
||||||
|
self.test_connection()
|
||||||
elif self.path.startswith("/static/"):
|
elif self.path.startswith("/static/"):
|
||||||
self.serve_static()
|
self.serve_static()
|
||||||
else:
|
else:
|
||||||
@@ -237,6 +349,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.fetch_subscription()
|
self.fetch_subscription()
|
||||||
elif self.path == "/apply-subscription":
|
elif self.path == "/apply-subscription":
|
||||||
self.apply_subscription()
|
self.apply_subscription()
|
||||||
|
elif self.path == "/ping-target":
|
||||||
|
self.ping_target()
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@@ -290,11 +404,44 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_json({
|
self.send_json({
|
||||||
"saved": True,
|
"saved": True,
|
||||||
"url": sub.get("url"),
|
"url": sub.get("url"),
|
||||||
"selectedServer": sub.get("selectedServer")
|
"selectedServer": sub.get("selectedServer"),
|
||||||
|
"userInfo": sub.get("userInfo")
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
self.send_json({"saved": False})
|
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):
|
def apply_config(self):
|
||||||
"""Apply new config from VLESS URL"""
|
"""Apply new config from VLESS URL"""
|
||||||
try:
|
try:
|
||||||
@@ -372,6 +519,20 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
with urllib.request.urlopen(req, timeout=15) as response:
|
with urllib.request.urlopen(req, timeout=15) as response:
|
||||||
config_text = response.read().decode("utf-8")
|
config_text = response.read().decode("utf-8")
|
||||||
config = json.loads(config_text)
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
self.send_json({"success": False, "error": f"Ошибка HTTP: {e.code}"}, 400)
|
||||||
return
|
return
|
||||||
@@ -389,7 +550,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
"tag": outbound.get("tag", "unknown"),
|
"tag": outbound.get("tag", "unknown"),
|
||||||
"type": outbound.get("type"),
|
"type": outbound.get("type"),
|
||||||
"server": outbound.get("server", "unknown"),
|
"server": outbound.get("server", "unknown"),
|
||||||
"port": outbound.get("server_port", 443)
|
"server_port": outbound.get("server_port", 443)
|
||||||
})
|
})
|
||||||
|
|
||||||
if not servers:
|
if not servers:
|
||||||
@@ -399,7 +560,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_json({
|
self.send_json({
|
||||||
"success": True,
|
"success": True,
|
||||||
"servers": servers,
|
"servers": servers,
|
||||||
"config": config
|
"config": config,
|
||||||
|
"userInfo": user_info
|
||||||
})
|
})
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -417,6 +579,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
config = data.get("config")
|
config = data.get("config")
|
||||||
selected_tag = data.get("selectedServer")
|
selected_tag = data.get("selectedServer")
|
||||||
sub_url = data.get("subUrl") # URL подписки для сохранения
|
sub_url = data.get("subUrl") # URL подписки для сохранения
|
||||||
|
user_info = data.get("userInfo")
|
||||||
|
|
||||||
if not config:
|
if not config:
|
||||||
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
self.send_json({"success": False, "error": "Конфигурация не указана"}, 400)
|
||||||
@@ -448,7 +611,6 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
new_outbounds.insert(0, selected_outbound)
|
new_outbounds.insert(0, selected_outbound)
|
||||||
|
|
||||||
# Update route - remove incompatible fields and set only final
|
# Update route - remove incompatible fields and set only final
|
||||||
# Some subscription configs have route.rules with "action" field which is not supported
|
|
||||||
routes = {
|
routes = {
|
||||||
"final": selected_tag,
|
"final": selected_tag,
|
||||||
"auto_detect_interface": True
|
"auto_detect_interface": True
|
||||||
@@ -463,8 +625,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
config.pop("platform", None)
|
config.pop("platform", None)
|
||||||
config.pop("experimental", None)
|
config.pop("experimental", None)
|
||||||
|
|
||||||
# Replace TUN inbounds with mixed proxy (TUN requires privileges in Docker)
|
# Replace TUN inbounds with mixed proxy
|
||||||
# Use mixed proxy on 127.0.0.1:2412 instead
|
|
||||||
config["inbounds"] = [
|
config["inbounds"] = [
|
||||||
{
|
{
|
||||||
"tag": "mixed-in",
|
"tag": "mixed-in",
|
||||||
@@ -488,7 +649,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
# Save subscription URL for persistence
|
# Save subscription URL for persistence
|
||||||
if sub_url:
|
if sub_url:
|
||||||
save_subscription(sub_url, selected_tag)
|
save_subscription(sub_url, selected_tag, user_info)
|
||||||
|
|
||||||
# Trigger reload via internal control port
|
# Trigger reload via internal control port
|
||||||
try:
|
try:
|
||||||
@@ -509,7 +670,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Start the web server"""
|
"""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] Server started on port {PORT}")
|
||||||
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
|
|||||||
Reference in New Issue
Block a user