Protect private bank configuration from drift reconciliation (Refs #152)

This commit is contained in:
2026-10-12 23:32:20 -04:00
parent 4e3df81786
commit d9fa15cdf6
23 changed files with 597 additions and 28 deletions
+8
View File
@@ -2,6 +2,14 @@
## 2026-10-12
### 1.9.3 — Protect private bank configuration (EC #152)
- Exclude the generated bank config from file manifests and reject file reads,
staged changes, stale activations, rollback snapshots and log reads targeting
it. Creation and rebuilding still inject the private instance credential.
- Package and offline-sign this release, then update agents before resuming
drift recovery. Old private-file stages fail safely and require a fresh plan.
### 1.9.1 — Public game-bank endpoint
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
+23 -2
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.9.2"
VERSION = "1.9.3"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -400,7 +400,17 @@ def managed_open(root, value):
os.close(fd)
def private_bank_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
def require_public_file(value):
if private_bank_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations")
def managed_read(root, value):
require_public_file(value)
try:
with managed_open(root, value) as stream:
if os.fstat(stream.fileno()).st_size > MAX_FILE:
@@ -446,6 +456,8 @@ def managed_manifest(root, payload, stopping):
def validate_paths():
for value in paths:
if private_bank_config(value):
continue
check_budget()
try:
with managed_open(root, value):
@@ -480,7 +492,7 @@ def managed_manifest(root, payload, stopping):
entries += 1
if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored:
if child.name in ignored or private_bank_config(relative + "/" + child.name):
continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode):
@@ -1949,6 +1961,8 @@ class Agent:
files = payload.get("files", [])
if not isinstance(files, list):
raise Rejected("Invalid initial file list")
for item in files:
require_public_file(item["path"])
bank = payload.get("bank")
if bank is None:
return files
@@ -2219,6 +2233,7 @@ class Agent:
paths = set()
total_size = 0
for item in files:
require_public_file(item["path"])
scoped(directory / "app", item["path"])
if item["path"] in paths:
raise AgentError("Duplicate staged file path")
@@ -2267,6 +2282,8 @@ class Agent:
stage = directory / "stages" / identifier(stage_id)
marker = stage / "manifest.json"
saved = json.loads(marker.read_text())
for item in saved["files"]:
require_public_file(item["path"])
if saved["state"] == "activated":
return saved["before"]
if saved["state"] != "staged":
@@ -2297,6 +2314,8 @@ class Agent:
return before
def restore_files(self, app, before):
for item in before:
require_public_file(item["path"])
for item in before:
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
@@ -2351,6 +2370,7 @@ class Agent:
def read_log(self, instance_id, generation, payload):
app = self.instance_dir(instance_id, generation) / "app"
value = payload["path"]
require_public_file(value)
count = min(2000, max(1, int(payload.get("lines", 200))))
# Validate the original relative spelling independently of symlinks.
scoped(self.root / "agent" / "log-validation", value)
@@ -2367,6 +2387,7 @@ class Agent:
raise AgentError("Log symlink resolves outside the managed instance") from None
# Reopen the resolved relative target through no-follow directory FDs;
# swapping either the original link or a target component cannot escape.
require_public_file(relative)
with managed_open(app, relative) as stream:
stat = os.fstat(stream.fileno())
if payload.get("mode") == "incremental":
+50 -3
View File
@@ -1644,9 +1644,51 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_create_materializes_private_bank_config_from_instance_credential(self):
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self):
self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
value = "configs/electric_cherry_bank.cfg"
target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret)
files = [{"id": 9, "path": value, "delete": True, "contentBase64": None}]
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.stage(INSTANCE, 1, {"stageId": "private", "files": files}, "stage")
marker = self.agent.instance_dir(INSTANCE, 1) / "stages/private/manifest.json"
# A stage written by a previous agent must be rejected on activation too.
module.atomic_json(marker, {"state": "staged", "files": files})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.activate(INSTANCE, 1, "private", override_safety=True)
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.rebuild_before(INSTANCE, 1, "private", {"files": []})
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.restore_files(app, [{"path": value, "contentBase64": None}])
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.file_operation("files.read", INSTANCE, 1, {"path": value}, "read")
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.read_log(INSTANCE, 1, {"path": value})
manifest = self.agent.file_operation("files.manifest", INSTANCE, 1,
{"roots": ["configs"], "paths": [value], "ignoreNames": []}, "manifest")
self.assertNotIn(value, [item["path"] for item in manifest["files"]])
self.assertEqual(target.read_bytes(), secret)
self.assertNotIn("before", json.loads(marker.read_text()))
with self.assertRaisesRegex(AgentError, "Private bank"):
self.agent.initial_files({"files": [{"path": value, "contentBase64": base64.b64encode(secret).decode()}]})
def prepare_t6_library(self):
target = self.agent.library_dir("t6", self.library_id)
module.shutil.copytree(self.library, target)
manifest = json.loads((target / "manifest.json").read_text())
manifest["game"] = "t6"
module.atomic_json(target / "manifest.json", manifest)
def test_t6_create_materializes_private_bank_config(self):
self.prepare_t6_library()
self.test_create_materializes_private_bank_config_from_instance_credential(game="t6")
def test_create_materializes_private_bank_config_from_instance_credential(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = {"name": "server", "game": "t5", "libraryId": self.library_id,
payload = {"name": "server", "game": game, "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
operation = self.operation("instance.create", payload, "create")
@@ -1699,9 +1741,14 @@ class AgentTests(unittest.TestCase):
self.assertFalse(path.exists())
self.assertEqual(self.docker.calls, calls)
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self):
def test_t6_rebuild_preserves_private_bank_configuration(self):
self.prepare_t6_library()
self.test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(game="t6")
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self, game="t5"):
secret = "ec_bank_" + "a" * 43
payload = self.create()
payload["game"] = game
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
paths = [self.bank_journal(game=game, endpoint=endpoint)
for game in ("t5", "t6")
+9
View File
@@ -44,6 +44,15 @@ Do not upload `electric_cherry_bank.cfg`: its filename is reserved. Protect the
controller database, host operation journal and per-instance runtime directory,
which contain private credentials.
Drift scans exclude the exact generated configuration path for both games.
A maintenance rescan resolves old bank-only incidents and retains other file
changes in mixed incidents. Private-file campaign plans saved before this fix
are rejected at activation; cancel them and rescan rather than retrying the old
plan. Upgrade host agents to **1.9.3 or later** before resuming drift recovery
so stages already saved on a host also reject private-file reads and changes.
Legacy private bank-file campaign diffs and their blob downloads are blocked;
this does not erase previously captured bytes from databases or backups.
Retries, rebuilds and host moves of the same instance reuse its credential.
Retiring the instance immediately revokes authentication. Creating another
server with the same display name produces a new instance ID and secret; it
+8
View File
@@ -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<{
+18
View File
@@ -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 (
+17 -5
View File
@@ -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 {
+54
View File
@@ -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);
}
+7
View File
@@ -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') {
+2
View File
@@ -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);
+32 -2
View File
@@ -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);
+9 -1
View File
@@ -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(
+8 -1
View File
@@ -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. */
@@ -1,3 +1,4 @@
import { isPrivateFileBlob } from '$lib/server/db/file-blobs.js';
import { error } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { db } from '$lib/server/db/index.js';
@@ -5,6 +6,7 @@ import { db } from '$lib/server/db/index.js';
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
const host = authenticateAgent(request);
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
if (isPrivateFileBlob(params.sha256)) error(404, 'File not found.');
const row = db()
.prepare(
`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
@@ -1,3 +1,4 @@
import { isPrivateConfigEvidencePath } from '$lib/server/fleet/private-config.js';
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
@@ -35,7 +36,7 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
snapshot_pending: number;
}
| undefined;
if (!row) throw error(404, 'file not found');
if (!row || isPrivateConfigEvidencePath(row.rel_path)) throw error(404, 'file not found');
if (row.snapshot_pending)
return json(
{
@@ -1,3 +1,4 @@
import { isPrivateConfigEvidencePath } from '$lib/server/fleet/private-config.js';
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
@@ -22,7 +23,7 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
content_after: Buffer | null;
}
| undefined;
if (!row) throw error(404, 'file not found');
if (!row || isPrivateConfigEvidencePath(row.rel_path)) throw error(404, 'file not found');
return json({
verb: row.verb,
+23 -2
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.9.2"
VERSION = "1.9.3"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -400,7 +400,17 @@ def managed_open(root, value):
os.close(fd)
def private_bank_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
def require_public_file(value):
if private_bank_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations")
def managed_read(root, value):
require_public_file(value)
try:
with managed_open(root, value) as stream:
if os.fstat(stream.fileno()).st_size > MAX_FILE:
@@ -446,6 +456,8 @@ def managed_manifest(root, payload, stopping):
def validate_paths():
for value in paths:
if private_bank_config(value):
continue
check_budget()
try:
with managed_open(root, value):
@@ -480,7 +492,7 @@ def managed_manifest(root, payload, stopping):
entries += 1
if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored:
if child.name in ignored or private_bank_config(relative + "/" + child.name):
continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode):
@@ -1949,6 +1961,8 @@ class Agent:
files = payload.get("files", [])
if not isinstance(files, list):
raise Rejected("Invalid initial file list")
for item in files:
require_public_file(item["path"])
bank = payload.get("bank")
if bank is None:
return files
@@ -2219,6 +2233,7 @@ class Agent:
paths = set()
total_size = 0
for item in files:
require_public_file(item["path"])
scoped(directory / "app", item["path"])
if item["path"] in paths:
raise AgentError("Duplicate staged file path")
@@ -2267,6 +2282,8 @@ class Agent:
stage = directory / "stages" / identifier(stage_id)
marker = stage / "manifest.json"
saved = json.loads(marker.read_text())
for item in saved["files"]:
require_public_file(item["path"])
if saved["state"] == "activated":
return saved["before"]
if saved["state"] != "staged":
@@ -2297,6 +2314,8 @@ class Agent:
return before
def restore_files(self, app, before):
for item in before:
require_public_file(item["path"])
for item in before:
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
@@ -2351,6 +2370,7 @@ class Agent:
def read_log(self, instance_id, generation, payload):
app = self.instance_dir(instance_id, generation) / "app"
value = payload["path"]
require_public_file(value)
count = min(2000, max(1, int(payload.get("lines", 200))))
# Validate the original relative spelling independently of symlinks.
scoped(self.root / "agent" / "log-validation", value)
@@ -2367,6 +2387,7 @@ class Agent:
raise AgentError("Log symlink resolves outside the managed instance") from None
# Reopen the resolved relative target through no-follow directory FDs;
# swapping either the original link or a target component cannot escape.
require_public_file(relative)
with managed_open(app, relative) as stream:
stat = os.fstat(stream.fileno())
if payload.get("mode") == "incremental":
+2 -2
View File
@@ -1,8 +1,8 @@
{
"version": "1.9.2",
"version": "1.9.3",
"protocolVersion": 1,
"platform": "debian-amd64",
"sha256": "acff8fdbf75652821638a9c90ee9cc4ef5254bc2f19fabc436b9135f93307a5a",
"sha256": "a0baaab0f0a60e848300e63f31eeefff6293d8b749b5ca9397c36e8e75ff4a93",
"updaterProtocolVersion": 2,
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
-8
View File
@@ -1,8 +0,0 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCas2drBsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfqXUwD/UtXrU4iXCqcAGzYY+ETl
JyvOrDoi5txZ3qz4pkwsZZIBAO3nz5BzaX4k3GfvKOYFsBJsfHDa76WeWwuC1h7m
zdoN
=mSzg
-----END PGP SIGNATURE-----
+1
View File
@@ -110,6 +110,7 @@ async function setup(capable = true) {
);
// The runtime owns this generated file; neither scanner should classify it as drift.
contents.set('configs/dedicated.cfg', Buffer.from('runtime generated'));
contents.set('configs/electric_cherry_bank.cfg', Buffer.from('set tws_bank_token private'));
const backend = await import('../../src/lib/server/backends/native.js');
const request = vi
+269
View File
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
const filename = 'electric_cherry_bank.cfg';
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(servers);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { destOfKind } = await import('../../src/lib/server/config.js');
for (const server of servers) {
await mkdir(destOfKind(server, 'config'), { recursive: true });
await writeFile(join(destOfKind(server, 'config'), filename), secret);
}
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
async function evidenceIsPrivate() {
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual([]);
expect(db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)).toEqual(
[]
);
}
describe.each(servers)('private bank config on %s', (server) => {
it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation } =
await import('../../src/lib/server/fleet/staged-files.js');
const backend = await import('../../src/lib/server/backends/files.js');
const reads = vi.spyOn(backend, 'readFile');
expect(await drift.scanServerDrift(server)).toEqual([]);
expect((await drift.previewDrift([server])).files).toEqual([]);
expect((await drift.prepareDriftCampaign(server)).files).toEqual([]);
expect((await drift.runDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
const dest = destOfKind(server, 'config');
await writeFile(join(dest, 'stray.cfg'), 'unwanted');
// The exemption is for the generated path, not every matching basename.
await mkdir(join(dest, 'nested'));
await writeFile(join(dest, 'nested', filename), 'unwanted nested file');
for (const name of ['modified.gsc', 'missing.gsc']) {
const artifact = createScript({
name,
releaseVersion: '1.0.0',
targetRelPath: name,
kind: 'script',
scopeType: 'game',
scopeValue: server.slice(0, 2),
content: Buffer.from('promoted')
});
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(artifact.version.id, artifact.script.id);
}
await writeFile(join(destOfKind(server, 'script'), 'modified.gsc'), 'modified');
const plan = await drift.previewDrift([server]);
expect(plan.files.map((file) => file.relPath).sort()).toEqual([
'missing.gsc',
'modified.gsc',
`nested/${filename}`,
'stray.cfg'
]);
const result = await drift.runDriftDeployment({
servers: [server],
expectedFingerprint: plan.fingerprint
});
const operation = requestRestart(server, { kind: 'campaign', id: result.deploymentId! });
applyStagedFilesForOperation(operation.id, server);
expect(await drift.scanServerDrift(server)).toEqual([]);
expect(await readFile(join(dest, filename))).toEqual(secret);
expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(false);
await evidenceIsPrivate();
});
it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => {
const { prepareDriftCampaign, createDriftRebuildCampaign } =
await import('../../src/lib/server/fleet/drift.js');
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted');
const prepared = await prepareDriftCampaign(server);
const privateFile = {
...prepared.files[0],
before: secret,
file: { ...prepared.files[0].file, relPath: filename }
};
prepared.files.push(privateFile);
prepared.plan.files.push(privateFile.file);
const operation = beginDeployment(server, 'rebuild', 'watchdog');
const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared);
expect(
db().prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?').all(id)
).toEqual([{ rel_path: 'stray.cfg' }]);
expect(
createDriftRebuildCampaign(server, operation, 'bank-only', {
...prepared,
files: [privateFile]
})
).toBeNull();
await evidenceIsPrivate();
});
it('rejects new and persisted private deletions before activation or evidence capture', async () => {
const staged = await import('../../src/lib/server/fleet/staged-files.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const { applyActivationFiles } =
await import('../../src/lib/server/provisioning/activation-files.js');
const { captureRecipePlan } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { destOfKind } = await import('../../src/lib/server/config.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('old','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('old',?,1,'pending')"
)
.run(server);
const change = {
sourceKind: 'campaign' as const,
sourceId: 'old',
server,
kind: 'config' as const,
relPath: filename,
content: null,
inventorySource: 'drift:old'
};
expect(() => staged.queueFileChange(change)).toThrow('Private bank configuration');
db()
.prepare(
"INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,inventory_source) VALUES('campaign','old',?,'config',?,'drift:old')"
)
.run(server, filename);
const operation = requestRestart(server, { kind: 'campaign', id: 'old' });
expect(() => staged.applyStagedFilesForOperation(operation.id, server)).toThrow(
'Private bank configuration'
);
const recipe = captureRecipePlan(
server.startsWith('t6') ? 't6' : 't5',
server.startsWith('t6') ? 'town' : 'theater'
);
expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow(
'Private bank configuration'
);
expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(secret);
await evidenceIsPrivate();
});
});
it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js');
expect(() =>
createScript({
name: 'private',
kind: 'config',
targetRelPath: filename,
scopeType: 'game',
scopeValue: 't5',
content: secret
})
).toThrow('reserved');
const packed = packFileJson({
before: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
});
expect(packed.hashes.size).toBe(0);
expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64'));
await evidenceIsPrivate();
});
it('blocks legacy private campaign diffs and blob downloads', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { putFileBlob, getFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
const hash = putFileBlob(secret)!;
const file = db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
for (const route of [
await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js'),
await import('../../src/routes/api/deployments/[id]/file/[fileId]/+server.js')
]) {
await expect(
route.GET({ params: { id: 'legacy', fileId: String(file.lastInsertRowid) } })
).rejects.toMatchObject({ status: 404 });
}
expect(() => getFileBlob(hash)).toThrow('Private file content');
});
it('blocks old host commands and redacts late private read receipts from older agents', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const host = hosts.createHost({ name: 'Legacy private command host' });
const credential = hosts.enrollHost(hosts.createEnrollment(host.id).token, {}).credential;
const payload = {
files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
};
expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow('Private bank');
const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] });
db()
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
.run(JSON.stringify(payload), stage.id);
expect(() => hosts.getHostOperationPayload(host.id, stage.id)).toThrow('Private bank');
const healthyRead = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg'
});
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).toContain(
healthyRead.id
);
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).not.toContain(
stage.id
);
const read = hosts.queueHostOperation(host.id, 'files.read', { path: 'configs/stray.cfg' });
db()
.prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?')
.run(JSON.stringify({ path: `configs/${filename}` }), `configs/${filename}`, read.id);
hosts.reportHostOperationStatus(host.id, read.id, {
state: 'completed',
result: { contentBase64: secret.toString('base64') }
});
expect(hosts.getHostOperation(read.id)?.result).toEqual({ redacted: true });
await evidenceIsPrivate();
// Even an old operation reference cannot authorize downloading captured credentials.
const { putFileBlob, referenceBlobs } = await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(secret)!;
referenceBlobs('operation', stage.id, [hash]);
db()
.prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
)
.run();
db()
.prepare(
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
)
.run(servers[0], filename, hash);
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
expect(() =>
GET({
request: new Request('https://test', {
headers: { Authorization: `Bearer ${credential}` }
}),
params: { sha256: hash }
})
).toThrow();
});
+49
View File
@@ -122,6 +122,55 @@ async function sustainedFailure() {
}
describe('Watchdog detection and ownership', () => {
it.each([false, true])(
'rescans legacy bank drift and preserves real differences (mixed=%s)',
async (mixed) => {
const { mkdir } = await import('node:fs/promises');
const { destOfKind } = await import('../../src/lib/server/config.js');
const { setMaintenancePolicy, pauseMaintenance } =
await import('../../src/lib/server/maintenance/policies.js');
setMaintenancePolicy('drift', false);
pauseMaintenance(true);
const dest = destOfKind(server, 'config');
await mkdir(dest, { recursive: true });
const secret = 'set tws_bank_token "ec_bank_watchdog_private"';
await writeFile(join(dest, 'electric_cherry_bank.cfg'), secret);
if (mixed) await writeFile(join(dest, 'stray.cfg'), 'real drift');
database
.db()
.prepare(
`INSERT INTO watchdog_incidents
(id,server,kind,state,summary,details,first_seen_at,last_seen_at,failures)
VALUES('legacy-bank',?,'drift','open','Old drift',?,?,?,1)`
)
.run(
server,
JSON.stringify({
files: [{ kind: 'config', relPath: 'electric_cherry_bank.cfg' }]
}),
new Date().toISOString(),
new Date().toISOString()
);
await watchdog.watchdogTick(true);
const row = database
.db()
.prepare('SELECT state,details FROM watchdog_incidents WHERE id=?')
.get('legacy-bank') as { state: string; details: string };
expect(row.state).toBe(mixed ? 'open' : 'resolved');
expect(
JSON.parse(row.details).files.map((file: { relPath: string }) => file.relPath)
).toEqual(mixed ? ['stray.cfg'] : []);
expect(operations()).toEqual([]);
expect(
database
.db()
.prepare('SELECT 1 FROM file_blobs WHERE instr(content,?)>0')
.all(Buffer.from(secret))
).toEqual([]);
expect(await readFile(join(dest, 'electric_cherry_bank.cfg'), 'utf8')).toBe(secret);
}
);
it('waits for status-monitor evidence without issuing its own routine probes', async () => {
down();
for (let minute = 0; minute < 4; minute++) {