feat: wire daemon console/files/config/players and improve runtime fallbacks

This commit is contained in:
2026-02-22 12:09:07 +00:00
parent 614d25c189
commit 44c439e2f9
12 changed files with 1793 additions and 96 deletions
+3
View File
@@ -10,6 +10,8 @@
"lint": "eslint src/"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.0",
"@grpc/proto-loader": "^0.8.0",
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^13.0.2",
@@ -18,6 +20,7 @@
"@fastify/websocket": "^11.0.0",
"@sinclair/typebox": "^0.34.0",
"@source/database": "workspace:*",
"@source/proto": "workspace:*",
"@source/shared": "workspace:*",
"argon2": "^0.41.0",
"drizzle-orm": "^0.38.0",
+2
View File
@@ -5,6 +5,7 @@ import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import dbPlugin from './plugins/db.js';
import authPlugin from './plugins/auth.js';
import socketPlugin from './plugins/socket.js';
import authRoutes from './routes/auth/index.js';
import organizationRoutes from './routes/organizations/index.js';
import internalRoutes from './routes/internal/index.js';
@@ -41,6 +42,7 @@ await app.register(rateLimit, {
await app.register(cookie);
await app.register(dbPlugin);
await app.register(authPlugin);
await app.register(socketPlugin);
// Error handler
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
+494
View File
@@ -0,0 +1,494 @@
import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';
import type { PowerAction } from '@source/shared';
import { PROTO_PATH } from '@source/proto';
export interface DaemonNodeConnection {
fqdn: string;
grpcPort: number;
daemonToken: string;
}
export interface DaemonPortMapping {
host_port: number;
container_port: number;
protocol: 'tcp' | 'udp';
}
export interface DaemonCreateServerRequest {
uuid: string;
docker_image: string;
memory_limit: number;
disk_limit: number;
cpu_limit: number;
startup_command: string;
environment: Record<string, string>;
ports: DaemonPortMapping[];
install_plugin_urls: string[];
}
interface DaemonServerResponse {
uuid: string;
status: string;
}
interface DaemonStatusResponse {
uuid: string;
state: string;
}
interface EmptyResponse {
[key: string]: never;
}
interface DaemonFileListResponseRaw {
files: {
name: string;
path: string;
is_directory: boolean;
size: number;
modified_at: number;
mime_type: string;
}[];
}
interface DaemonFileContentRaw {
data: Uint8Array | Buffer;
mime_type: string;
}
interface DaemonPlayerListRaw {
players: {
name: string;
uuid: string;
connected_at: number;
}[];
max_players: number;
}
export interface DaemonConsoleOutput {
uuid: string;
line: string;
timestamp: number;
}
export interface DaemonConsoleStreamHandle {
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
close: () => void;
}
export interface DaemonFileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
mimeType: string;
}
export interface DaemonPlayersResponse {
players: Array<{
name: string;
id: string;
connectedAt: number;
}>;
maxPlayers: number;
}
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
interface DaemonServiceClient extends grpc.Client {
createServer(
request: DaemonCreateServerRequest,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonServerResponse>,
): void;
deleteServer(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
setPowerState(
request: { uuid: string; action: number },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getServerStatus(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonStatusResponse>,
): void;
streamConsole(
request: { uuid: string },
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonConsoleOutput>;
sendCommand(
request: { uuid: string; command: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
listFiles(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileListResponseRaw>,
): void;
readFile(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileContentRaw>,
): void;
writeFile(
request: { uuid: string; path: string; data: Uint8Array | Buffer },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteFiles(
request: { uuid: string; paths: string[] },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getActivePlayers(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonPlayerListRaw>,
): void;
}
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: Number,
enums: Number,
defaults: true,
oneofs: true,
});
const loaded = grpc.loadPackageDefinition(packageDefinition) as {
gamepanel?: {
daemon?: {
DaemonService?: grpc.ServiceClientConstructor;
};
};
};
const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService;
if (!DaemonServiceCtor) {
throw new Error('Failed to load DaemonService gRPC definition');
}
const DaemonService = DaemonServiceCtor;
const POWER_ACTIONS: Record<PowerAction, number> = {
start: 0,
stop: 1,
restart: 2,
kill: 3,
};
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const trimmed = fqdn.trim();
if (!trimmed) throw new Error('Node FQDN is empty');
if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed);
const host = parsed.hostname || parsed.host;
if (!host) throw new Error('Node FQDN has no hostname');
if (parsed.port) return `${host}:${parsed.port}`;
return `${host}:${grpcPort}`;
} catch {
// Fall through to raw handling below.
}
}
const withoutPath = trimmed.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath;
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
return `${withoutPath}:${grpcPort}`;
}
function getMetadata(daemonToken: string): grpc.Metadata {
const metadata = new grpc.Metadata();
metadata.set('authorization', `Bearer ${daemonToken}`);
return metadata;
}
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
return new DaemonService(
target,
grpc.credentials.createInsecure(),
) as unknown as DaemonServiceClient;
}
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
client.waitForReady(Date.now() + timeoutMs, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function callUnary<TResponse>(
invoke: (callback: UnaryCallback<TResponse>) => void,
timeoutMs: number,
): Promise<TResponse> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC request timed out after ${timeoutMs}ms`));
}, timeoutMs);
invoke((error, response) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
if (error) {
reject(error);
return;
}
resolve(response);
});
});
}
function toBuffer(data: Uint8Array | Buffer): Buffer {
if (Buffer.isBuffer(data)) return data;
return Buffer.from(data);
}
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
export async function daemonCreateServer(
node: DaemonNodeConnection,
request: DaemonCreateServerRequest,
): Promise<DaemonServerResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonServerResponse>(
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteServer(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonSetPowerState(
node: DaemonNodeConnection,
serverUuid: string,
action: PowerAction,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetServerStatus(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonStatusResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonOpenConsoleStream(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonConsoleStreamHandle> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
const close = () => {
try {
stream.cancel();
} catch {
// no-op
}
client.close();
};
stream.on('end', () => client.close());
stream.on('error', () => client.close());
return { stream, close };
} catch (error) {
client.close();
throw error;
}
}
export async function daemonSendCommand(
node: DaemonNodeConnection,
serverUuid: string,
command: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonListFiles(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<DaemonFileEntry[]> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileListResponseRaw>(
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return response.files.map((file) => ({
name: file.name,
path: file.path,
isDirectory: file.is_directory,
size: Number(file.size),
modifiedAt: Number(file.modified_at),
mimeType: file.mime_type,
}));
} finally {
client.close();
}
}
export async function daemonReadFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<{ data: Buffer; mimeType: string }> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileContentRaw>(
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
data: toBuffer(response.data),
mimeType: response.mime_type,
};
} finally {
client.close();
}
}
export async function daemonWriteFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
data: string | Buffer,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.writeFile(
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteFiles(
node: DaemonNodeConnection,
serverUuid: string,
paths: string[],
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetActivePlayers(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonPlayersResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonPlayerListRaw>(
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
players: response.players.map((player) => ({
name: player.name,
id: player.uuid,
connectedAt: Number(player.connected_at),
})),
maxPlayers: Number(response.max_players),
};
} finally {
client.close();
}
}
+259
View File
@@ -0,0 +1,259 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';
import { and, eq } from 'drizzle-orm';
import { Server as SocketIOServer } from 'socket.io';
import { nodes, organizationMembers, servers } from '@source/database';
import { ROLES } from '@source/shared';
import type { Role } from '@source/shared';
import type { AccessTokenPayload } from '../lib/jwt.js';
import {
daemonOpenConsoleStream,
daemonSendCommand,
type DaemonConsoleStreamHandle,
type DaemonNodeConnection,
} from '../lib/daemon.js';
declare module 'fastify' {
interface FastifyInstance {
io: SocketIOServer;
}
}
type ConsolePermission = 'console.read' | 'console.write';
export default fp(async (app: FastifyInstance) => {
const io = new SocketIOServer(app.server, {
path: '/socket.io',
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
},
});
app.decorate('io', io);
const activeStreams = new Map<string, DaemonConsoleStreamHandle>();
io.use((socket, next) => {
const token = typeof socket.handshake.auth?.token === 'string'
? socket.handshake.auth.token
: null;
if (!token) {
next(new Error('Unauthorized'));
return;
}
const verifier = (app as any).jwt?.verify;
if (typeof verifier !== 'function') {
next(new Error('Authentication is not configured'));
return;
}
try {
const payload = verifier(token) as AccessTokenPayload;
(socket.data as { user?: AccessTokenPayload }).user = payload;
next();
} catch {
next(new Error('Unauthorized'));
}
});
io.on('connection', (socket) => {
const cleanupSocketStream = () => {
const current = activeStreams.get(socket.id);
if (!current) return;
current.close();
activeStreams.delete(socket.id);
};
socket.on('server:console:join', async (payload: unknown) => {
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
? ((payload as { serverId: string }).serverId)
: '';
if (!serverId) {
socket.emit('server:console:output', { line: '[error] Invalid server id' });
return;
}
const user = (socket.data as { user?: AccessTokenPayload }).user;
if (!user) {
socket.emit('server:console:output', { line: '[error] Unauthorized' });
return;
}
const server = await getServerContext(app, serverId);
if (!server) {
socket.emit('server:console:output', { line: '[error] Server not found' });
return;
}
const allowed = await hasConsolePermission(app, user, server.organizationId, 'console.read');
if (!allowed) {
socket.emit('server:console:output', { line: '[error] Missing permission: console.read' });
return;
}
cleanupSocketStream();
try {
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
streamHandle.stream.on('data', (output) => {
socket.emit('server:console:output', { line: output.line });
});
streamHandle.stream.on('end', () => {
activeStreams.delete(socket.id);
socket.emit('server:console:output', { line: '[console] Stream ended' });
});
streamHandle.stream.on('error', (error) => {
activeStreams.delete(socket.id);
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Console stream failed',
);
socket.emit('server:console:output', { line: '[error] Console stream failed' });
});
activeStreams.set(socket.id, streamHandle);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to open console stream',
);
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
}
});
socket.on('server:console:leave', () => {
cleanupSocketStream();
});
socket.on('server:console:command', async (payload: unknown) => {
const body = payload as {
serverId?: unknown;
orgId?: unknown;
command?: unknown;
};
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
const command = typeof body.command === 'string' ? body.command.trim() : '';
if (!serverId || !orgId || !command) {
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
return;
}
const user = (socket.data as { user?: AccessTokenPayload }).user;
if (!user) {
socket.emit('server:console:output', { line: '[error] Unauthorized' });
return;
}
const server = await getServerContext(app, serverId, orgId);
if (!server) {
socket.emit('server:console:output', { line: '[error] Server not found' });
return;
}
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
if (!allowed) {
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
return;
}
try {
await daemonSendCommand(server.node, server.serverUuid, command);
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
'Failed to send console command',
);
socket.emit('server:console:output', { line: '[error] Failed to send command' });
}
});
socket.on('disconnect', () => {
cleanupSocketStream();
});
});
app.addHook('onClose', async () => {
for (const handle of activeStreams.values()) {
handle.close();
}
activeStreams.clear();
await new Promise<void>((resolve) => {
io.close(() => resolve());
});
});
});
async function hasConsolePermission(
app: FastifyInstance,
user: AccessTokenPayload,
orgId: string,
permission: ConsolePermission,
): Promise<boolean> {
if (user.isSuperAdmin) return true;
const member = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.sub),
),
columns: {
role: true,
customPermissions: true,
},
});
if (!member) return false;
const custom = (member.customPermissions ?? {}) as Record<string, boolean>;
if (permission in custom) {
return Boolean(custom[permission]);
}
const rolePerms = ROLES[member.role as Role]?.permissions ?? [];
return (rolePerms as readonly string[]).includes(permission);
}
async function getServerContext(
app: FastifyInstance,
serverId: string,
orgId?: string,
): Promise<{
organizationId: string;
serverUuid: string;
node: DaemonNodeConnection;
} | null> {
const whereClause = orgId
? and(eq(servers.id, serverId), eq(servers.organizationId, orgId))
: eq(servers.id, serverId);
const [row] = await app.db
.select({
organizationId: servers.organizationId,
serverUuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(whereClause);
if (!row) return null;
return {
organizationId: row.organizationId,
serverUuid: row.serverUuid,
node: {
fqdn: row.nodeFqdn,
grpcPort: row.nodeGrpcPort,
daemonToken: row.nodeDaemonToken,
},
};
}
+64 -16
View File
@@ -1,11 +1,12 @@
import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { Type } from '@sinclair/typebox';
import { servers, games } from '@source/database';
import { servers, games, nodes } from '@source/database';
import type { GameConfigFile, ConfigParser } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
@@ -60,16 +61,26 @@ export default async function configRoutes(app: FastifyInstance) {
};
await requirePermission(request, orgId, 'config.read');
const { game, server, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
// TODO: Read file from daemon via gRPC
// For now, return empty parsed result (will be connected in Phase 4 integration)
let raw = '';
try {
const file = await daemonReadFile(node, server.uuid, configFile.path);
raw = file.data.toString('utf8');
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
}
}
const entries = raw ? parseConfig(raw, configFile.parser as ConfigParser) : [];
return {
path: configFile.path,
parser: configFile.parser,
editableKeys: configFile.editableKeys ?? null,
entries: [],
raw: '',
entries,
raw,
};
});
@@ -98,7 +109,7 @@ export default async function configRoutes(app: FastifyInstance) {
const { entries } = request.body as { entries: { key: string; value: string }[] };
await requirePermission(request, orgId, 'config.write');
const { configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
// If editableKeys is set, only allow those keys
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
@@ -111,11 +122,24 @@ export default async function configRoutes(app: FastifyInstance) {
}
}
// Serialize the entries
const content = serializeConfig(entries, configFile.parser as ConfigParser);
let originalContent: string | undefined;
try {
const current = await daemonReadFile(node, server.uuid, configFile.path);
originalContent = current.data.toString('utf8');
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
}
}
// TODO: Write file to daemon via gRPC
// For now, just return success
const content = serializeConfig(
entries,
configFile.parser as ConfigParser,
originalContent,
);
await daemonWriteFile(node, server.uuid, configFile.path, content);
return { success: true, path: configFile.path, content };
},
);
@@ -127,13 +151,22 @@ async function getServerConfig(
serverId: string,
configIndex: number,
) {
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
gameId: servers.gameId,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
const game = await app.db.query.games.findFirst({
where: eq(games.id, server.gameId),
where: eq(games.id, server.gameId as string),
});
if (!game) throw AppError.notFound('Game not found');
@@ -141,5 +174,20 @@ async function getServerConfig(
const configFile = configFiles[configIndex];
if (!configFile) throw AppError.notFound('Config file not found');
return { game, server, configFile };
const node: DaemonNodeConnection = {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
};
return { game, server, node, configFile };
}
function isMissingConfigFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { nodes, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import {
daemonDeleteFiles,
daemonListFiles,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
const FileParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
export default async function fileRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.get(
'/',
{
schema: {
...FileParamSchema,
querystring: Type.Object({
path: Type.Optional(Type.String()),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path } = request.query as { path?: string };
await requirePermission(request, orgId, 'files.read');
const serverContext = await getServerContext(app, orgId, serverId);
const files = await daemonListFiles(
serverContext.node,
serverContext.serverUuid,
path?.trim() || '/',
);
return { files };
},
);
app.get(
'/read',
{
schema: {
...FileParamSchema,
querystring: Type.Object({
path: Type.String({ minLength: 1 }),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path } = request.query as { path: string };
await requirePermission(request, orgId, 'files.read');
const serverContext = await getServerContext(app, orgId, serverId);
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
return { data: content.data.toString('utf8') };
},
);
app.post(
'/write',
{
schema: {
...FileParamSchema,
body: Type.Object({
path: Type.String({ minLength: 1 }),
data: Type.String(),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { path, data } = request.body as { path: string; data: string };
await requirePermission(request, orgId, 'files.write');
const serverContext = await getServerContext(app, orgId, serverId);
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, data);
return { success: true, path };
},
);
app.post(
'/delete',
{
schema: {
...FileParamSchema,
body: Type.Object({
paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
}),
},
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { paths } = request.body as { paths: string[] };
await requirePermission(request, orgId, 'files.delete');
const serverContext = await getServerContext(app, orgId, serverId);
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths);
return { success: true, paths };
},
);
}
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}
+249 -25
View File
@@ -1,12 +1,20 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, count } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { setTimeout as sleep } from 'timers/promises';
import { servers, allocations, nodes, games } from '@source/database';
import type { PowerAction } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonCreateServer,
daemonDeleteServer,
daemonGetServerStatus,
daemonSetPowerState,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
ServerParamSchema,
CreateServerSchema,
@@ -14,16 +22,123 @@ import {
PowerActionSchema,
} from './schemas.js';
import configRoutes from './config.js';
import fileRoutes from './files.js';
import pluginRoutes from './plugins.js';
import playerRoutes from './players.js';
import scheduleRoutes from './schedules.js';
import backupRoutes from './backups.js';
type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error';
function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
switch (rawStatus.toLowerCase()) {
case 'installing':
return 'installing';
case 'running':
return 'running';
case 'stopped':
return 'stopped';
case 'starting':
return 'running';
case 'stopping':
return 'stopped';
case 'error':
return 'error';
default:
return null;
}
}
function buildDaemonEnvironment(
gameEnvVarsRaw: unknown,
overrides: Record<string, string> | undefined,
memoryLimitBytes: number,
): Record<string, string> {
const environment: Record<string, string> = {};
if (Array.isArray(gameEnvVarsRaw)) {
for (const item of gameEnvVarsRaw) {
if (!item || typeof item !== 'object') continue;
const record = item as Record<string, unknown>;
const key = typeof record.key === 'string' ? record.key.trim() : '';
if (!key) continue;
const defaultValue = record.default;
if (defaultValue === undefined || defaultValue === null) continue;
environment[key] = String(defaultValue);
}
}
for (const [key, value] of Object.entries(overrides ?? {})) {
environment[key] = String(value);
}
if ('MEMORY' in environment && !(overrides && 'MEMORY' in overrides)) {
const memoryMiB = Math.max(256, Math.floor(memoryLimitBytes / (1024 * 1024)));
environment.MEMORY = `${memoryMiB}M`;
}
return environment;
}
async function syncServerInstallStatus(
app: FastifyInstance,
node: DaemonNodeConnection,
serverId: string,
serverUuid: string,
): Promise<void> {
const maxAttempts = 120;
const intervalMs = 5_000;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (attempt > 1) {
await sleep(intervalMs);
}
try {
const daemonStatus = await daemonGetServerStatus(node, serverUuid);
const mapped = mapDaemonStatus(daemonStatus.state);
if (!mapped) continue;
if (mapped === 'installing') continue;
const now = new Date();
await app.db
.update(servers)
.set({
status: mapped,
installedAt: mapped === 'running' || mapped === 'stopped' ? now : null,
updatedAt: now,
})
.where(eq(servers.id, serverId));
app.log.info(
{ serverId, serverUuid, status: mapped, attempt },
'Synchronized install status from daemon',
);
return;
} catch (error) {
app.log.warn(
{ error, serverId, serverUuid, attempt },
'Failed to poll daemon server status',
);
}
}
app.log.warn(
{ serverId, serverUuid },
'Timed out while waiting for daemon install completion',
);
}
export default async function serverRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// Register sub-routes
await app.register(configRoutes, { prefix: '/:serverId/config' });
await app.register(fileRoutes, { prefix: '/:serverId/files' });
await app.register(pluginRoutes, { prefix: '/:serverId/plugins' });
await app.register(playerRoutes, { prefix: '/:serverId/players' });
await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' });
await app.register(backupRoutes, { prefix: '/:serverId/backups' });
@@ -128,24 +243,80 @@ export default async function serverRoutes(app: FastifyInstance) {
status: 'installing',
})
.returning();
if (!server) {
throw new AppError(500, 'Failed to create server record', 'SERVER_CREATE_FAILED');
}
// Assign allocation to server
await app.db
.update(allocations)
.set({ serverId: server!.id, isDefault: true })
.set({ serverId: server.id, isDefault: true })
.where(eq(allocations.id, body.allocationId));
// TODO: Send gRPC CreateServer to daemon
// This will be implemented in Phase 4
const nodeConnection: DaemonNodeConnection = {
fqdn: node.fqdn,
grpcPort: node.grpcPort,
daemonToken: node.daemonToken,
};
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId: server!.id,
action: 'server.create',
metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId },
});
const daemonRequest = {
uuid: server.uuid,
docker_image: game.dockerImage,
memory_limit: server.memoryLimit,
disk_limit: server.diskLimit,
cpu_limit: server.cpuLimit,
startup_command: body.startupOverride ?? game.startupCommand,
environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit),
ports: [
{
host_port: allocation.port,
container_port: game.defaultPort,
protocol: 'tcp' as const,
},
],
install_plugin_urls: [],
};
return reply.code(201).send(server);
try {
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
const now = new Date();
const [updatedServer] = await app.db
.update(servers)
.set({
status: daemonStatus,
installedAt: daemonStatus === 'running' || daemonStatus === 'stopped' ? now : null,
updatedAt: now,
})
.where(eq(servers.id, server.id))
.returning();
if (daemonStatus === 'installing') {
void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid);
}
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId: server.id,
action: 'server.create',
metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId },
});
return reply.code(201).send(updatedServer ?? server);
} catch (error) {
app.log.error(
{ error, serverId: server.id, serverUuid: server.uuid, nodeId: node.id },
'Failed to provision server on daemon',
);
await app.db
.update(servers)
.set({ status: 'error', updatedAt: new Date() })
.where(eq(servers.id, server.id));
throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED');
}
});
// GET /api/organizations/:orgId/servers/:serverId
@@ -217,21 +388,43 @@ export default async function serverRoutes(app: FastifyInstance) {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.delete');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
name: servers.name,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
try {
await daemonDeleteServer(
{
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
server.uuid,
);
} catch (error) {
app.log.error(
{ error, serverId: server.id, serverUuid: server.uuid },
'Failed to delete server on daemon',
);
throw new AppError(502, 'Failed to delete server on daemon', 'DAEMON_DELETE_FAILED');
}
// Release allocations
await app.db
.update(allocations)
.set({ serverId: null })
.set({ serverId: null, isDefault: false })
.where(eq(allocations.serverId, serverId));
// TODO: Send gRPC DeleteServer to daemon
await app.db.delete(servers).where(eq(servers.id, serverId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
@@ -239,6 +432,8 @@ export default async function serverRoutes(app: FastifyInstance) {
metadata: { name: server.name, uuid: server.uuid },
});
await app.db.delete(servers).where(eq(servers.id, serverId));
return reply.code(204).send();
});
@@ -256,18 +451,43 @@ export default async function serverRoutes(app: FastifyInstance) {
} as const;
await requirePermission(request, orgId, permMap[action]);
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
status: servers.status,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
if (server.status === 'suspended') {
throw AppError.badRequest('Cannot send power action to a suspended server');
}
// TODO: Send gRPC SetPowerState to daemon
// For now, just update status optimistically
const statusMap: Record<PowerAction, string> = {
try {
await daemonSetPowerState(
{
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
server.uuid,
action,
);
} catch (error) {
app.log.error(
{ error, serverId: server.id, serverUuid: server.uuid, action },
'Failed to send power action to daemon',
);
throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED');
}
const statusMap: Record<PowerAction, MutableServerStatus> = {
start: 'running',
stop: 'stopped',
restart: 'running',
@@ -276,7 +496,11 @@ export default async function serverRoutes(app: FastifyInstance) {
await app.db
.update(servers)
.set({ status: statusMap[action] as any, updatedAt: new Date() })
.set({
status: statusMap[action],
installedAt: statusMap[action] === 'running' ? new Date() : undefined,
updatedAt: new Date(),
})
.where(eq(servers.id, serverId));
await createAuditLog(app.db, request, {
+63
View File
@@ -0,0 +1,63 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { nodes, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js';
const ParamSchema = {
params: Type.Object({
orgId: Type.String({ format: 'uuid' }),
serverId: Type.String({ format: 'uuid' }),
}),
};
export default async function playerRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
app.get('/', { schema: ParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.read');
const serverContext = await getServerContext(app, orgId, serverId);
const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid);
return {
players: players.players.map((player) => ({
name: player.name,
steamid: player.id || undefined,
})),
maxPlayers: players.maxPlayers,
};
});
}
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
throw AppError.notFound('Server not found');
}
return {
serverUuid: server.uuid,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,
daemonToken: server.nodeDaemonToken,
},
};
}