diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 93bf881..a0f7c33 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -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= — /devices этого ключа отвечает 502; MOCK_MK_DOWN= — этот сервер «не ответил». +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 })); diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index 4228fc7..c15690b 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -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) => ( ); -// 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([]); @@ -56,18 +60,40 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const [folded, setFolded] = useState>(new Set()); // свёрнутые узлы const [tools, setTools] = useState(null); // узел с открытыми настройками const [limitEdit, setLimitEdit] = useState(''); + const [failed, setFailed] = useState>(new Set()); // ключи, чьи устройства не загрузились + const [down, setDown] = useState>({}); // серверы ключа, не ответившие на статистику + 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(), unreachable: Record = {}; + const fresh: Record = {}; + 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) { ); + // Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику. + const dataFlag = (k: MasterKey) => { + if (failed.has(k.id)) return · устройства не загрузились; + const d = down[k.id]; + return d?.length ? · нет связи: {serverNames(d)} : null; + }; + // Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа. const summary = (list: Device[]) => { if (!list.length) return <>; @@ -304,12 +343,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { ); }; + // Адрес с другого сервера подписан его именем; подсказка со всеми адресами — когда их больше одного. const ipCell = (d: Device) => { const all = d.ips ?? []; - const ip = {ipOf(d)}; + const { ip, serverId, other } = ipPick(d); + const text = {ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}; return all.length > 1 - ? `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip} - : ip; + ? `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text} + : text; }; const caret = (k: MasterKey) => ( @@ -333,7 +374,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { {/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
- {ipOf(d)} + {ipCell(d)}
@@ -361,7 +402,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { setLimit(e.target.value)} />
- +
@@ -385,14 +426,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { ); 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 ( - {caret(k)}{nameCell(k)} + {caret(k)}{nameCell(k)}{dataFlag(k)} {summary(list)} - {meter(k, list.length)} + {meter(k, count)} {actions(k)} {tools === k.id && {toolbox(k)}} @@ -449,15 +491,16 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { ); 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 (
  • {caret(k)} - {k.label}{k.uuid} - {meter(k, list.length)} + {k.label}{dataFlag(k)}{k.uuid} + {meter(k, count)} {actions(k)}
    {tools === k.id && toolbox(k)} diff --git a/internal/ui/sub.go b/internal/ui/sub.go index 57a5820..e7c737c 100644 --- a/internal/ui/sub.go +++ b/internal/ui/sub.go @@ -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 // подпись → когда забыть @@ -201,11 +204,12 @@ type Sub struct { func newSub(a *App) (*Sub, error) { s := &Sub{ a: a, port: envNum("SUB_PORT"), - useTLS: strings.ToLower(envOr("SUB_TLS", "off")) == "on", - host: strings.TrimSpace(os.Getenv("SUB_HOST")), - auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off", - locks: map[int]*sync.Mutex{}, - seenSigs: map[string]time.Time{}, + useTLS: strings.ToLower(envOr("SUB_TLS", "off")) == "on", + 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) { return func(ip string) { diff --git a/internal/ui/sub_admin.go b/internal/ui/sub_admin.go index b51cb66..1cdbd61 100644 --- a/internal/ui/sub_admin.go +++ b/internal/ui/sub_admin.go @@ -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) diff --git a/internal/ui/sub_admin_test.go b/internal/ui/sub_admin_test.go new file mode 100644 index 0000000..987bd16 --- /dev/null +++ b/internal/ui/sub_admin_test.go @@ -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) + } + } +}