feat: add dynamic per-call SSH connections and secret redaction

- execute-command/upload/download accept host/port/username/password inline
  to open an ephemeral connection for a single call
- add redactSecret/redactSecrets to mask credentials in logs and errors
- logger log()/handleError() accept optional secrets[] for redaction
- add SSH_CONFIG_MISSING error code and --password-from-env CLI flag
- dynamic-mode startup: server runs with no static config
This commit is contained in:
Max Shcheglov
2026-09-01 10:54:20 +07:00
commit b889154bb3
39 changed files with 10418 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
# 测试文档
本项目使用 Node.js 内置的测试框架进行单元测试和集成测试。
## 测试结构
```
test/
├── ssh-config-parser.test.js # SSH 配置解析器测试
├── command-line-parser.test.js # 命令行参数解析器测试
├── ssh-connection-manager.test.js # SSH 连接管理器测试
├── integration.test.js # 集成测试
└── fixtures/ # 测试数据(自动生成)
```
## 运行测试
### 运行所有测试
```bash
npm test
```
### 监听模式(开发时使用)
```bash
npm run test:watch
```
监听模式会在文件变化时自动重新运行测试。
### 运行单个测试文件
```bash
node --test test/ssh-config-parser.test.js
```
## 测试覆盖范围
### 1. SSH Config Parser 测试
测试 `src/utils/ssh-config-parser.ts` 的功能:
- ✅ 基本 Host 别名解析
- ✅ 多别名 Host 行(`Host a b c`
- ✅ 通配符匹配(`Host *.example.com`
-`Host *` 默认值 fallback
-`Include` 指令支持
- ✅ 路径展开(`~` 和相对路径)
- ✅ First-match-wins 语义
- ✅ 错误处理(文件不存在等)
### 2. Command Line Parser 测试
测试 `src/cli/command-line-parser.ts` 的功能:
- ✅ JSON 配置文件解析(对象和数组格式)
-`--ssh` 参数解析(JSON 和旧格式)
- ✅ 单连接模式(命令行参数和位置参数)
- ✅ SSH config 集成
- ✅ 参数优先级(命令行 > SSH config
- ✅ 命令白名单和黑名单
- ✅ 其他选项(`--pty`, `--pre-connect`, `--proxy`, `--socksProxy`
- ✅ 错误处理
### 3. SSH Connection Manager 测试
测试 `src/services/ssh-connection-manager.ts` 的功能:
- ✅ 配置管理(初始化、获取配置)
- ✅ 命令验证(白名单、黑名单、正则表达式)
- ✅ 连接状态管理
- ✅ 多服务器支持
### 4. 集成测试
端到端测试完整流程:
- ✅ 从命令行参数到连接管理器的完整流程
- ✅ 从 SSH config 到连接管理器的完整流程
- ✅ 多服务器配置场景
- ✅ 错误处理(无效配置、缺少字段等)
## 编写新测试
### 测试文件命名
测试文件应该以 `.test.js` 结尾,并放在 `test/` 目录下。
### 测试示例
```javascript
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
describe('功能模块名称', () => {
before(() => {
// 测试前的准备工作
});
after(() => {
// 测试后的清理工作
});
describe('子功能', () => {
it('应该做某事', () => {
// 测试代码
assert.strictEqual(1 + 1, 2);
});
it('应该处理错误情况', () => {
assert.throws(() => {
throw new Error('测试错误');
}, /测试错误/);
});
});
});
```
## 测试最佳实践
1. **独立性**:每个测试应该独立运行,不依赖其他测试的状态
2. **清理**:使用 `after` 钩子清理测试创建的临时文件和资源
3. **描述性**:测试名称应该清楚地描述测试的内容
4. **覆盖边界情况**:测试正常情况、边界情况和错误情况
5. **使用 fixtures**:将测试数据放在 `test/fixtures/` 目录下
## CI/CD 集成
测试可以轻松集成到 CI/CD 流程中:
```yaml
# GitHub Actions 示例
- name: Run tests
run: npm test
```
## 调试测试
### 使用 Node.js 调试器
```bash
node --inspect-brk --test test/ssh-config-parser.test.js
```
然后在 Chrome 中打开 `chrome://inspect` 进行调试。
### 查看详细输出
```bash
node --test --test-reporter=tap test/**/*.test.js
```
## 常见问题
### Q: 测试失败但没有详细错误信息?
A: 使用 `--test-reporter=spec` 查看详细输出:
```bash
node --test --test-reporter=spec test/**/*.test.js
```
### Q: 如何跳过某个测试?
A: 使用 `it.skip()`:
```javascript
it.skip('暂时跳过的测试', () => {
// 测试代码
});
```
### Q: 如何只运行某个测试?
A: 使用 `it.only()`:
```javascript
it.only('只运行这个测试', () => {
// 测试代码
});
```
## 贡献指南
提交 PR 时,请确保:
1. 所有测试通过:`npm test`
2. 新功能有对应的测试
3. 测试覆盖了正常情况和边界情况
4. 代码编译通过:`npm run build`
+95
View File
@@ -0,0 +1,95 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SERVER_CONFIG } from '../build/config/server.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const entrypoint = path.join(rootDir, 'build', 'index.js');
const packageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
);
const expectedHelpOptions = [
'--config-file <path>',
'--ssh-config-file <path>',
'--ssh <config>',
'-h, --host <host>',
'-p, --port <port>',
'-u, --username <name>',
'-w, --password <password>',
'-k, --privateKey <path>',
'-P, --passphrase <passphrase>',
'-a, --agent <path>',
'-W, --whitelist <patterns>',
'-B, --blacklist <patterns>',
'--proxy <url>',
'-s, --socksProxy <url>',
'--allowed-local-paths <paths>',
'--allowed-remote-paths <paths>',
'--transport-mode <mode>',
'--shell-ready-timeout <ms>',
'--command-template <template>',
'--pty',
'--try-keyboard',
'--pre-connect',
'--version, -v',
'--help',
];
function runCli(args) {
return spawnSync(process.execPath, [entrypoint, ...args], {
cwd: rootDir,
encoding: 'utf8',
});
}
describe('CLI info flags', () => {
it('keeps the MCP server version in sync with package metadata', () => {
assert.strictEqual(SERVER_CONFIG.version, packageJson.version);
});
it('prints package version and exits successfully for --version', () => {
const result = runCli(['--version']);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout.trim(), packageJson.version);
assert.doesNotMatch(result.stderr, /Unknown option/);
});
it('prints package version and exits successfully for -v', () => {
const result = runCli(['-v']);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout.trim(), packageJson.version);
assert.doesNotMatch(result.stderr, /Unknown option/);
});
it('prints help and exits successfully for --help', () => {
const result = runCli(['--help']);
assert.strictEqual(result.status, 0);
assert.match(result.stdout, /Usage: ssh-mcp-server/);
for (const option of expectedHelpOptions) {
assert.ok(
result.stdout.includes(option),
`Expected help output to include ${option}`
);
}
assert.doesNotMatch(result.stderr, /Unknown option/);
});
it('handles info flags before normal config parsing', () => {
const missingConfigPath = path.join(rootDir, 'missing-cli-info-config.json');
const result = runCli(['--config-file', missingConfigPath, '--help']);
assert.strictEqual(result.status, 0);
assert.match(result.stdout, /Usage: ssh-mcp-server/);
assert.doesNotMatch(result.stderr, /Config file not found/);
assert.doesNotMatch(result.stderr, /Unknown option/);
});
});
+645
View File
@@ -0,0 +1,645 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { CommandLineParser } from '../build/cli/command-line-parser.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
describe('Command Line Parser', () => {
let originalArgv;
let fixturesDir;
let testConfigPath;
let testSshConfigPath;
before(() => {
originalArgv = process.argv;
// 创建测试配置文件
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-cli-test-'));
testConfigPath = path.join(fixturesDir, 'test-config.json');
testSshConfigPath = path.join(fixturesDir, 'test-ssh-config');
// 创建 JSON 配置文件
fs.writeFileSync(testConfigPath, JSON.stringify({
dev: {
host: '192.168.1.100',
port: 22,
username: 'devuser',
password: 'devpass'
},
prod: {
host: '10.0.0.50',
port: 22,
username: 'produser',
privateKey: '~/.ssh/prod_key'
}
}));
// 创建 SSH 配置文件
fs.writeFileSync(testSshConfigPath, `
Host testhost
HostName 172.16.0.1
Port 2222
User testuser
IdentityFile ~/.ssh/test_key
`);
});
after(() => {
process.argv = originalArgv;
fs.rmSync(fixturesDir, { recursive: true, force: true });
});
describe('配置文件解析', () => {
it('应该正确解析 JSON 配置文件(对象格式)', () => {
process.argv = ['node', 'test', '--config-file', testConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(Object.keys(result.configs).length, 2);
assert.strictEqual(result.configs.dev.host, '192.168.1.100');
assert.strictEqual(result.configs.dev.username, 'devuser');
assert.strictEqual(result.configs.prod.privateKey, path.join(os.homedir(), '.ssh', 'prod_key'));
});
it('应该正确解析 JSON 配置文件(数组格式)', () => {
const arrayConfigPath = path.join(fixturesDir, 'array-config.json');
fs.writeFileSync(arrayConfigPath, JSON.stringify([
{
name: 'server1',
host: '1.2.3.4',
port: 22,
username: 'user1',
password: 'pass1'
},
{
name: 'server2',
host: '5.6.7.8',
port: 2222,
username: 'user2',
privateKey: '~/.ssh/key2'
}
]));
process.argv = ['node', 'test', '--config-file', arrayConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(Object.keys(result.configs).length, 2);
assert.strictEqual(result.configs.server1.host, '1.2.3.4');
assert.strictEqual(result.configs.server2.port, 2222);
fs.unlinkSync(arrayConfigPath);
});
it('应该保留 JSON 配置中的 SSH algorithms', () => {
const algorithmsConfigPath = path.join(fixturesDir, 'algorithms-config.json');
const algorithms = {
serverHostKey: { append: ['ssh-rsa'] },
hmac: ['hmac-sha1', 'hmac-md5']
};
fs.writeFileSync(algorithmsConfigPath, JSON.stringify({
legacy: {
host: '192.168.1.100',
port: 22,
username: 'legacy-user',
password: 'legacy-pass',
algorithms
}
}));
try {
process.argv = ['node', 'test', '--config-file', algorithmsConfigPath];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(result.configs.legacy.algorithms, algorithms);
} finally {
fs.unlinkSync(algorithmsConfigPath);
}
});
it('应该保留并校验 JSON 配置中的 maxOutputBytes', () => {
const outputLimitConfigPath = path.join(fixturesDir, 'output-limit-config.json');
fs.writeFileSync(outputLimitConfigPath, JSON.stringify({
limited: {
host: '192.168.1.100',
port: 22,
username: 'limited-user',
password: 'limited-pass',
maxOutputBytes: 2048
},
unlimited: {
host: '192.168.1.101',
port: 22,
username: 'unlimited-user',
password: 'unlimited-pass',
maxOutputBytes: 0
}
}));
try {
process.argv = ['node', 'test', '--config-file', outputLimitConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.limited.maxOutputBytes, 2048);
assert.strictEqual(result.configs.unlimited.maxOutputBytes, 0);
} finally {
fs.unlinkSync(outputLimitConfigPath);
}
});
it('应从配置文件解析 commandTimeoutMs', () => {
const timeoutConfigPath = path.join(fixturesDir, 'command-timeout-config.json');
fs.writeFileSync(timeoutConfigPath, JSON.stringify({
slow: {
host: '192.168.1.100',
port: 22,
username: 'slow-user',
password: 'slow-pass',
commandTimeoutMs: 180000
},
plain: {
host: '192.168.1.101',
port: 22,
username: 'plain-user',
password: 'plain-pass'
}
}));
try {
process.argv = ['node', 'test', '--config-file', timeoutConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.slow.commandTimeoutMs, 180000);
assert.strictEqual(result.configs.plain.commandTimeoutMs, undefined);
} finally {
fs.unlinkSync(timeoutConfigPath);
}
});
it('无效的 commandTimeoutMs 应抛出错误', () => {
const invalidTimeoutPath = path.join(fixturesDir, 'invalid-command-timeout-config.json');
fs.writeFileSync(invalidTimeoutPath, JSON.stringify({
invalid: {
host: '192.168.1.100',
port: 22,
username: 'invalid-user',
password: 'invalid-pass',
commandTimeoutMs: 0
}
}));
try {
process.argv = ['node', 'test', '--config-file', invalidTimeoutPath];
assert.throws(
() => CommandLineParser.parseArgs(),
/commandTimeoutMs must be a positive number/,
);
} finally {
fs.unlinkSync(invalidTimeoutPath);
}
});
it('无效的 maxOutputBytes 应抛出错误', () => {
const invalidConfigPath = path.join(fixturesDir, 'invalid-output-limit-config.json');
fs.writeFileSync(invalidConfigPath, JSON.stringify({
invalid: {
host: '192.168.1.100',
port: 22,
username: 'invalid-user',
password: 'invalid-pass',
maxOutputBytes: -1
}
}));
try {
process.argv = ['node', 'test', '--config-file', invalidConfigPath];
assert.throws(
() => CommandLineParser.parseArgs(),
/maxOutputBytes must be a non-negative integer/,
);
} finally {
fs.unlinkSync(invalidConfigPath);
}
});
it('配置文件不存在时应抛出错误', () => {
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /not found/);
});
});
describe('动态模式(无连接参数)', () => {
it('没有任何连接参数时应返回空配置以允许按调用动态连接', () => {
process.argv = ['node', 'test'];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(result.configs, {});
assert.strictEqual(result.preConnect, false);
});
it('只传 --pre-connect 而无连接参数时同样返回空配置', () => {
process.argv = ['node', 'test', '--pre-connect'];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(result.configs, {});
assert.strictEqual(result.preConnect, true);
});
});
describe('--ssh 参数解析', () => {
it('应该正确解析 JSON 格式的 --ssh 参数', () => {
const sshJson = JSON.stringify({
name: 'test',
host: '1.2.3.4',
port: 22,
username: 'testuser',
password: 'testpass',
transportMode: 'shell',
shellReadyTimeoutMs: 15000,
maxOutputBytes: 0
});
process.argv = ['node', 'test', '--ssh', sshJson];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.test.host, '1.2.3.4');
assert.strictEqual(result.configs.test.username, 'testuser');
assert.strictEqual(result.configs.test.transportMode, 'shell');
assert.strictEqual(result.configs.test.shellReadyTimeoutMs, 15000);
assert.strictEqual(result.configs.test.maxOutputBytes, 0);
});
it('应该正确解析旧格式的 --ssh 参数', () => {
process.argv = ['node', 'test', '--ssh', 'name=legacy,host=1.2.3.4,port=22,user=legacyuser,password=legacypass'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.legacy.host, '1.2.3.4');
assert.strictEqual(result.configs.legacy.username, 'legacyuser');
});
it('应该支持多个 --ssh 参数', () => {
const ssh1 = JSON.stringify({ name: 'server1', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
const ssh2 = JSON.stringify({ name: 'server2', host: '2.2.2.2', port: 22, username: 'user2', password: 'pass2' });
process.argv = ['node', 'test', '--ssh', ssh1, '--ssh', ssh2];
const result = CommandLineParser.parseArgs();
assert.strictEqual(Object.keys(result.configs).length, 2);
assert.strictEqual(result.configs.server1.host, '1.1.1.1');
assert.strictEqual(result.configs.server2.host, '2.2.2.2');
});
});
describe('单连接模式(旧格式)', () => {
it('应该正确解析命令行参数', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--password', 'testpass'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '1.2.3.4');
assert.strictEqual(result.configs.default.port, 22);
assert.strictEqual(result.configs.default.username, 'testuser');
assert.strictEqual(result.configs.default.password, 'testpass');
});
it('应该正确解析位置参数', () => {
process.argv = ['node', 'test', '1.2.3.4', '22', 'testuser', 'testpass'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '1.2.3.4');
assert.strictEqual(result.configs.default.port, 22);
assert.strictEqual(result.configs.default.username, 'testuser');
assert.strictEqual(result.configs.default.password, 'testpass');
});
it('应该支持私钥认证', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
assert.strictEqual(result.configs.default.password, undefined);
});
it('显式 password 存在时不应注入环境 SSH agent', () => {
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--password', 'testpass'];
try {
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.password, 'testpass');
assert.strictEqual(result.configs.default.agent, undefined);
} finally {
if (originalSshAuthSock === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
}
}
});
it('显式 privateKey 存在时不应注入环境 SSH agent', () => {
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
try {
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
assert.strictEqual(result.configs.default.agent, undefined);
} finally {
if (originalSshAuthSock === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
}
}
});
it('缺少必需参数时应抛出错误', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4'];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /Missing required parameters/);
});
});
describe('SSH Config 集成', () => {
it('SSH config 缺少 Port 和 IdentityFile 时应使用默认端口和 SSH agent', () => {
const minimalSshConfigPath = path.join(fixturesDir, 'minimal-ssh-config');
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
fs.writeFileSync(minimalSshConfigPath, `
Host minimalhost
HostName 172.16.0.2
User minimaluser
`);
process.env.SSH_AUTH_SOCK = '/tmp/test-ssh-agent.sock';
process.argv = ['node', 'test', '--host', 'minimalhost', '--ssh-config-file', minimalSshConfigPath];
try {
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '172.16.0.2');
assert.strictEqual(result.configs.default.port, 22);
assert.strictEqual(result.configs.default.username, 'minimaluser');
assert.strictEqual(result.configs.default.agent, '/tmp/test-ssh-agent.sock');
} finally {
if (originalSshAuthSock === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
}
fs.unlinkSync(minimalSshConfigPath);
}
});
it('命令行 port 和 agent 应覆盖 SSH config 默认值', () => {
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
try {
process.argv = [
'node', 'test', '--host', 'testhost', '--port', '3333', '--agent', '/tmp/explicit-agent.sock',
'--ssh-config-file', testSshConfigPath,
];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.port, 3333);
assert.strictEqual(result.configs.default.agent, '/tmp/explicit-agent.sock');
} finally {
if (originalSshAuthSock === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
}
}
});
it('应该从 SSH config 读取连接参数', () => {
process.argv = ['node', 'test', '--host', 'testhost', '--ssh-config-file', testSshConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '172.16.0.1');
assert.strictEqual(result.configs.default.port, 2222);
assert.strictEqual(result.configs.default.username, 'testuser');
assert.ok(
result.configs.default.privateKey.endsWith(path.join('.ssh', 'test_key')),
);
});
it('命令行参数应覆盖 SSH config 值', () => {
process.argv = ['node', 'test', '--host', 'testhost', '--port', '3333', '--ssh-config-file', testSshConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.port, 3333); // 覆盖
assert.strictEqual(result.configs.default.host, '172.16.0.1'); // 从 SSH config
assert.strictEqual(result.configs.default.username, 'testuser'); // 从 SSH config
});
it('应该支持 SSH config 别名 + 密码认证', () => {
process.argv = ['node', 'test', '--host', 'testhost', '--password', 'mypass', '--ssh-config-file', testSshConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.password, 'mypass');
assert.strictEqual(result.configs.default.host, '172.16.0.1');
});
});
describe('命令白名单和黑名单', () => {
it('应该正确解析命令白名单', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--whitelist', 'ls,cat,grep'];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
});
it('应该正确解析命令黑名单', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--blacklist', 'rm,shutdown,reboot'];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown', 'reboot']);
});
});
describe('其他选项', () => {
it('默认 transportMode 应为 exec', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.transportMode, 'exec');
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 10000);
});
it('应该正确解析 shell transport 相关选项', () => {
process.argv = [
'node',
'test',
'--host', '1.2.3.4',
'--port', '22',
'--username', 'user',
'--password', 'pass',
'--transport-mode', 'shell',
'--shell-ready-timeout', '15000'
];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.transportMode, 'shell');
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 15000);
});
it('应该正确解析 --pty 选项', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pty'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.pty, true);
});
it('应该正确解析配置文件中的字符串 false pty', () => {
const ptyConfigPath = path.join(fixturesDir, 'pty-config.json');
fs.writeFileSync(ptyConfigPath, JSON.stringify({
dev: {
host: '192.168.1.100',
port: 22,
username: 'devuser',
password: 'devpass',
pty: 'false'
}
}));
process.argv = ['node', 'test', '--config-file', ptyConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.dev.pty, false);
fs.unlinkSync(ptyConfigPath);
});
it('应该正确解析 --pre-connect 选项', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pre-connect'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.preConnect, true);
});
it('应该正确解析 SOCKS 代理', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--socksProxy', 'socks://proxy:1080'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.socksProxy, 'socks://proxy:1080');
});
it('应该正确解析通用代理', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--proxy', 'http://proxy:8080'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.proxy, 'http://proxy:8080');
});
it('应该正确解析 allowed local paths', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-local-paths', './tmp,~/.ssh'];
const result = CommandLineParser.parseArgs();
assert.ok(Array.isArray(result.configs.default.allowedLocalPaths));
assert.strictEqual(result.configs.default.allowedLocalPaths.length, 2);
assert.ok(result.configs.default.allowedLocalPaths.every((entry) => path.isAbsolute(entry)));
assert.strictEqual(result.configs.default.allowedLocalPaths[1], path.join(os.homedir(), '.ssh'));
});
it('应该正确解析 allowed remote paths', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', '/var/log,/home/ops/inbox/'];
const result = CommandLineParser.parseArgs();
assert.deepStrictEqual(
result.configs.default.allowedRemotePaths,
['/var/log', '/home/ops/inbox']
);
});
it('相对的 allowedRemotePaths 条目应抛出错误', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', 'var/log'];
assert.throws(() => CommandLineParser.parseArgs(), /absolute POSIX/);
});
it('应该正确解析配置文件中的 commandTemplate', () => {
const templateConfigPath = path.join(fixturesDir, 'template-config.json');
fs.writeFileSync(templateConfigPath, JSON.stringify({
dev: {
host: '192.168.1.100',
port: 22,
username: 'devuser',
password: 'devpass',
commandTemplate: "su root -c '<command>'"
}
}));
process.argv = ['node', 'test', '--config-file', templateConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c '<command>'");
fs.unlinkSync(templateConfigPath);
});
it('应该支持 commandTemplate 的 <quotedCommand> 占位符', () => {
const templateConfigPath = path.join(fixturesDir, 'quoted-template-config.json');
fs.writeFileSync(templateConfigPath, JSON.stringify({
dev: {
host: '192.168.1.100',
port: 22,
username: 'devuser',
password: 'devpass',
commandTemplate: "su root -c <quotedCommand>"
}
}));
process.argv = ['node', 'test', '--config-file', templateConfigPath];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c <quotedCommand>");
fs.unlinkSync(templateConfigPath);
});
it('commandTemplate 缺少 <command> 占位符时应抛出错误', () => {
const badConfigPath = path.join(fixturesDir, 'bad-template-config.json');
fs.writeFileSync(badConfigPath, JSON.stringify({
dev: {
host: '192.168.1.100',
port: 22,
username: 'devuser',
password: 'devpass',
commandTemplate: "su root -c 'missing placeholder'"
}
}));
process.argv = ['node', 'test', '--config-file', badConfigPath];
assert.throws(() => CommandLineParser.parseArgs(), /<command>/);
fs.unlinkSync(badConfigPath);
});
});
describe('优先级测试', () => {
it('配置文件应优先于 --ssh 参数', () => {
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
process.argv = ['node', 'test', '--config-file', testConfigPath, '--ssh', sshJson];
const result = CommandLineParser.parseArgs();
// 应该只有配置文件中的服务器
assert.strictEqual(Object.keys(result.configs).length, 2);
assert.ok(result.configs.dev);
assert.ok(result.configs.prod);
assert.ok(!result.configs.test);
});
it('--ssh 参数应优先于单连接模式', () => {
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
process.argv = ['node', 'test', '--ssh', sshJson, '--host', '2.2.2.2', '--port', '22', '--username', 'user2', '--password', 'pass2'];
const result = CommandLineParser.parseArgs();
assert.strictEqual(Object.keys(result.configs).length, 1);
assert.strictEqual(result.configs.test.host, '1.1.1.1');
});
});
});
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { CommandLineParser } from '../build/cli/command-line-parser.js';
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
let fixturesDir;
describe('集成测试', () => {
let originalArgv;
before(() => {
originalArgv = process.argv;
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-integration-test-'));
});
after(() => {
process.argv = originalArgv;
fs.rmSync(fixturesDir, { recursive: true, force: true });
});
describe('端到端场景', () => {
it('应该能够从命令行参数创建完整配置并传递给连接管理器', () => {
process.argv = [
'node',
'test',
'--host', '192.168.1.100',
'--port', '22',
'--username', 'testuser',
'--password', 'testpass',
'--whitelist', 'ls,cat,grep',
'--blacklist', 'rm,shutdown'
];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '192.168.1.100');
assert.strictEqual(result.configs.default.port, 22);
assert.strictEqual(result.configs.default.username, 'testuser');
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown']);
// 验证可以用这个配置初始化连接管理器
const manager = SSHConnectionManager.getInstance();
manager.setConfig(result.configs);
const config = manager.getConfig('default');
assert.strictEqual(config.host, '192.168.1.100');
assert.strictEqual(config.username, 'testuser');
});
it('应该能够从命令行参数生成 shell transport 配置', () => {
process.argv = [
'node',
'test',
'--host', '192.168.1.110',
'--port', '22',
'--username', 'shelluser',
'--password', 'shellpass',
'--transport-mode', 'shell',
'--shell-ready-timeout', '18000'
];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.transportMode, 'shell');
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 18000);
const manager = SSHConnectionManager.getInstance();
manager.setConfig(result.configs);
const config = manager.getConfig('default');
assert.strictEqual(config.transportMode, 'shell');
assert.strictEqual(config.shellReadyTimeoutMs, 18000);
});
it('应该能够从 SSH config 创建完整配置', () => {
const tempSshConfig = path.join(fixturesDir, 'integration-ssh-config');
fs.writeFileSync(tempSshConfig, [
'Host integration-test',
' HostName 192.168.1.200',
' Port 2222',
' User integrationuser',
' IdentityFile ~/.ssh/integration_key',
].join('\n'));
try {
process.argv = [
'node',
'test',
'--host', 'integration-test',
'--ssh-config-file', tempSshConfig
];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.default.host, '192.168.1.200');
assert.strictEqual(result.configs.default.port, 2222);
assert.strictEqual(result.configs.default.username, 'integrationuser');
assert.ok(result.configs.default.privateKey.includes('integration_key'));
// 验证可以用这个配置初始化连接管理器
const manager = SSHConnectionManager.getInstance();
manager.setConfig(result.configs);
const config = manager.getConfig('default');
assert.strictEqual(config.host, '192.168.1.200');
} finally {
fs.unlinkSync(tempSshConfig);
}
});
it('应该能够处理多服务器配置', () => {
const tempConfig = path.join(fixturesDir, 'multi-server-config.json');
fs.writeFileSync(tempConfig, JSON.stringify({
server1: {
host: '192.168.1.1',
port: 22,
username: 'user1',
password: 'pass1',
commandWhitelist: ['^ls', '^cat']
},
server2: {
host: '192.168.1.2',
port: 2222,
username: 'user2',
privateKey: '~/.ssh/key2',
commandBlacklist: ['^rm', '^shutdown']
}
}));
try {
process.argv = ['node', 'test', '--config-file', tempConfig];
const result = CommandLineParser.parseArgs();
assert.strictEqual(Object.keys(result.configs).length, 2);
const manager = SSHConnectionManager.getInstance();
manager.setConfig(result.configs);
// 验证配置列表
const allInfos = manager.getAllServerInfos();
assert.strictEqual(allInfos.length, 2);
const server1 = allInfos.find(i => i.name === 'server1');
assert.ok(server1);
assert.strictEqual(server1.host, '192.168.1.1');
assert.strictEqual(server1.connected, false);
const server2 = allInfos.find(i => i.name === 'server2');
assert.ok(server2);
assert.strictEqual(server2.port, 2222);
} finally {
fs.unlinkSync(tempConfig);
}
});
it('应该能够从配置文件生成 shell transport 配置', () => {
const tempConfig = path.join(fixturesDir, 'shell-server-config.json');
fs.writeFileSync(tempConfig, JSON.stringify({
shellbox: {
host: '192.168.1.20',
port: 22,
username: 'shelluser',
password: 'shellpass',
transportMode: 'shell',
shellReadyTimeoutMs: 12000
}
}));
try {
process.argv = ['node', 'test', '--config-file', tempConfig];
const result = CommandLineParser.parseArgs();
assert.strictEqual(result.configs.shellbox.transportMode, 'shell');
assert.strictEqual(result.configs.shellbox.shellReadyTimeoutMs, 12000);
const manager = SSHConnectionManager.getInstance();
manager.setConfig(result.configs);
const config = manager.getConfig('shellbox');
assert.strictEqual(config.transportMode, 'shell');
assert.strictEqual(config.shellReadyTimeoutMs, 12000);
} finally {
fs.unlinkSync(tempConfig);
}
});
});
describe('错误处理', () => {
it('应该正确处理无效的 JSON 配置文件', () => {
const invalidConfig = path.join(fixturesDir, 'invalid-config.json');
fs.writeFileSync(invalidConfig, '{ invalid json }');
try {
process.argv = ['node', 'test', '--config-file', invalidConfig];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /Invalid JSON/);
} finally {
fs.unlinkSync(invalidConfig);
}
});
it('应该正确处理缺少必需字段的配置', () => {
const incompleteConfig = path.join(fixturesDir, 'incomplete-config.json');
fs.writeFileSync(incompleteConfig, JSON.stringify({
server1: {
host: '192.168.1.1'
// 缺少 port, username 等必需字段
}
}));
try {
process.argv = ['node', 'test', '--config-file', incompleteConfig];
assert.throws(() => {
CommandLineParser.parseArgs();
});
} finally {
fs.unlinkSync(incompleteConfig);
}
});
it('应该正确处理不存在的配置文件', () => {
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /not found/);
});
it('应该正确处理缺少认证参数的情况', () => {
const emptySshConfig = path.join(fixturesDir, 'empty-ssh-config');
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
fs.writeFileSync(emptySshConfig, '');
try {
delete process.env.SSH_AUTH_SOCK;
process.argv = [
'node',
'test',
'--host', '1.2.3.4',
'--port', '22',
'--username', 'user',
'--ssh-config-file', emptySshConfig
];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /Missing required parameters/);
} finally {
if (originalSshAuthSock === undefined) {
delete process.env.SSH_AUTH_SOCK;
} else {
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
}
fs.unlinkSync(emptySshConfig);
}
});
it('应该正确处理无效的端口号', () => {
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', 'abc', '--username', 'user', '--password', 'pass'];
assert.throws(() => {
CommandLineParser.parseArgs();
}, /Port must be a valid number/);
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import * as path from 'node:path';
test('does not load SSH dependencies when importing the connection manager', () => {
const managerUrl = pathToFileURL(
path.resolve('build/services/ssh-connection-manager.js'),
).href;
const loader = `data:text/javascript,${encodeURIComponent(`
export async function resolve(specifier, context, nextResolve) {
if (specifier === 'ssh2' || specifier === 'socks') {
throw new Error('eager SSH dependency: ' + specifier);
}
return nextResolve(specifier, context);
}
`)}`;
const result = spawnSync(
process.execPath,
[
'--experimental-loader',
loader,
'--input-type=module',
'--eval',
`await import(${JSON.stringify(managerUrl)});`,
],
{ cwd: path.resolve('.'), encoding: 'utf8', timeout: 10_000 },
);
assert.strictEqual(
result.status,
0,
[result.error?.stack, result.stderr, result.stdout].filter(Boolean).join('\n'),
);
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.join(__dirname, '..');
const entrypoint = path.join(rootDir, 'build', 'index.js');
function waitForOutput(stream, pattern, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
let output = '';
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`));
}, timeoutMs);
const onData = (chunk) => {
output += chunk.toString();
if (pattern.test(output)) {
cleanup();
resolve(output);
}
};
const cleanup = () => {
clearTimeout(timeout);
stream.off('data', onData);
};
stream.on('data', onData);
});
}
async function waitForRunning(child, delayMs = 200) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
assert.strictEqual(child.exitCode, null);
}
function waitForExit(child, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('Process did not exit before timeout'));
}, timeoutMs);
child.once('exit', (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
function createTempConfig() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
const configPath = path.join(tmpDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
test: {
host: '127.0.0.1',
port: 22,
username: 'test',
password: 'test',
commandWhitelist: ['^echo'],
},
}));
return { tmpDir, configPath };
}
function createPreConnectConfig(port) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
const configPath = path.join(tmpDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
test: {
host: '127.0.0.1',
port,
username: 'test',
password: 'test',
connectionTimeoutMs: 10000,
commandWhitelist: ['^echo'],
},
}));
return { tmpDir, configPath };
}
function spawnServer(configPath) {
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let closed = false;
return {
child,
closeInput: () => {
if (closed) return;
closed = true;
child.stdin.end();
},
};
}
describe('MCP server lifecycle', () => {
// Windows has no POSIX signals: child.kill('SIGTERM') maps to
// TerminateProcess, so the handler under test never runs and the exit is
// always reported as signalled. The stdin path below covers shutdown there,
// and is what MCP clients actually use.
it('exits after SIGTERM even when stdin remains open', { skip: process.platform === 'win32' && 'no POSIX signals on Windows' }, async () => {
const { tmpDir, configPath } = createTempConfig();
const { child, closeInput } = spawnServer(configPath);
try {
await waitForRunning(child, 1500);
child.kill('SIGTERM');
const result = await waitForExit(child);
assert.strictEqual(result.signal, null);
assert.strictEqual(result.code, 0);
} finally {
try {
closeInput();
} catch {}
child.kill('SIGKILL');
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('exits after stdin is closed', async () => {
const { tmpDir, configPath } = createTempConfig();
const { child, closeInput } = spawnServer(configPath);
try {
await waitForRunning(child);
closeInput();
const result = await waitForExit(child);
assert.strictEqual(result.signal, null);
assert.strictEqual(result.code, 0);
} finally {
try {
closeInput();
} catch {}
child.kill('SIGKILL');
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('accepts initialize requests while pre-connect is still pending', async () => {
const handshakeServer = net.createServer();
const sockets = new Set();
handshakeServer.on('connection', (socket) => {
sockets.add(socket);
// Killing the child resets this connection instead of closing it
// cleanly on some platforms; an unhandled 'error' would fail the test.
socket.on('error', () => {});
socket.on('close', () => sockets.delete(socket));
});
await new Promise((resolve, reject) => {
handshakeServer.once('error', reject);
handshakeServer.listen(0, '127.0.0.1', resolve);
});
const address = handshakeServer.address();
assert.ok(address && typeof address !== 'string');
const { tmpDir, configPath } = createPreConnectConfig(address.port);
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath, '--pre-connect'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
child.stdin.write(`${JSON.stringify({
jsonrpc: '2.0',
id: 61,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'lifecycle-test', version: '1.0.0' },
},
})}\n`);
const output = await waitForOutput(child.stdout, /"id":61/, 1250);
assert.match(output, /"result"/);
} finally {
child.stdin.end();
if (child.exitCode === null) {
const exitPromise = waitForExit(child);
child.kill('SIGKILL');
await exitPromise;
}
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => handshakeServer.close(resolve));
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { formatServerList } from '../build/tools/list-servers.js';
describe('List Servers Tool', () => {
it('没有配置时应返回友好提示', () => {
assert.strictEqual(formatServerList([]), 'No SSH servers configured.');
});
it('应返回可读摘要和原始 JSON', () => {
const output = formatServerList([
{
name: 'dev',
host: '192.168.1.100',
port: 22,
username: 'root',
connected: true,
status: {
reachable: true,
hostname: 'dev-box',
osName: 'Linux',
lastUpdated: '2026-04-02T12:00:00.000Z'
}
}
]);
assert.match(output, /Configured SSH servers:/);
assert.match(output, /\[connected\] dev \| root@192.168.1.100:22/);
assert.match(output, /hostname=dev-box/);
assert.match(output, /Raw JSON:/);
assert.match(output, /"name":"dev"/);
});
it('原始 JSON 不缩进,且仍可解析回等价对象', () => {
const servers = [
{
name: 'dev',
host: '192.168.1.100',
port: 22,
username: 'root',
connected: true,
status: {
reachable: true,
hostname: 'dev-box',
osName: 'Linux',
drives: [
{
device: '/dev/sda1',
mountPoint: '/',
total: '512G',
used: '380G',
free: '106G',
usagePercent: '78%',
},
],
lastUpdated: '2026-04-02T12:00:00.000Z',
},
},
];
const rawJson = formatServerList(servers).split('\nRaw JSON:\n')[1];
assert.deepStrictEqual(JSON.parse(rawJson), servers);
// 缩进换行是这里的主要体积来源,压缩后应只剩一行
assert.ok(!rawJson.includes('\n'));
assert.ok(rawJson.length < JSON.stringify(servers, null, 2).length * 0.7);
});
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert';
import { redactSecret, redactSecrets } from '../build/utils/redact.js';
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
test('redactSecret masks long secrets everywhere', () => {
const text = 'login with hunter2hunter2 then done';
assert.strictEqual(redactSecret(text, 'hunter2'), 'login with [REDACTED][REDACTED] then done');
});
test('redactSecret masks long secret even mid-word', () => {
assert.strictEqual(
redactSecret('password: secretvalue and secretvalueagain', 'secretvalue'),
'password: [REDACTED] and [REDACTED]again',
);
});
test('redactSecret masks short secrets only as whole tokens', () => {
// 2-3 char secrets are only replaced when they stand alone
const text = 'the cat sat on a mat';
assert.strictEqual(redactSecret(text, 'cat'), 'the [REDACTED] sat on a mat');
});
test('redactSecret ignores short secret inside a word', () => {
// "cat" inside "catastrophe" must not be destroyed
assert.strictEqual(redactSecret('a catastrophe', 'cat'), 'a catastrophe');
});
test('redactSecret ignores empty and 1-char secrets', () => {
assert.strictEqual(redactSecret('abc', ''), 'abc');
assert.strictEqual(redactSecret('abc', 'a'), 'abc');
assert.strictEqual(redactSecret('abc', undefined), 'abc');
assert.strictEqual(redactSecret('abc', null), 'abc');
assert.strictEqual(redactSecret('', 'secret'), '');
});
test('redactSecret escapes regex special characters', () => {
const text = 'tokens like a.b and aXb';
assert.strictEqual(redactSecret(text, 'a.b'), 'tokens like [REDACTED] and aXb');
});
test('redactSecrets redacts many secrets at once', () => {
const text = 'user=alice pass=topsecret key=pLskey-end';
assert.strictEqual(
redactSecrets(text, ['topsecret', 'pLskey']),
'user=alice pass=[REDACTED] key=[REDACTED]-end',
);
});
test('redactSecret returns original when no match', () => {
const text = 'nothing sensitive here';
assert.strictEqual(redactSecret(text, 'nope'), text);
});
test('dynamic mode config requires host and username', async () => {
const manager = SSHConnectionManager.getInstance();
await assert.rejects(
() => manager.executeCommandDynamic({ username: 'root' }, 'id'),
(err) => err?.code === 'SSH_CONFIG_MISSING',
);
});
+310
View File
@@ -0,0 +1,310 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { lookupSshConfig } from '../build/utils/ssh-config-parser.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
let fixturesDir;
describe('SSH Config Parser', () => {
let testConfigPath;
let testConfigWithIncludePath;
let includedConfigPath;
let originalHome;
before(() => {
originalHome = process.env.HOME;
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-config-test-'));
testConfigPath = path.join(fixturesDir, 'ssh-config-basic');
testConfigWithIncludePath = path.join(fixturesDir, 'ssh-config-include');
includedConfigPath = path.join(fixturesDir, 'ssh-config-included');
// 基本测试配置
fs.writeFileSync(testConfigPath, [
'# 多别名测试',
'Host dev staging',
' HostName 192.168.1.100',
' Port 2222',
' User devuser',
' IdentityFile ~/.ssh/dev_key',
'',
'# 单别名测试',
'Host prod',
' HostName 10.0.0.50',
' User produser',
' IdentityFile ~/.ssh/prod_key',
'',
'# 通配符测试',
'Host *.example.com',
' User wildcarduser',
' Port 2200',
'',
'# 全局默认值',
'Host *',
' Port 22',
' User defaultuser',
].join('\n'));
// 被包含的配置文件
fs.writeFileSync(includedConfigPath, [
'Host included-host',
' HostName 172.16.0.1',
' Port 3333',
' User includeduser',
].join('\n'));
// 带 Include 的配置文件
fs.writeFileSync(testConfigWithIncludePath, [
`Include ${includedConfigPath}`,
'',
'Host main-host',
' HostName 192.168.1.1',
' User mainuser',
'',
'Host *',
' Port 22',
].join('\n'));
});
after(() => {
process.env.HOME = originalHome;
fs.rmSync(fixturesDir, { recursive: true, force: true });
});
describe('基本功能', () => {
it('应该正确解析单个 Host 别名', () => {
const config = lookupSshConfig('prod', testConfigPath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '10.0.0.50');
assert.strictEqual(config.user, 'produser');
assert.strictEqual(config.identityFile, path.join(os.homedir(), '.ssh', 'prod_key'));
assert.strictEqual(config.port, 22); // 从 Host * fallback
});
it('应该正确解析多别名 Host 行 - dev', () => {
const config = lookupSshConfig('dev', testConfigPath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '192.168.1.100');
assert.strictEqual(config.port, 2222);
assert.strictEqual(config.user, 'devuser');
});
it('应该正确解析多别名 Host 行 - staging', () => {
const config = lookupSshConfig('staging', testConfigPath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '192.168.1.100');
assert.strictEqual(config.port, 2222);
assert.strictEqual(config.user, 'devuser');
});
it('应该支持通配符匹配', () => {
const config = lookupSshConfig('server.example.com', testConfigPath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.user, 'wildcarduser');
assert.strictEqual(config.port, 2200);
});
it('应该使用 Host * 作为默认值', () => {
const config = lookupSshConfig('unknown-host', testConfigPath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.user, 'defaultuser');
assert.strictEqual(config.port, 22);
assert.strictEqual(config.hostName, undefined);
});
it('通配符匹配应转义正则特殊字符', () => {
const tempConfig = path.join(fixturesDir, 'special-pattern-config');
fs.writeFileSync(tempConfig, [
'Host host[1]',
' User literaluser',
'',
'Host *',
' User defaultuser',
].join('\n'));
const literalConfig = lookupSshConfig('host[1]', tempConfig);
const regexLikeConfig = lookupSshConfig('host1', tempConfig);
assert.ok(literalConfig);
assert.strictEqual(literalConfig.user, 'literaluser');
assert.ok(regexLikeConfig);
assert.strictEqual(regexLikeConfig.user, 'defaultuser');
fs.unlinkSync(tempConfig);
});
it('应该支持 Host 中的否定模式', () => {
const tempConfig = path.join(fixturesDir, 'negated-pattern-config');
fs.writeFileSync(tempConfig, [
'Host *.example.com !blocked.example.com',
' User wildcarduser',
'',
'Host *',
' User defaultuser',
].join('\n'));
const allowedConfig = lookupSshConfig('app.example.com', tempConfig);
const blockedConfig = lookupSshConfig('blocked.example.com', tempConfig);
assert.ok(allowedConfig);
assert.strictEqual(allowedConfig.user, 'wildcarduser');
assert.ok(blockedConfig);
assert.strictEqual(blockedConfig.user, 'defaultuser');
fs.unlinkSync(tempConfig);
});
});
describe('Include 指令', () => {
it('应该正确处理 Include 指令', () => {
const config = lookupSshConfig('included-host', testConfigWithIncludePath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '172.16.0.1');
assert.strictEqual(config.port, 3333);
assert.strictEqual(config.user, 'includeduser');
});
it('应该在 Include 后继续解析主配置文件', () => {
const config = lookupSshConfig('main-host', testConfigWithIncludePath);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '192.168.1.1');
assert.strictEqual(config.user, 'mainuser');
assert.strictEqual(config.port, 22);
});
it('应该静默跳过不存在的 Include 文件', () => {
const tempConfig = path.join(fixturesDir, 'temp-include-config');
fs.writeFileSync(tempConfig, [
'Include /nonexistent/path/config',
'',
'Host test',
' HostName 1.2.3.4',
].join('\n'));
const config = lookupSshConfig('test', tempConfig);
assert.ok(config, '应该返回非 null 的配置');
assert.strictEqual(config.hostName, '1.2.3.4');
fs.unlinkSync(tempConfig);
});
});
describe('边界情况', () => {
it('默认配置文件不存在时应返回 null', () => {
const fakeHome = fs.mkdtempSync(path.join(fixturesDir, 'fake-home-'));
process.env.HOME = fakeHome;
try {
const config = lookupSshConfig('any-host');
assert.strictEqual(config, null);
} finally {
process.env.HOME = originalHome;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
it('显式指定的配置文件不存在时应抛出错误', () => {
assert.throws(() => {
lookupSshConfig('any-host', '/nonexistent/config');
}, /not found/);
});
it('未找到匹配的 Host 时应返回 null', () => {
const tempConfig = path.join(fixturesDir, 'empty-config');
fs.writeFileSync(tempConfig, '# Empty config\n');
const config = lookupSshConfig('any-host', tempConfig);
assert.strictEqual(config, null);
fs.unlinkSync(tempConfig);
});
it('应该正确展开 ~ 路径', () => {
const config = lookupSshConfig('dev', testConfigPath);
assert.ok(config);
assert.ok(config.identityFile.startsWith(os.homedir()));
assert.ok(!config.identityFile.includes('~'));
});
it('应该正确处理注释行', () => {
const tempConfig = path.join(fixturesDir, 'comment-config');
fs.writeFileSync(tempConfig, [
'# 这是注释',
'Host test',
' HostName 1.2.3.4 # 行内注释',
' Port 2222',
].join('\n'));
const config = lookupSshConfig('test', tempConfig);
assert.ok(config);
assert.strictEqual(config.hostName, '1.2.3.4');
assert.strictEqual(config.port, 2222);
fs.unlinkSync(tempConfig);
});
it('应该正确处理空白行和缩进', () => {
const tempConfig = path.join(fixturesDir, 'whitespace-config');
fs.writeFileSync(tempConfig, [
'',
' Host test ',
' HostName 1.2.3.4 ',
'',
' Port 2222 ',
'',
].join('\n'));
const config = lookupSshConfig('test', tempConfig);
assert.ok(config);
assert.strictEqual(config.hostName, '1.2.3.4');
assert.strictEqual(config.port, 2222);
fs.unlinkSync(tempConfig);
});
});
describe('First-match-wins 语义', () => {
it('应该使用第一个匹配的值', () => {
const tempConfig = path.join(fixturesDir, 'priority-config');
fs.writeFileSync(tempConfig, [
'Host test',
' Port 2222',
' User firstuser',
'',
'Host test',
' Port 3333',
' User seconduser',
'',
'Host *',
' Port 22',
].join('\n'));
const config = lookupSshConfig('test', tempConfig);
assert.ok(config);
assert.strictEqual(config.port, 2222);
assert.strictEqual(config.user, 'firstuser');
fs.unlinkSync(tempConfig);
});
it('特定 Host 的值应优先于 Host *', () => {
const tempConfig = path.join(fixturesDir, 'specific-priority-config');
fs.writeFileSync(tempConfig, [
'Host specific',
' Port 2222',
'',
'Host *',
' Port 22',
' User globaluser',
].join('\n'));
const config = lookupSshConfig('specific', tempConfig);
assert.ok(config);
assert.strictEqual(config.port, 2222); // 来自 Host specific
assert.strictEqual(config.user, 'globaluser'); // 来自 Host *
fs.unlinkSync(tempConfig);
});
});
});
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { collectSystemStatus } from '../build/utils/status-collector.js';
const PROBE_PATTERN = /printf '\\n(__MCP_FIELD_\w+_)(\w+)\\n'/g;
/** Field names in the order the script probes them, plus the shared marker. */
function readProbes(script) {
const probes = [...script.matchAll(PROBE_PATTERN)];
return {
marker: probes[0]?.[1],
fields: probes.map((probe) => probe[2]),
};
}
/**
* Stand-in for the remote shell: emits the marker line for every probe the
* script declares, followed by whatever `values` supplies for it.
*/
function fakeRemote(values, { lineEnding = '\n' } = {}) {
return (script) => {
const { marker, fields } = readProbes(script);
const output = fields
.map((field) => `${marker}${field}${lineEnding}${values[field] ?? ''}`)
.join(lineEnding);
return Promise.resolve(lineEnding + output);
};
}
describe('status collector', () => {
it('所有探针合并为一条单行命令', async () => {
const scripts = [];
await collectSystemStatus((script) => {
scripts.push(script);
return Promise.resolve('');
}, 'dev');
assert.strictEqual(scripts.length, 1);
// 多行脚本会被 commandTemplate 的引号包裹破坏
assert.ok(!scripts[0].includes('\n'));
// 每个探针都被隔离,单个失败不影响其余,且整体以成功退出
assert.ok(scripts[0].endsWith('; true'));
const { fields } = readProbes(scripts[0]);
assert.ok(fields.includes('hostname'));
assert.ok(fields.includes('servicesInstalled'));
assert.strictEqual(new Set(fields).size, fields.length);
});
it('按 marker 还原各字段', async () => {
const status = await collectSystemStatus(
fakeRemote({
hostname: 'web-01',
osName: 'Linux',
kernelVersion: '6.1.0',
memory: 'free:2.1G total:7.7G',
processes: '181',
threads: '901',
}),
'dev',
);
assert.strictEqual(status.reachable, true);
assert.strictEqual(status.hostname, 'web-01');
assert.strictEqual(status.osName, 'Linux');
assert.strictEqual(status.kernelVersion, '6.1.0');
assert.deepStrictEqual(status.memory, { free: '2.1G', total: '7.7G' });
// 解析时会各减去一行表头
assert.deepStrictEqual(status.processes, { running: 180, threads: 900 });
});
// 多行字段是 CRLF 归一化真正起作用的地方:逐行拆分的值不会再单独 trim,
// 残留的 \r 会直接进入结果。
it('pty 的 CRLF 输出同样能解析', async () => {
const status = await collectSystemStatus(
fakeRemote(
{
hostname: 'web-02',
ipAddresses: '10.0.0.7\r\n192.168.1.5',
drives: '/dev/sda1|50G|20G|30G|40%|/',
},
{ lineEnding: '\r\n' },
),
'dev',
);
assert.strictEqual(status.hostname, 'web-02');
assert.deepStrictEqual(status.ipAddresses, ['10.0.0.7', '192.168.1.5']);
assert.deepStrictEqual(status.drives, [
{
device: '/dev/sda1',
total: '50G',
used: '20G',
free: '30G',
usagePercent: '40%',
mountPoint: '/',
},
]);
});
it('空探针不影响其它字段', async () => {
const status = await collectSystemStatus(
fakeRemote({ hostname: 'web-03', ipAddresses: '', osName: 'Linux' }),
'dev',
);
assert.strictEqual(status.hostname, 'web-03');
assert.strictEqual(status.osName, 'Linux');
assert.strictEqual(status.ipAddresses, undefined);
});
// 命令白名单会拒绝这条探针脚本;那种情况下字段留空即可,
// 不能把主机报成不可达。
it('命令被拒绝时字段留空但仍视为可达', async () => {
const status = await collectSystemStatus(
() => Promise.reject(new Error('Command validation failed')),
'dev',
);
assert.strictEqual(status.reachable, true);
assert.strictEqual(status.hostname, undefined);
assert.ok(status.lastUpdated);
});
it('先逐条授权探针,再只批量执行允许的命令', async () => {
const scripts = [];
const recordingRemote = fakeRemote({ hostname: 'allowed-host' });
const status = await collectSystemStatus(
(script) => {
scripts.push(script);
return recordingRemote(script);
},
'dev',
(command) => command === 'hostname',
);
assert.strictEqual(status.hostname, 'allowed-host');
assert.strictEqual(scripts.length, 1);
assert.match(scripts[0], /hostname/);
assert.ok(!scripts[0].includes('uname -s'));
assert.ok(!scripts[0].includes('cat /etc/os-release'));
});
it('没有获准探针时不执行远端脚本', async () => {
let calls = 0;
const status = await collectSystemStatus(
async () => {
calls += 1;
return '';
},
'dev',
() => false,
);
assert.strictEqual(calls, 0);
assert.strictEqual(status.reachable, true);
assert.strictEqual(status.hostname, undefined);
});
});