Account for runtime RCON passwords in config drift

This commit is contained in:
2026-10-08 03:07:49 -04:00
parent 8de9247ff2
commit f8942c732a
13 changed files with 299 additions and 19 deletions
+11 -1
View File
@@ -148,7 +148,9 @@ Missing keys block teardown. Submitted rebuild verification continues while paus
Drift includes missing promoted files, changed bytes, and extra files in managed Drift includes missing promoted files, changed bytes, and extra files in managed
script, config, and plugin directories. Config comparison includes the composed script, config, and plugin directories. Config comparison includes the composed
baseline, overlays, and the selected map. Unmanaged default config is exempt until baseline, overlays, selected map, and the RCON password assigned to the current
instance at launch. Launches and beta config writes use the same password rendering
as drift checks, including Plutainer's startup newline handling. Unmanaged default config is exempt until
a baseline is promoted. Missing mounts are scan errors, not empty directories. a baseline is promoted. Missing mounts are scan errors, not empty directories.
Routine drift scans run every five minutes per server, reducing file traffic Routine drift scans run every five minutes per server, reducing file traffic
while health observations continue every minute. **Check now**, a replacement while health observations continue every minute. **Check now**, a replacement
@@ -156,6 +158,14 @@ registration, or a new lifecycle submission bypasses the drift interval. Failed
file scans retry on the next observer pass; post-rebuild verification still reads file scans retry on the next observer pass; post-rebuild verification still reads
fresh files. fresh files.
For older instances whose only difference is Plutainer's expected password rewrite,
a successful scan reconciles the recorded config hash without changing server
files or rebuilding. The entire file must match the expected runtime config;
unexpected passwords and other config edits still count as drift. Current launch
credentials remain available through history cleanup while the instance is live.
If that launch history was already expired, the check reports unavailable evidence
instead of guessing a password or requesting a rebuild.
Drift repair creates a Campaign capturing observed differences and requests a Drift repair creates a Campaign capturing observed differences and requests a
Provisioning rebuild. The Campaign observes that rebuild without requesting Provisioning rebuild. The Campaign observes that rebuild without requesting
another restart. Its detection evidence and the actual launch recipe remain another restart. Its detection evidence and the actual launch recipe remain
+19
View File
@@ -1,3 +1,4 @@
import { runtimeLaunchSql } from '../fleet/runtime-config.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { getHost, queueHostOperation, waitHostOperationStatus } from '../hosts/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; 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 { export function nativeDeployment(id: string): boolean {
return !!db().prepare(`SELECT 1 FROM native_provisioning WHERE deployment_id = ? return !!db().prepare(`SELECT 1 FROM native_provisioning WHERE deployment_id = ?
UNION ALL SELECT 1 FROM native_creation_intents WHERE deployment_id = ? UNION ALL SELECT 1 FROM native_creation_intents WHERE deployment_id = ?
+3 -1
View File
@@ -38,6 +38,8 @@ import {
type ScriptVersionRow type ScriptVersionRow
} from './scripts.js'; } from './scripts.js';
import { composeServerCfg } from './config-compose.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 { removeScript } from './inventory.js';
import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges } from './staged-files.js'; import { cancelStagedFiles, isFileApplied, queueFileChange, stagedFileChanges } from './staged-files.js';
import { emit } from '../pipeline/bus.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') })); .map(({ script, version }) => ({ id: script.id, text: getVersionContent(version.id).toString('utf8') }));
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug); 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 { export interface BetaRow {
+21 -8
View File
@@ -8,7 +8,9 @@ import { configFileOf, destOfKind, serverDir, type ArtifactKind } from '../confi
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { emit } from '../pipeline/bus.js'; import { emit } from '../pipeline/bus.js';
import { sha256Buffer } from './files.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 { getBaselineForScope, getVersion } from './scripts.js';
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js'; import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
import { selectedZombieMap } from './map-aliases.js'; import { selectedZombieMap } from './map-aliases.js';
@@ -235,13 +237,22 @@ async function scanManagedForServer(server: string, manifest: Map<string, FileFi
if (srv.game) { if (srv.game) {
const baseline = getBaselineForScope('game', srv.game); const baseline = getBaselineForScope('game', srv.game);
if (baseline?.current_version_id) { 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) { if (composed) {
const dest = destOfKind(server, 'config'); const dest = destOfKind(server, 'config');
const targetRel = configFileOf(server); const targetRel = configFileOf(server);
const abs = join(dest, targetRel); const abs = join(dest, targetRel);
const inv = getServerScript(server, targetRel); let inv = getServerScript(server, targetRel);
const content = await fingerprint(abs, manifest); 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; const onDiskMatches = content?.sha256 === composed.sha256 && inv?.sha256 === composed.sha256;
if (!onDiskMatches) { if (!onDiskMatches) {
out.push({ out.push({
@@ -272,7 +283,7 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
const srv = getServer(server); const srv = getServer(server);
if (!srv?.game) return null; if (!srv?.game) return null;
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug); 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; return composed?.content ?? null;
} }
if (!entry.scriptVersionId) return null; if (!entry.scriptVersionId) return null;
@@ -281,13 +292,15 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
} }
function composeExpectedCfg( function composeExpectedCfg(
server: string,
game: string | null, game: string | null,
map: string | null, map: string | null,
mapSlug?: string | null mapSlug?: string | null
): { content: Buffer; sha256: string } | null { ): { content: Buffer; sha256: string; promotedSha256: string } | null {
const content = getPromotedServerConfig(game, map, mapSlug); const promoted = getPromotedServerConfig(game, map, mapSlug);
if (!content) return null; if (!promoted) return null;
return { content, sha256: sha256Buffer(content) }; const content = Buffer.from(applyRuntimePassword(promoted.toString('utf8'), runtimePasswordForServer(server)));
return { content, sha256: sha256Buffer(content), promotedSha256: sha256Buffer(promoted) };
} }
/** /**
+5 -5
View File
@@ -71,8 +71,8 @@ function buildEntries(input: unknown): BlueprintSelection[] {
return entries; return entries;
} }
export function captureRecipe(game: ZombieGame, mapSlug: string): LaunchRecipe { export function captureRecipe(game: ZombieGame, mapSlug: string, rconPassword?: string): LaunchRecipe {
return buildRecipe(game, mapSlug, true); return buildRecipe(game, mapSlug, true, rconPassword);
} }
/** Preview and admission keep file references; execution captures the current bytes. */ /** 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); 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: 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); const map = zombieMapBySlug(game, mapSlug);
if (!map) throw new Error('Choose a supported game and map.'); if (!map) throw new Error('Choose a supported game and map.');
const metadata = ({ kind, script, version }: ReturnType<typeof promotedArtifactMetadata>[number]): LaunchFileSummary => ({ 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, name, path, kind: 'config', versionId: null, version: null,
sha256: sha256Buffer(content), size: content.length, ...(includeContent ? { content: content.toString('base64') } : {}) 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'; let configSource = 'Plutainer defaults, seeded on first boot';
if (composed) { if (composed) {
const baseline = getBaselineForScope('game', game)!; const baseline = getBaselineForScope('game', game)!;
+35
View File
@@ -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))`;
+3 -2
View File
@@ -2,6 +2,7 @@ import { getVersionContent } from './scripts.js';
import { composeServerCfg } from './config-compose.js'; import { composeServerCfg } from './config-compose.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js'; import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { selectedZombieMap, mapRotation } from './map-aliases.js'; import { selectedZombieMap, mapRotation } from './map-aliases.js';
import { applyRuntimePassword } from './runtime-config.js';
export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg'; 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. */ /** 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; if (!game) return null;
const baseline = getBaselineForScope('game', game); const baseline = getBaselineForScope('game', game);
if (!baseline?.current_version_id) return null; 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; const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
return current ? [{ id: overlay.id, text: getVersionContent(current.id).toString('utf8') }] : []; 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');
} }
+2
View File
@@ -55,6 +55,8 @@ function migrateHostSchema(handle: Database.Database): void {
-- causing a history scan for every queued command on Node's main thread. -- 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 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_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) CREATE INDEX IF NOT EXISTS ec_host_operations_active ON ec_host_operations(state, id)
WHERE state IN ('pending','accepted','running','unknown'); WHERE state IN ('pending','accepted','running','unknown');
CREATE TABLE IF NOT EXISTS ec_host_operation_payloads ( CREATE TABLE IF NOT EXISTS ec_host_operation_payloads (
+1 -1
View File
@@ -17,7 +17,7 @@ parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeL
} }
const input = message.input; const input = message.input;
// All file versions and composed configuration belong to one snapshot. // 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 }))(); native: input.native, image: input.instance.image }))();
const instance = input.instance; const instance = input.instance;
const payload = { const payload = {
+2
View File
@@ -2,6 +2,7 @@ import { blobSize, pruneFileBlobs, referenceBlobs } from './db/file-blobs.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { fleetSettings } from './settings.js'; import { fleetSettings } from './settings.js';
import { runtimeLaunchSql } from './fleet/runtime-config.js';
import { ensureHostSchema } from './hosts/schema.js'; import { ensureHostSchema } from './hosts/schema.js';
import { ensureGameFileSchema } from './game-files/schema.js'; import { ensureGameFileSchema } from './game-files/schema.js';
import { ensureAuthSchema } from './auth/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 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)))`; 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)) 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_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 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') AND o.kind NOT IN ('runtime.pull','library.download','library.import','library.remove','host.decommission','host.rotate-credential')
+9 -1
View File
@@ -49,15 +49,23 @@ describe('reviewed beta promotion', () => {
const { betaId } = await ready(kind); const { betaId } = await ready(kind);
const { prepareNativeHost } = await import('../helpers/native-host.js'); const { prepareNativeHost } = await import('../helpers/native-host.js');
await prepareNativeHost([servers[1]]); await prepareNativeHost([servers[1]]);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare(`INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at)
SELECT 'launch',host_id,id,generation,'instance.create',?,'completed',created_at,created_at
FROM native_instances WHERE server=?`).run(JSON.stringify({ rconPassword: 'assigned-password' }), servers[1]);
const native = await import('../../src/lib/server/backends/native.js'); const native = await import('../../src/lib/server/backends/native.js');
const request = vi.spyOn(native, 'nativeRequest').mockRejectedValue(new Error('Agent unavailable')); const request = vi.spyOn(native, 'nativeRequest').mockRejectedValue(new Error('Agent unavailable'));
const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const result = await promoteBeta(betaId, previewBetaPromotion(betaId).token); const result = await promoteBeta(betaId, previewBetaPromotion(betaId).token);
expect(result.awaitingActivation).toBe(1); expect(result.awaitingActivation).toBe(1);
expect(request).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT snapshot_pending FROM deployment_files WHERE deployment_id = ? AND server = ?') expect(db().prepare('SELECT snapshot_pending FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(result.deploymentId, servers[1])).toEqual({ snapshot_pending: 1 }); .get(result.deploymentId, servers[1])).toEqual({ snapshot_pending: 1 });
if (kind === 'baseline') {
const staged = db().prepare(`SELECT b.content FROM staged_file_changes f JOIN file_blobs b ON b.sha256=f.content_hash
WHERE f.source_kind='campaign' AND f.source_id=? AND f.server=?`).get(result.deploymentId, servers[1]) as { content: Buffer };
expect(staged.content.toString()).toContain('set rcon_password "assigned-password"');
}
}); });
it.each([ it.each([
+41
View File
@@ -26,6 +26,8 @@ async function setup(capable = true) {
VALUES(?,'t5','zombie_theater','zombie','theater','ui')`).run(server); VALUES(?,'t5','zombie_theater','zombie','theater','ui')`).run(server);
database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES(?,?,?,'library','image',28960,?)`).run(randomUUID(), server, host.id, new Date().toISOString()); VALUES(?,?,?,'library','image',28960,?)`).run(randomUUID(), server, host.id, new Date().toISOString());
database.prepare(`INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at)
SELECT 'launch',host_id,id,generation,'instance.create','{}','completed',created_at,created_at FROM native_instances WHERE server=?`).run(server);
const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js'); const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
@@ -94,6 +96,45 @@ function expectOneManifest(request: Awaited<ReturnType<typeof setup>>['request']
} }
describe('native drift manifests', () => { describe('native drift manifests', () => {
it.each([true, false])('reconciles verified startup password changes without writing server files (manifest: %s)', async capable => {
const api = await setup(capable);
const { applyRuntimePassword } = await import('../../src/lib/server/fleet/runtime-config.js');
const { getServerScript, recordScript } = await import('../../src/lib/server/fleet/inventory.js');
const promoted = Buffer.from('set rcon_password "password"\nset sv_maprotation "map zombie_theater"\n');
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
// Replace the promoted baseline bytes, retaining a legacy inventory hash.
const next = uploadNewVersion(api.baseline.script.id, promoted);
api.database.prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(next.id, api.baseline.script.id);
const password = 'a'.repeat(64);
api.database.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?').run(JSON.stringify({ rconPassword: password }), 'launch');
recordScript(server, 'dedicated_sp.cfg', null, hash(promoted), promoted.length, 'native-create');
const effective = Buffer.from(applyRuntimePassword(promoted.toString(), password));
expect(effective.length - promoted.length).toBe(56);
api.contents.set('configs/dedicated_sp.cfg', effective);
expect(await api.scanServerDrift(server)).toEqual([]);
expect(getServerScript(server, 'dedicated_sp.cfg')).toMatchObject({ sha256: hash(effective), size: effective.length,
source: 'runtime-config-reconciliation' });
if (capable) expectOneManifest(api.request);
expect((await api.prepareDriftCampaign(server)).plan.files).toEqual([]);
// A wrong password of the same length is still real drift.
api.contents.set('configs/dedicated_sp.cfg', Buffer.from(applyRuntimePassword(promoted.toString(), 'b'.repeat(64))));
expect(await api.scanServerDrift(server)).toEqual([expect.objectContaining({ relPath: 'dedicated_sp.cfg', driftKind: 'replace' })]);
if (!capable) {
const repair = await api.prepareDriftCampaign(server);
expect(repair.files[0].after).toEqual(effective);
}
api.contents.set('configs/dedicated_sp.cfg', Buffer.concat([effective, Buffer.from('set sv_voice "0"\n')]));
expect(await api.scanServerDrift(server)).toHaveLength(1);
});
it('reports unavailable credentials as a scan failure rather than scheduling a destructive repair', async () => {
const api = await setup();
api.database.prepare("UPDATE ec_host_operations SET history_expired_at='expired',payload='{}' WHERE id='launch'").run();
await expect(api.scanServerDrift(server)).rejects.toThrow('launch credentials are unavailable');
});
it('checks managed files, composed config, generated config, and untracked trees in one request', async () => { it('checks managed files, composed config, generated config, and untracked trees in one request', async () => {
const api = await setup(); const api = await setup();
expect(await api.scanServerDrift(server)).toEqual([]); expect(await api.scanServerDrift(server)).toEqual([]);
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyRuntimePassword } from '../../src/lib/server/fleet/runtime-config.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
describe('Plutainer password rendering', () => {
it.each(['set rcon_password', 'seta rcon_password', 'RCON_PASSWORD'])('rewrites %s while preserving comments and other settings', prefix => {
const source = `// set rcon_password "comment"\n${prefix} 'old' // keep\nset sv_voice "1"\n`;
const rendered = applyRuntimePassword(source, 'abc&/\\$xyz');
expect(rendered).toBe(`// set rcon_password "comment"\n${prefix} "abc&/\\$xyz" // keep\nset sv_voice "1"\n`);
expect(applyRuntimePassword(rendered, 'abc&/\\$xyz')).toBe(rendered);
});
it('matches startup handling of missing, duplicate, quoted and unquoted assignments', () => {
expect(applyRuntimePassword('set sv_voice "1"\n', 'secret')).toBe(
'set sv_voice "1"\n\n// Added by Plutainer from PLUTAINER_RCON_PASSWORD.\nset rcon_password "secret"\n');
expect(applyRuntimePassword('set rcon_password old\nrcon_password "later"', 'sec"ret'))
.toBe('set rcon_password "secret"\nrcon_password "secret"');
expect(applyRuntimePassword('set rcon_password old\r\n', '')).toBe('set rcon_password old\r\n');
});
it('normalizes CRLF only when a password write is needed, matching Plutainer', () => {
const old = 'set rcon_password "old"\r\nset sv_voice "1"\r\n';
const effective = 'set rcon_password "new"\nset sv_voice "1"\n';
expect(applyRuntimePassword(old, 'new')).toBe(effective);
expect(applyRuntimePassword(effective, 'new')).toBe(effective);
const matching = old.replace('old', 'new');
expect(applyRuntimePassword(matching, 'new')).toBe(matching);
});
});
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
async function setup(game: 't5' | 't6') {
const { db } = await import('../../src/lib/server/db/index.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Runtime config' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1, capabilities: ['files.manifest'] });
const server = `${game}_runtime_1`;
db().prepare('INSERT INTO servers(name,game) VALUES(?,?)').run(server, game);
db().prepare(`INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at)
VALUES('instance',?,?,1,'library','image',28960,?)`).run(server, host.id, new Date().toISOString());
const launch = (id: string, kind: string, generation: number, password: string, serialized = false, state = 'completed') => {
db().prepare(`INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,payload,state,created_at,updated_at)
VALUES(?,?,'instance',?,?,?,?,?,?)`).run(id, host.id, generation, kind,
JSON.stringify(serialized ? {} : { rconPassword: password }), state, new Date().toISOString(), new Date().toISOString());
if (serialized) db().prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)')
.run(id, JSON.stringify({ rconPassword: password }));
};
return { db: db(), host, server, launch };
}
it.each([
['t5', 'a'.repeat(64)], ['t6', 'a'.repeat(64)], ['t5', 'shared-password'], ['t6', 'shared-password']
] as const)('uses the assigned password for %s launches, drift and beta writes (%s)', async (game, password) => {
const a = await setup(game);
a.launch('create', 'instance.create', 1, password, true);
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const baseline = createScript({ name: 'Baseline', kind: 'baseline', scopeType: 'game', scopeValue: game,
content: Buffer.from('set rcon_password "password"\nset sv_voice "1"\n') });
a.db.prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(baseline.version.id, baseline.script.id);
const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js');
const { captureRecipe } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { configFileForGame } = await import('../../src/lib/server/config.js');
const { sha256Buffer } = await import('../../src/lib/server/fleet/files.js');
const { recordScript, getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const target = configFileForGame(game);
const promoted = getPromotedServerConfig(game, null)!;
const effective = getPromotedServerConfig(game, null, null, password)!;
expect(effective.length - promoted.length).toBe(password.length - 8);
const recipe = captureRecipe(game, game === 't5' ? 'theater' : 'transit', password);
const recipeConfig = recipe.files.find(file => file.path === target)!;
expect(Buffer.from(recipeConfig.content, 'base64').toString()).toContain(`set rcon_password "${password}"`);
expect(recipeConfig.sha256).toBe(sha256Buffer(Buffer.from(recipeConfig.content, 'base64')));
recordScript(a.server, target, null, sha256Buffer(promoted), promoted.length, 'native-create');
const backend = await import('../../src/lib/server/backends/native.js');
const { prepareNativeLaunch } = await import('../../src/lib/server/provisioning/launch-preparation.js');
const prepared = await prepareNativeLaunch({ game, mapSlug: recipe.mapSlug, instance: backend.nativeInstance(a.server)!,
serverKey: 'test-key', rconPassword: password, rebuild: false });
const actualConfig = prepared.recipe.files.find(file => file.path === target)!;
expect(actualConfig.sha256).toBe(recipeConfig.sha256);
const actualBytes = prepared.blobs!.find(blob => blob.sha256 === actualConfig.sha256)!.content;
expect(Buffer.from(actualBytes)).toEqual(Buffer.from(recipeConfig.content, 'base64'));
expect(applyRuntimePassword(Buffer.from(actualBytes).toString(), password)).toBe(Buffer.from(actualBytes).toString());
const payload = JSON.parse(Buffer.from(prepared.payloadJson).toString());
expect(payload.rconPassword).toBe(password);
expect(payload.files).toContainEqual(expect.objectContaining({ path: `configs/${target}`, sha256: actualConfig.sha256 }));
expect(prepared.recipeSummary).not.toContain(password);
let observed = effective;
vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => {
expect(kind).toBe('files.manifest');
return { files: [{ path: `configs/${target}`, size: observed.length, sha256: sha256Buffer(observed) }] } as never;
});
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
expect(await scanServerDrift(a.server)).toEqual([]);
expect(getServerScript(a.server, target)?.sha256).toBe(sha256Buffer(effective));
observed = Buffer.from(effective.toString().replace(password, 'b'.repeat(password.length)));
expect(await scanServerDrift(a.server)).toEqual([expect.objectContaining({ relPath: target, driftKind: 'replace' })]);
const overlay = createScript({ name: 'Overlay', kind: 'config', scopeType: 'game', scopeValue: game,
content: Buffer.from('set sv_voice "0"\nset rcon_password "overlay-password"\n') });
const { openBeta, previewBetaPromotion } = await import('../../src/lib/server/fleet/beta.js');
const { betaId } = await openBeta({ scripts: [{ scriptId: overlay.script.id, scriptVersionId: overlay.version.id }],
servers: [a.server], waitForHealth: false, waitForMatch: false });
const staged = a.db.prepare(`SELECT content_hash FROM staged_file_changes WHERE source_kind='beta' AND source_id=? AND server=?`)
.get(String(betaId), a.server) as { content_hash: string };
const { getFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const stagedContent = getFileBlob(staged.content_hash).toString();
expect(stagedContent).toContain(`set rcon_password "${password}"`);
expect(stagedContent).toContain('set sv_voice "0"');
expect(previewBetaPromotion(betaId).configWrites).toContainEqual(expect.objectContaining({ server: a.server, sha256: staged.content_hash }));
});
it('retains live launch credentials through history cleanup, allowing file bodies and retired credentials to expire', async () => {
const a = await setup('t5');
a.launch('create', 'instance.create', 1, 'original', true);
a.launch('rebuild', 'instance.rebuild', 1, 'current', true);
a.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
a.db.prepare('UPDATE ec_host_operations SET updated_at=?').run(new Date(Date.now() - 90 * 86400000).toISOString());
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
const { pruneHistory } = await import('../../src/lib/server/retention.js');
const { runtimePasswordForServer } = await import('../../src/lib/server/backends/native.js');
saveFleetSettings({ ...fleetSettings(), actionKeepDays: 1, payloadKeepDays: 1, historyKeepCount: 0 });
expect(pruneHistory('operation-payload')).toBe(2);
expect(runtimePasswordForServer(a.server)).toBe('current');
expect(pruneHistory('operation')).toBe(1);
expect(runtimePasswordForServer(a.server)).toBe('current');
a.db.prepare("UPDATE native_instances SET retired_at=? WHERE id='instance'").run(new Date().toISOString());
expect(pruneHistory('operation')).toBe(1);
});
it('selects confirmed credentials by host and resulting generation, not future or failed attempts', async () => {
const a = await setup('t5');
const { runtimePasswordForServer } = await import('../../src/lib/server/backends/native.js');
a.launch('create', 'instance.create', 1, 'original');
a.launch('future', 'instance.rebuild', 1, 'future', true, 'pending');
expect(runtimePasswordForServer(a.server)).toBe('original');
a.db.prepare("UPDATE ec_host_operations SET state='completed' WHERE id='future'").run();
expect(runtimePasswordForServer(a.server)).toBe('original');
a.db.prepare("UPDATE native_instances SET generation=2 WHERE id='instance'").run();
expect(runtimePasswordForServer(a.server)).toBe('future');
a.launch('failed', 'instance.create', 2, 'wrong', false, 'failed');
expect(runtimePasswordForServer(a.server)).toBe('future');
a.db.prepare("UPDATE native_instances SET host_id='different' WHERE id='instance'").run();
expect(() => runtimePasswordForServer(a.server)).toThrow('launch credentials are unavailable');
});