fix(api): показывать источник и частичные сбои каталога интерфейсов
This commit is contained in:
1 parent
ecd72d9750
commit
c37498512f
9 files changed
+165
-44
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readInventorySnapshot, readTrafficInventory } from "./interface-inventory.js"
|
||||
assert.deepEqual(readInventorySnapshot([]),{known:true,rows:[]})
|
||||
assert.equal(readInventorySnapshot(null).known,false)
|
||||
const snapshot=readInventorySnapshot([{name:"ether1",running:false,disabled:false},{name:"wg1",running:true},{name:"gre1",running:"n/a",disabled:"0"}])
|
||||
assert.equal(snapshot.rows[0]?.running,false);assert.equal(snapshot.rows[1]?.disabled,null);assert.equal(snapshot.rows[2]?.running,null);assert.equal(snapshot.rows[2]?.disabled,false)
|
||||
for(const value of ["bad-json",{},[{}],[{name:"ether1"},{name:"ether1"}]])assert.throws(()=>readInventorySnapshot(value))
|
||||
const at="2026-10-01T00:00:00Z";const history=readTrafficInventory([
|
||||
{interfaceName:"old",peerPublicKey:"",sampledAt:"2026-09-01T00:00:00Z",flags:1},
|
||||
{interfaceName:"ether1",peerPublicKey:"",sampledAt:at,flags:0},
|
||||
{interfaceName:"wg1",peerPublicKey:"peer",sampledAt:at,flags:1},
|
||||
{interfaceName:"loopback",peerPublicKey:"",sampledAt:at,flags:1},
|
||||
{interfaceName:"gre1",peerPublicKey:"",sampledAt:at,flags:null},
|
||||
{interfaceName:"ether2",peerPublicKey:"",sampledAt:at,flags:3},
|
||||
],at)
|
||||
assert.deepEqual(history.map(row=>row.name),["ether1","gre1","ether2"]);assert.equal(history[0]?.running,false);assert.equal(history[0]?.disabled,false);assert.equal(history[1]?.running,null);assert.equal(history[2]?.disabled,true)
|
||||
console.log("User inventory: explicit empty, strict snapshots, tri-state flags and one traffic receipt; no DB/RouterOS")
|
||||
@@ -0,0 +1,31 @@
|
||||
import { mapRosInterfaceType, type InterfaceType } from "./iface-type.js"
|
||||
export interface InventoryIface { name: string; type: InterfaceType; running: boolean | null; disabled: boolean | null }
|
||||
function flag(value: unknown): boolean | null {
|
||||
if (value === true || value === 1 || value === "true" || value === "yes" || value === "1") return true
|
||||
if (value === false || value === 0 || value === "false" || value === "no" || value === "0") return false
|
||||
return null
|
||||
}
|
||||
export function readInventorySnapshot(raw: unknown): { known: boolean; rows: InventoryIface[] } {
|
||||
if (raw == null || raw === "") return { known: false, rows: [] }
|
||||
let rows: unknown
|
||||
try { rows = typeof raw === "string" ? JSON.parse(raw) : raw } catch { throw new Error("Некорректный JSON снимка интерфейсов.") }
|
||||
if (!Array.isArray(rows)) throw new Error("Снимок интерфейсов не является массивом.")
|
||||
const names = new Set<string>()
|
||||
return { known: true, rows: rows.map(item => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("Некорректная запись снимка интерфейсов.")
|
||||
const record = item as Record<string, unknown>
|
||||
const name = typeof record.name === "string" ? record.name.trim() : ""
|
||||
if (!name || names.has(name)) throw new Error("Пустое или повторяющееся имя интерфейса.")
|
||||
names.add(name)
|
||||
return { name, type: mapRosInterfaceType(typeof record.type === "string" ? record.type : "", name), running: flag(record.running), disabled: flag(record.disabled) }
|
||||
}) }
|
||||
}
|
||||
export function readTrafficInventory(rows: Array<{ interfaceName: string; peerPublicKey: string | null; flags: number | null; sampledAt: string }>, sampledAt: string): InventoryIface[] {
|
||||
const seen = new Set<string>()
|
||||
return rows.filter(row => Number.isFinite(Date.parse(sampledAt)) && Date.parse(row.sampledAt) === Date.parse(sampledAt) && row.interfaceName && !/^(lo|loopback)/i.test(row.interfaceName) && !row.peerPublicKey).flatMap(row => {
|
||||
if (seen.has(row.interfaceName)) return []
|
||||
seen.add(row.interfaceName)
|
||||
const known = typeof row.flags === "number" && Number.isInteger(row.flags) && row.flags >= 0 && row.flags <= 3
|
||||
return [{ name: row.interfaceName, type: mapRosInterfaceType("", row.interfaceName), running: known ? !!(row.flags! & 1) : null, disabled: known ? !!(row.flags! & 2) : null }]
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import type {
|
||||
AppUserCreate,
|
||||
AppUserRead,
|
||||
AppUserUpdate,
|
||||
CatalogInterface,
|
||||
CatalogInterfaceState,
|
||||
InterfaceCatalogState,
|
||||
InterfaceType,
|
||||
SectionPerm,
|
||||
ServerPerm,
|
||||
@@ -13,7 +15,7 @@ import type {
|
||||
} from "@mmapp/contracts/users"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { parseJsonArray } from "../../../db/json.js"
|
||||
import { decodeTrafficSampleFlags } from "../../../db/traffic-flags.js"
|
||||
import { readInventorySnapshot, readTrafficInventory } from "../interface-inventory.js"
|
||||
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||
import {
|
||||
createBindingRow,
|
||||
@@ -253,44 +255,29 @@ async function inferIfaceType(serverId: number, ifaceName: string): Promise<Inte
|
||||
return found?.type ?? "other"
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||
export async function getInterfaceCatalogState(serverId: number): Promise<InterfaceCatalogState> {
|
||||
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||
|
||||
const snap = await getLatestSnapshot(serverId)
|
||||
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
|
||||
if (ifaces.length === 0) {
|
||||
const lastRows = await db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
let inventory: ReturnType<typeof readInventorySnapshot>
|
||||
try { inventory = readInventorySnapshot(snap?.rawInterfaces) } catch (error) { throw new UsersServiceError(error instanceof Error ? error.message : "Некорректный снимок интерфейсов.", 502) }
|
||||
let ifaces = inventory.rows
|
||||
let source: InterfaceCatalogState["source"] = inventory.known ? "snapshot" : "unknown"
|
||||
let sampledAt = inventory.known ? snap?.polledAt ?? null : null
|
||||
const failures: InterfaceCatalogState["failures"] = []
|
||||
if (!inventory.known) {
|
||||
const lastRows = await db.select({ sampledAt: trafficSamples.sampledAt }).from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId)).orderBy(desc(trafficSamples.sampledAt)).limit(1)
|
||||
const last = lastRows[0]
|
||||
if (last) {
|
||||
const rows = (await db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
flags: trafficSamples.flags,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId)))
|
||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||
const seen = new Set<string>()
|
||||
ifaces = []
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.interfaceName)) continue
|
||||
seen.add(r.interfaceName)
|
||||
const decoded = decodeTrafficSampleFlags(r.flags)
|
||||
ifaces.push({
|
||||
name: r.interfaceName,
|
||||
type: mapRosInterfaceType("", r.interfaceName),
|
||||
running: decoded.running,
|
||||
disabled: decoded.disabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
const rows = await db.select({
|
||||
interfaceName: trafficSamples.interfaceName, peerPublicKey: trafficSamples.peerPublicKey,
|
||||
flags: trafficSamples.flags, sampledAt: trafficSamples.sampledAt,
|
||||
}).from(trafficSamples).where(and(eq(trafficSamples.serverId, serverId), eq(trafficSamples.sampledAt, last.sampledAt)))
|
||||
ifaces = readTrafficInventory(rows, last.sampledAt)
|
||||
source = "traffic-history"; sampledAt = last.sampledAt
|
||||
} else failures.push({ resource: "interfaces", error: "Нет подтверждённого снимка интерфейсов." })
|
||||
}
|
||||
|
||||
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
|
||||
@@ -309,10 +296,13 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
// IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата).
|
||||
const ipsecLive = await listIpsecClientsForCatalog(serverId)
|
||||
|
||||
const entries: CatalogInterface[] = ifaces.map((iface) => {
|
||||
if (wgLive.error) failures.push({ resource: "wireguard", error: wgLive.error })
|
||||
if (ipsecLive.error) failures.push({ resource: "ipsec", error: ipsecLive.error })
|
||||
|
||||
const entries: CatalogInterfaceState[] = ifaces.map((iface) => {
|
||||
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||
const base: CatalogInterface = {
|
||||
const base: CatalogInterfaceState = {
|
||||
name: iface.name,
|
||||
type: iface.type,
|
||||
running: iface.running,
|
||||
@@ -320,6 +310,7 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
boundUserId: ifaceBind?.userId ?? null,
|
||||
boundUserLogin: owner?.login ?? null,
|
||||
}
|
||||
if (iface.type === "ipsec") return { ...base, peers: [], peersError: "Список клиентов для этого интерфейса не подтверждён." }
|
||||
if (iface.type !== "wg") return base
|
||||
const livePeers = peersByIface.get(iface.name) ?? []
|
||||
return {
|
||||
@@ -341,14 +332,16 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
}
|
||||
})
|
||||
|
||||
if (ipsecLive.clients.length > 0) {
|
||||
{
|
||||
const duplicate = entries.findIndex(iface => iface.name === "ipsec-vpn")
|
||||
if (duplicate >= 0) entries.splice(duplicate, 1)
|
||||
const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? ""))
|
||||
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||
entries.push({
|
||||
name: "ipsec-vpn",
|
||||
type: "ipsec",
|
||||
running: true,
|
||||
disabled: false,
|
||||
running: null,
|
||||
disabled: null,
|
||||
boundUserId: ifaceBind?.userId ?? null,
|
||||
boundUserLogin: owner?.login ?? null,
|
||||
peersError: ipsecLive.error,
|
||||
@@ -369,7 +362,12 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return { serverId, source, sampledAt, inventoryComplete: inventory.known, interfaces: entries.sort((a, b) => a.name.localeCompare(b.name)), failures }
|
||||
}
|
||||
|
||||
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||
const state = await getInterfaceCatalogState(serverId)
|
||||
return state.interfaces.filter(iface => !(iface.type === "ipsec" && iface.name === "ipsec-vpn" && !iface.peers?.length)).map(iface => ({ ...iface, running: iface.running ?? false, disabled: iface.disabled ?? false }))
|
||||
}
|
||||
|
||||
export { parseRawInterfaces, mapRosInterfaceType }
|
||||
Reference in new issue
Block a user