diff --git a/.env.example b/.env.example index 4e17b30..4788e86 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,11 @@ PROXYCHECK_API_KEY= IP_INTELLIGENCE_CACHE_HOURS=48 BLOCK_HOSTING_IPS=false +# Internal RCON proxy. Endpoints must be exact host:port pairs. +RCON_ALLOWED_ENDPOINTS=season4.somc.svc.cluster.local:25575 +# Optional independent 32-byte base64 keys. When omitted, domain-separated keys are derived from AUTH_SECRET. +RCON_CREDENTIAL_KEY= +RCON_AUDIT_KEY= + # Structured Pino logging LOG_LEVEL=info diff --git a/README.md b/README.md index 41c0a4e..465a87b 100644 --- a/README.md +++ b/README.md @@ -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, internal RCON connection 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` diff --git a/apps/web/package.json b/apps/web/package.json index 2c94e9d..5c267c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/admin/(console)/layout.tsx b/apps/web/src/app/admin/(console)/layout.tsx index 7190e73..929cb81 100644 --- a/apps/web/src/app/admin/(console)/layout.tsx +++ b/apps/web/src/app/admin/(console)/layout.tsx @@ -38,6 +38,7 @@ export default async function AdminConsoleLayout({ children }: { children: React Settings Users Groups + RCON Events diff --git a/apps/web/src/app/admin/(console)/rcon/actions.test.ts b/apps/web/src/app/admin/(console)/rcon/actions.test.ts new file mode 100644 index 0000000..b4b86e6 --- /dev/null +++ b/apps/web/src/app/admin/(console)/rcon/actions.test.ts @@ -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[], + inserts: [] as unknown[], + audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record }>, + executions: [] as Array<{ connection: Record; 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) => ({ + 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) => 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, 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, + ) => { + 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([]); + }); +}); diff --git a/apps/web/src/app/admin/(console)/rcon/actions.ts b/apps/web/src/app/admin/(console)/rcon/actions.ts new file mode 100644 index 0000000..4e6c59f --- /dev/null +++ b/apps/web/src/app/admin/(console)/rcon/actions.ts @@ -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>; +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) { + 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 { + 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 }; +} diff --git a/apps/web/src/app/admin/(console)/rcon/page.tsx b/apps/web/src/app/admin/(console)/rcon/page.tsx new file mode 100644 index 0000000..caed09b --- /dev/null +++ b/apps/web/src/app/admin/(console)/rcon/page.tsx @@ -0,0 +1,147 @@ +import { rconServers } from "@minecraft-account-manager/database"; +import { asc } from "drizzle-orm"; +import { RconConsole } from "@/components/rcon-console"; +import { db } from "@/lib/database"; +import { + createRconServer, + deleteRconServer, + setRconServerEnabled, + testSavedRconServer, + updateRconServer, +} from "./actions"; + +export const dynamic = "force-dynamic"; + +const savedMessages: Record = { + 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 = { + "invalid-connection": "Enter a valid allowlisted 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>; +}) { + const query = await searchParams; + const saved = queryValue(query.saved); + const error = queryValue(query.error); + const servers = await db.select({ + id: rconServers.id, + name: rconServers.name, + host: rconServers.host, + port: rconServers.port, + enabled: rconServers.enabled, + updatedAt: rconServers.updatedAt, + }).from(rconServers).orderBy(asc(rconServers.name)); + + return ( +
+
+

Server operations

+

RCON

+

Run commands through the portal backend. RCON endpoints remain internal and credentials are never sent to the browser.

+
+ + {saved &&

{savedMessages[saved] ?? "RCON settings saved."}

} + {error &&

{errorMessages[error] ?? "The RCON operation failed."}

} + +
+
+

Command proxy

+

Console

+

Only the latest bounded response is shown. Commands and responses are not saved as console history.

+ server.enabled).map(({ id, name }) => ({ id, name }))} /> +
+ +
+

Configuration

+

Add connection

+
+ + + + +
+
+ +
+
+

Saved endpoints

Connections

