feat(rcon): discover commands and autocomplete server help
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 11m23s

This commit is contained in:
dmg
2026-09-14 15:52:56 -04:00
parent eb8ef18688
commit 999c27c7f9
7 changed files with 690 additions and 12 deletions
@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RconConsole } from "@/components/rcon-console";
const actionState = vi.hoisted(() => ({
authorized: 0,
authFailure: false,
helpResponses: {} as Record<string, string>,
requestedBeforeSend: [] as boolean[],
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
return { email: "admin@example.test", name: "Admin" };
},
}));
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
@@ -115,8 +125,12 @@ const savedServer = {
};
describe("RCON server actions", () => {
afterEach(() => cleanup());
beforeEach(() => {
actionState.authorized = 0;
actionState.authFailure = false;
actionState.helpResponses = {};
actionState.requestedBeforeSend = [];
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
actionState.selected = [savedServer];
actionState.helpResponses = {
help: "Help: Index (1/2)\n/leaf: Private protection description",
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
"help tyrant": "Usage: /tyrant <menu|status>",
};
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
expect(actionState.authorized).toBe(3);
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
expect(actionState.audits).toHaveLength(6);
for (let index = 0; index < 6; index += 2) {
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
}
const audit = JSON.stringify(actionState.audits);
expect(audit).not.toContain("Private protection description");
expect(audit).not.toContain("/tyrant <menu|status>");
expect(audit).not.toContain("decrypted-password");
});
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
actionState.authFailure = failure === "authorization";
actionState.auditFailure = failure === "audit";
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([]);
expect(document.body.textContent).not.toContain("REDIRECT:");
});
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.executions).toHaveLength(1);
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
+186 -2
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
@@ -41,6 +41,190 @@ const creative = {
};
describe("RconConsole", () => {
beforeEach(() => {
actionMocks.execute.mockClear();
actionMocks.execute.mockImplementation(async (_previous, formData) => {
const command = String(formData.get("command") ?? "");
const serverId = String(formData.get("serverId") ?? "");
const message = command === "help"
? "§eHelp: Index (1/2)\n§6Leaf: §fAll commands for Leaf\n§6/leaf: §fProtection"
: command === "help 2" ? "Help: Index (2/2)\n/tyrant: Tyrant features"
: command === "help tyrant" ? "Usage: /tyrant <menu|status|armor <helmet|boots>>"
: `Executed ${command}`;
return { status: "success", message, serverId };
});
});
it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.getByRole("option", { name: /tyrant Tyrant features/ })).toBeTruthy();
expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false);
expect(input.value).toBe("tyrant ");
expect(screen.queryByRole("listbox")).toBeNull();
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2"]);
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyrant ");
expect(screen.getByLabelText("Terminal transcript").textContent).not.toContain("$ help");
});
it("fetches and caches usage on demand and offers contextual literal arguments", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(screen.getByRole("option", { name: "menu From server help" })).toBeTruthy();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(input.value).toBe("tyrant status ");
fireEvent.change(input, { target: { value: "tyrant armor " } });
fireEvent.click(screen.getByRole("option", { name: "helmet From server help" }));
expect(input.value).toBe("tyrant armor helmet ");
expect(document.activeElement).toBe(input);
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant"]);
});
it("reports incomplete help safely and leaves unknown manual commands usable", async () => {
actionMocks.execute.mockRejectedValueOnce(new Error("private transport detail"));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(document.body.textContent).not.toContain("private transport detail");
const input = screen.getByLabelText("Command") as HTMLInputElement;
expect(input.disabled).toBe(false);
fireEvent.change(input, { target: { value: "custom-plugin arbitrary value" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed custom-plugin arbitrary value");
});
it("isolates caches by server, invalidates edited endpoints, and discards help on remount", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
let input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryByLabelText("Command usage")).toBeNull();
fireEvent.change(screen.getByLabelText("Server"), { target: { value: server.id } });
input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
expect(screen.getByLabelText("Command usage")).toBeTruthy();
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
view.rerender(<RconConsole servers={[{ ...server, host: "replacement.example.com" }, creative]} />);
expect(screen.queryByLabelText("Command usage")).toBeNull();
expect(screen.queryByRole("listbox")).toBeNull();
view.unmount();
render(<RconConsole servers={[server]} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
it("dismisses suggestions for history recall, restores drafts, and submits only intentionally", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed list");
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(input, { target: { value: "tyr" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyr");
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("tyr");
fireEvent.submit(input.form!);
await screen.findByText("Executed tyr");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["list", "help", "help 2", "tyr"]);
});
it("does not offer end-of-command replacement while editing in the middle", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
input.setSelectionRange(1, 1);
fireEvent.select(input);
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
expect(input.value).toBe("tyr");
});
it("stops pagination on server change and never publishes a late result to the new selection", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/secretcmd: Private", serverId: server.id }));
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "secret" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.getByText("Refresh commands to discover server help")).toBeTruthy();
});
it("lets manual execution interrupt discovery and waits for the in-flight help request", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/leaf: Protection", serverId: server.id }));
await screen.findByText("Executed list");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "list"]);
expect(input.disabled).toBe(false);
});
it("caches safe usage failure without retries until refresh and keeps manual entry available", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
actionMocks.execute.mockRejectedValueOnce(new Error("private usage detail"));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByText("Usage unavailable. Refresh commands to retry.");
fireEvent.change(input, { target: { value: "tyrant armor " } });
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
expect(document.body.textContent).not.toContain("private usage detail");
expect(input.disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("/tyrant <menu|status|armor <helmet|boots>>");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant", "help", "help 2", "help tyrant"]);
});
it("renders server help as inert text and never refreshes missing or disabled connections", async () => {
const view = render(<RconConsole servers={[]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<RconConsole servers={[{ ...server, enabled: false }]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
expect(actionMocks.execute).not.toHaveBeenCalled();
view.rerender(<RconConsole servers={[server]} />);
actionMocks.execute.mockResolvedValueOnce({ status: "success", message: 'Help: Index (1/1)\n/leaf: <img src=x onerror="alert(1)">', serverId: server.id });
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("1 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "lea" } });
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeTruthy();
expect(document.querySelector("img")).toBeNull();
});
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"');
+80 -7
View File
@@ -1,19 +1,18 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { 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";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -46,8 +45,9 @@ export function RconConsole({
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
@@ -57,11 +57,27 @@ export function RconConsole({
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
@@ -101,6 +117,8 @@ export function RconConsole({
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
@@ -133,7 +151,11 @@ export function RconConsole({
<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)}
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
@@ -200,11 +222,39 @@ export function RconConsole({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint"> Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</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
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
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"
@@ -213,8 +263,31 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
@@ -0,0 +1,97 @@
"use client";
import { useActionState, useCallback, useEffect, useRef, useState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
import { discoverRconCommands, parseRconHelp, type RconHelpCatalog } from "@/lib/rcon-help";
import type { RconServerOption } from "./rcon-console";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type UsageResult = { text: string | null; failed: boolean };
type HelpCache = RconHelpCatalog & { usage: Map<string, UsageResult> };
export function useRconSession(server: RconServerOption | undefined, command: string) {
// Endpoint edits/enable changes invalidate the old cache, not just selection changes.
const key = server ? JSON.stringify([server.id, server.host, server.port, server.enabled]) : "";
const [cache, setCache] = useState(new Map<string, HelpCache>());
const [loading, setLoading] = useState(false);
const operationRef = useRef<AbortController | null>(null);
const flightRef = useRef<Promise<string> | null>(null);
function stopHelp() { operationRef.current?.abort(); }
useEffect(() => () => { operationRef.current?.abort(); }, [key]);
const [state, action, pending] = useActionState(async (previous: RconCommandState, formData: FormData) => {
// Do not race the page's help lookup against a manual command for the gateway lock.
stopHelp();
await flightRef.current?.catch(() => undefined);
return executeRconCommand(previous, formData);
}, initialState);
const serverId = server?.id ?? "";
const requestHelp = useCallback((helpCommand: string): Promise<string> => {
const data = new FormData();
data.set("serverId", serverId);
data.set("command", helpCommand);
const flight = executeRconCommand(initialState, data).then((result) => {
if (result.status !== "success") throw new Error("Help request unavailable");
return result.message;
});
flightRef.current = flight;
return flight;
}, [serverId]);
async function refresh() {
if (!server?.enabled || pending || operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
try {
const result = await discoverRconCommands(requestHelp, controller.signal);
if (!controller.signal.aborted) {
setCache((current) => new Map(current).set(key, { ...result, usage: new Map() }));
}
} finally {
operationRef.current = null;
setLoading(false);
}
}
const catalog = cache.get(key);
const typedVerb = command.match(/^\/?([a-z0-9_.:-]+)\s/i)?.[1];
const verb = catalog?.commands.find((entry) => entry.name.toLowerCase() === typedVerb?.toLowerCase())?.name;
const usage = verb ? catalog?.usage.get(verb) : undefined;
const enabled = server?.enabled ?? false;
useEffect(() => {
if (!enabled || !verb || usage || pending || loading) return;
const timer = setTimeout(async () => {
if (operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
let result: UsageResult;
try {
result = { text: parseRconHelp(await requestHelp(`help ${verb}`)).usage, failed: false };
} catch {
result = { text: null, failed: true };
}
if (!controller.signal.aborted) {
setCache((current) => {
const entry = current.get(key);
if (!entry) return current;
return new Map(current).set(key, { ...entry, usage: new Map(entry.usage).set(verb, result) });
});
}
operationRef.current = null;
setLoading(false);
}, 350);
return () => clearTimeout(timer);
}, [enabled, key, verb, usage, pending, loading, requestHelp]);
const message = catalog
? catalog.incomplete
? `${catalog.commands.length} commands discovered · incomplete; refresh to retry`
: `${catalog.commands.length} commands discovered · best-effort server help`
: "Refresh commands to discover server help";
return { state, action, pending, catalog, usage, loading, refresh, stopHelp, message };
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { discoverRconCommands, MAX_HELP_COMMANDS, MAX_HELP_PAGES, parseRconHelp, rconSuggestions } from "./rcon-help";
describe("RCON help discovery", () => {
it("walks help pages sequentially and deduplicates commands", async () => {
const requests: string[] = [];
const result = await discoverRconCommands(async (command) => {
requests.push(command);
return command === "help"
? "Help: Index (1/2)\nLeaf: All commands for Leaf\n/leaf: Protection"
: "Help: Index (2/2)\n/leaf: Protection\n/tyrant: Tyrant features";
});
expect(requests).toEqual(["help", "help 2"]);
expect(result).toEqual({ commands: [
{ name: "leaf", description: "Protection" },
{ name: "tyrant", description: "Tyrant features" },
], incomplete: false });
});
it.each([
"Unrecognized help format",
"Help: Index (0/2)",
"Help: Index (1/0)",
"Help: Index (1/9007199254740992)",
])("stops on malformed pagination: %s", async (output) => {
let calls = 0;
const result = await discoverRconCommands(async () => { calls++; return output; });
expect(calls).toBe(1);
expect(result.incomplete).toBe(true);
});
it("stops on repeated pages, transport failure, or cancellation without losing earlier commands", async () => {
const first = "Help: Index (1/3)\n/leaf: Protection";
let calls = 0;
const repeated = await discoverRconCommands(async () => { calls++; return first; });
expect(calls).toBe(2);
expect(repeated).toEqual({ commands: [{ name: "leaf", description: "Protection" }], incomplete: true });
calls = 0;
const failed = await discoverRconCommands(async () => {
if (calls++) throw new Error("private transport detail");
return first;
});
expect(failed).toEqual(repeated);
const controller = new AbortController();
const cancelled = await discoverRconCommands(async () => { controller.abort(); return first; }, controller.signal);
expect(cancelled).toEqual({ commands: [], incomplete: true });
});
it("bounds cached command count independently of pagination", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => {
calls++;
return `Help: Index (${calls}/64)\n` + Array.from({ length: 100 }, (_, index) => `/c${calls}_${index}: A command`).join("\n");
});
expect(result.commands).toHaveLength(MAX_HELP_COMMANDS);
expect(result.incomplete).toBe(true);
expect(calls).toBeLessThan(MAX_HELP_PAGES);
});
it("caps excessive pagination and reports partial results", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => `Help: Index (${++calls}/99999)\n/c${calls}: A command`);
expect(calls).toBe(MAX_HELP_PAGES);
expect(result.commands).toHaveLength(MAX_HELP_PAGES);
expect(result.incomplete).toBe(true);
});
});
describe("RCON suggestions and usage", () => {
it("filters discovered names without requiring a slash and preserves a typed slash", () => {
const commands = [{ name: "tyrant", description: "Tyrant features" }, { name: "leaf", description: "Protection" }];
expect(rconSuggestions("tyr", commands)).toEqual([{ value: "tyrant ", label: "tyrant", description: "Tyrant features" }]);
expect(rconSuggestions("/tyr", commands)[0]?.value).toBe("/tyrant ");
expect(rconSuggestions("unknown", commands)).toEqual([]);
expect(rconSuggestions("", commands)).toEqual([]);
});
it("reads wrapped Tyrant usage and offers only context-correct literal branches", () => {
const { usage } = parseRconHelp(`§e--------- §fHelp: §r/tyrant §e-------------------------------
§6Description: §fView and use Spigot Tyrant game features.
§f§6Usage: §f/tyrant <menu|status|choices|buy|armor
§f<helmet|chestplate|leggings|boots>|gear
§f<axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence
§f|optout|optin|relinquish confirm>`);
expect(usage).toContain("/tyrant <menu|status");
expect(usage).not.toContain("§");
const commands = [{ name: "tyrant", description: "Tyrant features" }];
expect(rconSuggestions("tyrant ", commands, usage).map((item) => item.label)).toEqual([
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish",
]);
expect(rconSuggestions("tyrant armor he", commands, usage).map((item) => item.value)).toEqual(["tyrant armor helmet "]);
expect(rconSuggestions("tyrant relinquish ", commands, usage).map((item) => item.label)).toEqual(["confirm"]);
});
it.each([
"/leaf <player>", "/leaf [player]", "/leaf <a|b", "/other <a|b>",
"/leaf <a||b>", "/leaf <a|b>>", "/leaf <a|b> ".repeat(300),
"/leaf " + "<a|b> ".repeat(10),
])("does not invent literals for opaque or malformed syntax: %s", (usage) => {
expect(rconSuggestions("leaf ", [{ name: "leaf", description: "Protection" }], usage)).toEqual([]);
});
});
describe("RCON help parsing", () => {
it("discovers slash commands, not help categories, from formatted paginated output", () => {
expect(parseRconHelp(`§e--------- §fHelp: §rIndex (1/31) §e--------------------------
§7Use /help [n] to get page n of help.
§7§6Aliases: §fLists command aliases
§f§6Leaf: §fAll commands for Leaf
§f§6/leaf: §fControl or administer Leaf protection.
§f§6/minecraft:help: §fProvides help
§f§6/not a command: §fIgnore this
`)).toEqual({
commands: [
{ name: "leaf", description: "Control or administer Leaf protection." },
{ name: "minecraft:help", description: "Provides help" },
],
page: 1, pages: 31, usage: null,
});
});
});
+127
View File
@@ -0,0 +1,127 @@
export type RconHelpCommand = { name: string; description: string };
export type RconHelpPage = {
commands: RconHelpCommand[];
page: number | null;
pages: number | null;
usage: string | null;
};
export type RconHelpCatalog = { commands: RconHelpCommand[]; incomplete: boolean };
export const MAX_HELP_PAGES = 64;
export const MAX_HELP_COMMANDS = 2_048;
export async function discoverRconCommands(
request: (command: string) => Promise<string>,
signal?: AbortSignal,
): Promise<RconHelpCatalog> {
const commands = new Map<string, RconHelpCommand>();
let total: number | null = null;
for (let page = 1; page <= MAX_HELP_PAGES; page += 1) {
if (signal?.aborted) break;
let parsed: RconHelpPage;
try {
parsed = parseRconHelp(await request(page === 1 ? "help" : `help ${page}`));
} catch {
break;
}
if (signal?.aborted) break;
for (const command of parsed.commands) {
if (commands.size >= MAX_HELP_COMMANDS) return { commands: [...commands.values()], incomplete: true };
commands.set(command.name.toLowerCase(), command);
}
if (total === null) total = parsed.pages;
if (parsed.page !== page || parsed.pages !== total || !Number.isSafeInteger(total) || total! < page) break;
if (page === total) return { commands: [...commands.values()], incomplete: false };
}
return { commands: [...commands.values()], incomplete: true };
}
export type RconSuggestion = { value: string; label: string; description: string };
// Only expand a small, balanced literal/alternative grammar. Single angle-bracket
// values are placeholders, not literals; unsupported syntax remains a usage hint.
function usagePaths(pattern: string): Array<Array<string | null>> {
if (pattern.length > 4_096 || /[^a-z0-9_.:<>|\s-]/i.test(pattern)) return [];
const tokens = pattern.match(/[a-z0-9_.:-]+|[<>|]/gi) ?? [];
if (tokens.length > 256) return [];
let cursor = 0;
function expression(depth: number): Array<Array<string | null>> {
if (depth > 8) throw new Error("Complex usage");
const alternatives: Array<Array<string | null>> = [];
let sequence: Array<Array<string | null>> = [[]];
let hasAlternatives = false;
while (cursor < tokens.length && tokens[cursor] !== ">") {
const token = tokens[cursor++]!;
if (token === "|") {
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
sequence = [[]];
hasAlternatives = true;
continue;
}
const values = token === "<" ? expression(depth + 1) : [[token]];
if (token === "<" && tokens[cursor++] !== ">") throw new Error("Unbalanced usage");
if (sequence.length * values.length + alternatives.length > 256) throw new Error("Complex usage");
sequence = sequence.flatMap((prefix) => values.map((suffix) => [...prefix, ...suffix]));
}
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
return depth > 0 && !hasAlternatives ? [[null]] : alternatives;
}
try {
const paths = expression(0);
return cursor === tokens.length ? paths : [];
} catch {
return [];
}
}
export function rconSuggestions(input: string, commands: RconHelpCommand[], usage?: string | null): RconSuggestion[] {
if (!input || input.length > 1_024) return [];
const slash = input.startsWith("/") ? "/" : "";
const parts = input.replace(/^\//, "").split(/\s+/);
const verb = parts[0]!;
if (parts.length === 1) {
return commands.filter((command) => command.name.toLowerCase().startsWith(verb.toLowerCase()))
.slice(0, 20).map((command) => ({ value: `${slash}${command.name} `, label: command.name, description: command.description }));
}
if (!commands.some((command) => command.name.toLowerCase() === verb.toLowerCase()) || !usage) return [];
const match = usage.match(/^\/?([a-z0-9_.:-]+)\s+([\s\S]+)$/i);
if (!match || match[1]!.toLowerCase() !== verb.toLowerCase()) return [];
const entered = parts.slice(1, -1);
const prefix = parts.at(-1)!;
const literals = new Set<string>();
for (const path of usagePaths(match[2]!)) {
if (!entered.every((value, index) => path[index] === value)) continue;
const next = path[entered.length];
if (next && next.startsWith(prefix)) literals.add(next);
}
const base = input.slice(0, input.length - prefix.length);
return [...literals].slice(0, 20).map((literal) => ({ value: `${base}${literal} `, label: literal, description: "From server help" }));
}
export function parseRconHelp(output: string): RconHelpPage {
const text = output.slice(0, 65_536).replace(/§[0-9a-fk-orx]/gi, "");
const pagination = text.match(/^.*Help:.*\((\d+)\/(\d+)\).*$/m);
const commands: RconHelpCommand[] = [];
for (const line of text.split(/\r?\n/)) {
const entry = line.trim().match(/^\/([a-z0-9_.:-]{1,128}):\s+(.+)$/i);
if (entry) commands.push({ name: entry[1]!, description: entry[2]!.slice(0, 512) });
}
const lines = text.split(/\r?\n/).map((line) => line.trim());
const usageStart = lines.findIndex((line) => /^Usage:\s*/i.test(line));
const usageLines: string[] = [];
if (usageStart !== -1) {
usageLines.push(lines[usageStart]!.replace(/^Usage:\s*/i, ""));
for (const line of lines.slice(usageStart + 1)) {
if (!line || /^[a-z][a-z ]+:\s|^-{3}/i.test(line)) break;
usageLines.push(line);
}
}
return {
commands,
page: pagination ? Number(pagination[1]) : null,
pages: pagination ? Number(pagination[2]) : null,
usage: usageLines.length ? usageLines.join("\n").slice(0, 4_096) : null,
};
}
+14 -1
View File
@@ -2,6 +2,19 @@
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
## Command discovery and autocomplete
Select an enabled server and choose **Refresh commands**. The portal reads `help`, then its numbered pages, stripping Minecraft formatting codes and collecting slash-prefixed command entries and descriptions. Plugin/category headings are not executable suggestions. Discovery is best-effort: plugins can omit commands, customize help, or use unsupported formatting. Unknown commands remain freely enterable.
- Refresh is sequential and capped at **64 pages** and **2,048 commands**. Inconsistent pagination, limits, cancellation, or failed requests produce incomplete results rather than a complete-coverage claim.
- Type a command prefix to open suggestions. **Arrow Up/Down** selects; **Tab/Enter** inserts without executing; **Escape** dismisses. Mouse selection also inserts. With suggestions closed, Arrow Up/Down recalls submitted commands and restores the unsent draft. Enter then submits normally; the explicit submit button remains available.
- After a discovered command followed by whitespace is entered, a **350 ms debounce** fetches `help <command>` once for usage hints. Wrapped usage remains readable. Balanced, bounded literal alternatives can suggest arguments, including nested alternatives; placeholders, optional/unsupported syntax, and overly complex usage are hints only, never invented values. This is not native Minecraft tab completion or live player-name completion.
- Commands, descriptions, and usage lookup results (including failures) stay in **page memory**, separately keyed by server ID, endpoint, and enabled state. Reload clears them; endpoint edits invalidate the matching cache. Refresh clears cached usage so failed lookups can be retried. Browsing already cached suggestions sends no RCON requests.
- Switching servers or submitting a manual command stops further discovery pages. An already-sent help request cannot be recalled; manual execution waits for that request to finish rather than racing it for the connection lock. Discovery and usage requests do not populate the console transcript or Arrow-key recall.
- Help requests use the same authorized, audited, bounded server-side command path as manual commands. Each request records a command audit event; responses and suggestion caches are not persisted. Help text is rendered as inert text, never HTML. Errors leave manual command entry available.
No additional Minecraft plugin, database migration, secret, or deployment setting is required for autocomplete.
## Application configuration
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
@@ -23,7 +36,7 @@ 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.
- 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.
- 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 or application logs, and responses are not persisted. Audit events persist the complete submitted command, acting administrator and server identity, command verb, a domain-separated HMAC digest, and a correlated safe outcome. Persistent audit-backed command history is separate from page-memory recall.
- Connection passwords are never selected by page queries or returned to the browser.
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.