106 lines
12 KiB
TypeScript
106 lines
12 KiB
TypeScript
import { z } from "zod"
|
|
import { randomUUID } from "node:crypto"
|
|
import { pool } from "../../db/index.js"
|
|
import { hasPermission } from "../../lib/permissions.js"
|
|
import { assertVerifiedConfigurationAccess } from "../../services/config-mutation-policy.js"
|
|
import { wireguardInterfaceCreateSchema, wireguardCreateKey, assertWireguardCreateAllowed, readWireguardInterfaceCreateState, createWireguardInterface, wireguardCreateStages, wireguardCreateProtectionProof } from "../../services/wireguard-interface-create.js"
|
|
import { effectiveMcpConnection } from "./store.js"
|
|
import { routerClient } from "./resources.js"
|
|
import { fingerprint, McpError, decryptSecret, privateSecretFingerprint, redact } from "./security.js"
|
|
import { registerMcpService } from "./service-registry.js"
|
|
import type { add } from "./actions.js"
|
|
|
|
export const WIREGUARD_INTERFACE_KEY_PURPOSE = "wireguard-interface-private-key"
|
|
export const WIREGUARD_PEER_KEY_PURPOSE = "wireguard-peer-private-key"
|
|
const peer = wireguardInterfaceCreateSchema.shape.peer.unwrap().omit({ privateKey: true }).extend({ privateKeyRef: z.string().uuid().optional(), privateKeyMode: z.enum(["auto", "none"]).optional() }).strict().refine(value => !(value.privateKeyRef && value.privateKeyMode), "Выберите ссылку или режим ключа")
|
|
export const wireguardInterfaceCreatePlanSchema = wireguardInterfaceCreateSchema.omit({ privateKey: true, peer: true }).extend({ privateKeyRef: z.string().uuid().optional(), peer: peer.optional() }).strict()
|
|
const fail = (code: string, message: string) => new McpError(code, message, 409)
|
|
async function access(input: Record<string, unknown>, signal?: AbortSignal) {
|
|
if (signal?.aborted) throw fail("READ_CANCELLED", "Подготовка WireGuard отменена")
|
|
const principal = z.object({ connectionId: z.string().uuid(), credentialId: z.string().uuid(), credentialExpiresAt: z.string() }).strict().parse(input.principal)
|
|
const current = await effectiveMcpConnection(principal)
|
|
if (current.owner_id !== input.ownerId || !hasPermission(current.policy.permissions, "mm:network:write") || !hasPermission(current.owner_permissions, "mm:network:write")) throw fail("PERMISSION_DENIED", "Допуск WireGuard отозван")
|
|
if (!current.policy.scope.serverIds.includes(Number(input.serverId))) throw fail("SERVER_DENIED", "Сервер вне области допуска")
|
|
if (fingerprint(current.policy) !== input.policyProof || fingerprint(current.owner_permissions) !== input.ownerProof) throw fail("RESET_REQUIRED", "Допуск изменился; подготовьте новый план")
|
|
}
|
|
async function material(input: Record<string, unknown>, signal?: AbortSignal) {
|
|
await access(input, signal)
|
|
const request = wireguardInterfaceCreatePlanSchema.parse(input.request), references: string[] = []
|
|
if (input.requestProof !== fingerprint(request)) throw fail("STATE_CONFLICT", "Параметры WireGuard не соответствуют сохранённому proof")
|
|
const secret = async (id: string | undefined, purpose: string) => {
|
|
if (!id) return undefined
|
|
await access(input, signal)
|
|
const row = (await pool.query("SELECT purpose,server_id,created_by,CASE WHEN octet_length(ciphertext)<=16384 THEN ciphertext ELSE NULL END AS ciphertext FROM mcp_secrets WHERE id=$1 AND created_by=$2", [id, input.ownerId])).rows[0]
|
|
if (!row || row.server_id !== request.serverId || row.purpose !== purpose || typeof row.ciphertext !== "string") throw fail("SECRET_UNAVAILABLE", "Ссылка не выдана владельцу для этого ключа и сервера WireGuard")
|
|
let value: unknown
|
|
try { value = await decryptSecret(row.ciphertext) } catch { throw fail("SECRET_UNAVAILABLE", "Ключ WireGuard недоступен") }
|
|
const parsed = wireguardCreateKey.safeParse(value)
|
|
if (!parsed.success) throw fail("SECRET_UNAVAILABLE", "Неподдерживаемый формат ключа WireGuard")
|
|
references.push(await privateSecretFingerprint(fingerprint({ id, ...row }), "wireguard-create-reference-v1"))
|
|
return parsed.data
|
|
}
|
|
const privateKey = await secret(request.privateKeyRef, WIREGUARD_INTERFACE_KEY_PURPOSE), peerKey = await secret(request.peer?.privateKeyRef, WIREGUARD_PEER_KEY_PURPOSE)
|
|
const { privateKeyRef: _privateKeyRef, peer: requestedPeer, ...fields } = request
|
|
let nativePeer
|
|
if (requestedPeer) { const { privateKeyRef: _peerRef, privateKeyMode, ...publicFields } = requestedPeer; nativePeer = { ...publicFields, privateKey: peerKey ?? privateKeyMode } }
|
|
await access(input, signal)
|
|
return { body: wireguardInterfaceCreateSchema.parse({ ...fields, privateKey, peer: nativePeer }), referenceProof: fingerprint(references) }
|
|
}
|
|
export function registerWireguardInterfaceCreateActions(register: typeof add) {
|
|
const source = async (input: Record<string, unknown>, signal?: AbortSignal) => {
|
|
const keys = await material(input, signal)
|
|
const protectionProof = await wireguardCreateProtectionProof(Number(input.serverId))
|
|
const authorize = async () => {
|
|
const current = await material(input, signal)
|
|
if (current.referenceProof !== keys.referenceProof) throw fail("STATE_CONFLICT", "Ссылка на ключ изменилась")
|
|
if (await wireguardCreateProtectionProof(Number(input.serverId)) !== protectionProof) throw fail("STATE_CONFLICT", "Защиты WireGuard изменились")
|
|
}
|
|
const { server } = await routerClient(Number(input.serverId)), state = await readWireguardInterfaceCreateState(server, authorize)
|
|
await authorize()
|
|
return { ...keys, state, server, authorize, publicState: { proof: state.proof, referenceProof: keys.referenceProof, protectionProof } }
|
|
}
|
|
registerMcpService("network.wireguard.interfaces.create", {
|
|
current: async input => (await source(input)).publicState,
|
|
execute: async input => {
|
|
const before = await source(input)
|
|
if (fingerprint(before.publicState) !== input.beforeFingerprint) throw fail("STATE_CONFLICT", "Источник, ключи или зависимости WireGuard изменились")
|
|
return createWireguardInterface(before.server, before.body, before.state.proof, async () => { await assertVerifiedConfigurationAccess(); await before.authorize() })
|
|
},
|
|
verify: async (input, result) => { const receipt = result as { verified?: boolean; afterProof?: string } | null; return receipt?.verified === true && (await source(input)).state.proof === receipt.afterProof },
|
|
reconcile: async (input, authorize) => {
|
|
if (input.proofRevision !== 1 || typeof input.expectedProof !== "string" || !/^[a-f0-9]{64}$/.test(input.expectedProof) || input.requestProof !== fingerprint(input.request)) throw fail("UNSUPPORTED_CAPABILITY", "Нет совместимого proof создания WireGuard")
|
|
await authorize()
|
|
const latest = await source(input)
|
|
if (latest.referenceProof !== input.expectedReferenceProof) throw fail("STATE_CONFLICT", "Ссылка на ключ изменилась после подготовки")
|
|
const stages = wireguardCreateStages(latest.body)
|
|
if (!z.string().uuid().safeParse(input.receiptId).success) throw fail("UNSUPPORTED_CAPABILITY", "Нет идентификатора проверенного checkpoint WireGuard")
|
|
const principal = input.principal as { connectionId: string }
|
|
const receipts = (await pool.query("SELECT s.result FROM mcp_operation_steps s JOIN mcp_operations o ON o.id=s.operation_id WHERE o.connection_id=$1 AND s.state='verified' AND s.intended_state=$2::jsonb AND s.result->>'verified'='true' LIMIT 2", [principal.connectionId, JSON.stringify(redact({ kind: "service", service: "network.wireguard.interfaces.create", input }))])).rows
|
|
await authorize()
|
|
const fresh = await source(input)
|
|
if (fingerprint(fresh.publicState) !== fingerprint(latest.publicState)) throw fail("STATE_CONFLICT", "WireGuard изменился во время сверки")
|
|
const afterProof = receipts.length === 1 && /^[a-f0-9]{64}$/.test(receipts[0].result?.afterProof ?? "") ? receipts[0].result.afterProof as string : undefined
|
|
const original = latest.state.proof === input.expectedProof, desired = afterProof !== undefined && latest.state.proof === afterProof
|
|
await authorize()
|
|
return { matchesOriginalState: original, matchesDesiredState: desired, matchesExpectedPrefixState: original || desired, verifiedPrefixCommands: desired ? stages.length : original ? 0 : null, verifiedFinalCheckpointAvailable: afterProof !== undefined, forwardingVerified: false }
|
|
},
|
|
})
|
|
register("network.wireguard.interfaces.create", "mm_wireguard_interface_create_plan", "Создать WireGuard, необязательный адрес и peer с отключённой подготовкой", "network", "mm:network:write", "write", "servers", wireguardInterfaceCreatePlanSchema, {
|
|
version: 3,
|
|
prepare: async (input, context) => {
|
|
// Seal the same JSON representation that survives durable plan storage.
|
|
const request = wireguardInterfaceCreatePlanSchema.parse(JSON.parse(JSON.stringify(input))), internal = { request, requestProof: fingerprint(request), serverId: request.serverId, principal: context.principal, ownerId: context.connection.owner_id, policyProof: fingerprint(context.connection.policy), ownerProof: fingerprint(context.connection.owner_permissions) }
|
|
const before = await source(internal, context.signal)
|
|
assertWireguardCreateAllowed(before.state, before.body)
|
|
const objects = [{ kind: "server", id: String(request.serverId), serverId: request.serverId }, { kind: "wireguard", id: request.name, serverId: request.serverId }]
|
|
const protections = await pool.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=$1 OR (object_ref->>'kind'='server' AND object_ref->>'id'=$1) LIMIT 1001", [String(request.serverId)])
|
|
if (protections.rows.length > 1000) throw fail("STATE_UNAVAILABLE", "Превышен предел защит WireGuard")
|
|
for (const { object_ref: ref } of protections.rows) objects.push({ kind: ref.kind, id: ref.id, serverId: request.serverId })
|
|
await before.authorize()
|
|
return { target: { scope: "servers", serverIds: [request.serverId] }, objects, snapshots: [], requiresHuman: true, mutationCost: wireguardCreateStages(before.body).length + 1, before: before.publicState,
|
|
steps: [{ kind: "service", service: "network.wireguard.interfaces.create", input: { ...internal, receiptId: randomUUID(), expectedProof: before.state.proof, expectedReferenceProof: before.referenceProof, proofRevision: 1, beforeFingerprint: fingerprint(before.publicState) } }],
|
|
warnings: ["Интерфейс, необязательные адрес и peer создаются отключёнными. Затем отдельными командами включаются запрошенные объекты; операция не атомарна и может остановиться в промежуточном состоянии.", "Ключи принимаются только через owner/purpose/server secret reference; без ключа интерфейс генерирует RouterOS. Приватные ключи не возвращаются модели.", "Каждая команда требует свежий источник и read-back; потеря ответа останавливает выполнение без повтора. Сверка подтверждает исходный proof или сохранённый проверенный конечный checkpoint; без checkpoint достижение результата не объявляется. Автоматического отката нет.", "Передача трафика, handshake, firewall и маршрутизация требуют отдельной приёмки."] }
|
|
},
|
|
})
|
|
}
|