feat(gui): security hardening, UI overhaul, light theme

- CSRF protection on all POST forms (session token)
- ensure_schema() moved to module-level, removed from before_request
- gunicorn now binds to 127.0.0.1 only, runs as unprivileged user wgadmin
- nginx reverse proxy with HTTPS (Let's Encrypt, wg.4mont.ru)
- HTTP → HTTPS redirect before Basic Auth prompt
- Auth moved to nginx level (auth_basic), wg-peerctl called via sudo
- ufw firewall: only 22/80/443/51820 open
- fail2ban: SSH + nginx (5 attempts → 1h ban)
- Add Enable/Disable toggle buttons in peer table
- Add .conf file download route
- Light theme: white background, blue accent, subtle shadows
- Modern sidebar layout, styled badges, responsive forms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 10:10:19 +03:00
parent 530e93c1df
commit 904582e7fa
7 changed files with 620 additions and 170 deletions
+34 -17
View File
@@ -7,23 +7,40 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<header class="top">
<h1>WG Admin</h1>
<nav>
<a href="{{ url_for('index') }}">Клиенты</a>
<a href="{{ url_for('new_peer') }}">Добавить peer</a>
<a href="{{ url_for('scripts') }}">Скрипты</a>
<aside class="sidebar">
<div class="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
<span>WG Admin</span>
</div>
<nav class="sidebar-nav">
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Клиенты
</a>
<a href="{{ url_for('new_peer') }}" class="{{ 'active' if request.endpoint == 'new_peer' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Добавить peer
</a>
<a href="{{ url_for('scripts') }}" class="{{ 'active' if request.endpoint == 'scripts' else '' }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Скрипты
</a>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {{category}}">{{message}}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</aside>
<div class="layout">
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {{ category }}">
<span>{{ message }}</span>
<button onclick="this.parentElement.remove()"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>
+83 -37
View File
@@ -1,41 +1,87 @@
{% extends 'base.html' %}
{% block content %}
<h2>Клиенты</h2>
<p>Интерфейс: <b>{{ meta.get('WG_INTERFACE','wg0') }}</b> | Сеть: <b>{{ meta.get('WG_NETWORK','-') }}</b> | Endpoint: <b>{{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}</b></p>
<table>
<thead>
<tr>
<th>Имя</th><th>Статус</th><th>IP</th><th>Роуты</th><th>AllowedIPs</th><th>Endpoint</th><th>Handshake</th><th>RX</th><th>TX</th><th>PubKey</th><th>Действие</th>
</tr>
</thead>
<tbody>
{% for p in peers %}
<tr>
<td>{{ p.name }}</td>
<td><span class="badge {{ p.status }}">{{ p.status }}</span></td>
<td>{{ p.client_address }}</td>
<td>{{ p.routes }}</td>
<td>{{ p.allowed_ips }}</td>
<td>{{ p.endpoint }}</td>
<td>{{ p.latest_handshake }}</td>
<td>{{ p.rx }}</td>
<td>{{ p.tx }}</td>
<td class="mono">{{ p.public_key }}</td>
<td>
{% if p.id %}
<a href="{{ url_for('peer_view', peer_id=p.id) }}">QR/Config</a>
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<button type="submit" class="danger">Удалить</button>
</form>
<div class="page-header">
<div>
<h2>Клиенты</h2>
<p class="meta-line">
<span class="meta-tag">{{ meta.get('WG_INTERFACE','wg0') }}</span>
<span class="meta-tag">{{ meta.get('WG_NETWORK','-') }}</span>
<span class="meta-tag">{{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}</span>
</p>
</div>
<a href="{{ url_for('new_peer') }}" class="btn btn-primary">+ Добавить peer</a>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>Статус</th>
<th>IP</th>
<th>Роуты</th>
<th>Endpoint</th>
<th>Handshake</th>
<th>RX / TX</th>
<th>Публичный ключ</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for p in peers %}
<tr class="{{ 'row-disabled' if not p.enabled else '' }}">
<td><span class="peer-name">{{ p.name }}</span></td>
<td>
<span class="badge {{ p.status }}">
<i class="dot"></i>{{ p.status }}
</span>
</td>
<td class="mono-sm">{{ p.client_address }}</td>
<td class="mono-sm text-muted">{{ p.routes }}</td>
<td class="mono-sm text-muted">{{ p.endpoint }}</td>
<td class="text-muted">{{ p.latest_handshake }}</td>
<td class="text-muted">{{ p.rx }} / {{ p.tx }}</td>
<td><span class="pubkey" title="{{ p.public_key }}">{{ p.public_key[:20] }}…</span></td>
<td>
<div class="actions">
{% if p.id %}
<a href="{{ url_for('peer_view', peer_id=p.id) }}" class="btn btn-sm">QR</a>
<a href="{{ url_for('peer_download', peer_id=p.id) }}" class="btn btn-sm">↓ .conf</a>
{% if p.enabled %}
<form method="post" action="{{ url_for('peer_disable', peer_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-warn">Откл</button>
</form>
{% else %}
<form method="post" action="{{ url_for('peer_enable', peer_id=p.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-ok">Вкл</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer {{ p.name }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form>
{% else %}
<form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="public_key" value="{{ p.public_key }}" />
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<input type="hidden" name="public_key" value="{{ p.public_key }}" />
<button type="submit" class="danger">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<tr>
<td colspan="9" class="empty">Пиров нет. <a href="{{ url_for('new_peer') }}">Добавить первый</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+50 -41
View File
@@ -1,46 +1,55 @@
{% extends 'base.html' %}
{% block content %}
<h2>Новый peer</h2>
<form method="post" class="card">
<label>Имя клиента
<input name="name" required placeholder="astra" />
</label>
<label>Режим
<select name="mode" id="mode">
<option value="full">full (весь трафик через VPN)</option>
<option value="split">split (только выбранные сети)</option>
</select>
</label>
<label>AllowedIPs (для split)
<input
id="allowed_ips"
name="allowed_ips"
placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
data-default="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
/>
</label>
<label>Сети за клиентом (роуты)
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" />
</label>
<button type="submit">Создать</button>
</form>
<div class="page-header">
<h2>Новый peer</h2>
</div>
<div class="card form-card">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Имя клиента</label>
<input type="text" name="name" placeholder="например: phone-ruslan" autofocus required />
</div>
<div class="form-group">
<label>Режим</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="mode" value="full" checked onchange="toggleRoutes(this)">
<span>Полный туннель (0.0.0.0/0)</span>
</label>
<label class="radio-label">
<input type="radio" name="mode" value="split" onchange="toggleRoutes(this)">
<span>Split-tunnel (только нужные сети)</span>
</label>
</div>
</div>
<div class="form-group" id="allowed-ips-group" style="display:none">
<label>AllowedIPs для клиента</label>
<input type="text" name="allowed_ips" placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}" />
<small>Через запятую. Оставьте пустым — подставится сеть WG.</small>
</div>
<div class="form-group">
<label>Дополнительные роуты (advertised)</label>
<input type="text" name="routes" placeholder="192.168.1.0/24, 10.0.0.0/8" />
<small>Сети, которые клиент анонсирует другим участникам. Необязательно.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Создать peer</button>
<a href="{{ url_for('index') }}" class="btn">Отмена</a>
</div>
</form>
</div>
<script>
(() => {
const mode = document.getElementById("mode");
const allowed = document.getElementById("allowed_ips");
const def = allowed.dataset.default || "10.66.66.0/24";
function syncAllowed() {
if (mode.value === "split") {
if (!allowed.value.trim()) allowed.value = def;
allowed.readOnly = false;
} else {
allowed.readOnly = true;
}
}
mode.addEventListener("change", syncAllowed);
syncAllowed();
})();
function toggleRoutes(el) {
document.getElementById('allowed-ips-group').style.display =
el.value === 'split' ? 'block' : 'none';
}
</script>
{% endblock %}
+21 -10
View File
@@ -1,16 +1,27 @@
{% extends 'base.html' %}
{% block content %}
<h2>Peer создан: {{ name }}</h2>
<p>PublicKey: <span class="mono">{{ public_key }}</span></p>
<p><a href="data:text/plain;charset=utf-8,{{ client_conf | urlencode }}" download="{{ name }}.conf">Скачать {{ name }}.conf</a></p>
<div class="grid2">
<div>
<h3>QR</h3>
<img alt="QR" src="data:image/png;base64,{{ qr_b64 }}" />
<div class="page-header">
<h2>{{ name }}</h2>
<a href="{{ url_for('index') }}" class="btn">← Назад</a>
</div>
<div class="peer-detail-grid">
<div class="card qr-card">
<h3>QR-код</h3>
<img src="data:image/png;base64,{{ qr_b64 }}" alt="QR" class="qr-img" />
<p class="text-muted">Отсканируйте в приложении WireGuard</p>
</div>
<div>
<h3>Client config</h3>
<pre>{{ client_conf }}</pre>
<div class="card conf-card">
<div class="conf-header">
<h3>Конфигурация</h3>
<a href="{{ url_for('peer_download', peer_id=request.view_args.get('peer_id', 0)) }}" class="btn btn-sm">↓ Скачать .conf</a>
</div>
<pre class="conf-block">{{ client_conf }}</pre>
<div class="pubkey-block">
<span class="label">Публичный ключ:</span>
<span class="mono-sm">{{ public_key }}</span>
</div>
</div>
</div>
{% endblock %}
+36 -12
View File
@@ -1,15 +1,39 @@
{% extends 'base.html' %}
{% block content %}
<h2>Скрипты и команды</h2>
<h3>Команды</h3>
{% for k, v in commands.items() %}
<p><b>{{ k }}</b></p>
<pre>{{ v }}</pre>
{% endfor %}
<h3>Важные пути</h3>
<ul>
{% for p in paths %}
<li><code>{{ p }}</code></li>
{% endfor %}
</ul>
<div class="page-header">
<h2>Скрипты и пути</h2>
</div>
<div class="card">
<h3>Команды</h3>
<div class="script-list">
{% for key, cmd in commands.items() %}
<div class="script-item">
<div class="script-label">{{ key }}</div>
<div class="script-cmd-wrap">
<pre class="script-cmd">{{ cmd }}</pre>
<button class="btn btn-sm copy-btn" onclick="copyText(this, '{{ cmd | replace("'", "\\'") }}')">Копировать</button>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<h3>Важные пути</h3>
<ul class="path-list">
{% for path in paths %}
<li class="mono-sm">{{ path }}</li>
{% endfor %}
</ul>
</div>
<script>
function copyText(btn, text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Скопировано!';
setTimeout(() => btn.textContent = 'Копировать', 2000);
});
}
</script>
{% endblock %}