import type Database from 'better-sqlite3'; import { createRegistry } from '../mcp/registry.js'; import type { McpServerPublic } from '../mcp/types.js'; import { logger } from '../logger.js'; import { createConnectionRepo, type SshConnection } from '../ssh/connection-repo.js'; import { decryptPrivateKey, encryptPrivateKey } from '../ssh/crypto.js'; export interface TransferDeps { db: Database.Database; } export interface TransferCandidate { type: 'mcp' | 'ssh'; id: string; name: string; detail: string; willSkip: boolean; skipReason?: string; } export interface TransferResult { copied: Array<{ type: 'mcp' | 'ssh'; id: string; name: string }>; skipped: Array<{ type: 'mcp' | 'ssh'; name: string; reason: string }>; } /** 移し先に同名 or 同 url の MCP があるか。 */ function mcpSkipReason(target: McpServerPublic[], src: McpServerPublic): string | null { if (target.some((t) => t.name === src.name)) return `同名のサーバが既にあります(${src.name})`; if (target.some((t) => t.url === src.url)) return `同じ接続先が既にあります(${src.url})`; return null; } export function listMcpCandidates( deps: TransferDeps, sourceSpaceId: string, targetSpaceId: string, ): TransferCandidate[] { const reg = createRegistry(deps.db); const target = reg.listForSpace(targetSpaceId); return reg.listForSpace(sourceSpaceId).map((s) => { const reason = mcpSkipReason(target, s); return { type: 'mcp' as const, id: s.id, name: s.name, detail: s.url, willSkip: reason !== null, skipReason: reason ?? undefined, }; }); } export function transferMcpServer( deps: TransferDeps, sourceId: string, sourceSpaceId: string, targetSpaceId: string, actorId: string, ): { copied?: { type: 'mcp'; id: string; name: string }; skipped?: { type: 'mcp'; name: string; reason: string } } { const reg = createRegistry(deps.db); // 元WSへの所属を「復号せずに」確認する(SSH 経路と対称)。listForSpace は // McpServerPublic(シークレット非返却)なので、他スペースの id では相手の // シークレットをメモリに展開しないまま skip できる(IDOR 対策の前段)。 if (!reg.listForSpace(sourceSpaceId).some((s) => s.id === sourceId)) { // listPublic も McpServerPublic(シークレット非返却)。存在しない id か、 // 他スペースの id かを復号せずに区別して正確な理由を返す(SSH 経路と対称)。 const existsElsewhere = reg.listPublic().some((s) => s.id === sourceId); return { skipped: { type: 'mcp', name: sourceId, reason: existsElsewhere ? '指定元ワークスペースに属さない' : '見つかりません', }, }; } const src = reg.getDecrypted(sourceId); if (!src) return { skipped: { type: 'mcp', name: sourceId, reason: '見つかりません' } }; const reason = mcpSkipReason(reg.listForSpace(targetSpaceId), src); if (reason) return { skipped: { type: 'mcp', name: src.name, reason } }; const newId = `${src.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}`; reg.upsert({ id: newId, name: src.name, url: src.url, authKind: src.authKind, ownerId: null, // スペース所有(owner 束縛しない) spaceId: targetSpaceId, oauthClientId: src.oauthClientId, oauthClientSecret: src.oauthClientSecret, oauthScopes: src.oauthScopes, staticToken: src.staticToken ?? undefined, authHeaderName: src.authHeaderName, enabled: src.enabled, createdBy: actorId, }); logger.info(`[cred-transfer] mcp copied src=${sourceId} -> space=${targetSpaceId} id=${newId}`); return { copied: { type: 'mcp', id: newId, name: src.name } }; } // ──────────────────────────────────────────────────────────────────────────── // SSH 経路(DEK ラウンドトリップ) // ──────────────────────────────────────────────────────────────────────────── /** 移し先に同名 or 同接続先の SSH 接続があるか。 */ function sshSkipReason(target: SshConnection[], src: SshConnection): string | null { if (target.some((t) => t.label === src.label)) return `同名の接続が既にあります(${src.label})`; if (target.some((t) => t.host === src.host && t.username === src.username && t.port === src.port)) return `同じ接続先が既にあります(${src.username}@${src.host}:${src.port})`; return null; } export function listSshCandidates( deps: TransferDeps, sourceSpaceId: string, targetSpaceId: string, ): TransferCandidate[] { const repo = createConnectionRepo(deps.db); const target = repo.listBySpace(targetSpaceId); return repo.listBySpace(sourceSpaceId).map((s) => { const reason = sshSkipReason(target, s); return { type: 'ssh' as const, id: s.id, name: s.label, detail: `${s.username}@${s.host}:${s.port}`, willSkip: reason !== null, skipReason: reason ?? undefined, }; }); } export function transferSshConnection( deps: TransferDeps, sourceId: string, sourceSpaceId: string, targetSpaceId: string, actorId: string, ): { copied?: { type: 'ssh'; id: string; name: string }; skipped?: { type: 'ssh'; name: string; reason: string } } { const repo = createConnectionRepo(deps.db); const src = repo.resolveConnection(sourceId); if (!src) return { skipped: { type: 'ssh', name: sourceId, reason: '見つかりません' } }; if (src.spaceId !== sourceSpaceId) { return { skipped: { type: 'ssh', name: src.label, reason: '指定元ワークスペースに属さない' } }; } const reason = sshSkipReason(repo.listBySpace(targetSpaceId), src); if (reason) return { skipped: { type: 'ssh', name: src.label, reason } }; // 元WSのDEKで復号 → 移し先WSのDEKで再暗号化。平文は即クリア。 const pem = decryptPrivateKey(deps.db, src.ownerId, src.privateKeyEnc, src.spaceId); let passphrase: Buffer | null = null; try { if (src.passphraseEnc) passphrase = decryptPrivateKey(deps.db, src.ownerId, src.passphraseEnc, src.spaceId); const pk = encryptPrivateKey(deps.db, null, pem, targetSpaceId); const pp = passphrase ? encryptPrivateKey(deps.db, null, passphrase, targetSpaceId) : null; const created = repo.create({ ownerId: null, // スペース所有 spaceId: targetSpaceId, label: src.label, host: src.host, port: src.port, username: src.username, privateKeyEnc: pk.blob, passphraseEnc: pp ? pp.blob : null, keyVersion: pk.keyVersion, keyFingerprint: src.keyFingerprint ?? 'SHA256:unknown', remotePathPrefix: src.remotePathPrefix, allowRemoteUnrestricted: src.allowRemoteUnrestricted, allowPrivateAddresses: src.allowPrivateAddresses, commandDenyPatterns: src.commandDenyPatterns, commandAllowPatterns: src.commandAllowPatterns, }); logger.info(`[cred-transfer] ssh copied src=${sourceId} -> space=${targetSpaceId} id=${created.id}`); return { copied: { type: 'ssh', id: created.id, name: src.label } }; } finally { pem.fill(0); passphrase?.fill(0); } } // ──────────────────────────────────────────────────────────────────────────── // オーケストレーション(MCP + SSH 統合) // ──────────────────────────────────────────────────────────────────────────── /** スペース間転送の候補一覧を MCP・SSH それぞれ返す。 */ export function listTransferCandidates( deps: TransferDeps, sourceSpaceId: string, targetSpaceId: string, ): { mcp: TransferCandidate[]; ssh: TransferCandidate[] } { return { mcp: listMcpCandidates(deps, sourceSpaceId, targetSpaceId), ssh: listSshCandidates(deps, sourceSpaceId, targetSpaceId), }; } /** 指定した MCP サーバー・SSH 接続を一括転送し、copied/skipped のサマリを返す。 */ export function executeTransfer( deps: TransferDeps, opts: { sourceSpaceId: string; targetSpaceId: string; actorId: string; mcpServerIds: string[]; sshConnectionIds: string[]; }, ): TransferResult { return deps.db.transaction(() => { const result: TransferResult = { copied: [], skipped: [] }; for (const id of opts.mcpServerIds) { const r = transferMcpServer(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId); if (r.copied) result.copied.push(r.copied); if (r.skipped) result.skipped.push(r.skipped); } for (const id of opts.sshConnectionIds) { const r = transferSshConnection(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId); if (r.copied) result.copied.push(r.copied); if (r.skipped) result.skipped.push(r.skipped); } return result; })(); }