feat: Реализован новый веб-интерфейс и бэкенд для управления VPN-клиентом, включая списки серверов, элементы управления прокси и опции конфигурации.
This commit is contained in:
0
web/app/__init__.py
Normal file
0
web/app/__init__.py
Normal file
742
web/app/api.py
Normal file
742
web/app/api.py
Normal file
@@ -0,0 +1,742 @@
|
||||
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)
|
||||
31
web/app/config.py
Normal file
31
web/app/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
108
web/app/network.py
Normal file
108
web/app/network.py
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
80
web/app/storage.py
Normal file
80
web/app/storage.py
Normal file
@@ -0,0 +1,80 @@
|
||||
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
|
||||
26
web/app/utils.py
Normal file
26
web/app/utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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
Normal file
176
web/app/vless.py
Normal file
@@ -0,0 +1,176 @@
|
||||
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
|
||||
37
web/components/connection_info.html
Normal file
37
web/components/connection_info.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-black border border-[#00ff41]/20 p-4 font-mono">
|
||||
<div class="text-[11px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-3 flex items-center gap-2">
|
||||
<i data-lucide="plug" class="w-4 h-4"></i> Connection_Settings
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<!-- HTTP Proxy -->
|
||||
<div class="flex items-center gap-3 p-3 bg-[#0a0a0a] border border-[#00ff41]/10">
|
||||
<span class="text-[11px] uppercase opacity-50 text-[#00ff41] w-16">HTTP</span>
|
||||
<input type="text" id="httpProxyUrl" readonly value="Loading..."
|
||||
class="flex-grow bg-transparent text-sm text-[#00ff41]/80 focus:outline-none cursor-pointer font-mono"
|
||||
title="Click to copy" />
|
||||
<button onclick="copyToClipboard('httpProxyUrl', this)"
|
||||
class="text-[10px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase px-2 py-1 border border-[#00ff41]/20">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- SOCKS5 Proxy -->
|
||||
<div class="flex items-center gap-3 p-3 bg-[#0a0a0a] border border-[#00ff41]/10">
|
||||
<span class="text-[11px] uppercase opacity-50 text-[#00ff41] w-16">SOCKS5</span>
|
||||
<input type="text" id="socks5ProxyUrl" readonly value="Loading..."
|
||||
class="flex-grow bg-transparent text-sm text-[#00ff41]/80 focus:outline-none cursor-pointer font-mono"
|
||||
title="Click to copy" />
|
||||
<button onclick="copyToClipboard('socks5ProxyUrl', this)"
|
||||
class="text-[10px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase px-2 py-1 border border-[#00ff41]/20">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-[11px] opacity-40 text-[#00ff41] flex items-center gap-2">
|
||||
<i data-lucide="info" class="w-3 h-3"></i>
|
||||
Use these URLs in browser/app proxy settings
|
||||
</div>
|
||||
</div>
|
||||
50
web/components/fallback_config.html
Normal file
50
web/components/fallback_config.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!-- Fallback Proxy Configuration (Expanded) -->
|
||||
<div id="fallbackSection" class="flex flex-col bg-black border border-[#00ff41]/30 overflow-hidden font-mono">
|
||||
<div class="bg-[#111] px-5 py-3 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
||||
<span class="text-[11px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
||||
<i data-lucide="git-branch" class="w-4 h-4"></i> Fallback_Proxy_Settings
|
||||
</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Enable/Disable Toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<span id="fallbackToggleLabel" class="text-[11px] opacity-50 uppercase text-[#00ff41]">OFF</span>
|
||||
<div class="relative">
|
||||
<input type="checkbox" id="fallbackToggle" class="sr-only peer">
|
||||
<div
|
||||
class="w-10 h-5 bg-[#1a1a1a] border border-[#00ff41]/20 rounded-full peer-checked:bg-[#00ff41]/20 peer-checked:border-[#00ff41]/50 transition-all">
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-0.5 top-0.5 w-4 h-4 bg-[#00ff41]/30 rounded-full peer-checked:translate-x-5 peer-checked:bg-[#00ff41] transition-all">
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<button id="saveFallbackBtn"
|
||||
class="text-[11px] opacity-50 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase px-3 py-1 border border-[#00ff41]/20 hover:border-[#00ff41]/50">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Host/Port inputs -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="text-[11px] opacity-50 uppercase text-[#00ff41] block mb-1">Host</label>
|
||||
<input type="text" id="fallbackHost" placeholder="192.168.50.111"
|
||||
class="w-full bg-[#0a0a0a] border border-[#00ff41]/20 p-3 text-sm text-[#00ff41] focus:outline-none focus:border-[#00ff41]/50 placeholder:text-[#00ff41]/20" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[11px] opacity-50 uppercase text-[#00ff41] block mb-1">Port</label>
|
||||
<input type="number" id="fallbackPort" placeholder="8080"
|
||||
class="w-full bg-[#0a0a0a] border border-[#00ff41]/20 p-3 text-sm text-[#00ff41] focus:outline-none focus:border-[#00ff41]/50 placeholder:text-[#00ff41]/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="text-[11px] opacity-50 text-[#00ff41] flex items-start gap-2">
|
||||
<i data-lucide="info" class="w-4 h-4 shrink-0 mt-0.5"></i>
|
||||
<span>URLTest auto-selects fastest proxy. Re-apply subscription after changes.</span>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="fallbackStatus" class="text-sm text-[#00ff41]/50 uppercase hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
15
web/components/footer.html
Normal file
15
web/components/footer.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!-- Footer -->
|
||||
<footer class="z-30 bg-[#0d0d0d] border-t border-[#00ff41]/10 py-2 mt-auto">
|
||||
<div
|
||||
class="max-w-[1400px] mx-auto px-6 flex justify-between items-center text-[10px] uppercase tracking-[0.2em] opacity-40 text-[#00ff41]">
|
||||
<div class="flex gap-6">
|
||||
<span>Core: 4.1.0-Release</span>
|
||||
<span>Proxy: HTTP/8080</span>
|
||||
</div>
|
||||
<div class="hidden md:flex gap-6">
|
||||
<span>AES-256-GCM</span>
|
||||
<span class="text-[#00ff41] opacity-100 font-bold tracking-normal">SESSION: <span
|
||||
id="sessionId">...</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
27
web/components/header.html
Normal file
27
web/components/header.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!-- Header -->
|
||||
<header class="z-30 border-b border-[#00ff41]/20 bg-black/90 backdrop-blur-md sticky top-0">
|
||||
<div class="max-w-[1400px] mx-auto px-4 md:px-6 py-3 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative p-1.5 border border-[#00ff41]/50 shadow-[0_0_10px_rgba(0,255,65,0.2)] bg-black">
|
||||
<i data-lucide="terminal" class="w-5 h-5 animate-pulse text-[#00ff41]"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-black tracking-[0.2em] uppercase text-[#00ff41]">
|
||||
VPN<span class="text-white">_</span>CLIENT
|
||||
</h1>
|
||||
<p class="text-[10px] opacity-40 uppercase tracking-widest text-[#00ff41]">Secure Shell v4.2</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-6 text-[11px] uppercase">
|
||||
<div class="flex flex-col items-end border-r border-[#00ff41]/20 pr-6">
|
||||
<span class="opacity-30 text-[#00ff41]">Status</span>
|
||||
<span id="headerStatus" class="text-white font-bold text-sm">STANDBY</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="opacity-30 text-[#00ff41]">Traffic_Used</span>
|
||||
<span id="trafficValue" class="text-blue-400 font-bold text-sm">-- / --</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
18
web/components/logs.html
Normal file
18
web/components/logs.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- Terminal Logs -->
|
||||
<div class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono min-h-[180px]">
|
||||
<div class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
||||
<span class="text-[11px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
||||
<div class="w-1.5 h-1.5 bg-[#00ff41] rounded-full animate-ping"></div> Logs
|
||||
</span>
|
||||
<button id="clearLogs"
|
||||
class="text-[10px] opacity-30 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="logsContainer"
|
||||
class="flex-grow p-3 overflow-y-auto custom-scrollbar text-[11px] space-y-1 opacity-80 font-mono">
|
||||
<div class="flex gap-2">
|
||||
<span class="opacity-20 text-[#00ff41]">[SYSTEM]</span>
|
||||
<span class="text-[#00ff41] animate-pulse">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
web/components/map.html
Normal file
4
web/components/map.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Placeholder for World Map Component -->
|
||||
<div class="hidden p-4 bg-black border border-[#00ff41]/30">
|
||||
<div class="text-[11px] uppercase opacity-50 text-[#00ff41]">Global_Map_View // Not_Implemented</div>
|
||||
</div>
|
||||
90
web/components/proxy_chain.html
Normal file
90
web/components/proxy_chain.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!-- Proxy Chain Visualization (Expanded) -->
|
||||
<div id="proxyChainSection" class="bg-black border border-[#00ff41]/30 p-5 font-mono">
|
||||
<div class="text-[11px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-5 flex items-center gap-2">
|
||||
<i data-lucide="git-branch" class="w-4 h-4"></i> Proxy_Chain_Visualization
|
||||
</div>
|
||||
|
||||
<div id="proxyChain" class="flex items-stretch gap-4 text-sm justify-center py-4">
|
||||
<!-- You -->
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-[#00ff41] flex items-center justify-center bg-[#00ff41]/10">
|
||||
<i data-lucide="user" class="w-6 h-6 text-[#00ff41]"></i>
|
||||
</div>
|
||||
<span class="uppercase opacity-60 text-[#00ff41] text-[10px]">You</span>
|
||||
</div>
|
||||
|
||||
<!-- Arrow to branch -->
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-[3px] bg-[#00ff41]"></div>
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41]"></i>
|
||||
</div>
|
||||
|
||||
<!-- Branch: Fallback + VPN -->
|
||||
<div id="chainBranch" class="flex flex-col gap-3 py-1">
|
||||
<!-- Fallback branch -->
|
||||
<div id="chainFallbackRow" class="flex items-center gap-3 transition-all duration-300 hidden">
|
||||
<div class="w-5 h-[3px] bg-[#00ff41]/50 rounded-full"></div>
|
||||
<div id="chainFallbackBox"
|
||||
class="relative w-16 h-12 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
||||
<i data-lucide="server" class="w-5 h-5 text-[#00ff41]/50"></i>
|
||||
<div id="chainFallbackX" class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
||||
<i data-lucide="x" class="w-6 h-6 text-red-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span id="chainFallbackLabel"
|
||||
class="uppercase text-[10px] opacity-60 text-[#00ff41]">Fallback</span>
|
||||
<span id="chainFallbackLatency" class="text-xs text-[#00ff41]/50">--ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VPN branch -->
|
||||
<div id="chainVPNRow" class="flex items-center gap-3 transition-all duration-300">
|
||||
<div class="w-5 h-[3px] bg-[#00ff41]/50 rounded-full"></div>
|
||||
<div id="chainVPNBox"
|
||||
class="relative w-16 h-12 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
||||
<i data-lucide="shield" class="w-5 h-5 text-[#00ff41]/50"></i>
|
||||
<div id="chainVPNX" class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
||||
<i data-lucide="x" class="w-6 h-6 text-red-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span id="chainVPNLabel" class="uppercase text-[10px] opacity-60 text-[#00ff41]">VPN</span>
|
||||
<span id="chainVPNLatency" class="text-xs text-[#00ff41]/50">--ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direct branch (when proxy disabled) -->
|
||||
<div id="chainDirectRow" class="flex items-center gap-3 transition-all duration-300 hidden">
|
||||
<div class="w-5 h-[3px] bg-yellow-500/50 rounded-full"></div>
|
||||
<div id="chainDirectBox"
|
||||
class="relative w-16 h-12 border-2 border-yellow-500/50 flex items-center justify-center bg-yellow-500/5 transition-all">
|
||||
<i data-lucide="zap" class="w-5 h-5 text-yellow-500/70"></i>
|
||||
</div>
|
||||
<span class="uppercase text-[10px] opacity-60 text-yellow-500">DIRECT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow from branch -->
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-[#00ff41]"></i>
|
||||
<div class="w-8 h-[3px] bg-[#00ff41]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Internet -->
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<div
|
||||
class="w-14 h-14 rounded-full border-2 border-blue-400 flex items-center justify-center bg-blue-400/10">
|
||||
<i data-lucide="globe" class="w-6 h-6 text-blue-400"></i>
|
||||
</div>
|
||||
<span class="uppercase opacity-60 text-blue-400 text-[10px]">Internet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain Status -->
|
||||
<div id="chainStatus"
|
||||
class="mt-4 text-sm text-center py-2 px-4 bg-[#0a0a0a] border border-[#00ff41]/20 text-[#00ff41] uppercase">
|
||||
No proxy configured
|
||||
</div>
|
||||
</div>
|
||||
14
web/components/server_list.html
Normal file
14
web/components/server_list.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- Server List as Cards -->
|
||||
<div class="flex flex-col bg-[#0a0a0a]/50 border border-[#00ff41]/10 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-[#00ff41]/10 bg-black flex justify-between items-center shrink-0">
|
||||
<span class="text-[11px] uppercase tracking-[0.2em] font-bold text-[#00ff41]">Servers</span>
|
||||
<span id="serverCount" class="text-[10px] opacity-40 text-[#00ff41]">0 endpoints</span>
|
||||
</div>
|
||||
|
||||
<div id="serverListContainer" class="overflow-y-auto custom-scrollbar p-3 grid grid-cols-3 gap-2 max-h-[280px]">
|
||||
<!-- Cards populated by JS -->
|
||||
<div class="col-span-3 text-center py-6 text-[#00ff41]/30 text-xs uppercase">
|
||||
No_Data // Awaiting_Sync
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
web/components/subscription.html
Normal file
25
web/components/subscription.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- Subscription Input -->
|
||||
<div class="flex flex-col gap-2 p-4 bg-[#0a0a0a] border border-[#00ff41]/30 relative">
|
||||
<label class="text-[11px] uppercase tracking-widest opacity-50 text-[#00ff41]">Subscription_URL</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-grow">
|
||||
<i data-lucide="link" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#00ff41]/40"></i>
|
||||
<!-- Hidden full URL input -->
|
||||
<input type="hidden" id="subUrlFull" />
|
||||
<!-- Masked display input -->
|
||||
<input type="text" id="subUrlInput" placeholder="https://provider.com/..."
|
||||
class="w-full bg-black border border-[#00ff41]/20 py-2.5 pl-10 pr-10 text-sm tracking-wider focus:outline-none focus:border-[#00ff41] transition-all placeholder:text-[#00ff41]/20 text-[#00ff41]" />
|
||||
<!-- Toggle visibility -->
|
||||
<button id="toggleUrlVisibility" type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[#00ff41]/40 hover:text-[#00ff41] transition-colors"
|
||||
title="Show/Hide full URL">
|
||||
<i data-lucide="eye-off" class="w-4 h-4" id="urlEyeIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button id="fetchServersBtn"
|
||||
class="flex items-center justify-center gap-2 bg-[#00ff41] text-black px-4 py-2.5 text-xs font-black uppercase tracking-widest hover:bg-white hover:shadow-[0_0_15px_rgba(0,255,65,0.4)] transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i data-lucide="download" class="w-4 h-4" id="fetchIcon"></i>
|
||||
<span id="fetchText">Sync</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
32
web/components/switch.html
Normal file
32
web/components/switch.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!-- Master Proxy Toggle + Status -->
|
||||
<div class="bg-black border-2 border-[#00ff41]/30 p-5 relative">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<!-- Toggle -->
|
||||
<div class="flex items-center gap-5">
|
||||
<label class="big-toggle">
|
||||
<input type="checkbox" id="masterProxyToggle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<div>
|
||||
<div id="proxyModeLabel" class="text-xl font-black tracking-wider text-[#00ff41]">
|
||||
VPN_MODE
|
||||
</div>
|
||||
<div id="proxyModeSubtitle" class="text-[11px] opacity-50 text-[#00ff41] uppercase">
|
||||
Traffic routed via proxy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status -->
|
||||
<div id="quickStatus" class="text-right hidden md:flex gap-6">
|
||||
<div>
|
||||
<div class="text-[11px] opacity-40 uppercase text-[#00ff41]">Uptime</div>
|
||||
<div id="uptimeDisplay" class="text-lg font-bold text-[#00ff41]">00:00:00</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] opacity-40 uppercase text-[#00ff41]">Current_IP</div>
|
||||
<div id="currentIpDisplay" class="text-lg font-bold text-white">---.---.---.---</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1182
web/index.html
1182
web/index.html
File diff suppressed because it is too large
Load Diff
1106
web/server.py
1106
web/server.py
File diff suppressed because it is too large
Load Diff
130
web/static/css/style.css
Normal file
130
web/static/css/style.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap");
|
||||
|
||||
:root {
|
||||
--color-neon: #00ff41;
|
||||
--color-bg: #050505;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-neon);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-neon);
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
.matrix-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 0, 0, 0.06),
|
||||
rgba(0, 255, 0, 0.02),
|
||||
rgba(0, 0, 255, 0.06)
|
||||
);
|
||||
background-size:
|
||||
100% 2px,
|
||||
3px 100%;
|
||||
}
|
||||
|
||||
/* Big Toggle Switch */
|
||||
.big-toggle {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.big-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.big-toggle .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #1a1a1a;
|
||||
border: 2px solid rgba(0, 255, 65, 0.3);
|
||||
border-radius: 40px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.big-toggle .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: rgba(0, 255, 65, 0.4);
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.big-toggle input:checked + .slider {
|
||||
background-color: rgba(0, 255, 65, 0.2);
|
||||
border-color: #00ff41;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
|
||||
.big-toggle input:checked + .slider:before {
|
||||
transform: translateX(40px);
|
||||
background-color: #00ff41;
|
||||
box-shadow: 0 0 10px #00ff41;
|
||||
}
|
||||
|
||||
/* Server Card */
|
||||
.server-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 65, 0.15);
|
||||
}
|
||||
|
||||
.server-card.active {
|
||||
border-color: #00ff41 !important;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.blink-1 {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
.blink-2 {
|
||||
animation: blink 1s infinite 0.2s;
|
||||
}
|
||||
.blink-3 {
|
||||
animation: blink 1s infinite 0.4s;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
732
web/static/js/app.js
Normal file
732
web/static/js/app.js
Normal file
@@ -0,0 +1,732 @@
|
||||
// --- Icons Initialization ---
|
||||
lucide.createIcons();
|
||||
|
||||
// --- State ---
|
||||
const state = {
|
||||
nodes: [],
|
||||
logs: [],
|
||||
activeNode: null,
|
||||
isFetching: false,
|
||||
isConnecting: false,
|
||||
subscriptionUrl: '',
|
||||
sessionId: Math.random().toString(16).substr(2, 8).toUpperCase(),
|
||||
userInfo: null,
|
||||
proxyEnabled: true,
|
||||
urlVisible: false,
|
||||
serverStartTime: 0,
|
||||
uptimeInterval: null
|
||||
};
|
||||
|
||||
// --- DOM Elements ---
|
||||
const els = {
|
||||
subUrlInput: document.getElementById('subUrlInput'),
|
||||
subUrlFull: document.getElementById('subUrlFull'),
|
||||
toggleUrlVisibility: document.getElementById('toggleUrlVisibility'),
|
||||
urlEyeIcon: document.getElementById('urlEyeIcon'),
|
||||
fetchServersBtn: document.getElementById('fetchServersBtn'),
|
||||
fetchIcon: document.getElementById('fetchIcon'),
|
||||
fetchText: document.getElementById('fetchText'),
|
||||
serverListContainer: document.getElementById('serverListContainer'),
|
||||
serverCount: document.getElementById('serverCount'),
|
||||
logsContainer: document.getElementById('logsContainer'),
|
||||
clearLogs: document.getElementById('clearLogs'),
|
||||
headerStatus: document.getElementById('headerStatus'),
|
||||
sessionId: document.getElementById('sessionId'),
|
||||
trafficValue: document.getElementById('trafficValue'),
|
||||
// Master toggle
|
||||
masterProxyToggle: document.getElementById('masterProxyToggle'),
|
||||
proxyModeLabel: document.getElementById('proxyModeLabel'),
|
||||
proxyModeSubtitle: document.getElementById('proxyModeSubtitle'),
|
||||
currentIpDisplay: document.getElementById('currentIpDisplay'),
|
||||
// Fallback Proxy elements
|
||||
fallbackToggle: document.getElementById('fallbackToggle'),
|
||||
fallbackToggleLabel: document.getElementById('fallbackToggleLabel'),
|
||||
fallbackHost: document.getElementById('fallbackHost'),
|
||||
fallbackPort: document.getElementById('fallbackPort'),
|
||||
saveFallbackBtn: document.getElementById('saveFallbackBtn'),
|
||||
fallbackStatus: document.getElementById('fallbackStatus'),
|
||||
// Proxy Chain visualization
|
||||
chainFallbackRow: document.getElementById('chainFallbackRow'),
|
||||
chainFallbackBox: document.getElementById('chainFallbackBox'),
|
||||
chainFallbackLabel: document.getElementById('chainFallbackLabel'),
|
||||
chainFallbackLatency: document.getElementById('chainFallbackLatency'),
|
||||
chainFallbackX: document.getElementById('chainFallbackX'),
|
||||
chainVPNRow: document.getElementById('chainVPNRow'),
|
||||
chainVPNBox: document.getElementById('chainVPNBox'),
|
||||
chainVPNLabel: document.getElementById('chainVPNLabel'),
|
||||
chainVPNLatency: document.getElementById('chainVPNLatency'),
|
||||
chainVPNX: document.getElementById('chainVPNX'),
|
||||
chainDirectRow: document.getElementById('chainDirectRow'),
|
||||
chainStatus: document.getElementById('chainStatus'),
|
||||
// Connection settings
|
||||
httpProxyUrl: document.getElementById('httpProxyUrl'),
|
||||
socks5ProxyUrl: document.getElementById('socks5ProxyUrl'),
|
||||
// Uptime
|
||||
uptimeDisplay: document.getElementById('uptimeDisplay')
|
||||
};
|
||||
|
||||
els.sessionId.textContent = state.sessionId;
|
||||
|
||||
// --- Helpers ---
|
||||
function formatBytes(bytes, decimals = 1) {
|
||||
if (!+bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function maskUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.hostname}/...`;
|
||||
} catch {
|
||||
return url.length > 30 ? url.substring(0, 30) + '...' : url;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startUptimeTimer() {
|
||||
if (state.uptimeInterval) clearInterval(state.uptimeInterval);
|
||||
if (!state.serverStartTime || state.serverStartTime <= 0) {
|
||||
els.uptimeDisplay.textContent = '00:00:00';
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust local offset if needed, but simple diff is usually enough
|
||||
const update = () => {
|
||||
const now = Date.now() / 1000;
|
||||
const elapsed = Math.floor(now - state.serverStartTime);
|
||||
if (elapsed >= 0) {
|
||||
els.uptimeDisplay.textContent = formatUptime(elapsed);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
state.uptimeInterval = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
function stopUptimeTimer() {
|
||||
if (state.uptimeInterval) {
|
||||
clearInterval(state.uptimeInterval);
|
||||
state.uptimeInterval = null;
|
||||
}
|
||||
els.uptimeDisplay.textContent = '00:00:00';
|
||||
}
|
||||
|
||||
// --- URL Visibility Toggle ---
|
||||
els.toggleUrlVisibility.addEventListener('click', () => {
|
||||
state.urlVisible = !state.urlVisible;
|
||||
updateUrlDisplay();
|
||||
});
|
||||
|
||||
function updateUrlDisplay() {
|
||||
const fullUrl = els.subUrlFull.value || state.subscriptionUrl;
|
||||
if (state.urlVisible) {
|
||||
els.subUrlInput.value = fullUrl;
|
||||
els.urlEyeIcon.setAttribute('data-lucide', 'eye');
|
||||
} else {
|
||||
els.subUrlInput.value = maskUrl(fullUrl);
|
||||
els.urlEyeIcon.setAttribute('data-lucide', 'eye-off');
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
els.subUrlInput.addEventListener('input', () => {
|
||||
// When user types, store full URL
|
||||
els.subUrlFull.value = els.subUrlInput.value;
|
||||
state.subscriptionUrl = els.subUrlInput.value;
|
||||
});
|
||||
|
||||
els.subUrlInput.addEventListener('blur', () => {
|
||||
// On blur, mask if not visible
|
||||
if (!state.urlVisible && els.subUrlFull.value) {
|
||||
els.subUrlInput.value = maskUrl(els.subUrlFull.value);
|
||||
}
|
||||
});
|
||||
|
||||
els.subUrlInput.addEventListener('focus', () => {
|
||||
// On focus, show full for editing
|
||||
if (els.subUrlFull.value) {
|
||||
els.subUrlInput.value = els.subUrlFull.value;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Logger ---
|
||||
function addLog(msg, type = 'info') {
|
||||
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
|
||||
|
||||
const logEl = document.createElement('div');
|
||||
logEl.className = 'flex gap-2 items-start leading-tight animate-in fade-in slide-in-from-left-1 duration-300';
|
||||
|
||||
let colorClass = 'text-[#00ff41]/70';
|
||||
let prefix = '>>';
|
||||
if (type === 'success') { colorClass = 'text-blue-400'; prefix = 'OK.'; }
|
||||
if (type === 'warning') { colorClass = 'text-yellow-500'; prefix = '!!'; }
|
||||
if (type === 'error') { colorClass = 'text-red-500'; prefix = 'ERR'; }
|
||||
|
||||
logEl.innerHTML = `
|
||||
<span class="opacity-20 shrink-0 tracking-tighter text-[#00ff41]">[${time}]</span>
|
||||
<span class="${colorClass}">${prefix} ${msg}</span>
|
||||
`;
|
||||
|
||||
els.logsContainer.insertBefore(logEl, els.logsContainer.lastElementChild);
|
||||
els.logsContainer.scrollTop = els.logsContainer.scrollHeight;
|
||||
|
||||
// Limit logs
|
||||
while (els.logsContainer.children.length > 50) {
|
||||
els.logsContainer.removeChild(els.logsContainer.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Global copy function for proxy URLs
|
||||
function copyToClipboard(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('text-blue-400');
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.classList.remove('text-blue-400');
|
||||
}, 1500);
|
||||
addLog(`COPIED: ${input.value}`, 'success');
|
||||
});
|
||||
}
|
||||
|
||||
els.clearLogs.addEventListener('click', () => {
|
||||
const lastChild = els.logsContainer.lastElementChild;
|
||||
els.logsContainer.innerHTML = '';
|
||||
els.logsContainer.appendChild(lastChild);
|
||||
addLog('LOGS_CLEARED', 'info');
|
||||
});
|
||||
|
||||
// --- UI Rendering ---
|
||||
function renderNodes() {
|
||||
els.serverListContainer.innerHTML = '';
|
||||
|
||||
if (state.nodes.length === 0) {
|
||||
els.serverListContainer.innerHTML = `
|
||||
<div class="col-span-3 text-center py-6 text-[#00ff41]/30 text-xs uppercase">
|
||||
No_Data // Awaiting_Sync
|
||||
</div>`;
|
||||
els.serverCount.textContent = '0 endpoints';
|
||||
return;
|
||||
}
|
||||
|
||||
els.serverCount.textContent = `${state.nodes.length} endpoints`;
|
||||
|
||||
state.nodes.forEach((node, index) => {
|
||||
const isActive = state.activeNode && state.activeNode.tag === node.tag;
|
||||
const card = document.createElement('div');
|
||||
card.className = `server-card cursor-pointer p-2 bg-black border border-[#00ff41]/20 hover:border-[#00ff41]/50 ${isActive ? 'active' : ''}`;
|
||||
card.onclick = () => handleConnect(node);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<div class="w-1.5 h-1.5 rounded-full ${isActive ? 'bg-[#00ff41] animate-pulse' : 'bg-[#00ff41]/30'}"></div>
|
||||
<span class="text-[10px] font-bold text-white uppercase truncate">${node.tag}</span>
|
||||
</div>
|
||||
<div class="text-[9px] opacity-40 text-[#00ff41] truncate">${node.type}</div>
|
||||
<div id="ping-${index}" class="text-[10px] font-mono text-[#00ff41]/70 mt-1">--</div>
|
||||
`;
|
||||
els.serverListContainer.appendChild(card);
|
||||
});
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateMasterToggleUI() {
|
||||
if (state.proxyEnabled) {
|
||||
els.proxyModeLabel.textContent = 'VPN_MODE';
|
||||
els.proxyModeLabel.classList.remove('text-yellow-500');
|
||||
els.proxyModeLabel.classList.add('text-[#00ff41]');
|
||||
els.proxyModeSubtitle.textContent = 'Traffic routed via proxy';
|
||||
els.headerStatus.textContent = state.activeNode ? 'TUNNEL_UP' : 'STANDBY';
|
||||
|
||||
// Show VPN row, hide direct
|
||||
els.chainVPNRow.classList.remove('hidden');
|
||||
els.chainDirectRow.classList.add('hidden');
|
||||
|
||||
// Start uptime if connected
|
||||
if (state.activeNode && !state.uptimeInterval) {
|
||||
startUptimeTimer();
|
||||
}
|
||||
} else {
|
||||
els.proxyModeLabel.textContent = 'DIRECT_MODE';
|
||||
els.proxyModeLabel.classList.remove('text-[#00ff41]');
|
||||
els.proxyModeLabel.classList.add('text-yellow-500');
|
||||
els.proxyModeSubtitle.textContent = 'Bypass proxy — direct connection';
|
||||
els.headerStatus.textContent = 'DIRECT';
|
||||
|
||||
// Hide VPN/Fallback, show direct
|
||||
els.chainVPNRow.classList.add('hidden');
|
||||
els.chainFallbackRow.classList.add('hidden');
|
||||
els.chainDirectRow.classList.remove('hidden');
|
||||
|
||||
els.chainStatus.innerHTML = `<span class="text-yellow-400">●</span> Direct connection active`;
|
||||
|
||||
// Stop uptime timer
|
||||
stopUptimeTimer();
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateTrafficUI(info) {
|
||||
if (!info) return;
|
||||
const used = formatBytes((info.download || 0) + (info.upload || 0));
|
||||
const total = info.total ? formatBytes(info.total) : '∞';
|
||||
els.trafficValue.textContent = `${used} / ${total}`;
|
||||
}
|
||||
|
||||
async function checkServerLatencies(nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const pingEl = document.getElementById(`ping-${i}`);
|
||||
if (pingEl) pingEl.textContent = '...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/ping-target', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ server: node.server, port: node.server_port || 443 })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (pingEl) {
|
||||
if (data.latency && data.latency !== -1) {
|
||||
pingEl.textContent = data.latency + 'ms';
|
||||
if (data.latency > 300) pingEl.style.color = 'rgb(239, 68, 68)';
|
||||
else if (data.latency < 100) pingEl.style.color = '#00ff41';
|
||||
else pingEl.style.color = 'rgb(234, 179, 8)';
|
||||
} else {
|
||||
pingEl.textContent = 'Timeout';
|
||||
pingEl.style.color = 'rgb(239, 68, 68)';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (pingEl) pingEl.textContent = 'Err';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnectionSpeed(fullTest = false) {
|
||||
els.currentIpDisplay.textContent = '...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/test-connection?speed=${fullTest}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
els.currentIpDisplay.textContent = 'ERROR';
|
||||
} else {
|
||||
els.currentIpDisplay.textContent = data.ip || '---.---.---.---';
|
||||
}
|
||||
} catch (e) {
|
||||
els.currentIpDisplay.textContent = 'NET_ERR';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Master Proxy Toggle ---
|
||||
els.masterProxyToggle.addEventListener('change', async () => {
|
||||
state.proxyEnabled = els.masterProxyToggle.checked;
|
||||
updateMasterToggleUI();
|
||||
|
||||
addLog(state.proxyEnabled ? 'PROXY_ENABLED' : 'PROXY_DISABLED_DIRECT_MODE', state.proxyEnabled ? 'success' : 'warning');
|
||||
|
||||
try {
|
||||
const res = await fetch('/proxy-enabled', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: state.proxyEnabled })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
addLog('CONFIG_APPLIED', 'success');
|
||||
|
||||
// Force fetch new status to get correct fallback/uptime state
|
||||
setTimeout(async () => {
|
||||
await fetchStatus();
|
||||
checkConnectionSpeed(false);
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('TOGGLE_FAILED: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
updateProxyChain();
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
async function handleFetchNodes() {
|
||||
// Get the actual URL (from hidden field or input)
|
||||
let url = els.subUrlFull.value || els.subUrlInput.value;
|
||||
url = url.trim();
|
||||
|
||||
if (!url || url.endsWith('/...')) {
|
||||
addLog('ERROR_MISSING_URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.subscriptionUrl = url;
|
||||
els.subUrlFull.value = url;
|
||||
|
||||
state.isFetching = true;
|
||||
els.fetchIcon.classList.add('hidden');
|
||||
els.fetchText.innerHTML = '<i data-lucide="refresh-cw" class="w-4 h-4 animate-spin"></i>';
|
||||
lucide.createIcons();
|
||||
|
||||
addLog(`FETCHING: ${maskUrl(url)}`, 'info');
|
||||
|
||||
try {
|
||||
const res = await fetch('/fetch-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.servers) {
|
||||
state.nodes = data.servers;
|
||||
state.config = data.config;
|
||||
state.userInfo = data.userInfo;
|
||||
|
||||
renderNodes();
|
||||
updateTrafficUI(state.userInfo);
|
||||
updateUrlDisplay();
|
||||
addLog(`SYNC_OK: ${state.nodes.length} endpoints`, 'success');
|
||||
|
||||
addLog('CHECKING_LATENCY...', 'info');
|
||||
checkServerLatencies(state.nodes);
|
||||
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog(`SYNC_FAILED: ${e.message}`, 'error');
|
||||
} finally {
|
||||
state.isFetching = false;
|
||||
els.fetchIcon.classList.remove('hidden');
|
||||
els.fetchText.textContent = 'Sync';
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect(node) {
|
||||
if (!state.proxyEnabled) {
|
||||
addLog('ENABLE_PROXY_FIRST', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.activeNode && state.activeNode.tag === node.tag && !state.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeNode = node;
|
||||
state.isConnecting = true;
|
||||
// Block UI
|
||||
els.serverListContainer.classList.add('pointer-events-none', 'opacity-50');
|
||||
renderNodes();
|
||||
|
||||
addLog(`CONNECTING: ${node.tag}`, 'warning');
|
||||
|
||||
try {
|
||||
const res = await fetch('/apply-subscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: state.config,
|
||||
selectedServer: node.tag,
|
||||
subUrl: state.subscriptionUrl,
|
||||
userInfo: state.userInfo
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setTimeout(async () => {
|
||||
state.isConnecting = false;
|
||||
els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50');
|
||||
els.headerStatus.textContent = 'TUNNEL_UP';
|
||||
renderNodes();
|
||||
addLog(`CONNECTED: ${node.tag}`, 'success');
|
||||
|
||||
// Wait for next status update to sync start time properly
|
||||
await fetchStatus();
|
||||
|
||||
// Fallback: start timer optimistically if status didn't catch it yet
|
||||
if (!state.uptimeInterval) {
|
||||
state.serverStartTime = Date.now() / 1000;
|
||||
startUptimeTimer();
|
||||
}
|
||||
|
||||
checkConnectionSpeed(false);
|
||||
updateProxyChain();
|
||||
|
||||
}, 800);
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
state.isConnecting = false;
|
||||
els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50');
|
||||
state.activeNode = null;
|
||||
renderNodes();
|
||||
addLog(`CONNECT_FAILED: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.proxyPort) {
|
||||
const host = window.location.hostname;
|
||||
els.httpProxyUrl.value = `http://${host}:${data.proxyPort}`;
|
||||
els.socks5ProxyUrl.value = `socks5://${host}:${data.proxyPort}`;
|
||||
}
|
||||
|
||||
if (data.proxyEnabled !== undefined) {
|
||||
state.proxyEnabled = data.proxyEnabled;
|
||||
els.masterProxyToggle.checked = state.proxyEnabled;
|
||||
updateMasterToggleUI();
|
||||
}
|
||||
|
||||
if (data.startTime) {
|
||||
state.serverStartTime = data.startTime;
|
||||
}
|
||||
|
||||
if (data.active && data.tag && state.proxyEnabled) {
|
||||
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
||||
|
||||
if (currentTag !== data.tag) {
|
||||
const fullNode = state.nodes.find(n => n.tag === data.tag);
|
||||
|
||||
if (fullNode) {
|
||||
state.activeNode = fullNode;
|
||||
} else {
|
||||
state.activeNode = { tag: data.tag, server: data.server, port: '?', type: 'UNKNOWN' };
|
||||
}
|
||||
|
||||
renderNodes();
|
||||
checkConnectionSpeed(false);
|
||||
|
||||
// Restart timer with new server start time
|
||||
startUptimeTimer();
|
||||
}
|
||||
|
||||
// Ensure timer is running if active
|
||||
if (!state.uptimeInterval) startUptimeTimer();
|
||||
} else if (!data.active && state.proxyEnabled) {
|
||||
// Proxy enabled but backend says not active/configured
|
||||
state.activeNode = null;
|
||||
renderNodes();
|
||||
stopUptimeTimer();
|
||||
}
|
||||
|
||||
// Always update chain visualization
|
||||
updateProxyChain();
|
||||
|
||||
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSaved() {
|
||||
try {
|
||||
addLog('SYSTEM_BOOT...', 'info');
|
||||
const res = await fetch('/subscription');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.saved && data.url) {
|
||||
state.subscriptionUrl = data.url;
|
||||
els.subUrlFull.value = data.url;
|
||||
els.subUrlInput.value = maskUrl(data.url);
|
||||
state.userInfo = data.userInfo;
|
||||
updateTrafficUI(state.userInfo);
|
||||
|
||||
await handleFetchNodes();
|
||||
} else {
|
||||
addLog('NO_SAVED_CONFIG', 'warning');
|
||||
}
|
||||
|
||||
await fetchStatus();
|
||||
|
||||
} catch (e) {
|
||||
addLog('BOOT_ERROR', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
els.fetchServersBtn.addEventListener('click', handleFetchNodes);
|
||||
|
||||
// --- Fallback Proxy Functions ---
|
||||
async function loadFallbackConfig() {
|
||||
try {
|
||||
const res = await fetch('/fallback-config');
|
||||
const data = await res.json();
|
||||
|
||||
els.fallbackToggle.checked = data.enabled || false;
|
||||
els.fallbackHost.value = data.host || '192.168.50.111';
|
||||
els.fallbackPort.value = data.port || 8080;
|
||||
|
||||
updateFallbackUI(data.enabled || false);
|
||||
} catch (e) {
|
||||
addLog('FALLBACK_LOAD_FAILED', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFallbackUI(enabled) {
|
||||
els.fallbackToggleLabel.textContent = enabled ? 'ON' : 'OFF';
|
||||
els.fallbackToggleLabel.classList.toggle('opacity-100', enabled);
|
||||
els.fallbackToggleLabel.classList.toggle('opacity-50', !enabled);
|
||||
}
|
||||
|
||||
async function saveFallbackConfig() {
|
||||
const enabled = els.fallbackToggle.checked;
|
||||
const host = els.fallbackHost.value.trim();
|
||||
const port = parseInt(els.fallbackPort.value) || 8080;
|
||||
|
||||
if (enabled && !host) {
|
||||
addLog('FALLBACK_HOST_REQUIRED', 'error');
|
||||
showFallbackStatus('Host required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/fallback-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled, host, port })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
addLog(enabled ? 'FALLBACK_ON' : 'FALLBACK_OFF', 'success');
|
||||
showFallbackStatus('Applied!', 'success');
|
||||
updateFallbackUI(enabled);
|
||||
updateProxyChain();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
addLog(`FALLBACK_FAILED: ${e.message}`, 'error');
|
||||
showFallbackStatus('Failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showFallbackStatus(msg, type = 'info') {
|
||||
els.fallbackStatus.textContent = msg;
|
||||
els.fallbackStatus.classList.remove('hidden', 'text-[#00ff41]/50', 'text-red-500', 'text-blue-400');
|
||||
|
||||
if (type === 'success') els.fallbackStatus.classList.add('text-blue-400');
|
||||
else if (type === 'error') els.fallbackStatus.classList.add('text-red-500');
|
||||
else els.fallbackStatus.classList.add('text-[#00ff41]/50');
|
||||
|
||||
setTimeout(() => {
|
||||
els.fallbackStatus.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- Proxy Chain Visualization ---
|
||||
async function updateProxyChain() {
|
||||
// Always update UI based on current state first for immediate feedback
|
||||
if (!state.proxyEnabled) {
|
||||
// Direct mode is handled by updateMasterToggleUI generally,
|
||||
// but we ensure clean slate here too if needed
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If we are in VPN mode but no node selected (Standby)
|
||||
if (!state.activeNode) {
|
||||
els.chainVPNLabel.textContent = 'VPN (Standby)';
|
||||
els.chainVPNLatency.textContent = '--ms';
|
||||
els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainVPNBox.classList.add('border-[#00ff41]/30', 'border-dashed');
|
||||
els.chainStatus.innerHTML = '<span class="text-orange-400">●</span> VPN Standby - Select Server';
|
||||
// Show X to indicate no connection through VPN yet
|
||||
els.chainVPNX.classList.remove('hidden');
|
||||
els.chainVPNX.classList.add('flex');
|
||||
} else {
|
||||
els.chainVPNBox.classList.remove('border-dashed');
|
||||
}
|
||||
|
||||
const res = await fetch('/active-proxy');
|
||||
const data = await res.json();
|
||||
|
||||
|
||||
// Reset all states
|
||||
els.chainFallbackBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainFallbackBox.classList.add('border-[#00ff41]/30');
|
||||
els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainVPNBox.classList.add('border-[#00ff41]/30');
|
||||
els.chainFallbackX.classList.add('hidden');
|
||||
els.chainFallbackX.classList.remove('flex');
|
||||
els.chainVPNX.classList.add('hidden');
|
||||
els.chainVPNX.classList.remove('flex');
|
||||
|
||||
if (!data.configured) {
|
||||
els.chainStatus.innerHTML = '<span class="text-orange-400">●</span> VPN Standby';
|
||||
els.chainFallbackRow.classList.add('hidden');
|
||||
els.chainVPNLabel.textContent = 'Select Server';
|
||||
// Visual cue for disconnected VPN
|
||||
els.chainVPNX.classList.remove('hidden');
|
||||
els.chainVPNX.classList.add('flex');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update VPN label
|
||||
els.chainVPNLabel.textContent = data.vpnTag || 'VPN';
|
||||
els.chainVPNLatency.textContent = data.vpnLatency ? `${data.vpnLatency}ms` : '--ms';
|
||||
|
||||
if (data.fallbackEnabled) {
|
||||
els.chainFallbackRow.classList.remove('hidden');
|
||||
els.chainFallbackLabel.textContent = data.fallbackHost || 'Fallback';
|
||||
els.chainFallbackLatency.textContent = data.fallbackLatency ? `${data.fallbackLatency}ms` : '--ms';
|
||||
|
||||
if (data.fallbackReachable) {
|
||||
els.chainFallbackBox.classList.remove('border-[#00ff41]/30');
|
||||
els.chainFallbackBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainStatus.innerHTML = `<span class="text-[#00ff41]">●</span> Fallback active (${data.fallbackLatency}ms)`;
|
||||
} else {
|
||||
els.chainFallbackX.classList.remove('hidden');
|
||||
els.chainFallbackX.classList.add('flex');
|
||||
els.chainVPNBox.classList.remove('border-[#00ff41]/30');
|
||||
els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainStatus.innerHTML = `<span class="text-yellow-400">●</span> VPN active (fallback down)`;
|
||||
}
|
||||
} else {
|
||||
els.chainFallbackRow.classList.add('hidden');
|
||||
els.chainVPNBox.classList.remove('border-[#00ff41]/30');
|
||||
els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20');
|
||||
els.chainStatus.innerHTML = `<span class="text-[#00ff41]">●</span> VPN direct`;
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
} catch (e) {
|
||||
els.chainStatus.textContent = 'Failed to load';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback Event Listeners
|
||||
els.saveFallbackBtn.addEventListener('click', saveFallbackConfig);
|
||||
els.fallbackToggle.addEventListener('change', saveFallbackConfig);
|
||||
|
||||
// --- Init ---
|
||||
addLog('TERMINAL_READY', 'info');
|
||||
loadSaved();
|
||||
loadFallbackConfig();
|
||||
updateProxyChain();
|
||||
|
||||
// Periodically update proxy chain
|
||||
setInterval(updateProxyChain, 10000);
|
||||
Reference in New Issue
Block a user