From 5884bd8873cb9944b4221b40eedb48a1f93810c2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 6 Sep 2026 20:45:37 +0700 Subject: [PATCH] feat(traffic, users): enhance interface and peer management features Added support for managing peer information in interface bindings, including new fields for peerPublicKey and peerName in the BoundIfaceTraffic interface. Updated the database schema to include these fields in user_interface_bindings and traffic_samples tables. Enhanced the UI components to display peer details alongside interface names, improving user experience and clarity in the traffic management system. Updated relevant functions and services to handle peer-specific logic, ensuring robust integration across the application. --- app/(main)/traffic/page.tsx | 18 +- app/(main)/users/page.tsx | 11 +- backend/src/db/index.ts | 65 +++- backend/src/db/schema.ts | 5 +- backend/src/modules/users/bindings.test.ts | 32 +- backend/src/modules/users/peer-bind.ts | 44 +++ .../users/repository/users-repository.ts | 4 +- .../modules/users/service/users-service.ts | 87 +++++- backend/src/routes/users.ts | 2 +- backend/src/services/traffic-collector.ts | 78 ++++- backend/src/services/traffic-rate.test.ts | 25 ++ backend/src/services/traffic-rate.ts | 23 +- backend/src/services/traffic-users.ts | 30 +- backend/src/services/wireguard-live.ts | 47 +++ .../data-grids/users-expanded-detail.tsx | 14 +- components/users/user-sheet.tsx | 288 ++++++++++++++---- lib/users.ts | 91 +++++- packages/contracts/src/users.ts | 17 ++ shared/api/users.ts | 2 + 19 files changed, 749 insertions(+), 134 deletions(-) create mode 100644 backend/src/modules/users/peer-bind.ts diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index ae525b4..604855c 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -53,6 +53,8 @@ interface BoundIfaceTraffic { userName: string interfaceName: string interfaceType: InterfaceType + peerPublicKey?: string + peerName?: string comment: string serverId: string serverName: string @@ -141,6 +143,11 @@ function hashSeed(s: string): number { return Math.abs(h) } +function boundIfaceLabel(c: Pick): string { + if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}` + return c.interfaceName +} + function mockBoundFromUsers(): BoundIfaceTraffic[] { return INIT_USERS.flatMap((u) => u.bindings.map((b) => { @@ -149,13 +156,15 @@ function mockBoundFromUsers(): BoundIfaceTraffic[] { const rxNow = offline ? 0 : 12 + (seed % 140) const txNow = offline ? 0 : 8 + (seed % 110) return { - id: `${b.userId}:${b.serverId}:${b.interfaceName}`, + id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`, bindingId: b.id, userId: u.id, userLogin: u.login, userName: u.name, interfaceName: b.interfaceName, interfaceType: b.interfaceType, + peerPublicKey: b.peerPublicKey, + peerName: b.peerName, comment: b.comment, serverId: b.serverId, serverName: b.serverName, @@ -397,7 +406,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
-

{c.interfaceName}

+

{boundIfaceLabel(c)}

{c.userLogin}

@@ -435,7 +444,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
- {c.interfaceName} + {boundIfaceLabel(c)} {IFACE_TYPE_LABEL[c.interfaceType]} {showServer && ( @@ -652,7 +661,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
-

{sel.interfaceName}

+

{boundIfaceLabel(sel)}

{sel.comment || sel.userLogin}

{IFACE_TYPE_LABEL[sel.interfaceType]} @@ -897,6 +906,7 @@ export default function TrafficPage() { return [...activeBoundIfaces] .filter(c => !q || c.interfaceName.toLowerCase().includes(q) + || (c.peerName ?? "").toLowerCase().includes(q) || c.comment.toLowerCase().includes(q) || c.userLogin.toLowerCase().includes(q)) .sort((a, b) => { diff --git a/app/(main)/users/page.tsx b/app/(main)/users/page.tsx index 65e2add..79b2cfd 100644 --- a/app/(main)/users/page.tsx +++ b/app/(main)/users/page.tsx @@ -23,6 +23,7 @@ import { useDataSource } from "@/lib/data-source" import { ALL_SECTIONS, INIT_USERS, + bindingDiffKey, userInitials, type AppUser, type AppUserForm, @@ -106,19 +107,21 @@ export default function UsersPage() { const activeCount = users.filter((u) => u.active).length const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => { - const nextKeys = new Set(next.map((b) => `${b.serverId}::${b.interfaceName}`)) - const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const)) + const nextKeys = new Set(next.map(bindingDiffKey)) + const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const)) for (const b of prev) { - if (!nextKeys.has(`${b.serverId}::${b.interfaceName}`)) { + if (!nextKeys.has(bindingDiffKey(b))) { await deleteUserBinding(backendUrl, userId, b.id) } } for (const b of next) { - if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) { + if (!prevKeys.has(bindingDiffKey(b))) { await createUserBinding(backendUrl, userId, { serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType, + peerPublicKey: b.peerPublicKey, + peerName: b.peerName, comment: b.comment, }) } diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index e0d6531..5dc0c4f 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL, interface_name TEXT NOT NULL, + peer_public_key TEXT NOT NULL DEFAULT '', sampled_at TEXT NOT NULL, rx_bytes INTEGER NOT NULL DEFAULT 0, tx_bytes INTEGER NOT NULL DEFAULT 0, @@ -533,18 +534,80 @@ CREATE TABLE IF NOT EXISTS user_interface_bindings ( server_id INTEGER NOT NULL, interface_name TEXT NOT NULL, interface_type TEXT NOT NULL DEFAULT 'other', + peer_public_key TEXT NOT NULL DEFAULT '', + peer_name TEXT NOT NULL DEFAULT '', comment TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, - UNIQUE (server_id, interface_name) + UNIQUE (server_id, interface_name, peer_public_key) ); CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id); `) // Lightweight schema evolution for existing databases without migrations +{ + const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }> + if (!sampleCols.some((c) => c.name === "peer_public_key")) { + sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`) + } +} + +{ + const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }> + if (!bindCols.some((c) => c.name === "peer_public_key")) { + sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`) + } + if (!bindCols.some((c) => c.name === "peer_name")) { + sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`) + } + + const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{ + name?: string + unique?: number + }> + let hasPeerUnique = false + for (const idx of indexes) { + if (!idx.name || !idx.unique) continue + const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }> + const names = info.map((c) => c.name) + if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) { + hasPeerUnique = true + } + } + if (!hasPeerUnique) { + sqlite.exec(`PRAGMA foreign_keys = OFF`) + sqlite.exec(` + CREATE TABLE user_interface_bindings_new ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + server_id INTEGER NOT NULL, + interface_name TEXT NOT NULL, + interface_type TEXT NOT NULL DEFAULT 'other', + peer_public_key TEXT NOT NULL DEFAULT '', + peer_name TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, + UNIQUE (server_id, interface_name, peer_public_key) + ); + INSERT INTO user_interface_bindings_new + (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at) + SELECT id, user_id, server_id, interface_name, interface_type, + COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at + FROM user_interface_bindings; + DROP TABLE user_interface_bindings; + ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings; + CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id); + `) + sqlite.exec(`PRAGMA foreign_keys = ON`) + } +} + const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }> const hasCountryColumn = recursiveCols.some((c) => c.name === "country") if (!hasCountryColumn) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3d8c265..aa599b9 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -164,6 +164,7 @@ export const trafficSamples = sqliteTable("traffic_samples", { .notNull() .references(() => servers.id, { onDelete: "cascade" }), interfaceName: text("interface_name").notNull(), + peerPublicKey: text("peer_public_key").notNull().default(""), sampledAt: text("sampled_at").notNull(), rxBytes: integer("rx_bytes").notNull().default(0), txBytes: integer("tx_bytes").notNull().default(0), @@ -570,11 +571,13 @@ export const userInterfaceBindings = sqliteTable("user_interface_bindings", { interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] }) .notNull() .default("other"), + peerPublicKey: text("peer_public_key").notNull().default(""), + peerName: text("peer_name").notNull().default(""), comment: text("comment").notNull().default(""), createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }, (t) => [ - uniqueIndex("idx_user_iface_bind_server_name").on(t.serverId, t.interfaceName), + uniqueIndex("idx_user_iface_bind_server_name_peer").on(t.serverId, t.interfaceName, t.peerPublicKey), ]) export const internetPathSnapshots = sqliteTable("internet_path_snapshots", { diff --git a/backend/src/modules/users/bindings.test.ts b/backend/src/modules/users/bindings.test.ts index 26fd130..9a5b2d6 100644 --- a/backend/src/modules/users/bindings.test.ts +++ b/backend/src/modules/users/bindings.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import Database from "better-sqlite3" +import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js" const sqlite = new Database(":memory:") sqlite.pragma("foreign_keys = ON") @@ -29,12 +30,14 @@ CREATE TABLE user_interface_bindings ( server_id INTEGER NOT NULL, interface_name TEXT NOT NULL, interface_type TEXT NOT NULL DEFAULT 'other', + peer_public_key TEXT NOT NULL DEFAULT '', + peer_name TEXT NOT NULL DEFAULT '', comment TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, - UNIQUE (server_id, interface_name) + UNIQUE (server_id, interface_name, peer_public_key) ); `) @@ -55,8 +58,33 @@ assert.throws( "один интерфейс на сервере — один пользователь", ) +sqlite.prepare(` + INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name) + VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone') +`).run() +sqlite.prepare(` + INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name) + VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop') +`).run() +assert.throws( + () => sqlite.prepare(` + INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key) + VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa') + `).run(), + /UNIQUE/i, + "один пир — один пользователь", +) + +assert.throws( + () => normalizeBindingPeer("wg", ""), + (err: unknown) => err instanceof PeerBindError && err.status === 400, + "WG без ключа — 400", +) +assert.equal(normalizeBindingPeer("ether", "ignored"), "") +assert.equal(normalizeBindingPeer("wg", " abc "), "abc") + sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run() const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number } -assert.equal(leftover.n, 0, "каскад: привязки удаляются вместе с пользователем") +assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся") console.log("users bindings unique+cascade tests ok") diff --git a/backend/src/modules/users/peer-bind.ts b/backend/src/modules/users/peer-bind.ts new file mode 100644 index 0000000..535c4b3 --- /dev/null +++ b/backend/src/modules/users/peer-bind.ts @@ -0,0 +1,44 @@ +import type { InterfaceType } from "./iface-type.js" + +export class PeerBindError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message) + this.name = "PeerBindError" + } +} + +export function truncPeerKey(key: string): string { + const k = key.trim() + if (k.length <= 20) return k + return `${k.slice(0, 8)}…${k.slice(-8)}` +} + +export function peerDisplayName(opts: { + publicKey: string + name?: string | null + comment?: string | null +}): string { + const name = (opts.name ?? "").trim() + if (name) return name + const comment = (opts.comment ?? "").trim() + if (comment) return comment + return truncPeerKey(opts.publicKey) +} + +/** Ether/GRE — пустой ключ. WG — обязательный public-key. */ +export function normalizeBindingPeer( + type: InterfaceType, + peerPublicKey: string | undefined, +): string { + const key = (peerPublicKey ?? "").trim() + if (type === "wg") { + if (!key) { + throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400) + } + return key + } + return "" +} diff --git a/backend/src/modules/users/repository/users-repository.ts b/backend/src/modules/users/repository/users-repository.ts index b4db5ef..0e1af16 100644 --- a/backend/src/modules/users/repository/users-repository.ts +++ b/backend/src/modules/users/repository/users-repository.ts @@ -46,9 +46,10 @@ export function getBindingRowById(id: string): BindingRow | undefined { return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0] } -export function getBindingByServerIface( +export function getBindingByServerIfacePeer( serverId: number, interfaceName: string, + peerPublicKey = "", ): BindingRow | undefined { return db .select() @@ -56,6 +57,7 @@ export function getBindingByServerIface( .where(and( eq(userInterfaceBindings.serverId, serverId), eq(userInterfaceBindings.interfaceName, interfaceName), + eq(userInterfaceBindings.peerPublicKey, peerPublicKey), )) .limit(1) .all()[0] diff --git a/backend/src/modules/users/service/users-service.ts b/backend/src/modules/users/service/users-service.ts index 9ff396a..3701609 100644 --- a/backend/src/modules/users/service/users-service.ts +++ b/backend/src/modules/users/service/users-service.ts @@ -18,7 +18,7 @@ import { createUserRow, deleteBindingRowById, deleteUserRowById, - getBindingByServerIface, + getBindingByServerIfacePeer, getBindingRowById, getUserRowById, getUserRowByLogin, @@ -35,6 +35,12 @@ import { mapRosInterfaceType, parseRawInterfaces, } from "../iface-type.js" +import { + normalizeBindingPeer, + PeerBindError, + peerDisplayName, +} from "../peer-bind.js" +import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js" export class UsersServiceError extends Error { constructor( @@ -80,6 +86,8 @@ function toBindingDto(row: BindingRow): UserBinding { serverCountry: meta.country, interfaceName: row.interfaceName, interfaceType: row.interfaceType, + peerPublicKey: row.peerPublicKey ?? "", + peerName: row.peerName ?? "", comment: row.comment, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -179,11 +187,29 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin if (!server) throw new UsersServiceError("Сервер не найден", 404) const ifaceName = input.interfaceName.trim() if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400) - const taken = getBindingByServerIface(input.serverId, ifaceName) - if (taken) { - throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409) - } const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName) + let peerPublicKey = "" + try { + peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey) + } catch (err) { + if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status) + throw err + } + const peerName = type === "wg" + ? peerDisplayName({ + publicKey: peerPublicKey, + name: input.peerName, + }) + : "" + const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey) + if (taken) { + throw new UsersServiceError( + type === "wg" + ? "Этот пир уже привязан к другому пользователю" + : "Интерфейс уже привязан к другому пользователю", + 409, + ) + } const now = new Date().toISOString() try { const row = createBindingRow({ @@ -192,6 +218,8 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin serverId: input.serverId, interfaceName: ifaceName, interfaceType: type, + peerPublicKey, + peerName, comment: (input.comment ?? "").trim(), createdAt: now, updatedAt: now, @@ -199,7 +227,12 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin return toBindingDto(row) } catch (err) { if (isUniqueConstraintError(err)) { - throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409) + throw new UsersServiceError( + type === "wg" + ? "Этот пир уже привязан к другому пользователю" + : "Интерфейс уже привязан к другому пользователю", + 409, + ) } throw err } @@ -220,7 +253,7 @@ function inferIfaceType(serverId: number, ifaceName: string): InterfaceType { return found?.type ?? "other" } -export function listInterfaceCatalog(serverId: number): CatalogInterface[] { +export async function listInterfaceCatalog(serverId: number): Promise { const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] if (!server) throw new UsersServiceError("Сервер не найден", 404) @@ -238,13 +271,14 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] { const rows = db .select({ interfaceName: trafficSamples.interfaceName, + peerPublicKey: trafficSamples.peerPublicKey, running: trafficSamples.running, disabled: trafficSamples.disabled, }) .from(trafficSamples) .where(eq(trafficSamples.serverId, serverId)) .all() - .filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName)) + .filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? "")) const seen = new Set() ifaces = [] for (const r of rows) { @@ -262,18 +296,47 @@ export function listInterfaceCatalog(serverId: number): CatalogInterface[] { const bindings = listBindingRows().filter((b) => b.serverId === serverId) const usersById = new Map(listUserRows().map((u) => [u.id, u])) + const hasWg = ifaces.some((i) => i.type === "wg") + const wgLive = hasWg + ? await listWireGuardPeersForCatalog(serverId) + : { peers: [] as Awaited>["peers"] } + const peersByIface = new Map() + for (const peer of wgLive.peers) { + const list = peersByIface.get(peer.interfaceName) ?? [] + list.push(peer) + peersByIface.set(peer.interfaceName, list) + } return ifaces.map((iface) => { - const bind = bindings.find((b) => b.interfaceName === iface.name) - const owner = bind ? usersById.get(bind.userId) : undefined - return { + const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? "")) + const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined + const base: CatalogInterface = { name: iface.name, type: iface.type, running: iface.running, disabled: iface.disabled, - boundUserId: bind?.userId ?? null, + boundUserId: ifaceBind?.userId ?? null, boundUserLogin: owner?.login ?? null, } + if (iface.type !== "wg") return base + const livePeers = peersByIface.get(iface.name) ?? [] + return { + ...base, + peersError: wgLive.error, + peers: livePeers.map((p) => { + const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey) + const peerOwner = bind ? usersById.get(bind.userId) : undefined + return { + publicKey: p.publicKey, + name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }), + comment: p.comment, + allowedIps: p.allowedIps, + latestHandshake: p.latestHandshake, + boundUserId: bind?.userId ?? null, + boundUserLogin: peerOwner?.login ?? null, + } + }), + } }).sort((a, b) => a.name.localeCompare(b.name)) } diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 861a49b..bb1dafc 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -39,7 +39,7 @@ const usersRoutes: FastifyPluginAsyncZod = async (app) => { }, async (req, reply) => { const q = req.query as { serverId: number } try { - return reply.send({ interfaces: listInterfaceCatalog(q.serverId) }) + return reply.send({ interfaces: await listInterfaceCatalog(q.serverId) }) } catch (err) { return sendServiceError(reply, err) } diff --git a/backend/src/services/traffic-collector.ts b/backend/src/services/traffic-collector.ts index 89cbb5d..76f68ce 100644 --- a/backend/src/services/traffic-collector.ts +++ b/backend/src/services/traffic-collector.ts @@ -16,6 +16,38 @@ interface RosIfaceTraffic { "tx-bits-per-second"?: string } +interface RosWgPeerTraffic { + interface?: string + name?: string + comment?: string + "public-key"?: string + rx?: string + tx?: string + disabled?: string +} + +function waveKey(interfaceName: string, peerPublicKey = ""): string { + return `${interfaceName}\0${peerPublicKey}` +} + +function sampleRate( + prevWave: Map, + key: string, + rxBytes: number, + txBytes: number, + nowMs: number, +): { rxBps: number; txBps: number } { + const prev = prevWave.get(key) + const prevMs = prev ? Date.parse(prev.sampledAt) : NaN + const rxBps = prev && Number.isFinite(prevMs) + ? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0) + : 0 + const txBps = prev && Number.isFinite(prevMs) + ? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0) + : 0 + return { rxBps, txBps } +} + export interface TrafficCollectorState { running: boolean lastRunAt: string | null @@ -63,6 +95,7 @@ function readPreviousWave(serverId: number): Map [r.interfaceName, r])) + return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r])) } export async function collectTrafficOnce(): Promise { @@ -116,14 +149,7 @@ export async function collectTrafficOnce(): Promise { const txBytes = toNum(i["tx-byte"]) const running = (i.running ?? "false") === "true" const disabled = (i.disabled ?? "false") === "true" - const prev = prevWave.get(interfaceName) - const prevMs = prev ? Date.parse(prev.sampledAt) : NaN - const rxBps = prev && Number.isFinite(prevMs) - ? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0) - : 0 - const txBps = prev && Number.isFinite(prevMs) - ? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0) - : 0 + const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs) if (shouldIncludeIface(interfaceName, running, disabled)) { sumRxMbps += bpsToMbps(rxBps) sumTxMbps += bpsToMbps(txBps) @@ -131,6 +157,7 @@ export async function collectTrafficOnce(): Promise { return { serverId: srv.id, interfaceName, + peerPublicKey: "", sampledAt: now, rxBytes, txBytes, @@ -140,6 +167,39 @@ export async function collectTrafficOnce(): Promise { disabled, } }) + try { + const peers = await client.get("/interface/wireguard/peers") + for (const p of peers) { + const interfaceName = (p.interface ?? "").trim() + const peerPublicKey = (p["public-key"] ?? "").trim() + if (!interfaceName || !peerPublicKey) continue + const rxBytes = toNum(p.rx) + const txBytes = toNum(p.tx) + const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes" + const running = !disabled + const { rxBps, txBps } = sampleRate( + prevWave, + waveKey(interfaceName, peerPublicKey), + rxBytes, + txBytes, + nowMs, + ) + rows.push({ + serverId: srv.id, + interfaceName, + peerPublicKey, + sampledAt: now, + rxBytes, + txBytes, + rxBps, + txBps, + running, + disabled, + }) + } + } catch { + /* WG peers optional — iface samples already recorded */ + } if (rows.length > 0) { db.insert(trafficSamples).values(rows).run() } diff --git a/backend/src/services/traffic-rate.test.ts b/backend/src/services/traffic-rate.test.ts index 1d53454..ff1e1cf 100644 --- a/backend/src/services/traffic-rate.test.ts +++ b/backend/src/services/traffic-rate.test.ts @@ -97,4 +97,29 @@ const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, [ assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён") assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface") +const peerA: TrafficSampleLike[] = [ + { interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false }, +] +const peerB: TrafficSampleLike[] = [ + { interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false }, +] +const ifaceWg: TrafficSampleLike[] = [ + { interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false }, + { interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false }, +] +const mixed = [...peerA, ...peerB, ...ifaceWg] +const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a") +const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b") +const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server") +assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру") +assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate") +assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров") +assert.equal( + buildTrafficFromSamples(mixed, start, end).rxNow, + rateIface.rxNow, + "режим сервера игнорирует семплы пиров", +) + console.log("traffic-rate tests ok") diff --git a/backend/src/services/traffic-rate.ts b/backend/src/services/traffic-rate.ts index 140b4ac..de5e16d 100644 --- a/backend/src/services/traffic-rate.ts +++ b/backend/src/services/traffic-rate.ts @@ -4,6 +4,7 @@ export const SERIES_POINTS = 60 export interface TrafficSampleLike { interfaceName: string + peerPublicKey?: string sampledAt: string rxBytes: number txBytes: number @@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number { return Number.isFinite(t) ? t : 0 } +export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string { + return `${interfaceName}\0${peerPublicKey}` +} + export function buildTrafficFromSamples( rows: TrafficSampleLike[], rangeStartMs: number, rangeEndMs: number, onlyInterface?: string | readonly string[], + peerPublicKey?: string, ): BuiltTrafficSeries { const empty: BuiltTrafficSeries = { rxNow: 0, @@ -113,11 +119,12 @@ export function buildTrafficFromSamples( } if (rows.length === 0) return empty - const byIface = new Map() + const bySeries = new Map() for (const r of rows) { - const arr = byIface.get(r.interfaceName) ?? [] + const peer = r.peerPublicKey ?? "" + const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? [] arr.push(r) - byIface.set(r.interfaceName, arr) + bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr) } const allowList = Array.isArray(onlyInterface) @@ -132,12 +139,20 @@ export function buildTrafficFromSamples( let txBytesDelta = 0 let sessions = 0 - for (const [name, arr] of byIface) { + for (const [key, arr] of bySeries) { + const sep = key.indexOf("\0") + const name = sep >= 0 ? key.slice(0, sep) : key + const peer = sep >= 0 ? key.slice(sep + 1) : "" if (allowList) { if (!allowList.includes(name)) continue } else if (isLoopbackName(name)) { continue } + if (peerPublicKey === undefined) { + if (peer !== "") continue + } else if (peer !== peerPublicKey) { + continue + } const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt)) const last = sorted[sorted.length - 1] diff --git a/backend/src/services/traffic-users.ts b/backend/src/services/traffic-users.ts index cdc9841..3dabde3 100644 --- a/backend/src/services/traffic-users.ts +++ b/backend/src/services/traffic-users.ts @@ -17,6 +17,8 @@ export interface BoundIfaceTrafficDto { userName: string interfaceName: string interfaceType: string + peerPublicKey: string + peerName: string comment: string serverId: string serverName: string @@ -77,22 +79,6 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): return users.map((user) => { const parts: BuiltTrafficSeries[] = [] const interfaces: BoundIfaceTrafficDto[] = [] - const byServer = new Map() - for (const b of user.bindings) { - const arr = byServer.get(b.serverId) ?? [] - arr.push(b.interfaceName) - byServer.set(b.serverId, arr) - } - - for (const [serverId, names] of byServer) { - let rows = sampleCache.get(serverId) - if (!rows) { - rows = readServerSamplesInRange(serverId, sinceIso) - sampleCache.set(serverId, rows) - } - const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, names) - parts.push(built) - } for (const b of user.bindings) { let rows = sampleCache.get(b.serverId) @@ -100,19 +86,25 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): rows = readServerSamplesInRange(b.serverId, sinceIso) sampleCache.set(b.serverId, rows) } - const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName) - const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)] + const peerKey = b.peerPublicKey ?? "" + const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey) + parts.push(built) + const last = [...rows.filter((r) => + r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey, + )] .sort((a, c) => a.sampledAt.localeCompare(c.sampledAt)) .at(-1) const running = Boolean(last?.running) && !last?.disabled interfaces.push({ - id: `${b.userId}:${b.serverId}:${b.interfaceName}`, + id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`, bindingId: b.id, userId: user.id, userLogin: user.login, userName: user.name, interfaceName: b.interfaceName, interfaceType: b.interfaceType, + peerPublicKey: peerKey, + peerName: b.peerName ?? "", comment: b.comment, serverId: String(b.serverId), serverName: b.serverName, diff --git a/backend/src/services/wireguard-live.ts b/backend/src/services/wireguard-live.ts index 18636da..ebc86e5 100644 --- a/backend/src/services/wireguard-live.ts +++ b/backend/src/services/wireguard-live.ts @@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null } +export type CatalogWgPeer = { + interfaceName: string + publicKey: string + name: string + comment: string + allowedIps: string[] + latestHandshake?: string + disabled: boolean +} + +const WG_CATALOG_TIMEOUT_MS = 5_000 + +export async function listWireGuardPeersForCatalog(serverId: number): Promise<{ + peers: CatalogWgPeer[] + error?: string +}> { + const row = getEnabledServerById(serverId) + if (!row) return { peers: [], error: "Сервер не найден" } + try { + const client = MikrotikClient.fromServer(row) + const peersRaw = await Promise.race([ + client.get("/interface/wireguard/peers"), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS) + }), + ]) + const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => { + const mapped = mapPeer(p, idx) + const interfaceName = (p.interface ?? "").trim() + const publicKey = mapped.publicKey.trim() + if (!interfaceName || !publicKey) return [] + return [{ + interfaceName, + publicKey, + name: mapped.name ?? "", + comment: mapped.comment ?? "", + allowedIps: mapped.allowedIps, + latestHandshake: mapped.latestHandshake, + disabled: mapped.disabled === true, + }] + }) + return { peers } + } catch (e) { + return { peers: [], error: e instanceof Error ? e.message : String(e) } + } +} + export { type RosWireGuard, type RosWireGuardPeer } diff --git a/components/data-grids/users-expanded-detail.tsx b/components/data-grids/users-expanded-detail.tsx index 1735fd0..9c910aa 100644 --- a/components/data-grids/users-expanded-detail.tsx +++ b/components/data-grids/users-expanded-detail.tsx @@ -12,7 +12,7 @@ import { type AppUser, type InterfaceType, } from "@/lib/users" -import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react" +import { CableIcon, KeyRoundIcon, NetworkIcon, ShieldIcon } from "lucide-react" const TYPE_VARIANT: Record = { ether: "outline", @@ -87,23 +87,29 @@ function UsersExpandedDetail({
{items.map((b) => { const meta = TYPE_ICON[b.interfaceType] - const Icon = meta.icon + const Icon = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon + const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className return (
- +

- {b.interfaceName} + {b.interfaceType === "wg" && (b.peerName || b.peerPublicKey) + ? `${b.peerName || "peer"} · ${b.interfaceName}` + : b.interfaceName}

{IFACE_TYPE_LABEL[b.interfaceType]} + {b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? ( + весь интерфейс + ) : null} {b.comment ? ( {b.comment} diff --git a/components/users/user-sheet.tsx b/components/users/user-sheet.tsx index d3119d1..12cae0d 100644 --- a/components/users/user-sheet.tsx +++ b/components/users/user-sheet.tsx @@ -19,12 +19,23 @@ import { StepperTrigger, } from "@/components/reui/stepper" import { Badge } from "@/components/reui/badge" +import { IconTile } from "@/components/reui/icon-tile" +import { Frame, FramePanel } from "@/components/reui/frame" +import { + Item, + ItemActions, + ItemContent, + ItemMedia, + ItemTitle, +} from "@/components/ui/item" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" import { cn } from "@/lib/utils" import { listInterfaceCatalog } from "@/shared/api/users" import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail" import { + bindingDiffKey, + bindingTitle, catalogForServer, defaultSections, defaultServers, @@ -43,10 +54,28 @@ import { type UserServerOption, } from "@/lib/users" import { - LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon, - TrashIcon, WrenchIcon, + CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, + NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon, } from "lucide-react" +const IFACE_TILE: Record = { + ether: { icon: CableIcon, className: "text-muted-foreground" }, + gre: { icon: NetworkIcon, className: "text-info" }, + wg: { icon: ShieldIcon, className: "text-success" }, + other: { icon: CableIcon, className: "text-muted-foreground" }, +} + +type CatalogPick = { + interfaceName: string + peerPublicKey: string + peerName?: string + type: InterfaceType +} + +function pickKey(p: CatalogPick): string { + return `${p.interfaceName}\0${p.peerPublicKey}` +} + const SECTION_GROUP_ICONS: Record = { "Обзор": , "Данные": , @@ -124,7 +153,8 @@ function UserSheet({ const [errors, setErrors] = useState>>({}) const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "") const [catalog, setCatalog] = useState([]) - const [selectedNames, setSelectedNames] = useState([]) + const [selectedPicks, setSelectedPicks] = useState([]) + const [expandedWg, setExpandedWg] = useState(null) const [newComment, setNewComment] = useState("") useEffect(() => { @@ -133,7 +163,8 @@ function UserSheet({ setSheetStep(1) setErrors({}) setCatalogServerId(servers[0]?.id ?? "") - setSelectedNames([]) + setSelectedPicks([]) + setExpandedWg(null) setNewComment("") }, [open, user, servers]) @@ -212,29 +243,32 @@ function UserSheet({ const catalogSrv = servers.find((s) => s.id === catalogServerId) const addSelectedBindings = () => { - if (!catalogSrv || selectedNames.length === 0) return - const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`)) + if (!catalogSrv || selectedPicks.length === 0) return + const existing = new Set(form.bindings.map(bindingDiffKey)) const next: InterfaceBinding[] = [...form.bindings] - for (const name of selectedNames) { - const key = `${catalogSrv.id}::${name}` + for (const pick of selectedPicks) { + const key = bindingDiffKey({ + serverId: catalogSrv.id, + interfaceName: pick.interfaceName, + peerPublicKey: pick.peerPublicKey, + }) if (existing.has(key)) continue - const iface = catalog.find((c) => c.name === name) - if (!iface) continue - if (iface.boundUserId && iface.boundUserId !== user?.id) continue next.push({ - id: `pending-${catalogSrv.id}-${name}`, + id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`, userId: user?.id ?? "", serverId: catalogSrv.id, serverName: catalogSrv.name, serverSite: catalogSrv.site, serverCountry: catalogSrv.country, - interfaceName: name, - interfaceType: iface.type, + interfaceName: pick.interfaceName, + interfaceType: pick.type, + peerPublicKey: pick.peerPublicKey || undefined, + peerName: pick.peerName, comment: newComment.trim(), }) } setForm((f) => ({ ...f, bindings: next })) - setSelectedNames([]) + setSelectedPicks([]) setNewComment("") } @@ -242,15 +276,26 @@ function UserSheet({ setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) })) } - const toggleName = (name: string) => { - setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]) + const togglePick = (pick: CatalogPick) => { + setSelectedPicks((prev) => { + const key = pickKey(pick) + return prev.some((p) => pickKey(p) === key) + ? prev.filter((p) => pickKey(p) !== key) + : [...prev, pick] + }) } const alreadyBoundHere = useMemo( - () => new Set(form.bindings.filter((b) => b.serverId === catalogServerId).map((b) => b.interfaceName)), + () => new Set( + form.bindings + .filter((b) => b.serverId === catalogServerId) + .map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`), + ), [form.bindings, catalogServerId], ) + const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks]) + return ( { if (!v) onClose() }}> @@ -411,7 +456,7 @@ function UserSheet({

- Привязка интерфейсов сервера. Один интерфейс — один пользователь. + Ethernet и GRE — целиком. WireGuard — только пир (public-key).

@@ -419,56 +464,178 @@ function UserSheet({ -
- {catalog.length === 0 && ( -

Нет интерфейсов в каталоге

- )} - {catalog.map((iface) => { - const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) - const mine = alreadyBoundHere.has(iface.name) - const disabled = taken || mine - return ( - - ) - })} -
+ + + {catalog.length === 0 && ( +

Нет интерфейсов в каталоге

+ )} +
+ {catalog.map((iface) => { + const tile = IFACE_TILE[iface.type] + const Icon = tile.icon + if (iface.type === "wg") { + const open = expandedWg === iface.name + const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) + const legacyMine = alreadyBoundHere.has(`${iface.name}\0`) + return ( +
+ setExpandedWg(open ? null : iface.name)} />} + className="h-11 min-h-11 flex-nowrap rounded-md py-0" + > + + + + + + + + {iface.name} + WireGuard + + + + {legacyMine ? ( + весь интерфейс + ) : null} + + + + {open ? ( +
+ {iface.peersError ? ( +

Не удалось загрузить пиры

+ ) : null} + {(iface.peers ?? []).length === 0 && !iface.peersError ? ( +

Нет пиров на интерфейсе

+ ) : null} + {(iface.peers ?? []).map((peer) => { + const pick: CatalogPick = { + interfaceName: iface.name, + peerPublicKey: peer.publicKey, + peerName: peer.name, + type: "wg", + } + const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id) + const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`) + const disabled = taken || mine || legacyTaken + const selected = selectedPickKeys.has(pickKey(pick)) + return ( + togglePick(pick)} + /> + } + className={cn( + "h-11 min-h-11 flex-nowrap rounded-md py-0", + selected && "ring-1 ring-border", + disabled && "cursor-not-allowed opacity-50", + )} + > + + + + + + + + {peer.name || peer.publicKey} + {peer.latestHandshake ? ( + handshake + ) : null} + + + + {taken ? ( + {peer.boundUserLogin} + ) : mine ? ( + уже привязан + ) : selected ? ( + Выбран + ) : null} + + + ) + })} +
+ ) : null} +
+ ) + } + + const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type } + const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id) + const mine = alreadyBoundHere.has(`${iface.name}\0`) + const disabled = taken || mine + const selected = selectedPickKeys.has(pickKey(pick)) + return ( + togglePick(pick)} + /> + } + className={cn( + "h-11 min-h-11 flex-nowrap rounded-md py-0", + selected && "ring-1 ring-border", + disabled && "cursor-not-allowed opacity-50", + )} + > + + + + + + + + {iface.running ? : } + {iface.name} + + {IFACE_TYPE_LABEL[iface.type]} + + + + + {taken ? ( + {iface.boundUserLogin} + ) : mine ? ( + уже привязан + ) : selected ? ( + Выбран + ) : null} + + + ) + })} +
+
+ setNewComment(e.target.value)} /> -
@@ -480,8 +647,11 @@ function UserSheet({ {form.bindings.map((b) => (
- {b.interfaceName} + {bindingTitle(b)} {IFACE_TYPE_LABEL[b.interfaceType]} + {b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? ( + весь интерфейс + ) : null} {b.serverName}