diff --git a/dist-selfhosted/config.env b/dist-selfhosted/config.env index 1cd335f4..a6993016 100644 --- a/dist-selfhosted/config.env +++ b/dist-selfhosted/config.env @@ -31,6 +31,8 @@ STUN_SERVERS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302 # from another machine, set these to this machine's address, or empty to listen everywhere. SFU_CONTROL_HOST=127.0.0.1 SFU_METRICS_HOST=127.0.0.1 +# Where the server reads them for owners with view_diagnostics. +SFU_METRICS_URL=http://127.0.0.1:9091/metrics # Turns the server's own metrics off. Their default port is 9091, the same as the SFU's, # and they'd listen on every address. diff --git a/example.env b/example.env index 529a36a8..4fe53db2 100644 --- a/example.env +++ b/example.env @@ -7,6 +7,10 @@ # Your secure SFU host (WebRTC signaling server) SFU_WS_HOST="wss://your-sfu-server.com" +# The SFU's Prometheus metrics, read for owners with view_diagnostics (optional). +# Keep that port off the internet; this server reaches it on the inside. +# SFU_METRICS_URL="http://sfu:9091/metrics" + # Comma separated list of STUN servers for WebRTC STUN_SERVERS="stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302" diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts index 1d49413c..03bcf0b9 100644 --- a/src/constants/permissions.ts +++ b/src/constants/permissions.ts @@ -92,6 +92,8 @@ export const PERMISSIONS = [ "view_audit_log", /** Whether this server is running a current build. */ "view_server_status", + /** Live voice rooms, latency and SFU numbers. What an AI debugging the server reads. */ + "view_diagnostics", ] as const; export type Permission = (typeof PERMISSIONS)[number]; @@ -211,7 +213,8 @@ const ADMIN_PERMISSIONS = EVERY_PERMISSION.filter( p !== "replace_identity" && // Owner-only to begin with: approving a bot grants permissions to // something nobody in the room can vouch for. - p !== "manage_bots", + p !== "manage_bots" && + p !== "view_diagnostics", ) as Permission[]; /** Spaced so a custom role slots between two built-ins without renumbering. */ @@ -270,7 +273,7 @@ export interface PermissionBackfill { } /** Bump this when adding a batch, and give the new entries the new number. */ -export const PERMISSION_SCHEMA_VERSION = 10; +export const PERMISSION_SCHEMA_VERSION = 11; export const PERMISSION_BACKFILLS: readonly PermissionBackfill[] = [ // Had no gate before: anybody admitted to the server could do all four. @@ -313,6 +316,9 @@ export const PERMISSION_BACKFILLS: readonly PermissionBackfill[] = [ // Banners used the avatar-image gate before this was split out. { version: 10, permission: "upload_banner_image", grantedWith: "upload_avatar_image" }, + + // GRYT-2005. Owner-only to begin with, like `manage_bots`. + { version: 11, permission: "view_diagnostics", grantedWith: "manage_server" }, ]; /** Pure: getting this wrong is a silent privilege change in either direction. */ diff --git a/src/diagnostics.test.ts b/src/diagnostics.test.ts new file mode 100644 index 00000000..56f92ec4 --- /dev/null +++ b/src/diagnostics.test.ts @@ -0,0 +1,21 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseSfuMetrics } from "./diagnostics"; + +describe("parseSfuMetrics", () => { + it("keeps gryt_sfu series, sums labelled ones and drops the rest", () => { + const text = [ + "# HELP gryt_sfu_peers_active Total peers", + "# TYPE gryt_sfu_peers_active gauge", + "gryt_sfu_peers_active 3", + 'gryt_sfu_tracks_refused_total{kind="video"} 2', + 'gryt_sfu_tracks_refused_total{kind="audio"} 1', + "go_goroutines 41", + "gryt_sfu_rooms_active NaN", + ].join("\n"); + assert.deepEqual(parseSfuMetrics(text), { + gryt_sfu_peers_active: 3, + gryt_sfu_tracks_refused_total: 3, + }); + }); +}); diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 00000000..f373fc97 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,78 @@ +import type { Clients } from "./types"; +import type { SFUClient } from "./sfu/client"; + +export interface DiagnosticsSnapshot { + serverVersion: string; + uptimeSeconds: number; + online: number; + sfu: ReturnType | null; + /** Null when SFU_METRICS_URL is unset or the SFU did not answer. */ + sfuMetrics: Record | null; + voice: { + channelId: string; + members: { + nickname: string; + muted: boolean; + deafened: boolean; + serverMuted: boolean; + camera: boolean; + screenShares: number; + latency: NonNullable | null; + }[]; + }[]; +} + +/** Only the `gryt_sfu_*` series: the Go runtime ones say nothing about voice. */ +export function parseSfuMetrics(text: string): Record { + const out: Record = {}; + for (const line of text.split("\n")) { + const m = /^(gryt_sfu_[a-z0-9_]+)(?:\{[^}]*\})?\s+(\S+)/.exec(line); + if (!m) continue; + const value = Number(m[2]); + if (Number.isFinite(value)) out[m[1]] = (out[m[1]] ?? 0) + value; + } + return out; +} + +async function readSfuMetrics(url: string | undefined): Promise | null> { + if (!url) return null; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + return res.ok ? parseSfuMetrics(await res.text()) : null; + } catch { + return null; + } +} + +export async function collectDiagnostics( + clientsInfo: Clients, + sfuClient: SFUClient | null, +): Promise { + const rooms = new Map(); + let online = 0; + for (const c of Object.values(clientsInfo)) { + if (!c.serverUserId || c.serverUserId.startsWith("temp_")) continue; + online++; + if (!c.hasJoinedChannel || !c.voiceChannelId) continue; + const members = rooms.get(c.voiceChannelId) ?? []; + members.push({ + nickname: c.nickname, + muted: c.isMuted, + deafened: c.isDeafened, + serverMuted: c.isServerMuted, + camera: c.cameraEnabled, + screenShares: c.screenShares?.length ?? (c.screenShareEnabled ? 1 : 0), + latency: c.latencyStats ?? null, + }); + rooms.set(c.voiceChannelId, members); + } + + return { + serverVersion: process.env.SERVER_VERSION || "0.0.0", + uptimeSeconds: Math.round(process.uptime()), + online, + sfu: sfuClient?.getConnectionStatus() ?? null, + sfuMetrics: await readSfuMetrics(process.env.SFU_METRICS_URL), + voice: [...rooms].map(([channelId, members]) => ({ channelId, members })), + }; +} diff --git a/src/socket/handlers/admin.ts b/src/socket/handlers/admin.ts index ff7ad96d..c2c9bad1 100644 --- a/src/socket/handlers/admin.ts +++ b/src/socket/handlers/admin.ts @@ -56,6 +56,7 @@ import { checkRateLimit, type RateLimitRule } from "../../utils/rateLimiter"; import { evictUser, resolveGrytUserId } from "../../moderation/evict"; import { announceMute, pushSfuAudioState } from "../../moderation/timeout"; import { getVersionStatus } from "../../versionCheck"; +import { collectDiagnostics } from "../../diagnostics"; import { registerAdminChannelHandlers } from "./adminChannels"; import { FALLBACK_ROLE_ID, @@ -1609,6 +1610,16 @@ export function registerAdminHandlers(ctx: HandlerContext): EventHandlerMap { } }, + 'server:diagnostics': async (payload: { accessToken: string }) => { + try { + const auth = await requireAuth(socket, payload, { permission: "view_diagnostics" }); + if (!auth) return; + socket.emit("server:diagnostics", await collectDiagnostics(clientsInfo, sfuClient)); + } catch (e) { + consola.error("server:diagnostics failed", e); + } + }, + // ── Channels & Sidebar (from adminChannels.ts) ────────────── ...registerAdminChannelHandlers(ctx), }; diff --git a/src/socket/handlers/permissionGates.test.ts b/src/socket/handlers/permissionGates.test.ts index c05298a6..a2681d78 100644 --- a/src/socket/handlers/permissionGates.test.ts +++ b/src/socket/handlers/permissionGates.test.ts @@ -202,6 +202,7 @@ const TOKEN_GATES: { { event: "server:user:replace", permission: "replace_identity", payload: { targetServerUserId: "user_x", newGrytUserId: "account-new" } }, { event: "server:audit:list", permission: "view_audit_log" }, { event: "server:version:check", permission: "view_server_status", refusalOnly: true }, + { event: "server:diagnostics", permission: "view_diagnostics" }, { event: "server:member:invite", permission: "create_invite", payload: { targetServerUserId: "user_x" } }, { event: "server:bots:list", permission: "manage_bots" }, { event: "server:bots:decide", permission: "manage_bots", payload: { botId: "BOT_x", decision: "denied" } },