lzc-cli SSH 密钥选择机制分析

王.W

发布于130天前
暂时没想好

lzc-cli SSH 密钥选择机制分析

1. 概述

lzc-cli懒猫微服 的官方命令行工具,用于应用的开发、构建、安装、调试和发布。

在使用 lzc-cli 连接懒猫微服设备时,需要将本机的 SSH 公钥添加到设备的信任列表。
lzc-cli 为此实现了一套 密钥扫描 → 交互选择 → 授权注册 的流程,核心逻辑分布在以下两个文件中:

文件核心函数职责
lib/utils.jsfindSshPublicKey()扫描 ~/.ssh/ 下所有 .pub 公钥文件
lib/utils.jsselectSshPublicKey()交互式菜单让用户选择密钥
lib/debug_bridge.jssshApplyGrant()串联扫描和选择,生成授权 URL

2. 源码解析

2.1 密钥扫描 — findSshPublicKey()

📍 lib/utils.js · 第 569-630 行

export function findSshPublicKey() {
    // ① 根据操作系统获取 HOME 目录
    let homeDir;
    switch (process.platform) {
        case 'win32':
            homeDir = process.env.USERPROFILE || os.homedir();
            break;
        case 'darwin':
        case 'linux':
            homeDir = process.env.HOME || os.homedir();
            break;
        default:
            throw new Error('Unsupported operating system');
    }

    // ② 定位 .ssh 目录
    const sshDir = path.join(homeDir, '.ssh');

    // ③ 校验 .ssh 目录是否存在且合法
    const dirStat = fs.statSync(sshDir);
    if (!dirStat.isDirectory()) {
        throw new Error('.ssh 目前存在,但不是一个正常的目录');
    }

    // ④ 遍历目录,收集所有可读的 .pub 文件
    let avaiableKeys = [];
    const files = fs.readdirSync(sshDir);
    files.forEach((keyName) => {
        if (keyName.endsWith('.pub')) {
            const keyPath = path.join(sshDir, keyName);
            try {
                fs.accessSync(keyPath, fs.constants.R_OK);
                avaiableKeys.push({
                    keyName,        // 文件名,如 "id_ed25519.pub"
                    path: keyPath,  // 完整路径,如 "/root/.ssh/id_ed25519.pub"
                });
            } catch (error) {
                console.warn(`Found ${keyName} but cannot read it:`, error.message);
            }
        }
    });

    // ⑤ 没有找到任何公钥则抛出异常
    if (avaiableKeys.length == 0) {
        throw new Error('.ssh 目录没有找到任何 .pub 公钥');
    }

    return avaiableKeys;
}

关键设计

  • 不硬编码密钥名称:不限定 id_ed25519id_rsa,而是扫描所有 .pub 后缀文件。
  • 可读性校验:通过 fs.accessSync 确保文件可读,不可读的文件会输出警告并跳过。
  • 跨平台支持:兼容 Windows(USERPROFILE)、macOS/Linux(HOME)。

2.2 交互式选择 — selectSshPublicKey()

📍 lib/utils.js · 第 551-567 行

export async function selectSshPublicKey(avaiableKeys) {
    const keyNames = avaiableKeys.map((k) => k.path);

    // 使用 inquirer 弹出交互式列表
    const selected = (
        await inquirer.prompt([
            {
                name: 'type',
                message: '选择使用的公钥',
                type: 'list',
                choices: keyNames,
            },
        ])
    )['type'];

    return {
        path: selected,
        content: fs.readFileSync(selected, 'utf8'),
    };
}

关键设计

  • 使用 inquirerlist 类型,用户通过上下键选择、回车确认。
  • 无自动选择逻辑:即使只有一个密钥,也会弹出交互菜单等待用户确认。
  • 返回值包含密钥的 path(路径)和 content(文件内容)。

2.3 授权注册 — sshApplyGrant()

📍 lib/debug_bridge.js · 第 131 行附近

