Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dist-selfhosted/config.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
10 changes: 8 additions & 2 deletions src/constants/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down
21 changes: 21 additions & 0 deletions src/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
78 changes: 78 additions & 0 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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<SFUClient["getConnectionStatus"]> | null;
/** Null when SFU_METRICS_URL is unset or the SFU did not answer. */
sfuMetrics: Record<string, number> | null;
voice: {
channelId: string;
members: {
nickname: string;
muted: boolean;
deafened: boolean;
serverMuted: boolean;
camera: boolean;
screenShares: number;
latency: NonNullable<Clients[string]["latencyStats"]> | null;
}[];
}[];
}

/** Only the `gryt_sfu_*` series: the Go runtime ones say nothing about voice. */
export function parseSfuMetrics(text: string): Record<string, number> {
const out: Record<string, number> = {};
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<Record<string, number> | 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<DiagnosticsSnapshot> {
const rooms = new Map<string, DiagnosticsSnapshot["voice"][number]["members"]>();
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 })),
};
}
11 changes: 11 additions & 0 deletions src/socket/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
};
Expand Down
1 change: 1 addition & 0 deletions src/socket/handlers/permissionGates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
Loading