@znmin/claude-remote-cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -6
- package/THIRD_PARTY_NOTICES.md +16 -16
- package/bin/claude-remote.js +3 -3
- package/bin/claude.js +3 -3
- package/package.json +67 -67
- package/scripts/postinstall.js +11 -11
- package/src/cli.js +187 -157
- package/src/client.js +232 -232
- package/src/installer.js +96 -96
- package/src/local/browser.js +290 -234
- package/src/local/mcp-config.js +320 -0
- package/src/local/mcp-proxy.js +199 -0
- package/src/local/processes.js +144 -144
- package/src/local/server.js +201 -193
- package/src/local/shell.js +72 -72
- package/src/local/tunnel.js +98 -98
- package/src/local/workspace.js +225 -225
- package/src/local-mode.js +218 -182
- package/src/powershell-conflicts.js +67 -67
- package/src/remote-claude.js +39 -39
- package/src/ssh-auth.js +113 -113
- package/src/ssh-policy.js +92 -92
- package/src/ssh.js +16 -16
- package/src/system.js +179 -179
package/src/remote-claude.js
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import { quote } from './system.js';
|
|
2
|
-
|
|
3
|
-
export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
|
|
4
|
-
|
|
5
|
-
export function ensureRemoteClaude(command) {
|
|
6
|
-
const missing = command === 'claude' ? `
|
|
7
|
-
echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
|
|
8
|
-
(
|
|
9
|
-
set -e
|
|
10
|
-
installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
|
|
11
|
-
trap 'rm -f -- "$installer"' EXIT
|
|
12
|
-
if command -v curl >/dev/null 2>&1; then
|
|
13
|
-
curl -fSL </dev/null --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
14
|
-
--output "$installer" https://claude.ai/install.sh
|
|
15
|
-
elif command -v wget >/dev/null 2>&1; then
|
|
16
|
-
wget </dev/null --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
17
|
-
else
|
|
18
|
-
echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
|
|
19
|
-
exit 127
|
|
20
|
-
fi
|
|
21
|
-
if ! bash "$installer" >&2 </dev/null; then
|
|
22
|
-
echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
|
|
23
|
-
exit 1
|
|
24
|
-
fi
|
|
25
|
-
)
|
|
26
|
-
hash -r
|
|
27
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
28
|
-
echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
|
|
29
|
-
exit 127
|
|
30
|
-
fi
|
|
31
|
-
echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
|
|
32
|
-
` : `
|
|
33
|
-
echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
|
|
34
|
-
exit 127
|
|
35
|
-
`;
|
|
36
|
-
// A failed download/installer must stop before any project directory is created or synced.
|
|
37
|
-
return 'set -e\n' + remoteEnvironment + '\n' +
|
|
38
|
-
`if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
|
|
39
|
-
}
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
|
|
4
|
+
|
|
5
|
+
export function ensureRemoteClaude(command) {
|
|
6
|
+
const missing = command === 'claude' ? `
|
|
7
|
+
echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
|
|
8
|
+
(
|
|
9
|
+
set -e
|
|
10
|
+
installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
|
|
11
|
+
trap 'rm -f -- "$installer"' EXIT
|
|
12
|
+
if command -v curl >/dev/null 2>&1; then
|
|
13
|
+
curl -fSL </dev/null --connect-timeout 20 --max-time 120 --retry 2 \\
|
|
14
|
+
--output "$installer" https://claude.ai/install.sh
|
|
15
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
16
|
+
wget </dev/null --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
|
|
17
|
+
else
|
|
18
|
+
echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
|
|
19
|
+
exit 127
|
|
20
|
+
fi
|
|
21
|
+
if ! bash "$installer" >&2 </dev/null; then
|
|
22
|
+
echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
)
|
|
26
|
+
hash -r
|
|
27
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
28
|
+
echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
|
|
29
|
+
exit 127
|
|
30
|
+
fi
|
|
31
|
+
echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
|
|
32
|
+
` : `
|
|
33
|
+
echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
|
|
34
|
+
exit 127
|
|
35
|
+
`;
|
|
36
|
+
// A failed download/installer must stop before any project directory is created or synced.
|
|
37
|
+
return 'set -e\n' + remoteEnvironment + '\n' +
|
|
38
|
+
`if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
|
|
39
|
+
}
|
package/src/ssh-auth.js
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
|
-
import * as fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
|
-
import { sshBase } from './ssh.js';
|
|
6
|
-
import { exists, quote, which, withLock } from './system.js';
|
|
7
|
-
import { parseSSHPolicy, sshPolicyScript } from './ssh-policy.js';
|
|
8
|
-
|
|
9
|
-
// Force a new public-key connection: an existing multiplexed/password session
|
|
10
|
-
// must never be mistaken for successful key authentication.
|
|
11
|
-
const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
|
|
12
|
-
'-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
|
|
13
|
-
|
|
14
|
-
export function authorizeKeyScript(publicKey) {
|
|
15
|
-
if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
|
|
16
|
-
throw new Error('专用 SSH 公钥格式无效。');
|
|
17
|
-
return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
|
|
18
|
-
'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
|
|
19
|
-
'key=' + quote(publicKey) + '; ' +
|
|
20
|
-
'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
|
|
21
|
-
// A pre-existing file need not end with a newline.
|
|
22
|
-
'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
|
|
26
|
-
if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
|
|
27
|
-
const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
|
|
28
|
-
{ env: client.env, timeout: 30000, ...options });
|
|
29
|
-
const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
|
|
30
|
-
{ capture: true, check: false })).code === 0;
|
|
31
|
-
if (await probe()) return;
|
|
32
|
-
if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
|
|
33
|
-
if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
|
|
34
|
-
|
|
35
|
-
const directory = path.join(client.home, '.ssh', 'claude-remote');
|
|
36
|
-
// Windows OpenSSH cannot share one login through a control socket, so each
|
|
37
|
-
// step there opens its own connection and may ask for the password again.
|
|
38
|
-
const multiplex = (client.platform ?? process.platform) !== 'win32';
|
|
39
|
-
await withLock(path.join(directory, 'setup'), async () => {
|
|
40
|
-
if (await probe()) return;
|
|
41
|
-
let temporary, socket;
|
|
42
|
-
const login = args => multiplex ? ['-S', socket, ...args] : args;
|
|
43
|
-
// Without multiplexing, prompts belong to these commands: keep them visible and allow time to answer.
|
|
44
|
-
const prompted = multiplex ? {} : { prompt: true, timeout: 180000 };
|
|
45
|
-
try {
|
|
46
|
-
if (multiplex) {
|
|
47
|
-
const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
|
|
48
|
-
temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
|
|
49
|
-
socket = path.join(temporary, 'ssh');
|
|
50
|
-
client.log('请按提示完成 SSH 验证。');
|
|
51
|
-
// Authenticate once; subsequent installation reuses this private socket.
|
|
52
|
-
// Do not capture authentication output or handle passwords in JavaScript.
|
|
53
|
-
await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
|
|
54
|
-
'-f', '-N', config.host], { timeout: 180000, capture: false });
|
|
55
|
-
} else client.log('请按提示完成 SSH 验证。Windows 的 OpenSSH 不支持连接复用,可能需要输入两次密码。');
|
|
56
|
-
const inspect = await run(login([config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))]),
|
|
57
|
-
{ capture: true, ...prompted });
|
|
58
|
-
const policy = parseSSHPolicy(inspect.stdout);
|
|
59
|
-
if (policy.backup) client.log('已开启公钥登录。配置备份:' + policy.backup);
|
|
60
|
-
if (policy.pubkeyauthentication === 'no')
|
|
61
|
-
throw new Error('服务器关闭了公钥登录(PubkeyAuthentication no),尚未生成或添加密钥。' +
|
|
62
|
-
(policy.uid === '0' ? '如需开启,请运行:claude-remote setup-ssh --enable-pubkey(备份配置、校验后重新加载,保留密码登录)。' :
|
|
63
|
-
'请联系管理员开启该账号的公钥登录,再运行 claude-remote setup-ssh。'));
|
|
64
|
-
if (policy.authenticationmethods && policy.authenticationmethods !== 'any' &&
|
|
65
|
-
!policy.authenticationmethods.split(/\s+/).includes('publickey'))
|
|
66
|
-
throw new Error('服务器 AuthenticationMethods=' + policy.authenticationmethods +
|
|
67
|
-
',不允许仅凭公钥登录;请联系管理员确认认证策略。未修改该策略,尚未生成或添加密钥。');
|
|
68
|
-
if (policy.status !== 'ok') {
|
|
69
|
-
if (enablePubkey) throw new Error('无法读取服务器有效 SSH 配置,未自动修改服务器策略;请联系管理员检查。');
|
|
70
|
-
client.debug('无法读取服务器 SSH 策略,尝试普通公钥授权。');
|
|
71
|
-
}
|
|
72
|
-
const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
|
|
73
|
-
const key = path.join(directory, 'id_ed25519_' + id);
|
|
74
|
-
await fs.chmod(directory, 0o700);
|
|
75
|
-
if (!await exists(key)) {
|
|
76
|
-
if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
|
|
77
|
-
client.debug('创建专用 SSH 密钥:' + key);
|
|
78
|
-
await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
|
|
79
|
-
{ env: client.env, capture: true, timeout: 30000 });
|
|
80
|
-
}
|
|
81
|
-
await fs.chmod(key, 0o600);
|
|
82
|
-
// Derive from the private key so an outdated .pub file cannot authorize
|
|
83
|
-
// the wrong key. Never transmit the private key.
|
|
84
|
-
const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
|
|
85
|
-
{ env: client.env, capture: true, timeout: 30000 });
|
|
86
|
-
const publicKey = derived.stdout.trim();
|
|
87
|
-
const script = authorizeKeyScript(publicKey);
|
|
88
|
-
await run(login([config.host, 'sh -c ' + quote(script)]), { capture: false, ...(multiplex ? {} : { timeout: 180000 }) });
|
|
89
|
-
if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
|
|
90
|
-
throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
|
|
91
|
-
|
|
92
|
-
// SSH's ordinary config also makes the key available to Mutagen and scp.
|
|
93
|
-
// IdentityFile is additive; do not replace existing host/user/proxy rules.
|
|
94
|
-
const configFile = path.join(client.home, '.ssh', 'config');
|
|
95
|
-
const host = config.host.split('@').at(-1);
|
|
96
|
-
// Forward slashes avoid backslash escaping differences between OpenSSH versions on Windows.
|
|
97
|
-
const file = (client.platform ?? process.platform) === 'win32' ? key.replaceAll('\\', '/') : key;
|
|
98
|
-
const escaped = file.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
99
|
-
const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
|
|
100
|
-
const previous = await fs.readFile(configFile, 'utf8').catch(error => {
|
|
101
|
-
if (error.code === 'ENOENT') return ''; throw error;
|
|
102
|
-
});
|
|
103
|
-
if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
|
|
104
|
-
if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
|
|
105
|
-
client.debug('SSH 免密登录已配置并验证成功。');
|
|
106
|
-
} finally {
|
|
107
|
-
if (multiplex) {
|
|
108
|
-
if (socket) await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
|
|
109
|
-
if (temporary) await fs.rm(temporary, { recursive: true, force: true });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
}
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { sshBase } from './ssh.js';
|
|
6
|
+
import { exists, quote, which, withLock } from './system.js';
|
|
7
|
+
import { parseSSHPolicy, sshPolicyScript } from './ssh-policy.js';
|
|
8
|
+
|
|
9
|
+
// Force a new public-key connection: an existing multiplexed/password session
|
|
10
|
+
// must never be mistaken for successful key authentication.
|
|
11
|
+
const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
|
|
12
|
+
'-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
|
|
13
|
+
|
|
14
|
+
export function authorizeKeyScript(publicKey) {
|
|
15
|
+
if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
|
|
16
|
+
throw new Error('专用 SSH 公钥格式无效。');
|
|
17
|
+
return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
|
|
18
|
+
'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
|
|
19
|
+
'key=' + quote(publicKey) + '; ' +
|
|
20
|
+
'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
|
|
21
|
+
// A pre-existing file need not end with a newline.
|
|
22
|
+
'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
|
|
26
|
+
if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
|
|
27
|
+
const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
|
|
28
|
+
{ env: client.env, timeout: 30000, ...options });
|
|
29
|
+
const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
|
|
30
|
+
{ capture: true, check: false })).code === 0;
|
|
31
|
+
if (await probe()) return;
|
|
32
|
+
if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
|
|
33
|
+
if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
|
|
34
|
+
|
|
35
|
+
const directory = path.join(client.home, '.ssh', 'claude-remote');
|
|
36
|
+
// Windows OpenSSH cannot share one login through a control socket, so each
|
|
37
|
+
// step there opens its own connection and may ask for the password again.
|
|
38
|
+
const multiplex = (client.platform ?? process.platform) !== 'win32';
|
|
39
|
+
await withLock(path.join(directory, 'setup'), async () => {
|
|
40
|
+
if (await probe()) return;
|
|
41
|
+
let temporary, socket;
|
|
42
|
+
const login = args => multiplex ? ['-S', socket, ...args] : args;
|
|
43
|
+
// Without multiplexing, prompts belong to these commands: keep them visible and allow time to answer.
|
|
44
|
+
const prompted = multiplex ? {} : { prompt: true, timeout: 180000 };
|
|
45
|
+
try {
|
|
46
|
+
if (multiplex) {
|
|
47
|
+
const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
|
|
48
|
+
temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
|
|
49
|
+
socket = path.join(temporary, 'ssh');
|
|
50
|
+
client.log('请按提示完成 SSH 验证。');
|
|
51
|
+
// Authenticate once; subsequent installation reuses this private socket.
|
|
52
|
+
// Do not capture authentication output or handle passwords in JavaScript.
|
|
53
|
+
await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
|
|
54
|
+
'-f', '-N', config.host], { timeout: 180000, capture: false });
|
|
55
|
+
} else client.log('请按提示完成 SSH 验证。Windows 的 OpenSSH 不支持连接复用,可能需要输入两次密码。');
|
|
56
|
+
const inspect = await run(login([config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))]),
|
|
57
|
+
{ capture: true, ...prompted });
|
|
58
|
+
const policy = parseSSHPolicy(inspect.stdout);
|
|
59
|
+
if (policy.backup) client.log('已开启公钥登录。配置备份:' + policy.backup);
|
|
60
|
+
if (policy.pubkeyauthentication === 'no')
|
|
61
|
+
throw new Error('服务器关闭了公钥登录(PubkeyAuthentication no),尚未生成或添加密钥。' +
|
|
62
|
+
(policy.uid === '0' ? '如需开启,请运行:claude-remote setup-ssh --enable-pubkey(备份配置、校验后重新加载,保留密码登录)。' :
|
|
63
|
+
'请联系管理员开启该账号的公钥登录,再运行 claude-remote setup-ssh。'));
|
|
64
|
+
if (policy.authenticationmethods && policy.authenticationmethods !== 'any' &&
|
|
65
|
+
!policy.authenticationmethods.split(/\s+/).includes('publickey'))
|
|
66
|
+
throw new Error('服务器 AuthenticationMethods=' + policy.authenticationmethods +
|
|
67
|
+
',不允许仅凭公钥登录;请联系管理员确认认证策略。未修改该策略,尚未生成或添加密钥。');
|
|
68
|
+
if (policy.status !== 'ok') {
|
|
69
|
+
if (enablePubkey) throw new Error('无法读取服务器有效 SSH 配置,未自动修改服务器策略;请联系管理员检查。');
|
|
70
|
+
client.debug('无法读取服务器 SSH 策略,尝试普通公钥授权。');
|
|
71
|
+
}
|
|
72
|
+
const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
|
|
73
|
+
const key = path.join(directory, 'id_ed25519_' + id);
|
|
74
|
+
await fs.chmod(directory, 0o700);
|
|
75
|
+
if (!await exists(key)) {
|
|
76
|
+
if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
|
|
77
|
+
client.debug('创建专用 SSH 密钥:' + key);
|
|
78
|
+
await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
|
|
79
|
+
{ env: client.env, capture: true, timeout: 30000 });
|
|
80
|
+
}
|
|
81
|
+
await fs.chmod(key, 0o600);
|
|
82
|
+
// Derive from the private key so an outdated .pub file cannot authorize
|
|
83
|
+
// the wrong key. Never transmit the private key.
|
|
84
|
+
const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
|
|
85
|
+
{ env: client.env, capture: true, timeout: 30000 });
|
|
86
|
+
const publicKey = derived.stdout.trim();
|
|
87
|
+
const script = authorizeKeyScript(publicKey);
|
|
88
|
+
await run(login([config.host, 'sh -c ' + quote(script)]), { capture: false, ...(multiplex ? {} : { timeout: 180000 }) });
|
|
89
|
+
if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
|
|
90
|
+
throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
|
|
91
|
+
|
|
92
|
+
// SSH's ordinary config also makes the key available to Mutagen and scp.
|
|
93
|
+
// IdentityFile is additive; do not replace existing host/user/proxy rules.
|
|
94
|
+
const configFile = path.join(client.home, '.ssh', 'config');
|
|
95
|
+
const host = config.host.split('@').at(-1);
|
|
96
|
+
// Forward slashes avoid backslash escaping differences between OpenSSH versions on Windows.
|
|
97
|
+
const file = (client.platform ?? process.platform) === 'win32' ? key.replaceAll('\\', '/') : key;
|
|
98
|
+
const escaped = file.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
99
|
+
const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
|
|
100
|
+
const previous = await fs.readFile(configFile, 'utf8').catch(error => {
|
|
101
|
+
if (error.code === 'ENOENT') return ''; throw error;
|
|
102
|
+
});
|
|
103
|
+
if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
|
|
104
|
+
if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
|
|
105
|
+
client.debug('SSH 免密登录已配置并验证成功。');
|
|
106
|
+
} finally {
|
|
107
|
+
if (multiplex) {
|
|
108
|
+
if (socket) await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
|
|
109
|
+
if (temporary) await fs.rm(temporary, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/ssh-policy.js
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
import { quote } from './system.js';
|
|
2
|
-
|
|
3
|
-
// configFile is injectable only for isolated filesystem tests, never a CLI flag.
|
|
4
|
-
export function sshPolicyScript({ enable = false, configFile = '/etc/ssh/sshd_config', procRoot = '/proc' } = {}) {
|
|
5
|
-
return `# claude-remote-ssh-policy
|
|
6
|
-
set -eu
|
|
7
|
-
sshd=$(command -v sshd || true)
|
|
8
|
-
if [ -z "$sshd" ] && [ -x /usr/sbin/sshd ]; then sshd=/usr/sbin/sshd; fi
|
|
9
|
-
printf 'uid=%s\\n' "$(id -u)"
|
|
10
|
-
if [ -z "$sshd" ]; then printf 'status=unavailable\\n'; exit 0; fi
|
|
11
|
-
set -- \${SSH_CONNECTION:-}
|
|
12
|
-
connection="user=$(id -un)"
|
|
13
|
-
if [ "$#" -eq 4 ]; then connection="$connection,addr=$1,host=$1,laddr=$3,lport=$4"; fi
|
|
14
|
-
policy() { "$sshd" -T -C "$connection"; }
|
|
15
|
-
if ! current=$(policy 2>/dev/null); then printf 'status=unavailable\\n'; exit 0; fi
|
|
16
|
-
${enable ? `
|
|
17
|
-
if printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication no'; then
|
|
18
|
-
printf '%s\\n' "$current" | grep -Eq '^authenticationmethods (any|([^ ]+ )*publickey)( |$)' || {
|
|
19
|
-
echo '服务器要求额外认证,未修改认证策略。' >&2; exit 1;
|
|
20
|
-
}
|
|
21
|
-
[ "$(id -u)" = 0 ] || { echo '自动开启公钥登录需要 root;请由管理员配置。' >&2; exit 1; }
|
|
22
|
-
[ "$(uname -s)" = Linux ] || { echo '自动开启仅支持 Linux systemd 的标准 SSH 服务。' >&2; exit 1; }
|
|
23
|
-
service=
|
|
24
|
-
for name in ssh.service sshd.service; do
|
|
25
|
-
if systemctl is-active --quiet "$name"; then service=$name; break; fi
|
|
26
|
-
done
|
|
27
|
-
[ -n "$service" ] || { echo '无法确认正在运行的 SSH 服务,未修改配置。' >&2; exit 1; }
|
|
28
|
-
pid=$(systemctl show "$service" --property=MainPID --value)
|
|
29
|
-
case "$pid" in ''|*[!0-9]*|0) echo '无法确认 SSH 服务进程,未修改配置。' >&2; exit 1;; esac
|
|
30
|
-
process_file=${quote(procRoot)}/"$pid"/cmdline
|
|
31
|
-
[ -r "$process_file" ] || { echo '无法读取 SSH 服务参数,未修改配置。' >&2; exit 1; }
|
|
32
|
-
process_args=$(tr '\\000' ' ' < "$process_file")
|
|
33
|
-
invocation=$(systemctl show "$service" --property=ExecStart --property=Environment)
|
|
34
|
-
case "$invocation $process_args" in
|
|
35
|
-
*' -f'*|*' -o'*) echo 'SSH 服务使用自定义参数,请由管理员配置;未修改配置。' >&2; exit 1;;
|
|
36
|
-
esac
|
|
37
|
-
file=${quote(configFile)}
|
|
38
|
-
[ -f "$file" ] && [ ! -L "$file" ] || { echo 'SSH 配置不是普通文件,未修改配置。' >&2; exit 1; }
|
|
39
|
-
backup=$(mktemp "$file.claude-remote.XXXXXX")
|
|
40
|
-
cp -p "$file" "$backup"
|
|
41
|
-
changed=0
|
|
42
|
-
candidate=
|
|
43
|
-
rollback() {
|
|
44
|
-
code=$?
|
|
45
|
-
trap - EXIT HUP INT TERM
|
|
46
|
-
if [ -n "$candidate" ]; then rm -f "$candidate"; fi
|
|
47
|
-
if [ "$changed" = 1 ]; then
|
|
48
|
-
cp -p "$backup" "$file"
|
|
49
|
-
if ! "$sshd" -t || ! systemctl reload "$service"; then
|
|
50
|
-
echo "恢复后的 SSH 配置校验或重新加载失败,请使用备份检查:$backup" >&2
|
|
51
|
-
fi
|
|
52
|
-
echo "开启失败,已恢复原配置;备份:$backup" >&2
|
|
53
|
-
fi
|
|
54
|
-
exit "$code"
|
|
55
|
-
}
|
|
56
|
-
trap rollback EXIT
|
|
57
|
-
trap 'exit 1' HUP INT TERM
|
|
58
|
-
# First global value wins, including over later Include files. Restrictive
|
|
59
|
-
# Match rules must pass the connection-specific check or we roll back.
|
|
60
|
-
candidate=$(mktemp "$file.claude-remote-stage.XXXXXX")
|
|
61
|
-
cp -p "$backup" "$candidate"
|
|
62
|
-
{ printf 'PubkeyAuthentication yes\\n'; cat "$backup"; } > "$candidate"
|
|
63
|
-
changed=1
|
|
64
|
-
mv -f "$candidate" "$file"
|
|
65
|
-
"$sshd" -t
|
|
66
|
-
current=$(policy)
|
|
67
|
-
printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication yes' || {
|
|
68
|
-
echo '当前账号的 Match 策略仍禁止公钥登录。' >&2; exit 1;
|
|
69
|
-
}
|
|
70
|
-
systemctl reload "$service"
|
|
71
|
-
changed=0
|
|
72
|
-
trap - EXIT HUP INT TERM
|
|
73
|
-
printf 'backup=%s\\n' "$backup"
|
|
74
|
-
fi
|
|
75
|
-
` : ''}
|
|
76
|
-
printf 'status=ok\\n'
|
|
77
|
-
printf '%s\\n' "$current" | while IFS=' ' read -r key value; do
|
|
78
|
-
case "$key" in pubkeyauthentication|authenticationmethods|authorizedkeysfile|permitrootlogin)
|
|
79
|
-
printf '%s=%s\\n' "$key" "$value";;
|
|
80
|
-
esac
|
|
81
|
-
done
|
|
82
|
-
`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function parseSSHPolicy(output) {
|
|
86
|
-
const result = {};
|
|
87
|
-
for (const line of output.split(/\r?\n/)) {
|
|
88
|
-
const index = line.indexOf('=');
|
|
89
|
-
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
|
|
90
|
-
}
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
// configFile is injectable only for isolated filesystem tests, never a CLI flag.
|
|
4
|
+
export function sshPolicyScript({ enable = false, configFile = '/etc/ssh/sshd_config', procRoot = '/proc' } = {}) {
|
|
5
|
+
return `# claude-remote-ssh-policy
|
|
6
|
+
set -eu
|
|
7
|
+
sshd=$(command -v sshd || true)
|
|
8
|
+
if [ -z "$sshd" ] && [ -x /usr/sbin/sshd ]; then sshd=/usr/sbin/sshd; fi
|
|
9
|
+
printf 'uid=%s\\n' "$(id -u)"
|
|
10
|
+
if [ -z "$sshd" ]; then printf 'status=unavailable\\n'; exit 0; fi
|
|
11
|
+
set -- \${SSH_CONNECTION:-}
|
|
12
|
+
connection="user=$(id -un)"
|
|
13
|
+
if [ "$#" -eq 4 ]; then connection="$connection,addr=$1,host=$1,laddr=$3,lport=$4"; fi
|
|
14
|
+
policy() { "$sshd" -T -C "$connection"; }
|
|
15
|
+
if ! current=$(policy 2>/dev/null); then printf 'status=unavailable\\n'; exit 0; fi
|
|
16
|
+
${enable ? `
|
|
17
|
+
if printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication no'; then
|
|
18
|
+
printf '%s\\n' "$current" | grep -Eq '^authenticationmethods (any|([^ ]+ )*publickey)( |$)' || {
|
|
19
|
+
echo '服务器要求额外认证,未修改认证策略。' >&2; exit 1;
|
|
20
|
+
}
|
|
21
|
+
[ "$(id -u)" = 0 ] || { echo '自动开启公钥登录需要 root;请由管理员配置。' >&2; exit 1; }
|
|
22
|
+
[ "$(uname -s)" = Linux ] || { echo '自动开启仅支持 Linux systemd 的标准 SSH 服务。' >&2; exit 1; }
|
|
23
|
+
service=
|
|
24
|
+
for name in ssh.service sshd.service; do
|
|
25
|
+
if systemctl is-active --quiet "$name"; then service=$name; break; fi
|
|
26
|
+
done
|
|
27
|
+
[ -n "$service" ] || { echo '无法确认正在运行的 SSH 服务,未修改配置。' >&2; exit 1; }
|
|
28
|
+
pid=$(systemctl show "$service" --property=MainPID --value)
|
|
29
|
+
case "$pid" in ''|*[!0-9]*|0) echo '无法确认 SSH 服务进程,未修改配置。' >&2; exit 1;; esac
|
|
30
|
+
process_file=${quote(procRoot)}/"$pid"/cmdline
|
|
31
|
+
[ -r "$process_file" ] || { echo '无法读取 SSH 服务参数,未修改配置。' >&2; exit 1; }
|
|
32
|
+
process_args=$(tr '\\000' ' ' < "$process_file")
|
|
33
|
+
invocation=$(systemctl show "$service" --property=ExecStart --property=Environment)
|
|
34
|
+
case "$invocation $process_args" in
|
|
35
|
+
*' -f'*|*' -o'*) echo 'SSH 服务使用自定义参数,请由管理员配置;未修改配置。' >&2; exit 1;;
|
|
36
|
+
esac
|
|
37
|
+
file=${quote(configFile)}
|
|
38
|
+
[ -f "$file" ] && [ ! -L "$file" ] || { echo 'SSH 配置不是普通文件,未修改配置。' >&2; exit 1; }
|
|
39
|
+
backup=$(mktemp "$file.claude-remote.XXXXXX")
|
|
40
|
+
cp -p "$file" "$backup"
|
|
41
|
+
changed=0
|
|
42
|
+
candidate=
|
|
43
|
+
rollback() {
|
|
44
|
+
code=$?
|
|
45
|
+
trap - EXIT HUP INT TERM
|
|
46
|
+
if [ -n "$candidate" ]; then rm -f "$candidate"; fi
|
|
47
|
+
if [ "$changed" = 1 ]; then
|
|
48
|
+
cp -p "$backup" "$file"
|
|
49
|
+
if ! "$sshd" -t || ! systemctl reload "$service"; then
|
|
50
|
+
echo "恢复后的 SSH 配置校验或重新加载失败,请使用备份检查:$backup" >&2
|
|
51
|
+
fi
|
|
52
|
+
echo "开启失败,已恢复原配置;备份:$backup" >&2
|
|
53
|
+
fi
|
|
54
|
+
exit "$code"
|
|
55
|
+
}
|
|
56
|
+
trap rollback EXIT
|
|
57
|
+
trap 'exit 1' HUP INT TERM
|
|
58
|
+
# First global value wins, including over later Include files. Restrictive
|
|
59
|
+
# Match rules must pass the connection-specific check or we roll back.
|
|
60
|
+
candidate=$(mktemp "$file.claude-remote-stage.XXXXXX")
|
|
61
|
+
cp -p "$backup" "$candidate"
|
|
62
|
+
{ printf 'PubkeyAuthentication yes\\n'; cat "$backup"; } > "$candidate"
|
|
63
|
+
changed=1
|
|
64
|
+
mv -f "$candidate" "$file"
|
|
65
|
+
"$sshd" -t
|
|
66
|
+
current=$(policy)
|
|
67
|
+
printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication yes' || {
|
|
68
|
+
echo '当前账号的 Match 策略仍禁止公钥登录。' >&2; exit 1;
|
|
69
|
+
}
|
|
70
|
+
systemctl reload "$service"
|
|
71
|
+
changed=0
|
|
72
|
+
trap - EXIT HUP INT TERM
|
|
73
|
+
printf 'backup=%s\\n' "$backup"
|
|
74
|
+
fi
|
|
75
|
+
` : ''}
|
|
76
|
+
printf 'status=ok\\n'
|
|
77
|
+
printf '%s\\n' "$current" | while IFS=' ' read -r key value; do
|
|
78
|
+
case "$key" in pubkeyauthentication|authenticationmethods|authorizedkeysfile|permitrootlogin)
|
|
79
|
+
printf '%s=%s\\n' "$key" "$value";;
|
|
80
|
+
esac
|
|
81
|
+
done
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseSSHPolicy(output) {
|
|
86
|
+
const result = {};
|
|
87
|
+
for (const line of output.split(/\r?\n/)) {
|
|
88
|
+
const index = line.indexOf('=');
|
|
89
|
+
if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
package/src/ssh.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { quote } from './system.js';
|
|
2
|
-
|
|
3
|
-
export function sshBase(config) {
|
|
4
|
-
const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
|
|
5
|
-
if (config.port != null) args.push('-p', String(config.port));
|
|
6
|
-
return args;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function sshArgs(config, script, { interactive = false, tty = false, input = false, controlPath } = {}) {
|
|
10
|
-
const args = sshBase(config);
|
|
11
|
-
if (!interactive && !input) args.push('-n');
|
|
12
|
-
if (tty) args.push('-tt');
|
|
13
|
-
if (controlPath) args.push('-S', controlPath, '-o', 'ControlMaster=no');
|
|
14
|
-
args.push(config.host, 'bash -lc ' + quote(script));
|
|
15
|
-
return args;
|
|
16
|
-
}
|
|
1
|
+
import { quote } from './system.js';
|
|
2
|
+
|
|
3
|
+
export function sshBase(config) {
|
|
4
|
+
const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
|
|
5
|
+
if (config.port != null) args.push('-p', String(config.port));
|
|
6
|
+
return args;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sshArgs(config, script, { interactive = false, tty = false, input = false, controlPath } = {}) {
|
|
10
|
+
const args = sshBase(config);
|
|
11
|
+
if (!interactive && !input) args.push('-n');
|
|
12
|
+
if (tty) args.push('-tt');
|
|
13
|
+
if (controlPath) args.push('-S', controlPath, '-o', 'ControlMaster=no');
|
|
14
|
+
args.push(config.host, 'bash -lc ' + quote(script));
|
|
15
|
+
return args;
|
|
16
|
+
}
|