6 Commits
Author SHA1 Message Date
maeneko 0687650d26 Bump to Beta 0.4.4
ci / check (push) Successful in 24m29s
release / release (push) Successful in 19m48s
2026-10-20 20:39:09 +03:00
maeneko 3511b8c6c3 Master keys: a down server no longer breaks the device list, 403 for keys outside the API key's scope
masterDevices skips a server that did not answer and reports it as
unreachable; peers are fetched once per server per pass. The tab loads each
key on its own, counts devices from the key when the list is missing (and still
asks before unbinding), drops stale responses, labels an IP from another
server and blocks a double create. masterFor answers 403 instead of 404 for a
key that exists but is outside the API key's servers, so a client does not
mistake it for a deleted one. updateMaster checks every statement in its
transaction.
2026-10-20 20:39:09 +03:00
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
19 changed files with 435 additions and 116 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.4.2 VERSION=0.4.4
+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.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `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.4",
"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.4",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+10 -2
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 }));
@@ -275,7 +276,14 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false }); r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
}); });
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); }); // MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
app.get("/ui/masterkeys/:id/devices", (q, r) => {
const m = find(q.params.id);
if (!m) return r.status(404).json({ error: "Не найден" });
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
const down = Number(process.env.MOCK_MK_DOWN);
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
});
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); }); app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); }); app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 })); app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.4.2", "version": "0.4.4",
"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);
+149 -50
View File
@@ -1,10 +1,10 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// 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, useRef } 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,22 @@ 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 устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
const own = d.ips?.find(x => x.server_id === getServerId());
const pick = own ?? d.ips?.[0];
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
};
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);
@@ -43,18 +60,40 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState(''); const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
const [creating, setCreating] = useState(false);
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null); const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
const seq = ++loadSeq.current;
try { try {
const d = await apiFetch('GET', '/ui/masterkeys', token); const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? []; const list: MasterKey[] = d.keys ?? [];
if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k => // Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const))); const live = list.filter(k => !k.deleting);
setDevs(Object.fromEntries(all)); const res = await Promise.allSettled(live.map(k =>
} catch { showMsg('Ошибка загрузки мастер-ключей'); } apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
if (seq !== loadSeq.current) return;
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
const fresh: Record<number, Device[]> = {};
res.forEach((r, i) => {
const id = live[i].id;
if (r.status === 'fulfilled') {
fresh[id] = r.value.devices ?? [];
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
} else bad.add(id);
});
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
setDevs(prev => Object.fromEntries(live.flatMap(k =>
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
setFailed(bad); setDown(unreachable);
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]); }, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные. // Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -64,15 +103,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [load, showMsg]); }, [load, showMsg]);
const create = useCallback(async () => { const create = useCallback(async () => {
if (!label.trim()) return; if (!label.trim() || creatingRef.current) return;
await act(async () => { creatingRef.current = true; setCreating(true);
try { await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа. // Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id); const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, { await apiFetch('POST', '/ui/masterkeys', token, {
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}), label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
}); });
setLabel(''); setLabel('');
}, 'Мастер-ключ создан'); }, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, token, act]); }, [label, limit, nodes, token, act]);
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]); const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
@@ -106,15 +147,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [fetchLink, showMsg]); }, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, — // Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно. // поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => { const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit); const limit = Number(limitEdit);
const list = devs[k.id] ?? []; const list = devs[k.id];
const extra = list.length - limit; const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) { if (Number.isInteger(limit) && extra > 0) {
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra) const who = list
.map(d => `«${d.device_name || d.id}»`).join(', '); ? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return; .map(d => `«${d.device_name || d.id}»`).join(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
} }
try { try {
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit }); const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
@@ -254,17 +299,60 @@ 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}
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}> onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
<code className="mk-device-id">{k.uuid}</code> {k.uuid.slice(0, 8)}…
</button> </button>
</span> </span>
</span>
); );
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
const dataFlag = (k: MasterKey) => {
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
const d = down[k.id];
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
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, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
};
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 +367,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">
<IcoGlobe />
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} /> <PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip 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>
@@ -311,7 +402,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств" <input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} /> aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns"> <div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button> <button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
</div> </div>
</div> </div>
@@ -320,8 +411,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,39 +421,46 @@ 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] ?? [];
const free = Math.max(0, k.device_limit - list.length); const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id); const open = !folded.has(k.id);
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)}{dataFlag(k)}</span></td>
<td className="td-mono">{uuidCell(k)}</td> {summary(list)}
<td colSpan={5}></td> <td>{meter(k, count)}</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>
@@ -393,15 +491,16 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</li> </li>
); );
const list = devs[k.id] ?? []; const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length); const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id); const isFolded = folded.has(k.id);
return ( return (
<li className="mk-root" key={k.id}> <li className="mk-root" key={k.id}>
<div className="mk-master"> <div className="mk-master">
{caret(k)} {caret(k)}
<IcoKey /> <IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span> <span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, list.length)} {meter(k, count)}
{actions(k)} {actions(k)}
</div> </div>
{tools === k.id && toolbox(k)} {tools === k.id && toolbox(k)}
+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.4",
"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.4"
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}})
}), }),
+22 -6
View File
@@ -72,6 +72,8 @@ 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 с меткой «торренты», по порядку; шейпинг держит их в фоновом классе.
+4
View File
@@ -186,6 +186,9 @@ type Sub struct {
locksMu sync.Mutex locksMu sync.Mutex
locks map[int]*sync.Mutex // один проход на сервер за раз locks map[int]*sync.Mutex // один проход на сервер за раз
peersMu sync.Mutex
peerCalls map[int]*peersCall // последний GET /api/peers по серверу, см. peersOf
sigMu sync.Mutex sigMu sync.Mutex
seenSigs map[string]time.Time // подпись → когда забыть seenSigs map[string]time.Time // подпись → когда забыть
@@ -205,6 +208,7 @@ func newSub(a *App) (*Sub, error) {
host: strings.TrimSpace(os.Getenv("SUB_HOST")), host: strings.TrimSpace(os.Getenv("SUB_HOST")),
auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off", auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off",
locks: map[int]*sync.Mutex{}, locks: map[int]*sync.Mutex{},
peerCalls: map[int]*peersCall{},
seenSigs: map[string]time.Time{}, seenSigs: map[string]time.Time{},
} }
limited := func(what string) func(string) { limited := func(what string) func(string) {
+88 -30
View File
@@ -14,6 +14,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -104,12 +105,17 @@ func (s *Sub) visible(masterID int64, scope []int) bool {
return true return true
} }
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий. // masterFor — живой (не удаляемый) ключ в пределах области. Ключ, которого нет или который
// удалён, — 404; ключ, у которого есть сервер вне области API-ключа, — 403: клиент не должен
// принимать его за удалённый и выдавать вместо него второй (так у пользователя появлялся дубль).
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) { func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
m := s.masterByID(s.a.db, id) m := s.masterByID(s.a.db, id)
if m == nil || !s.visible(m.ID, scope) { if m == nil {
return nil, fail(404, "Не найден") return nil, fail(404, "Не найден")
} }
if !s.visible(m.ID, scope) {
return nil, fail(403, "Ключ вне области API-ключа")
}
return m, nil return m, nil
} }
@@ -278,20 +284,30 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
devices := s.devicesOf(s.a.db, m.ID) devices := s.devicesOf(s.a.db, m.ID)
if err := s.a.tx(func(tx *sql.Tx) error { if err := s.a.tx(func(tx *sql.Tx) error {
for _, sid := range removed { for _, sid := range removed {
tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid) if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid); err != nil {
return err
}
for _, d := range devices { for _, d := range devices {
for _, l := range s.linksOf(tx, d.ID) { for _, l := range s.linksOf(tx, d.ID) {
if l.ServerID == sid && l.PeerPub.Valid { if l.ServerID == sid && l.PeerPub.Valid {
tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String) if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
return err
} }
} }
tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid) }
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
return err
}
} }
} }
for _, sid := range added { for _, sid := range added {
tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid) if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid); err != nil {
return err
}
for _, d := range devices { for _, d := range devices {
tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid) if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
return err
}
} }
} }
return nil return nil
@@ -380,13 +396,61 @@ type devStat struct {
torrent bool torrent bool
} }
// peersTTL — сколько держится ответ GET /api/peers сервера. Вкладка за один проход просит
// устройства каждого ключа, и без этого каждый запрос заново тянул бы пиров со всех серверов.
const peersTTL = 3 * time.Second
type peersCall struct {
done chan struct{} // закрывается, когда ответ получен
at time.Time // нулевое — ответ не годится для повторного использования (ошибка)
peers []any
err error
}
// peersOf — пиры сервера с их статистикой. Параллельные запросы к одному серверу делят один
// вызов; свежий ответ переиспользуется peersTTL, ошибка — нет.
func (s *Sub) peersOf(id int) ([]any, error) {
s.peersMu.Lock()
if c := s.peerCalls[id]; c != nil {
select {
case <-c.done:
if !c.at.IsZero() && time.Since(c.at) < peersTTL {
s.peersMu.Unlock()
return c.peers, c.err
}
default: // уже идёт — дождаться его же
s.peersMu.Unlock()
<-c.done
return c.peers, c.err
}
}
c := &peersCall{done: make(chan struct{})}
s.peerCalls[id] = c
s.peersMu.Unlock()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
switch {
case err != nil:
c.err = err
case !ok2xx(r.Status):
c.err = fmt.Errorf("awg-ctrl ответил %d", r.Status)
default:
c.peers = arr(field(r.Data, "peers"))
c.at = time.Now()
}
close(c.done)
return c.peers, c.err
}
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий // masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ. // handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
func (s *Sub) masterDevices(m *masterRow) ([]any, error) { // Сервер, который не ответил, не рушит список: его статистики просто нет, а id возвращается
// вторым значением — панель покажет, что данные неполные.
func (s *Sub) masterDevices(m *masterRow) ([]any, []int) {
stats := map[string]*devStat{} stats := map[string]*devStat{}
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
var firstErr error unreachable := []int{}
for _, id := range s.serverIDsOf(s.a.db, m.ID) { for _, id := range s.serverIDsOf(s.a.db, m.ID) {
if !s.a.nodes.IsOnline(id) { if !s.a.nodes.IsOnline(id) {
continue continue
@@ -394,19 +458,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil) peers, err := s.peersOf(id)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
if firstErr == nil { unreachable = append(unreachable, id)
firstErr = err
}
return return
} }
if !ok2xx(r.Status) { for _, p := range peers {
return
}
for _, p := range arr(field(r.Data, "peers")) {
pub, _ := field(p, "pub_key").(string) pub, _ := field(p, "pub_key").(string)
st := stats[pub] st := stats[pub]
if st == nil { if st == nil {
@@ -426,9 +485,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
}() }()
} }
wg.Wait() wg.Wait()
if firstErr != nil { sort.Ints(unreachable)
return nil, firstErr
}
out := []any{} out := []any{}
for _, d := range s.devicesOf(s.a.db, m.ID) { for _, d := range s.devicesOf(s.a.db, m.ID) {
links := s.linksOf(s.a.db, d.ID) links := s.linksOf(s.a.db, d.ID)
@@ -437,10 +494,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,10 +513,10 @@ 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, unreachable
} }
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object { func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
@@ -609,8 +670,8 @@ func (s *Sub) masterKeysRouter() *Router {
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
list, err := s.masterDevices(m) list, down := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err return 200, jsjson.Object{{Key: "devices", Val: list}, {Key: "unreachable", Val: intsJSON(down)}}, nil
})) }))
return rt return rt
} }
@@ -700,10 +761,7 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
} else if out, err = withLink(c, m, out); err != nil { } else if out, err = withLink(c, m, out); err != nil {
return 0, nil, err return 0, nil, err
} }
list, err := s.masterDevices(m) list, _ := s.masterDevices(m)
if err != nil {
return 0, nil, err
}
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
})) }))
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) { rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
@@ -747,8 +805,8 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
list, err := s.masterDevices(m) list, _ := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err return 200, jsjson.Object{{Key: "devices", Val: list}}, nil
})) }))
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) { rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
d, err := device(c) d, err := device(c)
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"errors"
"path/filepath"
"testing"
)
// Ключ, который есть, но выходит за область API-ключа, — 403, а не 404: клиент не должен
// принимать его за удалённый и выдавать вместо него второй.
func TestMasterForScope(t *testing.T) {
dir := t.TempDir()
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &Sub{a: &App{db: db, baseDir: dir}} // baseDir: initSchema кладёт туда panel.id
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatal(err)
}
}
for id, secret := range map[int]string{1: "a", 2: "b", 3: "c"} {
exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (?, ?, 'k', ?, 1)", id, "u"+secret, []byte(secret))
}
exec("INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (2, 0), (2, 1), (3, 0)")
exec("UPDATE master_keys SET revoked_at = 1 WHERE id = 3")
status := func(id int64, scope []int) int {
_, err := s.masterFor(id, scope)
var ae *apiError
if err == nil {
return 200
}
if !errors.As(err, &ae) {
t.Fatalf("masterFor(%d, %v): %v", id, scope, err)
}
return ae.status
}
for _, c := range []struct {
name string
id int64
scope []int
want int
}{
{"панель видит всё", 2, nil, 200},
{"ключ в области", 1, []int{0}, 200},
{"сервер ключа вне области", 2, []int{0}, 403},
{"нет такого ключа", 99, []int{0}, 404},
{"удалённый ключ", 3, []int{0}, 404},
{"удалённый ключ в панели", 3, nil, 404},
} {
if got := status(c.id, c.scope); got != c.want {
t.Errorf("%s: %d, ожидали %d", c.name, got, c.want)
}
}
}