Files
forgetting/awg-ui/src/tabs/users/index.tsx
T
maeneko c73da602d6 Block BitTorrent on the server
- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by
  default). A connection is judged by its first packets, like Xray's
  sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain
  TCP handshake and HTTP announce. A match marks the connection in
  conntrack and drops it whole; without @ih only the UDP part is used
- devices that tried are kept in a 10-minute set and flagged as
  "torrent" in user and device stats; with shaping on they sit in the
  background class while the flag lasts
- metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total,
  awg_torrent_devices and per-peer awg_peer_torrent
- panel: "Торренты" section in server settings, "торренты" chip on users
  and devices; Save sends only what changed
2026-10-17 23:35:30 +03:00

315 lines
17 KiB
TypeScript
Raw 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.
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen,
} from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './users.css';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
// + модалка QR. Сервер-бар сверху рендерит App. Свой стейт и поллинг — здесь.
export default function UsersPage({ token, showMsg }: PageProps) {
const [users, setUsers] = useState<User[]>([]);
const [newName, setNewName] = useState('');
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2');
const [upgrading, setUpgrading] = useState(false);
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen);
const loadStats = useCallback(async (tok: string) => {
try {
const data = await apiFetch('GET', '/api/users/stats', tok);
setUsers(prev => prev.map(u => {
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority, torrent: s.torrent } : u;
}));
} catch { /* silent */ }
}, []);
const loadUsers = useCallback(async (tok: string) => {
try {
const [data, health] = await Promise.all([
apiFetch('GET', '/api/users', tok),
apiFetch('GET', '/health', tok),
]);
setUsers(data.users ?? []);
setServerGen(health.gen ?? '2');
await loadStats(tok);
} catch {
showMsg('Ошибка загрузки');
}
}, [loadStats, showMsg]);
// Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса.
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => {
if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return;
try {
const r = await apiFetch('POST', '/api/users/reissue', token);
showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`);
await loadUsers(token);
} catch {
showMsg('Не удалось перевыпустить ключи');
}
}, [stale.length, serverGen, token, loadUsers, showMsg]);
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
// их не примет) и сразу перевыпускает ключи — старые собраны на
// 2.0-параметрах и работать не будут.
const upgradeGen = useCallback(async () => {
if (!confirm(
'Перевести сервер на AmneziaWG 3.1?\n\n' +
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
'клиентам придётся импортировать ключ заново.',
)) return;
setUpgrading(true);
try {
const r = await apiFetch('POST', '/awg/upgrade', token);
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
await loadUsers(token);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
} finally {
setUpgrading(false);
}
}, [token, loadUsers, showMsg]);
const createUser = useCallback(async () => {
if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName });
if (u.error) { showMsg(u.error); return; }
setNewName('');
showMsg('Создан: ' + newName);
await loadUsers(token);
}, [newName, token, loadUsers, showMsg]);
const deleteUser = useCallback(async (name: string) => {
if (!confirm('Удалить ' + name + '?')) return;
await apiFetch('DELETE', '/api/users/' + name, token);
showMsg('Удалён: ' + name);
await loadUsers(token);
}, [token, loadUsers, showMsg]);
const showQR = useCallback(async (name: string) => {
try {
const u = await apiFetch('POST', '/api/users/' + name, token);
const vpnKey = u.vpn_key ?? '';
// errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ),
// при 'H' ёмкость QR падает до ~1273 байт и кодирование падает.
const dataUrl = await QRCode.toDataURL(vpnKey, {
width: 560,
margin: 2,
errorCorrectionLevel: 'L',
});
setQrModal({ name, dataUrl, vpnKey });
} catch {
showMsg('Не удалось сгенерировать QR-код');
}
}, [token, showMsg]);
// Загрузить пользователей и запустить поллинг статистики, пока вкладка открыта.
useEffect(() => {
if (!token) return;
loadUsers(token);
statsRef.current = setInterval(() => loadStats(token), 60_000);
return () => { if (statsRef.current) clearInterval(statsRef.current); };
}, [token, loadUsers, loadStats]);
return (
<>
<div className="toolbar">
<input
className="field"
placeholder="Имя пользователя"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createUser()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createUser}>
<IcoPlus /> Создать
</button>
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить
</button>
{serverGen !== '3.1' && (
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
</button>
)}
{stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length})
</button>
)}
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Статус</th>
<th>Приоритет</th>
<th>Имя</th>
<th>IP</th>
<th>↓ rx</th>
<th>↑ tx</th>
<th></th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
) : users.map((u, i) => (
<tr key={u.name}>
<td className="td-num">{i + 1}</td>
<td>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{u.online
? 'онлайн'
: u.lastHandshake
? timeAgo(u.lastHandshake) + ' назад'
: 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={u.priority} torrent={u.torrent} /><TorrentChip torrent={u.torrent} /></span></td>
<td>
{u.name}
{u.key_gen !== serverGen && (
<span
className="tip-wrap user-gen"
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
>
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
</span>
)}
</td>
<td className="td-mono">{u.ip}</td>
<td>{bytes(u.rx)}</td>
<td>{bytes(u.tx)}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon"
title="QR-код vpn://"
onClick={() => showQR(u.name)}
><IcoQR /></button>
<button
className="btn-icon btn-icon--danger"
title="Удалить"
onClick={() => deleteUser(u.name)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="user-cards-list">
<p className="users-count">{users.length} пользователей</p>
{users.length === 0 ? (
<p className="empty-cards">Нет пользователей</p>
) : users.map((u, i) => (
<div className="user-card" key={u.name}>
<div className="user-card-header">
<div className="user-card-title">
<span className="user-card-num">#{i + 1}</span>
<span className="user-card-name">{u.name}</span>
{u.key_gen !== serverGen && (
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
)}
</div>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{u.online
? 'онлайн'
: u.lastHandshake
? timeAgo(u.lastHandshake) + ' назад'
: 'никогда'}
</span>
</div>
<div className="user-card-ip">
<IcoGlobe />
<span className="user-card-ip-text">{u.ip}</span>
<PriorityChip priority={u.priority} torrent={u.torrent} />
<TorrentChip torrent={u.torrent} />
</div>
<div className="user-card-metrics">
<div className="metric-block">
<span className="metric-label">↓ rx</span>
<span className="metric-value">{bytes(u.rx)}</span>
</div>
<div className="metric-block">
<span className="metric-label">↑ tx</span>
<span className="metric-value">{bytes(u.tx)}</span>
</div>
</div>
<div className="user-card-actions">
<button
className="btn btn--outline"
aria-label="QR-код"
onClick={() => showQR(u.name)}
><IcoQR /> QR</button>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить"
onClick={() => deleteUser(u.name)}
><IcoTrash /></button>
</div>
</div>
))}
</div>
{qrModal && (
<div className="qr-backdrop" onClick={() => setQrModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">{qrModal.name}</p>
<img
className="qr-img"
src={qrModal.dataUrl}
alt="QR"
title="Нажми чтобы скопировать vpn:// ключ"
onClick={async () => { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }}
/>
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
<div className="qr-actions">
<button className="btn btn--tonal" onClick={async () => {
try {
const conf = await vpnKeyToConf(qrModal.vpnKey);
const safe = qrModal.name.replace(/[\/\\:*?"<>|]+/g, '_').trim() || 'awg';
downloadFile(safe + '.conf', conf);
showMsg('Конфиг скачан');
} catch { showMsg('Не удалось декодировать ключ'); }
}}>Скачать .conf</button>
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}