fix(gui): correct wg dump indexes, status, traffic, UX improvements
- Fix off-by-one bug in wg_dump(): handshake was read from parts[5] (rx_bytes), now correctly reads from parts[4]; rx/tx shifted accordingly - Run wg show via sudo to work under unprivileged wgadmin user - Remove NoNewPrivileges from systemd service (needed for sudo) - Merge Handshake column into Status badge (shows "online · 2м назад") - Add humanize_ago() for human-readable handshake time - Add next_free_ip() to suggest next available IP in new peer form - Add device type quick-select buttons (Phone/Laptop/PC/Router/Server/Tablet) - Placeholder in AllowedIPs now shows the real next free IP - Traffic column shows ↓ rx / ↑ tx separately Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+41
-11
@@ -149,7 +149,7 @@ def parse_wg_conf_peer_meta():
|
||||
|
||||
def wg_dump():
|
||||
try:
|
||||
out = run(["wg", "show", WG_INTERFACE, "dump"])
|
||||
out = run(["sudo", "/usr/bin/wg", "show", WG_INTERFACE, "dump"])
|
||||
except Exception:
|
||||
return []
|
||||
rows = out.splitlines()
|
||||
@@ -158,20 +158,18 @@ def wg_dump():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 8:
|
||||
continue
|
||||
# dump format: pubkey psk endpoint allowed_ips handshake_ts rx tx keepalive
|
||||
latest_ts = 0
|
||||
latest = "never"
|
||||
if parts[5].isdigit() and int(parts[5]) > 0:
|
||||
latest_ts = int(parts[5])
|
||||
latest = datetime.utcfromtimestamp(latest_ts).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if parts[4].isdigit() and int(parts[4]) > 0:
|
||||
latest_ts = int(parts[4])
|
||||
peers.append(
|
||||
{
|
||||
"public_key": parts[0],
|
||||
"endpoint": parts[2] or "-",
|
||||
"allowed_ips": parts[3],
|
||||
"latest_handshake": latest,
|
||||
"latest_handshake_ts": latest_ts,
|
||||
"rx_bytes": int(parts[6] or 0),
|
||||
"tx_bytes": int(parts[7] or 0),
|
||||
"rx_bytes": int(parts[5] or 0),
|
||||
"tx_bytes": int(parts[6] or 0),
|
||||
}
|
||||
)
|
||||
return peers
|
||||
@@ -186,6 +184,37 @@ def bytes_h(n):
|
||||
x /= 1024
|
||||
|
||||
|
||||
def humanize_ago(ts: int, now: int) -> str:
|
||||
if not ts:
|
||||
return "никогда"
|
||||
diff = now - ts
|
||||
if diff < 60:
|
||||
return f"{diff}с назад"
|
||||
if diff < 3600:
|
||||
return f"{diff // 60}м назад"
|
||||
if diff < 86400:
|
||||
return f"{diff // 3600}ч назад"
|
||||
return f"{diff // 86400}д назад"
|
||||
|
||||
|
||||
def next_free_ip(network: str) -> str:
|
||||
import ipaddress
|
||||
try:
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
except ValueError:
|
||||
return ""
|
||||
with db_conn() as conn:
|
||||
used = {
|
||||
row[0].split("/")[0]
|
||||
for row in conn.execute("SELECT client_address FROM peers WHERE client_address IS NOT NULL")
|
||||
}
|
||||
# skip network address and first host (server usually takes .1)
|
||||
for host in list(net.hosts())[1:]:
|
||||
if str(host) not in used:
|
||||
return f"{host}/{net.prefixlen}"
|
||||
return ""
|
||||
|
||||
|
||||
def gen_keypair_psk():
|
||||
priv = run(["wg", "genkey"])
|
||||
pub = subprocess.check_output(["wg", "pubkey"], input=priv, text=True).strip()
|
||||
@@ -268,7 +297,7 @@ def index():
|
||||
"routes": row.get("advertised_routes") or "-",
|
||||
"allowed_ips": rt.get("allowed_ips", "-"),
|
||||
"endpoint": rt.get("endpoint", "-"),
|
||||
"latest_handshake": rt.get("latest_handshake", "offline"),
|
||||
"handshake_ago": humanize_ago(ts, now),
|
||||
"rx": bytes_h(rt.get("rx_bytes", 0)),
|
||||
"tx": bytes_h(rt.get("tx_bytes", 0)),
|
||||
"status": "online" if is_online else "offline",
|
||||
@@ -308,7 +337,7 @@ def index():
|
||||
"routes": imported_routes,
|
||||
"allowed_ips": rt.get("allowed_ips", "-"),
|
||||
"endpoint": rt.get("endpoint", "-"),
|
||||
"latest_handshake": rt.get("latest_handshake", "offline"),
|
||||
"handshake_ago": humanize_ago(ts, now),
|
||||
"rx": bytes_h(rt.get("rx_bytes", 0)),
|
||||
"tx": bytes_h(rt.get("tx_bytes", 0)),
|
||||
"status": "online" if is_online else "offline",
|
||||
@@ -323,7 +352,8 @@ def index():
|
||||
def new_peer():
|
||||
meta = load_meta()
|
||||
if request.method == "GET":
|
||||
return render_template("new_peer.html", meta=meta)
|
||||
next_ip = next_free_ip(meta.get("WG_NETWORK", "10.66.66.0/24"))
|
||||
return render_template("new_peer.html", meta=meta, next_ip=next_ip)
|
||||
|
||||
name = request.form.get("name", "").strip()
|
||||
mode = request.form.get("mode", "full").strip()
|
||||
|
||||
@@ -200,6 +200,11 @@ tbody tr.row-disabled td { opacity: 0.45; }
|
||||
.badge.offline { background: var(--bg3); color: var(--text-muted); }
|
||||
.badge.offline .dot { background: #9ca3af; }
|
||||
|
||||
.badge-ago { font-weight: 400; opacity: 0.75; font-size: 10.5px; margin-left: 2px; }
|
||||
|
||||
.traffic { display: flex; flex-direction: column; gap: 1px; font-size: 12px; font-family: var(--mono); }
|
||||
.traffic span { white-space: nowrap; }
|
||||
|
||||
/* ─── Buttons ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
@@ -269,6 +274,23 @@ tbody tr.row-disabled td { opacity: 0.45; }
|
||||
|
||||
.form-actions { display: flex; gap: 8px; margin-top: 24px; }
|
||||
|
||||
.optional { font-size: 11px; font-weight: 400; color: var(--text-muted); margin-left: 4px; }
|
||||
|
||||
.name-hints { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.hint-label { font-size: 12px; color: var(--text-muted); }
|
||||
.hint-btn {
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg3);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.hint-btn:hover { background: #e8eaf6; border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ─── Peer detail ────────────────────────────────────────────── */
|
||||
.peer-detail-grid { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: start; }
|
||||
@media (max-width: 768px) { .peer-detail-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<th>IP</th>
|
||||
<th>Роуты</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Handshake</th>
|
||||
<th>RX / TX</th>
|
||||
<th>Публичный ключ</th>
|
||||
<th>Действия</th>
|
||||
@@ -33,15 +32,19 @@
|
||||
<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 class="badge {{ p.status }}" title="{{ p.handshake_ago }}">
|
||||
<i class="dot"></i>
|
||||
<span>{{ p.status }}</span>
|
||||
<span class="badge-ago">{{ p.handshake_ago }}</span>
|
||||
</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 class="text-muted traffic">
|
||||
<span title="Получено">↓ {{ p.rx }}</span>
|
||||
<span title="Отправлено">↑ {{ p.tx }}</span>
|
||||
</td>
|
||||
<td><span class="pubkey" title="{{ p.public_key }}">{{ p.public_key[:20] }}…</span></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
@@ -77,7 +80,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" class="empty">Пиров нет. <a href="{{ url_for('new_peer') }}">Добавить первый</a></td>
|
||||
<td colspan="8" class="empty">Пиров нет. <a href="{{ url_for('new_peer') }}">Добавить первый</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -9,34 +9,44 @@
|
||||
<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 />
|
||||
<label>Имя устройства</label>
|
||||
<input type="text" name="name" id="name-input" placeholder="например: iphone-ruslan" autofocus required />
|
||||
<div class="name-hints">
|
||||
<span class="hint-label">Быстрый выбор:</span>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('phone')">Phone</button>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('laptop')">Laptop</button>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('pc')">PC</button>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('router')">Router</button>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('server')">Server</button>
|
||||
<button type="button" class="hint-btn" onclick="setPrefix('tablet')">Tablet</button>
|
||||
</div>
|
||||
<small>Рекомендуем формат <code>тип-место</code>: <code>laptop-home</code>, <code>router-office</code>, <code>phone-moscow</code></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Режим</label>
|
||||
<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>
|
||||
<span>Полный туннель — весь трафик через VPN (<code>0.0.0.0/0</code>)</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="mode" value="split" onchange="toggleRoutes(this)">
|
||||
<span>Split-tunnel (только нужные сети)</span>
|
||||
<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>
|
||||
<input type="text" name="allowed_ips" placeholder="{{ next_ip or meta.get('WG_NETWORK','10.66.66.0/24') }}" />
|
||||
<small>Следующий свободный IP: <code>{{ next_ip or 'не определён' }}</code>. Оставьте пустым — подставится автоматически.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Дополнительные роуты (advertised)</label>
|
||||
<label>Дополнительные роуты <span class="optional">необязательно</span></label>
|
||||
<input type="text" name="routes" placeholder="192.168.1.0/24, 10.0.0.0/8" />
|
||||
<small>Сети, которые клиент анонсирует другим участникам. Необязательно.</small>
|
||||
<small>Локальные сети за клиентом, доступные другим участникам VPN.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -51,5 +61,14 @@ function toggleRoutes(el) {
|
||||
document.getElementById('allowed-ips-group').style.display =
|
||||
el.value === 'split' ? 'block' : 'none';
|
||||
}
|
||||
function setPrefix(type) {
|
||||
const inp = document.getElementById('name-input');
|
||||
const cur = inp.value;
|
||||
const m = cur.match(/^[a-z]+-?(.*)$/i);
|
||||
const suffix = (m && m[1]) ? m[1] : '';
|
||||
inp.value = suffix ? type + '-' + suffix : type + '-';
|
||||
inp.focus();
|
||||
inp.setSelectionRange(inp.value.length, inp.value.length);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user