
王.W
# 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暂无评论