feat(rcon): add audited command history
This commit is contained in:
@@ -6,7 +6,8 @@ const actionState = vi.hoisted(() => ({
|
||||
transactionSelected: [] as unknown[],
|
||||
updates: [] as Record<string, unknown>[],
|
||||
inserts: [] as unknown[],
|
||||
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown> }>,
|
||||
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
|
||||
auditFailure: false,
|
||||
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
|
||||
gatewayResult: { ok: true, response: "private response" } as
|
||||
| { ok: true; response: string }
|
||||
@@ -84,8 +85,11 @@ vi.mock("@/lib/audit", () => ({
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { correlationId?: string },
|
||||
) => {
|
||||
actionState.audits.push({ admin, subject, type, data });
|
||||
if (actionState.auditFailure) throw new Error("audit unavailable");
|
||||
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
|
||||
return "22222222-2222-4222-8222-222222222222";
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -118,6 +122,7 @@ describe("RCON server actions", () => {
|
||||
actionState.updates = [];
|
||||
actionState.inserts = [];
|
||||
actionState.audits = [];
|
||||
actionState.auditFailure = false;
|
||||
actionState.executions = [];
|
||||
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||
});
|
||||
@@ -154,7 +159,7 @@ describe("RCON server actions", () => {
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("rechecks enabled saved state and records credential-safe command lifecycle audits", async () => {
|
||||
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
@@ -176,16 +181,19 @@ describe("RCON server actions", () => {
|
||||
admin: { email: "admin@example.test", name: "Admin" },
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.requested",
|
||||
data: expect.objectContaining({ verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
]);
|
||||
const serializedAudits = JSON.stringify(actionState.audits);
|
||||
expect(serializedAudits).not.toContain("private value");
|
||||
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
|
||||
expect(serializedAudits).toContain("private value");
|
||||
expect(serializedAudits).not.toContain("private response");
|
||||
expect(serializedAudits).not.toContain("decrypted-password");
|
||||
});
|
||||
@@ -209,6 +217,21 @@ describe("RCON server actions", () => {
|
||||
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("does not send a command when its requested audit cannot be recorded", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.auditFailure = true;
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Command not sent because its audit record could not be created.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not execute or audit when the enabled connection is unavailable", async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
|
||||
@@ -227,13 +227,19 @@ export async function executeRconCommand(
|
||||
return { status: "error", message: "RCON command auditing is not configured.", serverId };
|
||||
}
|
||||
const started = Date.now();
|
||||
const correlationId = randomUUID();
|
||||
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
verb,
|
||||
commandDigest,
|
||||
});
|
||||
try {
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
command,
|
||||
verb,
|
||||
commandDigest,
|
||||
}, { correlationId });
|
||||
} catch {
|
||||
return { status: "error", message: "Command not sent because its audit record could not be created.", serverId };
|
||||
}
|
||||
const result = await executeRcon(server, command);
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
|
||||
serverId: server.id,
|
||||
@@ -243,7 +249,7 @@ export async function executeRconCommand(
|
||||
success: result.ok,
|
||||
reason: result.ok ? null : result.reason,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
}, { correlationId });
|
||||
if (!result.ok) {
|
||||
const message = result.reason === "busy"
|
||||
? "Another command is already running for this server."
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
buildRconCommandHistory,
|
||||
normalizeRconHistoryFilters,
|
||||
RCON_COMMAND_COMPLETED,
|
||||
RCON_COMMAND_REQUESTED,
|
||||
} from "@/lib/rcon-command-history";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestedFields = {
|
||||
id: events.id,
|
||||
time: events.time,
|
||||
correlationId: events.correlationId,
|
||||
data: events.data,
|
||||
};
|
||||
|
||||
export default async function RconHistoryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const [query, servers] = await Promise.all([
|
||||
searchParams,
|
||||
db.select({ id: rconServers.id, name: rconServers.name }).from(rconServers).orderBy(rconServers.name),
|
||||
]);
|
||||
const filters = normalizeRconHistoryFilters(query, servers.map((server) => server.id));
|
||||
const conditions: SQL[] = [eq(events.type, RCON_COMMAND_REQUESTED)];
|
||||
if (filters.serverId) conditions.push(eq(events.subject, `rcon-server/${filters.serverId}`));
|
||||
if (filters.command) conditions.push(ilike(sql<string>`${events.data} ->> 'command'`, `%${filters.command}%`));
|
||||
if (filters.admin) {
|
||||
conditions.push(or(
|
||||
ilike(sql<string>`${events.data} ->> 'adminEmail'`, `%${filters.admin}%`),
|
||||
ilike(sql<string>`${events.data} ->> 'adminName'`, `%${filters.admin}%`),
|
||||
)!);
|
||||
}
|
||||
|
||||
const requested = await db.select(requestedFields)
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.time))
|
||||
.limit(100);
|
||||
const correlationIds = requested.flatMap((event) => event.correlationId ? [event.correlationId] : []);
|
||||
const completed = correlationIds.length
|
||||
? await db.select(requestedFields).from(events).where(and(
|
||||
eq(events.type, RCON_COMMAND_COMPLETED),
|
||||
inArray(events.correlationId, correlationIds),
|
||||
))
|
||||
: [];
|
||||
const history = buildRconCommandHistory(requested, completed);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-14">
|
||||
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/rcon">← RCON console</Link>
|
||||
<header className="mt-7 grid gap-5 border-b-2 border-ink pb-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Persistent audit ledger</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Command history</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Search commands sent through the portal. Responses and RCON credentials are never retained here.</p>
|
||||
</div>
|
||||
<div className="border border-line bg-panel px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||
<span className="text-accent">{history.length}</span> matching records
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="mt-8 border border-line bg-panel p-5 shadow-[6px_6px_0_var(--color-shadow)]" method="get">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<Filter label="Command text" name="command" placeholder="say, whitelist add…" value={filters.command} />
|
||||
<Filter label="Administrator" name="admin" placeholder="name or email" value={filters.admin} />
|
||||
<label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="history-server">
|
||||
Server
|
||||
<select className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none focus:border-accent" defaultValue={filters.serverId} id="history-server" name="server">
|
||||
<option value="">All servers</option>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-4">
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Search history</button>
|
||||
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/rcon/history">Clear filters</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 overflow-x-auto border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[980px] border-collapse text-left">
|
||||
<caption className="sr-only">RCON command audit history</caption>
|
||||
<thead className="border-b-2 border-ink bg-canvas font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Server</th><th className="p-4" scope="col">Administrator</th><th className="p-4" scope="col">Command</th><th className="p-4" scope="col">Outcome</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{history.map((entry) => (
|
||||
<tr className="align-top hover:bg-canvas/60" key={entry.eventId}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${entry.eventId}`}><time dateTime={entry.time.toISOString()}>{entry.time.toISOString()}</time></Link></td>
|
||||
<td className="p-4"><span className="font-mono font-bold">{entry.serverName}</span><span className="mt-1 block font-mono text-[9px] text-muted">{entry.serverId}</span></td>
|
||||
<td className="p-4"><span className="font-bold">{entry.adminName ?? "Unknown administrator"}</span><span className="mt-1 block font-mono text-[10px] text-muted">{entry.adminEmail ?? "Email unavailable"}</span></td>
|
||||
<td className="max-w-xl p-4"><code className="whitespace-pre-wrap break-words font-mono text-xs"><span className="mr-2 text-accent">$</span>{entry.command}</code></td>
|
||||
<td className="p-4"><Outcome status={entry.status} />{entry.reason && <span className="mt-2 block font-mono text-[9px] text-muted">{entry.reason}</span>}{entry.durationMs !== null && <span className="mt-1 block font-mono text-[9px] text-muted">{entry.durationMs} ms</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!history.length && <tr><td className="p-10 text-center text-muted" colSpan={5}>No RCON commands match these filters.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ label, name, placeholder, value }: { label: string; name: string; placeholder: string; value: string }) {
|
||||
return <label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor={`history-${name}`}>{label}<input className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none placeholder:text-muted focus:border-accent" defaultValue={value} id={`history-${name}`} maxLength={name === "command" ? 1024 : 320} name={name} placeholder={placeholder} /></label>;
|
||||
}
|
||||
|
||||
function Outcome({ status }: { status: "pending" | "succeeded" | "failed" }) {
|
||||
const className = status === "succeeded" ? "border-signal text-ink" : status === "failed" ? "border-accent text-accent" : "border-line text-muted";
|
||||
return <span className={`inline-block border-l-2 pl-2 font-mono text-[9px] font-bold uppercase tracking-wider ${className}`}>{status}</span>;
|
||||
}
|
||||
@@ -56,6 +56,8 @@ describe("RconConsole", () => {
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain('href="/admin/rcon/history"');
|
||||
expect(markup).toContain("Command history");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
@@ -145,6 +146,7 @@ export function RconConsole({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function recordAdminSubjectEvent(
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: { correlationId?: string } = {},
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
@@ -18,6 +19,7 @@ export async function recordAdminSubjectEvent(
|
||||
source: "/web/admin",
|
||||
subject,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
correlationId: options.correlationId,
|
||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
|
||||
|
||||
const correlationId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
time: new Date("2026-08-14T01:00:00Z"),
|
||||
correlationId,
|
||||
data: {
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
name: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON command history", () => {
|
||||
it("normalizes bounded search filters and accepts only known servers", () => {
|
||||
expect(normalizeRconHistoryFilters({
|
||||
command: [" say hello ", "ignored"],
|
||||
admin: " admin@example.test ",
|
||||
server: "33333333-3333-4333-8333-333333333333",
|
||||
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
|
||||
command: "say hello",
|
||||
admin: "admin@example.test",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
|
||||
command: "",
|
||||
admin: "",
|
||||
serverId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs requested commands with their completion outcome without exposing responses", () => {
|
||||
const requested = event();
|
||||
const completed = event({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
data: { success: false, reason: "timeout", durationMs: 5001 },
|
||||
});
|
||||
|
||||
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
|
||||
eventId: requested.id,
|
||||
time: requested.time,
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
serverName: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
status: "failed",
|
||||
reason: "timeout",
|
||||
durationMs: 5001,
|
||||
}]);
|
||||
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
|
||||
});
|
||||
|
||||
it("marks a requested command pending when no completion event exists", () => {
|
||||
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
|
||||
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
|
||||
|
||||
export type RconHistoryEvent = {
|
||||
id: string;
|
||||
time: Date;
|
||||
correlationId: string | null;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RconCommandHistoryRow = {
|
||||
eventId: string;
|
||||
time: Date;
|
||||
command: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
adminEmail: string | null;
|
||||
adminName: string | null;
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
reason: string | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
function first(value: string | string[] | undefined) {
|
||||
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||
}
|
||||
|
||||
function text(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key];
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
|
||||
const requestedServerId = first(query.server);
|
||||
return {
|
||||
command: first(query.command).slice(0, 1024),
|
||||
admin: first(query.admin).slice(0, 320),
|
||||
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRconCommandHistory(
|
||||
requestedEvents: RconHistoryEvent[],
|
||||
completedEvents: RconHistoryEvent[],
|
||||
): RconCommandHistoryRow[] {
|
||||
const completions = new Map(completedEvents
|
||||
.filter((event) => event.correlationId)
|
||||
.map((event) => [event.correlationId, event]));
|
||||
|
||||
return requestedEvents.flatMap((event) => {
|
||||
const command = text(event.data, "command");
|
||||
const serverId = text(event.data, "serverId");
|
||||
const serverName = text(event.data, "name");
|
||||
if (!command || !serverId || !serverName) return [];
|
||||
|
||||
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
|
||||
const success = completed?.data.success;
|
||||
const duration = completed?.data.durationMs;
|
||||
return [{
|
||||
eventId: event.id,
|
||||
time: event.time,
|
||||
command,
|
||||
serverId,
|
||||
serverName,
|
||||
adminEmail: text(event.data, "adminEmail"),
|
||||
adminName: text(event.data, "adminName"),
|
||||
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
|
||||
reason: completed ? text(completed.data, "reason") : null,
|
||||
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user