Compare commits
2
Commits
28ccc17bde
...
v0.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0687650d26 | ||
|
|
3511b8c6c3 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.4.3
|
VERSION=0.4.4
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
|||||||
|
|
||||||
| Метод | Ответ |
|
| Метод | Ответ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
| `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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-agent",
|
"name": "awg-agent",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -276,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 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, getServerId, 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, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
|
||||||
@@ -41,9 +41,13 @@ const statusChip = (d: Device) => (
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом.
|
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
|
||||||
const ipOf = (d: Device): string =>
|
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
|
||||||
(d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—';
|
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[]>([]);
|
||||||
@@ -56,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]);
|
||||||
|
|
||||||
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
|
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
|
||||||
@@ -77,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]);
|
||||||
@@ -119,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 });
|
||||||
@@ -281,6 +313,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</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[]) => {
|
const summary = (list: Device[]) => {
|
||||||
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
|
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
|
||||||
@@ -304,12 +343,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
|
||||||
const ipCell = (d: Device) => {
|
const ipCell = (d: Device) => {
|
||||||
const all = d.ips ?? [];
|
const all = d.ips ?? [];
|
||||||
const ip = <code className="mk-ip">{ipOf(d)}</code>;
|
const { ip, serverId, other } = ipPick(d);
|
||||||
|
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
|
||||||
return all.length > 1
|
return all.length > 1
|
||||||
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip}</span>
|
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
|
||||||
: ip;
|
: text;
|
||||||
};
|
};
|
||||||
|
|
||||||
const caret = (k: MasterKey) => (
|
const caret = (k: MasterKey) => (
|
||||||
@@ -333,7 +374,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
|
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
|
||||||
<div className="mk-device-ip">
|
<div className="mk-device-ip">
|
||||||
<IcoGlobe />
|
<IcoGlobe />
|
||||||
<span className="mk-ip">{ipOf(d)}</span>
|
{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>
|
||||||
@@ -361,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>
|
||||||
|
|
||||||
@@ -385,14 +426,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</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 />{nameCell(k)}</span></td>
|
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
|
||||||
{summary(list)}
|
{summary(list)}
|
||||||
<td>{meter(k, list.length)}</td>
|
<td>{meter(k, count)}</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>}
|
||||||
@@ -449,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)}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
|||||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||||
# При бампе версии правь оба места.
|
# При бампе версии правь оба места.
|
||||||
VERSION="0.4.3"
|
VERSION="0.4.4"
|
||||||
CHANNEL="${CHANNEL:-Beta}"
|
CHANNEL="${CHANNEL:-Beta}"
|
||||||
|
|
||||||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||||||
|
|||||||
+9
-5
@@ -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 // подпись → когда забыть
|
||||||
|
|
||||||
@@ -201,11 +204,12 @@ type Sub struct {
|
|||||||
func newSub(a *App) (*Sub, error) {
|
func newSub(a *App) (*Sub, error) {
|
||||||
s := &Sub{
|
s := &Sub{
|
||||||
a: a, port: envNum("SUB_PORT"),
|
a: a, port: envNum("SUB_PORT"),
|
||||||
useTLS: strings.ToLower(envOr("SUB_TLS", "off")) == "on",
|
useTLS: strings.ToLower(envOr("SUB_TLS", "off")) == "on",
|
||||||
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{},
|
||||||
seenSigs: map[string]time.Time{},
|
peerCalls: map[int]*peersCall{},
|
||||||
|
seenSigs: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
limited := func(what string) func(string) {
|
limited := func(what string) func(string) {
|
||||||
return func(ip string) {
|
return func(ip string) {
|
||||||
|
|||||||
+83
-29
@@ -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)
|
||||||
@@ -459,7 +516,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
|||||||
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "ips", Val: ips},
|
{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 {
|
||||||
@@ -613,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
|
||||||
}
|
}
|
||||||
@@ -704,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) {
|
||||||
@@ -751,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user