import { createHash, randomUUID } from "node:crypto" import { mkdir, open, readFile, readdir, unlink } from "node:fs/promises" import path from "node:path" import { z } from "zod" import { pool } from "../../db/index.js" import { hasPermission, type AuthUser } from "../../lib/permissions.js" import { validateSystemDatabaseRestoreArchive } from "../../services/system-database-restore-execution.js" import { restoreSystemDatabaseBackup, assertSystemDatabaseRestoreTools, withSystemDatabaseRestoreAdmission } from "../../services/system-database-backup.js" import { withVerifiedConfigurationChange } from "../../services/config-mutation-policy.js" import { accessGeneration, decryptBinary, encryptBinary, decryptSecret, McpError, mcpStateDirectory } from "./security.js" import { audit, validatePrincipal, type McpPrincipal } from "./store.js" import { publishStateFile } from "./state-file-publish.js" import type { add } from "./actions.js" import type { ChangeEvidence } from "./action-types.js" import { withApplicationMutationLock } from "../../services/config-mutation-lock.js" import {assertOriginalMcpTemporaryGrant,type OriginalServiceGrant} from "./temporary-grant-origin.js" import {reconcileRestoreReceiptAudit,inspectRestoreReceiptAudit} from "../../services/database-restore-audit.js" export const MAX_RESTORE_ARCHIVE_BYTES = 64 * 1024 * 1024 const metadataSchema = z.object({ id: z.string().uuid(), ownerId: z.string().min(1), sha256: z.string().regex(/^[a-f0-9]{64}$/), sizeBytes: z.number().int().positive().max(MAX_RESTORE_ARCHIVE_BYTES), generation: z.string(), expiresAt: z.string().datetime(), createdAt: z.string().datetime() }).strict() type Archive = z.infer const vault = () => path.join(mcpStateDirectory(), "restore-vault") const filename = (id: string, suffix: string) => path.join(vault(), z.string().uuid().parse(id) + suffix) async function durableWrite(target: string, content: Buffer | string, exclusive = false) { await mkdir(vault(), { recursive: true, mode: 0o700 }) const destination = exclusive ? target : target + "." + randomUUID() + ".tmp" const handle = await open(destination, "wx", 0o600) try { await handle.writeFile(content); await handle.sync() } finally { await handle.close() } if (!exclusive) { try { await publishStateFile(destination, target) } catch (error) { await unlink(destination).catch(() => {}); throw error } } } async function archive(id: string, ownerId: string): Promise { let data: Archive try { data = metadataSchema.parse(JSON.parse(await readFile(filename(id, ".archive.json"), "utf8"))) } catch { throw new McpError("NOT_FOUND", "Архив восстановления не найден", 404) } if (data.id !== id || data.ownerId !== ownerId) throw new McpError("NOT_FOUND", "Архив восстановления не найден", 404) if (Date.parse(data.expiresAt) <= Date.now() || data.generation !== await accessGeneration()) throw new McpError("PLAN_EXPIRED", "Архив или поколение доступа устарели", 409) try { await readFile(filename(id, ".used")); throw new McpError("STATE_CONFLICT", "Архив уже использован; для нового восстановления загрузите его заново", 409) } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error } return data } export async function uploadRestoreArchive(buffer: Buffer, actor: AuthUser) { return withApplicationMutationLock(() => uploadArchive(buffer, actor)) } async function uploadArchive(buffer: Buffer, actor: AuthUser) { if (!hasPermission(actor.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Для архива нужны административные права системы") if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > MAX_RESTORE_ARCHIVE_BYTES) throw new McpError("INVALID_INPUT", "Ожидается архив PostgreSQL размером до 64 МиБ", 400) validateSystemDatabaseRestoreArchive(buffer) await mkdir(vault(), { recursive: true, mode: 0o700 }) const files = (await readdir(vault())).filter(file => /^[a-f0-9-]{36}\.archive\.json$/.test(file)) let active = 0 for (const file of files) { const raw = metadataSchema.safeParse(JSON.parse(await readFile(path.join(vault(), file), "utf8"))) if (!raw.success) continue if (Date.parse(raw.data.expiresAt) > Date.now()) { active++; continue } if (file !== raw.data.id + ".archive.json") continue await unlink(filename(raw.data.id, ".archive.bin")).catch(() => {}) await unlink(filename(raw.data.id, ".archive.json")).catch(() => {}) } if (active >= 4) throw new McpError("BUDGET_EXCEEDED", "Уже загружены четыре действующих архива; дождитесь их истечения", 429) const data: Archive = { id: randomUUID(), ownerId: actor.id, sha256: createHash("sha256").update(buffer).digest("hex"), sizeBytes: buffer.length, generation: await accessGeneration(), createdAt: new Date().toISOString(), expiresAt: new Date(Math.min(Date.now() + 900000, actor.expiresAt ?? Date.now() + 900000)).toISOString() } try { await durableWrite(filename(data.id, ".archive.bin"), await encryptBinary(buffer), true) await durableWrite(filename(data.id, ".archive.json"), JSON.stringify(data), true) await audit(pool, actor.id, "database.archive.upload", null, { archiveRef: data.id, sha256: data.sha256, sizeBytes: data.sizeBytes, expiresAt: data.expiresAt }) } catch (error) { await unlink(filename(data.id, ".archive.bin")).catch(() => {}) await unlink(filename(data.id, ".archive.json")).catch(() => {}) throw error } return { archiveRef: data.id, sha256: data.sha256, sizeBytes: data.sizeBytes, expiresAt: data.expiresAt } } export async function readRestoreReceipt(id: string, actor: AuthUser) { if (!hasPermission(actor.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Нужны административные права системы") try { return JSON.parse(await readFile(filename(id, ".receipt.json"), "utf8")) as Record } catch { throw new McpError("NOT_FOUND", "Внешняя квитанция восстановления не найдена", 404) } } export async function readRestoreReceiptAuditStatus(id:string,actor:AuthUser){ await readRestoreReceipt(id,actor) return inspectRestoreReceiptAudit(pool,mcpStateDirectory(),id) } export function registerDatabaseRestore(register: typeof add) { register("database.restore", "mm_database_restore_plan", "Восстановить БД из загруженного человеком архива", "settings", "mm:settings:admin", "write", "global", z.object({ archiveRef: z.string().uuid(), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), { prepare: async (input, context) => { const source = await archive(String(input.archiveRef), context.connection.owner_id) if (source.sha256 !== input.expectedSha256) throw new McpError("STATE_CONFLICT", "Хеш архива не совпадает", 409) try { await assertSystemDatabaseRestoreTools() } catch { throw new McpError("UNSUPPORTED_CAPABILITY", "На backend недоступны pg_restore/psql; установите PostgreSQL client tools", 503) } const ids = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id)) if (ids.some(id => !context.connection.policy.scope.serverIds.includes(id))) throw new McpError("SERVER_DENIED", "Полное восстановление затрагивает серверы вне подключения") return { target: { scope: "global" }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], steps: [{ kind: "service", service: "database.restore", input: { archiveRef: source.id, sha256: source.sha256 } }], before: { archiveRef: source.id, sha256: source.sha256, sizeBytes: source.sizeBytes }, requiresHuman: true, warnings: ["Данные приложения будут заменены. Старые MCP-токены, grants и планы будут аннулированы до записи.", "После restore результат читается человеком из внешней квитанции: журнал исходной операции может исчезнуть вместе со старой БД.", "Неопределённый restore не повторяется. Для нового восстановления нужна новая загрузка архива."] } }, }) } /** This terminal operation must never hold a database-request admission while asking for an exclusive restore. */ export async function executeMcpDatabaseRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) { return withSystemDatabaseRestoreAdmission(async () => { const lease = await pool.connect() let locked = false, unsafe = false try { locked = (await lease.query<{ locked: boolean }>("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS locked", ["mcp-operation:" + operationId])).rows[0].locked if (locked) await executeRestore(operationId, principal,originalGrant) } finally { if (locked) try { await lease.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", ["mcp-operation:" + operationId]) } catch { unsafe = true } lease.release(unsafe) } }) } async function executeRestore(operationId: string, principal: McpPrincipal,originalGrant?:OriginalServiceGrant) { const { loadPlan } = await import("./runtime.js") const { rows: [operation] } = await pool.query("SELECT * FROM mcp_operations WHERE id=$1 AND connection_id=$2", [operationId, principal.connectionId]) if (!operation || operation.state !== "pending") return const plan = await loadPlan(operation.plan_id), evidence = JSON.parse(await decryptSecret(plan.evidence.sealed)) as ChangeEvidence const payload = JSON.parse(await decryptSecret(plan.payload.sealed)) as { archiveRef: string; expectedSha256: string } let receipt: Record = { operationId, archiveRef: payload.archiveRef, state: "running", startedAt: new Date().toISOString(), automaticRetry: false } try { await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt), true) } catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") return; throw error } let attempted = false, stage = "authority", restoreActorId:string|undefined const assertAuthority = async () => { await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant) const connection = await validatePrincipal(principal) if (!plan.approved_by || plan.state !== "executing" || plan.operation_id !== "database.restore" || plan.expires_at.getTime() <= Date.now() || plan.policy_version !== connection.policy.version || plan.generation !== await accessGeneration() || !connection.policy.scope.allowGlobal || !connection.policy.scope.operationIds.includes("database.restore") || !hasPermission(connection.owner_permissions, "mm:settings:admin") || !hasPermission(connection.policy.permissions, "mm:settings:admin")) throw new McpError("PERMISSION_DENIED", "Допуск или подтверждение восстановления устарели") const ids = (await pool.query("SELECT id FROM servers")).rows.map(row => Number(row.id)) if (ids.some(id => !connection.policy.scope.serverIds.includes(id) || !evidence.objects.some(object => object.kind === "server" && object.serverId === id))) throw new McpError("SERVER_DENIED", "Область восстановления изменилась") const { rows: grants } = await pool.query("SELECT payload FROM mcp_grants WHERE connection_id=$1 AND revoked_at IS NULL AND payload->>'credentialId'=$2", [connection.id, principal.credentialId]) if (grants.some(grant => !grant.payload.scope?.allowGlobal || !grant.payload.scope?.operationIds?.includes("database.restore") || ids.some(id => !grant.payload.scope?.serverIds?.includes(id)))) throw new McpError("PERMISSION_DENIED", "Временный допуск не разрешает восстановление") if ((await pool.query("SELECT 1 FROM mcp_protections WHERE object_ref->>'protection'='deny' LIMIT 1")).rows.length) throw new McpError("PROTECTED_OBJECT", "Восстановление заменяет запрещённый защищённый объект") return { connection, ids } } try { const { connection, ids } = await assertAuthority(), source = await archive(payload.archiveRef, connection.owner_id) restoreActorId=connection.owner_id stage = "archive-read" if (source.sha256 !== payload.expectedSha256) throw new McpError("STATE_CONFLICT", "Архив подменён", 409) const buffer = await decryptBinary(await readFile(filename(source.id, ".archive.bin"))) if (createHash("sha256").update(buffer).digest("hex") !== source.sha256) throw new McpError("STATE_CONFLICT", "Содержимое архива изменилось", 409) if (!(await pool.query("UPDATE mcp_operations SET state='running' WHERE id=$1 AND state='pending' RETURNING id", [operationId])).rowCount) throw new McpError("STATE_CONFLICT", "Операция уже остановлена", 409) stage = "restore" const outcome = await withVerifiedConfigurationChange({ actorId: connection.owner_id, connectionId: connection.id, serverIds: ids, objects: evidence.objects, approved: true, serverWide: true }, () => restoreSystemDatabaseBackup(buffer, { beforeRestore: async () => { await assertAuthority(); await archive(payload.archiveRef, connection.owner_id) await durableWrite(filename(source.id, ".used"), JSON.stringify({ operationId, at: new Date().toISOString() }), true) await assertOriginalMcpTemporaryGrant(pool,principal.connectionId,principal.credentialId,originalGrant) attempted = true } })) receipt = { ...receipt, state: "applied", finishedAt: new Date().toISOString(), accessRevoked: true, warnings: Object.keys(outcome).filter(key => key.endsWith("Error") && outcome[key as keyof typeof outcome]) } stage = "receipt-publish" await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt)) } catch (error) { // The isolated destructive acceptance runner can print bounded diagnostics // without archive SQL, server credentials, or arbitrary error text. if (!(error instanceof McpError) && process.env.MMAPP_OWN_TERMINAL_RESTORE === "true" && process.env.NODE_ENV === "test") { const message = error instanceof Error ? error.message : "" const kinds = ["pg_restore", "psql", "already exists", "does not exist", "cannot drop", "constraint", "permission denied", "deadlock", "lock timeout", "out of shared memory", "connection", "syntax error", "duplicate key"].filter(value => message.toLowerCase().includes(value)) const identifiers = [...message.matchAll(/\b(?:table|relation|constraint|index|column) "([a-z_][a-z0-9_]{0,100})"/g)].map(match => match[1]).slice(0, 10) const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^(?:E[A-Z]{1,20}|[0-9A-Z]{5})$/.test(error.code) ? error.code : null console.error("Owned restore failure diagnostic:", JSON.stringify({ stage, code, kinds, identifiers })) } receipt = { ...receipt, state: attempted ? "unknown" : "blocked", errorCode: error instanceof McpError ? error.code : "RESTORE_UNCONFIRMED", finishedAt: new Date().toISOString() } await durableWrite(filename(operationId, ".receipt.json"), JSON.stringify(receipt)) if (!attempted) await pool.query("UPDATE mcp_operations SET state='rejected',error_code=$2,finished_at=now() WHERE id=$1", [operationId, receipt.errorCode]).catch(() => {}) } // Audit failure is a separate pending/unconfirmed import AFTER durable // applied receipt publication. It must never relabel confirmed restore. if(receipt.state==="applied"){ try{await reconcileRestoreReceiptAudit(pool,mcpStateDirectory(),operationId,restoreActorId)}catch{/* Startup reconciles the existing external receipt; never replay restore. */} } }