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
+9 -1
View File
@@ -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([
+41
View File
@@ -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<ReturnType<typeof setup>>['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([]);
+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');
});