4 Commits
Author SHA1 Message Date
maeneko 28ccc17bde Bump to Beta 0.4.3
ci / check (push) Successful in 22m56s
release / release (push) Successful in 17m49s
2026-10-19 12:23:21 +03:00
maeneko d6fc2049c3 Master keys tab: same columns as users, device IPs, per-key summary
Status, priority/torrent chips and IP columns like the users tab; the API
returns each device's IP per server. The key row sums its devices (online,
priority, traffic); key UUID and device ID sit under the name, copy on click.
2026-10-19 12:23:21 +03:00
maeneko ea91265674 Bump to Beta 0.4.2.1
ci / check (push) Successful in 22m38s
release / release (push) Successful in 17m42s
2026-10-18 00:09:34 +03:00
maeneko de8a32ac41 Fix awg-ctrl hanging on the torrent flag; log out on an expired session
The torrent flag was read inside loops over users rows (users/stats,
peers, metrics) and each read queried the config table. SQLite has a
single connection here, so the nested query waited for it forever: the
first stats request or Prometheus scrape locked awg-ctrl for good, and
the panel showed no users and timed out.

- the block setting is kept in memory (loaded at start, updated on
  save), so the flag never touches the database
- the set of flagged IPs is taken once before the query, not per row
- regression test: collectMetrics must finish with users in the table