async sshApplyGrant() {
    // ① 扫描所有可用公钥
    const keys = await findSshPublicKey();

    logger.info(
        '检测到您当前的环境还没有添加 ssh 公钥到 \'懒猫开发者工具\' 中,' +
        '请选择您需要添加的公钥类型'
    );

    // ② 让用户交互式选择
    const sshInfo = await selectSshPublicKey(keys);
    logger.debug('ssh public key info', sshInfo);

    // ③ Base64 编码公钥内容
    const pk = Buffer.from(sshInfo.content.trimLeft()).toString('base64');

    // ④ 生成授权 URL,引导管理员在浏览器中访问
    logger.warn(
        `您当前机器的公钥未添加到微服(${this.boxname})的信任列表中,\n` +
        `请使用微服管理员账号在浏览器中访问以下地址:\n` +
        `-> https://${this.domain}/auth?key=${pk}`
    );
}

流程小结

  1. 调用 findSshPublicKey() 获取 ~/.ssh/ 下所有 .pub 文件。
  2. 调用 selectSshPublicKey() 让用户选择一个。
  3. 将公钥内容 Base64 编码后拼入 URL 的 key 参数。
  4. 用户(管理员)在浏览器中打开该 URL,即可完成公钥授权。

3. 完整流程图

┌──────────────────────────────────────────────────────────────────┐
│                  lzc-cli SSH 密钥选择完整流程                      │
└──────────────────────────────────────────────────────────────────┘

  用户执行 lzc-cli 命令(如 lzc-cli app install)
                        │
                        ▼
         ┌──────────────────────────────┐
         │  sshApplyGrant() 被触发      │
         │  检测到当前机器未授权         │
         └──────────────────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │  findSshPublicKey()          │
         │  扫描 ~/.ssh/*.pub           │
         │                              │
         │  找到:                       │
         │  ├─ id_ed25519.pub           │
         │  ├─ id_rsa.pub              │
         │  └─ my_custom.pub           │
         └──────────────────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │  selectSshPublicKey()        │
         │                              │
         │  ? 选择使用的公钥            │
         │  ❯ /root/.ssh/id_ed25519.pub│
         │    /root/.ssh/id_rsa.pub    │
         │    /root/.ssh/my_custom.pub │
         └──────────────────────────────┘
                        │
                  用户按回车确认
                        │
                        ▼
         ┌──────────────────────────────┐
         │  Base64 编码公钥内容         │
         │  生成授权 URL:               │
         │  https://box.domain/auth     │
         │    ?key=c3NoLWVkMjU1MT...    │
         └──────────────────────────────┘
                        │
                        ▼
         ┌──────────────────────────────┐
         │  管理员在浏览器中打开 URL     │
         │  完成公钥授权 ✅              │
         └──────────────────────────────┘

4. 与标准 SSH 客户端的对比

维度SSH 客户端(ssh / gitlzc-cli
密钥发现按固定优先级搜索标准名称扫描 ~/.ssh/*.pub 所有公钥
选择逻辑自动匹配,无需干预inquirer 交互式菜单
支持自定义名称需通过 -i 参数或 ~/.ssh/config 指定天然支持,扫描即可发现
非交互环境✅ 天然支持inquirer.prompt 会阻塞

SSH 客户端的默认搜索顺序(参考 ssh_config(5) 手册):

~/.ssh/id_ed25519
~/.ssh/id_ecdsa
~/.ssh/id_rsa
~/.ssh/id_dsa

5. 实战案例:Docker 容器中自动化使用 lzc-cli

5.1 场景描述

在一个 Docker 化的部署流水线中,容器需要通过 lzc-cli 将应用部署到懒猫微服设备。
容器内没有终端(TTY),无法进行交互式操作。

5.2 目录结构与持久化

# entrypoint.sh 中的关键配置

SSH_DATA_DIR="/data/ssh"        # 挂载的持久化卷
SSH_HOME_DIR="/root/.ssh"       # SSH 标准查找路径

# 软链接:让所有读取 ~/.ssh 的程序都实际访问持久化目录
ln -sf "${SSH_DATA_DIR}" "${SSH_HOME_DIR}"

容器首次启动时生成密钥:

if [ ! -f "/data/ssh/id_ed25519" ]; then
    ssh-keygen -t ed25519 -f /data/ssh/id_ed25519 -N "" -q
    echo "✓ SSH 密钥对已生成"
fi

生成后的目录结构:

/data/ssh/                      # 持久化卷(重启不丢失)
├── id_ed25519                  # 私钥
└── id_ed25519.pub              # 公钥

/root/.ssh -> /data/ssh         # 软链接

5.3 问题:交互式菜单阻塞

当后端通过 subprocess 调用 lzc-cli 时:

  1. findSshPublicKey() 正常扫描到 id_ed25519.pub
  2. selectSshPublicKey() 弹出 inquirer 交互菜单 → 容器无 TTY,程序卡死

5.4 解决方案:自动选择 Patch

entrypoint.sh 中对 lzc-cli 源码做运行时 Patch:

# ==================== lzc-cli SSH 自动选择 Patch ====================
echo ""
echo "Patching lzc-cli SSH auto-select..."

LZC_UTILS="/usr/lib/node_modules/@lazycatcloud/lzc-cli/lib/utils.js"

if [ -f "${LZC_UTILS}" ]; then
    if ! grep -q "auto-select single key" "${LZC_UTILS}" 2>/dev/null; then
        python3 -c "
import pathlib, sys

p = pathlib.Path('${LZC_UTILS}')
c = p.read_text()

old = 'export async function selectSshPublicKey(avaiableKeys) {'
new = '''export async function selectSshPublicKey(avaiableKeys) {
\t// [patched by deploy-pipeline] auto-select single key
\tif (avaiableKeys.length === 1) {
\t\tconst key = avaiableKeys[0];
\t\tconsole.log(\"Auto-selecting the only available SSH key:\", key.path);
\t\treturn { path: key.path, content: require(\"fs\").readFileSync(key.path, \"utf8\") };
\t}'''

if old in c and 'auto-select single key' not in c:
    p.write_text(c.replace(old, new))
    print('  ✓ lzc-cli SSH auto-select patch 已应用')
else:
    print('  ✓ lzc-cli SSH 无需 patch 或已 patch')
"
    else
        echo "  ✓ lzc-cli SSH auto-select 已 patch"
    fi
fi

Patch 后的 selectSshPublicKey 等价逻辑:

export async function selectSshPublicKey(avaiableKeys) {
    // [patched] 单密钥时自动选择,跳过交互
    if (avaiableKeys.length === 1) {
        const key = avaiableKeys[0];
        console.log('Auto-selecting the only available SSH key:', key.path);
        return { path: key.path, content: fs.readFileSync(key.path, 'utf8') };
    }

    // 多密钥时仍走交互式选择
    const keyNames = avaiableKeys.map((k) => k.path);
    const selected = (
        await inquirer.prompt([
            {
                name: 'type',
                message: '选择使用的公钥',
                type: 'list',
                choices: keyNames,
            },
        ])
    )['type'];

    return {
        path: selected,
        content: fs.readFileSync(selected, 'utf8'),
    };
}

5.5 验证结果

验证项结果说明
SSH/Git 能找到密钥软链接 /root/.ssh/data/ssh,标准名称 id_ed25519 自动匹配
lzc-cli 能扫描到公钥findSshPublicKey() 扫描 /root/.ssh/*.pub → 实际读取 /data/ssh/*.pub
非交互环境不阻塞Patch 后单密钥自动选择,不触发 inquirer.prompt
容器重启后密钥持久化/data 为持久化卷,密钥不丢失
多密钥场景兼容多密钥时仍走交互式选择(适用于开发者本地终端)

6. 注意事项

6.1 安全提醒

  • ⚠️ 授权 URL 中的 key 参数仅为 Base64 编码,而非加密。公钥本身是公开信息,但应避免在不安全的网络中传输该 URL。
  • ⚠️ 运行时 Patch 修改了 node_modules 中的源码,npm update 后需要重新应用。

6.2 常见问题

问题原因解决方案
.ssh 目录没有找到任何 .pub 公钥未生成密钥对运行 ssh-keygen -t ed25519
inquirer 交互卡死在无 TTY 的环境中运行应用自动选择 Patch
重启容器后密钥丢失~/.ssh 未持久化使用持久化卷 + 软链接方案

7. 总结

问题答案
lzc-cli 如何发现 SSH 密钥?扫描 ~/.ssh/*.pub,收集所有可读的公钥文件
是否硬编码特定密钥名称?,支持任意 .pub 后缀文件
只有一个密钥会自动选择吗?原版 不会,仍需交互确认;可通过 Patch 实现自动选择
软链接方案是否对 lzc-cli 透明?findSshPublicKey() 通过标准文件系统 API 访问,软链接完全透明

参考资料

评论

0

暂无评论

说点什么呢~
收藏
1
0
0