her sey
This commit is contained in:
@@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] {
|
||||
const entries: ConfigEntry[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Match: key "value" or key value
|
||||
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
|
||||
@@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) {
|
||||
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
+61
-26
@@ -200,6 +200,11 @@ interface DaemonServiceClient extends grpc.Client {
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
|
||||
): void;
|
||||
importDatabaseSql(
|
||||
request: { database_name: string; sql: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
updateDatabasePassword(
|
||||
request: { username: string; password: string },
|
||||
metadata: grpc.Metadata,
|
||||
@@ -319,9 +324,7 @@ function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
||||
|
||||
const withoutPath = host.replace(/\/.*$/, '');
|
||||
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
|
||||
const innerHost = withoutPath
|
||||
.replace(/^\[/, '')
|
||||
.replace(/\](?::\d+)?$/, '');
|
||||
const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, '');
|
||||
return `[${innerHost}]:${grpcPort}`;
|
||||
}
|
||||
if (/^[^:]+:\d+$/.test(withoutPath)) {
|
||||
@@ -340,14 +343,10 @@ function getMetadata(daemonToken: string): grpc.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;
|
||||
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> {
|
||||
@@ -444,9 +443,7 @@ interface DaemonRequestTimeoutOptions {
|
||||
rpcTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function daemonGetNodeStatus(
|
||||
node: DaemonNodeConnection,
|
||||
): Promise<DaemonNodeStatus> {
|
||||
export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise<DaemonNodeStatus> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
@@ -466,9 +463,7 @@ export async function daemonGetNodeStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonGetNodeStats(
|
||||
node: DaemonNodeConnection,
|
||||
): Promise<DaemonNodeStats> {
|
||||
export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise<DaemonNodeStats> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
@@ -511,7 +506,8 @@ export async function daemonDeleteServer(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
@@ -593,6 +589,30 @@ export async function daemonUpdateDatabasePassword(
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonImportDatabaseSql(
|
||||
node: DaemonNodeConnection,
|
||||
request: { databaseName: string; sql: string },
|
||||
): Promise<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.importDatabaseSql(
|
||||
{
|
||||
database_name: request.databaseName,
|
||||
sql: request.sql,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonDeleteDatabase(
|
||||
node: DaemonNodeConnection,
|
||||
request: { databaseName: string; username: string },
|
||||
@@ -626,7 +646,12 @@ export async function daemonSetPowerState(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.setPowerState(
|
||||
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
POWER_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
@@ -643,7 +668,8 @@ export async function daemonGetServerStatus(
|
||||
try {
|
||||
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
@@ -688,7 +714,8 @@ export async function daemonSendCommand(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
@@ -705,7 +732,8 @@ export async function daemonListFiles(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileListResponseRaw>(
|
||||
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
@@ -731,7 +759,8 @@ export async function daemonReadFile(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonFileContentRaw>(
|
||||
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
@@ -756,7 +785,11 @@ export async function daemonWriteFile(
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) =>
|
||||
client.writeFile(
|
||||
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
|
||||
{
|
||||
uuid: serverUuid,
|
||||
path,
|
||||
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
|
||||
},
|
||||
getMetadata(node.daemonToken),
|
||||
callback,
|
||||
),
|
||||
@@ -776,7 +809,8 @@ export async function daemonDeleteFiles(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
@@ -870,7 +904,8 @@ export async function daemonGetActivePlayers(
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonPlayerListRaw>(
|
||||
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
(callback) =>
|
||||
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import * as tar from 'tar-stream';
|
||||
import type { Headers } from 'tar-stream';
|
||||
import * as unzipper from 'unzipper';
|
||||
import { serverDatabases, servers } from '@source/database';
|
||||
import {
|
||||
daemonCreateDatabase,
|
||||
daemonDeleteDatabase,
|
||||
daemonDeleteFiles,
|
||||
daemonImportDatabaseSql,
|
||||
daemonReadFile,
|
||||
daemonWriteFile,
|
||||
type DaemonNodeConnection,
|
||||
} from './daemon.js';
|
||||
|
||||
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
|
||||
const QBCORE_DATABASE_NAME = 'qbcore';
|
||||
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
|
||||
const FIVE_M_INTERNAL_PORT = 30120;
|
||||
const QBCORE_SQL_URL =
|
||||
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
|
||||
const OXMYSQL_ZIP_URL =
|
||||
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
|
||||
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
|
||||
|
||||
interface ExtractedFile {
|
||||
path: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface ManagedServerDatabaseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
databaseName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
phpMyAdminUrl: string | null;
|
||||
}
|
||||
|
||||
interface FivemProvisionContext {
|
||||
node: DaemonNodeConnection;
|
||||
serverDescription?: string | null;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
serverUuid: string;
|
||||
}
|
||||
|
||||
interface GitHubArchiveResource {
|
||||
destination: string;
|
||||
owner: string;
|
||||
ref: string;
|
||||
repo: string;
|
||||
subpath?: string;
|
||||
}
|
||||
|
||||
interface RemoteArchiveResource {
|
||||
collapseTopLevelDirectory?: boolean;
|
||||
destination: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'cfx-server-data',
|
||||
ref: 'master',
|
||||
destination: '/resources/[cfx-default]',
|
||||
subpath: 'resources',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'bob74_ipl',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/bob74_ipl',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'safecracker',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/safecracker',
|
||||
},
|
||||
{
|
||||
owner: 'citizenfx',
|
||||
repo: 'screenshot-basic',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/screenshot-basic',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'progressbar',
|
||||
ref: 'main',
|
||||
destination: '/resources/[standalone]/progressbar',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'interact-sound',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/interact-sound',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'connectqueue',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/connectqueue',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'PolyZone',
|
||||
ref: 'master',
|
||||
destination: '/resources/[standalone]/PolyZone',
|
||||
},
|
||||
{
|
||||
owner: 'AvarianKnight',
|
||||
repo: 'pma-voice',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/pma-voice',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'qb-radio',
|
||||
ref: 'main',
|
||||
destination: '/resources/[voice]/qb-radio',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'hospital_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/hospital_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'dealer_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/dealer_map',
|
||||
},
|
||||
{
|
||||
owner: 'qbcore-framework',
|
||||
repo: 'prison_map',
|
||||
ref: 'main',
|
||||
destination: '/resources/[defaultmaps]/prison_map',
|
||||
},
|
||||
...[
|
||||
'qb-core',
|
||||
'qb-scoreboard',
|
||||
'qb-adminmenu',
|
||||
'qb-multicharacter',
|
||||
'qb-target',
|
||||
'qb-vehiclesales',
|
||||
'qb-vehicleshop',
|
||||
'qb-houserobbery',
|
||||
'qb-prison',
|
||||
'qb-hud',
|
||||
'qb-management',
|
||||
'qb-weed',
|
||||
'qb-lapraces',
|
||||
'qb-inventory',
|
||||
'qb-houses',
|
||||
'qb-garages',
|
||||
'qb-ambulancejob',
|
||||
'qb-radialmenu',
|
||||
'qb-crypto',
|
||||
'qb-weathersync',
|
||||
'qb-policejob',
|
||||
'qb-apartments',
|
||||
'qb-vehiclekeys',
|
||||
'qb-mechanicjob',
|
||||
'qb-phone',
|
||||
'qb-vineyard',
|
||||
'qb-weapons',
|
||||
'qb-scrapyard',
|
||||
'qb-towjob',
|
||||
'qb-streetraces',
|
||||
'qb-storerobbery',
|
||||
'qb-spawn',
|
||||
'qb-smallresources',
|
||||
'qb-recyclejob',
|
||||
'qb-crafting',
|
||||
'qb-diving',
|
||||
'qb-cityhall',
|
||||
'qb-truckrobbery',
|
||||
'qb-pawnshop',
|
||||
'qb-minigames',
|
||||
'qb-taxijob',
|
||||
'qb-busjob',
|
||||
'qb-newsjob',
|
||||
'qb-fuel',
|
||||
'qb-jewelery',
|
||||
'qb-bankrobbery',
|
||||
'qb-banking',
|
||||
'qb-clothing',
|
||||
'qb-hotdogjob',
|
||||
'qb-doorlock',
|
||||
'qb-garbagejob',
|
||||
'qb-drugs',
|
||||
'qb-shops',
|
||||
'qb-interior',
|
||||
'qb-menu',
|
||||
'qb-input',
|
||||
'qb-loading',
|
||||
].map((repo) => ({
|
||||
owner: 'qbcore-framework',
|
||||
repo,
|
||||
ref: 'main',
|
||||
destination: `/resources/[qb]/${repo}`,
|
||||
})),
|
||||
];
|
||||
|
||||
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
|
||||
{
|
||||
url: OXMYSQL_ZIP_URL,
|
||||
destination: '/resources/[standalone]/oxmysql',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
{
|
||||
url: MENUV_ZIP_URL,
|
||||
destination: '/resources/[standalone]/menuv',
|
||||
collapseTopLevelDirectory: true,
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathSegments(path: string): string[] {
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string): string | null {
|
||||
const segments = normalizePathSegments(path);
|
||||
if (segments.length === 0) return null;
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function joinServerPath(base: string, relative: string): string {
|
||||
const baseSegments = normalizePathSegments(base);
|
||||
const relativeSegments = normalizePathSegments(relative);
|
||||
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
|
||||
if (files.length === 0) return files;
|
||||
|
||||
const firstSegments = new Set<string>();
|
||||
for (const file of files) {
|
||||
const [first] = normalizePathSegments(file.path);
|
||||
if (!first) return files;
|
||||
firstSegments.add(first);
|
||||
if (firstSegments.size > 1) {
|
||||
return files;
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
.map((file) => {
|
||||
const segments = normalizePathSegments(file.path).slice(1);
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
path: segments.join('/'),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null);
|
||||
}
|
||||
|
||||
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
|
||||
const prefix = normalizePathSegments(subpath).join('/');
|
||||
if (!prefix) return files;
|
||||
|
||||
const normalizedPrefix = `${prefix}/`;
|
||||
return files
|
||||
.map((file) => {
|
||||
if (file.path === prefix) return null;
|
||||
if (!file.path.startsWith(normalizedPrefix)) return null;
|
||||
return {
|
||||
path: file.path.slice(normalizedPrefix.length),
|
||||
data: file.data,
|
||||
};
|
||||
})
|
||||
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
|
||||
}
|
||||
|
||||
async function downloadBinary(
|
||||
url: string,
|
||||
maxBytes: number,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||
if (contentLength > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error(`Downloaded archive is empty: ${url}`);
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'SourceGamePanel/1.0',
|
||||
...headers,
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Text download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
throw new Error(`Downloaded text is empty: ${url}`);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function extractZipFiles(buffer: Buffer): 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);
|
||||
if (!normalized) continue;
|
||||
|
||||
files.push({
|
||||
path: normalized,
|
||||
data: await entry.buffer(),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractTarFiles(buffer: Buffer): 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);
|
||||
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 extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
|
||||
const normalizedUrl = url.toLowerCase();
|
||||
if (normalizedUrl.endsWith('.zip')) {
|
||||
return extractZipFiles(buffer);
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
|
||||
return extractTarFiles(gunzipSync(buffer));
|
||||
}
|
||||
if (normalizedUrl.endsWith('.tar')) {
|
||||
return extractTarFiles(buffer);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported archive type: ${url}`);
|
||||
}
|
||||
|
||||
async function writeFilesToServer(
|
||||
node: DaemonNodeConnection,
|
||||
serverUuid: string,
|
||||
destination: string,
|
||||
files: ExtractedFile[],
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeCfgValue(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
|
||||
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
|
||||
}
|
||||
|
||||
function renderFivemServerConfig(
|
||||
serverName: string,
|
||||
description: string | null | undefined,
|
||||
database: ManagedServerDatabaseRecord,
|
||||
): string {
|
||||
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
|
||||
const safeProjectDescription = escapeCfgValue(
|
||||
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
|
||||
);
|
||||
const rconPassword = randomBytes(16).toString('hex');
|
||||
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
|
||||
|
||||
return `# Generated by Source GamePanel
|
||||
# QBCore resources and base dependencies are installed automatically.
|
||||
|
||||
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
|
||||
|
||||
sv_maxclients "32"
|
||||
sv_hostname "${safeServerName}"
|
||||
sets sv_projectName "[QBCore] ${safeServerName}"
|
||||
sets sv_projectDesc "${safeProjectDescription}"
|
||||
sets locale "en-US"
|
||||
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
|
||||
set steam_webApiKey "none"
|
||||
set resources_useSystemChat "true"
|
||||
set mysql_connection_string "${mysqlConnectionString}"
|
||||
|
||||
setr qb_locale "en"
|
||||
setr UseTarget "false"
|
||||
setr voice_useNativeAudio "true"
|
||||
setr voice_useSendingRangeOnly "true"
|
||||
setr voice_defaultCycle "GRAVE"
|
||||
setr voice_defaultVolume "0.3"
|
||||
setr voice_enableRadioAnim "1"
|
||||
setr voice_syncData "1"
|
||||
|
||||
sv_scriptHookAllowed "0"
|
||||
sv_endpointprivacy "true"
|
||||
rcon_password "${rconPassword}"
|
||||
|
||||
ensure mapmanager
|
||||
ensure chat
|
||||
ensure spawnmanager
|
||||
ensure sessionmanager
|
||||
ensure basic-gamemode
|
||||
ensure hardcap
|
||||
ensure baseevents
|
||||
|
||||
ensure qb-core
|
||||
ensure [qb]
|
||||
ensure [standalone]
|
||||
ensure [voice]
|
||||
ensure [defaultmaps]
|
||||
|
||||
add_ace group.admin command allow
|
||||
add_ace group.admin command.quit deny
|
||||
add_ace resource.qb-core command allow
|
||||
add_ace qbcore.god command allow
|
||||
add_principal qbcore.god group.admin
|
||||
add_principal qbcore.god qbcore.admin
|
||||
add_principal qbcore.admin qbcore.mod
|
||||
`;
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
export function isFivemQbCoreGame(gameSlug: string): boolean {
|
||||
return gameSlug.trim().toLowerCase() === 'fivem';
|
||||
}
|
||||
|
||||
export async function ensureFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
|
||||
): Promise<ManagedServerDatabaseRecord> {
|
||||
const existing = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(eq(serverDatabases.serverId, context.serverId))
|
||||
.orderBy(asc(serverDatabases.createdAt));
|
||||
|
||||
const preferred =
|
||||
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
|
||||
existing[0];
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const managedDatabase = await daemonCreateDatabase(context.node, {
|
||||
serverUuid: context.serverUuid,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
const [created] = await app.db
|
||||
.insert(serverDatabases)
|
||||
.values({
|
||||
serverId: context.serverId,
|
||||
name: QBCORE_DATABASE_NAME,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
password: managedDatabase.password,
|
||||
host: managedDatabase.host,
|
||||
port: managedDatabase.port,
|
||||
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||
})
|
||||
.returning({
|
||||
id: serverDatabases.id,
|
||||
name: serverDatabases.name,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
password: serverDatabases.password,
|
||||
host: serverDatabases.host,
|
||||
port: serverDatabases.port,
|
||||
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Failed to persist managed database metadata');
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
try {
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: managedDatabase.databaseName,
|
||||
username: managedDatabase.username,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
app.log.error(
|
||||
{
|
||||
cleanupError,
|
||||
databaseName: managedDatabase.databaseName,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Failed to roll back managed MySQL database after metadata save failure',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFivemQbCoreDatabase(
|
||||
app: FastifyInstance,
|
||||
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
|
||||
): Promise<void> {
|
||||
const [database] = await app.db
|
||||
.select({
|
||||
id: serverDatabases.id,
|
||||
databaseName: serverDatabases.databaseName,
|
||||
username: serverDatabases.username,
|
||||
})
|
||||
.from(serverDatabases)
|
||||
.where(
|
||||
and(
|
||||
eq(serverDatabases.serverId, context.serverId),
|
||||
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
|
||||
),
|
||||
);
|
||||
|
||||
if (!database) return;
|
||||
|
||||
await daemonDeleteDatabase(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
username: database.username,
|
||||
});
|
||||
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
|
||||
}
|
||||
|
||||
async function installGitHubResource(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: GitHubArchiveResource,
|
||||
): Promise<void> {
|
||||
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
|
||||
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractTarFiles(gunzipSync(archive));
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
if (resource.subpath) {
|
||||
files = filterFilesBySubpath(files, resource.subpath);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(
|
||||
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
repo: `${resource.owner}/${resource.repo}`,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
},
|
||||
'Installed FiveM GitHub resource',
|
||||
);
|
||||
}
|
||||
|
||||
async function installRemoteArchive(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
resource: RemoteArchiveResource,
|
||||
): Promise<void> {
|
||||
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
|
||||
let files = await extractArchive(archive, resource.url);
|
||||
if (resource.collapseTopLevelDirectory) {
|
||||
files = stripSharedTopLevelDirectory(files);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Remote archive had no files: ${resource.url}`);
|
||||
}
|
||||
|
||||
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
destination: resource.destination,
|
||||
filesWritten: files.length,
|
||||
serverId: context.serverId,
|
||||
serverUuid: context.serverUuid,
|
||||
url: resource.url,
|
||||
},
|
||||
'Installed FiveM remote archive',
|
||||
);
|
||||
}
|
||||
|
||||
export async function provisionFivemQbCoreServer(
|
||||
app: FastifyInstance,
|
||||
context: FivemProvisionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const database = await ensureFivemQbCoreDatabase(app, context);
|
||||
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
|
||||
|
||||
await daemonImportDatabaseSql(context.node, {
|
||||
databaseName: database.databaseName,
|
||||
sql: qbCoreSql,
|
||||
});
|
||||
|
||||
for (const resource of FIVEM_GITHUB_RESOURCES) {
|
||||
await installGitHubResource(app, context, resource);
|
||||
}
|
||||
|
||||
for (const resource of FIVEM_REMOTE_ARCHIVES) {
|
||||
await installRemoteArchive(app, context, resource);
|
||||
}
|
||||
|
||||
try {
|
||||
await daemonDeleteFiles(context.node, context.serverUuid, [
|
||||
'/resources/[cfx-default]/[gameplay]/chat',
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
'/server.cfg',
|
||||
renderFivemServerConfig(context.serverName, context.serverDescription, database),
|
||||
);
|
||||
|
||||
await daemonWriteFile(
|
||||
context.node,
|
||||
context.serverUuid,
|
||||
FIVE_M_QBCORE_MARKER_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
installedAt: new Date().toISOString(),
|
||||
manifestVersion: 1,
|
||||
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await app.db
|
||||
.update(servers)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(servers.id, context.serverId));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ export const CreateServerSchema = {
|
||||
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
|
||||
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
|
||||
allocationId: Type.String({ format: 'uuid' }),
|
||||
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
|
||||
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
startupOverride: Type.Optional(Type.String()),
|
||||
}),
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use bollard::container::{
|
||||
AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats,
|
||||
AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions,
|
||||
};
|
||||
use bollard::image::CreateImageOptions;
|
||||
use bollard::models::{HostConfig, PortBinding};
|
||||
use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding};
|
||||
use futures::StreamExt;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::docker::DockerManager;
|
||||
use crate::server::ServerSpec;
|
||||
use crate::server::state::ServerState;
|
||||
|
||||
/// Container name prefix for all managed game servers.
|
||||
const CONTAINER_PREFIX: &str = "gp_";
|
||||
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
|
||||
|
||||
pub fn container_name(server_uuid: &str) -> String {
|
||||
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
||||
}
|
||||
|
||||
fn uuid_from_container_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim_start_matches('/');
|
||||
trimmed
|
||||
.strip_prefix(CONTAINER_PREFIX)
|
||||
.filter(|uuid| !uuid.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn container_data_path_for_image(image: &str) -> &'static str {
|
||||
let normalized = image.to_ascii_lowercase();
|
||||
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
|
||||
@@ -29,9 +41,30 @@ fn container_data_path_for_image(image: &str) -> &'static str {
|
||||
if normalized.contains("cm2network/csgo") {
|
||||
return "/home/steam/csgo-dedicated";
|
||||
}
|
||||
if normalized.contains("spritsail/fivem") {
|
||||
return "/config";
|
||||
}
|
||||
if normalized.contains("wolveix/satisfactory-server") {
|
||||
return "/config";
|
||||
}
|
||||
"/data"
|
||||
}
|
||||
|
||||
fn is_wolveix_satisfactory_image(image: &str) -> bool {
|
||||
image
|
||||
.to_ascii_lowercase()
|
||||
.contains("wolveix/satisfactory-server")
|
||||
}
|
||||
|
||||
fn server_state_from_container_status(status: &str) -> ServerState {
|
||||
match status {
|
||||
"running" | "restarting" | "paused" => ServerState::Running,
|
||||
"created" | "exited" => ServerState::Stopped,
|
||||
"dead" => ServerState::Error,
|
||||
_ => ServerState::Error,
|
||||
}
|
||||
}
|
||||
|
||||
impl DockerManager {
|
||||
async fn attach_command_stream(
|
||||
&self,
|
||||
@@ -136,6 +169,33 @@ impl DockerManager {
|
||||
Err(anyhow::anyhow!("exec command timeout"))
|
||||
}
|
||||
|
||||
async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> {
|
||||
let mut archive = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut archive);
|
||||
let bytes = SATISFACTORY_RUN_SH.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?;
|
||||
builder.finish()?;
|
||||
}
|
||||
|
||||
self.client()
|
||||
.upload_to_container(
|
||||
container_name,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/home/steam",
|
||||
no_overwrite_dir_non_dir: "false",
|
||||
}),
|
||||
archive.into(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
|
||||
let name = container_name(server_uuid);
|
||||
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
||||
@@ -248,6 +308,10 @@ impl DockerManager {
|
||||
let options = CreateContainerOptions { name: name.as_str(), platform: None };
|
||||
let response = self.client().create_container(Some(options), config).await?;
|
||||
|
||||
if is_wolveix_satisfactory_image(&spec.docker_image) {
|
||||
self.patch_satisfactory_run_script(&name).await?;
|
||||
}
|
||||
|
||||
info!(container_id = %response.id, uuid = %spec.uuid, "Container created");
|
||||
Ok(response.id)
|
||||
}
|
||||
@@ -380,6 +444,162 @@ impl DockerManager {
|
||||
Ok((image, env_map))
|
||||
}
|
||||
|
||||
/// Discover existing managed containers and rebuild in-memory server specs.
|
||||
pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result<Vec<ServerSpec>> {
|
||||
let containers = self
|
||||
.client()
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let mut recovered = Vec::new();
|
||||
|
||||
for container in containers {
|
||||
let uuid = container
|
||||
.names
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|name| uuid_from_container_name(name));
|
||||
|
||||
let Some(uuid) = uuid else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let info = self.client().inspect_container(&container_name(&uuid), None).await?;
|
||||
|
||||
let image = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.image.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let data_mount_path = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.source.as_ref().map(PathBuf::from)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| data_root.join(&uuid));
|
||||
|
||||
let data_destination = info
|
||||
.mounts
|
||||
.as_ref()
|
||||
.and_then(|mounts| {
|
||||
mounts.iter().find_map(|mount| {
|
||||
if mount.typ != Some(MountPointTypeEnum::BIND) {
|
||||
return None;
|
||||
}
|
||||
mount.destination.clone()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| container_data_path_for_image(&image).to_string());
|
||||
|
||||
let startup_command = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| {
|
||||
let working_dir = cfg.working_dir.as_deref().unwrap_or_default();
|
||||
if working_dir != data_destination {
|
||||
return None;
|
||||
}
|
||||
cfg.cmd.as_ref().map(|cmd| cmd.join(" "))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut environment = HashMap::new();
|
||||
if let Some(env_vars) = info
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.env.clone())
|
||||
{
|
||||
for entry in env_vars {
|
||||
if let Some((key, value)) = entry.split_once('=') {
|
||||
environment.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ports = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.port_bindings.as_ref())
|
||||
.map(|bindings| {
|
||||
bindings
|
||||
.iter()
|
||||
.flat_map(|(container_port, host_bindings)| {
|
||||
let (container_port, protocol) = match container_port.split_once('/') {
|
||||
Some((port, protocol)) => (port, protocol),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let Ok(container_port_num) = container_port.parse::<u16>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
host_bindings
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|binding| {
|
||||
let host_port = binding.host_port.as_deref()?.parse::<u16>().ok()?;
|
||||
Some(crate::server::PortMap {
|
||||
host_port,
|
||||
container_port: container_port_num,
|
||||
protocol: protocol.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let memory_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.memory)
|
||||
.unwrap_or_default();
|
||||
|
||||
let cpu_limit = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.nano_cpus)
|
||||
.map(|nano_cpus| (nano_cpus / 10_000_000) as i32)
|
||||
.unwrap_or_default();
|
||||
|
||||
let state = info
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.status.as_ref())
|
||||
.map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase()))
|
||||
.unwrap_or(ServerState::Error);
|
||||
|
||||
recovered.push(ServerSpec {
|
||||
uuid,
|
||||
docker_image: image,
|
||||
memory_limit,
|
||||
disk_limit: 0,
|
||||
cpu_limit,
|
||||
startup_command,
|
||||
environment,
|
||||
ports,
|
||||
data_path: data_mount_path,
|
||||
state,
|
||||
container_id: info.id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
||||
pub fn stream_logs(
|
||||
self: &Arc<Self>,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
NUMCHECK='^[0-9]+$'
|
||||
MSGWARNING="\033[0;33mWARNING:\033[0m"
|
||||
|
||||
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
|
||||
SERVERGAMEPORT="7777"
|
||||
fi
|
||||
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
|
||||
|
||||
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
|
||||
SERVERMESSAGINGPORT="8888"
|
||||
fi
|
||||
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
|
||||
|
||||
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
|
||||
AUTOSAVENUM="5"
|
||||
fi
|
||||
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
|
||||
|
||||
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
|
||||
MAXOBJECTS="2162688"
|
||||
fi
|
||||
printf "Setting max objects to %s\n" "$MAXOBJECTS"
|
||||
|
||||
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
|
||||
MAXTICKRATE="30"
|
||||
fi
|
||||
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
|
||||
|
||||
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
|
||||
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
|
||||
|
||||
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
|
||||
TIMEOUT="30"
|
||||
fi
|
||||
printf "Setting timeout to %s\n" "$TIMEOUT"
|
||||
|
||||
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
|
||||
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
|
||||
MAXPLAYERS="4"
|
||||
fi
|
||||
printf "Setting max players to %s\n" "$MAXPLAYERS"
|
||||
|
||||
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
|
||||
printf "Disabling seasonal events\n"
|
||||
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
|
||||
else
|
||||
DISABLESEASONALEVENTS=""
|
||||
fi
|
||||
|
||||
if [[ "$MULTIHOME" != "" ]]; then
|
||||
if [[ "$MULTIHOME" == "::" ]]; then
|
||||
printf "Multihome will accept IPv4 and IPv6 connections\n"
|
||||
fi
|
||||
printf "Setting multihome to %s\n" "$MULTIHOME"
|
||||
MULTIHOME="-multihome=$MULTIHOME"
|
||||
fi
|
||||
|
||||
ini_args=(
|
||||
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
|
||||
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
|
||||
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
|
||||
"$DISABLESEASONALEVENTS"
|
||||
"$MULTIHOME"
|
||||
)
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
|
||||
SKIPUPDATE="false"
|
||||
fi
|
||||
|
||||
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
|
||||
STEAMBETAPASSWORD=""
|
||||
|
||||
if [[ -n "${STEAMBETAID}" ]]; then
|
||||
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
|
||||
STEAMBETAFLAG="$STEAMBETAID"
|
||||
if [[ -n "${STEAMBETAKEY}" ]]; then
|
||||
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
|
||||
printf "Beta password provided\n"
|
||||
fi
|
||||
elif [[ "${STEAMBETA,,}" == "true" ]]; then
|
||||
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
|
||||
STEAMBETAFLAG="experimental"
|
||||
else
|
||||
STEAMBETAFLAG=""
|
||||
fi
|
||||
|
||||
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
|
||||
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
|
||||
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
|
||||
|
||||
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
|
||||
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
|
||||
fi
|
||||
|
||||
printf "\nDownloading the latest version of the game...\n"
|
||||
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
|
||||
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
|
||||
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
|
||||
fi
|
||||
|
||||
if [[ -n "$STEAMBETAFLAG" ]]; then
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
|
||||
else
|
||||
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
|
||||
fi
|
||||
|
||||
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
|
||||
else
|
||||
printf "Skipping update as flag is set\n"
|
||||
fi
|
||||
|
||||
printf "Launching game server\n\n"
|
||||
|
||||
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
|
||||
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
|
||||
rm -rf "$GAMESAVESDIR"
|
||||
ln -sf "/config/saved" "$GAMESAVESDIR"
|
||||
|
||||
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
|
||||
printf "FactoryServer launch script is missing.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /config/gamefiles || exit 1
|
||||
|
||||
chmod +x FactoryServer.sh || true
|
||||
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
|
||||
|
||||
sleep 2
|
||||
satisfactory_pid=$(ps --ppid ${!} o pid=)
|
||||
|
||||
shutdown() {
|
||||
printf "\nReceived SIGINT. Shutting down.\n"
|
||||
kill -INT $satisfactory_pid 2>/dev/null
|
||||
}
|
||||
trap shutdown SIGINT SIGTERM
|
||||
|
||||
wait
|
||||
@@ -303,6 +303,28 @@ impl DaemonService for DaemonServiceImpl {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn import_database_sql(
|
||||
&self,
|
||||
request: Request<ImportDatabaseSqlRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
self.check_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.database_name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Database name is required"));
|
||||
}
|
||||
if req.sql.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("SQL payload is required"));
|
||||
}
|
||||
|
||||
self.managed_mysql
|
||||
.import_sql(req.database_name.trim(), &req.sql)
|
||||
.await
|
||||
.map_err(Status::from)?;
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
async fn update_database_password(
|
||||
&self,
|
||||
request: Request<UpdateDatabasePasswordRequest>,
|
||||
|
||||
@@ -49,6 +49,9 @@ async fn main() -> Result<()> {
|
||||
let server_manager = Arc::new(ServerManager::new(docker, &config));
|
||||
info!("Server manager initialized");
|
||||
|
||||
let recovered_servers = server_manager.recover_existing_servers().await?;
|
||||
info!(recovered_servers, "Recovered managed servers from Docker");
|
||||
|
||||
// Initialize shared command dispatcher (single command pipeline for all games/sources)
|
||||
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
|
||||
info!("Command dispatcher initialized");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::io::ErrorKind;
|
||||
use std::process::Stdio;
|
||||
|
||||
use reqwest::Url;
|
||||
use thiserror::Error;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tonic::Status;
|
||||
use uuid::Uuid;
|
||||
@@ -164,6 +166,28 @@ impl ManagedMysqlManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_sql(
|
||||
&self,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||
let database_name = database_name.trim();
|
||||
if database_name.is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"Database name is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if sql.trim().is_empty() {
|
||||
return Err(ManagedMysqlError::CommandFailed(
|
||||
"SQL payload is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.run_sql_script(config, database_name, sql).await
|
||||
}
|
||||
|
||||
pub async fn delete_database(
|
||||
&self,
|
||||
database_name: &str,
|
||||
@@ -242,6 +266,76 @@ impl ManagedMysqlManager {
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
|
||||
async fn run_sql_script(
|
||||
&self,
|
||||
config: &ManagedMysqlRuntimeConfig,
|
||||
database_name: &str,
|
||||
sql: &str,
|
||||
) -> Result<(), ManagedMysqlError> {
|
||||
let binaries = match config.client_bin.as_deref() {
|
||||
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||
};
|
||||
|
||||
let mut missing_binary = false;
|
||||
|
||||
for binary in binaries {
|
||||
let child = Command::new(&binary)
|
||||
.args([
|
||||
"--protocol=TCP",
|
||||
"--batch",
|
||||
"--skip-column-names",
|
||||
"-h",
|
||||
&config.admin_host,
|
||||
"-P",
|
||||
&config.admin_port.to_string(),
|
||||
"-u",
|
||||
&config.admin_username,
|
||||
database_name,
|
||||
])
|
||||
.env("MYSQL_PWD", &config.admin_password)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match child {
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(sql.as_bytes()).await?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().await?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr
|
||||
} else if !stdout.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{} exited with status {}", binary, output.status)
|
||||
};
|
||||
return Err(ManagedMysqlError::CommandFailed(message));
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
missing_binary = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
if missing_binary {
|
||||
return Err(ManagedMysqlError::ClientMissing);
|
||||
}
|
||||
|
||||
Err(ManagedMysqlError::ClientMissing)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_runtime_config(
|
||||
|
||||
@@ -49,6 +49,32 @@ impl ServerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild in-memory server specs from existing managed Docker containers.
|
||||
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
|
||||
let recovered = self
|
||||
.docker
|
||||
.recover_managed_server_specs(&self.data_root)
|
||||
.await
|
||||
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
|
||||
|
||||
let recovered_count = recovered.len();
|
||||
let mut servers = self.servers.write().await;
|
||||
servers.clear();
|
||||
|
||||
for spec in recovered {
|
||||
self.ensure_server_data_dir(&spec.data_path).await?;
|
||||
info!(
|
||||
uuid = %spec.uuid,
|
||||
state = %spec.state,
|
||||
image = %spec.docker_image,
|
||||
"Recovered managed server from Docker runtime"
|
||||
);
|
||||
servers.insert(spec.uuid.clone(), spec);
|
||||
}
|
||||
|
||||
Ok(recovered_count)
|
||||
}
|
||||
|
||||
/// Get server spec by UUID.
|
||||
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
|
||||
let servers = self.servers.read().await;
|
||||
@@ -200,6 +226,10 @@ impl ServerManager {
|
||||
})?;
|
||||
}
|
||||
|
||||
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
|
||||
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
|
||||
})?;
|
||||
|
||||
match self.docker.create_container(&desired_spec).await {
|
||||
Ok(container_id) => {
|
||||
desired_spec.container_id = Some(container_id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -7,15 +7,34 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface Game {
|
||||
environmentVars?: GameEnvironmentVar[];
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
dockerImage: string;
|
||||
}
|
||||
|
||||
interface GameEnvironmentVar {
|
||||
key: string;
|
||||
label?: string;
|
||||
default?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
inputType?: 'text' | 'boolean';
|
||||
composeInto?: string;
|
||||
enabledLabel?: string;
|
||||
disabledLabel?: string;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,6 +55,28 @@ interface PaginatedResponse<T> {
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
interface AdditionalPortRequirement {
|
||||
key: string;
|
||||
label: string;
|
||||
defaultPort: number;
|
||||
protocols: Array<'tcp' | 'udp'>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
|
||||
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'satisfactory-messaging',
|
||||
label: 'Messaging Port',
|
||||
defaultPort: 8888,
|
||||
protocols: ['tcp'],
|
||||
description: 'Required by the Satisfactory server messaging API.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function CreateServerPage() {
|
||||
const { orgId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -46,9 +87,13 @@ export function CreateServerPage() {
|
||||
const [gameId, setGameId] = useState('');
|
||||
const [nodeId, setNodeId] = useState('');
|
||||
const [allocationId, setAllocationId] = useState('');
|
||||
const [additionalAllocationIds, setAdditionalAllocationIds] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [memoryLimit, setMemoryLimit] = useState(1024);
|
||||
const [diskLimit, setDiskLimit] = useState(5120);
|
||||
const [cpuLimit, setCpuLimit] = useState(100);
|
||||
const [environment, setEnvironment] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['games'],
|
||||
@@ -63,9 +108,7 @@ export function CreateServerPage() {
|
||||
const { data: allocationsData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Allocation>>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
|
||||
enabled: !!nodeId,
|
||||
});
|
||||
|
||||
@@ -81,8 +124,60 @@ export function CreateServerPage() {
|
||||
|
||||
const games = gamesData?.data ?? [];
|
||||
const nodes = nodesData?.data ?? [];
|
||||
const activeGame = games.find((game) => game.id === gameId);
|
||||
const additionalPortRequirements = activeGame
|
||||
? additionalPortRequirementsForGame(activeGame.slug)
|
||||
: [];
|
||||
const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => {
|
||||
const key = variable.key?.trim();
|
||||
return Boolean(key) && !variable.composeInto?.trim();
|
||||
});
|
||||
const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => {
|
||||
const key = variable.key.trim();
|
||||
const currentValue = environment[key] ?? String(variable.default ?? '');
|
||||
return Boolean(variable.required) && !currentValue.trim();
|
||||
});
|
||||
const missingRequiredAdditionalPorts = additionalPortRequirements.some(
|
||||
(requirement) => !additionalAllocationIds[requirement.key],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGame) {
|
||||
setEnvironment({});
|
||||
return;
|
||||
}
|
||||
|
||||
setEnvironment((current) => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const variable of activeGame.environmentVars ?? []) {
|
||||
const key = variable.key?.trim();
|
||||
if (!key || variable.composeInto?.trim()) continue;
|
||||
next[key] = current[key] ?? String(variable.default ?? '');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAdditionalAllocationIds({});
|
||||
if (activeGame.slug.trim().toLowerCase() === 'satisfactory') {
|
||||
setMemoryLimit((current) => Math.max(current, 8192));
|
||||
setDiskLimit((current) => Math.max(current, 12288));
|
||||
}
|
||||
}, [activeGame?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setAdditionalAllocationIds((current) => {
|
||||
const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key));
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(current).filter(([key]) => allowedKeys.has(key)),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, [activeGame?.slug]);
|
||||
|
||||
const handleCreate = () => {
|
||||
const environmentPayload = Object.fromEntries(
|
||||
Object.entries(environment).filter(([, value]) => value.trim() !== ''),
|
||||
);
|
||||
|
||||
createMutation.mutate({
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -92,9 +187,30 @@ export function CreateServerPage() {
|
||||
memoryLimit: memoryLimit * 1024 * 1024,
|
||||
diskLimit: diskLimit * 1024 * 1024,
|
||||
cpuLimit,
|
||||
additionalAllocationIds:
|
||||
additionalPortRequirements.length > 0
|
||||
? additionalPortRequirements
|
||||
.map((requirement) => additionalAllocationIds[requirement.key])
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const allocationOptionsForRequirement = (requirementKey: string) => {
|
||||
const selectedByOtherRequirements = new Set(
|
||||
Object.entries(additionalAllocationIds)
|
||||
.filter(([key]) => key !== requirementKey)
|
||||
.map(([, id]) => id)
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return freeAllocations.filter(
|
||||
(allocation) =>
|
||||
allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
@@ -149,7 +265,69 @@ export function CreateServerPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => setStep(2)} disabled={!name || !gameId}>
|
||||
{visibleEnvironmentVars.map((variable) => {
|
||||
const key = variable.key.trim();
|
||||
const label = variable.label?.trim() || key;
|
||||
const value = environment[key] ?? String(variable.default ?? '');
|
||||
const isSecret =
|
||||
key.toLowerCase().includes('password') || key.toLowerCase().includes('license');
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>
|
||||
{label}
|
||||
{variable.required ? ' *' : ''}
|
||||
</Label>
|
||||
{variable.inputType === 'boolean' ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'true' ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: 'true',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{variable.enabledLabel ?? 'Enabled'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === 'false' ? 'secondary' : 'outline'}
|
||||
onClick={() =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: 'false',
|
||||
}))
|
||||
}
|
||||
>
|
||||
{variable.disabledLabel ?? 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={isSecret ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setEnvironment((current) => ({
|
||||
...current,
|
||||
[key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={variable.description || label}
|
||||
/>
|
||||
)}
|
||||
{variable.description && (
|
||||
<p className="text-xs text-muted-foreground">{variable.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={!name || !gameId || missingRequiredEnvironment}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -170,6 +348,7 @@ export function CreateServerPage() {
|
||||
onValueChange={(v) => {
|
||||
setNodeId(v);
|
||||
setAllocationId('');
|
||||
setAdditionalAllocationIds({});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -187,7 +366,17 @@ export function CreateServerPage() {
|
||||
{nodeId && (
|
||||
<div className="space-y-2">
|
||||
<Label>Port Allocation</Label>
|
||||
<Select value={allocationId} onValueChange={setAllocationId}>
|
||||
<Select
|
||||
value={allocationId}
|
||||
onValueChange={(value) => {
|
||||
setAllocationId(value);
|
||||
setAdditionalAllocationIds((current) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(current).filter(([, selectedId]) => selectedId !== value),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a port" />
|
||||
</SelectTrigger>
|
||||
@@ -204,11 +393,53 @@ export function CreateServerPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{nodeId &&
|
||||
additionalPortRequirements.map((requirement) => {
|
||||
const options = allocationOptionsForRequirement(requirement.key);
|
||||
const protocols = requirement.protocols.join('/').toUpperCase();
|
||||
|
||||
return (
|
||||
<div key={requirement.key} className="space-y-2">
|
||||
<Label>
|
||||
{requirement.label} ({protocols})
|
||||
</Label>
|
||||
<Select
|
||||
value={additionalAllocationIds[requirement.key] ?? ''}
|
||||
onValueChange={(value) =>
|
||||
setAdditionalAllocationIds((current) => ({
|
||||
...current,
|
||||
[requirement.key]: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select port ${requirement.defaultPort}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((allocation) => (
|
||||
<SelectItem key={allocation.id} value={allocation.id}>
|
||||
{allocation.ip}:{allocation.port}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{requirement.description}</p>
|
||||
{options.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
No free allocation available for this port
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => setStep(3)} disabled={!nodeId || !allocationId}>
|
||||
<Button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user