Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3f3cd843 | ||
|
|
168a7a2c36 | ||
|
|
56cbecc3f7 | ||
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 |
@@ -76,7 +76,7 @@ The token is displayed once and stored only as a SHA-256 hash.
|
||||
|
||||
- PostgreSQL and Drizzle ORM
|
||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, internal RCON connection management and command proxying, and automatic Discord nickname synchronization
|
||||
- Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, RCON server-address management and command proxying, and automatic Discord nickname synchronization
|
||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows
|
||||
- Deployment-managed Discord guild ID and invite URL
|
||||
- discord.js bot with `/register` and `/account`
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole } from "@/components/rcon-console";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -46,13 +39,17 @@ export default async function RconPage({
|
||||
const query = await searchParams;
|
||||
const saved = queryValue(query.saved);
|
||||
const error = queryValue(query.error);
|
||||
const notice: RconTerminalNotice | undefined = error
|
||||
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||
: saved
|
||||
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||
: undefined;
|
||||
const servers = await db.select({
|
||||
id: rconServers.id,
|
||||
name: rconServers.name,
|
||||
host: rconServers.host,
|
||||
port: rconServers.port,
|
||||
enabled: rconServers.enabled,
|
||||
updatedAt: rconServers.updatedAt,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
return (
|
||||
@@ -60,88 +57,19 @@ export default async function RconPage({
|
||||
<header className="border-b border-line pb-8">
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Run commands through the portal backend. RCON endpoints remain internal and credentials are never sent to the browser.</p>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
{saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "RCON settings saved."}</p>}
|
||||
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The RCON operation failed."}</p>}
|
||||
|
||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Console</h2>
|
||||
<p className="mt-3 text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
<RconConsole servers={servers.filter((server) => server.enabled).map(({ id, name }) => ({ id, name }))} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Configuration</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Add connection</h2>
|
||||
<form action={createRconServer} className="mt-5 space-y-4 border border-line bg-panel p-6">
|
||||
<ConnectionFields prefix="new" />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" name="enabled" type="checkbox" value="yes" />Enable immediately</label>
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add connection</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Saved endpoints</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Connections</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{servers.length} configured</span>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{servers.map((server) => (
|
||||
<article className="grid gap-5 py-6 lg:grid-cols-[1fr_auto] lg:items-start" key={server.id}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3"><h3 className="font-mono text-lg font-bold">{server.name}</h3><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${server.enabled ? "bg-signal text-ink" : "border border-line text-muted"}`}>{server.enabled ? "Enabled" : "Disabled"}</span></div>
|
||||
<p className="mt-2 font-mono text-[10px] text-muted">{server.host}:{server.port}</p>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">Updated {server.updatedAt.toISOString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<form action={testSavedRconServer}><input name="serverId" type="hidden" value={server.id} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">Test</button></form>
|
||||
<form action={setRconServerEnabled}><input name="serverId" type="hidden" value={server.id} /><input name="enabled" type="hidden" value={server.enabled ? "no" : "yes"} /><button className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" type="submit">{server.enabled ? "Disable" : "Enable"}</button></form>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase underline underline-offset-4">Edit</summary>
|
||||
<form action={updateRconServer} className="relative z-10 mt-3 w-[min(28rem,80vw)] space-y-4 border border-line bg-panel p-5 shadow-[5px_5px_0_var(--color-shadow)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} />
|
||||
<ConnectionFields defaults={server} prefix={server.id} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase"><input className="size-4" defaultChecked={server.enabled} name="enabled" type="checkbox" value="yes" />Enabled</label>
|
||||
<button className="border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase" type="submit">Save connection</button>
|
||||
</form>
|
||||
</details>
|
||||
<details className="relative">
|
||||
<summary className="cursor-pointer list-none font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4">Delete</summary>
|
||||
<form action={deleteRconServer} className="relative z-10 mt-3 w-64 border border-accent bg-panel p-5 shadow-[5px_5px_0_var(--color-accent)] lg:absolute lg:right-0">
|
||||
<input name="serverId" type="hidden" value={server.id} /><input name="confirmation" type="hidden" value={server.id} />
|
||||
<p className="text-xs leading-5">Delete {server.name}? Its encrypted credential will be removed.</p>
|
||||
<button className="mt-4 bg-accent px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm deletion</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!servers.length && <p className="py-8 text-sm text-muted">No RCON connections configured.</p>}
|
||||
<section className="mt-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
</div>
|
||||
<RconConsole notice={notice} servers={servers} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
prefix,
|
||||
defaults,
|
||||
}: {
|
||||
prefix: string;
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Internal hostname<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="season4.somc.svc.cluster.local" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("health endpoint", () => {
|
||||
it("reports process readiness without requiring external services", async () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("reports process readiness and the immutable build version without requiring external services", async () => {
|
||||
vi.stubEnv("APP_VERSION", "1.19.0");
|
||||
|
||||
const response = GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
|
||||
});
|
||||
|
||||
it("reports a development version when no build version is supplied", async () => {
|
||||
vi.stubEnv("APP_VERSION", "");
|
||||
|
||||
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function GET(): Response {
|
||||
return Response.json(
|
||||
{ status: "ok" },
|
||||
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
@@ -1,23 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
|
||||
status: "success" as const,
|
||||
message: `Executed ${String(formData.get("command") ?? "")}`,
|
||||
serverId: String(formData.get("serverId") ?? ""),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
executeRconCommand: vi.fn(),
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: actionMocks.execute,
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const creative = {
|
||||
...server,
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Creative",
|
||||
host: "creative.example.com",
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
it("renders labelled keyboard-operable controls without history", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[{ id: "one", name: "Season 4" }]} />);
|
||||
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("Run command");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
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 ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("explains when no enabled connection is available", () => {
|
||||
expect(renderToStaticMarkup(<RconConsole servers={[]} />)).toContain("Enable an RCON connection");
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
|
||||
it("navigates page-memory command history and restores the unsent draft", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "list" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe("");
|
||||
|
||||
fireEvent.change(input, { target: { value: "say hello" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft command" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("list");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("draft command");
|
||||
});
|
||||
|
||||
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
let listResponses = 0;
|
||||
for (const command of ["list", "say hello", "list"]) {
|
||||
fireEvent.change(input, { target: { value: command } });
|
||||
fireEvent.submit(input.form!);
|
||||
if (command === "list") listResponses += 1;
|
||||
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||
}
|
||||
|
||||
const transcript = screen.getByLabelText("Terminal transcript");
|
||||
const text = transcript.textContent ?? "";
|
||||
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns focus to the command prompt after changing servers", async () => {
|
||||
render(<RconConsole servers={[server, creative]} />);
|
||||
const select = screen.getByLabelText("Server");
|
||||
select.focus();
|
||||
fireEvent.change(select, { target: { value: creative.id } });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
type RconCommandState,
|
||||
updateRconServer,
|
||||
} from "@/app/admin/(console)/rcon/actions";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
const MAX_COMMAND_HISTORY = 50;
|
||||
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||
|
||||
type ServerOption = { id: string; name: string };
|
||||
type TranscriptExchange = {
|
||||
id: number;
|
||||
serverName: string;
|
||||
command: string;
|
||||
status: "pending" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({ servers }: { servers: ServerOption[] }) {
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nextExchangeIdRef = useRef(0);
|
||||
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
|
||||
if (!servers.length) {
|
||||
return <p className="mt-5 text-sm text-muted">Enable an RCON connection before opening the console.</p>;
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const exchangeId = pendingExchangeIdRef.current;
|
||||
if (exchangeId === null || state.status === "idle") return;
|
||||
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||
? { ...exchange, status: resultStatus, message: state.message }
|
||||
: exchange));
|
||||
pendingExchangeIdRef.current = null;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
function navigateHistory(direction: "older" | "newer") {
|
||||
if (!history.length) return;
|
||||
if (direction === "older") {
|
||||
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
if (historyIndex === null) draftRef.current = command;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
return;
|
||||
}
|
||||
if (historyIndex === null) return;
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextIndex = historyIndex + 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
} else {
|
||||
setHistoryIndex(null);
|
||||
setCommand(draftRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSubmittedCommand() {
|
||||
const submitted = command.trim();
|
||||
if (!submitted || !selected) return;
|
||||
const exchangeId = ++nextExchangeIdRef.current;
|
||||
pendingExchangeIdRef.current = exchangeId;
|
||||
const exchange: TranscriptExchange = {
|
||||
id: exchangeId,
|
||||
serverName: selected.name,
|
||||
command: submitted,
|
||||
status: "pending",
|
||||
message: "Command in progress…",
|
||||
};
|
||||
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-6 space-y-4">
|
||||
<label className="block font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
Server
|
||||
<select className="mt-2 block w-full border border-line bg-canvas px-4 py-3 font-sans text-sm font-normal normal-case" defaultValue={state.serverId || servers[0]?.id} id="rcon-console-server" name="serverId" required>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="rcon-command">
|
||||
Command
|
||||
<input autoComplete="off" className="mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent" id="rcon-command" maxLength={1024} name="command" placeholder="list" required spellCheck={false} />
|
||||
</label>
|
||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Running…" : "Run command"}</button>
|
||||
{state.status !== "idle" && (
|
||||
<div aria-live="polite" className={`border-l-2 bg-canvas p-4 ${state.status === "error" ? "border-accent" : "border-signal"}`} role={state.status === "error" ? "alert" : "status"}>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-wider text-muted">Latest response{responseServer ? ` — ${responseServer.name}` : ""}</p>
|
||||
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5">{state.message}</pre>
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<select
|
||||
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||
id="rcon-console-server"
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||
{notice && (
|
||||
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||
<p className="mt-2">{notice.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||
<div className="space-y-6">
|
||||
{transcript.map((exchange) => (
|
||||
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||
<p className="break-words">
|
||||
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||
<span className="text-accent">$</span> {exchange.command}
|
||||
</p>
|
||||
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
|
||||
}
|
||||
}}
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
ref={inputRef}
|
||||
required
|
||||
spellCheck={false}
|
||||
value={command}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||
* [US-020 — Schedule group access in UTC](us-020-scheduled-group-access.md) - Enabled groups may be restricted to recurring weekly UTC windows with static denial-message templates.
|
||||
* [US-021 — Manage RCON server connections](us-021-rcon-connections.md) - Administrators manage encrypted internal Minecraft RCON endpoints.
|
||||
* [US-021 — Manage RCON server connections](us-021-rcon-connections.md) - Administrators manage encrypted Minecraft RCON server addresses.
|
||||
* [US-022 — Operate servers through an RCON console](us-022-rcon-console.md) - Administrators execute bounded commands through the server-side portal proxy.
|
||||
|
||||
# Tracking
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# Design Update Log
|
||||
|
||||
## 2026-08-14
|
||||
|
||||
* **Verify**: Persist complete administrator-attributed RCON commands in correlated requested/completed audit events and add protected history search by command text, server, and administrator without retaining responses or credentials.
|
||||
|
||||
## 2026-08-08
|
||||
|
||||
* **Verify**: Added encrypted, allowlisted administrator RCON connection management with credential-safe audits and a generated Drizzle migration.
|
||||
* **Implement**: Added a bounded server-side RCON command console with safe output and error handling; internal-only deployment verification remains pending.
|
||||
* **Refine**: Removed deployment-managed RCON endpoint allowlisting so administrators may configure any valid DNS hostname and port, while retaining IP-literal rejection and documenting the outbound-connectivity trust boundary.
|
||||
* **Verify**: Confirmed the RCON console uses an authenticated internal ClusterIP deployment with secret-backed credentials and no public RCON exposure.
|
||||
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
|
||||
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
|
||||
* **Extend**: Added bounded page-memory RCON command recall with Arrow Up/Arrow Down navigation, unsent-draft restoration, and prompt focus retention after results and server changes.
|
||||
* **Extend**: Retained up to 50 chronological page-memory RCON command/response exchanges in the auto-scrolling terminal transcript without persisting them.
|
||||
* **Extend**: Added the Docker-build-supplied immutable application version to the dependency-free `/healthz` response, with a `development` fallback.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Preserve a CloudEvents-style audit trail
|
||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
||||
tags: [audit, cloudevents, security, events]
|
||||
timestamp: 2026-08-02T00:12:32Z
|
||||
timestamp: 2026-08-14T01:23:35Z
|
||||
story_id: US-010
|
||||
status: verified
|
||||
---
|
||||
@@ -19,6 +19,9 @@ As an operator, I want security and identity activity recorded consistently, so
|
||||
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, game decisions, and confirmed proxy connections are recorded.
|
||||
- [x] Username changes learned from Velocity create their own event.
|
||||
- [x] Administrative actions include the acting SSO identity in event data.
|
||||
- [x] Every sent RCON command is represented in the audit ledger with its complete command text and acting SSO identity.
|
||||
- [x] RCON responses and credentials are never persisted in audit events.
|
||||
- [x] RCON command events are searchable by command text, server, and administrator.
|
||||
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
|
||||
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
||||
@@ -28,14 +31,18 @@ As an operator, I want security and identity activity recorded consistently, so
|
||||
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
|
||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
|
||||
- [`apps/web/src/lib/rcon-command-history.ts`](../apps/web/src/lib/rcon-command-history.ts)
|
||||
- [`apps/web/src/app/admin/(console)/rcon/actions.ts`](../apps/web/src/app/admin/%28console%29/rcon/actions.ts)
|
||||
- [`apps/web/src/app/admin/(console)/rcon/history/page.tsx`](../apps/web/src/app/admin/%28console%29/rcon/history/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts). RCON action and history tests verify complete command attribution, correlated outcomes, audit-before-send behavior, and response and credential exclusion. All workspace tests, type checks, web lint, OKF validation, Semgrep, dependency audit, and the production build passed on 2026-08-14.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
- [Operate servers through an RCON console](us-022-rcon-console.md)
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Operate settings and audit views
|
||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||
tags: [admin, settings, audit, operations]
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
timestamp: 2026-08-14T01:23:35Z
|
||||
story_id: US-012
|
||||
status: verified
|
||||
---
|
||||
@@ -18,6 +18,7 @@ As an administrator, I want operational settings and audit visibility, so that I
|
||||
- [x] An authorized administrator can update the denied-player registration message.
|
||||
- [x] Settings actions validate message length server-side.
|
||||
- [x] Administrators can browse the latest 100 events.
|
||||
- [x] Administrators can locate RCON command events through command-text, server, and administrator filters.
|
||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
||||
@@ -34,12 +35,14 @@ As an administrator, I want operational settings and audit visibility, so that I
|
||||
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/rcon/history/page.tsx`](../apps/web/src/app/admin/%28console%29/rcon/history/page.tsx)
|
||||
|
||||
# Validation
|
||||
|
||||
Admin routes are dynamic, role-protected, linted, and included in every production build.
|
||||
Admin routes are dynamic, role-protected, linted, and included in every production build. RCON history filter and outcome-pairing tests pass, and the searchable history route was verified in the production route manifest on 2026-08-14.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Administrator SSO](us-011-admin-sso.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
- [Operate servers through an RCON console](us-022-rcon-console.md)
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Deploy and operate the platform securely
|
||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||
tags: [operations, security, database, deployment]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
timestamp: 2026-08-08T15:05:48Z
|
||||
story_id: US-015
|
||||
status: verified
|
||||
---
|
||||
@@ -23,7 +23,7 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
||||
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
|
||||
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
|
||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
||||
- [x] The web runtime provides a dependency-free, uncached health endpoint for orchestration probes that reports readiness and the immutable `APP_VERSION`, falling back to `development` when no build version is supplied.
|
||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||
@@ -36,12 +36,14 @@ As a platform operator, I want reproducible deployment and security controls, so
|
||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
||||
- [`apps/web/src/app/healthz/route.ts`](../apps/web/src/app/healthz/route.ts)
|
||||
- [`Dockerfile`](../Dockerfile)
|
||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
||||
- [`docs/accessibility.md`](../docs/accessibility.md)
|
||||
|
||||
# Validation
|
||||
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts). Health endpoint tests verify the supplied immutable build version, the `development` fallback, readiness status, and uncached response; full workspace tests, type checks, lint, OKF validation, and a production web build passed on 2026-08-08.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage RCON server connections
|
||||
description: Administrators manage encrypted connection settings for internal Minecraft RCON endpoints.
|
||||
description: Administrators manage encrypted connection settings for Minecraft RCON server addresses.
|
||||
tags: [admin, rcon, minecraft, security, operations]
|
||||
timestamp: 2026-08-08T11:44:59Z
|
||||
timestamp: 2026-08-08T13:40:43Z
|
||||
story_id: US-021
|
||||
status: verified
|
||||
---
|
||||
@@ -14,23 +14,23 @@ As an administrator, I want to manage one or more Minecraft RCON connections, so
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Existing account-manager administrators can list, add, edit, test, enable or disable, and delete RCON server connections.
|
||||
- [x] Each connection has a unique display name, internal hostname, port, enabled state, and write-only password.
|
||||
- [x] Existing account-manager administrators use the terminal header to select, add, edit, test, enable or disable, and delete RCON server connections.
|
||||
- [x] Each connection has a unique display name, server address, port, enabled state, and write-only password.
|
||||
- [x] RCON passwords are encrypted with an authenticated cipher using a deployment-managed master key and are never returned to the browser, audit events, or application logs.
|
||||
- [x] Updating a connection preserves its password unless an administrator explicitly supplies a replacement.
|
||||
- [x] Administrators can save any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected.
|
||||
- [x] Testing a connection authenticates through the server-side RCON proxy and reports a safe success or failure result.
|
||||
- [x] Deleting a connection requires explicit confirmation.
|
||||
- [x] Add and edit use accessible modal forms, and deleting a connection requires an explicit danger-confirmation modal.
|
||||
- [x] Connection mutations independently recheck administrator authorization and create credential-safe audit events.
|
||||
- [x] Database changes use a generated versioned Drizzle migration rather than schema push.
|
||||
|
||||
# Implementation
|
||||
|
||||
The administrator RCON page and server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
|
||||
The unified terminal header selects connections and exposes add, test, enable or disable, edit, and delete controls. Add and edit use reusable accessible modal forms, while delete uses a danger-confirmation modal. Server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
|
||||
|
||||
# Validation
|
||||
|
||||
Verified with RCON validation, encryption, gateway, component, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Validation confirms arbitrary valid DNS hostname and port pairs no longer require deployment configuration while IP literals and malformed hostnames remain rejected. Action tests confirm independent authorization, password preservation, enabled-state rechecks, safe failures, and command audit redaction.
|
||||
Verified with RCON validation, encryption, gateway, component, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Validation confirms arbitrary valid internal or external DNS server addresses and ports no longer require deployment configuration while IP literals and malformed hostnames remain rejected. Action tests confirm independent authorization, password preservation, enabled-state rechecks, safe failures, and command audit redaction.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -3,34 +3,49 @@ type: User Story
|
||||
title: Operate servers through an RCON console
|
||||
description: Administrators execute bounded RCON commands through the server-side portal proxy.
|
||||
tags: [admin, rcon, minecraft, console, security]
|
||||
timestamp: 2026-08-08T11:44:59Z
|
||||
timestamp: 2026-08-14T01:23:35Z
|
||||
story_id: US-022
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want an RCON console in the portal, so that I can operate internal Minecraft servers without exposing RCON publicly.
|
||||
As an administrator, I want an RCON console in the portal, so that I can operate configured Minecraft servers without exposing credentials to the browser.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] Existing account-manager administrators can select an enabled connection and execute an RCON command from the admin UI.
|
||||
- [x] Browsers never connect to RCON directly; commands pass through the authenticated Next.js server runtime to an internal endpoint.
|
||||
- [x] Browsers never connect to RCON directly; commands pass through the authenticated Next.js server runtime to the configured endpoint.
|
||||
- [x] Every command independently rechecks administrator authorization and the selected connection's enabled state.
|
||||
- [x] Commands are length-limited, reject control characters, execute with bounded concurrency and a timeout, and return bounded output.
|
||||
- [x] Command responses are displayed safely and are not persisted in console history, audit data, or application logs.
|
||||
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
|
||||
- [x] Command responses are displayed safely, and commands and responses are not persisted in browser storage or application logs.
|
||||
- [x] Every valid command is written to a `games.minecraft.account-manager.rcon.command.requested` audit event before it is sent.
|
||||
- [x] The requested event records the complete command, administrator identity, server ID and name, timestamp, command verb, and command digest.
|
||||
- [x] The corresponding `games.minecraft.account-manager.rcon.command.completed` event records success or failure, a safe failure reason, and duration without storing the RCON response.
|
||||
- [x] A command is not sent if its required requested audit event cannot be recorded.
|
||||
- [x] Administrators can search persistent RCON command history by command text and filter it by server and administrator.
|
||||
- [x] History results show when the command was sent, who sent it, its target server, and its outcome.
|
||||
- [x] Browser page-memory recall remains separate from persistent audit-backed history.
|
||||
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
||||
- [x] The console is keyboard accessible and clearly identifies the selected server.
|
||||
- [x] RCON remains internal to the cluster and is not exposed through public ingress or a load balancer.
|
||||
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable transcript viewport.
|
||||
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
|
||||
- [x] Connection errors and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
|
||||
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
|
||||
- [x] A bounded page-memory-only history lets administrators use Arrow Up and Arrow Down to recall and edit commands, then restore the unsent draft after the newest history entry.
|
||||
- [x] After each command result and server selection change, focus returns to the command input for immediate editing or resubmission.
|
||||
- [x] A bounded chronological transcript retains up to 50 page-memory command/response exchanges, labels each selected server and submitted command, places its safe response or error directly below it, and scrolls to the newest exchange.
|
||||
|
||||
# Implementation
|
||||
|
||||
The client console invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
||||
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays connection-operation notices plus up to 50 chronological command/response exchanges in one auto-scrolling viewport. Each exchange labels its server and complete submitted command, then places the bounded safe response or error directly below it. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. Up to 50 submitted commands remain only in page memory for editable Arrow Up/Arrow Down recall, including restoration of the current unsent draft; focus returns to the prompt after command results and server changes.
|
||||
|
||||
The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. Before gateway execution, the action records the complete command and administrator in a requested event; if that write fails, the command is not sent. A shared correlation ID associates the requested event with its credential- and response-safe completion event. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, and sanitizes and truncates output.
|
||||
|
||||
The protected command-history route reads these audit events, pairs lifecycle outcomes by correlation ID, and searches the latest 100 matching commands by command text, server, and administrator. It remains separate from the console's page-memory recall and links each result to its complete CloudEvent envelope.
|
||||
|
||||
# Validation
|
||||
|
||||
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret, with no public ingress or load balancer exposure.
|
||||
Application behavior is verified with gateway, validation, component, credential, server-action, and command-history tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-14. Action tests confirm audit-before-send behavior, complete command and administrator attribution, lifecycle correlation, safe outcomes, and response and credential exclusion. History tests confirm bounded filters, lifecycle pairing, and pending outcomes; component tests confirm the persistent-history link remains distinct from page-memory command recall and transcript behavior. The SoMC GitOps deployment previously verified Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ User authentication begins with an opaque, short-lived, single-use token created
|
||||
|
||||
### RCON administration
|
||||
|
||||
The administrator console stores one or more RCON endpoints with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Production Minecraft RCON is exposed only through internal cluster services and never through public ingress.
|
||||
The administrator console stores one or more RCON server addresses with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid internal or external DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Operators remain responsible for endpoint exposure and transport security.
|
||||
|
||||
### Discord bot
|
||||
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ Saved passwords are encrypted with AES-256-GCM and connection-bound authenticate
|
||||
|
||||
## Minecraft server configuration
|
||||
|
||||
Enable RCON with a high-entropy password supplied through the deployment secret. Expose its port only on an internal `ClusterIP` service. Do not add RCON to an Ingress, NodePort, or public LoadBalancer.
|
||||
Enable RCON with a high-entropy password supplied through the deployment secret. Server addresses may resolve internally or externally. Prefer private networking, a VPN, or an encrypted tunnel; do not expose plaintext RCON directly to the public internet.
|
||||
|
||||
The password entered in the administrator connection form must match the server password. Existing passwords are write-only; leave the replacement field blank when editing unrelated connection settings.
|
||||
|
||||
@@ -23,10 +23,10 @@ The password entered in the administrator connection form must match the server
|
||||
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
|
||||
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
|
||||
- Responses are sanitized and limited to 64 KiB.
|
||||
- Full commands and responses are not persisted or logged. Audit events contain the command verb and a domain-separated HMAC digest.
|
||||
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
|
||||
- Connection passwords are never selected by page queries or returned to the browser.
|
||||
|
||||
RCON is plaintext TCP. Keep it on the cluster network and use network policy or an encrypted tunnel when the network trust model requires stronger isolation.
|
||||
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
|
||||
|
||||
## Migration
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ docker run --rm -p 3000:3000 \
|
||||
|
||||
Provide all deployment settings described by [`.env.example`](../.env.example). Only set `TRUST_PROXY=true` behind a proxy that overwrites forwarding headers.
|
||||
|
||||
The release workflow passes the semantic version through Docker's `VERSION` build argument, and the final image embeds it as `APP_VERSION`. `GET /healthz` reports this immutable image version with readiness, for example `{"status":"ok","version":"1.19.0"}`. Manual builds that omit the build argument report `development`.
|
||||
|
||||
## Velocity JAR
|
||||
|
||||
Download the JAR from the matching public Gitea release, copy it to Velocity's `plugins/` directory, and retain the existing `plugins/minecraft-account-manager/config.properties` during upgrades.
|
||||
|
||||
Reference in New Issue
Block a user