diff --git a/docs/guides/maintenance.md b/docs/guides/maintenance.md index 61ed29d..a03a637 100644 --- a/docs/guides/maintenance.md +++ b/docs/guides/maintenance.md @@ -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 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. Routine drift scans run every five minutes per server, reducing file traffic 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 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 Provisioning rebuild. The Campaign observes that rebuild without requesting another restart. Its detection evidence and the actual launch recipe remain diff --git a/src/lib/server/backends/native.ts b/src/lib/server/backends/native.ts index b424b24..4101d5a 100644 --- a/src/lib/server/backends/native.ts +++ b/src/lib/server/backends/native.ts @@ -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 = ? diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index f0aacfb..8e62628 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -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 { diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index 6f18774..1e125d8 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -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[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)!; diff --git a/src/lib/server/fleet/runtime-config.ts b/src/lib/server/fleet/runtime-config.ts new file mode 100644 index 0000000..d94c281 --- /dev/null +++ b/src/lib/server/fleet/runtime-config.ts @@ -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))`; diff --git a/src/lib/server/fleet/server-config.ts b/src/lib/server/fleet/server-config.ts index 5e8bd7c..5334ec1 100644 --- a/src/lib/server/fleet/server-config.ts +++ b/src/lib/server/fleet/server-config.ts @@ -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'); } diff --git a/src/lib/server/hosts/schema.ts b/src/lib/server/hosts/schema.ts index f359d0e..4409865 100644 --- a/src/lib/server/hosts/schema.ts +++ b/src/lib/server/hosts/schema.ts @@ -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 ( diff --git a/src/lib/server/provisioning/launch-worker.ts b/src/lib/server/provisioning/launch-worker.ts index 0eeaef5..cee4952 100644 --- a/src/lib/server/provisioning/launch-worker.ts +++ b/src/lib/server/provisioning/launch-worker.ts @@ -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 = { diff --git a/src/lib/server/retention.ts b/src/lib/server/retention.ts index 896e565..6610b2b 100644 --- a/src/lib/server/retention.ts +++ b/src/lib/server/retention.ts @@ -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') diff --git a/tests/unit/beta-promotion.test.ts b/tests/unit/beta-promotion.test.ts index 3217986..df05f1d 100644 --- a/tests/unit/beta-promotion.test.ts +++ b/tests/unit/beta-promotion.test.ts @@ -49,15 +49,23 @@ describe('reviewed beta promotion', () => { const { betaId } = await ready(kind); const { prepareNativeHost } = await import('../helpers/native-host.js'); 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 request = vi.spyOn(native, 'nativeRequest').mockRejectedValue(new Error('Agent unavailable')); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const result = await promoteBeta(betaId, previewBetaPromotion(betaId).token); expect(result.awaitingActivation).toBe(1); 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 = ?') .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([ diff --git a/tests/unit/native-drift-manifest.test.ts b/tests/unit/native-drift-manifest.test.ts index cdbd354..56fadab 100644 --- a/tests/unit/native-drift-manifest.test.ts +++ b/tests/unit/native-drift-manifest.test.ts @@ -26,6 +26,8 @@ async function setup(capable = true) { 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) 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 { recordScript } = await import('../../src/lib/server/fleet/inventory.js'); @@ -94,6 +96,45 @@ function expectOneManifest(request: Awaited>['request'] } 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 () => { const api = await setup(); expect(await api.scanServerDrift(server)).toEqual([]); diff --git a/tests/unit/runtime-config.test.ts b/tests/unit/runtime-config.test.ts new file mode 100644 index 0000000..2a748c1 --- /dev/null +++ b/tests/unit/runtime-config.test.ts @@ -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'); +});