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

王.W

发布于96天前
暂时没想好
# lzc-cli SSH 密钥选择机制分析

## 1. 概述

`lzc-cli` 是 [懒猫微服](https://lazycat.cloud/) 的官方命令行工具,用于应用的开发、构建、安装、调试和发布。

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

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

---

## 2. 源码解析

### 2.1 密钥扫描 — `findSshPublicKey()`

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

```javascript
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_ed25519` 或 `id_rsa`,而是扫描所有 `.pub` 后缀文件。
- **可读性校验**:通过 `fs.accessSync` 确保文件可读,不可读的文件会输出警告并跳过。
- **跨平台支持**:兼容 Windows(`USERPROFILE`)、macOS/Linux(`HOME`)。

---

### 2.2 交互式选择 — `selectSshPublicKey()`

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

```javascript
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'),
    };
}
```

**关键设计**:

- 使用 [`inquirer`](https://www.npmjs.com/package/inquirer) 的 `list` 类型,用户通过上下键选择、回车确认。
- **无自动选择逻辑**:即使只有一个密钥,也会弹出交互菜单等待用户确认。
- 返回值包含密钥的 `path`(路径)和 `content`(文件内容)。

---

### 2.3 授权注册 — `sshApplyGrant()`

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

```javascript
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` / `git`) | lzc-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 目录结构与持久化

```bash
# entrypoint.sh 中的关键配置

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

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

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

```bash
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:

```bash
# ==================== 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` 等价逻辑:

```javascript
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 访问,软链接完全透明 |

---

## 参考资料

- [懒猫微服开发者手册 - lzc-cli](https://developer.lazycat.cloud/lzc-cli.html)
- [@lazycatcloud/lzc-cli - npm](https://www.npmjs.com/package/@lazycatcloud/lzc-cli)
- [Node.js inquirer 文档](https://www.npmjs.com/package/inquirer)
- [ssh_config(5) 手册 - IdentityFile](https://man.openbsd.org/ssh_config#IdentityFile)

评论

0

暂无评论

说点什么呢~
收藏
1
0
0