fix: something
This commit is contained in:
@@ -244,6 +244,8 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||
defaultPort: number;
|
||||
startupCommand: string;
|
||||
stopCommand?: string;
|
||||
stopTimeoutSeconds?: number;
|
||||
containerDataPath?: string;
|
||||
configFiles?: unknown[];
|
||||
environmentVars?: unknown[];
|
||||
automationRules?: unknown[];
|
||||
|
||||
@@ -8,6 +8,8 @@ export const CreateGameSchema = {
|
||||
defaultPort: Type.Number({ minimum: 1, maximum: 65535 }),
|
||||
startupCommand: Type.String({ minLength: 1 }),
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
|
||||
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
@@ -21,6 +23,8 @@ export const UpdateGameSchema = {
|
||||
defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
|
||||
startupCommand: Type.Optional(Type.String({ minLength: 1 })),
|
||||
stopCommand: Type.Optional(Type.String()),
|
||||
stopTimeoutSeconds: Type.Optional(Type.Number({ minimum: 5, maximum: 3600 })),
|
||||
containerDataPath: Type.Optional(Type.String({ pattern: '^/.*' })),
|
||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||
|
||||
@@ -8,10 +8,10 @@ import { requirePermission } from '../../lib/permissions.js';
|
||||
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||
import {
|
||||
isManagedCs2ServerConfigPath,
|
||||
readManagedCs2ServerConfig,
|
||||
writeManagedCs2ServerConfig,
|
||||
} from '../../lib/cs2-server-config.js';
|
||||
managedConfigFileFor,
|
||||
readManagedConfig,
|
||||
writeManagedConfig,
|
||||
} from '../../lib/managed-config.js';
|
||||
|
||||
const ParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -70,8 +70,9 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) {
|
||||
raw = await readManagedCs2ServerConfig(node, server.uuid);
|
||||
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||
if (managedFile) {
|
||||
raw = await readManagedConfig(node, server.uuid, managedFile);
|
||||
} else {
|
||||
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
raw = file.data.toString('utf8');
|
||||
@@ -120,13 +121,13 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
|
||||
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||
|
||||
const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path);
|
||||
const managedFile = managedConfigFileFor(game.slug, configFile.path);
|
||||
|
||||
let originalContent: string | undefined;
|
||||
let originalEntries: { key: string; value: string }[] = [];
|
||||
try {
|
||||
if (isManagedCs2Config) {
|
||||
originalContent = await readManagedCs2ServerConfig(node, server.uuid);
|
||||
if (managedFile) {
|
||||
originalContent = await readManagedConfig(node, server.uuid, managedFile);
|
||||
} else {
|
||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||
originalContent = current.data.toString('utf8');
|
||||
@@ -161,8 +162,8 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
originalContent,
|
||||
);
|
||||
|
||||
if (isManagedCs2Config) {
|
||||
await writeManagedCs2ServerConfig(node, server.uuid, content);
|
||||
if (managedFile) {
|
||||
await writeManagedConfig(node, server.uuid, managedFile, content);
|
||||
} else {
|
||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
type DaemonNodeConnection,
|
||||
} from '../../lib/daemon.js';
|
||||
import {
|
||||
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
CS2_PERSISTED_SERVER_CFG_FILE,
|
||||
isManagedCs2ServerConfigPath,
|
||||
readManagedCs2ServerConfig,
|
||||
writeManagedCs2ServerConfig,
|
||||
} from '../../lib/cs2-server-config.js';
|
||||
isManagedConfigShadowFile,
|
||||
managedConfigFileFor,
|
||||
readManagedConfig,
|
||||
writeManagedConfig,
|
||||
} from '../../lib/managed-config.js';
|
||||
|
||||
const FileParamSchema = {
|
||||
params: Type.Object({
|
||||
@@ -27,8 +26,8 @@ const FileParamSchema = {
|
||||
};
|
||||
|
||||
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
||||
if (isManagedConfigShadowFile(gameSlug, fileName)) return true;
|
||||
if (gameSlug !== 'cs2') return false;
|
||||
if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true;
|
||||
if (isDirectory) return false;
|
||||
|
||||
const normalizedName = fileName.trim().toLowerCase();
|
||||
@@ -108,9 +107,10 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
let payload: Buffer;
|
||||
let mimeType = 'text/plain';
|
||||
|
||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (managedFile) {
|
||||
payload = Buffer.from(
|
||||
await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid),
|
||||
await readManagedConfig(serverContext.node, serverContext.serverUuid, managedFile),
|
||||
'utf8',
|
||||
);
|
||||
} else {
|
||||
@@ -156,8 +156,14 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
|
||||
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||
|
||||
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||
await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload);
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (managedFile) {
|
||||
await writeManagedConfig(
|
||||
serverContext.node,
|
||||
serverContext.serverUuid,
|
||||
managedFile,
|
||||
payload,
|
||||
);
|
||||
} else {
|
||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||
}
|
||||
@@ -182,16 +188,19 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||
await requirePermission(request, orgId, 'files.delete');
|
||||
const serverContext = await getServerContext(app, orgId, serverId);
|
||||
|
||||
const resolvedPaths = paths.flatMap((path) =>
|
||||
isManagedCs2ServerConfigPath(serverContext.gameSlug, path)
|
||||
? [
|
||||
path,
|
||||
path.trim().startsWith('/')
|
||||
? `/${CS2_PERSISTED_SERVER_CFG_PATH}`
|
||||
: CS2_PERSISTED_SERVER_CFG_PATH,
|
||||
]
|
||||
: [path],
|
||||
);
|
||||
// Deleting a managed config also drops the panel's sidecar copy,
|
||||
// otherwise the next start would resurrect the file.
|
||||
const resolvedPaths = paths.flatMap((path) => {
|
||||
const managedFile = managedConfigFileFor(serverContext.gameSlug, path);
|
||||
if (!managedFile) return [path];
|
||||
|
||||
return [
|
||||
path,
|
||||
path.trim().startsWith('/')
|
||||
? `/${managedFile.shadowPath}`
|
||||
: managedFile.shadowPath,
|
||||
];
|
||||
});
|
||||
|
||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
||||
return { success: true, paths };
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type DaemonNodeConnection,
|
||||
type DaemonPortMapping,
|
||||
} from '../../lib/daemon.js';
|
||||
import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js';
|
||||
import { sustainManagedConfigsAfterStart } from '../../lib/managed-config.js';
|
||||
import {
|
||||
ServerParamSchema,
|
||||
CreateServerSchema,
|
||||
@@ -146,6 +146,14 @@ function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequ
|
||||
];
|
||||
}
|
||||
|
||||
if (slug === 'ark-se') {
|
||||
return [
|
||||
{ key: 'ark-raw-udp', label: 'Raw UDP Socket Port', protocols: ['udp'] },
|
||||
{ key: 'ark-query', label: 'Steam Query Port', protocols: ['udp'] },
|
||||
{ key: 'ark-rcon', label: 'RCON Port', protocols: ['tcp'] },
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -188,18 +196,38 @@ function applyGameRuntimeEnvironment(
|
||||
additionalPortsRaw: unknown,
|
||||
): Record<string, string> {
|
||||
const slug = gameSlug.trim().toLowerCase();
|
||||
if (slug !== 'satisfactory') return environment;
|
||||
|
||||
const additionalPorts = normalizeAdditionalServerPorts(additionalPortsRaw);
|
||||
const messagingPort = additionalPorts.find(
|
||||
(port) => port.key === 'satisfactory-messaging' && port.protocol === 'tcp',
|
||||
);
|
||||
const findPort = (key: string, protocol: PortProtocol) =>
|
||||
additionalPorts.find((port) => port.key === key && port.protocol === protocol);
|
||||
|
||||
return {
|
||||
...environment,
|
||||
SERVERGAMEPORT: String(allocationPort),
|
||||
SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888),
|
||||
};
|
||||
if (slug === 'satisfactory') {
|
||||
const messagingPort = findPort('satisfactory-messaging', 'tcp');
|
||||
|
||||
return {
|
||||
...environment,
|
||||
SERVERGAMEPORT: String(allocationPort),
|
||||
SERVERMESSAGINGPORT: String(messagingPort?.hostPort ?? 8888),
|
||||
};
|
||||
}
|
||||
|
||||
if (slug === 'ark-se') {
|
||||
// The ARK image binds exactly the ports it is told about, so the
|
||||
// allocations have to be mirrored into its environment.
|
||||
const rconPort = findPort('ark-rcon', 'tcp')?.hostPort ?? 27020;
|
||||
const adminPassword = environment.ADMIN_PASSWORD ?? '';
|
||||
|
||||
return {
|
||||
...environment,
|
||||
GAME_CLIENT_PORT: String(allocationPort),
|
||||
UDP_SOCKET_PORT: String(findPort('ark-raw-udp', 'udp')?.hostPort ?? allocationPort + 1),
|
||||
SERVER_LIST_PORT: String(findPort('ark-query', 'udp')?.hostPort ?? 27015),
|
||||
RCON_PORT: String(rconPort),
|
||||
// The daemon reads these when it opens an RCON console session.
|
||||
RCON_PASSWORD: adminPassword,
|
||||
};
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
function buildDaemonPorts(
|
||||
@@ -231,6 +259,19 @@ function buildDaemonPorts(
|
||||
];
|
||||
}
|
||||
|
||||
if (slug === 'ark-se') {
|
||||
// Host and container ports match because the image is configured with the
|
||||
// very same numbers via its environment.
|
||||
return [
|
||||
{ host_port: allocationPort, container_port: allocationPort, protocol: 'udp' },
|
||||
...additionalPorts.map((port) => ({
|
||||
host_port: port.hostPort,
|
||||
container_port: port.hostPort,
|
||||
protocol: port.protocol,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (slug === 'cs2' || slug === 'csgo' || slug === 'fivem') {
|
||||
return [
|
||||
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
|
||||
@@ -512,6 +553,11 @@ async function syncServerInstallStatus(
|
||||
'Synchronized install status from daemon',
|
||||
);
|
||||
|
||||
if (mapped === 'running') {
|
||||
// First boot resets configs the same way a restart does.
|
||||
sustainManagedConfigAfterPowerStart(app, node, serverId, serverUuid, gameSlug);
|
||||
}
|
||||
|
||||
if (mapped === 'running' || mapped === 'stopped') {
|
||||
if (needsManagedProvisioning) {
|
||||
void runManagedInstallProvisioning(app, {
|
||||
@@ -543,30 +589,35 @@ async function syncServerInstallStatus(
|
||||
app.log.warn({ serverId, serverUuid }, 'Timed out while waiting for daemon install completion');
|
||||
}
|
||||
|
||||
async function sustainCs2ServerConfigAfterPowerStart(
|
||||
/**
|
||||
* Keep panel-managed config files alive across a start.
|
||||
*
|
||||
* Steam-based images re-validate the game install on every start and put their
|
||||
* own `server.cfg` back afterwards — often many minutes in, long after a fixed
|
||||
* short retry window would have given up. So the watcher polls for drift over a
|
||||
* long window and stops once the file has stayed put (or the server stops).
|
||||
*/
|
||||
function sustainManagedConfigAfterPowerStart(
|
||||
app: FastifyInstance,
|
||||
node: DaemonNodeConnection,
|
||||
serverId: string,
|
||||
serverUuid: string,
|
||||
gameSlug: string,
|
||||
): Promise<void> {
|
||||
if (gameSlug.trim().toLowerCase() !== 'cs2') return;
|
||||
): void {
|
||||
sustainManagedConfigsAfterStart(app, {
|
||||
node,
|
||||
serverId,
|
||||
serverUuid,
|
||||
gameSlug,
|
||||
isServerActive: async () => {
|
||||
const [row] = await app.db
|
||||
.select({ status: servers.status })
|
||||
.from(servers)
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
const attempts = 6;
|
||||
const intervalMs = 10_000;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
await sleep(intervalMs);
|
||||
|
||||
try {
|
||||
await reapplyManagedCs2ServerConfig(node, serverUuid);
|
||||
} catch (error) {
|
||||
app.log.warn(
|
||||
{ error, serverId, serverUuid, attempt },
|
||||
'Failed to reapply managed CS2 server.cfg after power start',
|
||||
);
|
||||
}
|
||||
}
|
||||
return row?.status === 'running' || row?.status === 'installing';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function serverRoutes(app: FastifyInstance) {
|
||||
@@ -827,6 +878,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
),
|
||||
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort, additionalServerPorts),
|
||||
install_plugin_urls: [],
|
||||
data_path: game.containerDataPath ?? '',
|
||||
stop_command: game.stopCommand ?? '',
|
||||
stop_timeout_seconds: game.stopTimeoutSeconds ?? 0,
|
||||
};
|
||||
let createdServerResponse = server;
|
||||
|
||||
@@ -1195,6 +1249,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
gameDefaultPort: games.defaultPort,
|
||||
gameSlug: games.slug,
|
||||
gameStartupCommand: games.startupCommand,
|
||||
gameStopCommand: games.stopCommand,
|
||||
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
|
||||
gameContainerDataPath: games.containerDataPath,
|
||||
gameEnvironmentVars: games.environmentVars,
|
||||
})
|
||||
.from(servers)
|
||||
@@ -1256,6 +1313,9 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
current.gameDefaultPort,
|
||||
current.additionalPorts,
|
||||
),
|
||||
data_path: current.gameContainerDataPath ?? '',
|
||||
stop_command: current.gameStopCommand ?? '',
|
||||
stop_timeout_seconds: current.gameStopTimeoutSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
nextStatus = mapDaemonStatus(response.status);
|
||||
@@ -1412,9 +1472,14 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
nodeFqdn: nodes.fqdn,
|
||||
nodeGrpcPort: nodes.grpcPort,
|
||||
nodeDaemonToken: nodes.daemonToken,
|
||||
gameSlug: games.slug,
|
||||
gameStopCommand: games.stopCommand,
|
||||
gameStopTimeoutSeconds: games.stopTimeoutSeconds,
|
||||
gameAutomationRules: games.automationRules,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||
if (!server) throw AppError.notFound('Server not found');
|
||||
|
||||
@@ -1422,16 +1487,17 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
throw AppError.badRequest('Cannot send power action to a suspended server');
|
||||
}
|
||||
|
||||
const nodeConnection: DaemonNodeConnection = {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
};
|
||||
|
||||
try {
|
||||
await daemonSetPowerState(
|
||||
{
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
server.uuid,
|
||||
action,
|
||||
);
|
||||
await daemonSetPowerState(nodeConnection, server.uuid, action, {
|
||||
stopCommand: server.gameStopCommand,
|
||||
stopTimeoutSeconds: server.gameStopTimeoutSeconds,
|
||||
});
|
||||
} catch (error) {
|
||||
app.log.error(
|
||||
{ error, serverId: server.id, serverUuid: server.uuid, action },
|
||||
@@ -1457,41 +1523,22 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||
.where(eq(servers.id, serverId));
|
||||
|
||||
if (action === 'start' || action === 'restart') {
|
||||
const [serverWithGame] = await app.db
|
||||
.select({
|
||||
gameSlug: games.slug,
|
||||
automationRules: games.automationRules,
|
||||
})
|
||||
.from(servers)
|
||||
.innerJoin(games, eq(servers.gameId, games.id))
|
||||
.where(eq(servers.id, serverId));
|
||||
sustainManagedConfigAfterPowerStart(
|
||||
app,
|
||||
nodeConnection,
|
||||
serverId,
|
||||
server.uuid,
|
||||
server.gameSlug,
|
||||
);
|
||||
|
||||
if (serverWithGame) {
|
||||
void sustainCs2ServerConfigAfterPowerStart(
|
||||
app,
|
||||
{
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
serverId,
|
||||
server.uuid,
|
||||
serverWithGame.gameSlug,
|
||||
);
|
||||
|
||||
void runServerAutomationEvent(app, {
|
||||
serverId,
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: serverWithGame.gameSlug,
|
||||
event: 'server.power.started',
|
||||
node: {
|
||||
fqdn: server.nodeFqdn,
|
||||
grpcPort: server.nodeGrpcPort,
|
||||
daemonToken: server.nodeDaemonToken,
|
||||
},
|
||||
automationRulesRaw: serverWithGame.automationRules,
|
||||
});
|
||||
}
|
||||
void runServerAutomationEvent(app, {
|
||||
serverId,
|
||||
serverUuid: server.uuid,
|
||||
gameSlug: server.gameSlug,
|
||||
event: 'server.power.started',
|
||||
node: nodeConnection,
|
||||
automationRulesRaw: server.gameAutomationRules,
|
||||
});
|
||||
}
|
||||
|
||||
await createAuditLog(app.db, request, {
|
||||
|
||||
Reference in New Issue
Block a user