feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
@@ -61,6 +61,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
stopCommand?: string;
|
||||
configFiles?: unknown[];
|
||||
environmentVars?: unknown[];
|
||||
automationRules?: unknown[];
|
||||
};
|
||||
|
||||
const existing = await app.db.query.games.findFirst({
|
||||
@@ -74,6 +75,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
...body,
|
||||
configFiles: body.configFiles ?? [],
|
||||
environmentVars: body.environmentVars ?? [],
|
||||
automationRules: body.automationRules ?? [],
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CreateGameSchema = {
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -22,6 +23,7 @@ export const UpdateGameSchema = {
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { games } from '@source/database';
|
||||
|
||||
export default async function gameRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
// GET /api/games
|
||||
app.get('/', async () => {
|
||||
const gameList = await app.db
|
||||
.select()
|
||||
.from(games)
|
||||
.orderBy(games.name);
|
||||
|
||||
return { data: gameList };
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nodes } from '@source/database';
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { nodes, scheduledTasks, servers } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||
|
||||
function extractBearerToken(authHeader?: string): string | null {
|
||||
if (!authHeader) return null;
|
||||
@@ -11,7 +12,10 @@ function extractBearerToken(authHeader?: string): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function requireDaemonToken(app: FastifyInstance, request: FastifyRequest): Promise<void> {
|
||||
async function requireDaemonToken(
|
||||
app: FastifyInstance,
|
||||
request: FastifyRequest,
|
||||
): Promise<{ id: string }> {
|
||||
const token = extractBearerToken(
|
||||
typeof request.headers.authorization === 'string'
|
||||
? request.headers.authorization
|
||||
@@ -30,12 +34,44 @@ async function requireDaemonToken(app: FastifyInstance, request: FastifyRequest)
|
||||
if (!node) {
|
||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export default async function internalRoutes(app: FastifyInstance) {
|
||||
app.get('/schedules/due', async (request) => {
|
||||
await requireDaemonToken(app, request);
|
||||
return { tasks: [] };
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const now = new Date();
|
||||
|
||||
const dueTasks = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
serverUuid: servers.uuid,
|
||||
action: scheduledTasks.action,
|
||||
payload: scheduledTasks.payload,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
isActive: scheduledTasks.isActive,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(servers.nodeId, node.id),
|
||||
eq(scheduledTasks.isActive, true),
|
||||
lte(scheduledTasks.nextRunAt, now),
|
||||
));
|
||||
|
||||
return {
|
||||
tasks: dueTasks.map((task) => ({
|
||||
id: task.id,
|
||||
server_uuid: task.serverUuid,
|
||||
action: task.action,
|
||||
payload: task.payload,
|
||||
schedule_type: task.scheduleType,
|
||||
is_active: task.isActive,
|
||||
next_run_at: task.nextRunAt?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post(
|
||||
@@ -48,8 +84,41 @@ export default async function internalRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
await requireDaemonToken(app, request);
|
||||
const node = await requireDaemonToken(app, request);
|
||||
const { taskId } = request.params as { taskId: string };
|
||||
|
||||
const [task] = await app.db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
isActive: scheduledTasks.isActive,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
scheduleData: scheduledTasks.scheduleData,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(and(
|
||||
eq(scheduledTasks.id, taskId),
|
||||
eq(servers.nodeId, node.id),
|
||||
));
|
||||
|
||||
if (!task) {
|
||||
throw AppError.notFound('Scheduled task not found');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nextRunAt = task.isActive
|
||||
? computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
await app.db
|
||||
.update(scheduledTasks)
|
||||
.set({
|
||||
lastRunAt: now,
|
||||
nextRunAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(scheduledTasks.id, taskId));
|
||||
|
||||
return { success: true, taskId };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,6 +5,11 @@ import { nodes, allocations, servers, games } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonGetNodeStats,
|
||||
daemonGetNodeStatus,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
NodeParamSchema,
|
||||
CreateNodeSchema,
|
||||
@@ -155,7 +160,7 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||
// Returns basic stats from DB; real-time stats come from daemon via gRPC
|
||||
// Returns real-time stats from daemon when available, with DB fallback.
|
||||
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
@@ -171,17 +176,54 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||
.where(eq(servers.nodeId, nodeId));
|
||||
|
||||
const totalServers = serverList.length;
|
||||
const activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||
let activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||
let cpuPercent = 0;
|
||||
let memoryUsed = 0;
|
||||
let memoryTotal = node.memoryTotal;
|
||||
let diskUsed = 0;
|
||||
let diskTotal = node.diskTotal;
|
||||
let uptime = 0;
|
||||
|
||||
const daemonNode: DaemonNodeConnection = {
|
||||
fqdn: node.fqdn,
|
||||
grpcPort: node.grpcPort,
|
||||
daemonToken: node.daemonToken,
|
||||
};
|
||||
|
||||
try {
|
||||
const [liveStats, liveStatus] = await Promise.all([
|
||||
daemonGetNodeStats(daemonNode),
|
||||
daemonGetNodeStatus(daemonNode),
|
||||
]);
|
||||
|
||||
cpuPercent = Number.isFinite(liveStats.cpuPercent)
|
||||
? Math.max(0, Math.min(100, liveStats.cpuPercent))
|
||||
: 0;
|
||||
memoryUsed = Math.max(0, liveStats.memoryUsed);
|
||||
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
|
||||
diskUsed = Math.max(0, liveStats.diskUsed);
|
||||
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
|
||||
uptime = Math.max(0, liveStatus.uptimeSeconds);
|
||||
|
||||
if (Number.isFinite(liveStatus.activeServers)) {
|
||||
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{ error, nodeId, orgId },
|
||||
'Failed to fetch live node stats from daemon, returning fallback values',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cpuPercent: 0,
|
||||
memoryUsed: 0,
|
||||
memoryTotal: node.memoryTotal,
|
||||
diskUsed: 0,
|
||||
diskTotal: node.diskTotal,
|
||||
cpuPercent,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
activeServers,
|
||||
totalServers,
|
||||
uptime: 0,
|
||||
uptime,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, backups } from '@source/database';
|
||||
import { servers, backups, nodes } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonCreateBackup,
|
||||
daemonDeleteBackup,
|
||||
daemonRestoreBackup,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -54,10 +60,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
|
||||
const body = request.body as { name: string; isLocked?: boolean };
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
// Create backup record (pending — daemon will update when complete)
|
||||
const [backup] = await app.db
|
||||
@@ -69,12 +72,38 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: Send gRPC CreateBackup to daemon
|
||||
// Daemon will:
|
||||
// 1. tar+gz the server directory
|
||||
// 2. Upload to @source/cdn
|
||||
// 3. Callback to API with cdnPath, sizeBytes, checksum
|
||||
// 4. API updates backup record with completedAt
|
||||
if (!backup) {
|
||||
throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED');
|
||||
}
|
||||
|
||||
let completedBackup = backup;
|
||||
try {
|
||||
const daemonResult = await daemonCreateBackup(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
backup.id,
|
||||
);
|
||||
|
||||
if (!daemonResult.success) {
|
||||
throw new Error('Daemon returned unsuccessful backup response');
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(backups)
|
||||
.set({
|
||||
sizeBytes: daemonResult.sizeBytes,
|
||||
checksum: daemonResult.checksum || null,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(backups.id, backup.id))
|
||||
.returning();
|
||||
|
||||
completedBackup = updated ?? completedBackup;
|
||||
} catch (error) {
|
||||
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
|
||||
await app.db.delete(backups).where(eq(backups.id, backup.id));
|
||||
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
@@ -83,7 +112,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
metadata: { name: body.name },
|
||||
});
|
||||
|
||||
return reply.code(201).send(backup);
|
||||
return reply.code(201).send(completedBackup);
|
||||
});
|
||||
|
||||
// POST /backups/:backupId/restore — restore a backup
|
||||
@@ -95,10 +124,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
};
|
||||
await requirePermission(request, orgId, 'backup.restore');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
const backup = await app.db.query.backups.findFirst({
|
||||
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
||||
@@ -106,12 +132,20 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
|
||||
|
||||
// TODO: Send gRPC RestoreBackup to daemon
|
||||
// Daemon will:
|
||||
// 1. Stop the server
|
||||
// 2. Download backup from @source/cdn
|
||||
// 3. Extract tar.gz over server directory
|
||||
// 4. Start the server
|
||||
try {
|
||||
await daemonRestoreBackup(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
backup.id,
|
||||
backup.cdnPath,
|
||||
);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to restore backup on daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
@@ -161,7 +195,17 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
if (!backup) throw AppError.notFound('Backup not found');
|
||||
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
|
||||
|
||||
// TODO: Send gRPC DeleteBackup to daemon to remove from CDN
|
||||
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||
|
||||
try {
|
||||
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
||||
} catch (error) {
|
||||
request.log.error(
|
||||
{ error, serverId, backupId },
|
||||
'Failed to delete backup on daemon',
|
||||
);
|
||||
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
||||
}
|
||||
|
||||
await app.db.delete(backups).where(eq(backups.id, backupId));
|
||||
|
||||
@@ -175,3 +219,33 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async function getServerBackupContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<{ serverUuid: string; node: DaemonNodeConnection }> {
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
serverUuid: 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.serverUuid,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,21 +111,12 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
|
||||
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) {
|
||||
const allowedKeys = new Set(configFile.editableKeys);
|
||||
const invalidKeys = entries.filter((e) => !allowedKeys.has(e.key));
|
||||
if (invalidKeys.length > 0) {
|
||||
throw AppError.badRequest(
|
||||
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let originalContent: string | undefined;
|
||||
let originalEntries: { key: string; value: string }[] = [];
|
||||
try {
|
||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
originalContent = current.data.toString('utf8');
|
||||
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||
} catch (error) {
|
||||
if (!isMissingConfigFileError(error)) {
|
||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
||||
@@ -133,6 +124,22 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// If editableKeys is set, allow:
|
||||
// 1) explicitly editable keys
|
||||
// 2) keys that already exist in the current file
|
||||
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
|
||||
const allowedKeys = new Set(configFile.editableKeys);
|
||||
const existingKeys = new Set(originalEntries.map((entry) => entry.key));
|
||||
const invalidKeys = entries.filter(
|
||||
(entry) => !allowedKeys.has(entry.key) && !existingKeys.has(entry.key),
|
||||
);
|
||||
if (invalidKeys.length > 0) {
|
||||
throw AppError.badRequest(
|
||||
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const content = serializeConfig(
|
||||
entries,
|
||||
configFile.parser as ConfigParser,
|
||||
|
||||
@@ -19,6 +19,17 @@ const FileParamSchema = {
|
||||
}),
|
||||
};
|
||||
|
||||
function decodeBase64Payload(data: string): Buffer {
|
||||
const normalized = data.trim();
|
||||
if (!normalized) return Buffer.alloc(0);
|
||||
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
||||
throw AppError.badRequest('Invalid base64 payload');
|
||||
}
|
||||
|
||||
return Buffer.from(normalized, 'base64');
|
||||
}
|
||||
|
||||
export default async function fileRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
@@ -56,40 +67,61 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
...FileParamSchema,
|
||||
querystring: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path } = request.query as { path: string };
|
||||
const { path, encoding } = request.query as {
|
||||
path: string;
|
||||
encoding?: 'utf8' | 'base64';
|
||||
};
|
||||
|
||||
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') };
|
||||
const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
|
||||
|
||||
return {
|
||||
data:
|
||||
requestedEncoding === 'base64'
|
||||
? content.data.toString('base64')
|
||||
: content.data.toString('utf8'),
|
||||
encoding: requestedEncoding,
|
||||
mimeType: content.mimeType,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/write',
|
||||
{
|
||||
bodyLimit: 128 * 1024 * 1024,
|
||||
schema: {
|
||||
...FileParamSchema,
|
||||
body: Type.Object({
|
||||
path: Type.String({ minLength: 1 }),
|
||||
data: Type.String(),
|
||||
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { path, data } = request.body as { path: string; data: string };
|
||||
const { path, data, encoding } = request.body as {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding?: 'utf8' | 'base64';
|
||||
};
|
||||
|
||||
await requirePermission(request, orgId, 'files.write');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, data);
|
||||
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||
|
||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||
return { success: true, path };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
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 type { GameAutomationRule, PowerAction, ServerAutomationEvent } 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 { runServerAutomationEvent } from '../../lib/server-automation.js';
|
||||
import {
|
||||
daemonCreateServer,
|
||||
daemonDeleteServer,
|
||||
daemonGetServerStatus,
|
||||
daemonSetPowerState,
|
||||
type DaemonNodeConnection,
|
||||
type DaemonPortMapping,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
ServerParamSchema,
|
||||
@@ -82,11 +85,27 @@ function buildDaemonEnvironment(
|
||||
return environment;
|
||||
}
|
||||
|
||||
function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPort: number): DaemonPortMapping[] {
|
||||
const slug = gameSlug.toLowerCase();
|
||||
if (slug === 'cs2' || slug === 'csgo') {
|
||||
return [
|
||||
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
|
||||
{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' },
|
||||
];
|
||||
}
|
||||
if (slug === 'minecraft-bedrock') {
|
||||
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' }];
|
||||
}
|
||||
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }];
|
||||
}
|
||||
|
||||
async function syncServerInstallStatus(
|
||||
app: FastifyInstance,
|
||||
node: DaemonNodeConnection,
|
||||
serverId: string,
|
||||
serverUuid: string,
|
||||
gameSlug: string,
|
||||
automationRules: unknown,
|
||||
): Promise<void> {
|
||||
const maxAttempts = 120;
|
||||
const intervalMs = 5_000;
|
||||
@@ -116,6 +135,18 @@ async function syncServerInstallStatus(
|
||||
{ serverId, serverUuid, status: mapped, attempt },
|
||||
'Synchronized install status from daemon',
|
||||
);
|
||||
|
||||
if (mapped === 'running' || mapped === 'stopped') {
|
||||
void runServerAutomationEvent(app, {
|
||||
serverId,
|
||||
serverUuid,
|
||||
gameSlug,
|
||||
event: 'server.install.completed',
|
||||
node,
|
||||
automationRulesRaw: automationRules,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
@@ -267,13 +298,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
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,
|
||||
},
|
||||
],
|
||||
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort),
|
||||
install_plugin_urls: [],
|
||||
};
|
||||
|
||||
@@ -281,6 +306,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
|
||||
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
|
||||
const now = new Date();
|
||||
const automationRules = (game as { automationRules?: GameAutomationRule[] }).automationRules ?? [];
|
||||
|
||||
const [updatedServer] = await app.db
|
||||
.update(servers)
|
||||
@@ -293,7 +319,23 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
.returning();
|
||||
|
||||
if (daemonStatus === 'installing') {
|
||||
void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid);
|
||||
void syncServerInstallStatus(
|
||||
app,
|
||||
nodeConnection,
|
||||
server.id,
|
||||
server.uuid,
|
||||
game.slug,
|
||||
automationRules,
|
||||
);
|
||||
} else if (daemonStatus === 'running' || daemonStatus === 'stopped') {
|
||||
void runServerAutomationEvent(app, {
|
||||
serverId: server.id,
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: game.slug,
|
||||
event: 'server.install.completed',
|
||||
node: nodeConnection,
|
||||
automationRulesRaw: automationRules,
|
||||
});
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
@@ -319,6 +361,83 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/servers/:serverId
|
||||
app.post(
|
||||
'/:serverId/automation/run',
|
||||
{
|
||||
schema: {
|
||||
...ServerParamSchema,
|
||||
body: Type.Object({
|
||||
event: Type.Union([
|
||||
Type.Literal('server.created'),
|
||||
Type.Literal('server.install.completed'),
|
||||
Type.Literal('server.power.started'),
|
||||
Type.Literal('server.power.stopped'),
|
||||
]),
|
||||
force: Type.Optional(Type.Boolean({ default: false })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { event, force } = request.body as {
|
||||
event: ServerAutomationEvent;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
await requirePermission(request, orgId, 'server.update');
|
||||
|
||||
const [server] = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
uuid: servers.uuid,
|
||||
gameSlug: games.slug,
|
||||
automationRules: games.automationRules,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.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 result = await runServerAutomationEvent(app, {
|
||||
serverId: server.id,
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: server.gameSlug,
|
||||
event,
|
||||
force: force ?? false,
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
automationRulesRaw: server.automationRules,
|
||||
});
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'server.automation.run',
|
||||
metadata: {
|
||||
event,
|
||||
force: force ?? false,
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
event,
|
||||
force: force ?? false,
|
||||
result,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/organizations/:orgId/servers/:serverId
|
||||
app.get('/:serverId', { schema: ServerParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
@@ -503,6 +622,32 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
if (action === 'start' || action === 'restart') {
|
||||
const [serverWithGame] = await app.db
|
||||
.select({
|
||||
gameSlug: games.slug,
|
||||
automationRules: games.automationRules,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
if (serverWithGame) {
|
||||
void runServerAutomationEvent(app, {
|
||||
serverId,
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: serverWithGame.gameSlug,
|
||||
event: 'server.power.started',
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
automationRulesRaw: serverWithGame.automationRules,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, plugins, serverPlugins, games } from '@source/database';
|
||||
import { servers, plugins, serverPlugins, games, nodes } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import {
|
||||
daemonDeleteFiles,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
searchSpigetPlugins,
|
||||
getSpigetResource,
|
||||
getSpigetDownloadUrl,
|
||||
} from '../../lib/spiget.js';
|
||||
|
||||
const PLUGIN_DOWNLOAD_TIMEOUT_MS = 45_000;
|
||||
const PLUGIN_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
@@ -18,6 +26,202 @@ const ParamSchema = {
|
||||
}),
|
||||
};
|
||||
|
||||
interface ServerPluginContext {
|
||||
serverId: string;
|
||||
serverUuid: string;
|
||||
gameId: string;
|
||||
gameSlug: string;
|
||||
gameName: string;
|
||||
node: DaemonNodeConnection;
|
||||
}
|
||||
|
||||
interface PluginArtifactInput {
|
||||
id: string;
|
||||
slug: string;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
function pluginInstallDirectory(gameSlug: string): string {
|
||||
const slug = gameSlug.toLowerCase();
|
||||
if (slug === 'cs2' || slug === 'csgo') return '/game/csgo/addons';
|
||||
if (slug === 'rust') return '/oxide/plugins';
|
||||
if (slug === 'minecraft-java') return '/plugins';
|
||||
return '/plugins';
|
||||
}
|
||||
|
||||
function pluginFileExtension(downloadUrl: string): string {
|
||||
try {
|
||||
const pathname = new URL(downloadUrl).pathname;
|
||||
const match = pathname.match(/\.([a-z0-9]{1,8})$/i);
|
||||
if (match) {
|
||||
return `.${match[1]!.toLowerCase()}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore URL parse failures and use default extension below.
|
||||
}
|
||||
return '.jar';
|
||||
}
|
||||
|
||||
function pluginFilePath(gameSlug: string, plugin: PluginArtifactInput): string | null {
|
||||
if (!plugin.downloadUrl) return null;
|
||||
const safeSlug = toSlug(plugin.slug) || 'plugin';
|
||||
const extension = pluginFileExtension(plugin.downloadUrl);
|
||||
const directory = pluginInstallDirectory(gameSlug).replace(/\/+$/, '');
|
||||
return `${directory}/${safeSlug}-${plugin.id.slice(0, 8)}${extension}`;
|
||||
}
|
||||
|
||||
async function getServerPluginContext(
|
||||
app: FastifyInstance,
|
||||
orgId: string,
|
||||
serverId: string,
|
||||
): Promise<ServerPluginContext> {
|
||||
const [row] = await app.db
|
||||
.select({
|
||||
serverId: servers.id,
|
||||
serverUuid: servers.uuid,
|
||||
gameId: servers.gameId,
|
||||
gameSlug: games.slug,
|
||||
gameName: games.name,
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
|
||||
if (!row) {
|
||||
throw AppError.notFound('Server not found');
|
||||
}
|
||||
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
serverUuid: row.serverUuid,
|
||||
gameId: row.gameId,
|
||||
gameSlug: row.gameSlug,
|
||||
gameName: row.gameName,
|
||||
node: {
|
||||
fqdn: row.nodeFqdn,
|
||||
grpcPort: row.nodeGrpcPort,
|
||||
daemonToken: row.nodeDaemonToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getPluginForGame(
|
||||
app: FastifyInstance,
|
||||
pluginId: string,
|
||||
gameId: string,
|
||||
) {
|
||||
const plugin = await app.db.query.plugins.findFirst({
|
||||
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
|
||||
});
|
||||
if (!plugin) {
|
||||
throw AppError.notFound('Plugin not found for this game');
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function downloadPluginArtifact(downloadUrl: string): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PLUGIN_DOWNLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: { 'User-Agent': 'GamePanel/1.0' },
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new AppError(
|
||||
502,
|
||||
`Plugin download failed with HTTP ${res.status}`,
|
||||
'PLUGIN_DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = Number(res.headers.get('content-length') ?? '0');
|
||||
if (contentLength > PLUGIN_DOWNLOAD_MAX_BYTES) {
|
||||
throw new AppError(413, 'Plugin artifact is too large', 'PLUGIN_TOO_LARGE');
|
||||
}
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (body.length === 0) {
|
||||
throw AppError.badRequest('Plugin download returned empty content');
|
||||
}
|
||||
if (body.length > PLUGIN_DOWNLOAD_MAX_BYTES) {
|
||||
throw new AppError(413, 'Plugin artifact is too large', 'PLUGIN_TOO_LARGE');
|
||||
}
|
||||
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
throw new AppError(
|
||||
502,
|
||||
'Unable to download plugin artifact',
|
||||
'PLUGIN_DOWNLOAD_FAILED',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function installPluginForServer(
|
||||
app: FastifyInstance,
|
||||
context: ServerPluginContext,
|
||||
plugin: PluginArtifactInput & { version: string | null },
|
||||
installedVersion: string | null,
|
||||
) {
|
||||
if (!plugin.downloadUrl) {
|
||||
throw AppError.badRequest('Plugin has no download URL configured');
|
||||
}
|
||||
|
||||
const existing = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('Plugin is already installed');
|
||||
}
|
||||
|
||||
const artifact = await downloadPluginArtifact(plugin.downloadUrl);
|
||||
const installPath = pluginFilePath(context.gameSlug, plugin);
|
||||
if (!installPath) {
|
||||
throw AppError.badRequest('Plugin install path could not be determined');
|
||||
}
|
||||
|
||||
await daemonWriteFile(context.node, context.serverUuid, installPath, artifact);
|
||||
|
||||
const [installed] = await app.db
|
||||
.insert(serverPlugins)
|
||||
.values({
|
||||
serverId: context.serverId,
|
||||
pluginId: plugin.id,
|
||||
installedVersion: installedVersion ?? plugin.version ?? null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!installed) {
|
||||
throw new AppError(500, 'Failed to save plugin installation', 'PLUGIN_INSTALL_FAILED');
|
||||
}
|
||||
|
||||
return { installed, installPath };
|
||||
}
|
||||
|
||||
export default async function pluginRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
@@ -25,11 +229,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
await requirePermission(request, orgId, 'plugin.read');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const installed = await app.db
|
||||
.select({
|
||||
@@ -51,6 +251,273 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
return { plugins: installed };
|
||||
});
|
||||
|
||||
// GET /plugins/marketplace — list game-specific marketplace plugins
|
||||
app.get(
|
||||
'/marketplace',
|
||||
{
|
||||
schema: {
|
||||
...ParamSchema,
|
||||
querystring: Type.Object({
|
||||
q: Type.Optional(Type.String({ minLength: 1 })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { q } = request.query as { q?: string };
|
||||
await requirePermission(request, orgId, 'plugin.read');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const catalog = await app.db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
name: plugins.name,
|
||||
slug: plugins.slug,
|
||||
description: plugins.description,
|
||||
source: plugins.source,
|
||||
externalId: plugins.externalId,
|
||||
downloadUrl: plugins.downloadUrl,
|
||||
version: plugins.version,
|
||||
updatedAt: plugins.updatedAt,
|
||||
})
|
||||
.from(plugins)
|
||||
.where(eq(plugins.gameId, context.gameId))
|
||||
.orderBy(plugins.name);
|
||||
|
||||
const installedRows = await app.db
|
||||
.select({
|
||||
installId: serverPlugins.id,
|
||||
pluginId: serverPlugins.pluginId,
|
||||
installedVersion: serverPlugins.installedVersion,
|
||||
isActive: serverPlugins.isActive,
|
||||
installedAt: serverPlugins.installedAt,
|
||||
})
|
||||
.from(serverPlugins)
|
||||
.where(eq(serverPlugins.serverId, context.serverId));
|
||||
|
||||
const installedByPluginId = new Map(
|
||||
installedRows.map((row) => [row.pluginId, row]),
|
||||
);
|
||||
|
||||
const needle = q?.trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? catalog.filter((plugin) => {
|
||||
const name = plugin.name.toLowerCase();
|
||||
const description = (plugin.description ?? '').toLowerCase();
|
||||
return name.includes(needle) || description.includes(needle);
|
||||
})
|
||||
: catalog;
|
||||
|
||||
return {
|
||||
game: {
|
||||
id: context.gameId,
|
||||
slug: context.gameSlug,
|
||||
name: context.gameName,
|
||||
},
|
||||
plugins: filtered.map((plugin) => {
|
||||
const installed = installedByPluginId.get(plugin.id);
|
||||
return {
|
||||
...plugin,
|
||||
isInstalled: Boolean(installed),
|
||||
installId: installed?.installId ?? null,
|
||||
installedVersion: installed?.installedVersion ?? null,
|
||||
isActive: installed?.isActive ?? false,
|
||||
installedAt: installed?.installedAt ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// POST /plugins/marketplace — create a game-specific plugin entry
|
||||
app.post(
|
||||
'/marketplace',
|
||||
{
|
||||
schema: {
|
||||
...ParamSchema,
|
||||
body: Type.Object({
|
||||
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
downloadUrl: Type.String({ format: 'uri' }),
|
||||
version: Type.Optional(Type.String({ maxLength: 100 })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||
const { name, slug, description, downloadUrl, version } = request.body as {
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
downloadUrl: string;
|
||||
version?: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const normalizedSlug = toSlug(slug ?? name);
|
||||
if (!normalizedSlug) {
|
||||
throw AppError.badRequest('Plugin slug is invalid');
|
||||
}
|
||||
|
||||
const existing = await app.db.query.plugins.findFirst({
|
||||
where: and(
|
||||
eq(plugins.gameId, context.gameId),
|
||||
eq(plugins.slug, normalizedSlug),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||
}
|
||||
|
||||
const [created] = await app.db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
gameId: context.gameId,
|
||||
name,
|
||||
slug: normalizedSlug,
|
||||
description: description ?? null,
|
||||
source: 'manual',
|
||||
downloadUrl,
|
||||
version: version ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.marketplace.create',
|
||||
metadata: { pluginId: created?.id, gameId: context.gameId, name },
|
||||
});
|
||||
|
||||
return reply.code(201).send(created);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /plugins/marketplace/:pluginId — update a marketplace plugin entry
|
||||
app.patch(
|
||||
'/marketplace/:pluginId',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
body: Type.Object({
|
||||
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||
description: Type.Optional(Type.String()),
|
||||
downloadUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||
version: Type.Optional(Type.String({ maxLength: 100 })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId, pluginId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
};
|
||||
const body = request.body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
downloadUrl?: string;
|
||||
version?: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
const existing = await getPluginForGame(app, pluginId, context.gameId);
|
||||
|
||||
const nextSlug = body.slug !== undefined
|
||||
? toSlug(body.slug)
|
||||
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
||||
if (!nextSlug) {
|
||||
throw AppError.badRequest('Plugin slug is invalid');
|
||||
}
|
||||
|
||||
const duplicate = await app.db.query.plugins.findFirst({
|
||||
where: and(
|
||||
eq(plugins.gameId, context.gameId),
|
||||
eq(plugins.slug, nextSlug),
|
||||
),
|
||||
});
|
||||
if (duplicate && duplicate.id !== existing.id) {
|
||||
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||
}
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(plugins)
|
||||
.set({
|
||||
name: body.name ?? existing.name,
|
||||
slug: nextSlug,
|
||||
description: body.description ?? existing.description,
|
||||
downloadUrl: body.downloadUrl ?? existing.downloadUrl,
|
||||
version: body.version ?? existing.version,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError(500, 'Failed to update plugin', 'PLUGIN_UPDATE_FAILED');
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.marketplace.update',
|
||||
metadata: { pluginId: existing.id },
|
||||
});
|
||||
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /plugins/marketplace/:pluginId — remove marketplace plugin entry
|
||||
app.delete(
|
||||
'/marketplace/:pluginId',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { orgId, serverId, pluginId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||
|
||||
const installation = await app.db.query.serverPlugins.findFirst({
|
||||
where: eq(serverPlugins.pluginId, plugin.id),
|
||||
});
|
||||
if (installation) {
|
||||
throw AppError.conflict('Plugin is installed on at least one server');
|
||||
}
|
||||
|
||||
await app.db.delete(plugins).where(eq(plugins.id, plugin.id));
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.marketplace.delete',
|
||||
metadata: { pluginId: plugin.id, name: plugin.name },
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
// GET /plugins/search — search Spiget for Minecraft plugins
|
||||
app.get(
|
||||
'/search',
|
||||
@@ -68,18 +535,8 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const { q, page } = request.query as { q: string; page?: number };
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
|
||||
// Verify server exists and is Minecraft
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
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),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
if (game.slug !== 'minecraft-java') {
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
if (context.gameSlug !== 'minecraft-java') {
|
||||
throw AppError.badRequest('Spiget search is only available for Minecraft: Java Edition');
|
||||
}
|
||||
|
||||
@@ -98,6 +555,51 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /plugins/install/:pluginId — install from game marketplace
|
||||
app.post(
|
||||
'/install/:pluginId',
|
||||
{
|
||||
schema: {
|
||||
params: Type.Object({
|
||||
orgId: Type.String({ format: 'uuid' }),
|
||||
serverId: Type.String({ format: 'uuid' }),
|
||||
pluginId: Type.String({ format: 'uuid' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { orgId, serverId, pluginId } = request.params as {
|
||||
orgId: string;
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||
|
||||
const { installed, installPath } = await installPluginForServer(
|
||||
app,
|
||||
context,
|
||||
{
|
||||
id: plugin.id,
|
||||
slug: plugin.slug,
|
||||
downloadUrl: plugin.downloadUrl,
|
||||
version: plugin.version,
|
||||
},
|
||||
plugin.version,
|
||||
);
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.install',
|
||||
metadata: { pluginId: plugin.id, name: plugin.name, source: 'marketplace', installPath },
|
||||
});
|
||||
|
||||
return installed;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /plugins/install/spiget — install a plugin from Spiget
|
||||
app.post(
|
||||
'/install/spiget',
|
||||
@@ -114,24 +616,17 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const { resourceId } = request.body as { resourceId: number };
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
if (context.gameSlug !== 'minecraft-java') {
|
||||
throw AppError.badRequest('Spiget install is only available for Minecraft: Java Edition');
|
||||
}
|
||||
|
||||
const game = await app.db.query.games.findFirst({
|
||||
where: eq(games.id, server.gameId),
|
||||
});
|
||||
if (!game) throw AppError.notFound('Game not found');
|
||||
|
||||
// Fetch resource info from Spiget
|
||||
const resource = await getSpigetResource(resourceId);
|
||||
if (!resource) throw AppError.notFound('Spiget resource not found');
|
||||
|
||||
// Create or find plugin entry
|
||||
let plugin = await app.db.query.plugins.findFirst({
|
||||
where: and(
|
||||
eq(plugins.gameId, game.id),
|
||||
eq(plugins.gameId, context.gameId),
|
||||
eq(plugins.externalId, String(resourceId)),
|
||||
eq(plugins.source, 'spiget'),
|
||||
),
|
||||
@@ -141,12 +636,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
const [created] = await app.db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
gameId: game.id,
|
||||
gameId: context.gameId,
|
||||
name: resource.name,
|
||||
slug: resource.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.slice(0, 200),
|
||||
slug: toSlug(resource.name),
|
||||
description: resource.tag || null,
|
||||
source: 'spiget',
|
||||
externalId: String(resourceId),
|
||||
@@ -157,41 +649,36 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
plugin = created!;
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const existing = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(
|
||||
eq(serverPlugins.serverId, serverId),
|
||||
eq(serverPlugins.pluginId, plugin.id),
|
||||
),
|
||||
});
|
||||
if (existing) throw AppError.conflict('Plugin is already installed');
|
||||
|
||||
// Install
|
||||
const [installed] = await app.db
|
||||
.insert(serverPlugins)
|
||||
.values({
|
||||
serverId,
|
||||
pluginId: plugin.id,
|
||||
installedVersion: resource.version ? String(resource.version.id) : null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// TODO: Send gRPC command to daemon to download the plugin file to /data/plugins/
|
||||
// downloadUrl: getSpigetDownloadUrl(resourceId)
|
||||
const { installed, installPath } = await installPluginForServer(
|
||||
app,
|
||||
context,
|
||||
{
|
||||
id: plugin.id,
|
||||
slug: plugin.slug,
|
||||
downloadUrl: plugin.downloadUrl,
|
||||
version: plugin.version,
|
||||
},
|
||||
resource.version ? String(resource.version.id) : plugin.version,
|
||||
);
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.install',
|
||||
metadata: { name: resource.name, source: 'spiget', resourceId },
|
||||
metadata: {
|
||||
pluginId: plugin.id,
|
||||
name: resource.name,
|
||||
source: 'spiget',
|
||||
resourceId,
|
||||
installPath,
|
||||
},
|
||||
});
|
||||
|
||||
return installed;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /plugins/install/manual — install a plugin manually (upload)
|
||||
// POST /plugins/install/manual — register manually uploaded plugin file
|
||||
app.post(
|
||||
'/install/manual',
|
||||
{
|
||||
@@ -212,21 +699,14 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
version?: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
|
||||
const server = await app.db.query.servers.findFirst({
|
||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
||||
});
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const [plugin] = await app.db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
gameId: server.gameId,
|
||||
gameId: context.gameId,
|
||||
name,
|
||||
slug: name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.slice(0, 200),
|
||||
slug: toSlug(name),
|
||||
source: 'manual',
|
||||
version: version ?? null,
|
||||
})
|
||||
@@ -246,7 +726,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.install',
|
||||
metadata: { name, source: 'manual', fileName },
|
||||
metadata: { pluginId: plugin?.id, name, source: 'manual', fileName },
|
||||
});
|
||||
|
||||
return installed;
|
||||
@@ -272,24 +752,50 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
pluginInstallId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const installed = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(
|
||||
const [installed] = await app.db
|
||||
.select({
|
||||
installId: serverPlugins.id,
|
||||
pluginId: serverPlugins.pluginId,
|
||||
pluginSlug: plugins.slug,
|
||||
pluginDownloadUrl: plugins.downloadUrl,
|
||||
})
|
||||
.from(serverPlugins)
|
||||
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
|
||||
.where(and(
|
||||
eq(serverPlugins.id, pluginInstallId),
|
||||
eq(serverPlugins.serverId, serverId),
|
||||
),
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
));
|
||||
|
||||
if (!installed) {
|
||||
throw AppError.notFound('Plugin installation not found');
|
||||
}
|
||||
|
||||
const uninstallPath = pluginFilePath(context.gameSlug, {
|
||||
id: installed.pluginId,
|
||||
slug: installed.pluginSlug,
|
||||
downloadUrl: installed.pluginDownloadUrl,
|
||||
});
|
||||
if (!installed) throw AppError.notFound('Plugin installation not found');
|
||||
|
||||
if (uninstallPath) {
|
||||
try {
|
||||
await daemonDeleteFiles(context.node, context.serverUuid, [uninstallPath]);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{ error, serverId, pluginInstallId, uninstallPath },
|
||||
'Failed to delete plugin artifact from server filesystem',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId));
|
||||
|
||||
// TODO: Send gRPC to daemon to delete the plugin file from /data/plugins/
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
organizationId: orgId,
|
||||
serverId,
|
||||
action: 'plugin.uninstall',
|
||||
metadata: { pluginInstallId },
|
||||
metadata: { pluginInstallId, pluginId: installed.pluginId },
|
||||
});
|
||||
|
||||
return reply.code(204).send();
|
||||
@@ -315,11 +821,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||
pluginInstallId: string;
|
||||
};
|
||||
await requirePermission(request, orgId, 'plugin.manage');
|
||||
const context = await getServerPluginContext(app, orgId, serverId);
|
||||
|
||||
const installed = await app.db.query.serverPlugins.findFirst({
|
||||
where: and(
|
||||
eq(serverPlugins.id, pluginInstallId),
|
||||
eq(serverPlugins.serverId, serverId),
|
||||
eq(serverPlugins.serverId, context.serverId),
|
||||
),
|
||||
});
|
||||
if (!installed) throw AppError.notFound('Plugin installation not found');
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { servers, scheduledTasks } from '@source/database';
|
||||
import { nodes, servers, scheduledTasks } from '@source/database';
|
||||
import type { PowerAction } from '@source/shared';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||
import {
|
||||
daemonSendCommand,
|
||||
daemonSetPowerState,
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -194,8 +200,18 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
});
|
||||
if (!task) throw AppError.notFound('Scheduled task not found');
|
||||
|
||||
// TODO: Execute task action (send to daemon via gRPC)
|
||||
// For now, just update lastRunAt and nextRunAt
|
||||
if (task.action === 'command') {
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
await daemonSendCommand(serverContext.node, serverContext.serverUuid, task.payload);
|
||||
} else if (task.action === 'power') {
|
||||
const action = task.payload as PowerAction;
|
||||
if (!['start', 'stop', 'restart', 'kill'].includes(action)) {
|
||||
throw AppError.badRequest('Invalid power action in schedule payload');
|
||||
}
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
await daemonSetPowerState(serverContext.node, serverContext.serverUuid, action);
|
||||
}
|
||||
|
||||
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
|
||||
|
||||
await app.db
|
||||
@@ -206,3 +222,32 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||
return { success: true, triggered: task.name };
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user