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.
This commit is contained in:
@@ -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,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)}
|
||||||
|
|||||||
+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