bitool 0.1.2__py3-none-any.whl
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.
- bitool/__init__.py +27 -0
- bitool/cmd/__init__.py +65 -0
- bitool/cmd/_base.py +105 -0
- bitool/cmd/_condition.py +60 -0
- bitool/cmd/_scheduler.py +548 -0
- bitool/cmd/env.py +454 -0
- bitool/cmd/git.py +123 -0
- bitool/cmd/io.py +248 -0
- bitool/cmd/pdf.py +385 -0
- bitool/cmd/run.py +300 -0
- bitool/cmd/toml.py +237 -0
- bitool/cmd/version.py +630 -0
- bitool/consts.py +14 -0
- bitool/core/__init__.py +7 -0
- bitool/core/app.py +142 -0
- bitool/core/commands.py +194 -0
- bitool/core/config.py +647 -0
- bitool/core/env.py +18 -0
- bitool/core/logger.py +237 -0
- bitool/core/plugin.py +117 -0
- bitool/core/workspace.py +76 -0
- bitool/models/__init__.py +3 -0
- bitool/models/version.py +173 -0
- bitool/scripts/__init__.py +1 -0
- bitool/scripts/bumpversion.py +189 -0
- bitool/scripts/clearscreen.py +37 -0
- bitool/scripts/envpy.py +161 -0
- bitool/scripts/envrs.py +119 -0
- bitool/scripts/filedate.py +246 -0
- bitool/scripts/filelevel.py +191 -0
- bitool/scripts/gittool.py +178 -0
- bitool/scripts/img2pdf.py +151 -0
- bitool/scripts/pdf2img.py +139 -0
- bitool/scripts/piptool.py +130 -0
- bitool/scripts/pymake.py +345 -0
- bitool/scripts/sshcopyid.py +491 -0
- bitool/scripts/taskkill.py +366 -0
- bitool/scripts/which.py +227 -0
- bitool/types.py +7 -0
- bitool/utils/__init__.py +9 -0
- bitool/utils/cli_parser.py +412 -0
- bitool/utils/executor.py +881 -0
- bitool/utils/profiler.py +369 -0
- bitool/utils/task.py +133 -0
- bitool/utils/task_group.py +668 -0
- bitool/utils/tests/__init__.py +0 -0
- bitool/utils/tests/test_profiler.py +487 -0
- bitool-0.1.2.dist-info/METADATA +154 -0
- bitool-0.1.2.dist-info/RECORD +51 -0
- bitool-0.1.2.dist-info/WHEEL +4 -0
- bitool-0.1.2.dist-info/entry_points.txt +15 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""类似 ssh-copy-id 的 SSH 密钥部署工具."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from bitool.utils import RunResult, execute
|
|
17
|
+
|
|
18
|
+
# 主机名验证正则表达式 (支持域名、IPv4、localhost)
|
|
19
|
+
HOSTNAME_PATTERN = re.compile(
|
|
20
|
+
r"^localhost$|" # localhost
|
|
21
|
+
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|" # 域名
|
|
22
|
+
r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" # IPv4
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# 用户名验证正则表达式
|
|
26
|
+
USERNAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_-]*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_hostname(hostname: str) -> bool:
|
|
30
|
+
"""验证主机名格式是否安全.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
hostname: 待验证的主机名
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True 如果格式有效,否则 False
|
|
37
|
+
"""
|
|
38
|
+
return bool(HOSTNAME_PATTERN.match(hostname))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_username(username: str) -> bool:
|
|
42
|
+
"""验证用户名格式是否安全.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
username: 待验证的用户名
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True 如果格式有效,否则 False
|
|
49
|
+
"""
|
|
50
|
+
return bool(USERNAME_PATTERN.match(username)) and len(username) <= 64
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
MIN_KEY_PARTS = 2
|
|
58
|
+
MIN_PORT = 1
|
|
59
|
+
MAX_PORT = 65535
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SSHCopyIDConfig:
|
|
64
|
+
"""SSH 密钥部署配置."""
|
|
65
|
+
|
|
66
|
+
hostname: str
|
|
67
|
+
username: str
|
|
68
|
+
password: str
|
|
69
|
+
port: int = 22
|
|
70
|
+
public_key_path: str = "~/.ssh/id_rsa.pub"
|
|
71
|
+
timeout: int = 30
|
|
72
|
+
connect_timeout: int = 10
|
|
73
|
+
|
|
74
|
+
@cached_property
|
|
75
|
+
def expanded_key_path(self) -> Path:
|
|
76
|
+
"""获取展开后的公钥文件路径."""
|
|
77
|
+
return Path(self.public_key_path).expanduser()
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def ssh_command_parts(self) -> list[str]:
|
|
81
|
+
"""生成基础 SSH 命令部分."""
|
|
82
|
+
return [
|
|
83
|
+
"ssh",
|
|
84
|
+
"-p",
|
|
85
|
+
str(self.port),
|
|
86
|
+
"-o",
|
|
87
|
+
"StrictHostKeyChecking=no",
|
|
88
|
+
"-o",
|
|
89
|
+
"UserKnownHostsFile=/dev/null",
|
|
90
|
+
"-o",
|
|
91
|
+
f"ConnectTimeout={self.connect_timeout}",
|
|
92
|
+
f"{self.username}@{self.hostname}",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SSHAuthenticationError(Exception):
|
|
97
|
+
"""SSH 认证失败时抛出."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SSHConnectionError(Exception):
|
|
101
|
+
"""SSH 连接失败时抛出."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SSHKeyNotFoundError(Exception):
|
|
105
|
+
"""SSH 公钥文件未找到时抛出."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def deploy_with_native_ssh(config: SSHCopyIDConfig, pub_key: str) -> None:
|
|
109
|
+
"""使用原生 SSH 部署 SSH 密钥,无需 sshpass."""
|
|
110
|
+
import shlex
|
|
111
|
+
|
|
112
|
+
# 为 SSH 密钥部署创建临时脚本
|
|
113
|
+
# 提取密钥类型和密钥数据以实现更可靠的匹配
|
|
114
|
+
key_parts = pub_key.split()
|
|
115
|
+
if len(key_parts) >= MIN_KEY_PARTS:
|
|
116
|
+
key_type = key_parts[0]
|
|
117
|
+
key_data = key_parts[1]
|
|
118
|
+
grep_pattern = f"{key_type}.*{key_data}"
|
|
119
|
+
else:
|
|
120
|
+
# 用于非标准密钥格式的后备方案
|
|
121
|
+
grep_pattern = pub_key
|
|
122
|
+
|
|
123
|
+
# 安全转义 shell 变量以防止命令注入
|
|
124
|
+
safe_grep_pattern = shlex.quote(grep_pattern)
|
|
125
|
+
safe_pub_key = shlex.quote(pub_key)
|
|
126
|
+
|
|
127
|
+
script_content = f"""#!/bin/bash
|
|
128
|
+
mkdir -p ~/.ssh
|
|
129
|
+
chmod 700 ~/.ssh
|
|
130
|
+
cd ~/.ssh
|
|
131
|
+
touch authorized_keys
|
|
132
|
+
chmod 600 authorized_keys
|
|
133
|
+
grep -qF {safe_grep_pattern} authorized_keys 2>/dev/null || \\
|
|
134
|
+
echo {safe_pub_key} >> authorized_keys
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
with tempfile.NamedTemporaryFile(
|
|
138
|
+
encoding="utf-8", mode="w", suffix=".sh", delete=False
|
|
139
|
+
) as script_file:
|
|
140
|
+
script_file.write(script_content)
|
|
141
|
+
script_path = script_file.name
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# 使用 SSH 执行部署脚本,通过管道传递给 bash
|
|
145
|
+
command = [
|
|
146
|
+
"ssh",
|
|
147
|
+
*config.ssh_command_parts[:-1], # 除主机名外的所有部分
|
|
148
|
+
f"{config.username}@{config.hostname}",
|
|
149
|
+
"bash -s",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
logger.info("尝试使用原生 SSH 进行部署...")
|
|
153
|
+
# 注意:此处已通过 validate_hostname() 和 validate_username() 验证输入
|
|
154
|
+
# 使用列表形式且 shell=False 是安全的
|
|
155
|
+
process = execute(
|
|
156
|
+
command, check=False, capture_output=True, text=True, timeout=config.timeout
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if process.returncode != 0:
|
|
160
|
+
if "Permission denied" in process.stderr:
|
|
161
|
+
msg = "原生 SSH 认证失败"
|
|
162
|
+
raise SSHAuthenticationError(msg)
|
|
163
|
+
msg = "原生 SSH 部署失败:%s", process.stderr
|
|
164
|
+
raise SSHConnectionError(msg)
|
|
165
|
+
|
|
166
|
+
logger.info("使用原生 SSH 方法成功部署 SSH 密钥")
|
|
167
|
+
|
|
168
|
+
finally:
|
|
169
|
+
# 清理临时文件
|
|
170
|
+
with contextlib.suppress(BaseException):
|
|
171
|
+
Path(script_path).unlink()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_public_key(key_path: Path) -> str:
|
|
175
|
+
"""读取并验证公钥文件.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
公钥内容
|
|
180
|
+
|
|
181
|
+
Raises
|
|
182
|
+
------
|
|
183
|
+
SSHKeyNotFoundError: 密钥文件未找到或为空
|
|
184
|
+
SSHConnectionError: 读取密钥文件失败
|
|
185
|
+
"""
|
|
186
|
+
# 验证密钥文件是否存在
|
|
187
|
+
if not key_path.exists():
|
|
188
|
+
msg = "公钥文件未找到:%s", key_path
|
|
189
|
+
raise SSHKeyNotFoundError(msg)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
with key_path.open(encoding="utf-8") as f:
|
|
193
|
+
pub_key = f.read().strip()
|
|
194
|
+
|
|
195
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
196
|
+
msg = "读取公钥文件失败:%s"
|
|
197
|
+
raise SSHConnectionError(msg) from e
|
|
198
|
+
|
|
199
|
+
# 验证密钥内容
|
|
200
|
+
if not pub_key:
|
|
201
|
+
msg = "公钥文件为空:%s", key_path
|
|
202
|
+
raise SSHKeyNotFoundError(msg)
|
|
203
|
+
|
|
204
|
+
return pub_key
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_key_pattern(pub_key: str) -> str:
|
|
208
|
+
"""从公钥中提取 grep 模式用于重复检测.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
pub_key: SSH 公钥内容
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
用于 grep 匹配的模式 (key_type.*key_data)
|
|
216
|
+
"""
|
|
217
|
+
key_parts = pub_key.split()
|
|
218
|
+
if len(key_parts) >= MIN_KEY_PARTS:
|
|
219
|
+
key_type = key_parts[0]
|
|
220
|
+
key_data = key_parts[1]
|
|
221
|
+
return f"{key_type}.*{key_data}"
|
|
222
|
+
# 用于非标准密钥格式的后备方案
|
|
223
|
+
return pub_key
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _build_ssh_command(
|
|
227
|
+
config: SSHCopyIDConfig, grep_pattern: str, pub_key: str
|
|
228
|
+
) -> list[str]:
|
|
229
|
+
"""使用 sshpass 构建 SSH 命令用于密钥部署.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
config: SSH 配置
|
|
233
|
+
grep_pattern: 用于 grep 匹配以避免重复密钥的模式
|
|
234
|
+
pub_key: 公钥内容
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
用于 subprocess 的命令列表 (sshpass + ssh + 远程命令)
|
|
239
|
+
"""
|
|
240
|
+
import shlex
|
|
241
|
+
|
|
242
|
+
# 安全转义 shell 参数以防止命令注入
|
|
243
|
+
safe_grep_pattern = shlex.quote(grep_pattern)
|
|
244
|
+
safe_pub_key = shlex.quote(pub_key)
|
|
245
|
+
|
|
246
|
+
return [
|
|
247
|
+
"sshpass",
|
|
248
|
+
"-p",
|
|
249
|
+
config.password,
|
|
250
|
+
*config.ssh_command_parts,
|
|
251
|
+
(
|
|
252
|
+
f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
|
|
253
|
+
f"cd ~/.ssh && touch authorized_keys && "
|
|
254
|
+
f"chmod 600 authorized_keys && "
|
|
255
|
+
f"grep -qF {safe_grep_pattern} "
|
|
256
|
+
f"authorized_keys 2>/dev/null || "
|
|
257
|
+
f"echo {safe_pub_key} >> authorized_keys"
|
|
258
|
+
),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _handle_ssh_error(process: RunResult, config: SSHCopyIDConfig) -> None:
|
|
263
|
+
"""处理 SSH 错误并抛出适当的异常.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
process: 已完成的具有返回码和标准错误的 RunResult
|
|
267
|
+
config: SSH 配置用于错误上下文
|
|
268
|
+
|
|
269
|
+
Raises
|
|
270
|
+
------
|
|
271
|
+
SSHAuthenticationError: 认证失败 (Permission denied)
|
|
272
|
+
SSHConnectionError: 连接失败 (refused, network unreachable 等)
|
|
273
|
+
"""
|
|
274
|
+
if "Permission denied" in process.stderr:
|
|
275
|
+
msg = "认证失败,请检查用户名或密码"
|
|
276
|
+
raise SSHAuthenticationError(msg)
|
|
277
|
+
if "Connection refused" in process.stderr:
|
|
278
|
+
msg = "连接被拒绝:%s:%d", config.hostname, config.port
|
|
279
|
+
raise SSHConnectionError(msg)
|
|
280
|
+
if "Network is unreachable" in process.stderr:
|
|
281
|
+
msg = "网络不可达:%s", config.hostname
|
|
282
|
+
raise SSHConnectionError(msg)
|
|
283
|
+
msg = "SSH 执行失败:%s", process.stderr.strip()
|
|
284
|
+
raise SSHConnectionError(msg)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _try_alternative_methods(config: SSHCopyIDConfig, pub_key: str) -> None:
|
|
288
|
+
"""当 sshpass 不可用时尝试替代部署方法.
|
|
289
|
+
|
|
290
|
+
尝试使用原生 SSH(无需密码认证)作为后备。
|
|
291
|
+
如果所有方法都失败,提供 sshpass 的安装说明。
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
config: SSH 配置
|
|
295
|
+
pub_key: 公钥内容
|
|
296
|
+
|
|
297
|
+
Raises
|
|
298
|
+
------
|
|
299
|
+
SystemExit: 如果所有方法都失败,以代码 1 退出
|
|
300
|
+
"""
|
|
301
|
+
logger.warning("未找到 sshpass 工具。尝试替代部署方法...")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
deploy_with_native_ssh(config, pub_key)
|
|
305
|
+
except (OSError, subprocess.SubprocessError):
|
|
306
|
+
logger.exception(
|
|
307
|
+
"原生 SSH 部署也失败了。请安装 sshpass 或使用手动方法:\n"
|
|
308
|
+
"安装方法:\n"
|
|
309
|
+
"Windows: 下载 Windows 版 sshpass 或使用 WSL\n"
|
|
310
|
+
"Ubuntu/Debian: sudo apt-get install sshpass\n"
|
|
311
|
+
"CentOS/RHEL: sudo yum install sshpass\n"
|
|
312
|
+
"macOS: brew install hudochenkov/sshpass/sshpass\n"
|
|
313
|
+
f"或手动方式:ssh-copy-id -p {config.port} {config.username}@{config.hostname}"
|
|
314
|
+
)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
else:
|
|
317
|
+
logger.info("使用原生 SSH 方法成功部署 SSH 密钥")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def ssh_copy_id(config: SSHCopyIDConfig) -> None:
|
|
321
|
+
"""将 SSH 公钥部署到远程服务器.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
config: SSH copy ID 配置
|
|
325
|
+
|
|
326
|
+
Raises
|
|
327
|
+
------
|
|
328
|
+
SSHAuthenticationError: 认证失败
|
|
329
|
+
SSHConnectionError: 连接失败
|
|
330
|
+
SSHKeyNotFoundError: 公钥文件未找到
|
|
331
|
+
Exception: 其他异常
|
|
332
|
+
ValueError: 端口号无效或输入验证失败
|
|
333
|
+
"""
|
|
334
|
+
# 读取本地公钥内容
|
|
335
|
+
pub_key = _read_public_key(config.expanded_key_path)
|
|
336
|
+
|
|
337
|
+
# 安全警告:StrictHostKeyChecking=no 禁用主机密钥验证
|
|
338
|
+
# 仅在受信任的网络环境中使用此选项
|
|
339
|
+
logger.warning("安全警告:禁用 SSH 主机密钥验证。确保您处于受信任的网络环境中.")
|
|
340
|
+
|
|
341
|
+
# 验证配置
|
|
342
|
+
if not config.hostname or not config.username or not config.password:
|
|
343
|
+
msg = "主机名、用户名和密码不能为空"
|
|
344
|
+
raise ValueError(msg)
|
|
345
|
+
|
|
346
|
+
# 验证主机名和用户名格式以防止命令注入
|
|
347
|
+
if not validate_hostname(config.hostname):
|
|
348
|
+
msg = "主机名格式无效"
|
|
349
|
+
raise ValueError(msg)
|
|
350
|
+
|
|
351
|
+
if not validate_username(config.username):
|
|
352
|
+
msg = "用户名格式无效"
|
|
353
|
+
raise ValueError(msg)
|
|
354
|
+
|
|
355
|
+
if config.port < MIN_PORT or config.port > MAX_PORT:
|
|
356
|
+
msg = "无效的端口号:%d, 必须在 1-65535 范围内", config.port
|
|
357
|
+
raise ValueError(msg)
|
|
358
|
+
|
|
359
|
+
# 提取密钥模式用于可靠匹配
|
|
360
|
+
grep_pattern = _extract_key_pattern(pub_key)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
# 使用 sshpass 执行远程命令
|
|
364
|
+
command = _build_ssh_command(config, grep_pattern, pub_key)
|
|
365
|
+
|
|
366
|
+
# 注意:此处已通过 validate_hostname() 和 validate_username() 验证输入
|
|
367
|
+
# 使用列表形式且 shell=False 是安全的
|
|
368
|
+
process = execute(
|
|
369
|
+
command, check=False, capture_output=True, text=True, timeout=config.timeout
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if process.returncode != 0:
|
|
373
|
+
_handle_ssh_error(process, config)
|
|
374
|
+
|
|
375
|
+
except FileNotFoundError:
|
|
376
|
+
# 如果找不到 sshpass,尝试替代方法
|
|
377
|
+
_try_alternative_methods(config, pub_key)
|
|
378
|
+
except (subprocess.TimeoutExpired, TimeoutError) as e:
|
|
379
|
+
msg = f"SSH 连接超时, 已等待 {config.timeout} 秒"
|
|
380
|
+
raise SSHConnectionError(msg) from e
|
|
381
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
382
|
+
msg = f"SSH 操作失败:{e}"
|
|
383
|
+
raise SSHConnectionError(msg) from e
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def parse_args() -> argparse.Namespace:
|
|
387
|
+
"""解析命令行参数.
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
已解析的参数
|
|
392
|
+
"""
|
|
393
|
+
parser = argparse.ArgumentParser(
|
|
394
|
+
description="将 SSH 公钥部署到远程服务器",
|
|
395
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
396
|
+
epilog="""
|
|
397
|
+
示例:
|
|
398
|
+
sshcopyid 192.168.1.100 user password
|
|
399
|
+
sshcopyid 192.168.1.100 user password -p 2222
|
|
400
|
+
sshcopyid 192.168.1.100 user password -k ~/.ssh/id_ed25519.pub
|
|
401
|
+
""",
|
|
402
|
+
)
|
|
403
|
+
parser.add_argument("hostname", help="远程服务器主机名或 IP 地址")
|
|
404
|
+
parser.add_argument("username", help="远程服务器用户名")
|
|
405
|
+
parser.add_argument("password", help="远程服务器密码")
|
|
406
|
+
parser.add_argument("-p", "--port", type=int, default=22, help="SSH 端口 (默认:22)")
|
|
407
|
+
parser.add_argument(
|
|
408
|
+
"-k",
|
|
409
|
+
"--keypath",
|
|
410
|
+
type=str,
|
|
411
|
+
default="~/.ssh/id_rsa.pub",
|
|
412
|
+
help="公钥文件路径 (默认:~/.ssh/id_rsa.pub)",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"-t", "--timeout", type=int, default=30, help="SSH 操作超时秒数 (默认:30)"
|
|
416
|
+
)
|
|
417
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="启用详细日志")
|
|
418
|
+
return parser.parse_args()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def setup_logging(*, verbose: bool = False) -> None:
|
|
422
|
+
"""设置日志配置.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
verbose: 启用详细日志
|
|
426
|
+
"""
|
|
427
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
428
|
+
logging.basicConfig(
|
|
429
|
+
level=level,
|
|
430
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
431
|
+
handlers=[logging.StreamHandler(sys.stdout)],
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def main() -> None:
|
|
436
|
+
"""运行命令行界面入口点."""
|
|
437
|
+
args = parse_args()
|
|
438
|
+
setup_logging(verbose=args.verbose)
|
|
439
|
+
|
|
440
|
+
# 创建配置对象
|
|
441
|
+
config = SSHCopyIDConfig(
|
|
442
|
+
hostname=args.hostname,
|
|
443
|
+
username=args.username,
|
|
444
|
+
password=args.password,
|
|
445
|
+
port=args.port,
|
|
446
|
+
public_key_path=args.keypath,
|
|
447
|
+
timeout=args.timeout,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# 在执行前验证配置
|
|
451
|
+
if not config.hostname or not config.username or not config.password:
|
|
452
|
+
logger.error("主机名、用户名和密码不能为空")
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
if config.port < MIN_PORT or config.port > MAX_PORT:
|
|
456
|
+
logger.error("无效的端口号:%d, 必须在 1-65535 范围内", config.port)
|
|
457
|
+
sys.exit(1)
|
|
458
|
+
|
|
459
|
+
# 验证公钥文件
|
|
460
|
+
if not config.expanded_key_path.exists():
|
|
461
|
+
logger.error("公钥文件不存在:%s", config.expanded_key_path)
|
|
462
|
+
sys.exit(1)
|
|
463
|
+
|
|
464
|
+
if not config.expanded_key_path.is_file():
|
|
465
|
+
logger.error("指定路径不是文件:%s", config.expanded_key_path)
|
|
466
|
+
sys.exit(1)
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
logger.info(
|
|
470
|
+
"正在部署 SSH 密钥到 %s@%s:%d",
|
|
471
|
+
config.username,
|
|
472
|
+
config.hostname,
|
|
473
|
+
config.port,
|
|
474
|
+
)
|
|
475
|
+
logger.info("使用公钥:%s", config.expanded_key_path)
|
|
476
|
+
|
|
477
|
+
ssh_copy_id(config)
|
|
478
|
+
logger.info("SSH 密钥部署成功完成")
|
|
479
|
+
|
|
480
|
+
except SSHAuthenticationError:
|
|
481
|
+
logger.exception("认证失败")
|
|
482
|
+
sys.exit(2)
|
|
483
|
+
except SSHConnectionError:
|
|
484
|
+
logger.exception("连接失败")
|
|
485
|
+
sys.exit(3)
|
|
486
|
+
except SSHKeyNotFoundError:
|
|
487
|
+
logger.exception("密钥文件错误")
|
|
488
|
+
sys.exit(4)
|
|
489
|
+
except (OSError, subprocess.SubprocessError):
|
|
490
|
+
logger.exception("意外错误")
|
|
491
|
+
sys.exit(1)
|