// remote-container.ts import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as vscode from 'vscode'; import * as rd from 'readline'; const { NodeSSH } = require('node-ssh'); const { spawn } = require('child_process'); const net = require('net'); import * as utils from './utils'; import User from './user'; import DevstarAPIHandler from './devstar-api'; export default class RemoteContainer { private user: User; private sshProcesses?: Map; private portMappings: Map> = new Map(); constructor(user: User) { this.user = user; } public setUser(user: User) { this.user = user; } /** * 第一次打开远程项目 */ async firstOpenProject(host: string, hostname: string, port: number, username: string, path: string, context: vscode.ExtensionContext) { if (vscode.env.remoteName) { try { await vscode.commands.executeCommand('workbench.action.terminal.newLocal'); const terminal = vscode.window.terminals[vscode.window.terminals.length - 1]; if (terminal) { let devstarDomain: string | undefined = context.globalState.get("devstarDomain_" + vscode.env.sessionId); if (devstarDomain == undefined || devstarDomain == "") { devstarDomain = undefined; } const semver = require('semver'); const powershellVersion = context.globalState.get('powershellVersion'); const powershell_semver_compatible_version = semver.coerce(powershellVersion); let command = ''; if (devstarDomain === undefined) { if (powershellVersion === undefined) { command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`; } else if (semver.satisfies(powershell_semver_compatible_version, ">=5.1.26100")) { command = `code --new-window ; code --% --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`; } else { command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}"`; } } else { if (powershellVersion === undefined) { command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`; } else if (semver.satisfies(powershell_semver_compatible_version, ">=5.1.26100")) { command = `code --new-window ; code --% --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`; } else { command = `code --new-window && code --open-url "vscode://mengning.devstar/openProjectSkippingLoginCheck?host=${host}&hostname=${hostname}&port=${port}&username=${username}&path=${path}&devstar_domain=${devstarDomain}"`; } } terminal.sendText(command); } else { vscode.window.showErrorMessage('无法创建终端,请检查终端是否可用。'); } } catch (error) { const errorMessage = error instanceof Error ? error.message : '未知错误'; vscode.window.showErrorMessage(`远程环境操作失败: ${errorMessage}`); } } else { try { await this.firstConnect(host, hostname, username, port, path) .then((res) => { if (res === 'success') { this.openRemoteFolder(host, port, username, path, context); } else { vscode.window.showErrorMessage('首次连接容器失败,请检查网络和容器状态。'); } }) .catch(error => { const errorMessage = error instanceof Error ? error.message : '未知错误'; vscode.window.showErrorMessage(`首次连接容器时发生错误: ${errorMessage}`); }); } catch (error) { const errorMessage = error instanceof Error ? error.message : '未知错误'; vscode.window.showErrorMessage(`打开项目失败: ${errorMessage}`); } } } /** * local environment,第一次连接其他项目 */ async firstConnect(host: string, hostname: string, _username: string, port: number, projectPath?: string): Promise { return new Promise(async (resolve, reject) => { const ssh = new NodeSSH(); vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t("Installing vscode-server and devstar extension in container"), cancellable: false }, async (progress) => { try { if (!this.user.existUserPrivateKey() || !this.user.existUserPublicKey()) { await this.user.createUserSSHKey(); const devstarAPIHandler = new DevstarAPIHandler(); const uploadResult = await devstarAPIHandler.uploadUserPublicKey(this.user); if (uploadResult !== "ok") { throw new Error('Upload public key failed.'); } } } catch (error) { reject(error); return; } try { await ssh.connect({ host: hostname, username: 'root', port: port, privateKeyPath: this.user.getUserPrivateKeyPath(), readyTimeout: 30000, onKeyboardInteractive: ( _name: string, _instructions: string, _instructionsLang: string, _prompts: any[], finish: (responses: string[]) => void ) => { finish([]); } }); progress.report({ message: vscode.l10n.t("Connected! Start installation") }); const vscodeCommitId = await utils.getVsCodeCommitId(); if ("" !== vscodeCommitId) { const vscodeServerUrl = `https://vscode.download.prss.microsoft.com/dbazure/download/stable/${vscodeCommitId}/vscode-server-linux-x64.tar.gz`; const installVscodeServerScript = ` mkdir -p ~/.vscode-server/bin/${vscodeCommitId} && \\ if [ "$(ls -A ~/.vscode-server/bin/${vscodeCommitId})" ]; then echo "VSCode server already exists, installing extension only" ~/.vscode-server/bin/${vscodeCommitId}/bin/code-server --install-extension mengning.devstar else echo "Downloading and installing VSCode server" wget ${vscodeServerUrl} -O vscode-server-linux-x64.tar.gz && \\ mv vscode-server-linux-x64.tar.gz ~/.vscode-server/bin/${vscodeCommitId} && \\ cd ~/.vscode-server/bin/${vscodeCommitId} && \\ tar -xvzf vscode-server-linux-x64.tar.gz --strip-components 1 && \\ rm vscode-server-linux-x64.tar.gz && \\ ~/.vscode-server/bin/${vscodeCommitId}/bin/code-server --install-extension mengning.devstar fi `; const installResult = await ssh.execCommand(installVscodeServerScript); if (installResult.code === 0) { vscode.window.showInformationMessage(vscode.l10n.t('Installation completed!')); } else { throw new Error(`Installation failed with exit code ${installResult.code}: ${installResult.stderr}`); } } else { throw new Error('Failed to get VSCode commit ID'); } if (projectPath) { } await ssh.dispose(); await this.storeProjectSSHInfo(host, hostname, port, 'root'); resolve('success'); } catch (error) { try { await ssh.dispose(); } catch (disposeError) { } reject(error); } }); }); } /** * 查找可用的本地端口 - 优先使用相同端口 */ private async findAvailableLocalPort(containerPort: number): Promise { if (await this.isPortAvailable(containerPort)) { return containerPort; } for (let offset = 1; offset <= 10; offset++) { const port1 = containerPort + offset; if (port1 < 65536 && await this.isPortAvailable(port1)) { return port1; } const port2 = containerPort - offset; if (port2 > 0 && await this.isPortAvailable(port2)) { return port2; } } return this.findRandomAvailablePort(); } /** * 检查端口是否可用 */ private async isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); server.unref(); server.on('error', () => { resolve(false); }); server.listen(port, () => { server.close(() => { resolve(true); }); }); }); } /** * 查找随机可用端口 */ private async findRandomAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, () => { const port = server.address().port; server.close(() => { resolve(port); }); }); }); } /** * 创建 SSH 端口转发 */ private async createSSHPortForward(hostname: string, sshPort: number, containerPort: number, localPort: number): Promise { return new Promise((resolve, reject) => { const sshArgs = [ '-N', '-L', `${localPort}:localhost:${containerPort}`, '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-p', sshPort.toString(), '-i', this.user.getUserPrivateKeyPath(), `root@${hostname}` ]; // 使用 detached 选项让 SSH 进程独立运行,不随父进程退出 const sshProcess = spawn('ssh', sshArgs, { detached: true, // 让进程在后台独立运行 stdio: 'ignore' // 忽略输入输出,避免进程挂起 }); // 解除父进程对子进程的引用,使其真正独立 sshProcess.unref(); sshProcess.on('error', (error: Error) => { reject(error); }); // 由于使用了 stdio: 'ignore',这些事件监听器不再需要 // sshProcess.stdout.on('data', (data: Buffer) => { // console.log(`[SSH stdout] ${data.toString()}`); // }); // sshProcess.stderr.on('data', (data: Buffer) => { // console.error(`[SSH stderr] ${data.toString()}`); // }); // 注意:由于进程已 detached 和 unref,不再需要保存到 sshProcesses Map // 因为我们无法也不需要控制这些独立进程的生命周期 // 等待 SSH 连接建立 setTimeout(() => { resolve(); }, 2000); }); } /** * 显示端口映射汇总信息 */ private showPortMappingsSummary(portMappings: Array<{ containerPort: number, localPort: number, label: string, source: string }>): void { let message = `🎯 已建立 ${portMappings.length} 个端口映射:\n\n`; portMappings.forEach(mapping => { const samePort = mapping.containerPort === mapping.localPort ? " ✅" : " 🔄"; message += `• ${mapping.label}\n`; message += ` 容器端口: ${mapping.containerPort} → 本地端口: ${mapping.localPort}${samePort}\n`; message += ` 访问地址: http://localhost:${mapping.localPort}\n`; message += ` 配置来源: ${mapping.source}\n\n`; }); vscode.window.showInformationMessage(message, '复制映射信息', '在浏览器中打开', '查看详细信息') .then(selection => { if (selection === '复制映射信息') { const copyText = portMappings.map(m => `${m.label}: http://localhost:${m.localPort} (容器端口: ${m.containerPort})` ).join('\n'); vscode.env.clipboard.writeText(copyText); vscode.window.showInformationMessage('端口映射信息已复制到剪贴板'); } else if (selection === '在浏览器中打开' && portMappings.length > 0) { vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${portMappings[0].localPort}`)); } else if (selection === '查看详细信息') { } }); } /** * 本地环境,保存项目的ssh连接信息 */ async storeProjectSSHInfo(host: string, hostname: string, port: number, username: string): Promise { const sshConfigPath = path.join(os.homedir(), '.ssh', 'config'); let canAppendSSHConfig = true; if (fs.existsSync(sshConfigPath)) { const reader = rd.createInterface(fs.createReadStream(sshConfigPath)); for await (const line of reader) { if (line.includes(`Host ${host}`)) { canAppendSSHConfig = false; break; } } } if (canAppendSSHConfig) { const privateKeyPath = this.user.getUserPrivateKeyPath(); const newSShConfigContent = `\nHost ${host}\n HostName ${hostname}\n Port ${port}\n User ${username}\n PreferredAuthentications publickey\n IdentityFile ${privateKeyPath}\n `; try { fs.writeFileSync(sshConfigPath, newSShConfigContent, { encoding: 'utf8', flag: 'a' }); } catch (error) { throw error; } } } /** * local env */ async openRemoteFolder(host: string, port: number, _username: string, path: string, context: vscode.ExtensionContext): Promise { try { const sshConfig = await this.getSSHConfig(host); if (sshConfig) { try { // 调用 setupPortForwardingFromGlobalState 方法 await this.setupPortForwardingFromGlobalState(sshConfig.hostname, port, context); } catch (portError) { vscode.window.showWarningMessage('端口映射设置失败,但容器连接已建立'); } } let terminal = vscode.window.activeTerminal || vscode.window.createTerminal(`Ext Terminal`); terminal.show(true); const command = `code --remote ssh-remote+root@${host}:${port} ${path} --reuse-window`; terminal.sendText(command); } catch (error) { const errorMessage = error instanceof Error ? error.message : '未知错误'; vscode.window.showErrorMessage(`打开远程文件夹失败: ${errorMessage}`); } } /** * 从 SSH config 获取主机配置 */ private async getSSHConfig(host: string): Promise<{ hostname: string; port: number } | null> { const sshConfigPath = path.join(os.homedir(), '.ssh', 'config'); if (!fs.existsSync(sshConfigPath)) { return null; } const configContent = fs.readFileSync(sshConfigPath, 'utf8'); const lines = configContent.split('\n'); let currentHost = ''; let hostname = ''; let port = 22; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('Host ')) { currentHost = trimmed.substring(5).trim(); } else if (currentHost === host) { if (trimmed.startsWith('HostName ')) { hostname = trimmed.substring(9).trim(); } else if (trimmed.startsWith('Port ')) { port = parseInt(trimmed.substring(5).trim()); } } if (hostname && currentHost === host) { return { hostname, port }; } } return null; } /** * 从 globalState 获取 forwardPorts 并建立端口映射 */ public async setupPortForwardingFromGlobalState(hostname: string, port: number, context: vscode.ExtensionContext): Promise { // 从 globalState 获取 forwardPorts 参数 const forwardPorts: number[] | undefined = context.globalState.get('forwardPorts'); if (forwardPorts && forwardPorts.length > 0) { const portMappings: Array<{ containerPort: number, localPort: number, label: string, source: string }> = []; for (const containerPort of forwardPorts) { const localPort = await this.findAvailableLocalPort(containerPort); try { await this.createSSHPortForward(hostname, port, containerPort, localPort); portMappings.push({ containerPort, localPort, label: `Port ${containerPort}`, source: 'globalState forwardPorts' }); } catch (error) { console.error(`映射容器端口 ${containerPort} 到本地端口 ${localPort} 失败:`, error); } } const mappingKey = `${hostname}:${port}`; this.portMappings.set(mappingKey, portMappings); if (portMappings.length > 0) { this.showPortMappingsSummary(portMappings); } // 使用完毕后立即清除 globalState 中的 forwardPorts,避免影响下一个项目 console.log('端口映射完成,清除 globalState 中的 forwardPorts'); context.globalState.update('forwardPorts', undefined); } else { console.log('未找到 forwardPorts 参数,跳过端口映射设置。'); } } } /** * 打开项目(无须插件登录) */ export async function openProjectWithoutLogging(hostname: string, port: number, username: string, path: string): Promise { const command = `code --remote ssh-remote+${username}@${hostname}:${port} ${path} --reuse-window`; try { let terminal = vscode.window.activeTerminal || vscode.window.createTerminal(`Ext Terminal`); terminal.show(true); terminal.sendText(command); } catch (error) { const errorMessage = error instanceof Error ? error.message : '未知错误'; vscode.window.showErrorMessage(`无登录打开项目失败: ${errorMessage}`); } }