Account for runtime RCON passwords in config drift
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { runtimeLaunchSql } from '../fleet/runtime-config.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { getHost, queueHostOperation, waitHostOperationStatus } from '../hosts/index.js';
|
||||
@@ -14,6 +15,24 @@ export function nativeInstance(server: string): NativeInstance | null {
|
||||
return db().prepare('SELECT * FROM native_instances WHERE server = ? AND retired_at IS NULL').get(server) as NativeInstance | undefined ?? null;
|
||||
}
|
||||
|
||||
/** Credentials come from the launch, never from the possibly modified config or
|
||||
* today's controller environment. This history is retained for live instances.
|
||||
*/
|
||||
export function runtimePasswordForServer(server: string): string | null {
|
||||
const instance = nativeInstance(server);
|
||||
if (!instance) return null;
|
||||
const row = db().prepare(`SELECT
|
||||
json_extract(COALESCE(p.payload,o.payload),'$.rconPassword') AS password,
|
||||
json_extract(COALESCE(p.payload,o.payload),'$.env.PLUTAINER_RCON_PASSWORD') AS env_password
|
||||
FROM ec_host_operations o JOIN native_instances i ON ${runtimeLaunchSql}
|
||||
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id
|
||||
WHERE i.id=? AND o.state='completed' AND o.history_expired_at IS NULL
|
||||
ORDER BY o.rowid DESC LIMIT 1`).get(instance.id) as { password: unknown; env_password: unknown } | undefined;
|
||||
if (!row) throw new Error(`Cannot verify runtime config for ${server}: the current instance's launch credentials are unavailable.`);
|
||||
const password = row.password || row.env_password;
|
||||
return typeof password === 'string' && password ? password : null;
|
||||
}
|
||||
|
||||
export function nativeDeployment(id: string): boolean {
|
||||
return !!db().prepare(`SELECT 1 FROM native_provisioning WHERE deployment_id = ?
|
||||
UNION ALL SELECT 1 FROM native_creation_intents WHERE deployment_id = ?
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
type ScriptVersionRow
|
||||
} from './scripts.js';
|
||||
import { composeServerCfg } from './config-compose.js';
|
||||
import { applyRuntimePassword } from './runtime-config.js';
|
||||
import { runtimePasswordForServer } from '../backends/native.js';
|
||||
import { removeScript } from './inventory.js';
|
||||
import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges } from './staged-files.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
@@ -138,7 +140,7 @@ function composeCfgForServer(
|
||||
.map(({ script, version }) => ({ id: script.id, text: getVersionContent(version.id).toString('utf8') }));
|
||||
|
||||
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
|
||||
return { text: composed, baselineScriptId };
|
||||
return { text: applyRuntimePassword(composed, runtimePasswordForServer(serverName)), baselineScriptId };
|
||||
}
|
||||
|
||||
export interface BetaRow {
|
||||
|
||||
@@ -8,7 +8,9 @@ import { configFileOf, destOfKind, serverDir, type ArtifactKind } from '../confi
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
import { sha256Buffer } from './files.js';
|
||||
import { getServerScript } from './inventory.js';
|
||||
import { getServerScript, recordScript } from './inventory.js';
|
||||
import { applyRuntimePassword } from './runtime-config.js';
|
||||
import { runtimePasswordForServer } from '../backends/native.js';
|
||||
import { getBaselineForScope, getVersion } from './scripts.js';
|
||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||
import { selectedZombieMap } from './map-aliases.js';
|
||||
@@ -235,13 +237,22 @@ async function scanManagedForServer(server: string, manifest: Map<string, FileFi
|
||||
if (srv.game) {
|
||||
const baseline = getBaselineForScope('game', srv.game);
|
||||
if (baseline?.current_version_id) {
|
||||
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||
const composed = composeExpectedCfg(server, srv.game, srv.map, srv.map_slug);
|
||||
if (composed) {
|
||||
const dest = destOfKind(server, 'config');
|
||||
const targetRel = configFileOf(server);
|
||||
const abs = join(dest, targetRel);
|
||||
const inv = getServerScript(server, targetRel);
|
||||
let inv = getServerScript(server, targetRel);
|
||||
const content = await fingerprint(abs, manifest);
|
||||
// Older launches tracked the promoted bytes before Plutainer
|
||||
// applied the instance password. Reconcile only that known
|
||||
// before-image, and only after verifying the full effective file.
|
||||
if (composed.sha256 !== composed.promotedSha256 && content?.sha256 === composed.sha256
|
||||
&& inv?.sha256 === composed.promotedSha256 && !hasActiveBeta(server)) {
|
||||
recordScript(server, targetRel, inv.script_version_id, composed.sha256,
|
||||
composed.content.byteLength, 'runtime-config-reconciliation');
|
||||
inv = getServerScript(server, targetRel);
|
||||
}
|
||||
const onDiskMatches = content?.sha256 === composed.sha256 && inv?.sha256 === composed.sha256;
|
||||
if (!onDiskMatches) {
|
||||
out.push({
|
||||
@@ -272,7 +283,7 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
||||
const srv = getServer(server);
|
||||
if (!srv?.game) return null;
|
||||
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug);
|
||||
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||
const composed = composeExpectedCfg(server, srv.game, srv.map, srv.map_slug);
|
||||
return composed?.content ?? null;
|
||||
}
|
||||
if (!entry.scriptVersionId) return null;
|
||||
@@ -281,13 +292,15 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
||||
}
|
||||
|
||||
function composeExpectedCfg(
|
||||
server: string,
|
||||
game: string | null,
|
||||
map: string | null,
|
||||
mapSlug?: string | null
|
||||
): { content: Buffer; sha256: string } | null {
|
||||
const content = getPromotedServerConfig(game, map, mapSlug);
|
||||
if (!content) return null;
|
||||
return { content, sha256: sha256Buffer(content) };
|
||||
): { content: Buffer; sha256: string; promotedSha256: string } | null {
|
||||
const promoted = getPromotedServerConfig(game, map, mapSlug);
|
||||
if (!promoted) return null;
|
||||
const content = Buffer.from(applyRuntimePassword(promoted.toString('utf8'), runtimePasswordForServer(server)));
|
||||
return { content, sha256: sha256Buffer(content), promotedSha256: sha256Buffer(promoted) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,8 +71,8 @@ function buildEntries(input: unknown): BlueprintSelection[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe {
|
||||
return buildRecipe(game, mapSlug, true);
|
||||
export function captureRecipe(game: ZombieGame, mapSlug: string, rconPassword?: string): LaunchRecipe {
|
||||
return buildRecipe(game, mapSlug, true, rconPassword);
|
||||
}
|
||||
|
||||
/** Preview and admission keep file references; execution captures the current bytes. */
|
||||
@@ -80,9 +80,9 @@ export function captureRecipePlan(game: ZombieGame, mapSlug: string): LaunchReci
|
||||
return buildRecipe(game, mapSlug, false);
|
||||
}
|
||||
|
||||
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: true): LaunchRecipe;
|
||||
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: true, rconPassword?: string): LaunchRecipe;
|
||||
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: false): LaunchRecipePlan;
|
||||
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean): LaunchRecipePlan {
|
||||
function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean, rconPassword?: string): LaunchRecipePlan {
|
||||
const map = zombieMapBySlug(game, mapSlug);
|
||||
if (!map) throw new Error('Choose a supported game and map.');
|
||||
const metadata = ({ kind, script, version }: ReturnType<typeof promotedArtifactMetadata>[number]): LaunchFileSummary => ({
|
||||
@@ -96,7 +96,7 @@ function buildRecipe(game: ZombieGame, mapSlug: string, includeContent: boolean)
|
||||
name, path, kind: 'config', versionId: null, version: null,
|
||||
sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { content: content.toString('base64') } : {})
|
||||
});
|
||||
const composed = getPromotedServerConfig(game, map.canonical, map.slug);
|
||||
const composed = getPromotedServerConfig(game, map.canonical, map.slug, rconPassword);
|
||||
let configSource = 'Plutainer defaults, seeded on first boot';
|
||||
if (composed) {
|
||||
const baseline = getBaselineForScope('game', game)!;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Match Plutainer's apply_rcon_password, including its newline handling.
|
||||
* Keep the original bytes when the password already matches: its Python reader
|
||||
* normalizes line endings, but only writes the normalized text after a change.
|
||||
*/
|
||||
export function applyRuntimePassword(text: string, password?: string | null): string {
|
||||
if (!password) return text;
|
||||
const lines = text.replace(/\r\n|\r/g, '\n').split('\n');
|
||||
const replacement = `"${password.replaceAll('"', '')}"`;
|
||||
const pattern = /^(\s*(?:set[a]?\s+)?rcon_password\s+)("[^"]*"|'[^']*'|\S+)(.*)$/i;
|
||||
let matched = false;
|
||||
let changed = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = pattern.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
matched = true;
|
||||
if (match[2] === replacement) continue;
|
||||
lines[i] = match[1] + replacement + match[3];
|
||||
changed = true;
|
||||
}
|
||||
if (!matched) {
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
lines.push('', '// Added by Plutainer from PLUTAINER_RCON_PASSWORD.', `set rcon_password ${replacement}`, '');
|
||||
changed = true;
|
||||
}
|
||||
return changed ? lines.join('\n') : text;
|
||||
}
|
||||
|
||||
/** Uses aliases o (operation) and i (instance). Rebuild commands target the old
|
||||
* generation; their successful result owns the next one. Never use a queued
|
||||
* rebuild's password for the instance that is still running.
|
||||
*/
|
||||
export const runtimeLaunchSql = `o.instance_id=i.id AND o.host_id=i.host_id
|
||||
AND o.kind IN ('instance.create','instance.rebuild')
|
||||
AND ((o.kind='instance.create' AND o.generation=i.generation)
|
||||
OR (o.kind='instance.rebuild' AND o.generation+1=i.generation))`;
|
||||
@@ -2,6 +2,7 @@ import { getVersionContent } from './scripts.js';
|
||||
import { composeServerCfg } from './config-compose.js';
|
||||
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
|
||||
import { selectedZombieMap, mapRotation } from './map-aliases.js';
|
||||
import { applyRuntimePassword } from './runtime-config.js';
|
||||
|
||||
export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg';
|
||||
|
||||
@@ -12,7 +13,7 @@ export function getServerMapConfig(game: string | null, map: string | null, mapS
|
||||
}
|
||||
|
||||
/** The promoted config used for first boot and drift reconciliation. */
|
||||
export function getPromotedServerConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
|
||||
export function getPromotedServerConfig(game: string | null, map: string | null, mapSlug?: string | null, rconPassword?: string | null): Buffer | null {
|
||||
if (!game) return null;
|
||||
const baseline = getBaselineForScope('game', game);
|
||||
if (!baseline?.current_version_id) return null;
|
||||
@@ -26,5 +27,5 @@ export function getPromotedServerConfig(game: string | null, map: string | null,
|
||||
const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
|
||||
return current ? [{ id: overlay.id, text: getVersionContent(current.id).toString('utf8') }] : [];
|
||||
});
|
||||
return Buffer.from(composeServerCfg(getVersionContent(version.id).toString('utf8'), overlays, game, map, mapSlug), 'utf8');
|
||||
return Buffer.from(applyRuntimePassword(composeServerCfg(getVersionContent(version.id).toString('utf8'), overlays, game, map, mapSlug), rconPassword), 'utf8');
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ function migrateHostSchema(handle: Database.Database): void {
|
||||
-- causing a history scan for every queued command on Node's main thread.
|
||||
CREATE INDEX IF NOT EXISTS ec_host_operations_kind ON ec_host_operations(host_id, kind, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ec_host_operations_kind_state ON ec_host_operations(kind, state);
|
||||
CREATE INDEX IF NOT EXISTS ec_host_operations_launch_instance ON ec_host_operations(instance_id,host_id,generation)
|
||||
WHERE kind IN ('instance.create','instance.rebuild');
|
||||
CREATE INDEX IF NOT EXISTS ec_host_operations_active ON ec_host_operations(state, id)
|
||||
WHERE state IN ('pending','accepted','running','unknown');
|
||||
CREATE TABLE IF NOT EXISTS ec_host_operation_payloads (
|
||||
|
||||
@@ -17,7 +17,7 @@ parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeL
|
||||
}
|
||||
const input = message.input;
|
||||
// All file versions and composed configuration belong to one snapshot.
|
||||
const recipe = db().transaction(() => ({ ...captureRecipe(input.game, input.mapSlug),
|
||||
const recipe = db().transaction(() => ({ ...captureRecipe(input.game, input.mapSlug, input.rconPassword),
|
||||
native: input.native, image: input.instance.image }))();
|
||||
const instance = input.instance;
|
||||
const payload = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { blobSize, pruneFileBlobs, referenceBlobs } from './db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from './db/index.js';
|
||||
import { fleetSettings } from './settings.js';
|
||||
import { runtimeLaunchSql } from './fleet/runtime-config.js';
|
||||
import { ensureHostSchema } from './hosts/schema.js';
|
||||
import { ensureGameFileSchema } from './game-files/schema.js';
|
||||
import { ensureAuthSchema } from './auth/schema.js';
|
||||
@@ -26,6 +27,7 @@ const unsettledDeployment = `d.state NOT IN ${terminal}
|
||||
OR EXISTS (SELECT 1 FROM ec_host_migrations m JOIN ec_host_operations a ON a.id=m.remove_operation_id
|
||||
WHERE m.deployment_id=d.id AND (a.state NOT IN ('completed','failed') OR (a.state='completed' AND m.removed_at IS NULL)))`;
|
||||
const unreferencedOperation = `NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE o.id IN (n.operation_id,n.stage_operation_id))
|
||||
AND NOT EXISTS (SELECT 1 FROM native_instances i WHERE i.retired_at IS NULL AND ${runtimeLaunchSql})
|
||||
AND NOT EXISTS (SELECT 1 FROM ec_host_migrations m WHERE m.remove_operation_id=o.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM ec_host_decommissions d WHERE d.operation_id=o.id)
|
||||
AND o.kind NOT IN ('runtime.pull','library.download','library.import','library.remove','host.decommission','host.rotate-credential')
|
||||
|
||||
Reference in New Issue
Block a user