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>
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user