diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ad29a07..146aee0 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -11,8 +11,6 @@ - 🔄 **Переключение серверов** — в один клик - 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются - - --- ## 🔧 Требования @@ -80,25 +78,57 @@ docker compose up -d ## 🌐 Порты -| Порт | Назначение | URL | -|------|------------|-----| -| `3456` | Веб-интерфейс | http://localhost:3456 | -| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` | -| `9090` | API управления (внутренний) | — | +| Порт | Назначение | URL | +| ------ | --------------------------- | --------------------- | +| `3456` | Веб-интерфейс | http://localhost:3456 | +| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` | +| `9090` | API управления (внутренний) | — | + +### 🔧 Изменение порта прокси + +Если порт `8080` уже занят, можно запустить на другом порту (например, `8082`): + +**Способ 1: Через переменную окружения (Mac/Linux)** + +```bash +PROXY_PORT=8082 docker compose up -d +``` + +**Способ 2: Через переменную окружения (Windows PowerShell)** + +```powershell +$env:PROXY_PORT=8082; docker compose up -d +``` + +**Способ 3: Через .env файл (универсальный)** + +Создайте файл `.env` в корне проекта: + +``` +PROXY_PORT=8082 +``` + +Затем запустите: + +```bash +docker compose up -d +``` + +> 💡 URL подключения изменится на `http://127.0.0.1:8082` и `socks5://127.0.0.1:8082` --- ## 📋 Управление контейнером -| Действие | Команда | -|----------|---------| -| Посмотреть статус | `docker ps` | -| Посмотреть логи | `docker logs --tail 50 sing-proxy` | -| Остановить | `docker compose stop` | -| Запустить снова | `docker compose start` | -| Перезапустить | `docker compose restart` | -| Полностью удалить | `docker compose down` | -| Пересобрать | `docker compose up -d --build` | +| Действие | Команда | +| ----------------- | ---------------------------------- | +| Посмотреть статус | `docker ps` | +| Посмотреть логи | `docker logs --tail 50 sing-proxy` | +| Остановить | `docker compose stop` | +| Запустить снова | `docker compose start` | +| Перезапустить | `docker compose restart` | +| Полностью удалить | `docker compose down` | +| Пересобрать | `docker compose up -d --build` | --- diff --git a/web/__pycache__/server.cpython-311.pyc b/web/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000..bddfb3b Binary files /dev/null and b/web/__pycache__/server.cpython-311.pyc differ diff --git a/web/index.html b/web/index.html index 4e4a2c7..65ce8a6 100644 --- a/web/index.html +++ b/web/index.html @@ -238,9 +238,183 @@ + +
+
+ Proxy_Chain +
+ +
+ +
+
+ +
+ You +
+ + +
+
+ +
+ + +
+ + + + +
+
+
+ + + +
+ VPN +
+
+ + +
+ +
+
+ + +
+
+ +
+ Net +
+
+ + +
+ No proxy configured +
+
+ + +
+
+ Connection_Settings +
+ +
+ +
+ HTTP + + +
+ + +
+ SOCKS5 + + +
+
+ +
+ + Use these URLs in browser/app proxy settings +
+
+ + +
+
+ + Fallback_Proxy + +
+ + + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + URLTest auto-selects fastest proxy. Re-apply subscription after changes. +
+ + + +
+
+
+ class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono h-[200px] lg:h-[250px]">
{ + 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 = ''; @@ -624,6 +832,12 @@ const res = await fetch('/status'); const data = await res.json(); + // Update proxy URLs with actual port + if (data.proxyPort) { + els.httpProxyUrl.value = `http://127.0.0.1:${data.proxyPort}`; + els.socks5ProxyUrl.value = `socks5://127.0.0.1:${data.proxyPort}`; + } + if (data.active && data.tag) { const currentTag = state.activeNode ? state.activeNode.tag : null; @@ -689,9 +903,151 @@ checkConnectionSpeed(true); }); + // --- 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); + addLog('FALLBACK_CONFIG_LOADED', 'info'); + } catch (e) { + addLog('FALLBACK_CONFIG_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 is 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_ENABLED' : 'FALLBACK_DISABLED', 'success'); + const msg = data.regenerated ? 'Applied!' : 'Saved!'; + showFallbackStatus(msg, 'success'); + updateFallbackUI(enabled); + // Refresh proxy chain visualization + updateProxyChain(); + } else { + throw new Error(data.error); + } + } catch (e) { + addLog(`FALLBACK_SAVE_FAILED: ${e.message}`, 'error'); + showFallbackStatus('Save 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() { + try { + 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.textContent = 'No proxy configured'; + els.chainFallbackRow.classList.add('hidden'); + els.chainVPNLabel.textContent = 'VPN'; + return; + } + + // Update VPN label + els.chainVPNLabel.textContent = data.vpnTag || 'VPN'; + + if (data.fallbackEnabled) { + // Show fallback branch + els.chainFallbackRow.classList.remove('hidden'); + els.chainFallbackLabel.textContent = data.fallbackHost || 'Fallback'; + + if (data.fallbackReachable) { + // Fallback is active (green border, no X) + els.chainFallbackBox.classList.remove('border-[#00ff41]/30'); + els.chainFallbackBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20'); + els.chainStatus.innerHTML = ` Fallback active (${data.fallbackLatency}ms)`; + } else { + // Fallback unreachable - show X, VPN is active + 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 = ` VPN active (fallback down)`; + } + } else { + // No fallback - hide fallback row, VPN is active + els.chainFallbackRow.classList.add('hidden'); + els.chainVPNBox.classList.remove('border-[#00ff41]/30'); + els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20'); + els.chainStatus.innerHTML = ` VPN direct`; + } + + // Reinitialize lucide icons for new X elements + 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); // Blink animation style const style = document.createElement('style'); diff --git a/web/server.py b/web/server.py index 47d9da8..560e3bc 100644 --- a/web/server.py +++ b/web/server.py @@ -27,6 +27,14 @@ DATA_DIR = BASE_DIR / "data" CONFIG_FILE = DATA_DIR / "client.json" HWID_FILE = DATA_DIR / "hwid" SUBSCRIPTION_FILE = DATA_DIR / "subscription.json" +FALLBACK_FILE = DATA_DIR / "fallback.json" + +# Default fallback proxy settings +DEFAULT_FALLBACK = { + "enabled": False, + "host": "192.168.50.111", + "port": 8080 +} def get_hwid() -> str: @@ -74,6 +82,27 @@ def load_subscription() -> dict: 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 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() @@ -339,6 +368,10 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): 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() else: self.send_error(404) @@ -352,6 +385,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): self.apply_subscription() elif self.path == "/ping-target": self.ping_target() + elif self.path == "/fallback-config": + self.save_fallback_config_endpoint() else: self.send_error(404) @@ -375,6 +410,175 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): 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("http://127.0.0.1:9090/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() @@ -395,7 +599,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): self.send_json({ "active": config_exists, "tag": current_tag, - "server": current_server + "server": current_server, + "proxyPort": PROXY_PORT }) def get_subscription(self): @@ -672,12 +877,49 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400) return - # Add selected server as main outbound - new_outbounds.insert(0, selected_outbound) + # Load fallback configuration + fallback = load_fallback_config() + fallback_enabled = fallback.get("enabled", False) + fallback_host = fallback.get("host", "") + fallback_port = fallback.get("port", 8080) - # Update route - remove incompatible fields and set only final + # 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": selected_tag, + "final": final_tag, "auto_detect_interface": True } @@ -703,7 +945,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler): } ] - config["outbounds"] = new_outbounds + config["outbounds"] = final_outbounds config["route"] = routes # Ensure data directory exists