Panel: a 401 from the panel itself (its JWT not accepted) now returns to
the login screen; a 401 relayed from a node's awg-ctrl does not.
2026-10-18 00:08:17 +03:00
17 changed files with 210 additions and 61 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.4.2 VERSION=0.4.3
+1 -1
View File
@@ -151,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ | | Метод | Ответ |
|---|---| |---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | | `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-agent", "name": "awg-agent",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.4.2", "version": "0.4.3",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+2 -1
View File
@@ -60,7 +60,8 @@ const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit
const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id; const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
const devOut = (m: MK, d: Dev) => ({ ...d, const devOut = (m: MK, d: Dev) => ({ ...d,
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal", priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION })); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+11
View File
@@ -785,3 +785,14 @@ tbody td:last-child { padding-right: 20px; }
.server-pick-name { font-weight: 500; } .server-pick-name { font-weight: 500; }
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); } .server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.server-pick-ip { font-family: var(--mono); margin-left: auto; } .server-pick-ip { font-family: var(--mono); margin-left: auto; }
/* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства):
первые символы моноширинным, полностью — в подсказке, по клику копируется. */
.name-stack { display: inline-flex; flex-direction: column; line-height: 1.25; vertical-align: middle; }
.copy-id {
align-self: flex-start;
background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text);
}
.copy-id:hover { color: var(--primary); }
.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
+18
View File
@@ -149,6 +149,24 @@ export default function App() {
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
}, []); }, []);
// Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран
// оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят):
// 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт.
useEffect(() => {
const id = axios.interceptors.response.use(undefined, e => {
if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setNodes([]);
setServerId(null);
setServerIdState(null);
showMsg('Сессия истекла — войдите снова');
}
return Promise.reject(e);
});
return () => axios.interceptors.response.eject(id);
}, [showMsg]);
// Восстановить сессию из сохранённого токена // Восстановить сессию из сохранённого токена
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY); const saved = localStorage.getItem(TOKEN_KEY);
+90 -34
View File
@@ -3,8 +3,8 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared'; import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker'; import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip'; import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip'; import TorrentChip from '../../components/TorrentChip';
@@ -19,6 +19,7 @@ interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
} }
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) — // Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
@@ -32,6 +33,18 @@ const platformLabel = (d: Device): string =>
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
const statusChip = (d: Device) => (
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
</span>
);
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом.
const ipOf = (d: Device): string =>
(d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—';
export default function MasterKeysPage({ token, showMsg }: PageProps) { export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]); const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
@@ -254,17 +267,51 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div> </div>
); );
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка), // Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// полностью — в подсказке, по клику копируется. // по клику копируется.
const uuidCell = (k: MasterKey) => ( const nameCell = (k: MasterKey) => (
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}> <span className="name-stack">
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`} {k.label}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}> <span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<code className="mk-device-id">{k.uuid}</code> <button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
</button> onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
{k.uuid.slice(0, 8)}…
</button>
</span>
</span> </span>
); );
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
const summary = (list: Device[]) => {
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
const on = list.filter(d => d.online).length;
const last = Math.max(...list.map(d => d.lastHandshake || 0));
const torrent = list.some(d => d.torrent);
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority;
return (
<>
<td>
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
<span className="chip-dot" />
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
</span>
</td>
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
<td colSpan={2}></td>
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
</>
);
};
const ipCell = (d: Device) => {
const all = d.ips ?? [];
const ip = <code className="mk-ip">{ipOf(d)}</code>;
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip}</span>
: ip;
};
const caret = (k: MasterKey) => ( const caret = (k: MasterKey) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`} <button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)} aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
@@ -279,14 +326,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className="mk-device-name">{d.device_name || 'Устройство'}</span> <span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code> <code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</span> </span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} <span className="mk-device-sub">{platformLabel(d)}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span> {d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
</div> </div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}> {statusChip(d)}
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'} {/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
</span> <div className="mk-device-ip">
<PriorityChip priority={d.priority} torrent={d.torrent} /> <IcoGlobe />
<TorrentChip torrent={d.torrent} /> <span className="mk-ip">{ipOf(d)}</span>
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
</div>
<div className="mk-device-traffic"> <div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span> <span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span> <span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
@@ -320,8 +370,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table"> <table className="mk-table">
<thead> <thead>
<tr> <tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th> <th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th> <th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -330,9 +380,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
) : keys.map(k => { ) : keys.map(k => {
if (k.deleting) return ( if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last"> <tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{k.label}</span></td> <td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td className="td-mono">{uuidCell(k)}</td> <td colSpan={8}>{deletingChip(k)}</td>
<td colSpan={7}>{deletingChip(k)}</td>
</tr> </tr>
); );
const list = devs[k.id] ?? []; const list = devs[k.id] ?? [];
@@ -341,28 +390,35 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
return ( return (
<Fragment key={k.id}> <Fragment key={k.id}>
<tr className="mk-tr-master"> <tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td> <td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}</span></td>
<td className="td-mono">{uuidCell(k)}</td> {summary(list)}
<td colSpan={5}></td>
<td>{meter(k, list.length)}</td> <td>{meter(k, list.length)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td> <td className="td-actions mk-td-actions">{actions(k)}</td>
</tr> </tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>} {tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => ( {open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}> <tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
<td>{platformLabel(d)}</td>
<td> <td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}> <span className="mk-indent">
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'} <span className="name-stack">
{d.device_name || 'Устройство'}
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
{d.device_id.slice(0, 8)}…
</button>
</span>
</span>
</span> </span>
</td>
<td className="td-mono">{bytes(d.rx)}</td>
<td className="td-mono">{bytes(d.tx)}</td>
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>} {d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td> {syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
</td>
<td>{statusChip(d)}</td>
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
<td className="td-mono">{ipCell(d)}</td>
<td>{platformLabel(d)}</td>
<td>{bytes(d.rx)}</td>
<td>{bytes(d.tx)}</td>
<td></td> <td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td> <td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr> </tr>
+10 -7
View File
@@ -26,13 +26,6 @@ LICENSE file in the root directory of this source tree.
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; } .mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); } .mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); } .mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
.mk-uuid-btn {
background: none; border: 0; padding: 0; margin: 0;
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
}
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
.mk-master-uuid { .mk-master-uuid {
display: block; display: block;
font-family: var(--mono); font-size: 12px; font-weight: 400; font-family: var(--mono); font-size: 12px; font-weight: 400;
@@ -46,6 +39,9 @@ LICENSE file in the root directory of this source tree.
.mk-tr-tools:hover { background: transparent; } .mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; } .mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; } .mk-flag { color: var(--neutral-text); font-size: 12px; }
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
.mk-ip { font-family: var(--mono); }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */ /* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child { .mk-tr-device td:first-child, .mk-tr-free td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat; background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
@@ -210,6 +206,13 @@ LICENSE file in the root directory of this source tree.
} }
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); } .mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); } .mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-ip {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
width: 100%;
color: var(--neutral-text);
}
.mk-device-ip .mk-ip { font-size: 13px; }
.mk-device-ip .tip-wrap { width: auto; }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); } .mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; } .mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; } .mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+1 -1
View File
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места. # При бампе версии правь оба места.
VERSION="0.4.2" VERSION="0.4.3"
CHANNEL="${CHANNEL:-Beta}" CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник # Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
+3 -2
View File
@@ -129,6 +129,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
if st.Up { if st.Up {
stats = s.getPeersData() stats = s.getPeersData()
} }
torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''") rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -141,7 +142,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
return nil, err return nil, err
} }
ps := stats[pub] ps := stats[pub]
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), s.torrent.Seen(ip)}) b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), torrent[ip]})
} }
rows.Close() rows.Close()
devs, err := s.peersAll() devs, err := s.peersAll()
@@ -150,7 +151,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
} }
for _, p := range devs { for _, p := range devs {
ps := stats[p.PubKey] ps := stats[p.PubKey]
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), s.torrent.Seen(p.IP)}) b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), torrent[p.IP]})
} }
b.Peers.Total = st.Peers b.Peers.Total = st.Peers
for _, ps := range stats { for _, ps := range stats {
+37
View File
@@ -0,0 +1,37 @@
package ctrl
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/maeneko/forgetting/internal/logx"
)
// SQLite открыт с одним соединением: любой запрос в базу изнутри цикла по строкам users ждал
// бы его вечно и намертво вешал awg-ctrl (так было в 0.4.2 с меткой торрента). Скрейп метрик
// идёт раз в 15 с, поэтому зависание проявлялось сразу.
func TestCollectMetricsNoDeadlock(t *testing.T) {
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES (?, ?, ?, 'vpn://x')", fmt.Sprintf("u%d", i), fmt.Sprintf("10.9.0.%d", i+2), fmt.Sprintf("pk%d", i))
}
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
s.shaping = newShaping(s)
s.torrent = newTorrent(s)
done := make(chan error, 1)
go func() { _, err := s.collectMetrics(); done <- err }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(3 * time.Second):
t.Fatal("collectMetrics завис: запрос в базу изнутри цикла по строкам")
}
}
+4 -2
View File
@@ -73,6 +73,7 @@ func (s *Server) routes() []route {
}), }),
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) { R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''") rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil { if err != nil {
return result{}, err return result{}, err
@@ -88,7 +89,7 @@ func (s *Server) routes() []route {
ps := stats[pub] ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online}, list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)},
{"torrent", s.torrent.Seen(ip)}}) {"torrent", torrent[ip]}})
} }
rows.Close() rows.Close()
return ok(ob{{"users", list}}) return ok(ob{{"users", list}})
@@ -138,6 +139,7 @@ func (s *Server) routes() []route {
}), }),
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) { R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
torrent := s.torrent.SeenSet()
stats := s.getPeersData() stats := s.getPeersData()
peers, err := s.peersAll() peers, err := s.peersAll()
if err != nil { if err != nil {
@@ -148,7 +150,7 @@ func (s *Server) routes() []route {
ps := stats[p.PubKey] ps := stats[p.PubKey]
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner}, list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", s.torrent.Seen(p.IP)}}) {"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}})
} }
return ok(ob{{"peers", list}}) return ok(ob{{"peers", list}})
}), }),
+23 -7
View File
@@ -71,7 +71,9 @@ type Torrent struct {
opMu sync.Mutex // сериализует nft opMu sync.Mutex // сериализует nft
mu sync.Mutex mu sync.Mutex
enabled bool // копия настройки в памяти: cached() зовут внутри циклов по строкам users, а
// соединение с SQLite одно — запрос в базу оттуда ждал бы его вечно
tcp bool tcp bool
lastError string lastError string
cacheAt time.Time cacheAt time.Time
@@ -79,7 +81,11 @@ type Torrent struct {
cacheHits float64 cacheHits float64
} }
func newTorrent(s *Server) *Torrent { return &Torrent{s: s} } func newTorrent(s *Server) *Torrent {
t := &Torrent{s: s}
t.enabled = t.Settings().Enabled
return t
}
func (t *Torrent) Settings() TorrentSettings { func (t *Torrent) Settings() TorrentSettings {
return TorrentSettings{Enabled: t.s.getCfg("torrentBlock", "0") == "1"} return TorrentSettings{Enabled: t.s.getCfg("torrentBlock", "0") == "1"}
@@ -163,6 +169,9 @@ func (t *Torrent) Update(st TorrentSettings) error {
v = "1" v = "1"
} }
t.s.setCfg("torrentBlock", v) t.s.setCfg("torrentBlock", v)
t.mu.Lock()
t.enabled, t.cache = st.Enabled, nil
t.mu.Unlock()
t.opMu.Lock() t.opMu.Lock()
err := t.applyNow() err := t.applyNow()
t.opMu.Unlock() t.opMu.Unlock()
@@ -228,11 +237,13 @@ func (t *Torrent) read() (seen map[string]bool, hits float64, ok bool) {
return seen, hits, true return seen, hits, true
} }
// cached не трогает базу (только память и nft) — его можно звать где угодно.
func (t *Torrent) cached() (map[string]bool, float64, bool) { func (t *Torrent) cached() (map[string]bool, float64, bool) {
if !t.Settings().Enabled { t.mu.Lock()
if !t.enabled {
t.mu.Unlock()
return nil, 0, false return nil, 0, false
} }
t.mu.Lock()
if t.cache != nil && time.Since(t.cacheAt) < torrentCacheS { if t.cache != nil && time.Since(t.cacheAt) < torrentCacheS {
c, h := t.cache, t.cacheHits c, h := t.cache, t.cacheHits
t.mu.Unlock() t.mu.Unlock()
@@ -249,10 +260,15 @@ func (t *Torrent) cached() (map[string]bool, float64, bool) {
return seen, hits, true return seen, hits, true
} }
// Seen — пытался ли этот IP открыть торрент-соединение за последние torrentSeenTTL. // SeenSet — кто пытался открыть торрент-соединение за последние torrentSeenTTL. Берите его до
func (t *Torrent) Seen(ip string) bool { // запроса к базе, а не внутри цикла по строкам: так на открытом курсоре не висит вызов nft.
func (t *Torrent) SeenSet() map[string]bool {
seen, _, _ := t.cached() seen, _, _ := t.cached()
return seen[ip] out := make(map[string]bool, len(seen))
for ip := range seen {
out[ip] = true
}
return out
} }
// SeenList — IP с меткой «торренты», по порядку; шейпинг держит их в фоновом классе. // SeenList — IP с меткой «торренты», по порядку; шейпинг держит их в фоновом классе.
+5 -1
View File
@@ -437,10 +437,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
st = &devStat{} st = &devStat{}
} }
okN := 0 okN := 0
ips := []any{} // IP устройства на каждом сервере ключа, где он уже выдан
for _, l := range links { for _, l := range links {
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" { if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
okN++ okN++
} }
if l.IP.Valid && l.IP.String != "" {
ips = append(ips, jsjson.Object{{Key: "server_id", Val: l.ServerID}, {Key: "ip", Val: l.IP.String}})
}
} }
prio := "normal" prio := "normal"
if st.background { if st.background {
@@ -452,7 +456,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
{Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS}, {Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS},
{Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio}, {Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio},
{Key: "torrent", Val: st.torrent}, {Key: "torrent", Val: st.torrent},
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "ips", Val: ips},
}) })
} }
return out, nil return out, nil