Add SCUM Source RCON transport
This commit is contained in:
@@ -90,6 +90,8 @@ import type {
|
||||
ServerLifecycleResponse,
|
||||
ServerConfigWriteApprovalRequest,
|
||||
ServerConfigWriteDispatchResponse,
|
||||
SourceRCONCommandRequest,
|
||||
SourceRCONCommandResponse,
|
||||
ServerInstanceListResponse,
|
||||
ServerDeletionRequest,
|
||||
ServerInstanceUpdateRequest,
|
||||
@@ -565,6 +567,10 @@ export class PlatformApiClient {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> {
|
||||
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async getServerConfig(id: string): Promise<ServerConfigResponse> {
|
||||
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient } from "./client";
|
||||
import type { SourceRCONCommandRequest } from "./types";
|
||||
|
||||
describe("PlatformApiClient Source RCON command dispatch", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("posts the typed server-scoped request and projects only safe queue state", async () => {
|
||||
const request: SourceRCONCommandRequest = {
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "Maintenance complete",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
};
|
||||
const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
authorization: new Headers(init?.headers).get("Authorization")
|
||||
});
|
||||
return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}));
|
||||
|
||||
const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request);
|
||||
|
||||
expect(calls).toEqual([{
|
||||
url: "/api/v1/server-instances/server%2F1/rcon/commands",
|
||||
method: "POST",
|
||||
body: request,
|
||||
authorization: "Bearer operator-session"
|
||||
}]);
|
||||
expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" });
|
||||
for (const forbidden of ["command", "password", "host", "configRef", "response"]) {
|
||||
expect(Object.keys(response)).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -485,6 +485,26 @@ export interface ServerLifecycleResponse {
|
||||
job: JobResponse;
|
||||
}
|
||||
|
||||
export type SourceRCONCommandKind = "chat" | "command";
|
||||
|
||||
export interface SourceRCONCommandRequest {
|
||||
kind: SourceRCONCommandKind;
|
||||
chatType?: number;
|
||||
message?: string;
|
||||
targetSteamId?: string;
|
||||
command?: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
// The Platform response intentionally excludes the command, local listener
|
||||
// details, credentials, and Source RCON response body.
|
||||
export interface SourceRCONCommandResponse {
|
||||
jobId: string;
|
||||
serverInstanceId: string;
|
||||
status: JobState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RunCapacityResponse {
|
||||
maxJobs: number;
|
||||
runningJobs: number;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
||||
|
||||
describe("SourceRCONCommandPanel", () => {
|
||||
it("uses the typed dispatch API without confirmation, transcript, or connection fields", () => {
|
||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONChatRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONRawCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("history");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("password");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
||||
import { ResultBadge } from "./StateViews";
|
||||
|
||||
interface SourceRCONCommandPanelProps {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
||||
|
||||
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
||||
const [chatType, setChatType] = useState(4);
|
||||
const [chatMessage, setChatMessage] = useState("");
|
||||
const [targetSteamId, setTargetSteamId] = useState("");
|
||||
const [rawCommand, setRawCommand] = useState("");
|
||||
const [pending, setPending] = useState<"chat" | "command" | null>(null);
|
||||
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
||||
|
||||
if (pluginId !== "game.scum") {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendChat(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("chat");
|
||||
setDispatch({ status: "pending", label: "正在提交聊天消息" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONChatRequest(serverId, { chatType, message: chatMessage, targetSteamId }));
|
||||
setChatMessage("");
|
||||
setTargetSteamId("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "聊天消息提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRawCommand(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setPending("command");
|
||||
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
||||
try {
|
||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, rawCommand));
|
||||
setRawCommand("");
|
||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
||||
} catch (error) {
|
||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="SCUM Source RCON controls">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>SCUM 聊天与管理员指令</h2>
|
||||
<p className="page-status">立即派发一次性任务;只显示安全状态,不保留聊天或指令记录。</p>
|
||||
</div>
|
||||
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
||||
</div>
|
||||
<div className="operations-command-grid">
|
||||
<section className="console-module" aria-label="SCUM chat command">
|
||||
<div className="panel-header"><h2>发送聊天</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendChat(event)}>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
聊天类型
|
||||
<select value={chatType} onChange={(event) => setChatType(Number(event.target.value))} disabled={pending !== null}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((value) => <option key={value} value={value}>类型 {value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
目标 SteamID64(可选)
|
||||
<input value={targetSteamId} inputMode="numeric" maxLength={17} onChange={(event) => setTargetSteamId(event.target.value)} disabled={pending !== null} placeholder="留空为广播" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
聊天内容
|
||||
<textarea value={chatMessage} maxLength={1024} rows={3} onChange={(event) => setChatMessage(event.target.value)} disabled={pending !== null} placeholder="输入单行聊天内容" />
|
||||
</label>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !chatMessage.trim()}>{pending === "chat" ? "提交中…" : "发送聊天"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="console-module" aria-label="SCUM raw administrator command">
|
||||
<div className="panel-header"><h2>原始管理员指令</h2></div>
|
||||
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
||||
<label>
|
||||
指令
|
||||
<textarea value={rawCommand} maxLength={4000} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 SetTime 12" />
|
||||
</label>
|
||||
<p className="page-status">指令会直接交给当前运行中的 SCUM,不会显示执行回包。</p>
|
||||
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceRCONDispatchLabel(jobId: string, status: string): string {
|
||||
return `已${status === "queued" ? "排队" : "提交"} · 任务 ${jobId}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw";
|
||||
import runtimeDLLExtensionsPanelSource from "../components/RuntimeDLLExtensionsPanel.tsx?raw";
|
||||
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
|
||||
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
|
||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||
|
||||
@@ -115,6 +116,18 @@ describe("ServerDetailPage config write approval", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("adds direct SCUM chat and raw commands through the one-time typed RCON API", () => {
|
||||
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不保留聊天或指令记录");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不会显示执行回包");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
||||
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the dependency catalog only after runtime actions expose dependency operations", () => {
|
||||
const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? "";
|
||||
expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"');
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecycl
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||||
import { RuntimeDLLExtensionsPanel } from "../components/RuntimeDLLExtensionsPanel";
|
||||
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
|
||||
import {
|
||||
RuntimeTaskProgressDialog,
|
||||
runtimeBuildStages,
|
||||
@@ -295,6 +296,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
|
||||
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||
{section === "overview" && (
|
||||
<RuntimeDistributionSection
|
||||
instance={instance.data}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "./sourceRcon";
|
||||
|
||||
describe("Source RCON browser request schemas", () => {
|
||||
it("builds a bounded typed chat request without connection material", () => {
|
||||
expect(sourceRCONChatRequest("server-1", { chatType: 4, message: "hello", targetSteamId: "76561198000000001" }, 12)).toEqual({
|
||||
kind: "chat",
|
||||
chatType: 4,
|
||||
message: "hello",
|
||||
targetSteamId: "76561198000000001",
|
||||
idempotencyKey: "web:source-rcon:chat:server-1:12"
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects framed text and formats raw commands without chat fields", () => {
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 2, message: "line one\nline two" }, 13)).toThrow("受限的单行文本");
|
||||
expect(() => sourceRCONChatRequest("server-1", { chatType: 8, message: "hello" }, 13)).toThrow("聊天类型");
|
||||
expect(sourceRCONRawCommandRequest("server-1", " SetTime 12 ", 14)).toEqual({
|
||||
kind: "command",
|
||||
command: "SetTime 12",
|
||||
idempotencyKey: "web:source-rcon:command:server-1:14"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SourceRCONCommandRequest } from "../api/types";
|
||||
|
||||
const maxChatBytes = 1024;
|
||||
const maxCommandBytes = 4000;
|
||||
const steamID64 = /^[0-9]{17}$/;
|
||||
|
||||
export interface SourceRCONChatDraft {
|
||||
chatType: number;
|
||||
message: string;
|
||||
targetSteamId?: string;
|
||||
}
|
||||
|
||||
export function sourceRCONChatRequest(serverInstanceId: string, draft: SourceRCONChatDraft, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
const message = validateSourceRCONText(draft.message, maxChatBytes, "聊天内容");
|
||||
if (!Number.isInteger(draft.chatType) || draft.chatType < 0 || draft.chatType > 7) {
|
||||
throw new Error("聊天类型必须在 0 到 7 之间。");
|
||||
}
|
||||
const targetSteamId = draft.targetSteamId?.trim() ?? "";
|
||||
if (targetSteamId && !steamID64.test(targetSteamId)) {
|
||||
throw new Error("目标 SteamID64 必须为 17 位数字。");
|
||||
}
|
||||
return {
|
||||
kind: "chat",
|
||||
chatType: draft.chatType,
|
||||
message,
|
||||
targetSteamId: targetSteamId || undefined,
|
||||
idempotencyKey: sourceRCONIdempotencyKey("chat", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
export function sourceRCONRawCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
|
||||
return {
|
||||
kind: "command",
|
||||
command: validateSourceRCONText(command, maxCommandBytes, "原始指令"),
|
||||
idempotencyKey: sourceRCONIdempotencyKey("command", serverInstanceId, sequence)
|
||||
};
|
||||
}
|
||||
|
||||
function validateSourceRCONText(value: string, maxBytes: number, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||
throw new Error(`${label}必须是受限的单行文本。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sourceRCONIdempotencyKey(kind: "chat" | "command", serverInstanceId: string, sequence: number): string {
|
||||
return `web:source-rcon:${kind}:${serverInstanceId.trim()}:${Math.max(0, Math.floor(sequence))}`;
|
||||
}
|
||||
Reference in New Issue
Block a user