Files
MikrotikManager/backend/src/modules/mcp/database-restore.ts
T

170 lines
16 KiB
TypeScript

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<typeof metadataSchema>
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<Archive> {
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<string, unknown> }
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<string, unknown> = { 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. */}
}
}