+ {servers.length} configured +
+
+ {servers.map((server) => ( +
+
+

{server.name}

{server.enabled ? "Enabled" : "Disabled"}
+

{server.host}:{server.port}

+

Updated {server.updatedAt.toISOString()}

+
+
+
+
+
+ Edit +
+ + + + + +
+
+ Delete +
+ +

Delete {server.name}? Its encrypted credential will be removed.

+ +
+
+
+
+ ))} + {!servers.length &&

No RCON connections configured.

} +
+
+
+ ); +} + +function ConnectionFields({ + prefix, + defaults, +}: { + prefix: string; + defaults?: { name: string; host: string; port: number }; +}) { + const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent"; + return ( + <> + + + + + + ); +} diff --git a/apps/web/src/components/rcon-console.test.tsx b/apps/web/src/components/rcon-console.test.tsx new file mode 100644 index 0000000..b4e25e4 --- /dev/null +++ b/apps/web/src/components/rcon-console.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/app/admin/(console)/rcon/actions", () => ({ + executeRconCommand: vi.fn(), +})); + +import { RconConsole } from "./rcon-console"; + +describe("RconConsole", () => { + it("renders labelled keyboard-operable controls without history", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain('for="rcon-console-server"'); + expect(markup).toContain('for="rcon-command"'); + expect(markup).toContain("Season 4"); + expect(markup).toContain("Run command"); + expect(markup).not.toContain("Latest response"); + }); + + it("explains when no enabled connection is available", () => { + expect(renderToStaticMarkup()).toContain("Enable an RCON connection"); + }); +}); diff --git a/apps/web/src/components/rcon-console.tsx b/apps/web/src/components/rcon-console.tsx new file mode 100644 index 0000000..495059c --- /dev/null +++ b/apps/web/src/components/rcon-console.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useActionState } from "react"; +import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions"; + +const initialState: RconCommandState = { status: "idle", message: "", serverId: "" }; + +type ServerOption = { id: string; name: string }; + +export function RconConsole({ servers }: { servers: ServerOption[] }) { + const [state, action, pending] = useActionState(executeRconCommand, initialState); + const responseServer = servers.find((server) => server.id === state.serverId); + + if (!servers.length) { + return

Enable an RCON connection before opening the console.

