feat: добавляет визуализацию цепочки прокси, настройки подключения и интерфейс для конфигурации резервного прокси.
This commit is contained in:
@@ -11,8 +11,6 @@
|
|||||||
- 🔄 **Переключение серверов** — в один клик
|
- 🔄 **Переключение серверов** — в один клик
|
||||||
- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются
|
- 💾 **Сохранение настроек** — URL и выбранный сервер сохраняются
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Требования
|
## 🔧 Требования
|
||||||
@@ -81,17 +79,49 @@ docker compose up -d
|
|||||||
## 🌐 Порты
|
## 🌐 Порты
|
||||||
|
|
||||||
| Порт | Назначение | URL |
|
| Порт | Назначение | URL |
|
||||||
|------|------------|-----|
|
| ------ | --------------------------- | --------------------- |
|
||||||
| `3456` | Веб-интерфейс | http://localhost:3456 |
|
| `3456` | Веб-интерфейс | http://localhost:3456 |
|
||||||
| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` |
|
| `8080` | HTTP/SOCKS5 прокси | `127.0.0.1:8080` |
|
||||||
| `9090` | API управления (внутренний) | — |
|
| `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 ps` |
|
||||||
| Посмотреть логи | `docker logs --tail 50 sing-proxy` |
|
| Посмотреть логи | `docker logs --tail 50 sing-proxy` |
|
||||||
| Остановить | `docker compose stop` |
|
| Остановить | `docker compose stop` |
|
||||||
|
|||||||
BIN
web/__pycache__/server.cpython-311.pyc
Normal file
BIN
web/__pycache__/server.cpython-311.pyc
Normal file
Binary file not shown.
360
web/index.html
360
web/index.html
@@ -238,9 +238,183 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy Chain Visualization -->
|
||||||
|
<div id="proxyChainSection" class="bg-black border border-[#00ff41]/20 p-4 font-mono">
|
||||||
|
<div
|
||||||
|
class="text-[9px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-4 flex items-center gap-2">
|
||||||
|
<i data-lucide="git-branch" class="w-3 h-3"></i> Proxy_Chain
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="proxyChain" class="flex items-stretch gap-3 text-[10px]">
|
||||||
|
<!-- You -->
|
||||||
|
<div class="flex flex-col items-center justify-center gap-1">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full border-2 border-[#00ff41] flex items-center justify-center bg-[#00ff41]/10">
|
||||||
|
<i data-lucide="user" class="w-5 h-5 text-[#00ff41]"></i>
|
||||||
|
</div>
|
||||||
|
<span class="uppercase opacity-60 text-[#00ff41] text-[8px]">You</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow to branch -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-6 h-[2px] bg-[#00ff41]"></div>
|
||||||
|
<i data-lucide="chevron-right" class="w-3 h-3 text-[#00ff41]"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branch: Fallback + VPN -->
|
||||||
|
<div id="chainBranch" class="flex flex-col gap-2 py-1">
|
||||||
|
<!-- Fallback branch -->
|
||||||
|
<div id="chainFallbackRow" class="flex items-center gap-2 transition-all duration-300 hidden">
|
||||||
|
<div class="w-4 h-[2px] bg-[#00ff41]/50 rounded-full"></div>
|
||||||
|
<div id="chainFallbackBox"
|
||||||
|
class="relative w-14 h-9 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
||||||
|
<i data-lucide="server" class="w-4 h-4 text-[#00ff41]/50"></i>
|
||||||
|
<!-- X overlay for unavailable -->
|
||||||
|
<div id="chainFallbackX"
|
||||||
|
class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
||||||
|
<i data-lucide="x" class="w-5 h-5 text-red-500"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="chainFallbackLabel"
|
||||||
|
class="uppercase text-[7px] opacity-40 text-[#00ff41] max-w-[55px] truncate">Fallback</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VPN branch -->
|
||||||
|
<div id="chainVPNRow" class="flex items-center gap-2 transition-all duration-300">
|
||||||
|
<div class="w-4 h-[2px] bg-[#00ff41]/50 rounded-full"></div>
|
||||||
|
<div id="chainVPNBox"
|
||||||
|
class="relative w-14 h-9 border-2 border-[#00ff41]/30 flex items-center justify-center bg-[#0a0a0a] transition-all">
|
||||||
|
<i data-lucide="shield" class="w-4 h-4 text-[#00ff41]/50"></i>
|
||||||
|
<!-- X overlay for unavailable -->
|
||||||
|
<div id="chainVPNX"
|
||||||
|
class="absolute inset-0 hidden items-center justify-center bg-black/60">
|
||||||
|
<i data-lucide="x" class="w-5 h-5 text-red-500"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="chainVPNLabel"
|
||||||
|
class="uppercase text-[7px] opacity-40 text-[#00ff41] max-w-[55px] truncate">VPN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow from branch -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i data-lucide="chevron-right" class="w-3 h-3 text-[#00ff41]"></i>
|
||||||
|
<div class="w-6 h-[2px] bg-[#00ff41]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Internet -->
|
||||||
|
<div class="flex flex-col items-center justify-center gap-1">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full border-2 border-blue-400 flex items-center justify-center bg-blue-400/10">
|
||||||
|
<i data-lucide="globe" class="w-5 h-5 text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<span class="uppercase opacity-60 text-blue-400 text-[8px]">Net</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status message -->
|
||||||
|
<div id="chainStatus" class="mt-3 text-[9px] text-center opacity-50 text-[#00ff41] uppercase">
|
||||||
|
No proxy configured
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Settings -->
|
||||||
|
<div class="bg-black border border-[#00ff41]/20 p-4 font-mono">
|
||||||
|
<div
|
||||||
|
class="text-[9px] uppercase font-bold tracking-[0.3em] text-[#00ff41] mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="plug" class="w-3 h-3"></i> Connection_Settings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
<!-- HTTP Proxy -->
|
||||||
|
<div class="flex items-center gap-2 p-2 bg-[#0a0a0a] border border-[#00ff41]/10">
|
||||||
|
<span class="text-[8px] uppercase opacity-50 text-[#00ff41] w-12">HTTP</span>
|
||||||
|
<input type="text" id="httpProxyUrl" readonly value="Loading..."
|
||||||
|
class="flex-grow bg-transparent text-[10px] text-[#00ff41]/70 focus:outline-none cursor-pointer font-mono"
|
||||||
|
title="Click to copy" />
|
||||||
|
<button onclick="copyToClipboard('httpProxyUrl', this)"
|
||||||
|
class="text-[8px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SOCKS5 Proxy -->
|
||||||
|
<div class="flex items-center gap-2 p-2 bg-[#0a0a0a] border border-[#00ff41]/10">
|
||||||
|
<span class="text-[8px] uppercase opacity-50 text-[#00ff41] w-12">SOCKS5</span>
|
||||||
|
<input type="text" id="socks5ProxyUrl" readonly value="Loading..."
|
||||||
|
class="flex-grow bg-transparent text-[10px] text-[#00ff41]/70 focus:outline-none cursor-pointer font-mono"
|
||||||
|
title="Click to copy" />
|
||||||
|
<button onclick="copyToClipboard('socks5ProxyUrl', this)"
|
||||||
|
class="text-[8px] text-[#00ff41]/50 hover:text-[#00ff41] transition-colors uppercase">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-[8px] opacity-30 text-[#00ff41]">
|
||||||
|
<i data-lucide="info" class="w-3 h-3 inline"></i>
|
||||||
|
Use these URLs in browser/app proxy settings
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback Proxy Configuration -->
|
||||||
|
<div id="fallbackSection"
|
||||||
|
class="flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono transition-opacity">
|
||||||
|
<div
|
||||||
|
class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
||||||
|
<span
|
||||||
|
class="text-[9px] uppercase font-bold tracking-[0.3em] flex items-center gap-2 text-[#00ff41]">
|
||||||
|
<i data-lucide="git-branch" class="w-3 h-3"></i> Fallback_Proxy
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Enable/Disable Toggle -->
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<span id="fallbackToggleLabel"
|
||||||
|
class="text-[8px] opacity-50 uppercase text-[#00ff41]">OFF</span>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="checkbox" id="fallbackToggle" class="sr-only peer">
|
||||||
|
<div
|
||||||
|
class="w-8 h-4 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-3 h-3 bg-[#00ff41]/30 rounded-full peer-checked:translate-x-4 peer-checked:bg-[#00ff41] transition-all">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button id="saveFallbackBtn"
|
||||||
|
class="text-[8px] opacity-30 hover:opacity-100 hover:text-[#00ff41] transition-opacity uppercase">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<!-- Host/Port inputs -->
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="text-[8px] opacity-40 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]/10 p-2 text-[10px] text-[#00ff41]/80 focus:outline-none focus:border-[#00ff41]/30" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-[8px] opacity-40 uppercase text-[#00ff41] block mb-1">Port</label>
|
||||||
|
<input type="number" id="fallbackPort" placeholder="8080"
|
||||||
|
class="w-full bg-[#0a0a0a] border border-[#00ff41]/10 p-2 text-[10px] text-[#00ff41]/80 focus:outline-none focus:border-[#00ff41]/30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="text-[8px] opacity-40 text-[#00ff41]">
|
||||||
|
<i data-lucide="info" class="w-3 h-3 inline"></i>
|
||||||
|
URLTest auto-selects fastest proxy. Re-apply subscription after changes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div id="fallbackStatus" class="text-[9px] text-[#00ff41]/50 uppercase hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Logs -->
|
<!-- Terminal Logs -->
|
||||||
<div
|
<div
|
||||||
class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono h-[300px] lg:h-[400px]">
|
class="flex-grow flex flex-col bg-black border border-[#00ff41]/20 overflow-hidden font-mono h-[200px] lg:h-[250px]">
|
||||||
<div
|
<div
|
||||||
class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
class="bg-[#111] px-4 py-2 border-b border-[#00ff41]/10 flex justify-between items-center shrink-0">
|
||||||
<span
|
<span
|
||||||
@@ -315,7 +489,26 @@
|
|||||||
headerStatus: document.getElementById('headerStatus'),
|
headerStatus: document.getElementById('headerStatus'),
|
||||||
sessionId: document.getElementById('sessionId'),
|
sessionId: document.getElementById('sessionId'),
|
||||||
trafficValue: document.getElementById('trafficValue'),
|
trafficValue: document.getElementById('trafficValue'),
|
||||||
testSpeedBtn: document.getElementById('testSpeedBtn')
|
testSpeedBtn: document.getElementById('testSpeedBtn'),
|
||||||
|
// 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'),
|
||||||
|
chainFallbackX: document.getElementById('chainFallbackX'),
|
||||||
|
chainVPNBox: document.getElementById('chainVPNBox'),
|
||||||
|
chainVPNLabel: document.getElementById('chainVPNLabel'),
|
||||||
|
chainVPNX: document.getElementById('chainVPNX'),
|
||||||
|
chainStatus: document.getElementById('chainStatus'),
|
||||||
|
// Connection settings
|
||||||
|
httpProxyUrl: document.getElementById('httpProxyUrl'),
|
||||||
|
socks5ProxyUrl: document.getElementById('socks5ProxyUrl')
|
||||||
};
|
};
|
||||||
|
|
||||||
els.sessionId.textContent = state.sessionId;
|
els.sessionId.textContent = state.sessionId;
|
||||||
@@ -357,6 +550,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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', () => {
|
els.clearLogs.addEventListener('click', () => {
|
||||||
const lastChild = els.logsContainer.lastElementChild;
|
const lastChild = els.logsContainer.lastElementChild;
|
||||||
els.logsContainer.innerHTML = '';
|
els.logsContainer.innerHTML = '';
|
||||||
@@ -624,6 +832,12 @@
|
|||||||
const res = await fetch('/status');
|
const res = await fetch('/status');
|
||||||
const data = await res.json();
|
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) {
|
if (data.active && data.tag) {
|
||||||
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
const currentTag = state.activeNode ? state.activeNode.tag : null;
|
||||||
|
|
||||||
@@ -689,9 +903,151 @@
|
|||||||
checkConnectionSpeed(true);
|
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 = `<span class="text-[#00ff41]">●</span> 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 = `<span class="text-yellow-400">●</span> 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 = `<span class="text-[#00ff41]">●</span> 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 ---
|
// --- Init ---
|
||||||
addLog('TERMINAL_READY', 'info');
|
addLog('TERMINAL_READY', 'info');
|
||||||
loadSaved();
|
loadSaved();
|
||||||
|
loadFallbackConfig();
|
||||||
|
updateProxyChain();
|
||||||
|
|
||||||
|
// Periodically update proxy chain
|
||||||
|
setInterval(updateProxyChain, 10000);
|
||||||
|
|
||||||
// Blink animation style
|
// Blink animation style
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
|
|||||||
254
web/server.py
254
web/server.py
@@ -27,6 +27,14 @@ DATA_DIR = BASE_DIR / "data"
|
|||||||
CONFIG_FILE = DATA_DIR / "client.json"
|
CONFIG_FILE = DATA_DIR / "client.json"
|
||||||
HWID_FILE = DATA_DIR / "hwid"
|
HWID_FILE = DATA_DIR / "hwid"
|
||||||
SUBSCRIPTION_FILE = DATA_DIR / "subscription.json"
|
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:
|
def get_hwid() -> str:
|
||||||
@@ -74,6 +82,27 @@ def load_subscription() -> dict:
|
|||||||
return None
|
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:
|
def measure_tcp_latency(host: str, port: int, timeout: float = 2.0) -> int:
|
||||||
"""Measure TCP latency to a host:port in milliseconds"""
|
"""Measure TCP latency to a host:port in milliseconds"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -339,6 +368,10 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.test_connection()
|
self.test_connection()
|
||||||
elif self.path.startswith("/static/"):
|
elif self.path.startswith("/static/"):
|
||||||
self.serve_static()
|
self.serve_static()
|
||||||
|
elif self.path == "/fallback-config":
|
||||||
|
self.get_fallback_config()
|
||||||
|
elif self.path == "/active-proxy":
|
||||||
|
self.get_active_proxy()
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@@ -352,6 +385,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.apply_subscription()
|
self.apply_subscription()
|
||||||
elif self.path == "/ping-target":
|
elif self.path == "/ping-target":
|
||||||
self.ping_target()
|
self.ping_target()
|
||||||
|
elif self.path == "/fallback-config":
|
||||||
|
self.save_fallback_config_endpoint()
|
||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
@@ -375,6 +410,175 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_error(404)
|
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):
|
def get_status(self):
|
||||||
"""Get current proxy status"""
|
"""Get current proxy status"""
|
||||||
config_exists = CONFIG_FILE.exists()
|
config_exists = CONFIG_FILE.exists()
|
||||||
@@ -395,7 +599,8 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_json({
|
self.send_json({
|
||||||
"active": config_exists,
|
"active": config_exists,
|
||||||
"tag": current_tag,
|
"tag": current_tag,
|
||||||
"server": current_server
|
"server": current_server,
|
||||||
|
"proxyPort": PROXY_PORT
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_subscription(self):
|
def get_subscription(self):
|
||||||
@@ -672,12 +877,49 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
self.send_json({"success": False, "error": f"Сервер '{selected_tag}' не найден"}, 400)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add selected server as main outbound
|
# Load fallback configuration
|
||||||
new_outbounds.insert(0, selected_outbound)
|
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 = {
|
routes = {
|
||||||
"final": selected_tag,
|
"final": final_tag,
|
||||||
"auto_detect_interface": True
|
"auto_detect_interface": True
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +945,7 @@ class ProxyControlHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
config["outbounds"] = new_outbounds
|
config["outbounds"] = final_outbounds
|
||||||
config["route"] = routes
|
config["route"] = routes
|
||||||
|
|
||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
|
|||||||
Reference in New Issue
Block a user