Files
vpn-proxy/web/index.html

925 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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);
}
.connection-info {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.connection-info h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 1rem;
font-weight: 500;
}
.info-item {
background: var(--bg-secondary);
padding: 0.75rem 1rem;
border-radius: 12px;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.85rem;
}
.info-item .label {
color: var(--text-secondary);
}
.info-item .value {
font-family: 'JetBrains Mono', monospace;
color: var(--accent);
background: rgba(99, 102, 241, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 6px;
user-select: all;
cursor: pointer;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.tab:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.tab.active {
background: var(--accent);
border-color: var(--accent);
color: white;
box-shadow: 0 4px 12px var(--accent-glow);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Server List */
.server-list {
max-height: 300px;
overflow-y: auto;
margin-bottom: 1rem;
}
.server-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.server-item:hover {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.05);
}
.server-item.selected {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.1);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.server-radio {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 50%;
position: relative;
flex-shrink: 0;
transition: all 0.2s ease;
}
.server-item.selected .server-radio {
border-color: var(--accent);
}
.server-item.selected .server-radio::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 50%;
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.server-details {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.server-type {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
background: rgba(99, 102, 241, 0.15);
color: var(--accent);
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.03em;
}
.btn-secondary {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.1);
}
.btn-group {
display: flex;
gap: 0.75rem;
}
.btn-group .btn {
flex: 1;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
/* Responsive */
@media (max-width: 480px) {
body {
padding: 1rem;
}
.card {
padding: 1.5rem;
border-radius: 20px;
}
h1 {
font-size: 1.25rem;
}
.tabs {
flex-direction: column;
gap: 0.25rem;
}
.btn-group {
flex-direction: column;
}
}
</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>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="subscription">📡 Подписка</button>
<button class="tab" data-tab="vless">🔑 VLESS Ключ</button>
</div>
<!-- Subscription Tab Content -->
<div id="subscription-tab" class="tab-content active">
<div class="form-group">
<label for="subUrlInput">URL подписки</label>
<div class="input-wrapper">
<input type="text" id="subUrlInput" placeholder="https://..." autocomplete="off"
spellcheck="false">
</div>
<p class="hint">Вставьте ссылку подписки (sing-box формат)</p>
</div>
<button type="button" class="btn btn-secondary" id="fetchServersBtn" style="margin-bottom: 1rem;">
<span class="btn-icon">🔄</span>
<span id="fetchBtnText">Загрузить серверы</span>
</button>
<div id="serverListContainer" style="display: none;">
<label style="margin-bottom: 0.75rem; display: block;">Выберите сервер</label>
<div class="server-list" id="serverList"></div>
<button type="button" class="btn btn-primary" id="applySubBtn">
<span class="btn-icon"></span>
<span id="applySubBtnText">Применить</span>
</button>
</div>
<div id="emptyServers" class="empty-state" style="display: none;">
<div class="empty-state-icon">📭</div>
<p>Серверы не загружены</p>
</div>
</div>
<!-- VLESS Tab Content -->
<div id="vless-tab" class="tab-content">
<form id="proxyForm">
<div class="form-group">
<label for="urlInput">VLESS Key</label>
<div class="input-wrapper">
<input type="text" id="urlInput" placeholder="vless://..." autocomplete="off"
spellcheck="false">
</div>
<p class="hint">Вставьте VLESS ссылку</p>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<span class="btn-icon"></span>
<span id="btnText">Применить</span>
</button>
</form>
</div>
<div class="message" id="message">
<span class="message-icon" id="messageIcon"></span>
<span id="messageText"></span>
</div>
<div class="connection-info">
<h3>Данные для подключения</h3>
<div class="info-item">
<span class="label">HTTP / HTTPS</span>
<code class="value" id="httpLink" title="Нажмите, чтобы скопировать">...</code>
</div>
<div class="info-item">
<span class="label">SOCKS5</span>
<code class="value" id="socksLink" title="Нажмите, чтобы скопировать">...</code>
</div>
</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>
// DOM Elements
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');
// Subscription elements
const subUrlInput = document.getElementById('subUrlInput');
const fetchServersBtn = document.getElementById('fetchServersBtn');
const fetchBtnText = document.getElementById('fetchBtnText');
const serverListContainer = document.getElementById('serverListContainer');
const serverList = document.getElementById('serverList');
const emptyServers = document.getElementById('emptyServers');
const applySubBtn = document.getElementById('applySubBtn');
const applySubBtnText = document.getElementById('applySubBtnText');
// State
let subscriptionConfig = null;
let selectedServer = null;
let subscriptionUrl = null;
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const targetTab = tab.dataset.tab;
document.getElementById(`${targetTab}-tab`).classList.add('active');
// Hide message when switching tabs
message.className = 'message';
});
});
// 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(btn, textEl, loading, defaultText = 'Применить') {
btn.disabled = loading;
if (loading) {
textEl.innerHTML = '<div class="spinner"></div>';
} else {
textEl.textContent = defaultText;
}
}
// VLESS form submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
if (!url) {
showMessage('error', 'Введите URL');
return;
}
message.className = 'message';
setLoading(submitBtn, btnText, 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(submitBtn, btnText, false);
}
});
// Fetch servers from subscription
fetchServersBtn.addEventListener('click', async () => {
const url = subUrlInput.value.trim();
if (!url) {
showMessage('error', 'Введите URL подписки');
return;
}
message.className = 'message';
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
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 && data.servers.length > 0) {
subscriptionConfig = data.config;
subscriptionUrl = url; // Save URL for persistence
renderServerList(data.servers);
serverListContainer.style.display = 'block';
emptyServers.style.display = 'none';
showMessage('success', `Найдено ${data.servers.length} сервер(ов)`);
} else {
serverListContainer.style.display = 'none';
emptyServers.style.display = 'block';
showMessage('error', data.error || 'Серверы не найдены');
}
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
}
});
// Render server list
function renderServerList(servers, savedServerTag = null) {
serverList.innerHTML = '';
selectedServer = null;
servers.forEach((server, index) => {
const item = document.createElement('div');
item.className = 'server-item';
item.dataset.tag = server.tag;
item.innerHTML = `
<div class="server-radio"></div>
<div class="server-info">
<div class="server-name">${escapeHtml(server.tag)}</div>
<div class="server-details">${escapeHtml(server.server)}:${server.port}</div>
</div>
<span class="server-type">${server.type}</span>
`;
item.addEventListener('click', () => {
document.querySelectorAll('.server-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedServer = server.tag;
});
// Select saved server or first by default
const shouldSelect = savedServerTag
? server.tag === savedServerTag
: index === 0;
if (shouldSelect) {
item.classList.add('selected');
selectedServer = server.tag;
}
serverList.appendChild(item);
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Apply subscription with selected server
applySubBtn.addEventListener('click', async () => {
if (!subscriptionConfig) {
showMessage('error', 'Сначала загрузите серверы');
return;
}
if (!selectedServer) {
showMessage('error', 'Выберите сервер');
return;
}
message.className = 'message';
setLoading(applySubBtn, applySubBtnText, true);
try {
const res = await fetch('/apply-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: subscriptionConfig,
selectedServer: selectedServer,
subUrl: subscriptionUrl
})
});
const data = await res.json();
if (data.success) {
showMessage('success', data.message || 'Конфигурация применена!');
fetchStatus();
} else {
showMessage('error', data.error || 'Произошла ошибка');
}
} catch (e) {
showMessage('error', `Ошибка сети: ${e.message}`);
} finally {
setLoading(applySubBtn, applySubBtnText, false);
}
});
// Load saved subscription
async function loadSavedSubscription() {
try {
const res = await fetch('/subscription');
const data = await res.json();
if (data.saved && data.url) {
subUrlInput.value = data.url;
subscriptionUrl = data.url;
// Auto-load servers from saved subscription
setLoading(fetchServersBtn, fetchBtnText, true, 'Загрузить серверы');
try {
const subRes = await fetch('/fetch-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.url })
});
const subData = await subRes.json();
if (subData.success && subData.servers && subData.servers.length > 0) {
subscriptionConfig = subData.config;
renderServerList(subData.servers, data.selectedServer);
serverListContainer.style.display = 'block';
emptyServers.style.display = 'none';
}
} finally {
setLoading(fetchServersBtn, fetchBtnText, false, 'Загрузить серверы');
}
}
} catch (e) {
console.log('No saved subscription found');
}
}
// Initial load
fetchStatus();
loadSavedSubscription();
// Refresh status every 30 seconds
setInterval(fetchStatus, 30000);
// Update connection links
const hostname = window.location.hostname;
const httpLink = document.getElementById('httpLink');
const socksLink = document.getElementById('socksLink');
httpLink.textContent = `http://${hostname}:8082`;
socksLink.textContent = `socks5://${hostname}:8082`;
// Copy to clipboard on click
[httpLink, socksLink].forEach(el => {
el.addEventListener('click', () => {
navigator.clipboard.writeText(el.textContent);
const original = el.style.color;
el.style.color = 'var(--success)';
setTimeout(() => el.style.color = original, 500);
});
});
</script>
</body>
</html>