refactor: реорганизация структуры проекта на логические папки
- Созданы директории: docker/, scripts/, config/ - Перемещены файлы Docker (Dockerfile, entrypoint.sh) в docker/ - Перемещены утилитарные скрипты в scripts/ - Шаблон конфигурации перенесен в config/ - Веб-сервер перемещен в web/ и переименован в server.py - Обновлены пути в docker-compose.yml, Dockerfile и entrypoint.sh
This commit is contained in:
448
web/index.html
Normal file
448
web/index.html
Normal file
@@ -0,0 +1,448 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VPN Proxy Control</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
--bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: #e8e8ec;
|
||||
--text-secondary: #8b8b9e;
|
||||
--accent: #6366f1;
|
||||
--accent-glow: rgba(99, 102, 241, 0.4);
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background-image:
|
||||
radial-gradient(ellipse at top, rgba(99, 102, 241, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(168, 85, 247, 0.08) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 24px;
|
||||
padding: 2.5rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, var(--accent), #a855f7);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.25rem;
|
||||
font-size: 1.75rem;
|
||||
box-shadow: 0 8px 32px var(--accent-glow);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.5);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.message.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.message-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="logo">🔐</div>
|
||||
<h1>VPN Proxy Control</h1>
|
||||
<p class="subtitle">Управление подключением sing-box</p>
|
||||
</div>
|
||||
|
||||
<div class="status-bar" id="statusBar">
|
||||
<div class="status-indicator" id="statusIndicator"></div>
|
||||
<div class="status-text">
|
||||
<div class="status-label">Статус</div>
|
||||
<div class="status-value" id="statusValue">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="proxyForm">
|
||||
<div class="form-group">
|
||||
<label for="urlInput">VLESS / Subscription URL</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="urlInput"
|
||||
placeholder="vless://... или https://subscription.link"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
<p class="hint">Вставьте VLESS ссылку или URL подписки</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<span class="btn-icon">⚡</span>
|
||||
<span id="btnText">Применить</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="message" id="message">
|
||||
<span class="message-icon" id="messageIcon"></span>
|
||||
<span id="messageText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Proxy работает на порту <strong>8082</strong> •
|
||||
<a href="https://github.com/SagerNet/sing-box" target="_blank">sing-box</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('proxyForm');
|
||||
const urlInput = document.getElementById('urlInput');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const btnText = document.getElementById('btnText');
|
||||
const message = document.getElementById('message');
|
||||
const messageIcon = document.getElementById('messageIcon');
|
||||
const messageText = document.getElementById('messageText');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const statusValue = document.getElementById('statusValue');
|
||||
|
||||
// Fetch initial status
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/status');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.active) {
|
||||
statusIndicator.classList.add('active');
|
||||
statusValue.textContent = data.tag
|
||||
? `${data.tag} (${data.server})`
|
||||
: 'Активен';
|
||||
} else {
|
||||
statusIndicator.classList.remove('active');
|
||||
statusValue.textContent = 'Не настроен';
|
||||
}
|
||||
} catch (e) {
|
||||
statusValue.textContent = 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(type, text) {
|
||||
message.className = `message show ${type}`;
|
||||
messageIcon.textContent = type === 'success' ? '✓' : '✕';
|
||||
messageText.textContent = text;
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
submitBtn.disabled = loading;
|
||||
if (loading) {
|
||||
btnText.innerHTML = '<div class="spinner"></div>';
|
||||
} else {
|
||||
btnText.textContent = 'Применить';
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) {
|
||||
showMessage('error', 'Введите URL');
|
||||
return;
|
||||
}
|
||||
|
||||
message.className = 'message';
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage('success', data.message || 'Конфигурация применена!');
|
||||
urlInput.value = '';
|
||||
fetchStatus();
|
||||
} else {
|
||||
showMessage('error', data.error || 'Произошла ошибка');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('error', `Ошибка сети: ${e.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchStatus();
|
||||
// Refresh status every 30 seconds
|
||||
setInterval(fetchStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
167
web/server.py
Normal file
167
web/server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple HTTP Web Server for VPN Proxy Control
|
||||
Provides a web UI to apply VLESS/subscription URLs
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import socketserver
|
||||
from urllib.parse import parse_qs
|
||||
from pathlib import Path
|
||||
|
||||
PORT = 3456
|
||||
APP_DIR = Path(__file__).parent
|
||||
WEB_DIR = APP_DIR / "web"
|
||||
DATA_DIR = APP_DIR / "data"
|
||||
CONFIG_FILE = DATA_DIR / "client.json"
|
||||
|
||||
|
||||
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.startswith("/static/"):
|
||||
self.serve_static()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
"""Handle POST requests"""
|
||||
if self.path == "/apply":
|
||||
self.apply_config()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def serve_index(self):
|
||||
"""Serve main HTML page"""
|
||||
index_path = WEB_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
self.send_html(index_path.read_bytes())
|
||||
else:
|
||||
self.send_error(404, "index.html not found")
|
||||
|
||||
def serve_static(self):
|
||||
"""Serve static files"""
|
||||
file_path = WEB_DIR / self.path[8:] # Remove /static/
|
||||
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_status(self):
|
||||
"""Get current proxy status"""
|
||||
config_exists = CONFIG_FILE.exists()
|
||||
current_tag = None
|
||||
current_server = None
|
||||
|
||||
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,
|
||||
"tag": current_tag,
|
||||
"server": current_server
|
||||
})
|
||||
|
||||
def apply_config(self):
|
||||
"""Apply new config from 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://") or url.startswith("http://") or url.startswith("https://")):
|
||||
self.send_json({"success": False, "error": "Неверный формат URL. Ожидается vless:// или http(s):// ссылка"}, 400)
|
||||
return
|
||||
|
||||
# Run gen-client-from-url.sh
|
||||
script_path = APP_DIR / "gen-client-from-url.sh"
|
||||
result = subprocess.run(
|
||||
[str(script_path), url, str(CONFIG_FILE)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(APP_DIR),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr or result.stdout or "Неизвестная ошибка"
|
||||
self.send_json({"success": False, "error": f"Ошибка генерации: {error_msg}"}, 500)
|
||||
return
|
||||
|
||||
# Trigger reload via internal control port
|
||||
try:
|
||||
import urllib.request
|
||||
urllib.request.urlopen("http://localhost:9090/reload", timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WebUI] Warning: reload request failed: {e}")
|
||||
# Continue anyway, config is generated
|
||||
|
||||
self.send_json({
|
||||
"success": True,
|
||||
"message": "Конфигурация применена успешно!",
|
||||
"output": result.stdout
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.send_json({"success": False, "error": "Неверный JSON"}, 400)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.send_json({"success": False, "error": "Таймаут при генерации конфига"}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({"success": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the web server"""
|
||||
with socketserver.TCPServer(("", PORT), ProxyControlHandler) as httpd:
|
||||
print(f"[WebUI] Server started on port {PORT}")
|
||||
print(f"[WebUI] Open http://localhost:{PORT} in your browser")
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user