Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0687650d26 | ||
|
|
3511b8c6c3 |
@@ -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 /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"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: "Не найден" });
|
||||
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.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 }));
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
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';
|
||||
@@ -41,9 +41,13 @@ const statusChip = (d: Device) => (
|
||||
</span>
|
||||
);
|
||||
|
||||
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом.
|
||||
const ipOf = (d: Device): string =>
|
||||
(d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—';
|
||||
// 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) {
|
||||
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 [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
|
||||
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 load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
try {
|
||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
if (seq !== loadSeq.current) return;
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||
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)));
|
||||
setDevs(Object.fromEntries(all));
|
||||
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
|
||||
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
|
||||
const live = list.filter(k => !k.deleting);
|
||||
const res = await Promise.allSettled(live.map(k =>
|
||||
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]);
|
||||
|
||||
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
|
||||
@@ -77,15 +103,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
}, [load, showMsg]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
await act(async () => {
|
||||
if (!label.trim() || creatingRef.current) return;
|
||||
creatingRef.current = true; setCreating(true);
|
||||
try { await act(async () => {
|
||||
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
|
||||
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
|
||||
});
|
||||
setLabel('');
|
||||
}, 'Мастер-ключ создан');
|
||||
}, 'Мастер-ключ создан'); }
|
||||
finally { creatingRef.current = false; setCreating(false); }
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
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]);
|
||||
|
||||
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
|
||||
// поэтому сперва спросить и назвать, кого именно.
|
||||
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
|
||||
// счётчику ключа и имён не называем, но спросить всё равно надо.
|
||||
const saveLimit = useCallback(async (k: MasterKey) => {
|
||||
const limit = Number(limitEdit);
|
||||
const list = devs[k.id] ?? [];
|
||||
const extra = list.length - limit;
|
||||
const list = devs[k.id];
|
||||
const have = list?.length ?? k.devices;
|
||||
const extra = have - limit;
|
||||
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)
|
||||
.map(d => `«${d.device_name || d.id}»`).join(', ');
|
||||
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
|
||||
const who = list
|
||||
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
|
||||
.map(d => `«${d.device_name || d.id}»`).join(', ')
|
||||
: `${extra} шт.`;
|
||||
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
|
||||
}
|
||||
try {
|
||||
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>
|
||||
);
|
||||
|
||||
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
|
||||
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></>;
|
||||
@@ -304,12 +343,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного.
|
||||
const ipCell = (d: Device) => {
|
||||
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
|
||||
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip}</span>
|
||||
: ip;
|
||||
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
|
||||
: text;
|
||||
};
|
||||
|
||||
const caret = (k: MasterKey) => (
|
||||
@@ -333,7 +374,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
|
||||
<div className="mk-device-ip">
|
||||
<IcoGlobe />
|
||||
<span className="mk-ip">{ipOf(d)}</span>
|
||||
{ipCell(d)}
|
||||
<PriorityChip priority={d.priority} torrent={d.torrent} />
|
||||
<TorrentChip torrent={d.torrent} />
|
||||
</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="Лимит устройств"
|
||||
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
|
||||
<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>
|
||||
|
||||
@@ -385,14 +426,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</tr>
|
||||
);
|
||||
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);
|
||||
return (
|
||||
<Fragment key={k.id}>
|
||||
<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)}
|
||||
<td>{meter(k, list.length)}</td>
|
||||
<td>{meter(k, count)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(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>
|
||||
);
|
||||
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);
|
||||
return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
{caret(k)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{meter(k, list.length)}
|
||||
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{meter(k, count)}
|
||||
{actions(k)}
|
||||
</div>
|
||||
{tools === k.id && toolbox(k)}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.4.3"
|
||||
VERSION="0.4.4"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||||
|
||||
@@ -186,6 +186,9 @@ type Sub struct {
|
||||
locksMu sync.Mutex
|
||||
locks map[int]*sync.Mutex // один проход на сервер за раз
|
||||
|
||||
peersMu sync.Mutex
|
||||
peerCalls map[int]*peersCall // последний GET /api/peers по серверу, см. peersOf
|
||||
|
||||
sigMu sync.Mutex
|
||||
seenSigs map[string]time.Time // подпись → когда забыть
|
||||
|
||||
@@ -205,6 +208,7 @@ func newSub(a *App) (*Sub, error) {
|
||||
host: strings.TrimSpace(os.Getenv("SUB_HOST")),
|
||||
auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off",
|
||||
locks: map[int]*sync.Mutex{},
|
||||
peerCalls: map[int]*peersCall{},
|
||||
seenSigs: map[string]time.Time{},
|
||||
}
|
||||
limited := func(what string) func(string) {
|
||||
|
||||
+83
-29
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -104,12 +105,17 @@ func (s *Sub) visible(masterID int64, scope []int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
|
||||
// masterFor — живой (не удаляемый) ключ в пределах области. Ключ, которого нет или который
|
||||
// удалён, — 404; ключ, у которого есть сервер вне области API-ключа, — 403: клиент не должен
|
||||
// принимать его за удалённый и выдавать вместо него второй (так у пользователя появлялся дубль).
|
||||
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
|
||||
m := s.masterByID(s.a.db, id)
|
||||
if m == nil || !s.visible(m.ID, scope) {
|
||||
if m == nil {
|
||||
return nil, fail(404, "Не найден")
|
||||
}
|
||||
if !s.visible(m.ID, scope) {
|
||||
return nil, fail(403, "Ключ вне области API-ключа")
|
||||
}
|
||||
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)
|
||||
if err := s.a.tx(func(tx *sql.Tx) error {
|
||||
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 _, l := range s.linksOf(tx, d.ID) {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
@@ -380,13 +396,61 @@ type devStat struct {
|
||||
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 — статистика устройства по всем серверам ключа: онлайн, если есть свежий
|
||||
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
|
||||
func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
||||
// Сервер, который не ответил, не рушит список: его статистики просто нет, а id возвращается
|
||||
// вторым значением — панель покажет, что данные неполные.
|
||||
func (s *Sub) masterDevices(m *masterRow) ([]any, []int) {
|
||||
stats := map[string]*devStat{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var firstErr error
|
||||
unreachable := []int{}
|
||||
for _, id := range s.serverIDsOf(s.a.db, m.ID) {
|
||||
if !s.a.nodes.IsOnline(id) {
|
||||
continue
|
||||
@@ -394,19 +458,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
|
||||
peers, err := s.peersOf(id)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
unreachable = append(unreachable, id)
|
||||
return
|
||||
}
|
||||
if !ok2xx(r.Status) {
|
||||
return
|
||||
}
|
||||
for _, p := range arr(field(r.Data, "peers")) {
|
||||
for _, p := range peers {
|
||||
pub, _ := field(p, "pub_key").(string)
|
||||
st := stats[pub]
|
||||
if st == nil {
|
||||
@@ -426,9 +485,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
sort.Ints(unreachable)
|
||||
out := []any{}
|
||||
for _, d := range s.devicesOf(s.a.db, m.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},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
return out, unreachable
|
||||
}
|
||||
|
||||
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
|
||||
@@ -613,8 +670,8 @@ func (s *Sub) masterKeysRouter() *Router {
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
|
||||
list, down := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}, {Key: "unreachable", Val: intsJSON(down)}}, nil
|
||||
}))
|
||||
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 {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, _ := s.masterDevices(m)
|
||||
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
|
||||
}))
|
||||
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 {
|
||||
return 0, nil, err
|
||||
}
|
||||
list, err := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
|
||||
list, _ := s.masterDevices(m)
|
||||
return 200, jsjson.Object{{Key: "devices", Val: list}}, nil
|
||||
}))
|
||||
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
|
||||
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