feat: overhaul server automation, files editor, and CS2 setup workflows

This commit is contained in:
2026-02-26 21:01:00 +00:00
parent 44c439e2f9
commit 2a3ad5e78f
40 changed files with 4675 additions and 468 deletions
+4
View File
@@ -26,10 +26,14 @@
"drizzle-orm": "^0.38.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.0.0",
"tar-stream": "^3.1.7",
"unzipper": "^0.12.3",
"pino-pretty": "^13.0.0",
"socket.io": "^4.8.0"
},
"devDependencies": {
"@types/tar-stream": "^3.1.4",
"@types/unzipper": "^0.10.11",
"dotenv-cli": "^8.0.0",
"tsx": "^4.19.0"
}
+2
View File
@@ -13,6 +13,7 @@ import daemonNodeRoutes from './routes/nodes/daemon.js';
import nodeRoutes from './routes/nodes/index.js';
import serverRoutes from './routes/servers/index.js';
import adminRoutes from './routes/admin/index.js';
import gameRoutes from './routes/games/index.js';
import { AppError } from './lib/errors.js';
const app = Fastify({
@@ -87,6 +88,7 @@ app.get('/api/health', async () => {
await app.register(authRoutes, { prefix: '/api/auth' });
await app.register(organizationRoutes, { prefix: '/api/organizations' });
await app.register(adminRoutes, { prefix: '/api/admin' });
await app.register(gameRoutes, { prefix: '/api/games' });
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
await app.register(internalRoutes, { prefix: '/api/internal' });
+246 -6
View File
@@ -32,6 +32,21 @@ interface DaemonServerResponse {
status: string;
}
interface DaemonNodeStatusRaw {
version: string;
is_healthy: boolean;
uptime_seconds: number;
active_servers: number;
}
interface DaemonNodeStatsRaw {
cpu_percent: number;
memory_used: number;
memory_total: number;
disk_used: number;
disk_total: number;
}
interface DaemonStatusResponse {
uuid: string;
state: string;
@@ -66,6 +81,13 @@ interface DaemonPlayerListRaw {
max_players: number;
}
interface DaemonBackupResponseRaw {
backup_id: string;
size_bytes: number;
checksum: string;
success: boolean;
}
export interface DaemonConsoleOutput {
uuid: string;
line: string;
@@ -95,9 +117,40 @@ export interface DaemonPlayersResponse {
maxPlayers: number;
}
export interface DaemonBackupResponse {
backupId: string;
sizeBytes: number;
checksum: string;
success: boolean;
}
export interface DaemonNodeStatus {
version: string;
isHealthy: boolean;
uptimeSeconds: number;
activeServers: number;
}
export interface DaemonNodeStats {
cpuPercent: number;
memoryUsed: number;
memoryTotal: number;
diskUsed: number;
diskTotal: number;
}
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
interface DaemonServiceClient extends grpc.Client {
getNodeStatus(
request: EmptyResponse,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonNodeStatusRaw>,
): void;
streamNodeStats(
request: EmptyResponse,
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonNodeStatsRaw>;
createServer(
request: DaemonCreateServerRequest,
metadata: grpc.Metadata,
@@ -147,6 +200,21 @@ interface DaemonServiceClient extends grpc.Client {
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
createBackup(
request: { server_uuid: string; backup_id: string; cdn_upload_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonBackupResponseRaw>,
): void;
restoreBackup(
request: { server_uuid: string; backup_id: string; cdn_download_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteBackup(
request: { server_uuid: string; backup_id: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getActivePlayers(
request: { uuid: string },
metadata: grpc.Metadata,
@@ -183,27 +251,34 @@ const POWER_ACTIONS: Record<PowerAction, number> = {
kill: 3,
};
const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024;
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const trimmed = fqdn.trim();
if (!trimmed) throw new Error('Node FQDN is empty');
let host = trimmed;
if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed);
const host = parsed.hostname || parsed.host;
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(/\/.*$/, '');
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`;
const innerHost = withoutPath
.replace(/^\[/, '')
.replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
const hostOnly = withoutPath.replace(/:\d+$/, '');
return `${hostOnly}:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath;
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
return `${withoutPath}:${grpcPort}`;
}
@@ -219,6 +294,10 @@ function createClient(node: DaemonNodeConnection): DaemonServiceClient {
return new DaemonService(
target,
grpc.credentials.createInsecure(),
{
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
},
) as unknown as DaemonServiceClient;
}
@@ -262,6 +341,46 @@ function callUnary<TResponse>(
});
}
function readFirstStreamMessage<TMessage>(
stream: grpc.ClientReadableStream<TMessage>,
timeoutMs: number,
): Promise<TMessage> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC stream timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onData = (message: TMessage) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
resolve(message);
};
const onError = (error: Error) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(error);
};
const onEnd = () => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(new Error('gRPC stream ended before first message'));
};
stream.on('data', onData);
stream.on('error', onError);
stream.on('end', onEnd);
});
}
function toBuffer(data: Uint8Array | Buffer): Buffer {
if (Buffer.isBuffer(data)) return data;
return Buffer.from(data);
@@ -270,6 +389,49 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
export async function daemonGetNodeStatus(
node: DaemonNodeConnection,
): Promise<DaemonNodeStatus> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonNodeStatusRaw>(
(callback) => client.getNodeStatus({}, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
version: response.version,
isHealthy: response.is_healthy,
uptimeSeconds: Number(response.uptime_seconds),
activeServers: Number(response.active_servers),
};
} finally {
client.close();
}
}
export async function daemonGetNodeStats(
node: DaemonNodeConnection,
): Promise<DaemonNodeStats> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamNodeStats({}, getMetadata(node.daemonToken));
const response = await readFirstStreamMessage(stream, DEFAULT_RPC_TIMEOUT_MS);
return {
cpuPercent: Number(response.cpu_percent),
memoryUsed: Number(response.memory_used),
memoryTotal: Number(response.memory_total),
diskUsed: Number(response.disk_used),
diskTotal: Number(response.disk_total),
};
} finally {
client.close();
}
}
export async function daemonCreateServer(
node: DaemonNodeConnection,
request: DaemonCreateServerRequest,
@@ -468,6 +630,84 @@ export async function daemonDeleteFiles(
}
}
export async function daemonCreateBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<DaemonBackupResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonBackupResponseRaw>(
(callback) =>
client.createBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
backupId: response.backup_id,
sizeBytes: Number(response.size_bytes),
checksum: response.checksum,
success: response.success,
};
} finally {
client.close();
}
}
export async function daemonRestoreBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
cdnPath?: string | null,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.restoreBackup(
{
server_uuid: serverUuid,
backup_id: backupId,
cdn_download_url: cdnPath ?? '',
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetActivePlayers(
node: DaemonNodeConnection,
serverUuid: string,
+793
View File
@@ -0,0 +1,793 @@
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import type {
GameAutomationRule,
ServerAutomationEvent,
ServerAutomationAction,
ServerAutomationGitHubReleaseExtractAction,
ServerAutomationHttpDirectoryExtractAction,
} from '@source/shared';
import {
daemonReadFile,
daemonSendCommand,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
cs2: [
{
id: 'cs2-install-latest-metamod',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-metamod',
type: 'http_directory_extract',
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
],
},
{
id: 'cs2-install-latest-counterstrikesharp-runtime',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{
id: 'install-cs2-runtime',
type: 'github_release_extract',
owner: 'roflmuffin',
repo: 'CounterStrikeSharp',
assetNamePatterns: [
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
'^counterstrikesharp-with-runtime.*\\.zip$',
],
destination: '/game/csgo',
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
],
},
],
};
interface ServerAutomationContext {
serverId: string;
serverUuid: string;
gameSlug: string;
event: ServerAutomationEvent;
node: DaemonNodeConnection;
automationRulesRaw: unknown;
force?: boolean;
}
export interface ServerAutomationRunResult {
workflowsMatched: number;
workflowsExecuted: number;
workflowsSkipped: number;
workflowsFailed: number;
actionFailures: number;
failures: ServerAutomationFailure[];
}
interface ExtractedFile {
path: string;
data: Buffer;
}
export interface ServerAutomationFailure {
level: 'action' | 'workflow';
workflowId: string;
actionId?: string;
message: string;
}
interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
size: number;
}
interface GitHubReleaseResponse {
tag_name: string;
assets: GitHubReleaseAsset[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function readWorkflowId(value: unknown): string | null {
if (!isObject(value)) return null;
const id = value.id;
if (typeof id !== 'string' || id.trim() === '') return null;
return id;
}
function normalizeWorkflow(
gameSlug: string,
workflow: GameAutomationRule,
): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id !== 'cs2-install-latest-counterstrikesharp-runtime') return workflow;
const normalizedActions = workflow.actions.map((action) => {
if (action.type !== 'github_release_extract') return action;
if (action.id !== 'install-cs2-runtime') return action;
const destination = (action.destination ?? '').trim();
if (destination !== '' && destination !== '/') return action;
return {
...action,
destination: '/game/csgo',
};
});
return {
...workflow,
actions: normalizedActions,
};
}
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
if (!Array.isArray(raw)) {
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const configured = raw as GameAutomationRule[];
if (defaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
const existingIds = new Set(
raw
.map(readWorkflowId)
.filter((workflowId): workflowId is string => workflowId !== null),
);
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
if (missingDefaults.length === 0) {
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
const segments = normalizePathSegments(path);
const stripped = segments.slice(Math.max(0, stripComponents));
if (stripped.length === 0) return null;
return stripped.join('/');
}
async function hasMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
): Promise<boolean> {
try {
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
return true;
} catch (error) {
if (isMissingFileError(error)) return false;
throw error;
}
}
async function writeMarker(
node: DaemonNodeConnection,
serverUuid: string,
event: ServerAutomationEvent,
workflowId: string,
payload: Record<string, unknown>,
): Promise<void> {
await daemonWriteFile(
node,
serverUuid,
markerPath(event, workflowId),
JSON.stringify(payload, null, 2),
);
}
function githubHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'SourceGamePanel/1.0',
};
const token = process.env.GITHUB_TOKEN?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function compileAssetPatterns(patterns: string[]): RegExp[] {
const compiled: RegExp[] = [];
const seen = new Set<string>();
const tryCompile = (pattern: string) => {
const key = pattern.trim();
if (!key || seen.has(key)) return;
try {
compiled.push(new RegExp(key, 'i'));
seen.add(key);
} catch {
// Ignore invalid regex patterns in configuration.
}
};
for (const pattern of patterns) {
tryCompile(pattern);
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
// Collapse double backslashes once and compile a fallback variant.
if (pattern.includes('\\\\')) {
tryCompile(pattern.replace(/\\\\/g, '\\'));
}
}
return compiled;
}
async function fetchLatestRelease(
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<GitHubReleaseResponse> {
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
const response = await fetch(releaseUrl, {
headers: githubHeaders(),
});
if (!response.ok) {
throw new Error(
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
);
}
const release = (await response.json()) as GitHubReleaseResponse;
if (!Array.isArray(release.assets)) {
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
}
return release;
}
interface DirectoryAssetCandidate {
name: string;
downloadUrl: string;
}
function extractNumberParts(value: string): number[] {
const matches = value.match(/\d+/g);
if (!matches) return [];
return matches
.map((part) => Number.parseInt(part, 10))
.filter((num) => Number.isFinite(num));
}
function compareNumberPartsDesc(a: number[], b: number[]): number {
const maxLength = Math.max(a.length, b.length);
for (let i = 0; i < maxLength; i += 1) {
const left = a[i] ?? -1;
const right = b[i] ?? -1;
if (left !== right) {
return right - left;
}
}
return 0;
}
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
const sorted = [...candidates].sort((left, right) => {
const numberDiff = compareNumberPartsDesc(
extractNumberParts(left.name),
extractNumberParts(right.name),
);
if (numberDiff !== 0) return numberDiff;
return right.name.localeCompare(left.name);
});
return sorted[0] ?? candidates[0]!;
}
function extractDirectoryCandidates(
html: string,
indexUrl: string,
assetPattern: RegExp,
): DirectoryAssetCandidate[] {
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
const candidates: DirectoryAssetCandidate[] = [];
let match: RegExpExecArray | null = null;
while ((match = hrefRegex.exec(html)) !== null) {
const href = (match[2] ?? '').trim();
if (!href || href.endsWith('/')) continue;
try {
const resolvedUrl = new URL(href, indexUrl);
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
if (!filename || !assetPattern.test(filename)) continue;
candidates.push({
name: filename,
downloadUrl: resolvedUrl.toString(),
});
} catch {
// Ignore malformed links.
}
}
return candidates;
}
async function resolveLatestDirectoryAsset(
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<DirectoryAssetCandidate> {
let assetPattern: RegExp;
try {
assetPattern = new RegExp(action.assetNamePattern, 'i');
} catch {
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
}
const response = await fetch(action.indexUrl, {
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
});
if (!response.ok) {
throw new Error(
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
);
}
const html = await response.text();
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
if (candidates.length === 0) {
throw new Error(
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
);
}
return pickLatestDirectoryAsset(candidates);
}
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
},
redirect: 'follow',
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error('Downloaded artifact is empty');
}
if (buffer.length > maxBytes) {
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
}
return buffer;
} finally {
clearTimeout(timeout);
}
}
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path, stripComponents);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name, stripComponents);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArtifactFiles(
artifact: Buffer,
assetName: string,
stripComponents = 0,
): Promise<ExtractedFile[]> {
const name = assetName.toLowerCase();
if (name.endsWith('.zip')) {
return extractZipFiles(artifact, stripComponents);
}
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(artifact), stripComponents);
}
if (name.endsWith('.tar')) {
return extractTarFiles(artifact, stripComponents);
}
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
return [{ path: normalized, data: artifact }];
}
async function executeGitHubReleaseExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationGitHubReleaseExtractAction,
): Promise<void> {
const release = await fetchLatestRelease(action);
const patterns = compileAssetPatterns(action.assetNamePatterns);
if (patterns.length === 0) {
throw new Error(`No valid asset regex pattern for action ${action.id}`);
}
const asset = release.assets.find((candidate) =>
patterns.some((pattern) => pattern.test(candidate.name)),
);
if (!asset) {
throw new Error(
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
);
}
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
const files = await extractArtifactFiles(
artifact,
asset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${asset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
release: release.tag_name,
asset: asset.name,
filesWritten: files.length,
},
'Automation action completed: github_release_extract',
);
}
async function executeHttpDirectoryExtract(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<void> {
const selectedAsset = await resolveLatestDirectoryAsset(action);
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
const files = await extractArtifactFiles(
artifact,
selectedAsset.name,
Number(action.stripComponents) || 0,
);
if (files.length === 0) {
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
}
const destination = action.destination ?? '/';
for (const file of files) {
const targetPath = joinServerPath(destination, file.path);
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
actionId: action.id,
source: action.indexUrl,
asset: selectedAsset.name,
filesWritten: files.length,
},
'Automation action completed: http_directory_extract',
);
}
async function executeAction(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationAction,
): Promise<void> {
switch (action.type) {
case 'github_release_extract': {
await executeGitHubReleaseExtract(app, context, action);
return;
}
case 'http_directory_extract': {
await executeHttpDirectoryExtract(app, context, action);
return;
}
case 'write_file': {
const payload =
action.encoding === 'base64'
? Buffer.from(action.data, 'base64')
: action.data;
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: write_file',
);
return;
}
case 'send_command': {
await daemonSendCommand(context.node, context.serverUuid, action.command);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
command: action.command,
},
'Automation action completed: send_command',
);
return;
}
default: {
const unknownAction = action as { type?: unknown };
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
}
}
}
export async function runServerAutomationEvent(
app: FastifyInstance,
context: ServerAutomationContext,
): Promise<ServerAutomationRunResult> {
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
.filter((rule) => isObject(rule))
.filter((rule) => rule.event === context.event)
.filter((rule) => rule.enabled !== false)
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
const result: ServerAutomationRunResult = {
workflowsMatched: workflows.length,
workflowsExecuted: 0,
workflowsSkipped: 0,
workflowsFailed: 0,
actionFailures: 0,
failures: [],
};
if (workflows.length === 0) {
return result;
}
for (const workflow of workflows) {
const runOnce = workflow.runOncePerServer !== false;
try {
if (
runOnce &&
!context.force &&
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
) {
result.workflowsSkipped += 1;
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Skipping automation workflow (already completed)',
);
continue;
}
for (const action of workflow.actions) {
try {
await executeAction(app, context, action);
} catch (error) {
const message = errorMessage(error);
result.actionFailures += 1;
result.failures.push({
level: 'action',
workflowId: workflow.id,
actionId: action.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
actionId: action.id,
},
'Automation action failed',
);
if (workflow.continueOnError) {
continue;
}
throw error;
}
}
if (runOnce) {
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
workflowId: workflow.id,
event: context.event,
completedAt: new Date().toISOString(),
});
}
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow completed',
);
result.workflowsExecuted += 1;
} catch (error) {
const message = errorMessage(error);
result.workflowsFailed += 1;
result.failures.push({
level: 'workflow',
workflowId: workflow.id,
message,
});
app.log.error(
{
err: error,
errorMessage: message,
serverId: context.serverId,
serverUuid: context.serverUuid,
gameSlug: context.gameSlug,
event: context.event,
workflowId: workflow.id,
},
'Automation workflow failed',
);
}
}
return result;
}
+2
View File
@@ -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();
+2
View File
@@ -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())),
}),
};
+16
View File
@@ -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 };
});
}
+75 -6
View File
@@ -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 };
},
);
+50 -8
View File
@@ -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,
};
});
+97 -23
View File
@@ -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,
},
};
}
+18 -11
View File
@@ -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,
+36 -4
View File
@@ -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 };
},
);
+154 -9
View File
@@ -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,
+586 -79
View File
@@ -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');
+48 -3
View File
@@ -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,
},
};
}