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:
test2
2026-07-08 00:41:50 +03:00
co-authored by Cursor
commit 7ef408afe7
46 changed files with 19886 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ _('site_description') }}">
<title>{{ site_settings.title or 'Amnezia Panel' }} {% block title_extra %}{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
<script src="/static/js/qrcode.min.js"></script>
{% block head_extra %}{% endblock %}
<style>
.lang-check {
color: var(--success);
font-weight: 700;
}
[dir="rtl"] .lang-check {
margin-right: auto;
}
[dir="ltr"] .lang-check {
margin-left: auto;
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="app-container">
<header class="app-header">
<a href="/" class="app-logo" style="text-decoration:none">
<div class="logo-icon">{{ site_settings.logo }}</div>
<div>
<div class="logo-text">{{ site_settings.title }}</div>
<div class="logo-subtitle">{{ site_settings.subtitle }}</div>
</div>
</a>
{% if current_user %}
<nav class="header-nav">
{% if current_user.role in ['admin', 'support'] %}
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
{% endif %}
{% if current_user.role == 'admin' %}
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
{% endif %}
<a href="/my" class="nav-link">{{ _('nav_connections') }}</a>
</nav>
<div class="nav-user">
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
style="margin-right: var(--space-sm);">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
%}🇮🇷{% else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
id="themeToggle">🌙</button>
<span class="nav-username">{{ current_user.username }}</span>
<span
class="badge badge-{{ 'success' if current_user.role == 'admin' else ('info' if current_user.role == 'support' else 'warn') }}"
style="font-size:0.65rem;">
{{ current_user.role | upper }}
</span>
<a href="/logout" class="btn btn-secondary btn-sm">{{ _('nav_logout') }}</a>
</div>
{% else %}
<div class="nav-user">
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
style="margin-right: var(--space-sm);">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
%}🇮🇷{% else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
id="themeToggle">🌙</button>
</div>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
</div>
<!-- ===== Language Modal ===== -->
<div class="modal-backdrop" id="langModal">
<div class="modal" style="max-width: 320px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('select_lang') }}</h2>
<button class="modal-close" onclick="closeModal('langModal')">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
{% set langs = [
('en', '🇺🇸', 'lang_en'),
('ru', '🇷🇺', 'lang_ru'),
('fr', '🇫🇷', 'lang_fr'),
('zh', '🇨🇳', 'lang_zh'),
('fa', '🇮🇷', 'lang_fa')
] %}
{% for l_code, l_flag, l_key in langs %}
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
<span style="font-weight: 500;">{{ _(l_key) }}</span>
{% if lang == l_code %}
<span class="lang-check"></span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
<script>
window.I18N = {{ translations_json | safe }};
window._ = function (key) { return window.I18N[key] || key; };
/* ===== Theme Toggle ===== */
(function () {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
const updateUI = () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
document.querySelectorAll('.theme-toggle').forEach(btn => {
const isLang = btn.getAttribute('onclick').includes('langModal');
if (!isLang) {
btn.textContent = isLight ? '☀️' : '🌙';
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateUI);
} else {
updateUI();
}
window.toggleTheme = function () {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateUI();
};
})();
/* ===== Active Nav Link ===== */
(function () {
const path = window.location.pathname;
document.querySelectorAll('.nav-link').forEach(link => {
const href = link.getAttribute('href');
if (href === '/' && path === '/') link.classList.add('active');
else if (href !== '/' && path.startsWith(href)) link.classList.add('active');
});
})();
/* ===== Toast Notifications ===== */
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = { success: '✓', error: '✕', info: '' };
toast.innerHTML = `<span>${icons[type] || ''}</span> <span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
/* ===== Modal Management ===== */
function openModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
/* Close modal on backdrop click */
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
e.target.classList.remove('active');
document.body.style.overflow = '';
}
});
/* Close modal on Escape */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
m.classList.remove('active');
});
document.body.style.overflow = '';
}
});
/* ===== API Helper ===== */
async function apiCall(url, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (res.status === 401 || res.status === 403) {
window.location.href = '/login';
throw new Error('Session expired');
}
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Unknown error');
}
return data;
}
/* ===== Copy to clipboard ===== */
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showToast(_('copied_to_clipboard'), 'success');
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
showToast(_('copied_to_clipboard'), 'success');
}
}
/* ===== Download file ===== */
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+453
View File
@@ -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 %}
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_settings.title or 'Amnezia Panel' }} — {{ _('login') }}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
<style>
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
background: var(--bg);
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-logo {
text-align: center;
margin-bottom: var(--space-xl);
}
.login-logo .logo-icon {
width: 72px;
height: 72px;
font-size: 2rem;
margin: 0 auto var(--space-md);
}
.login-logo .logo-text {
font-size: 1.6rem;
font-weight: 700;
}
.login-logo .logo-subtitle {
color: var(--text-muted);
font-size: 0.9rem;
}
.login-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: 0.85rem;
margin-bottom: var(--space-md);
display: none;
}
.login-error.visible {
display: block;
}
.lang-check {
color: var(--success);
font-weight: 700;
}
[dir="rtl"] .lang-check {
margin-right: auto;
}
[dir="ltr"] .lang-check {
margin-left: auto;
}
</style>
</head>
<body>
<button class="theme-toggle" onclick="openModal('langModal')" id="langToggle"
style="position:fixed; top:20px; {% if lang == 'fa' %}left:65px;{% else %}right:65px;{% endif %} z-index:10;"
title="{{ _('select_lang') }}">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa' %}🇮🇷{%
else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle"
style="position:fixed; top:20px; {% if lang == 'fa' %}left:20px;{% else %}right:20px;{% endif %} z-index:10;"
title="{{ _('toggle_theme') }}">🌙</button>
<div class="login-wrapper">
<div class="login-card">
<div class="login-logo">
<div class="logo-icon">{{ site_settings.logo or '🛡' }}</div>
<div class="logo-text">{{ site_settings.title or 'Amnezia Panel' }}</div>
<div class="logo-subtitle">{{ site_settings.subtitle or 'Web Panel' }}</div>
</div>
<div class="card">
<h2 style="font-size:1.2rem; font-weight:600; margin-bottom:var(--space-lg); text-align:center;">{{
_('login_title') }}</h2>
<div class="login-error" id="loginError"></div>
<form id="loginForm" onsubmit="return doLogin(event)">
<div class="form-group">
<label class="form-label">{{ _('username') }}</label>
<input class="form-input" type="text" id="username" placeholder="admin" required autofocus>
</div>
<div class="form-group">
<label class="form-label">{{ _('password') }}</label>
<input class="form-input" type="password" id="password" placeholder="••••••••" required>
</div>
{% if captcha_settings.enabled %}
<div class="form-group">
<label class="form-label">{{ _('captcha') }}</label>
<div
style="display: flex; margin-bottom: var(--space-xs); border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-color);">
<img id="captchaImage" src="/api/auth/captcha" alt="Captcha"
style="width: 100%; height: 65px; object-fit: cover; object-position: center; cursor: pointer; background: #fff;"
onclick="refreshCaptcha()" title="{{ _('captcha_hint') }}">
</div>
<input class="form-input" type="text" id="captcha" placeholder="{{ _('captcha_placeholder') }}"
required>
</div>
{% endif %}
<button type="submit" class="btn btn-primary" id="loginBtn"
style="width:100%; margin-top:var(--space-md);">
<span id="loginBtnText">{{ _('login') }}</span>
<div class="spinner hidden" id="loginSpinner"></div>
</button>
</form>
</div>
</div>
</div>
<!-- ===== Language Modal ===== -->
<div class="modal-backdrop" id="langModal">
<div class="modal" style="max-width: 320px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('select_lang') }}</h2>
<button class="modal-close" onclick="closeModal('langModal')">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
{% set langs = [
('en', '🇺🇸', 'lang_en'),
('ru', '🇷🇺', 'lang_ru'),
('fr', '🇫🇷', 'lang_fr'),
('zh', '🇨🇳', 'lang_zh'),
('fa', '🇮🇷', 'lang_fa')
] %}
{% for l_code, l_flag, l_key in langs %}
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
<span style="font-weight: 500;">{{ _(l_key) }}</span>
{% if lang == l_code %}
<span class="lang-check"></span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
<script>
window.I18N = {{ translations_json | safe }};
window._ = function (key) { return window.I18N[key] || key; };
/* Theme toggle */
(function () {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
const updateUI = () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
document.querySelectorAll('.theme-toggle').forEach(btn => {
if (btn.id === 'themeToggle') {
btn.textContent = isLight ? '☀️' : '🌙';
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateUI);
} else {
updateUI();
}
window.toggleTheme = function () {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateUI();
};
})();
/* ===== Modal Management ===== */
function openModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
/* Close modal on backdrop click */
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
e.target.classList.remove('active');
document.body.style.overflow = '';
}
});
/* Close modal on Escape */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
m.classList.remove('active');
});
document.body.style.overflow = '';
}
});
function refreshCaptcha() {
const img = document.getElementById('captchaImage');
if (img) {
img.src = '/api/auth/captcha?' + new Date().getTime();
}
}
async function doLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const text = document.getElementById('loginBtnText');
const spinner = document.getElementById('loginSpinner');
const errEl = document.getElementById('loginError');
btn.disabled = true;
text.textContent = _('logging_in');
spinner.classList.remove('hidden');
errEl.classList.remove('visible');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
captcha: document.getElementById('captcha') ? document.getElementById('captcha').value : null,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || _('login_error'));
}
window.location.href = '/';
} catch (err) {
errEl.textContent = err.message;
errEl.classList.add('visible');
refreshCaptcha();
if (document.getElementById('captcha')) {
document.getElementById('captcha').value = '';
}
} finally {
btn.disabled = false;
text.textContent = _('login');
spinner.classList.add('hidden');
}
}
</script>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('my_connections_title') }}{% endblock %}
{% block content %}
<section>
<h1 class="section-title">
<span class="icon">🔗</span>
{{ _('my_connections_title') }}
</h1>
{% if connections %}
<div class="clients-list" id="myConnectionsList">
{% for c in connections %}
<div class="client-item">
<div class="client-info">
<div class="client-avatar">🔗</div>
<div>
<div class="client-name">{{ c.name or 'VPN Connection' }}</div>
<div class="client-meta">
<span>🖥 {{ c.server_name }}</span>
<span class="badge badge-info" style="font-size:0.65rem;">
{{ 'AmneziaWG' if c.protocol == 'awg' else ('AmneziaWG 2.0' if c.protocol == 'awg2' else ('AWG Legacy' if c.protocol == 'awg_legacy' else
('Xray' if c.protocol == 'xray' else c.protocol | upper))) }}
</span>
{% if c.created_at %}
<span>📅 {{ c.created_at[:10] }}</span>
{% endif %}
</div>
</div>
</div>
<div class="client-actions">
<button class="btn btn-primary btn-sm"
onclick="showMyConfig('{{ c.id }}', '{{ c.name or 'Connection' }}', '{{ c.protocol }}')">
{{ _('show_config') }}
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<div class="empty-title">{{ _('no_connections') }}</div>
<div class="empty-desc">{{ _('no_connections_user_desc') }}</div>
</div>
{% endif %}
</section>
<!-- ===== Config Modal ===== -->
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
<button class="modal-close" onclick="closeModal('configModal')">×</button>
</div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
</div>
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<div class="config-text" id="configText"></div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy_config') }}
</button>
<button class="btn btn-primary btn-sm"
onclick="downloadFile(currentConfig, currentConfigName + '.conf')" style="flex:1">
{{ _('download_conf') }}
</button>
</div>
</div>
</div>
<div class="config-panel" id="panel-vpn">
<div class="vpn-link-box" id="vpnLinkText"></div>
<div class="config-actions" style="margin-top: var(--space-sm);">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">
{{ _('copy_key') }}
</button>
</div>
<div class="form-hint" style="margin-top: var(--space-sm);">
{{ _('vpn_link_hint') }}
</div>
</div>
<div class="config-panel" id="panel-qr">
<div class="qr-container">
<div id="qrCode"></div>
<div class="qr-hint">{{ _('qr_hint') }}</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentConfig = '';
let currentVpnLink = '';
let currentConfigName = 'connection';
async function showMyConfig(connectionId, name, proto) {
try {
showToast(_('loading_connections'), 'info');
const result = await apiCall(`/api/my/connections/${connectionId}/config`, 'POST');
if (result.config) {
currentConfig = result.config;
currentVpnLink = result.vpn_link || '';
currentConfigName = name.replace(/\s+/g, '_');
document.getElementById('configModalTitle').textContent = `${_('config')}${name}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
// Telemt (MTProxy) doesn't have a "VPN Link" (vpn://) format in Amnezia
const vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn');
if (proto === 'telemt') {
vpnTab.style.display = 'none';
} else {
vpnTab.style.display = '';
}
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
function switchConfigTab(tab) {
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
const tabs = document.querySelectorAll('.config-tab');
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
tabs[tabIdx[tab]].classList.add('active');
document.getElementById(panels[tab]).classList.add('active');
}
function generateQR(text) {
const container = document.getElementById('qrCode');
container.innerHTML = '';
try {
new QRCode(container, {
text: text, width: 280, height: 280,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
} catch (e) {
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
}
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('share_title') }}{% endblock %}
{% block content %}
<div class="card" style="max-width: 600px; margin: 2rem auto;">
<div class="card-header"
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
<h2 class="card-title">{{ _('vpn_access') }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('user_label_share') }}: <strong>{{
share_user.username }}</strong>
</p>
</div>
{% if need_password %}
<div style="padding: var(--space-lg); text-align: center;">
<div class="logo-icon" style="font-size: 3rem; margin-bottom: var(--space-md);">🔐</div>
<p style="margin-bottom: var(--space-md);">{{ _('share_protected_desc') }}
</p>
<form id="authForm" onsubmit="authShare(event)"
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
<div class="form-group">
<input type="password" id="sharePassword" class="form-input" placeholder="{{ _('password') }}" required
autofocus>
</div>
<button type="submit" class="btn btn-primary" id="authBtn">
<span id="authBtnText">{{ _('login') }}</span>
<div class="spinner hidden" id="authSpinner"></div>
</button>
</form>
</div>
{% else %}
<div id="connectionsList" style="padding: var(--space-md);">
<div style="text-align: center; padding: 2rem;" id="loadingState">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_share_conns') }}</p>
</div>
<div id="connectionsGrid" class="hidden" style="display: grid; gap: var(--space-md);">
<!-- Connections will be here -->
</div>
<div id="emptyState" class="hidden" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted);">{{ _('no_active_conns') }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Modal for Config -->
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
<button class="modal-close" onclick="closeModal('configModal')">×</button>
</div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
</div>
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<textarea class="config-text" id="configText" readonly rows="12"
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config')
}}</button>
<a id="downloadBtn" class="btn btn-primary btn-sm"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
{{ _('download_conf') }}
</a>
</div>
</div>
</div>
<div class="config-panel" id="panel-vpn">
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
<div class="config-actions" style="margin-top:var(--space-sm);">
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key')
}}</button>
</div>
</div>
<div class="config-panel" id="panel-qr">
<div class="qr-container">
<div id="qrcode"></div>
</div>
</div>
</div>
</div>
<script>
const TOKEN = "{{ token }}";
async function authShare(e) {
e.preventDefault();
const password = document.getElementById('sharePassword').value;
const btn = document.getElementById('authBtn');
const text = document.getElementById('authBtnText');
const spinner = document.getElementById('authSpinner');
btn.disabled = true;
text.classList.add('hidden');
spinner.classList.remove('hidden');
try {
const res = await fetch(`/api/share/${TOKEN}/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (data.status === 'success') {
window.location.reload();
} else {
alert(data.error || _('login_error'));
}
} catch (err) {
alert(`${_('error')}: ` + err.message);
} finally {
btn.disabled = false;
text.classList.remove('hidden');
spinner.classList.add('hidden');
}
}
async function loadConnections() {
if (document.getElementById('loadingState') === null) return;
try {
const res = await fetch(`/api/share/${TOKEN}/connections`);
if (res.status === 401) return; // Wait for auth
const data = await res.json();
document.getElementById('loadingState').classList.add('hidden');
if (!data.connections || data.connections.length === 0) {
document.getElementById('emptyState').classList.remove('hidden');
return;
}
const grid = document.getElementById('connectionsGrid');
grid.classList.remove('hidden');
grid.innerHTML = data.connections.map(c => `
<div class="card" style="padding: var(--space-md); border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-sm);">
<div style="text-align: initial;">
<div style="font-weight: 600; font-size: 1.1rem;">${c.name}</div>
<div style="font-size: 0.8rem; color: var(--text-muted);">${c.protocol.toUpperCase()} • ${c.server_name}</div>
</div>
<span class="badge badge-success">${_('active')}</span>
</div>
<button class="btn btn-secondary btn-sm w-full" onclick="showConfig('${c.id}', '${c.name}')">
${_('show_settings_btn')}
</button>
</div>
`).join('');
} catch (err) {
console.error(err);
}
}
function switchConfigTab(tab) {
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
const tabs = document.querySelectorAll('.config-tab');
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
tabs[tabIdx[tab]].classList.add('active');
document.getElementById(panels[tab]).classList.add('active');
}
let currentConfig = '';
let currentVpnLink = '';
async function showConfig(connId, name) {
try {
const res = await fetch(`/api/share/${TOKEN}/config/${connId}`, { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
currentConfig = data.config;
currentVpnLink = data.vpn_link || '';
document.getElementById('configText').value = data.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('configModalTitle').textContent = name;
const dl = document.getElementById('downloadBtn');
dl.onclick = () => downloadFile(data.config, `${name}.conf`);
const qrContainer = document.getElementById('qrcode');
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: data.config,
width: 256,
height: 256,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.L
});
switchConfigTab('conf');
openModal('configModal');
} catch (err) {
alert(`${_('error')}: ` + err.message);
}
}
function copyConfig() {
copyToClipboard(currentConfig);
}
function copyVpnLink() {
copyToClipboard(currentVpnLink);
}
document.addEventListener('DOMContentLoaded', loadConnections);
</script>
<style>
.w-full {
width: 100%;
}
.spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--accent-color);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
{% endblock %}
+1012
View File
File diff suppressed because it is too large Load Diff