223 lines
9.2 KiB
TypeScript
223 lines
9.2 KiB
TypeScript
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;
|
||
})();
|
||
}
|