; + } + + return ( +
+ + + + {state.status !== "idle" && ( +
+

Latest response{responseServer ? ` β€” ${responseServer.name}` : ""}

+
{state.message}
+
+ )} +
+ ); +} diff --git a/apps/web/src/lib/rcon-credentials.test.ts b/apps/web/src/lib/rcon-credentials.test.ts new file mode 100644 index 0000000..64bbe6f --- /dev/null +++ b/apps/web/src/lib/rcon-credentials.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/rcon-credentials.ts b/apps/web/src/lib/rcon-credentials.ts new file mode 100644 index 0000000..e994010 --- /dev/null +++ b/apps/web/src/lib/rcon-credentials.ts @@ -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"); + } +} diff --git a/apps/web/src/lib/rcon-gateway.test.ts b/apps/web/src/lib/rcon-gateway.test.ts new file mode 100644 index 0000000..11af071 --- /dev/null +++ b/apps/web/src/lib/rcon-gateway.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway"; + +function transport(overrides: Partial = {}): 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((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((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(); + } + }); +}); diff --git a/apps/web/src/lib/rcon-gateway.ts b/apps/web/src/lib/rcon-gateway.ts new file mode 100644 index 0000000..23549df --- /dev/null +++ b/apps/web/src/lib/rcon-gateway.ts @@ -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(); + +type Connection = { id?: string; host: string; port: number; password: string }; +type FailureReason = "busy" | "timeout" | "unavailable"; + +export interface RconTransport { + connect(): Promise; + send(command: string): Promise; + end(): Promise; + destroy?(): void; +} + +type TransportFactory = (connection: Connection) => RconTransport; + +class RconDeadlineError extends Error {} + +async function deadline(operation: Promise, timeout: () => void, timeoutMs = TIMEOUT_MS) { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, 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( + connection: Connection, + operation: (transport: RconTransport) => Promise, + factory: TransportFactory, +): Promise { + 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); +} diff --git a/apps/web/src/lib/rcon-validation.test.ts b/apps/web/src/lib/rcon-validation.test.ts new file mode 100644 index 0000000..12282d0 --- /dev/null +++ b/apps/web/src/lib/rcon-validation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation"; + +const allowed = "season4.somc.svc.cluster.local:25575,creative.somc.svc.cluster.local:25576"; + +describe("RCON validation", () => { + it("normalizes an allowlisted internal endpoint", () => { + expect(validateRconConnection({ + name: " Season 4 ", + host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL", + port: "25575", + password: "correct horse battery staple", + }, { allowedEndpoints: allowed, passwordRequired: true })).toEqual({ + name: "Season 4", + host: "season4.somc.svc.cluster.local", + port: 25575, + password: "correct horse battery staple", + }); + }); + + it("rejects unlisted hosts, ports, IP literals, and suffix confusion", () => { + for (const [host, port] of [ + ["postgres.somc.svc.cluster.local", "5432"], + ["season4.somc.svc.cluster.local", "5432"], + ["season4.somc.svc.cluster.local.attacker.example", "25575"], + ["10.0.0.1", "25575"], + ]) { + expect(validateRconConnection({ name: "Server", host, port, password: "secret" }, { + allowedEndpoints: allowed, + 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: "" }, { + allowedEndpoints: allowed, + passwordRequired: false, + })?.password).toBeNull(); + expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, { + allowedEndpoints: allowed, + 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); + }); +}); diff --git a/apps/web/src/lib/rcon-validation.ts b/apps/web/src/lib/rcon-validation.ts new file mode 100644 index 0000000..c86f00d --- /dev/null +++ b/apps/web/src/lib/rcon-validation.ts @@ -0,0 +1,59 @@ +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; +}; + +function endpointSet(value: string) { + return new Set(value.split(",").map((endpoint) => endpoint.trim().toLowerCase()).filter(Boolean)); +} + +export function validateRconConnection( + input: { name: unknown; host: unknown; port: unknown; password: unknown }, + options: { allowedEndpoints?: string; 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); + const allowed = endpointSet(options.allowedEndpoints ?? process.env.RCON_ALLOWED_ENDPOINTS ?? ""); + + 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 (!allowed.has(`${host}:${port}`)) 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; +} diff --git a/design/index.md b/design/index.md index 2872142..72a6ca5 100644 --- a/design/index.md +++ b/design/index.md @@ -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 internal Minecraft RCON endpoints. +* [US-022 β€” Operate servers through an RCON console](us-022-rcon-console.md) - Administrators execute bounded commands through the server-side portal proxy. # Tracking diff --git a/design/log.md b/design/log.md index d919340..2635dbc 100644 --- a/design/log.md +++ b/design/log.md @@ -1,5 +1,10 @@ # 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. + ## 2026-08-07 * **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records. diff --git a/design/us-021-rcon-connections.md b/design/us-021-rcon-connections.md new file mode 100644 index 0000000..9bac997 --- /dev/null +++ b/design/us-021-rcon-connections.md @@ -0,0 +1,39 @@ +--- +type: User Story +title: Manage RCON server connections +description: Administrators manage encrypted connection settings for internal Minecraft RCON endpoints. +tags: [admin, rcon, minecraft, security, operations] +timestamp: 2026-08-08T01:36:00Z +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 can list, add, edit, test, enable or disable, and delete RCON server connections. +- [x] Each connection has a unique display name, internal hostname, port, enabled state, and write-only password. +- [x] 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] Connection host and port pairs must match a deployment-managed exact internal endpoint allowlist, and IP literals are rejected. +- [x] Testing a connection authenticates through the server-side RCON proxy and reports a safe success or failure result. +- [x] Deleting a connection requires explicit confirmation. +- [x] Connection mutations independently recheck administrator authorization and create credential-safe audit events. +- [x] Database changes use a generated versioned Drizzle migration rather than schema push. + +# Implementation + +The administrator RCON page and server actions manage allowlisted endpoints, 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; and a production Next.js build on 2026-08-08. 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) diff --git a/design/us-022-rcon-console.md b/design/us-022-rcon-console.md new file mode 100644 index 0000000..3ab2623 --- /dev/null +++ b/design/us-022-rcon-console.md @@ -0,0 +1,39 @@ +--- +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-08T01:36:00Z +story_id: US-022 +status: in-progress +--- + +# User Story + +As an administrator, I want an RCON console in the portal, so that I can operate internal Minecraft servers without exposing RCON publicly. + +# Acceptance Criteria + +- [x] Existing account-manager administrators can select an enabled connection and execute an RCON command from the admin UI. +- [x] Browsers never connect to RCON directly; commands pass through the authenticated Next.js server runtime to an internal endpoint. +- [x] Every command independently rechecks administrator authorization and the selected connection's enabled state. +- [x] Commands are length-limited, reject control characters, execute with bounded concurrency and a timeout, and return bounded output. +- [x] Command responses are displayed safely and are not persisted in console history, audit data, or application logs. +- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses. +- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces. +- [x] The console is keyboard accessible and clearly identifies the selected server. +- [ ] RCON remains internal to the cluster and is not exposed through public ingress or a load balancer. + +# Implementation + +The client console invokes an authenticated server action that revalidates the enabled allowlisted 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; and a production Next.js build on 2026-08-08. Deployment-level verification remains pending because this repository has no Minecraft Kubernetes Service, NetworkPolicy, ingress, or load-balancer manifests with which to prove that the RCON port is internal-only. + +# 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) diff --git a/docs/architecture.md b/docs/architecture.md index 151ac25..526be1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.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 internal Minecraft RCON endpoints with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Exact deployment-managed endpoint allowlisting prevents the connection registry from becoming an arbitrary internal network proxy. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. RCON is exposed only through internal cluster services and never through public ingress. + ### 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 hostnames and ports must match the deployment allowlist on save and use; passwords never cross the browser trust boundary. +- RCON commands and responses are untrusted, bounded, rendered only as text, and excluded from persistent history and logs. ## Database invariants diff --git a/docs/rcon.md b/docs/rcon.md new file mode 100644 index 0000000..f38a33f --- /dev/null +++ b/docs/rcon.md @@ -0,0 +1,43 @@ +# 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 + +Set `RCON_ALLOWED_ENDPOINTS` to a comma-separated allowlist of exact internal `host:port` pairs: + +```text +RCON_ALLOWED_ENDPOINTS=season4.somc.svc.cluster.local:25575 +``` + +IP literals, trailing-dot hostnames, malformed DNS names, and endpoints absent from the allowlist are rejected whenever a connection is saved, tested, or used. + +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. Expose its port only on an internal `ClusterIP` service. Do not add RCON to an Ingress, NodePort, or public LoadBalancer. + +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. +- Full commands and responses are not persisted or logged. Audit events contain the command verb and a domain-separated HMAC digest. +- Connection passwords are never selected by page queries or returned to the browser. + +RCON is plaintext TCP. Keep it on the cluster network and use network policy or an encrypted tunnel when the network trust model requires stronger isolation. + +## Migration + +Apply the generated Drizzle migration before deploying the web image: + +```bash +npx drizzle-kit migrate +``` + +Never use `drizzle push` for this schema change. diff --git a/package-lock.json b/package-lock.json index 71b2013..e37b1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/database/drizzle/0006_curious_lester.sql b/packages/database/drizzle/0006_curious_lester.sql new file mode 100644 index 0000000..006e543 --- /dev/null +++ b/packages/database/drizzle/0006_curious_lester.sql @@ -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")); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0006_snapshot.json b/packages/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..8fde6cd --- /dev/null +++ b/packages/database/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1466 @@ +{ + "id": "69563bb8-b853-42fc-af9e-a88a1ed2bfe8", + "prevId": "0a2f498a-0596-4471-8c1a-2e457572636d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "registration_message": { + "name": "registration_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Please register your Minecraft account before joining.'" + }, + "group_access_denied_message": { + "name": "group_access_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Your account group does not currently have server access. Contact a host if you believe this is a mistake.'" + }, + "vpn_denied_message": { + "name": "vpn_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.'" + }, + "scheduled_access_denied_message": { + "name": "scheduled_access_denied_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Your group is only allowed access from {next_start} to {next_end}.'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_version": { + "name": "spec_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time": { + "name": "time", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "data_content_type": { + "name": "data_content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'application/json'" + }, + "data_schema": { + "name": "data_schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_time_idx": { + "name": "events_time_idx", + "columns": [ + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_type_time_idx": { + "name": "events_type_time_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_subject_time_idx": { + "name": "events_subject_time_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_unpublished_idx": { + "name": "events_unpublished_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"events\".\"published_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_actor_user_id_users_id_fk": { + "name": "events_actor_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_access_windows": { + "name": "group_access_windows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_minute_of_week": { + "name": "start_minute_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_minute_of_week": { + "name": "end_minute_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_access_windows_group_idx": { + "name": "group_access_windows_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_access_windows_group_id_groups_id_fk": { + "name": "group_access_windows_group_id_groups_id_fk", + "tableFrom": "group_access_windows", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_access_windows_minute_range_check": { + "name": "group_access_windows_minute_range_check", + "value": "\"group_access_windows\".\"start_minute_of_week\" >= 0 and \"group_access_windows\".\"start_minute_of_week\" < 10080 and \"group_access_windows\".\"end_minute_of_week\" >= 0 and \"group_access_windows\".\"end_minute_of_week\" < 10080 and \"group_access_windows\".\"start_minute_of_week\" <> \"group_access_windows\".\"end_minute_of_week\"" + } + }, + "isRLSEnabled": false + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "anonymized_networks_allowed": { + "name": "anonymized_networks_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "groups_slug_uidx": { + "name": "groups_slug_uidx", + "columns": [ + { + "expression": "lower(\"slug\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "groups_one_default_uidx": { + "name": "groups_one_default_uidx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"groups\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_intelligence": { + "name": "ip_intelligence", + "schema": "", + "columns": { + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": true, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "ip_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_response": { + "name": "raw_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_observations": { + "name": "ip_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "minecraft_account_id": { + "name": "minecraft_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ip_observation_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": true + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "ip_classification", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ip_observations_user_observed_idx": { + "name": "ip_observations_user_observed_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ip_observations_account_observed_idx": { + "name": "ip_observations_account_observed_idx", + "columns": [ + { + "expression": "minecraft_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ip_observations_user_id_users_id_fk": { + "name": "ip_observations_user_id_users_id_fk", + "tableFrom": "ip_observations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ip_observations_minecraft_account_id_minecraft_accounts_id_fk": { + "name": "ip_observations_minecraft_account_id_minecraft_accounts_id_fk", + "tableFrom": "ip_observations", + "tableTo": "minecraft_accounts", + "columnsFrom": [ + "minecraft_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_codes": { + "name": "login_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "login_codes_token_hash_uidx": { + "name": "login_codes_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_discord_user_idx": { + "name": "login_codes_discord_user_idx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "login_codes_expires_idx": { + "name": "login_codes_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.minecraft_accounts": { + "name": "minecraft_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "minecraft_uuid": { + "name": "minecraft_uuid", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "validation_status": { + "name": "validation_status", + "type": "minecraft_validation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "minecraft_accounts_user_idx": { + "name": "minecraft_accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_active_uuid_uidx": { + "name": "minecraft_accounts_active_uuid_uidx", + "columns": [ + { + "expression": "minecraft_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"deleted_at\" is null and \"minecraft_accounts\".\"minecraft_uuid\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_active_username_uidx": { + "name": "minecraft_accounts_active_username_uidx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "minecraft_accounts_one_primary_per_user_uidx": { + "name": "minecraft_accounts_one_primary_per_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"minecraft_accounts\".\"is_primary\" = true and \"minecraft_accounts\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "minecraft_accounts_user_id_users_id_fk": { + "name": "minecraft_accounts_user_id_users_id_fk", + "tableFrom": "minecraft_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_credentials": { + "name": "plugin_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_credentials_server_id_uidx": { + "name": "plugin_credentials_server_id_uidx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_requests": { + "name": "plugin_requests", + "schema": "", + "columns": { + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_requests_expires_idx": { + "name": "plugin_requests_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_requests_server_id_plugin_credentials_server_id_fk": { + "name": "plugin_requests_server_id_plugin_credentials_server_id_fk", + "tableFrom": "plugin_requests", + "tableTo": "plugin_credentials", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rcon_servers": { + "name": "rcon_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25575 + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rcon_servers_name_uidx": { + "name": "rcon_servers_name_uidx", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "rcon_servers_port_check": { + "name": "rcon_servers_port_check", + "value": "\"rcon_servers\".\"port\" between 1 and 65535" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_token_hash_uidx": { + "name": "sessions_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_idx": { + "name": "sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_group_memberships": { + "name": "user_group_memberships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_group_memberships_user_uidx": { + "name": "user_group_memberships_user_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_group_memberships_group_idx": { + "name": "user_group_memberships_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_group_memberships_user_id_users_id_fk": { + "name": "user_group_memberships_user_id_users_id_fk", + "tableFrom": "user_group_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_group_memberships_group_id_groups_id_fk": { + "name": "user_group_memberships_group_id_groups_id_fk", + "tableFrom": "user_group_memberships", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_discord_user_id_uidx": { + "name": "users_discord_user_id_uidx", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ip_classification": { + "name": "ip_classification", + "schema": "public", + "values": [ + "unknown", + "clear", + "vpn", + "proxy", + "hosting", + "tor" + ] + }, + "public.ip_observation_source": { + "name": "ip_observation_source", + "schema": "public", + "values": [ + "web", + "game" + ] + }, + "public.minecraft_validation_status": { + "name": "minecraft_validation_status", + "schema": "public", + "values": [ + "verified", + "user_confirmed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 8481db2..1b28929 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785692345708, "tag": "0005_young_vertigo", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1786151282526, + "tag": "0006_curious_lester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index f2ac2f8..a09bcf4 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -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"),