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}