source-gamepanel/apps/api/src/lib/daemon.ts

735 lines
18 KiB
TypeScript

import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';
import type { PowerAction } from '@source/shared';
import { PROTO_PATH } from '@source/proto';
export interface DaemonNodeConnection {
fqdn: string;
grpcPort: number;
daemonToken: string;
}
export interface DaemonPortMapping {
host_port: number;
container_port: number;
protocol: 'tcp' | 'udp';
}
export interface DaemonCreateServerRequest {
uuid: string;
docker_image: string;
memory_limit: number;
disk_limit: number;
cpu_limit: number;
startup_command: string;
environment: Record<string, string>;
ports: DaemonPortMapping[];
install_plugin_urls: string[];
}
interface DaemonServerResponse {
uuid: string;
status: string;
}
interface 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;
}
interface EmptyResponse {
[key: string]: never;
}
interface DaemonFileListResponseRaw {
files: {
name: string;
path: string;
is_directory: boolean;
size: number;
modified_at: number;
mime_type: string;
}[];
}
interface DaemonFileContentRaw {
data: Uint8Array | Buffer;
mime_type: string;
}
interface DaemonPlayerListRaw {
players: {
name: string;
uuid: string;
connected_at: number;
}[];
max_players: number;
}
interface DaemonBackupResponseRaw {
backup_id: string;
size_bytes: number;
checksum: string;
success: boolean;
}
export interface DaemonConsoleOutput {
uuid: string;
line: string;
timestamp: number;
}
export interface DaemonConsoleStreamHandle {
stream: grpc.ClientReadableStream<DaemonConsoleOutput>;
close: () => void;
}
export interface DaemonFileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
mimeType: string;
}
export interface DaemonPlayersResponse {
players: Array<{
name: string;
id: string;
connectedAt: number;
}>;
maxPlayers: number;
}
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,
callback: UnaryCallback<DaemonServerResponse>,
): void;
deleteServer(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
setPowerState(
request: { uuid: string; action: number },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getServerStatus(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonStatusResponse>,
): void;
streamConsole(
request: { uuid: string },
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonConsoleOutput>;
sendCommand(
request: { uuid: string; command: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
listFiles(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileListResponseRaw>,
): void;
readFile(
request: { uuid: string; path: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonFileContentRaw>,
): void;
writeFile(
request: { uuid: string; path: string; data: Uint8Array | Buffer },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteFiles(
request: { uuid: string; paths: string[] },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
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,
callback: UnaryCallback<DaemonPlayerListRaw>,
): void;
}
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: Number,
enums: Number,
defaults: true,
oneofs: true,
});
const loaded = grpc.loadPackageDefinition(packageDefinition) as {
gamepanel?: {
daemon?: {
DaemonService?: grpc.ServiceClientConstructor;
};
};
};
const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService;
if (!DaemonServiceCtor) {
throw new Error('Failed to load DaemonService gRPC definition');
}
const DaemonService = DaemonServiceCtor;
const POWER_ACTIONS: Record<PowerAction, number> = {
start: 0,
stop: 1,
restart: 2,
kill: 3,
};
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);
host = parsed.hostname || parsed.host;
if (!host) throw new Error('Node FQDN has no hostname');
} catch {
// Fall through to raw handling below.
}
}
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
const innerHost = withoutPath
.replace(/^\[/, '')
.replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
const hostOnly = withoutPath.replace(/:\d+$/, '');
return `${hostOnly}:${grpcPort}`;
}
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
return `${withoutPath}:${grpcPort}`;
}
function getMetadata(daemonToken: string): grpc.Metadata {
const metadata = new grpc.Metadata();
metadata.set('authorization', `Bearer ${daemonToken}`);
return metadata;
}
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
return new DaemonService(
target,
grpc.credentials.createInsecure(),
{
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
},
) as unknown as DaemonServiceClient;
}
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
client.waitForReady(Date.now() + timeoutMs, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
function callUnary<TResponse>(
invoke: (callback: UnaryCallback<TResponse>) => void,
timeoutMs: number,
): Promise<TResponse> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC request timed out after ${timeoutMs}ms`));
}, timeoutMs);
invoke((error, response) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
if (error) {
reject(error);
return;
}
resolve(response);
});
});
}
function 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);
}
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,
): Promise<DaemonServerResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonServerResponse>(
(callback) => client.createServer(request, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteServer(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonSetPowerState(
node: DaemonNodeConnection,
serverUuid: string,
action: PowerAction,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetServerStatus(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonStatusResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonOpenConsoleStream(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<DaemonConsoleStreamHandle> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken));
const close = () => {
try {
stream.cancel();
} catch {
// no-op
}
client.close();
};
stream.on('end', () => client.close());
stream.on('error', () => client.close());
return { stream, close };
} catch (error) {
client.close();
throw error;
}
}
export async function daemonSendCommand(
node: DaemonNodeConnection,
serverUuid: string,
command: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonListFiles(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<DaemonFileEntry[]> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileListResponseRaw>(
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return response.files.map((file) => ({
name: file.name,
path: file.path,
isDirectory: file.is_directory,
size: Number(file.size),
modifiedAt: Number(file.modified_at),
mimeType: file.mime_type,
}));
} finally {
client.close();
}
}
export async function daemonReadFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
): Promise<{ data: Buffer; mimeType: string }> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileContentRaw>(
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
data: toBuffer(response.data),
mimeType: response.mime_type,
};
} finally {
client.close();
}
}
export async function daemonWriteFile(
node: DaemonNodeConnection,
serverUuid: string,
path: string,
data: string | Buffer,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.writeFile(
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteFiles(
node: DaemonNodeConnection,
serverUuid: string,
paths: string[],
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function 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,
): Promise<DaemonPlayersResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonPlayerListRaw>(
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
players: response.players.map((player) => ({
name: player.name,
id: player.uuid,
connectedAt: Number(player.connected_at),
})),
maxPlayers: Number(response.max_players),
};
} finally {
client.close();
}
}