Files
test2andCursor 7ef408afe7 Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:41:50 +03:00

453 lines
19 KiB
HTML
Raw Permalink 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.
{% extends "base.html" %}
{% block content %}
<section>
<div class="flex items-center justify-between" style="margin-bottom: var(--space-lg);">
<h1 class="section-title" style="margin-bottom:0;">
<span class="icon">🖥</span>
{{ _('nav_servers') }}
</h1>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% if servers %}
<div class="server-grid" id="serverGrid">
{% for server in servers %}
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
draggable="true">
<div class="server-meta">
<div class="server-icon">🖥</div>
<div style="min-width: 0; flex: 1;">
<div class="server-name">
<span class="ping-dot ping-pending" id="ping-{{ loop.index0 }}"
title="{{ _('ping_checking') }}"></span>
<span>{{ server.name }}</span>
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
</div>
</div>
<div class="server-protocols" id="server-protocols-{{ loop.index0 }}">
{% if server.protocols %}
{% for proto_key, proto_val in server.protocols.items() %}
{% if proto_val.get('installed') %}
<span class="badge badge-success">
<span class="badge-dot"></span>
{{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if
proto_key == 'xray' else proto_key | upper)) }}
</span>
{% endif %}
{% endfor %}
{% else %}
<span class="badge badge-warn">
<span class="badge-dot"></span>
{{ _('no_protocols') }}
</span>
{% endif %}
</div>
<div class="server-actions">
<a href="/server/{{ loop.index0 }}" class="btn btn-secondary btn-sm" style="flex:1">
{{ _('manage') }}
</a>
<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditServer(this)"
title="{{ _('edit') }}">
✏️
</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteServer({{ loop.index0 }})"
title="{{ _('delete') }}">
🗑
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🛡</div>
<div class="empty-title">{{ _('no_servers') }}</div>
<div class="empty-desc">
{{ _('add_server_desc') }}
</div>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% endif %}
</section>
<!-- ===== Edit Server Modal ===== -->
<div class="modal-backdrop" id="editServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('edit_server_title') }}</h2>
<button class="modal-close" onclick="closeModal('editServerModal')">×</button>
</div>
<form id="editServerForm" onsubmit="return submitEditServer(event)">
<input type="hidden" id="editServerIdx">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="editServerName">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="editServerHost" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="editServerPort" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="editServerUser" required>
</div>
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchEditAuthTab('password', this)">{{ _('ssh_password') }}</button>
<button type="button" class="tab" onclick="switchEditAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="editAuthPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="editServerPassword"
placeholder="{{ _('edit_keep_credential') }}">
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div id="editAuthKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="editServerKey"
placeholder="{{ _('edit_keep_credential') }}"></textarea>
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="editServerBtn">
<span id="editServerBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="editServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
<!-- ===== Add Server Modal ===== -->
<div class="modal-backdrop" id="addServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('add_server') }}</h2>
<button class="modal-close" onclick="closeModal('addServerModal')">×</button>
</div>
<form id="addServerForm" onsubmit="return addServer(event)">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="serverName" placeholder="My VPN Server">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="serverHost" placeholder="192.168.1.1" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="serverPort" value="22" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="serverUser" placeholder="root" required>
</div>
<!-- Tabs for auth method -->
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchAuthTab('password', this)">{{ _('ssh_password')
}}</button>
<button type="button" class="tab" onclick="switchAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="authPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="serverPassword" placeholder="••••••••">
</div>
</div>
<div id="authKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="serverKey"
placeholder="{{ _('ssh_key_placeholder') }}"></textarea>
<div class="form-hint">{{ _('ssh_key_hint') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="addServerBtn">
<span id="addServerBtnText">{{ _('connect') }}</span>
<div class="spinner hidden" id="addServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function switchAuthTab(tab, btn) {
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('authPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('authKey').classList.toggle('hidden', tab !== 'key');
}
async function addServer(e) {
e.preventDefault();
const btn = document.getElementById('addServerBtn');
const btnText = document.getElementById('addServerBtnText');
const spinner = document.getElementById('addServerSpinner');
btn.disabled = true;
btnText.textContent = _('connecting');
spinner.classList.remove('hidden');
try {
const data = {
name: document.getElementById('serverName').value,
host: document.getElementById('serverHost').value,
ssh_port: parseInt(document.getElementById('serverPort').value) || 22,
username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').value,
};
const result = await apiCall('/api/servers/add', 'POST', data);
showToast(_('server_added'), 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = 'Подключить';
spinner.classList.add('hidden');
}
}
async function deleteServer(serverId) {
if (!confirm(_('delete_confirm'))) return;
try {
await apiCall(`/api/servers/${serverId}/delete`, 'POST');
showToast(_('server_deleted'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
}
}
/* ========== Edit server ========== */
function switchEditAuthTab(tab, btn) {
const tabs = btn.parentElement.querySelectorAll('.tab');
tabs.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('editAuthPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('editAuthKey').classList.toggle('hidden', tab !== 'key');
}
function openEditServer(btn) {
// Prefill from data-* on the parent server-card so we don't have to round-trip.
const card = btn.closest('.server-card');
if (!card) return;
document.getElementById('editServerIdx').value = card.dataset.idx;
document.getElementById('editServerName').value = card.dataset.name || '';
document.getElementById('editServerHost').value = card.dataset.host || '';
document.getElementById('editServerPort').value = card.dataset.port || '22';
document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').value = '';
// Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key';
const tabsEl = document.querySelector('#editServerForm .tabs');
const passTab = tabsEl.children[0];
const keyTab = tabsEl.children[1];
switchEditAuthTab(authIsKey ? 'key' : 'password', authIsKey ? keyTab : passTab);
openModal('editServerModal');
}
async function submitEditServer(e) {
e.preventDefault();
const idx = document.getElementById('editServerIdx').value;
const btn = document.getElementById('editServerBtn');
const btnText = document.getElementById('editServerBtnText');
const spinner = document.getElementById('editServerSpinner');
btn.disabled = true;
btnText.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
try {
const body = {
name: document.getElementById('editServerName').value,
host: document.getElementById('editServerHost').value,
ssh_port: parseInt(document.getElementById('editServerPort').value) || 22,
username: document.getElementById('editServerUser').value,
// Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').value || null,
};
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = _('save');
spinner.classList.add('hidden');
}
return false;
}
/* ========== Ping indicators ========== */
async function pingServer(idx) {
const dot = document.getElementById(`ping-${idx}`);
const ms = document.getElementById(`ping-ms-${idx}`);
if (!dot) return;
try {
const res = await fetch(`/api/servers/${idx}/ping`, { credentials: 'same-origin' });
const data = await res.json();
dot.classList.remove('ping-pending');
if (data.alive) {
dot.classList.add('ping-ok');
dot.title = `${data.ms} ms`;
if (ms) ms.textContent = `${data.ms} ms`;
} else {
dot.classList.add('ping-fail');
dot.title = data.error || _('offline');
if (ms) ms.textContent = _('offline');
}
} catch {
dot.classList.remove('ping-pending');
dot.classList.add('ping-fail');
if (ms) ms.textContent = _('offline');
}
}
function startAllPings() {
// Browsers will fan these out concurrently; the page never blocks waiting
// for results because each card updates independently.
document.querySelectorAll('.server-card').forEach(card => {
const idx = card.dataset.idx;
if (idx !== undefined) pingServer(idx);
});
}
/* ========== Drag-and-drop reorder ========== */
let draggedCard = null;
function setupDragAndDrop() {
const grid = document.getElementById('serverGrid');
if (!grid) return;
grid.addEventListener('dragstart', e => {
const card = e.target.closest('.server-card');
if (!card) return;
// Don't initiate drag from a button/link inside the card — those have
// their own click semantics (Edit/Delete/Manage).
if (e.target.closest('button, a')) {
e.preventDefault();
return;
}
draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Some browsers require this for dragstart to fire with text payload.
try { e.dataTransfer.setData('text/plain', card.dataset.idx); } catch {}
});
grid.addEventListener('dragend', e => {
const card = e.target.closest('.server-card');
if (card) card.classList.remove('dragging');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
draggedCard = null;
});
grid.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const card = e.target.closest('.server-card');
if (!card || card === draggedCard) return;
grid.querySelectorAll('.server-card.drag-over').forEach(c => {
if (c !== card) c.classList.remove('drag-over');
});
card.classList.add('drag-over');
});
grid.addEventListener('dragleave', e => {
const card = e.target.closest('.server-card');
if (card && !card.contains(e.relatedTarget)) card.classList.remove('drag-over');
});
grid.addEventListener('drop', async e => {
e.preventDefault();
const target = e.target.closest('.server-card');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
if (!target || !draggedCard || target === draggedCard) return;
// Insert before/after target depending on cursor position relative
// to the target's vertical centre, which feels natural in a grid.
const rect = target.getBoundingClientRect();
const insertAfter = (e.clientY - rect.top) > rect.height / 2;
grid.insertBefore(draggedCard, insertAfter ? target.nextSibling : target);
const newOrder = Array.from(grid.querySelectorAll('.server-card'))
.map(c => parseInt(c.dataset.idx, 10));
try {
await apiCall('/api/servers/reorder', 'POST', { order: newOrder });
showToast(_('servers_reordered'), 'success');
// Reload so onclick indices and per-card data-idx attrs realign with
// the new server array — simpler and safer than rewriting every handler.
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(err.message, 'error');
// Rollback the visual move — backend rejected.
window.location.reload();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
startAllPings();
setupDragAndDrop();
});
</script>
{% endblock %}