Protect private bank configuration from drift reconciliation (Refs #152)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { isPrivateConfigPath } from '../fleet/private-config.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { join, posix, relative, resolve, sep } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
@@ -85,6 +86,13 @@ export async function nativeFileManifest(
|
||||
}
|
||||
|
||||
export async function readFile(path: string): Promise<Buffer> {
|
||||
for (const root of [config.serversRoot, config.t6ServersRoot]) {
|
||||
const [, ...parts] = relative(resolve(root), resolve(path)).split(sep);
|
||||
if (isPrivateConfigPath(parts.join('/')))
|
||||
throw new Error(
|
||||
'Private bank configuration is not available through shared file reads.'
|
||||
);
|
||||
}
|
||||
const target = remote(path);
|
||||
if (!target) return fs.readFile(path);
|
||||
const result = await nativeRequest<{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isPrivateConfigEvidencePath } from '../fleet/private-config.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from './index.js';
|
||||
@@ -22,7 +23,18 @@ export function putFileBlob(
|
||||
return sha256;
|
||||
}
|
||||
|
||||
export function isPrivateFileBlob(sha256: string, handle: Database.Database = db()): boolean {
|
||||
const rows = handle
|
||||
.prepare(
|
||||
`SELECT rel_path FROM deployment_files
|
||||
WHERE content_before_hash=? OR content_after_hash=?`
|
||||
)
|
||||
.all(sha256, sha256) as { rel_path: string }[];
|
||||
return rows.some((row) => isPrivateConfigEvidencePath(row.rel_path));
|
||||
}
|
||||
|
||||
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
|
||||
if (isPrivateFileBlob(sha256, handle)) throw new Error('Private file content is unavailable.');
|
||||
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
|
||||
{ content: Buffer } | undefined;
|
||||
if (!row) throw new Error('File content is unavailable or has expired.');
|
||||
@@ -58,6 +70,12 @@ export function packFileJson(
|
||||
if (Array.isArray(value))
|
||||
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
|
||||
if (!value || typeof value !== 'object') return { value, hashes };
|
||||
if (
|
||||
'path' in value &&
|
||||
typeof value.path === 'string' &&
|
||||
isPrivateConfigEvidencePath(value.path)
|
||||
)
|
||||
return { value: { path: value.path, redacted: true }, hashes };
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isPrivateConfigTarget } from './private-config.js';
|
||||
import { putFileBlob } from '../db/file-blobs.js';
|
||||
import { getVersionContent } from './scripts.js';
|
||||
import {
|
||||
@@ -213,6 +214,7 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
||||
if (onDisk.length === 0) continue;
|
||||
|
||||
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
|
||||
if (isPrivateConfigTarget(server, kind, relPath)) return null;
|
||||
if (managed.has(`${kind}:${relPath}`)) return null;
|
||||
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server))
|
||||
return null;
|
||||
@@ -438,6 +440,7 @@ function driftTargets(servers?: string[]): string[] {
|
||||
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
||||
const files: DriftPlanFile[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isPrivateConfigTarget(entry.server, entry.kind, entry.relPath)) continue;
|
||||
const before = await readFile(
|
||||
join(destOfKind(entry.server, entry.kind), entry.relPath)
|
||||
).catch((error: NodeJS.ErrnoException) => {
|
||||
@@ -506,7 +509,9 @@ export interface PreparedDriftCampaign {
|
||||
/** Capture a rebuild's file evidence without changing the running instance. */
|
||||
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
|
||||
const servers = driftTargets([server]);
|
||||
const entries = await scanServerDrift(server);
|
||||
const entries = (await scanServerDrift(server)).filter(
|
||||
(entry) => !isPrivateConfigTarget(server, entry.kind, entry.relPath)
|
||||
);
|
||||
const files = await scanBatch(entries, async (entry) => {
|
||||
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
|
||||
const after =
|
||||
@@ -547,7 +552,11 @@ export function createDriftRebuildCampaign(
|
||||
incidentId: string | number,
|
||||
prepared: PreparedDriftCampaign
|
||||
): string | null {
|
||||
if (!prepared.files.length) return null;
|
||||
// Prepared evidence may have been created before the private-file exemption.
|
||||
const files = prepared.files.filter(
|
||||
({ file }) => !isPrivateConfigTarget(file.server, file.kind, file.relPath)
|
||||
);
|
||||
if (!files.length) return null;
|
||||
if (
|
||||
prepared.plan.servers.length !== 1 ||
|
||||
prepared.plan.servers[0] !== server ||
|
||||
@@ -575,7 +584,7 @@ export function createDriftRebuildCampaign(
|
||||
const insertFile = db().prepare(`INSERT INTO deployment_files
|
||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
for (const { file, before, after } of prepared.files) {
|
||||
for (const { file, before, after } of files) {
|
||||
insertFile.run(
|
||||
id,
|
||||
server,
|
||||
@@ -592,7 +601,7 @@ export function createDriftRebuildCampaign(
|
||||
`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
|
||||
VALUES (?, ?, ?, 'pending', ?)`
|
||||
)
|
||||
.run(id, server, prepared.files.length, operationId);
|
||||
.run(id, server, files.length, operationId);
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'campaign', ?, ?)"
|
||||
@@ -627,7 +636,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
entries: DriftEntry[];
|
||||
}> = [];
|
||||
for (const name of scanTargets) {
|
||||
const entries = await scanServerDrift(name);
|
||||
const entries = (await scanServerDrift(name)).filter(
|
||||
(entry) => !isPrivateConfigTarget(name, entry.kind, entry.relPath)
|
||||
);
|
||||
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
||||
}
|
||||
|
||||
@@ -692,6 +703,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
continue;
|
||||
}
|
||||
for (const entry of s.entries) {
|
||||
if (isPrivateConfigTarget(s.server, entry.kind, entry.relPath)) continue;
|
||||
const dest = destOfKind(s.server, entry.kind);
|
||||
const abs = join(dest, entry.relPath);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { posix } from 'node:path';
|
||||
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
|
||||
|
||||
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
|
||||
|
||||
export function isPrivateConfigPath(path: string, game = 't5'): boolean {
|
||||
return (
|
||||
posix.normalize(path.replaceAll('\\', '/')) ===
|
||||
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPrivateConfigTarget(server: string, kind: string, path: string): boolean {
|
||||
if (kind !== 'script' && kind !== 'config' && kind !== 'baseline' && kind !== 'plugin')
|
||||
throw new Error('Invalid artifact kind');
|
||||
const game = gameOfServer(server);
|
||||
return isPrivateConfigPath(
|
||||
posix.join(relDestForKind(kind, game), path.replaceAll('\\', '/')),
|
||||
game
|
||||
);
|
||||
}
|
||||
|
||||
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
|
||||
if (isPrivateConfigTarget(server, kind, path))
|
||||
throw new Error(
|
||||
'Private bank configuration cannot be captured or changed by a file campaign.'
|
||||
);
|
||||
}
|
||||
|
||||
export function isReservedConfigFilename(path: string): boolean {
|
||||
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE;
|
||||
}
|
||||
|
||||
// Legacy campaign evidence omits the kind and stores the path relative to its destination.
|
||||
export function isPrivateConfigEvidencePath(path: string): boolean {
|
||||
return (
|
||||
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
|
||||
isPrivateConfigPath(path)
|
||||
);
|
||||
}
|
||||
|
||||
/** Credential injection uses bank.configPath, never a shared file's path field. */
|
||||
export class PrivateFileOperationError extends Error {
|
||||
constructor() {
|
||||
super('Private bank configuration cannot be read or changed by file operations.');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPublicFilePayload(value: unknown): void {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if ('path' in value && typeof value.path === 'string' && isPrivateConfigPath(value.path))
|
||||
throw new PrivateFileOperationError();
|
||||
for (const item of Object.values(value)) assertPublicFilePayload(item);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isReservedConfigFilename } from './private-config.js';
|
||||
import { scriptVersionError } from '../../script-version.js';
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -62,6 +63,7 @@ export class ScriptError extends Error {
|
||||
| 'name-empty'
|
||||
| 'name-taken'
|
||||
| 'path-empty'
|
||||
| 'path-reserved'
|
||||
| 'no-such-script'
|
||||
| 'no-such-version'
|
||||
| 'invalid-scope'
|
||||
@@ -249,6 +251,11 @@ export function createScript(input: CreateScriptInput): {
|
||||
);
|
||||
}
|
||||
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
||||
if (isReservedConfigFilename(targetRelPath))
|
||||
throw new ScriptError(
|
||||
'electric_cherry_bank.cfg is reserved for instance credentials.',
|
||||
'path-reserved'
|
||||
);
|
||||
|
||||
// Kind-specific validation.
|
||||
if (kind === 'baseline') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertPublicFileTarget } from './private-config.js';
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, relative, resolve, isAbsolute } from 'node:path';
|
||||
@@ -39,6 +40,7 @@ export interface StagedFileChange {
|
||||
}
|
||||
|
||||
function targetPath(server: string, kind: ArtifactKind, relPath: string): string {
|
||||
assertPublicFileTarget(server, kind, relPath);
|
||||
const root = resolve(destOfKind(server, kind));
|
||||
const target = resolve(root, relPath);
|
||||
const rel = relative(root, target);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
assertPublicFilePayload,
|
||||
BANK_CONFIG_FILE,
|
||||
PrivateFileOperationError,
|
||||
isPrivateConfigPath
|
||||
} from '../fleet/private-config.js';
|
||||
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
|
||||
import { pruneHistory } from '../retention.js';
|
||||
import { recordAgentReceipt } from '../provisioning/agent-events.js';
|
||||
@@ -1248,7 +1254,9 @@ function enqueueHostOperation(
|
||||
)
|
||||
throw new Error('Host is disabled. Enable it before requesting operations.');
|
||||
const requestHash = createHash('sha256').update(payload).digest('hex');
|
||||
const packed = packFileJson(JSON.parse(payload.toString()));
|
||||
const parsedPayload = JSON.parse(payload.toString());
|
||||
assertPublicFilePayload(parsedPayload);
|
||||
const packed = packFileJson(parsedPayload);
|
||||
payload = JSON.stringify(packed.value);
|
||||
const id = options.id ?? randomUUID();
|
||||
const timestamp = now();
|
||||
@@ -1405,6 +1413,15 @@ export function pendingHostOperationRows(
|
||||
)
|
||||
: new Set<string>();
|
||||
const admitted = rows.filter((operation) => {
|
||||
// Withhold unsafe legacy commands without breaking the entire delivery stream.
|
||||
if (operation.kind.startsWith('files.') || operation.kind === 'logs.read') {
|
||||
try {
|
||||
getHostOperationPayload(hostId, operation.id);
|
||||
} catch (error) {
|
||||
if (error instanceof PrivateFileOperationError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!libraryKinds.includes(operation.kind) ||
|
||||
preparing.has(hostId) ||
|
||||
@@ -1431,6 +1448,9 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
|
||||
)
|
||||
.get(id, hostId) as { payload: Buffer } | undefined;
|
||||
if (!row) throw new Error('Host operation was not found for this host.');
|
||||
// Block old private-file commands before delivery to agents predating the guard.
|
||||
if (row.payload.includes(BANK_CONFIG_FILE))
|
||||
assertPublicFilePayload(JSON.parse(row.payload.toString()));
|
||||
if (
|
||||
!row.payload.includes('"contentSha256"') ||
|
||||
getHost(hostId)?.inventory.capabilities?.includes('files.blobs')
|
||||
@@ -1468,8 +1488,18 @@ export function reportHostOperationStatus(
|
||||
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
|
||||
throw new Error('Operation result exceeds the limit.');
|
||||
if (result !== null) {
|
||||
const target =
|
||||
operation.kind === 'files.read'
|
||||
? (database()
|
||||
.prepare('SELECT target_path FROM ec_host_operations WHERE id=?')
|
||||
.get(id) as { target_path: string | null })
|
||||
: null;
|
||||
const packed = packFileJson(
|
||||
operation.kind === 'logs.read' ? logResultMetadata(input.result) : input.result
|
||||
target?.target_path && isPrivateConfigPath(target.target_path)
|
||||
? { redacted: true }
|
||||
: operation.kind === 'logs.read'
|
||||
? logResultMetadata(input.result)
|
||||
: input.result
|
||||
);
|
||||
result = JSON.stringify(packed.value);
|
||||
referenceBlobs('operation-result', id, packed.hashes);
|
||||
|
||||
@@ -710,7 +710,15 @@ async function scanServer(server: string, forceScan: boolean): Promise<void> {
|
||||
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
|
||||
if (!prepared.plan.files.length) {
|
||||
const current = active(server, 'drift');
|
||||
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
|
||||
if (current) {
|
||||
db()
|
||||
.prepare('UPDATE watchdog_incidents SET details=? WHERE id=?')
|
||||
.run(
|
||||
JSON.stringify({ files: [], fingerprint: prepared.plan.fingerprint }),
|
||||
current.id
|
||||
);
|
||||
transition(current.id, 'resolved', 'Server files match the promoted setup');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const problem = detect(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertPublicFileTarget } from '../fleet/private-config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { blobSize, getFileBlob } from '../db/file-blobs.js';
|
||||
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||
@@ -22,6 +23,7 @@ export function applyActivationFiles(
|
||||
version: number | null;
|
||||
})[];
|
||||
for (const change of changes) {
|
||||
assertPublicFileTarget(change.server, change.kind, change.rel_path);
|
||||
if (change.kind !== 'script' && change.kind !== 'config' && change.kind !== 'plugin')
|
||||
throw new Error('File activation requires a runtime file kind.');
|
||||
recipe.files = recipe.files.filter(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertPublicFileTarget, isPrivateConfigPath } from '../fleet/private-config.js';
|
||||
import { assertScalingOwnership } from '../autoscaling/ownership.js';
|
||||
import { removalAuthorization } from '../autoscaling/authorization.js';
|
||||
import { scalingIntent } from '../autoscaling/admission.js';
|
||||
@@ -509,6 +510,7 @@ function filesFor(job: Job): FilePlan[] {
|
||||
'id' | 'kind' | 'rel_path' | 'check_before' | 'expected_before_sha'
|
||||
> & { content_hash: string | null }
|
||||
>;
|
||||
for (const row of rows) assertPublicFileTarget(job.server, row.kind, row.rel_path);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
path: posix.join(
|
||||
@@ -532,7 +534,12 @@ function storedFiles(job: Job): FilePlan[] {
|
||||
const row = db()
|
||||
.prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?')
|
||||
.get(job.id) as { files_json: string | null } | undefined;
|
||||
return (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
|
||||
const files = (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
|
||||
if (files.some((file) => isPrivateConfigPath(file.path)))
|
||||
throw new Error(
|
||||
'Private bank configuration cannot be captured or changed by a file campaign.'
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Agent receipts are the authoritative before images; do not read a controller mount. */
|
||||
|
||||
Reference in New Issue
Block a user