Add panel feature updates across API, daemon, and web

This commit is contained in:
2026-03-02 21:53:54 +00:00
parent 6b463c2b1a
commit afc64b83c1
49 changed files with 7040 additions and 305 deletions
+201
View File
@@ -0,0 +1,201 @@
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
import { AppError } from './errors.js';
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
let cachedClient: CdnClient | null = null;
let cachedFingerprint: string | null = null;
function envValue(name: string): string | null {
const value = process.env[name];
if (!value) return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
const baseUrl = envValue('CDN_BASE_URL');
const apiKey = envValue('CDN_API_KEY');
if (!baseUrl || !apiKey) return null;
return { baseUrl, apiKey };
}
function getArtifactAccessTtlSeconds(): number {
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
return Math.floor(raw);
}
function getOrCreateClient(): CdnClient | null {
const config = getCdnConfig();
if (!config) return null;
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
cachedClient = new CdnClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
timeoutMs: 45_000,
retry: {
retries: 2,
retryDelayMs: 250,
maxRetryDelayMs: 2_000,
},
});
cachedFingerprint = fingerprint;
return cachedClient;
}
function requireClient(): CdnClient {
const client = getOrCreateClient();
if (!client) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return client;
}
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
if (error instanceof AppError) return error;
if (error instanceof CdnError) {
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
}
return new AppError(502, fallbackMessage, fallbackCode);
}
export function getPluginBucketName(): string {
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
}
export async function ensurePrivatePluginBucket(): Promise<string> {
const client = requireClient();
const bucketName = getPluginBucketName();
try {
const bucket = await client.getBucket(bucketName);
if (bucket.isPublic) {
await client.updateBucket(bucketName, { isPublic: false });
}
return bucketName;
} catch (error) {
if (error instanceof CdnError && error.statusCode === 404) {
try {
await client.createBucket(bucketName, {
description: 'GamePanel plugin artifacts',
isPublic: false,
});
return bucketName;
} catch (createError) {
throw toCdnAppError(
createError,
'Failed to create CDN plugin bucket',
'CDN_BUCKET_CREATE_FAILED',
);
}
}
throw toCdnAppError(
error,
'Failed to fetch CDN plugin bucket',
'CDN_BUCKET_READ_FAILED',
);
}
}
export function buildCdnArtifactPointer(fileId: string): string {
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
}
export function parseCdnArtifactPointer(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
return id.length > 0 ? id : null;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
return candidate.length > 0 ? candidate : null;
}
} catch {
return null;
}
return null;
}
export async function uploadPluginArtifact(
content: Uint8Array,
filename: string,
metadata: Record<string, unknown> = {},
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
const client = requireClient();
const bucket = await ensurePrivatePluginBucket();
try {
const file = await client.upload(content, {
bucket,
filename,
metadata,
});
return {
bucket,
file,
artifactPointer: buildCdnArtifactPointer(file.id),
};
} catch (error) {
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
}
}
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
const fileId = parseCdnArtifactPointer(artifactUrl);
if (!fileId) return artifactUrl;
const client = requireClient();
const config = getCdnConfig();
const ttl = getArtifactAccessTtlSeconds();
try {
const access = await client.getFileAccessUrl(fileId, ttl);
if (!access.url || typeof access.url !== 'string') {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
const resolvedUrl = access.url.trim();
if (!resolvedUrl) {
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
}
if (/^https?:\/\//i.test(resolvedUrl)) {
return resolvedUrl;
}
if (!config) {
throw new AppError(
500,
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
'CDN_NOT_CONFIGURED',
);
}
return new URL(resolvedUrl, config.baseUrl).toString();
} catch (error) {
throw toCdnAppError(
error,
'Failed to get temporary CDN access URL',
'CDN_ACCESS_URL_FAILED',
);
}
}
+178
View File
@@ -0,0 +1,178 @@
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
hostname "GamePanel CS2 Server" // Set server hostname
sv_cheats 0 // Enable or disable cheats
sv_hibernate_when_empty 0 // Disable server hibernation
// Passwords
rcon_password "" // Set rcon password
sv_password "" // Set server password
// CSTV
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
tv_chatgroupsize 0 // Set the default chat group size
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
tv_debug 0 // CSTV debug info.
tv_delay 0 // CSTV broadcast delay in seconds
tv_delaymapchange 1 // Delays map change until broadcast is complete
tv_deltacache 2 // Enable delta entity bit stream cache
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
tv_maxclients 10 // Maximum client number on CSTV server.
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
tv_overridemaster 0 // Overrides the CSTV master root address.
tv_port 27020 // Host SourceTV port
tv_password "changeme" // CSTV password for clients
tv_relaypassword "changeme" // CSTV password for relay proxies
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
tv_timeout 60 // CSTV connection timeout in seconds.
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
tv_transmitall 1 // Transmit all entities (not only director view)
// Logs
log on // Turns logging 'on' or 'off', defaults to 'on'
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
`;
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
// CS2 Server Config
// ============================================
// ---- Sunucu Bilgileri ----
hostname "SourceGamePanel CS2 Server"
sv_password ""
rcon_password "changeme"
sv_cheats 0
// ---- Topluluk Sunucu Gorunurlugu ----
sv_region 3
sv_tags "competitive,community"
sv_lan 0
sv_steamgroup ""
sv_steamgroup_exclusive 0
// ---- Performans ----
sv_maxrate 0
sv_minrate 64000
sv_max_queries_sec 5
sv_max_queries_window 30
sv_parallel_sendsnapshot 1
net_maxroutable 1200
// ---- Baglanti ----
sv_maxclients 16
sv_timeout 60
// ---- GOTV (Tamamen Kapali) ----
tv_enable 0
tv_autorecord 0
tv_delay 0
tv_maxclients 0
tv_port 0
// ---- Loglama ----
log on
mp_logmoney 0
mp_logdetail 0
mp_logdetail_items 0
sv_logfile 1
// ---- Genel Oyun Ayarlari ----
mp_autokick 0
sv_allow_votes 0
sv_alltalk 0
sv_deadtalk 1
sv_voiceenable 1
`;
function normalizePath(path: string): string {
const normalized = path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/');
return normalized;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
function normalizeComparableContent(content: string): string {
return content.replace(/\r\n/g, '\n').trim();
}
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
return (
gameSlug.trim().toLowerCase() === 'cs2' &&
normalizePath(path) === CS2_SERVER_CFG_PATH
);
}
export async function readManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<string> {
try {
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
return persisted.data.toString('utf8');
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
try {
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
const content = current.data.toString('utf8');
const nextContent =
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
? DEFAULT_CS2_SERVER_CFG
: content;
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
return nextContent;
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
return DEFAULT_CS2_SERVER_CFG;
}
export async function writeManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
content: string | Buffer,
): Promise<void> {
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
export async function reapplyManagedCs2ServerConfig(
node: DaemonNodeConnection,
serverUuid: string,
): Promise<void> {
const content = await readManagedCs2ServerConfig(node, serverUuid);
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
}
+157 -3
View File
@@ -27,11 +27,31 @@ export interface DaemonCreateServerRequest {
install_plugin_urls: string[];
}
export interface DaemonUpdateServerRequest {
uuid: string;
docker_image: string;
memory_limit: number;
disk_limit: number;
cpu_limit: number;
startup_command: string;
environment: Record<string, string>;
ports: DaemonPortMapping[];
}
interface DaemonServerResponse {
uuid: string;
status: string;
}
interface DaemonManagedDatabaseCredentialsRaw {
database_name: string;
username: string;
password: string;
host: string;
port: number;
phpmyadmin_url: string;
}
interface DaemonNodeStatusRaw {
version: string;
is_healthy: boolean;
@@ -124,6 +144,15 @@ export interface DaemonBackupResponse {
success: boolean;
}
export interface DaemonManagedDatabaseCredentials {
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
export interface DaemonNodeStatus {
version: string;
isHealthy: boolean;
@@ -156,11 +185,31 @@ interface DaemonServiceClient extends grpc.Client {
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonServerResponse>,
): void;
updateServer(
request: DaemonUpdateServerRequest,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonServerResponse>,
): void;
deleteServer(
request: { uuid: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
createDatabase(
request: { server_uuid: string; name: string; password?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
): void;
updateDatabasePassword(
request: { username: string; password: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteDatabase(
request: { database_name: string; username: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
setPowerState(
request: { uuid: string; action: number },
metadata: grpc.Metadata,
@@ -388,6 +437,12 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
const POWER_RPC_TIMEOUT_MS = 45_000;
interface DaemonRequestTimeoutOptions {
connectTimeoutMs?: number;
rpcTimeoutMs?: number;
}
export async function daemonGetNodeStatus(
node: DaemonNodeConnection,
@@ -464,6 +519,104 @@ export async function daemonDeleteServer(
}
}
export async function daemonUpdateServer(
node: DaemonNodeConnection,
request: DaemonUpdateServerRequest,
): Promise<DaemonServerResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonServerResponse>(
(callback) => client.updateServer(request, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonCreateDatabase(
node: DaemonNodeConnection,
request: { serverUuid: string; name: string; password?: string },
): Promise<DaemonManagedDatabaseCredentials> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonManagedDatabaseCredentialsRaw>(
(callback) =>
client.createDatabase(
{
server_uuid: request.serverUuid,
name: request.name,
password: request.password ?? '',
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
databaseName: response.database_name,
username: response.username,
password: response.password,
host: response.host,
port: Number(response.port),
phpMyAdminUrl: response.phpmyadmin_url.trim() ? response.phpmyadmin_url : null,
};
} finally {
client.close();
}
}
export async function daemonUpdateDatabasePassword(
node: DaemonNodeConnection,
request: { username: string; password: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.updateDatabasePassword(
{
username: request.username,
password: request.password,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteDatabase(
node: DaemonNodeConnection,
request: { databaseName: string; username: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteDatabase(
{
database_name: request.databaseName,
username: request.username,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonSetPowerState(
node: DaemonNodeConnection,
serverUuid: string,
@@ -474,7 +627,7 @@ export async function daemonSetPowerState(
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,
POWER_RPC_TIMEOUT_MS,
);
} finally {
client.close();
@@ -484,13 +637,14 @@ export async function daemonSetPowerState(
export async function daemonGetServerStatus(
node: DaemonNodeConnection,
serverUuid: string,
timeouts: DaemonRequestTimeoutOptions = {},
): Promise<DaemonStatusResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
+41 -1
View File
@@ -10,6 +10,7 @@ import type {
ServerAutomationGitHubReleaseExtractAction,
ServerAutomationHttpDirectoryExtractAction,
ServerAutomationInsertBeforeLineAction,
ServerAutomationWriteFileAction,
} from '@source/shared';
import {
daemonReadFile,
@@ -17,6 +18,11 @@ import {
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
import {
CS2_PERSISTED_SERVER_CFG_PATH,
CS2_SERVER_CFG_PATH,
DEFAULT_CS2_SERVER_CFG,
} from './cs2-server-config.js';
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
@@ -26,7 +32,6 @@ const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
id: CS2_GAMEINFO_INSERT_ACTION_ID,
type: 'insert_before_line',
@@ -37,8 +42,33 @@ const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction
skipIfExists: true,
};
const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-default-server-config',
type: 'write_file',
path: `/${CS2_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = {
id: 'write-cs2-persisted-server-config',
type: 'write_file',
path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`,
data: DEFAULT_CS2_SERVER_CFG,
};
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
cs2: [
{
id: 'cs2-write-default-server-config',
event: 'server.install.completed',
enabled: true,
runOncePerServer: true,
continueOnError: false,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
},
{
id: 'cs2-install-latest-metamod',
event: 'server.install.completed',
@@ -147,6 +177,16 @@ function normalizeWorkflow(
): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id === 'cs2-write-default-server-config') {
return {
...workflow,
actions: [
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
],
};
}
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
const normalizedActions = workflow.actions.map((action) => {
if (action.type !== 'github_release_extract') return action;