// 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 { useState, useEffect, useCallback } from 'react'; import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoRefresh, IcoTrash } from '../../components/icons'; import { serverName } from '../../components/ServerPicker'; import './audit.css'; // Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере, // хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых // (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой. // Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*). interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean } interface AuditEntry { server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean; ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number; } interface Settings { clean: boolean; every_h: number; max: number } interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean } interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] } const PERIODS = [1, 3, 6, 12, 24]; const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; export default function AuditPage({ token, showMsg }: PageProps) { const [audit, setAudit] = useState(null); const [nodes, setNodes] = useState([]); const [busy, setBusy] = useState(false); const [maxEdit, setMaxEdit] = useState(''); const load = useCallback(async () => { try { setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token)); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); } catch { showMsg('Ошибка загрузки аудита'); } }, [token, showMsg]); useEffect(() => { if (token) load(); }, [token, load]); const call = useCallback(async (method: string, path: string, ok: string, body?: object) => { setBusy(true); try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); } catch (e) { showMsg(errText(e)); } finally { setBusy(false); } }, [token, showMsg]); const run = () => call('POST', '', 'Аудит выполнен'); const save = (patch: Partial) => call('PUT', '/settings', 'Настройки сохранены', patch); if (!audit) return null; if (!audit.enabled) return

Аудит выключен на этой панели: его проводит только core.

; const { settings } = audit; const servers = audit.report?.servers ?? []; const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0); const bad = servers.filter(e => e.status !== 'ok'); const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0); const blocked = servers.filter(e => e.blocked); const rows = servers.flatMap(e => [ ...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })), ...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })), ]); const state = (p: AuditPeer) => ( {p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'} ); const confirm = (p: AuditPeer) => ( {p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'} ); return ( <>
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'} {audit.report && ( Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч )}
{confirmed > 0 && ( )}

Настройки

Как часто core обходит серверы
Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя) setMaxEdit(e.target.value)} />
{bad.length > 0 && (

Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}

)} {blocked.length > 0 && (

Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.

)} {servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (

Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.

)} {rows.length > 0 && ( <>
{rows.map(({ e, p, why }) => ( ))}
СерверПубличный ключПричинаМастер-ключСостояние
{serverName(nodes, e.server_id)} {p.pub_key} {why} {p.key ?? '—'} {state(p)}
{confirm(p)}
    {rows.map(({ e, p, why }) => (
  • {serverName(nodes, e.server_id)}{state(p)}
    {p.pub_key} {why}{p.key ? ` · ${p.key}` : ''} {confirm(p)}
  • ))}
)} {audit.cleaned.length > 0 && (

Снято

    {audit.cleaned.slice(0, 20).map(c => (
  • {timeAgo(c.at)} назад {serverName(nodes, c.server_id)} {c.pub_key} {c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}
  • ))}
)} {audit.report && rows.length === 0 &&

Всё чисто: лишних пиров на серверах нет.

} ); }