From ca4fc9a03b80fb71ab4e91600804383b3291355a Mon Sep 17 00:00:00 2001 From: yetao Date: Tue, 29 Oct 2024 13:08:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor=F0=9F=8E=A8:=20=20(=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81)=EF=BC=9Aconnection.ts=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/connect.ts | 342 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 336 insertions(+), 6 deletions(-) diff --git a/src/connect.ts b/src/connect.ts index 4f3c3d0..c94f007 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -1,115 +1,222 @@ +// 导入 FileSystemConfig 类型,该类型定义了文件系统配置的结构 import type { FileSystemConfig } from 'common/fileSystemConfig'; +// 导入 fs 模块的 readFile 函数,用于读取文件 import { readFile } from 'fs'; +// 导入 net 模块的 Socket 类,用于创建网络套接字 import { Socket } from 'net'; +// 导入 os 模块的 userInfo 函数,用于获取当前用户的信息 import { userInfo } from 'os'; +// 导入 ssh2 模块的相关类型和函数,用于创建 SSH 连接和处理认证 import { AuthHandlerFunction, AuthHandlerObject, Client, ClientChannel, ConnectConfig } from 'ssh2'; +// 导入 ssh2 模块的 SFTP 类,用于创建 SFTP 客户端 import { SFTP } from 'ssh2/lib/protocol/SFTP'; +// 导入 vscode 模块,用于与 VS Code 编辑器进行交互 import * as vscode from 'vscode'; +// 从当前目录下的 config 模块中导入 getConfig 函数,用于获取配置信息 import { getConfig } from './config'; +// 从当前目录下的 flags 模块中导入 getFlagBoolean 函数,用于获取布尔类型的标志 import { getFlagBoolean } from './flags'; +// 从当前目录下的 logging 模块中导入 Logger 和 Logging 类型,用于日志记录 import { Logger, Logging } from './logging'; +// 导入 PuttySession 类型,该类型定义了 Putty 会话的结构 import type { PuttySession } from './putty'; +// 从当前目录下的 utils 模块中导入 toPromise 函数,用于将回调函数转换为 Promise。导入 validatePort 函数,用于验证端口号是否合法 import { toPromise, validatePort } from './utils'; +/** + * 默认的连接配置对象。 + * 这个对象包含了一些默认的配置选项,用于创建 SSH 连接。 + * @type {ConnectConfig} - 一个 ConnectConfig 对象,包含了默认的配置选项。 + */ const DEFAULT_CONFIG: ConnectConfig = { + // 尝试使用键盘交互进行认证 tryKeyboard: true, + // 设置保持连接的间隔时间为 30 秒(30e3 毫秒) keepaliveInterval: 30e3, }; +/** + * 替换字符串中的环境变量。 + * 这个函数会查找字符串中以 $ 符号开头的单词,并尝试将其替换为对应的环境变量值。 + * 如果环境变量不存在,则替换为空字符串。 + * @param string - 要替换的字符串。 + * @returns 替换后的字符串。 + */ function replaceVariables(string?: string) { + // 如果传入的参数不是字符串,则直接返回该参数 if (typeof string !== 'string') return string; + // 使用正则表达式匹配字符串中的环境变量,并将其替换为实际的值 return string.replace(/\$\w+/g, key => process.env[key.substr(1)] || ''); } +/** + * 定义了在提示用户输入文件系统配置时需要询问的字段。 + * 这个对象包含了每个字段的占位符、提示信息、是否在为空时提示以及是否是密码字段。 + * @type {Partial string, promptOnEmpty: boolean, password?: boolean]>>} - 一个部分记录,其中键是 FileSystemConfig 的键,值是一个包含占位符、提示、是否在为空时提示和是否是密码字段的数组。 + */ const PROMPT_FIELDS: Partial string, promptOnEmpty: boolean, password?: boolean]>> = { + // 定义了 host 字段的占位符、提示信息和是否在为空时提示 host: ['Host', c => `Host for ${c.name}`, true], + // 定义了 username 字段的占位符、提示信息和是否在为空时提示 username: ['Username', c => `Username for ${c.name}`, true], + // 定义了 password 字段的占位符、提示信息、是否在为空时提示和是否是密码字段 password: ['Password', c => `Password for '${c.username}' for ${c.name}`, false, true], + // 定义了 passphrase 字段的占位符、提示信息、是否在为空时提示和是否是密码字段 passphrase: ['Passphrase', c => `Passphrase for provided export/private key for '${c.username}' for ${c.name}`, false, true], }; +/** + * 提示用户输入文件系统配置中的某些字段。 + * 这个函数会遍历给定的字段列表,对于每个字段,如果它在配置对象中不存在或者是空的, + * 则会使用 PROMPT_FIELDS 中定义的占位符和提示信息来提示用户输入。 + * @param config - 文件系统配置对象。 + * @param fields - 要提示的字段列表。 + * @returns 当所有字段都被提示并输入后,返回一个 Promise,表示操作完成。 + */ async function promptFields(config: FileSystemConfig, ...fields: (keyof FileSystemConfig)[]): Promise { + // 遍历 fields 数组中的每个字段 for (const field of fields) { + // 从 PROMPT_FIELDS 中获取当前字段的提示信息 const prompt = PROMPT_FIELDS[field]; + // 如果没有找到对应的提示信息,则记录错误并继续循环 if (!prompt) { Logging.error`Prompting unexpected field '${field}'`; continue; } + // 获取配置对象中当前字段的值 const value = config[field]; + // 如果值存在且不是 true,则跳过当前字段的提示 if (value && value !== true) continue; // Truthy and not true + // 如果值不存在且不需要在为空时提示,则跳过当前字段的提示 if (!value && !prompt[2]) continue; // Falsy but not promptOnEmpty + // 显示输入框,提示用户输入当前字段的值 const result = await vscode.window.showInputBox({ + // 忽略失去焦点事件,即输入框不会因为失去焦点而关闭 ignoreFocusOut: true, + // 根据 prompt 数组的第四个元素来确定是否将输入内容显示为密码形式 password: !!prompt[3], + // 输入框中的占位符文本,用于提示用户应该输入什么样的信息 placeHolder: prompt[0], + // 输入框上方的提示信息,这里使用了 prompt 数组的第二个元素,并将 config 对象作为参数传递给该函数 prompt: prompt[1](config), + }); + // 将用户输入的值赋给配置对象中的当前字段 (config[field] as string | undefined) = result; } } +/** + * 计算文件系统配置的实际值。 + * 这个函数会根据给定的配置对象,计算出实际的配置值,包括替换环境变量、处理 PuTTY 配置等。 + * @param config - 文件系统配置对象。 + * @returns 计算后的文件系统配置对象,如果配置无效,则返回 null。 + */ export async function calculateActualConfig(config: FileSystemConfig): Promise { + // 如果配置对象已经计算过,则直接返回 if (config._calculated) return config; + // 创建一个日志记录器,用于记录函数执行过程中的信息 const logging = Logging.scope(); // Add the internal _calculated field to cache the actual config for the next calculateActualConfig call // (and it also allows accessing the original config that generated this actual config, if ever necessary) + // 将内部 _calculated 字段添加到配置对象中,以缓存实际配置,供下一次 calculateActualConfig 调用使用 + // (并且它还允许在必要时访问生成此实际配置的原始配置) config = { ...config, _calculated: config }; // Windows uses `$USERNAME` while Unix uses `$USER`, let's normalize it here + // Windows 使用 `$USERNAME` 而 Unix 使用 `$USER`,我们在这里进行标准化 if (config.username === '$USERNAME') config.username = '$USER'; // Delay handling just `$USER` until later, as PuTTY might handle it specially + // 延迟处理仅为 `$USER` 的情况,因为 PuTTY 可能会特别处理它 if (config.username !== '$USER') config.username = replaceVariables(config.username); + // 替换主机名中的环境变量 config.host = replaceVariables(config.host); + // 替换端口号中的环境变量,并验证端口号是否有效 const port = replaceVariables((config.port || '') + ''); config.port = port ? validatePort(port) : 22; + // 替换代理中的环境变量 config.agent = replaceVariables(config.agent); + // 替换私钥路径中的环境变量 config.privateKeyPath = replaceVariables(config.privateKeyPath); + // 记录日志,表示正在计算实际配置 logging.info`Calculating actual config`; + // 如果配置对象是从即时连接字符串创建的,则启用 PuTTY(在尝试模式下) if (config.instantConnection) { // Created from an instant connection string, so enable PuTTY (in try mode) config.putty = ''; // Could just set it to `true` but... consistency? } + // 如果配置对象启用了 PuTTY if (config.putty) { + // 如果当前平台不是 Windows,则记录警告日志 if (process.platform !== 'win32') { logging.warning(`\tConfigurating uses putty, but platform is ${process.platform}`); } + // 导入 getCachedFinder 函数,用于获取 PuTTY 会话查找器 const { getCachedFinder } = await import('./putty'); + // 获取会话查找器 const getSession = await getCachedFinder(); + // 如果用户名是 `$USER`,则将其设置为 undefined const cUsername = config.username === '$USER' ? undefined : config.username; + // 如果是尝试模式,则尝试查找 PuTTY 会话 const tryPutty = config.instantConnection || config.putty === ''; let session: PuttySession | undefined; + // 如果 tryPutty 为真,则尝试查找名为 config.host 的 PuTTY 会话 if (tryPutty) { // If we're trying to find one, we also check whether `config.host` represents the name of a PuTTY session + // 如果我们正在尝试查找一个会话,我们也会检查 config.host 是否代表一个 PuTTY 会话的名称 session = await getSession(config.host); + // 记录日志,表明正在尝试查找名为 config.host 的 PuTTY 会话,并显示查找结果 logging.info`\ttryPutty is true, tried finding a config named '${config.host}' and found ${session ? `'${session.name}'` : 'no match'}`; } + + // 如果没有找到会话,则执行以下操作 if (!session) { + // 初始化一个变量,用于标记是否仅提供了会话名称 let nameOnly = true; + // 如果 config.putty 被设置为 true if (config.putty === true) { + // 提示用户输入主机字段 await promptFields(config, 'host'); // TODO: `config.putty === true` without config.host should prompt the user with *all* PuTTY sessions + // TODO: 如果 config.putty 为 true 且没有配置 host,则应提示用户输入所有 PuTTY 会话 + // 如果没有提供主机,则抛出错误 if (!config.host) throw new Error(`'putty' was true but 'host' is empty/missing`); + // 将 config.putty 设置为提供的主机名 config.putty = config.host; + // 标记为不仅仅提供了会话名称 nameOnly = false; } else { + // 如果 config.putty 不是 true,则替换其中的变量 config.putty = replaceVariables(config.putty); } + // 根据提供的信息获取会话 session = await getSession(config.putty, config.host, cUsername, nameOnly); } + + // 如果找到了会话 if (session) { + // 如果会话协议不是 SSH,则抛出错误 if (session.protocol !== 'ssh') throw new Error(`The requested PuTTY session isn't a SSH session`); + // 设置用户名 config.username = cUsername || session.username; + // 如果用户名不存在,且主机名中包含 `@`,则从主机名中提取用户名 if (!config.username && session.hostname && session.hostname.indexOf('@') >= 1) { config.username = session.hostname.substr(0, session.hostname.indexOf('@')); } + // 主机名设置为会话中的主机名 // Used to be `config.host || session.hostname`, but `config.host` could've been just the session name config.host = session.hostname.replace(/^.*?@/, ''); + // 端口号设置为会话中的端口号 config.port = session.portnumber || config.port; + // 代理设置为会话中的代理 config.agent = config.agent || (session.tryagent ? 'pageant' : undefined); + // 如果会话设置了从环境变量获取用户名,则设置用户名 if (session.usernamefromenvironment) config.username = '$USER'; + // 私钥路径设置为会话中的私钥路径 config.privateKeyPath = config.privateKeyPath || (!config.agent && session.publickeyfile) || undefined; + // 根据会话中的代理方法设置代理配置 switch (session.proxymethod) { case 0: break; @@ -126,94 +233,156 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise(cb => readFile(config.privateKeyPath!, cb)); + // 将读取到的私钥内容赋值给配置对象中的 privateKey 属性 config.privateKey = key; + // 记录日志,显示已从指定路径读取私钥 logging.debug`\tRead private key from ${config.privateKeyPath}`; } catch (e) { + // 如果读取私钥文件时发生错误,抛出一个包含错误信息的新错误 throw new Error(`Error while reading the keyfile at:\n${config.privateKeyPath}`); } } + // 提示用户输入主机名、用户名和密码 await promptFields(config, 'host', 'username', 'password'); + // 如果用户输入了密码,则将代理设置为 undefined if (config.password) config.agent = undefined; + // 如果 passphrase 被设置为 true if ((config.passphrase as any) === true) { + // 如果存在私钥,则提示用户输入 passphrase if (config.privateKey) { await promptFields(config, 'passphrase'); - } else { + } + // 如果没有提供私钥 + else { + // 显示一个警告信息,提示用户没有为指定的用户名和主机名提供密钥 const answer = await vscode.window.showWarningMessage( `The field 'passphrase' was set to true, but no key was provided for ${config.username}@${config.name}`, 'Configure', 'Ignore'); + // 如果用户选择配置,则导航到配置页面 if (answer === 'Configure') { const webview = await import('./webview'); webview.navigate({ type: 'editconfig', config }); return null; } } + // 如果 passphrase 被设置为 false } else if ((config.passphrase as any) === false) { // Issue with the ssh2 dependency apparently not liking false + // 删除 passphrase 属性,因为 ssh2 库不支持 false 值 delete config.passphrase; } + // 如果配置了代理转发但没有配置代理,则禁用代理转发并记录日志 if (config.agentForward && !config.agent) { + // 如果配置了代理转发但没有配置代理,则禁用代理转发并记录日志 logging.debug`\tNo agent while having agentForward, disabling agent forwarding`; + // 将 agentForward 属性设置为 false,表示禁用代理转发 config.agentForward = false; } + // 如果没有私钥、代理和密码,则提示用户输入密码并记录日志 if (!config.privateKey && !config.agent && !config.password) { + // 如果没有私钥、代理和密码,则提示用户输入密码并记录日志 logging.debug`\tNo privateKey, agent or password. Gonna prompt for password`; + // 将密码设置为 true,表示需要用户输入密码 config.password = true as any; + // 提示用户输入密码 await promptFields(config, 'password'); } + // 记录最终的配置信息 logging.debug`\tFinal configuration:\n${config}`; + // 返回最终的配置对象 return config; } +/** + * 创建一个套接字,用于与远程主机建立连接。 + * @param config - 文件系统配置对象。 + * @returns 一个 Promise,成功时解析为 NodeJS.ReadableStream,失败时解析为 null。 + */ export async function createSocket(config: FileSystemConfig): Promise { + // 计算文件系统配置的实际值,并将结果赋值给 config 变量 config = (await calculateActualConfig(config))!; + // 如果 config 为空,则返回 null if (!config) return null; + // 使用 Logging.scope 方法创建一个日志记录器,并将其赋值给 logging 变量 const logging = Logging.scope(`createSocket(${config.name})`); + // 记录一条信息日志,表示正在创建套接字 logging.info`Creating socket`; + // 检查是否配置了跳转主机 if (config.hop) { + // 记录日志,显示正在通过指定的跳转主机进行跳转 logging.debug`\tHopping through ${config.hop}`; + // 获取跳转主机的配置 const hop = getConfig(config.hop); + // 如果没有找到跳转主机的配置,则抛出错误 if (!hop) throw new Error(`A SSH FS configuration with the name '${config.hop}' doesn't exist`); + // 创建到跳转主机的 SSH 连接 const ssh = await createSSH(hop); + // 如果连接失败,则记录日志并返回 null if (!ssh) { logging.debug`\tFailed in connecting to hop ${config.hop}`; return null; } + // 创建一个 Promise,用于处理与目标主机的连接 return new Promise((resolve, reject) => { + // 使用 SSH 客户端的 forwardOut 方法,通过跳转主机连接到目标主机 ssh.forwardOut('localhost', 0, config.host!, config.port || 22, (err, channel) => { + // 如果发生错误,记录日志并拒绝 Promise if (err) { logging.debug`\tError connecting to hop ${config.hop} for ${config.name}: ${err}`; + // 为错误消息添加额外的上下文信息 err.message = `Couldn't connect through the hop:\n${err}`; return reject(err); - } else if (!channel) { - err = new Error('Did not receive a channel'); + } + // 如果没有接收到通道,则抛出错误 + else if (!channel) { + // 如果没有接收到通道,则创建一个错误对象 + const err = new Error('Did not receive a channel'); + // 记录一条调试日志,显示在连接到跳转主机时没有接收到通道 logging.debug`\tGot no channel when connecting to hop ${config.hop} for ${config.name}`; + // 拒绝 Promise,并将错误对象传递给调用者 return reject(err); } + // 当通道关闭时,结束 SSH 连接 channel.once('close', () => ssh.end()); + // 成功连接到目标主机,解析 Promise 并返回通道 resolve(channel); }); }); } + // 根据配置对象中的代理类型,选择相应的代理处理方法 switch (config.proxy && config.proxy.type) { + // 如果代理类型为空或未定义,则不进行任何操作 case null: case undefined: break; + // 如果代理类型为 socks4 或 socks5,则使用 socks 代理处理方法 case 'socks4': case 'socks5': return await (await import('./proxy')).socks(config); + // 如果代理类型为 http,则使用 http 代理处理方法 case 'http': return await (await import('./proxy')).http(config); + // 如果代理类型为未知类型,则抛出错误 default: throw new Error(`Unknown proxy method`); } @@ -225,44 +394,79 @@ export async function createSocket(config: FileSystemConfig): Promise authsAllowed.shift() || false; } +/** + * 创建一个 SSH 客户端连接。 + * @param config - 文件系统配置对象,包含连接所需的信息。 + * @param sock - 可选的套接字流,用于建立 SSH 连接。如果未提供,则会尝试创建一个新的套接字。 + * @returns 一个 Promise,成功时解析为 SSH 客户端对象,失败时解析为 null。 + */ export async function createSSH(config: FileSystemConfig, sock?: NodeJS.ReadableStream): Promise { + // 计算实际的配置对象 config = (await calculateActualConfig(config))!; + // 如果配置对象为空,则返回 null if (!config) return null; + // 如果未提供套接字,则创建一个新的套接字 sock = sock || (await createSocket(config))!; + // 如果套接字为空,则返回 null if (!sock) return null; + // 创建一个日志记录器,用于记录创建 SSH 连接的过程 const logging = Logging.scope(`createSSH(${config.name})`); + // 返回一个 Promise,成功时解析为 SSH 客户端对象 return new Promise((resolve, reject) => { + // 创建一个新的 SSH 客户端对象 const client = new Client(); + // 当客户端准备就绪时,解析 Promise 并返回客户端对象 client.once('ready', () => resolve(client)); + // 当连接超时时,拒绝 Promise 并抛出错误 client.once('timeout', () => reject(new Error(`Socket timed out while connecting SSH FS '${config.name}'`))); + // 当接收到键盘交互请求时,处理用户输入 client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => { logging.debug`Received keyboard-interactive request with prompts ${prompts}`; Promise.all(prompts.map(prompt => @@ -273,6 +477,7 @@ export async function createSSH(config: FileSystemConfig, sock?: NodeJS.Readable }), )).then(finish).catch(e => logging.error(e)); }); + // 当发生错误时,记录错误并拒绝 Promise client.on('error', (error: Error & { description?: string }) => { if (error.description) { error.message = `${error.description}\n${error}`; @@ -281,52 +486,95 @@ export async function createSSH(config: FileSystemConfig, sock?: NodeJS.Readable reject(error); }); try { + // 创建最终的配置对象,包含默认配置和用户自定义配置 const finalConfig: FileSystemConfig = { ...config, sock, ...DEFAULT_CONFIG }; + // 创建一个认证处理函数 finalConfig.authHandler = makeAuthHandler(finalConfig, logging); + // 如果启用了调试模式,则设置调试日志记录器 if (config.debug || getFlagBoolean('DEBUG_SSH2', false, config.flags)[0]) { const scope = Logging.scope(`ssh2(${config.name})`); finalConfig.debug = (msg: string) => scope.debug(msg); } // Unless the flag 'DF-GE' is specified, disable DiffieHellman groupex algorithms (issue #239) // Note: If the config already specifies a custom `algorithms.key`, ignore it (trust the user?) + // 除非指定了 'DF-GE' 标志,否则禁用 DiffieHellman groupex 算法 const [flagV, flagR] = getFlagBoolean('DF-GE', false, config.flags); + // 如果 flagV 为真,则表示启用了 "DF-GE" 标志 if (flagV) { + // 记录一条信息日志,说明由于 flagR 的原因启用了 "DF-GE" 标志,并禁用了 DiffieHellman kex groupex 算法 logging.info`Flag "DF-GE" enabled due to '${flagR}', disabling DiffieHellman kex groupex algorithms`; + // 从 finalConfig.algorithms 中获取 kex 算法列表,如果存在的话 const removeKex = finalConfig.algorithms?.kex; + // 如果 removeKex 存在,则记录一条调试日志,说明已经存在的 algorithms.kex if (removeKex) logging.debug`\tAlready present algorithms.kex: ${removeKex}`; + // 更新 finalConfig.algorithms,将 kex 算法列表中的 'diffie-hellman-group-exchange' 移除 finalConfig.algorithms = { - ...finalConfig.algorithms, + ...finalConfig.algorithms, kex: { - ...finalConfig.algorithms?.kex, + ...finalConfig.algorithms?.kex, remove: [ - ...(Array.isArray(removeKex) ? removeKex : []), + ...(Array.isArray(removeKex) ? removeKex : []), 'diffie-hellman-group-exchange', ], }, }; + // 记录一条调试日志,说明更新后的 algorithms.kex logging.debug`\tResulting algorithms.kex: ${finalConfig.algorithms.kex}`; } + // 连接到 SSH 服务器 client.connect(finalConfig); } catch (e) { + // 如果发生异常,拒绝 Promise 并抛出错误 reject(e); } }); } +/** + * 启动一个 sudo 会话,用于在 SSH 连接中执行需要 root 权限的命令。 + * @param shell - SSH 客户端通道对象,用于与远程主机进行交互。 + * @param config - 文件系统配置对象,包含连接所需的信息。 + * @param user - 可选参数,指定要切换到的用户。如果未提供,则默认使用配置中的用户名。 + * @returns 一个 Promise,成功时解析为 void,表示 sudo 会话已启动。 + * @throws 如果在启动 sudo 会话过程中发生错误,则抛出错误。 + */ function startSudo(shell: ClientChannel, config: FileSystemConfig, user: string | boolean = true): Promise { + // 记录一条调试信息,表明正在将 shell 转换为 sudo shell Logging.debug`Turning shell into a sudo shell for ${typeof user === 'string' ? `'${user}'` : 'default sudo user'}`; + /** + * 启动一个 sudo 会话,用于在 SSH 连接中执行需要 root 权限的命令。 + * @param shell - SSH 客户端通道对象,用于与远程主机进行交互。 + * @param config - 文件系统配置对象,包含连接所需的信息。 + * @param user - 可选参数,指定要切换到的用户。如果未提供,则默认使用配置中的用户名。 + * @returns 一个 Promise,成功时解析为 void,表示 sudo 会话已启动。 + * @throws 如果在启动 sudo 会话过程中发生错误,则抛出错误。 + */ return new Promise((resolve, reject) => { + /** + * 处理标准输出数据。 + * @param data - 从远程主机接收到的标准输出数据。 + */ function stdout(data: Buffer | string) { data = data.toString(); + // 如果数据是 "SUDO OK",则表示 sudo 会话已成功启动 if (data.trim() === 'SUDO OK') { + // 清理资源并解决 Promise return cleanup(), resolve(); } else { + // 记录意外的标准输出数据 Logging.debug`Unexpected STDOUT: ${data}`; } } + /** + * 处理标准错误输出数据。 + * @param data - 从远程主机接收到的标准错误输出数据。 + * @returns 如果接收到的是 sudo 密码提示,则返回一个 Promise,等待用户输入密码;否则,清理资源并拒绝 Promise。 + */ async function stderr(data: Buffer | string) { data = data.toString(); + // 如果数据以 "[sudo]" 开头,则表示接收到了 sudo 密码提示 if (data.match(/^\[sudo\]/)) { + // 获取密码,如果配置中没有提供密码,则提示用户输入 const password = typeof config.password === 'string' ? config.password : await vscode.window.showInputBox({ password: true, @@ -334,86 +582,168 @@ function startSudo(shell: ClientChannel, config: FileSystemConfig, user: string placeHolder: 'Password', prompt: data.substr(7), }); + // 如果没有输入密码,则拒绝 Promise 并抛出错误 if (!password) return cleanup(), reject(new Error('No password given')); + // 将密码写入 shell return shell.write(`${password}\n`); } + // 如果不是 sudo 密码提示,则记录错误并拒绝 Promise return cleanup(), reject(new Error(`Sudo error: ${data}`)); } + /** + * 清理资源,移除事件监听器。 + */ function cleanup() { shell.removeListener('data', stdout); shell.stderr!.removeListener('data', stderr); } + // 监听 shell 的标准输出数据 shell.on('data', stdout); + // 监听 shell 的标准错误输出数据 shell.stderr!.on('data', stderr); + + // 根据用户参数构建 sudo 命令 const uFlag = typeof user === 'string' ? `-u ${user} ` : ''; + // 向 shell 写入 sudo 命令,启动 sudo 会话 shell.write(`sudo -S ${uFlag}bash -c "echo SUDO OK; cat | bash"\n`); }); + } +/** + * 去除命令字符串中的 sudo 前缀和相关参数。 + * 该函数会尝试去除命令字符串开头的 sudo 前缀,以及可能跟随的各种 sudo 参数。 + * 如果命令字符串中包含多个连续的 sudo 前缀或参数,函数会递归地去除它们,直到找到一个非 sudo 前缀或参数的部分。 + * @param cmd - 要处理的命令字符串。 + * @returns 去除 sudo 前缀和相关参数后的命令字符串。 + */ function stripSudo(cmd: string) { + // 首先去除字符串开头的 "sudo " 前缀 cmd = cmd.replace(/^sudo\s+/, ''); + // 初始化结果变量为原始命令字符串 let res = cmd; + // 进入一个无限循环,直到找到一个非 sudo 前缀或参数的部分 while (true) { + // 将结果字符串修剪掉首尾的空格 cmd = res.trim(); + // 尝试去除结果字符串开头的 "-- " 前缀 res = cmd.replace(/^\-\-\s+/, ''); + // 如果结果字符串发生了变化,说明找到了一个 "-- " 前缀,跳出当前循环 if (res !== cmd) break; + // 尝试去除结果字符串开头的单个短横线和一个字符的组合,如 "-A"、"-b" 等 res = cmd.replace(/^\-[AbEeKklnPSsVv]/, ''); + // 如果结果字符串发生了变化,说明找到了一个短横线和一个字符的组合,继续循环 if (res !== cmd) continue; + // 尝试去除结果字符串开头的单个短横线和一个字符以及一个空格和一个参数的组合,如 "-C file"、"-h host" 等 res = cmd.replace(/^\-[CHhprtUu]\s+\S+/, ''); + // 如果结果字符串发生了变化,说明找到了一个短横线和一个字符以及一个空格和一个参数的组合,继续循环 if (res !== cmd) continue; + // 尝试去除结果字符串开头的双短横线和一个参数的组合,如 "--close-from=123"、"--group=users" 等 res = cmd.replace(/^\-\-(close\-from|group|host|role|type|other\-user|user)=\S+/, ''); + // 如果结果字符串发生了变化,说明找到了一个双短横线和一个参数的组合,继续循环 if (res !== cmd) continue; + // 如果以上所有尝试都没有改变结果字符串,说明已经找到了一个非 sudo 前缀或参数的部分,跳出循环 break; } + // 返回去除 sudo 前缀和相关参数后的命令字符串 return cmd; } +/** + * 获取一个 SFTP 客户端实例,用于与远程主机进行文件传输操作。 + * @param client - SSH 客户端对象,用于与远程主机建立连接。 + * @param config - 文件系统配置对象,包含连接所需的信息。 + * @returns 一个 Promise,成功时解析为 SFTP 客户端实例。 + * @throws 如果在获取 SFTP 客户端过程中发生错误,则抛出错误。 + */ export async function getSFTP(client: Client, config: FileSystemConfig): Promise { + // 计算实际的配置对象 config = (await calculateActualConfig(config))!; + // 如果配置对象不存在,则抛出一个错误 if (!config) throw new Error('Couldn\'t calculate the config'); + + // 创建日志记录器 const logging = Logging.scope(`getSFTP(${config.name})`); + + // 检查是否设置了 sftpSudo 但没有设置 sftpCommand if (config.sftpSudo && !config.sftpCommand) { + // 如果是,则记录一条警告信息,指出 sftpSudo 已设置但未设置 sftpCommand logging.warning(`sftpSudo is set without sftpCommand. Assuming /usr/lib/openssh/sftp-server`); + // 然后,将默认的 sftpCommand 设置为 /usr/lib/openssh/sftp-server config.sftpCommand = '/usr/lib/openssh/sftp-server'; } + + // 检查是否没有设置 sftpCommand if (!config.sftpCommand) { + // 如果没有指定 sftpCommand,则使用默认的 sftp 子系统来创建 SFTP 会话 logging.info`Creating SFTP session using standard sftp subsystem`; + // 将 client.sftp 方法包装成一个 Promise,并返回这个 Promise return toPromise(cb => client.sftp(cb)); } + + // 从配置对象中获取 sftpCommand 命令,并将其赋值给 cmd 变量 let cmd = config.sftpCommand; + // 记录一条调试信息,表明正在将 shell 转换为 sudo shell logging.info`Creating SFTP session using specified command: ${cmd}`; + + // 获取一个 shell 通道 const shell = await toPromise(cb => client.shell(false, cb)); + + // 监听 shell 的标准输出和标准错误输出 // shell.stdout.on('data', (d: string | Buffer) => logging.debug`[SFTP-STDOUT] ${d}`); // shell.stderr.on('data', (d: string | Buffer) => logging.debug`[SFTP-STDERR] ${d}`); // Maybe the user hasn't specified `sftpSudo`, but did put `sudo` in `sftpCommand` // I can't find a good way of differentiating welcome messages, SFTP traffic, sudo password prompts, ... // so convert the `sftpCommand` to make use of `sftpSudo`, since that seems to work + + // 检查命令是否以 sudo 开头 if (cmd.match(/^sudo/)) { // If the -u flag is given, use that too + // 如果 -u 标志被给出,也使用它 const mat = cmd.match(/\-u\s+(\S+)/) || cmd.match(/\-\-user=(\S+)/); config.sftpSudo = mat ? mat[1] : true; // Now the tricky part of splitting the sudo and sftp command + // 现在拆分 sudo 和 sftp 命令的棘手部分 config.sftpCommand = cmd = stripSudo(cmd); logging.warning(`Reformed sftpCommand due to sudo to: ${cmd}`); } // If the user wants sudo, we'll first convert this shell into a sudo shell + // 如果用户想要 sudo,我们将首先把这个 shell 转换为一个 sudo shell if (config.sftpSudo) await startSudo(shell, config, config.sftpSudo); + + // 写入一个标记,表示 SFTP 准备就绪 shell.write(`echo SFTP READY\n`); // Wait until we see "SFTP READY" (skipping welcome messages etc) + // 等待直到我们看到 "SFTP READY"(跳过欢迎消息等) await new Promise((ready, nvm) => { + // 定义一个处理函数,用于处理 shell 通道接收到的数据 const handler = (data: string | Buffer) => { + // 将数据转换为字符串并去除首尾空格 if (data.toString().trim() !== 'SFTP READY') return; + // 如果接收到的数据不是 "SFTP READY",则忽略 shell.removeListener('data', handler); + // 移除 data 事件的监听器 ready(); + // 调用 ready 函数,表示 SFTP 会话已经准备就绪 }; + // 监听 shell 通道的 data 事件 shell.on('data', handler); + // 监听 shell 通道的 close 事件 shell.on('close', nvm); }); // Start sftpCommand (e.g. /usr/lib/openssh/sftp-server) and wrap everything nicely + // 启动 sftpCommand(例如 /usr/lib/openssh/sftp-server)并将所有内容包装得很好 const sftp = new SFTP(client, shell, { debug: config.debug }); + // 监听 shell 的 data 事件,当有数据时,将数据推送到 sftp 实例中 shell.on('data', data => sftp.push(data)); + // 监听 shell 的 close 事件,当 shell 关闭时,结束数据传输 shell.on('close', data => data.end()); + // 向 shell 写入 sftpCommand 命令,并等待命令执行完成 await toPromise(cb => shell.write(`${cmd}\n`, cb)); + // 初始化 sftp 实例 sftp._init(); + // 返回 sftp 实例 return sftp; + } +