Account for runtime RCON passwords in config drift
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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 { 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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,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')
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user