257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
"use server";
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { events, rconServers } from "@minecraft-account-manager/database";
|
|
import { getClientIp } from "@minecraft-account-manager/network";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { headers } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
|
import { db } from "@/lib/database";
|
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
|
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
|
|
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
|
|
import { recordAdminSubjectEvent } from "@/lib/audit";
|
|
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
|
export type RconCommandState = {
|
|
status: "idle" | "success" | "error";
|
|
message: string;
|
|
serverId: string;
|
|
};
|
|
|
|
function formConnection(formData: FormData, passwordRequired: boolean) {
|
|
return validateRconConnection({
|
|
name: formData.get("name"),
|
|
host: formData.get("host"),
|
|
port: formData.get("port"),
|
|
password: formData.get("password"),
|
|
}, { passwordRequired });
|
|
}
|
|
|
|
async function auditContext() {
|
|
const requestHeaders = await headers();
|
|
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
|
}
|
|
|
|
function auditData(admin: Admin, data: Record<string, unknown>) {
|
|
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
|
}
|
|
|
|
function rconPath(query: string) {
|
|
return `/admin/rcon?${query}`;
|
|
}
|
|
|
|
export async function createRconServer(formData: FormData) {
|
|
const admin = await requireAdminSession();
|
|
const details = formConnection(formData, true);
|
|
if (!details?.password) redirect(rconPath("error=invalid-connection"));
|
|
const id = randomUUID();
|
|
let encryptedPassword: string;
|
|
try {
|
|
encryptedPassword = encryptRconPassword(details.password, id);
|
|
} catch {
|
|
redirect(rconPath("error=configuration"));
|
|
}
|
|
const ipAddress = await auditContext();
|
|
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(rconServers).values({
|
|
id,
|
|
name: details.name,
|
|
host: details.host,
|
|
port: details.port,
|
|
encryptedPassword,
|
|
enabled: formData.get("enabled") === "yes",
|
|
});
|
|
await tx.insert(events).values({
|
|
id: randomUUID(),
|
|
source: "/web/admin",
|
|
type: "games.minecraft.account-manager.rcon.connection.created",
|
|
subject: `rcon-server/${id}`,
|
|
time: new Date(),
|
|
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
|
|
ipAddress: ipAddress ?? null,
|
|
});
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
|
redirect(rconPath("error=save-failed"));
|
|
}
|
|
redirect(rconPath("saved=created"));
|
|
}
|
|
|
|
export async function updateRconServer(formData: FormData) {
|
|
const admin = await requireAdminSession();
|
|
const serverId = String(formData.get("serverId") ?? "");
|
|
const details = formConnection(formData, false);
|
|
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
|
|
const ipAddress = await auditContext();
|
|
|
|
let result: string | null;
|
|
try {
|
|
result = await db.transaction(async (tx) => {
|
|
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
|
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
|
if (!current) return null;
|
|
let encryptedPassword = current.encryptedPassword;
|
|
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
|
|
const enabled = formData.get("enabled") === "yes";
|
|
await tx.update(rconServers).set({
|
|
name: details.name,
|
|
host: details.host,
|
|
port: details.port,
|
|
encryptedPassword,
|
|
enabled,
|
|
updatedAt: new Date(),
|
|
}).where(eq(rconServers.id, current.id));
|
|
await tx.insert(events).values({
|
|
id: randomUUID(),
|
|
source: "/web/admin",
|
|
type: "games.minecraft.account-manager.rcon.connection.updated",
|
|
subject: `rcon-server/${current.id}`,
|
|
time: new Date(),
|
|
data: auditData(admin, {
|
|
name: details.name,
|
|
host: details.host,
|
|
port: details.port,
|
|
enabled,
|
|
passwordReplaced: Boolean(details.password),
|
|
}),
|
|
ipAddress: ipAddress ?? null,
|
|
});
|
|
return current.id;
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
|
redirect(rconPath("error=save-failed"));
|
|
}
|
|
if (!result) redirect(rconPath("error=unknown-connection"));
|
|
redirect(rconPath("saved=updated"));
|
|
}
|
|
|
|
export async function setRconServerEnabled(formData: FormData) {
|
|
const admin = await requireAdminSession();
|
|
const serverId = String(formData.get("serverId") ?? "");
|
|
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
|
|
const enabled = formData.get("enabled") === "yes";
|
|
const ipAddress = await auditContext();
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
|
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
|
if (!current) return "missing" as const;
|
|
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
|
|
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
|
|
await tx.insert(events).values({
|
|
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
|
|
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
|
|
});
|
|
return "updated" as const;
|
|
});
|
|
if (result === "missing") redirect(rconPath("error=unknown-connection"));
|
|
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
|
|
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
|
|
}
|
|
|
|
export async function deleteRconServer(formData: FormData) {
|
|
const admin = await requireAdminSession();
|
|
const serverId = String(formData.get("serverId") ?? "");
|
|
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
|
|
const ipAddress = await auditContext();
|
|
|
|
const deleted = await db.transaction(async (tx) => {
|
|
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
|
|
if (!server) return null;
|
|
await tx.insert(events).values({
|
|
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
|
|
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
|
|
});
|
|
return server;
|
|
});
|
|
if (!deleted) redirect(rconPath("error=unknown-connection"));
|
|
redirect(rconPath("saved=deleted"));
|
|
}
|
|
|
|
async function savedConnection(serverId: string, requireEnabled: boolean) {
|
|
if (!UUID_PATTERN.test(serverId)) return null;
|
|
const [server] = await db.select().from(rconServers).where(requireEnabled
|
|
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
|
|
: eq(rconServers.id, serverId)).limit(1);
|
|
if (!server) return null;
|
|
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
|
|
if (!validated) return null;
|
|
try {
|
|
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function testSavedRconServer(formData: FormData) {
|
|
const admin = await requireAdminSession();
|
|
const serverId = String(formData.get("serverId") ?? "");
|
|
const server = await savedConnection(serverId, false);
|
|
if (!server) redirect(rconPath("error=connection-unavailable"));
|
|
const started = Date.now();
|
|
const result = await testRconConnection(server);
|
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
|
|
serverId: server.id,
|
|
name: server.name,
|
|
success: result.ok,
|
|
reason: result.ok ? null : result.reason,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
|
|
}
|
|
|
|
export async function executeRconCommand(
|
|
_previous: RconCommandState,
|
|
formData: FormData,
|
|
): Promise<RconCommandState> {
|
|
const admin = await requireAdminSession();
|
|
const serverId = String(formData.get("serverId") ?? "");
|
|
const command = validateRconCommand(formData.get("command"));
|
|
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
|
|
const server = await savedConnection(serverId, true);
|
|
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
|
|
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
|
|
let commandDigest: string;
|
|
try {
|
|
commandDigest = rconCommandDigest(command);
|
|
} catch {
|
|
return { status: "error", message: "RCON command auditing is not configured.", serverId };
|
|
}
|
|
const started = Date.now();
|
|
|
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
|
serverId: server.id,
|
|
name: server.name,
|
|
verb,
|
|
commandDigest,
|
|
});
|
|
const result = await executeRcon(server, command);
|
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
|
|
serverId: server.id,
|
|
name: server.name,
|
|
verb,
|
|
commandDigest,
|
|
success: result.ok,
|
|
reason: result.ok ? null : result.reason,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
if (!result.ok) {
|
|
const message = result.reason === "busy"
|
|
? "Another command is already running for this server."
|
|
: result.reason === "timeout"
|
|
? "The RCON request timed out."
|
|
: "The RCON server was unavailable or rejected authentication.";
|
|
return { status: "error", message, serverId };
|
|
}
|
|
return { status: "success", message: result.response || "Command completed with no response.", serverId };
|
|
}
|