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 @@
+
+
+ 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