Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 | ||
|
|
20dfc58d63 |
@@ -25,5 +25,9 @@ PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
|
||||
RCON_CREDENTIAL_KEY=
|
||||
RCON_AUDIT_KEY=
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -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, 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`
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
|
||||
@@ -38,6 +38,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
||||
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
|
||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||
<Link className="hover:text-accent" href="/admin/groups">Groups</Link>
|
||||
<Link className="hover:text-accent" href="/admin/rcon">RCON</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||
import { db } from "@/lib/database";
|
||||
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||
import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||
import { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -58,6 +58,7 @@ export default async function AdminDashboardPage() {
|
||||
))
|
||||
.where(and(
|
||||
isNotNull(ipObservations.userId),
|
||||
inArray(ipIntelligence.classification, MAP_LOCATION_CLASSIFICATIONS),
|
||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
authorized: 0,
|
||||
selected: [] as unknown[],
|
||||
transactionSelected: [] as unknown[],
|
||||
updates: [] as Record<string, unknown>[],
|
||||
inserts: [] as unknown[],
|
||||
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown> }>,
|
||||
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
|
||||
gatewayResult: { ok: true, response: "private response" } as
|
||||
| { ok: true; response: string }
|
||||
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(result: unknown[]) {
|
||||
const chain = {
|
||||
from: () => chain,
|
||||
where: () => chain,
|
||||
limit: async () => result,
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.transactionSelected),
|
||||
update: () => ({
|
||||
set: (value: Record<string, unknown>) => ({
|
||||
where: async () => { actionState.updates.push(value); },
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => { actionState.inserts.push(value); },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: () => selection(actionState.selected),
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rcon-validation", () => ({
|
||||
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
|
||||
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
|
||||
if (!input.name || !input.host || !input.port) return null;
|
||||
return input;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-credentials", () => ({
|
||||
decryptRconPassword: () => "decrypted-password",
|
||||
encryptRconPassword: vi.fn(),
|
||||
rconCommandDigest: () => "hmac-sha256:v1:digest",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-gateway", () => ({
|
||||
executeRcon: async (connection: Record<string, unknown>, command: string) => {
|
||||
actionState.executions.push({ connection, command });
|
||||
return actionState.gatewayResult;
|
||||
},
|
||||
testRconConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/audit", () => ({
|
||||
recordAdminSubjectEvent: async (
|
||||
admin: unknown,
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) => {
|
||||
actionState.audits.push({ admin, subject, type, data });
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
const serverId = "11111111-1111-4111-8111-111111111111";
|
||||
const savedServer = {
|
||||
id: serverId,
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
encryptedPassword: "ciphertext",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("RCON server actions", () => {
|
||||
beforeEach(() => {
|
||||
actionState.authorized = 0;
|
||||
actionState.selected = [];
|
||||
actionState.transactionSelected = [];
|
||||
actionState.updates = [];
|
||||
actionState.inserts = [];
|
||||
actionState.audits = [];
|
||||
actionState.executions = [];
|
||||
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||
});
|
||||
|
||||
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");
|
||||
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
|
||||
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
|
||||
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Enter one command of at most 1,024 bytes without control characters.",
|
||||
serverId: "",
|
||||
});
|
||||
expect(actionState.authorized).toBe(6);
|
||||
});
|
||||
|
||||
it("preserves the encrypted password on an unrelated connection update", async () => {
|
||||
actionState.transactionSelected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("name", "Renamed server");
|
||||
formData.set("host", "season4.somc.svc.cluster.local");
|
||||
formData.set("port", "25575");
|
||||
formData.set("password", "");
|
||||
formData.set("enabled", "yes");
|
||||
|
||||
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
|
||||
expect(actionState.updates).toEqual([
|
||||
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
|
||||
]);
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("rechecks enabled saved state and records credential-safe command lifecycle audits", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "say private value");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "success",
|
||||
message: "private response",
|
||||
serverId,
|
||||
});
|
||||
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.executions).toEqual([{
|
||||
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
|
||||
command: "say private value",
|
||||
}]);
|
||||
expect(actionState.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
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" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||
}),
|
||||
]);
|
||||
const serializedAudits = JSON.stringify(actionState.audits);
|
||||
expect(serializedAudits).not.toContain("private value");
|
||||
expect(serializedAudits).not.toContain("private response");
|
||||
expect(serializedAudits).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["busy", "Another command is already running for this server."],
|
||||
["timeout", "The RCON request timed out."],
|
||||
["unavailable", "The RCON server was unavailable or rejected authentication."],
|
||||
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.gatewayResult = { ok: false, reason };
|
||||
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,
|
||||
serverId,
|
||||
});
|
||||
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("does not execute or audit when the enabled connection is unavailable", async () => {
|
||||
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: "That RCON connection is disabled or unavailable.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
expect(actionState.audits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "RCON connection created.",
|
||||
updated: "RCON connection updated.",
|
||||
enabled: "RCON connection enabled.",
|
||||
disabled: "RCON connection disabled.",
|
||||
deleted: "RCON connection deleted.",
|
||||
tested: "RCON authentication succeeded.",
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
|
||||
"duplicate-name": "Connection names must be unique.",
|
||||
configuration: "RCON credential encryption is not configured.",
|
||||
"save-failed": "The RCON connection could not be saved.",
|
||||
"unknown-connection": "That RCON connection no longer exists.",
|
||||
"confirmation-required": "Confirm the connection before deleting it.",
|
||||
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
|
||||
"test-busy": "Another RCON operation is already using that server.",
|
||||
"test-timeout": "RCON authentication timed out.",
|
||||
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
|
||||
};
|
||||
|
||||
function queryValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export default async function RconPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
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,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<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">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
|
||||
import { db } from "@/lib/database";
|
||||
import { discordIdentity } from "@/lib/discord-identity";
|
||||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
|
||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||
recentEventsQuery,
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
ipAddress: ipObservations.ipAddress,
|
||||
source: ipObservations.source,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(100),
|
||||
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
|
||||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||
const addressGroups = groupAccessAddresses(
|
||||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
||||
);
|
||||
const addressGroups = groupAccessAddresses(observations);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
const nickname = user.firstName
|
||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||
@@ -215,7 +221,9 @@ export default async function AdminUserPage({
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{addressGroups.map((group) => (
|
||||
{addressGroups.map((group) => {
|
||||
const details = accessAddressDetails(group);
|
||||
return (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
@@ -223,8 +231,10 @@ export default async function AdminUserPage({
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
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", () => ({
|
||||
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 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("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("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
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("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")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
|
||||
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 [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
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) return;
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
||||
</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 terminalOutput({
|
||||
notice,
|
||||
pending,
|
||||
responseServer,
|
||||
selected,
|
||||
state,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
pending: boolean;
|
||||
responseServer?: RconServerOption;
|
||||
selected?: RconServerOption;
|
||||
state: RconCommandState;
|
||||
}) {
|
||||
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
||||
if (state.status !== "idle") return {
|
||||
status: state.status,
|
||||
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
||||
message: state.message,
|
||||
};
|
||||
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
||||
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
||||
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
||||
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupAccessAddresses } from "./access-address-groups";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
|
||||
|
||||
describe("groupAccessAddresses", () => {
|
||||
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||
@@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => {
|
||||
});
|
||||
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("presents the latest enriched location and classification with observation fallbacks", () => {
|
||||
expect(accessAddressDetails({
|
||||
classification: "vpn",
|
||||
intelligence: {
|
||||
classification: "vpn",
|
||||
location: { city: "Toronto", region: "Ontario", countryCode: "CA" },
|
||||
},
|
||||
})).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" });
|
||||
|
||||
expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({
|
||||
location: "Location unavailable",
|
||||
classification: "hosting",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { addressGroup } from "@minecraft-account-manager/network";
|
||||
import { intelligenceSummary } from "./event-ip-summary";
|
||||
|
||||
type AccessObservation = {
|
||||
id: string;
|
||||
@@ -9,6 +10,14 @@ type AccessObservation = {
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return {
|
||||
location: summary.location ?? "Location unavailable",
|
||||
classification: summary.classification ?? observation.classification,
|
||||
};
|
||||
}
|
||||
|
||||
export type AccessAddressGroup = {
|
||||
network: string;
|
||||
latestAddress: string;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
|
||||
|
||||
const key = randomBytes(32).toString("base64");
|
||||
const otherKey = randomBytes(32).toString("base64");
|
||||
const connectionId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("RCON credential encryption", () => {
|
||||
it("round trips with randomized authenticated encryption", () => {
|
||||
const first = encryptRconPassword("super-secret", connectionId, key);
|
||||
const second = encryptRconPassword("super-secret", connectionId, key);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).not.toContain("super-secret");
|
||||
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
|
||||
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
|
||||
});
|
||||
|
||||
it("fails closed for tampering, another connection, or another key", () => {
|
||||
const encrypted = encryptRconPassword("super-secret", connectionId, key);
|
||||
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
|
||||
});
|
||||
|
||||
it("requires an exact 32-byte deployment key", () => {
|
||||
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
|
||||
});
|
||||
|
||||
it("creates a keyed, versioned command digest", () => {
|
||||
const digest = rconCommandDigest("say secret message", key);
|
||||
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
|
||||
expect(digest).not.toContain("secret message");
|
||||
expect(rconCommandDigest("say secret message", key)).toBe(digest);
|
||||
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
const VERSION = "v1";
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function explicitKey(encoded: string) {
|
||||
const key = Buffer.from(encoded, "base64");
|
||||
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
|
||||
throw new Error("invalid key");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
|
||||
if (encoded !== undefined) return explicitKey(encoded);
|
||||
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
|
||||
if (configured) return explicitKey(configured);
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) throw new Error("missing key");
|
||||
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
|
||||
}
|
||||
|
||||
function additionalData(connectionId: string) {
|
||||
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
|
||||
}
|
||||
|
||||
function decodeBase64url(value: string) {
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey);
|
||||
} catch {
|
||||
throw new Error("RCON credential key is not configured");
|
||||
}
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
cipher.setAAD(additionalData(connectionId));
|
||||
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
|
||||
}
|
||||
|
||||
export function rconCommandDigest(command: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey, "audit");
|
||||
} catch {
|
||||
throw new Error("RCON audit key is not configured");
|
||||
}
|
||||
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
|
||||
try {
|
||||
const key = credentialKey(encodedKey);
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
|
||||
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
|
||||
const iv = decodeBase64url(ivValue);
|
||||
const tag = decodeBase64url(tagValue);
|
||||
const ciphertext = decodeBase64url(ciphertextValue);
|
||||
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
decipher.setAAD(additionalData(connectionId));
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("RCON credential unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
|
||||
|
||||
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
|
||||
return {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue("20 players online"),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON gateway", () => {
|
||||
it("authenticates a connection without sending a command", async () => {
|
||||
const client = transport();
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
|
||||
expect(client.connect).toHaveBeenCalledOnce();
|
||||
expect(client.send).not.toHaveBeenCalled();
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("executes one command and always closes the connection", async () => {
|
||||
const client = transport();
|
||||
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
expect(client.send).toHaveBeenCalledWith("list");
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns safe categorized failures and closes failed clients", async () => {
|
||||
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects concurrent work for the same connection", async () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
|
||||
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
|
||||
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
|
||||
|
||||
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("bounds total concurrent work", async () => {
|
||||
let release!: () => void;
|
||||
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
|
||||
const active = clients.map((client, index) => executeRcon({
|
||||
id: `server-${index}`,
|
||||
host: `season-${index}`,
|
||||
port: 25575,
|
||||
password: "secret",
|
||||
}, "list", () => client));
|
||||
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
|
||||
|
||||
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await Promise.all(active);
|
||||
});
|
||||
|
||||
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({
|
||||
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
|
||||
expect(client.destroy).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let stalled cleanup retain a connection lock", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
|
||||
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
|
||||
|
||||
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Rcon } from "rcon-client";
|
||||
import { sanitizeRconOutput } from "./rcon-validation";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const MAX_ACTIVE_CONNECTIONS = 8;
|
||||
const activeConnections = new Set<string>();
|
||||
|
||||
type Connection = { id?: string; host: string; port: number; password: string };
|
||||
type FailureReason = "busy" | "timeout" | "unavailable";
|
||||
|
||||
export interface RconTransport {
|
||||
connect(): Promise<unknown>;
|
||||
send(command: string): Promise<string>;
|
||||
end(): Promise<unknown>;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
type TransportFactory = (connection: Connection) => RconTransport;
|
||||
|
||||
class RconDeadlineError extends Error {}
|
||||
|
||||
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timeout();
|
||||
reject(new RconDeadlineError("RCON operation timed out"));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTransport(connection: Connection): RconTransport {
|
||||
const client = new Rcon({
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
password: connection.password,
|
||||
timeout: TIMEOUT_MS,
|
||||
maxPending: 1,
|
||||
});
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
send: (command) => client.send(command),
|
||||
end: async () => {
|
||||
if (!client.socket) return;
|
||||
if (client.socket.connecting || !client.socket.writable) {
|
||||
client.socket.destroy();
|
||||
return;
|
||||
}
|
||||
await client.end();
|
||||
},
|
||||
destroy: () => client.socket?.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(error: unknown): { ok: false; reason: FailureReason } {
|
||||
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
|
||||
}
|
||||
|
||||
async function withTransport<T>(
|
||||
connection: Connection,
|
||||
operation: (transport: RconTransport) => Promise<T>,
|
||||
factory: TransportFactory,
|
||||
): Promise<T | { ok: false; reason: FailureReason }> {
|
||||
const key = connection.id ?? `${connection.host}:${connection.port}`;
|
||||
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
|
||||
return { ok: false, reason: "busy" };
|
||||
}
|
||||
activeConnections.add(key);
|
||||
let transport: RconTransport | null = null;
|
||||
try {
|
||||
transport = factory(connection);
|
||||
return await deadline(operation(transport), () => transport?.destroy?.());
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
if (transport) {
|
||||
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
|
||||
}
|
||||
activeConnections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
return { ok: true as const };
|
||||
}, factory);
|
||||
}
|
||||
|
||||
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
const response = await transport.send(command);
|
||||
return { ok: true as const, response: sanitizeRconOutput(response) };
|
||||
}, factory);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||
|
||||
describe("RCON validation", () => {
|
||||
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||
expect(validateRconConnection({
|
||||
name: " Season 4 ",
|
||||
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||
port: "25575",
|
||||
password: "correct horse battery staple",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
|
||||
expect(validateRconConnection({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: "43210",
|
||||
password: "secret",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: 43210,
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a blank replacement password only while editing", () => {
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: false,
|
||||
})?.password).toBeNull();
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
|
||||
expect(validateRconCommand(" list ")).toBe("list");
|
||||
expect(validateRconCommand("say first\nsay second")).toBeNull();
|
||||
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
|
||||
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips output controls and bounds output by UTF-8 bytes", () => {
|
||||
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
|
||||
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const MAX_COMMAND_BYTES = 1_024;
|
||||
const MAX_OUTPUT_BYTES = 65_536;
|
||||
|
||||
export type ValidRconConnection = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
export function validateRconConnection(
|
||||
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||
options: { passwordRequired: boolean },
|
||||
): ValidRconConnection | null {
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||
const port = Number(portText);
|
||||
|
||||
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||
if (options.passwordRequired && !passwordText) return null;
|
||||
|
||||
return { name, host, port, password: passwordText || null };
|
||||
}
|
||||
|
||||
export function validateRconCommand(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const command = value.trim();
|
||||
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
||||
return command;
|
||||
}
|
||||
|
||||
export function sanitizeRconOutput(value: string) {
|
||||
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
|
||||
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
|
||||
let result = "";
|
||||
let bytes = 0;
|
||||
for (const character of safe) {
|
||||
const size = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + size > MAX_OUTPUT_BYTES) break;
|
||||
result += character;
|
||||
bytes += size;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map";
|
||||
import {
|
||||
MAP_LOCATION_CLASSIFICATIONS,
|
||||
groupMapLocations,
|
||||
parseUserLocation,
|
||||
parseUserNetwork,
|
||||
projectWorldPoint,
|
||||
} from "./user-location-map";
|
||||
|
||||
describe("user location map", () => {
|
||||
it("allows only clear and hosting observations as map locations", () => {
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).toEqual(["clear", "hosting"]);
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("vpn");
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("proxy");
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("tor");
|
||||
});
|
||||
|
||||
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||
expect(parseUserLocation({
|
||||
classification: "clear",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
type UnknownMap = Record<string, unknown>;
|
||||
|
||||
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
|
||||
|
||||
function objectValue(value: unknown): UnknownMap | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UnknownMap
|
||||
|
||||
@@ -34,6 +34,8 @@ 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 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,5 +1,20 @@
|
||||
# Design Update Log
|
||||
|
||||
## 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.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records.
|
||||
* **Refine**: Select each admin map marker from the user's latest coordinate-bearing clear or hosting observation while keeping VPN, proxy, and Tor activity in the network-risk view.
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Manage users as an administrator
|
||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||
tags: [admin, users, minecraft, discord]
|
||||
timestamp: 2026-08-02T15:03:59Z
|
||||
timestamp: 2026-08-07T23:02:04Z
|
||||
story_id: US-013
|
||||
status: verified
|
||||
---
|
||||
@@ -17,6 +17,9 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||
- [x] Search results show onboarding state, primary username, and active account count.
|
||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||
- [x] Each grouped recent address shows the latest observation's approximate location and classification, including clear, VPN, proxy, Tor, hosting, and unknown classifications.
|
||||
- [x] Missing IP enrichment is labelled as location unavailable and falls back to the stored observation classification.
|
||||
- [x] Address groups use the enrichment associated with their latest observation.
|
||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||
@@ -43,7 +46,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
||||
|
||||
# Validation
|
||||
|
||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
|
||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Latest-observation enrichment and classification fallback are covered by [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts). The full 107-test suite, TypeScript, lint, OKF validation, and the production build pass.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Monitor community account activity
|
||||
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||
timestamp: 2026-08-02T12:05:27Z
|
||||
timestamp: 2026-08-07T22:31:05Z
|
||||
story_id: US-018
|
||||
status: verified
|
||||
---
|
||||
@@ -15,7 +15,9 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||
- [x] A server-rendered Natural Earth overview plots each user's latest observation with valid approximate coordinates.
|
||||
- [x] A server-rendered Natural Earth overview plots each user's latest non-anonymized observation with valid approximate coordinates, allowing clear and hosting classifications while excluding VPN, proxy, and Tor observations.
|
||||
- [x] When a user's newest coordinate-bearing observation is VPN, proxy, or Tor, the map uses that user's older clear or hosting observation when one exists.
|
||||
- [x] A user without a coordinate-bearing clear or hosting observation is counted as unavailable on the map.
|
||||
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
|
||||
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
|
||||
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
|
||||
@@ -31,7 +33,7 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
||||
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
||||
- [x] The dashboard shows login denials from the previous 24 hours.
|
||||
- [x] Recent VPN, proxy, and Tor observations use enriched ProxyCheck classifications, collapse repeated rows per user, and show counts, sources, and latest activity.
|
||||
- [x] Recent VPN, proxy, and Tor observations remain available in the separate network-risk section when excluded from map-location selection.
|
||||
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
||||
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
|
||||
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
||||
@@ -48,8 +50,9 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
# Validation
|
||||
|
||||
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
||||
- Coordinate parsing, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests.
|
||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||
- Coordinate parsing, the clear/hosting map policy, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-location and user-world-map tests.
|
||||
- The full test suite passes with 106 tests across 36 files; web type checking and lint pass.
|
||||
- The Next.js production build succeeds and reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: User Story
|
||||
title: Manage RCON server connections
|
||||
description: Administrators manage encrypted connection settings for Minecraft RCON server addresses.
|
||||
tags: [admin, rcon, minecraft, security, operations]
|
||||
timestamp: 2026-08-08T13:40:43Z
|
||||
story_id: US-021
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
As an administrator, I want to manage one or more Minecraft RCON connections, so that server operations can be reached from the existing protected console.
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
- [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] 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 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 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
|
||||
|
||||
- [Operate servers through an RCON console](us-022-rcon-console.md)
|
||||
- [Authenticate administrators with SSO](us-011-admin-sso.md)
|
||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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-08T14:06:09Z
|
||||
story_id: US-022
|
||||
status: verified
|
||||
---
|
||||
|
||||
# User Story
|
||||
|
||||
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 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 commands and responses are not persisted in browser storage, 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] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
||||
- [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 latest-response 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] Command responses, 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.
|
||||
|
||||
# Implementation
|
||||
|
||||
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 command responses plus connection-operation notices in one scrollable viewport. 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. 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.
|
||||
|
||||
# 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. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, the actionable no-server state, editable command-history navigation with draft restoration, and prompt focus after command results and server changes. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Manage RCON server connections](us-021-rcon-connections.md)
|
||||
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||
- [Preserve an audit trail](us-010-audit-events.md)
|
||||
@@ -8,6 +8,10 @@ The Next.js application owns user onboarding, account management, admin configur
|
||||
|
||||
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
||||
|
||||
### RCON administration
|
||||
|
||||
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
|
||||
|
||||
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
|
||||
@@ -26,6 +30,8 @@ The admission decision is fail closed. Unknown players, disabled effective group
|
||||
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
|
||||
- Session and one-time-code values are random and stored only as hashes.
|
||||
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
|
||||
- RCON endpoints require syntactically valid DNS hostnames and ports; passwords never cross the browser trust boundary. Cluster egress policy and administrator authorization constrain the resulting outbound-connectivity trust boundary.
|
||||
- RCON commands and responses are untrusted, bounded, rendered only as text, and excluded from persistent history and logs.
|
||||
|
||||
## Database invariants
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# RCON administration
|
||||
|
||||
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
|
||||
|
||||
## 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.
|
||||
|
||||
This flexibility means an authorized or compromised administrator can make RCON connection attempts to any DNS hostname and port reachable from the web runtime. Use cluster egress policy and administrator access controls to constrain that trust boundary where required.
|
||||
|
||||
Saved passwords are encrypted with AES-256-GCM and connection-bound authenticated data. By default, domain-separated credential and audit keys are derived from `AUTH_SECRET`. Deployments may instead provide independent 32-byte base64 values through `RCON_CREDENTIAL_KEY` and `RCON_AUDIT_KEY`. Rotating the credential key requires replacing saved RCON passwords.
|
||||
|
||||
## Minecraft server configuration
|
||||
|
||||
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.
|
||||
|
||||
## Security behavior
|
||||
|
||||
- Existing account-manager administrator authorization is rechecked for every connection mutation, test, and command.
|
||||
- Commands are limited to 1,024 UTF-8 bytes and reject control characters.
|
||||
- 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 submitted commands remain only in page memory for Arrow Up/Arrow Down recall and 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. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
|
||||
|
||||
## Migration
|
||||
|
||||
Apply the generated Drizzle migration before deploying the web image:
|
||||
|
||||
```bash
|
||||
npx drizzle-kit migrate
|
||||
```
|
||||
|
||||
Never use `drizzle push` for this schema change.
|
||||
Generated
+19
-3
@@ -47,6 +47,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.2.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"rcon-client": "^4.2.5",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
@@ -7358,9 +7359,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8062,6 +8063,15 @@
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rcon-client": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/rcon-client/-/rcon-client-4.2.5.tgz",
|
||||
"integrity": "sha512-AnX1GU/ZTlwtYup3H6h0J1hwfP3OYltXVe+8ReBzmNEepX3xGH8nDg7gYqT5Y9rpAS/LmQ48h0BKINt1YGd8bA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"typed-emitter": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -9197,6 +9207,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-0.1.0.tgz",
|
||||
"integrity": "sha512-Tfay0l6gJMP5rkil8CzGbLthukn+9BN/VXWcABVFPjOoelJ+koW8BuPZYk+h/L+lEeIp1fSzVRiWRPIjKVjPdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "rcon_servers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"host" varchar(253) NOT NULL,
|
||||
"port" integer DEFAULT 25575 NOT NULL,
|
||||
"encrypted_password" text NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "rcon_servers_port_check" CHECK ("rcon_servers"."port" between 1 and 65535)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "rcon_servers_name_uidx" ON "rcon_servers" USING btree (lower("name"));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1785692345708,
|
||||
"tag": "0005_young_vertigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786151282526,
|
||||
"tag": "0006_curious_lester",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -206,6 +206,23 @@ export const appSettings = pgTable("app_settings", {
|
||||
...timestamps(),
|
||||
});
|
||||
|
||||
export const rconServers = pgTable(
|
||||
"rcon_servers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
host: varchar("host", { length: 253 }).notNull(),
|
||||
port: integer("port").notNull().default(25575),
|
||||
encryptedPassword: text("encrypted_password").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
...timestamps(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("rcon_servers_name_uidx").on(sql`lower(${table.name})`),
|
||||
check("rcon_servers_port_check", sql`${table.port} between 1 and 65535`),
|
||||
],
|
||||
);
|
||||
|
||||
export const ipIntelligence = pgTable("ip_intelligence", {
|
||||
ipAddress: inet("ip_address").primaryKey(),
|
||||
classification: ipClassification("classification").notNull().default("unknown"),
|
||||
|
||||
Reference in New Issue
Block a user