Stop master-key peers from outliving their devices
A deleted master key could keep letting its devices in: ui.db learns what is on a server only from awg-ctrl's reply, and a node that executed a request whose 504/502 reply got lost, or a device deleted while a reconcile pass was in flight, left a peer nothing remembered to remove. - awg-ctrl: PUT /api/peers/sync takes a panel's full list of wanted keys and removes every other peer of that panel's area. Removal is driven only by such an explicit list, never by time or inactivity: a panel that is offline sends nothing, so nothing is deleted. Registered before PUT /api/peers/:pub, with its own 512 kB body limit. - Every panel has a stable id (ui.db, mirrored to awg-ui/panel.id) and adds peers as "<panel>/m<key>/d<device>", so two panels on one awg-ctrl never touch each other's peers. Legacy "m1/d2" peers are claimed only when their key is in the panel's list. - Each reconcile pass ends with the sync, built from ui.db at that moment. Its reply also self-heals: a device whose key is missing on a server (a rekey rolled back after a lost reply) is added again. - Deleting a master key stops it at once but keeps it listed as "Удаляется — ждёт: …" until every server confirms, instead of saying "deleted" while an offline node still lets the devices in. - Deleting a node reconciles it first; if it is offline or has not confirmed a deleted key, it takes ?force=1 and a warning. Tombstones are no longer dropped silently. - /sub/v1: an unknown device gets a signed 410 revoked that does not count towards the failure limit (dead clients behind one node NAT used to lock everyone out), every response echoes the request's ts and device, and 401 reasons and the first 429 per window are logged. config and rev are unchanged, byte-identical on one server. - CLI: `awg-ctrl peers [drop <area|legacy>]` lists and removes subscription peers by area; `join --no-panel` asks whether to keep or drop the panel's own peers (NODE_PEERS=keep|drop).
This commit is contained in:
+9
-1
@@ -102,7 +102,15 @@ export default function App() {
|
||||
const deleteNode = useCallback(async (n: NodeInfo) => {
|
||||
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
} catch (e) {
|
||||
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
|
||||
// панель объясняет, что останется, и удаляет только с согласия.
|
||||
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
|
||||
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
|
||||
}
|
||||
showMsg('Сервер удалён');
|
||||
await loadNodes(token);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import './masterkeys.css';
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number }
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||
@@ -47,7 +48,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
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.map(k =>
|
||||
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('Ошибка загрузки мастер-ключей'); }
|
||||
@@ -71,10 +72,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
const serverNames = useCallback((ids: number[]) =>
|
||||
ids.map(id => nodes.find(n => n.id === id)?.name ?? `#${id}`).join(', '), [nodes]);
|
||||
|
||||
const remove = useCallback(async (k: MasterKey) => {
|
||||
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
||||
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
|
||||
}, [token, act]);
|
||||
try {
|
||||
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
|
||||
const pending: number[] = r.pending ?? [];
|
||||
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [token, load, showMsg, serverNames]);
|
||||
|
||||
const fetchLink = useCallback(async (k: MasterKey) => {
|
||||
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
|
||||
@@ -101,6 +110,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
|
||||
const waiting = keys.some(k => k.deleting);
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const t = setInterval(load, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [waiting, load]);
|
||||
|
||||
const deletingChip = (k: MasterKey) => (
|
||||
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
|
||||
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
|
||||
</span>
|
||||
);
|
||||
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
||||
@@ -277,6 +300,12 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
|
||||
) : keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<tr key={k.id} className="mk-tr-master mk-last">
|
||||
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
|
||||
<td colSpan={8}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const open = !folded.has(k.id);
|
||||
@@ -324,6 +353,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||
<ul className="mk-tree">
|
||||
{keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const isFolded = folded.has(k.id);
|
||||
|
||||
Reference in New Issue
Block a user