net-connector 0.17.2 → 0.18.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/architecture.md +14 -0
- data/docs/usage.md +4 -0
- data/docs/usage.zh-CN.md +4 -0
- data/lib/net/connector/collect/progress.rb +21 -21
- data/lib/net/connector/device/base.rb +12 -8
- data/lib/net/connector/device/save_config.rb +2 -2
- data/lib/net/connector/engine/error/metadata.rb +2 -1
- data/lib/net/connector/engine/execution.rb +37 -10
- data/lib/net/connector/engine/known_hosts.rb +19 -6
- data/lib/net/connector/engine/log/messages.rb +16 -16
- data/lib/net/connector/engine/session.rb +5 -4
- data/lib/net/connector/engine/transports.rb +60 -41
- data/lib/net/connector/inventory/filters.rb +2 -2
- data/lib/net/connector/netdisco/cli/backup.rb +6 -6
- data/lib/net/connector/netdisco/cli/credentials.rb +2 -2
- data/lib/net/connector/netdisco/cli/help.rb +16 -16
- data/lib/net/connector/netdisco/cli/options.rb +44 -44
- data/lib/net/connector/netdisco/cli/setup.rb +18 -18
- data/lib/net/connector/netdisco/cli/topology.rb +21 -21
- data/lib/net/connector/netdisco/cli.rb +24 -24
- data/lib/net/connector/netdisco/config/defaults.rb +1 -1
- data/lib/net/connector/netdisco/config/initializer.rb +13 -13
- data/lib/net/connector/netdisco/progress.rb +17 -17
- data/lib/net/connector/netdisco/report/text.rb +15 -15
- data/lib/net/connector/netdisco/ssh/archive/client.rb +109 -79
- data/lib/net/connector/netdisco/ssh/archive/protocol.rb +107 -0
- data/lib/net/connector/netdisco/ssh/archive/worker.py +35 -19
- data/lib/net/connector/netdisco/ssh/authentication.rb +8 -10
- data/lib/net/connector/netdisco/topology/files.rb +8 -8
- data/lib/net/connector/netdisco/topology/presentation.rb +15 -15
- data/lib/net/connector/progress.rb +36 -36
- data/lib/net/connector/resources.rb +1 -1
- data/lib/net/connector/storage/atomic_file.rb +60 -3
- data/lib/net/connector/version.rb +1 -1
- metadata +11 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87b108e7525f64a9df67db08537eafa2e3c2845797fccea6fe09fdb5701696fd
|
|
4
|
+
data.tar.gz: f6c6bea9be822222feb3056e2866b39d35209b4fe06b108613c059261b987467
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de0022df96e76aeeec4bd916c7abd27f9fb4646269d552846d07d1c575a05593fbf3605a25f271574a48202e81100986fb5508e44c162c6b1668c158bcad9ae9
|
|
7
|
+
data.tar.gz: 749ba87f2702d94098e02b3cf7f9fae6e00557f219c796e453028b7d67db07f1c7469985ad8275818de71b50a1bfc7b2319936e502817f7057f5b3a59c670cd7
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.18.1 - 2026-10-24
|
|
6
|
+
|
|
7
|
+
- Stream SSH archive bodies through bounded binary frames and incremental hashes instead of whole-file base64 copies; preserve deadlines, verified server receipts and committed local paths.
|
|
8
|
+
- Retain cleanup ownership across cancellation, failed closes and forked KnownHosts snapshots; reject inherited trust commits before file or lock access.
|
|
9
|
+
- Preserve the primary exception or nonlocal exit when atomic-file and archive-process cleanup also fails.
|
|
10
|
+
- Expose JSON Pointer field provenance through configuration previews and leave runtime dependency versions to host lockfiles.
|
|
11
|
+
|
|
12
|
+
## 0.18.0 - 2026-10-23
|
|
13
|
+
|
|
14
|
+
- Add on_receipt completion callbacks to command, script, save and custom operations, retaining completed responses after later logging, processing, budget or cancellation failures.
|
|
15
|
+
- Retain PTY channels after incomplete cleanup and retry cleanup before reopening or recovering a connection.
|
|
16
|
+
- Use owned Expect session scopes during SSH authentication so initialization cancellation releases terminal handles and cleanup failures preserve the authentication error.
|
|
17
|
+
- Use English diagnostics, CLI help and reports, and consume string-keyed progress documents from Runner 0.2.
|
|
18
|
+
|
|
5
19
|
## 0.17.2 - 2026-10-23
|
|
6
20
|
|
|
7
21
|
- Migrate shared CLI entrypoints to ctx_cli 0.2.0 names without legacy aliases.
|
data/docs/architecture.md
CHANGED
|
@@ -88,12 +88,20 @@ Fleet 在规范目标校验、凭据解析及连接器构造之前加同一把
|
|
|
88
88
|
|
|
89
89
|
`KnownHosts` 在跨进程锁内重读和合并已认证的会话密钥,条目索引保留原顺序及已有重复行,新增行只登记一次。提交复用 `AtomicFile`;目录同步失败保留已替换文件并报告 `file_persistence_unconfirmed`,不能触发再次登记。不安全的非普通共享信任文件报告 `known_hosts_unsafe`,strict 模式仍由 OpenSSH 管理。写入器仅在实际提交时加载,纯会话引擎加载不引入存储层。
|
|
90
90
|
|
|
91
|
+
信任快照及其 `.old` 文件由创建进程拥有。fork 副本关闭时只释放继承描述符;提交继承快照(即使没有新增密钥)在读取和加锁前报告 `known_hosts_inherited`。子进程需要重新打开 SSH 连接,取得自己的快照后再认证和登记;父进程的快照与连接继续有效。快照关闭失败时,KnownHosts 和 Ssh 保留清理责任,后续 close/open 重试,清理成功后才释放引用。
|
|
92
|
+
|
|
91
93
|
提交后的 `BackupPersistenceError` 只携带原 `Backup` 元数据和受控写入回执,Fleet 保留产物并使用已有 saved_with_error 分类。只接受库定义的具体完成错误及匹配产物类型,第三方异常的 backup 属性或自定义 WriteError 子类不作为完成证据。报告保留已提交位置,导出错误说明 committed;设备命令不会自动重放。
|
|
92
94
|
|
|
93
95
|
直接备份在路径锁退出前保存完成元数据,锁关闭的普通故障转换为 `BackupFinalizeError`,其中 `backup` 保留结果,`underlying_type` 经过类型白名单。该错误只确认备份操作已完成,不制造文件同步回执;未变文件的历史持久性仍未补验。Fleet 的外层路径锁及 TFTP 归档锁同样保存最新结果,退出故障转为 saved_with_error 或 reported_with_error,已有设备诊断优先,中断继续传播。
|
|
94
96
|
|
|
95
97
|
TFTP 归档分别保留本地批次副本与服务器归档副本的提交位置;写入器的原生回执必须与本次目标路径一致。目录同步或收尾失败仍保留已提交路径,成功上传转为 `reported_with_error`,诊断记录归档失败及持久化阶段;设备任务此前已有错误时保留原诊断。报告失败后的 JSON 补写同样保留已提交位置,不覆盖首次报告故障,也不重新执行设备任务。
|
|
96
98
|
|
|
99
|
+
SSH 归档的 Client 持有事务进程和管道,Protocol 只借用 IO,维护 16 KiB 控制帧、64 KiB 正文片段和阶段截止时间。`archived` 元数据、声明长度的二进制正文与 `delivered` 确认共用 280 秒期限;文件上限仍为 64 MiB,并发槽位仍为 4。Python 工作进程分片读取、复制和摘要核验,复制及交付前后均检查文件身份、大小与目录/锁边界,不保留整份正文或 base64 副本。
|
|
100
|
+
|
|
101
|
+
`AtomicFile.write_stream` 借出私有临时文件,只有正文长度、SHA-256 和交付确认全部通过后,才允许同步和原子替换。本地准备或写盘失败时,Client 尽可能继续有界读取并核验服务器副本,保留 `server_verified`,随后发送 abort,保持 staging;失败写入和设备上传均不重试。原子替换之后的同步故障保留可信回执中的 `local_path`。启动器只返回管道与等待线程,Client 是唯一清理者,取消和非局部退出不会被普通关闭错误覆盖。
|
|
102
|
+
|
|
103
|
+
AtomicFile 同样独占临时文件及父目录句柄的清理。取得句柄和登记临时目录项身份共用取消保护边界;清理仍尝试关闭及移除属于本次写入的临时目录项。生产者或目录同步期间的异常、取消及非局部退出优先于普通关闭错误;正常完成后的清理错误继续按原写入回执报告。
|
|
104
|
+
|
|
97
105
|
TFTP 只确认设备报告的上传结果:策略去掉命令、应答和提示符回显后,检查明确的完成行;文件名或表示“即将上传”的进度文字不算成功。原始输出或终端渲染文本中的失败证据优先于成功文字。
|
|
98
106
|
|
|
99
107
|
失败证据核对与完成核对共用精确回显过滤。静态应答从已完成步骤的 `Command#interactions` 读取,`Interaction#static_response` 对动态回调返回 nil,核对不会重新执行挑战回调。失败核对逐步读取原文、回车历史和终端画面,不累积全部画面副本;颜色样式只影响显示,不能拆开诊断词或借光标擦除隐藏已经收到的失败。命令诊断及拓扑保存确认同样保留这类失败历史。
|
|
@@ -206,8 +214,14 @@ Fleet 可把回执与本地普通文件的时间、稳定性、摘要及目录
|
|
|
206
214
|
|
|
207
215
|
提示符和交互标记是 Ruby 正则表达式,应短到能放入未匹配尾部;流式适配器不支持需要无限长历史的模式。收集输出的上限与匹配窗口上限不同。终端渲染器只处理常见行编辑控制符,不是完整屏幕终端模拟器。`Profile#terminal_size` 使用 `[宽, 高]`,PTY 适配器转换为 Ruby 的 `[行, 列]`。
|
|
208
216
|
|
|
217
|
+
传输关闭先撤下活跃信道,使连接不可用;尚未回收的信道由内部 `@closing_channel` 保留。`hard_close` 正常返回且当前进程拥有的子进程已退出后才释放引用;异常、非局部退出及有界等待后仍存活的子进程都保留清理责任,后者明确报告 IOError。后续 `close` 重试原信道,完成后不重复清理。fork 副本仅关闭继承句柄,不要求回收父进程拥有的子进程。
|
|
218
|
+
|
|
219
|
+
重新打开会先完成旧资源回收,失败时不创建新进程或 SSH 信任快照。私有 `close_resources` 供关闭与打开共用;公开 `close` 停止日志交付,而打开时保留新连接已配置的日志目标。SSH 临时信任快照在成功关闭后释放引用,失败时保留供重试。连接恢复只有在旧传输及日志清理成功后才能替换传输对象;失败仍报告原连接错误,保留旧所有者供后续关闭,不重放设备命令。注入信道工厂须遵守现有 Expect 的 `hard_close` / `alive?` 生命周期协议。
|
|
220
|
+
|
|
209
221
|
PTY 输出上限直接读取信道公开的 `buffer_bytesize` 标量,避免正常分片反复取得累计正文副本;只有实际超限时才获取错误正文,关闭日志也不会关闭上限检查。传输与 SSH 归档认证统一使用当前 `Session`、`transcript`、`spawn(raw: true)` 和 `expect` 契约,不探测或回退到旧接口。运行要求为 expect-pty 0.9.4+、Ruby 3.4+。
|
|
210
222
|
|
|
223
|
+
SSH 归档认证借用 `Expect.spawn` 块中交付的私有会话;初始化与交接期间取消的回收责任由 Expect 工厂持有,不在连接器重复构造、登记和 ensure 清理。普通清理错误不能替换认证主异常;没有主异常时仍传播清理错误,新发生的致命清理异常沿用 Expect 契约。密码仍只发送一次,15 秒对话预算、输出隐私及调用方的主连接核对保持原有职责。
|
|
224
|
+
|
|
211
225
|
`prepare_command` 钩子返回 `Command` 表示继续执行,返回 nil 表示跳过。其他返回值在替代命令发送前以 `ScriptError` 拒绝,保留原脚本位置和此前完成步骤,诊断不展开非法值。钩子内部主动执行的查询仍遵循原有执行与输出预算边界。
|
|
212
226
|
|
|
213
227
|
`max_script_output_bytes` 为正 Integer 或 nil。计数属于每个 Execution,主命令以及准备、提示符和后处理钩子的 `Execution#execute_command` 共用计数,包含原始分页标记和最终提示符,不能用 `capture: false` 绕过。登录和提权认证保留各自原有单响应上限。每次脚本新建计数器;一次租约中的多个脚本、多个设备和多个批次不共用它。
|
data/docs/usage.md
CHANGED
|
@@ -143,6 +143,10 @@ warn "#{result.error.code}: #{result.steps.size} completed commands" if result.f
|
|
|
143
143
|
|
|
144
144
|
`execute_command` runs one command; `execute_script` accepts a `Script` or command array. `Script.load(path)` reads a file. Commands are validated before I/O; completed steps survive later failures. `save_config` explicitly sends save commands; ordinary scripts do not save automatically.
|
|
145
145
|
|
|
146
|
+
Use `on_receipt: ->(step) { ... }` with these methods or `execute_operation` to retain completed responses even when later logging, postprocessing, output-budget checks or cancellation prevent a successful return. It receives each completed `Command::Result` once, before the optional success-progress block. Timeouts and device rejection without a completed step produce no receipt. A receipt confirms the response, not persistence or the intended configuration effect.
|
|
147
|
+
|
|
148
|
+
The receipt callback runs synchronously within the operation and must not reenter the device. Its exception stops subsequent commands; an earlier failure or cancellation keeps precedence. Step output is unredacted business data, like `Result#steps`; persist protected output or safe summaries. Interruptions still propagate, and a killed process cannot deliver callbacks.
|
|
149
|
+
|
|
146
150
|
`result.output` concatenates completed steps as raw bytes without transcoding. Each call returns a mutable binary string; changing it does not change the frozen step outputs. Use `result.config` for the cleaned configuration, or `result.value!` to raise on failure before reading the successful value.
|
|
147
151
|
|
|
148
152
|
Set `max_script_output_bytes:` to bound cumulative raw responses, including additional queries. Default is `nil`. Exceeding it returns `ScriptOutputLimitExceeded` (`:script_output_limit_exceeded`) and stops subsequent commands; the current command may already have executed. `max_output_bytes` separately limits each response. Neither setting is a process-memory limit.
|
data/docs/usage.zh-CN.md
CHANGED
|
@@ -186,6 +186,10 @@ end
|
|
|
186
186
|
|
|
187
187
|
`execute_command` 执行一条命令;`execute_script` 接收 `Script` 或命令数组;`Script.load(path)` 读取脚本文件。所有命令在设备 I/O 前校验,后续步骤失败时仍保留已完成结果,库不会自动重放命令。`save_config` 显式执行厂商保存命令,普通脚本不会自动保存。
|
|
188
188
|
|
|
189
|
+
上述方法及 `execute_operation` 接受 `on_receipt: ->(step) { ... }`,用于固定已收到的完整响应:即使之后的日志、后处理、累计输出检查或取消阻止方法正常返回,已完成的 `Command::Result` 仍交付一次,早于可选的成功进度块。没有完成步骤的超时或设备拒绝不产生回执;回执本身不证明设备保存或预期配置生效。
|
|
190
|
+
|
|
191
|
+
回执回调在操作中同步执行,不得重入设备。回调异常停止后续命令,已有主故障或取消保持优先。步骤正文与 `Result#steps` 一样未脱敏,宿主应保护正文或仅保存安全摘要。取消异常仍继续传播,进程被强制终止时无法保证回调交付。
|
|
192
|
+
|
|
189
193
|
`result.output` 按顺序拼接已完成步骤的原始字节,不执行字符编码转换。每次调用返回可修改的二进制字符串,修改它不会改变已冻结的步骤输出。清理后的配置使用 `result.config`;需要失败时先抛出错误,再读取成功值时使用 `result.value!`。
|
|
190
194
|
|
|
191
195
|
创建连接器时可设置 `max_script_output_bytes: 8 * 1024 * 1024`,限制每个脚本及其追加查询累计收到的原始响应字节;默认 `nil` 保持原有完整输出行为。达到上限后不再发送下一命令,当前响应超过上限时保留刚完成的步骤并返回 `ScriptOutputLimitExceeded`(`code: :script_output_limit_exceeded`)。命令可能已经执行,不会自动重试。原有 `max_output_bytes` 继续限制单次读取响应;累计预算不是进程内存上限,详细计数规则见架构文档。
|
|
@@ -23,46 +23,46 @@ module Net
|
|
|
23
23
|
status = result_status(result)
|
|
24
24
|
code = diagnostic_code(result)
|
|
25
25
|
message = case status
|
|
26
|
-
when
|
|
27
|
-
when
|
|
28
|
-
else "
|
|
26
|
+
when "complete" then "Collection complete; reports saved"
|
|
27
|
+
when "partial" then "Partially complete (#{code})"
|
|
28
|
+
else "Incomplete (#{code})"
|
|
29
29
|
end
|
|
30
|
-
super(host: result.fetch(
|
|
31
|
-
duration_ms: result.fetch(
|
|
30
|
+
super(host: result.fetch("device").fetch("host"), success: status == "complete",
|
|
31
|
+
duration_ms: result.fetch("duration_ms"), message: message, error_code: code&.to_sym)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# 使用持久化摘要作最终判定,不把已处理数量或完整性当作健康结论。
|
|
35
35
|
def finish(summary)
|
|
36
|
-
results = summary.fetch(
|
|
36
|
+
results = summary.fetch("devices")
|
|
37
37
|
counts = results.group_by { |result| result_status(result) }.transform_values(&:size)
|
|
38
|
-
failures = results.reject { |result| result_status(result) ==
|
|
38
|
+
failures = results.reject { |result| result_status(result) == "complete" }
|
|
39
39
|
.group_by { |result| diagnostic_code(result) }.transform_values(&:size)
|
|
40
|
-
super(success: summary.fetch(
|
|
41
|
-
partial: counts.fetch(
|
|
42
|
-
pending = summary.fetch(
|
|
43
|
-
notice("
|
|
44
|
-
notice("
|
|
45
|
-
location(File.join(summary.fetch(
|
|
46
|
-
location(File.join(summary.fetch(
|
|
40
|
+
super(success: summary.fetch("status") == "complete", succeeded: counts.fetch("complete", 0),
|
|
41
|
+
partial: counts.fetch("partial", 0), failed: counts.fetch("failed", 0), skipped: @skipped || 0, failures: failures)
|
|
42
|
+
pending = summary.fetch("pending").size
|
|
43
|
+
notice("Not collected: #{pending} devices; see the batch report") if pending.positive?
|
|
44
|
+
notice("Complete evidence does not establish network health; see the review results in the report.")
|
|
45
|
+
location(File.join(summary.fetch("directory"), "summary.json"))
|
|
46
|
+
location(File.join(summary.fetch("directory"), "summary.txt"), label: "Text report")
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
51
|
# 写入失败的完整快照仍是未交付结果,不能在终端宣称成功。
|
|
52
52
|
def result_status(result)
|
|
53
|
-
return
|
|
53
|
+
return "failed" unless result["report_saved"] == true
|
|
54
54
|
|
|
55
|
-
%
|
|
55
|
+
%w[complete partial].include?(result["status"]) ? result["status"] : "failed"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
# 完整维度的提示不属于失败;优先保留会话错误,再选择未完成维度的诊断码。
|
|
59
59
|
def diagnostic_code(result)
|
|
60
|
-
return
|
|
61
|
-
return nil if result[
|
|
62
|
-
return result[
|
|
60
|
+
return "report_not_saved" unless result["report_saved"] == true
|
|
61
|
+
return nil if result["status"] == "complete"
|
|
62
|
+
return result["error"] if result["error"]
|
|
63
63
|
|
|
64
|
-
dimensions = result.fetch(
|
|
65
|
-
dimensions.flat_map { |dimension| dimension.fetch(
|
|
64
|
+
dimensions = result.fetch("dimensions", {}).values.reject { |dimension| dimension["status"] == "complete" }
|
|
65
|
+
dimensions.flat_map { |dimension| dimension.fetch("issues", []) }.first || "collection_incomplete"
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -130,23 +130,26 @@ module Net
|
|
|
130
130
|
def close = @session.close
|
|
131
131
|
|
|
132
132
|
# 将一条文本命令包装成脚本并执行。
|
|
133
|
-
def execute_command(text, **, &)
|
|
134
|
-
|
|
133
|
+
def execute_command(text, on_receipt: nil, **, &)
|
|
134
|
+
script = Script.new([Command.new(text, **)])
|
|
135
|
+
return execute_script(script, &) if on_receipt.nil?
|
|
136
|
+
|
|
137
|
+
execute_script(script, on_receipt: on_receipt, &)
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
# 接收脚本或命令数组,统一交给单次会话执行。
|
|
138
|
-
def execute_script(script, &)
|
|
141
|
+
def execute_script(script, on_receipt: nil, &)
|
|
139
142
|
script = Script.new(script) unless script.is_a?(Script)
|
|
140
|
-
perform_script(script, &)
|
|
143
|
+
perform_script(script, on_receipt: on_receipt, &)
|
|
141
144
|
end
|
|
142
145
|
|
|
143
146
|
# 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
|
|
144
147
|
def with_operation(name, &) = @session.with_operation(name, &)
|
|
145
148
|
|
|
146
149
|
# 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
|
|
147
|
-
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
|
|
150
|
+
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, on_receipt: nil, &finalize)
|
|
148
151
|
perform_script(script, operation: name, prompt: prompt, after_command: after_command,
|
|
149
|
-
privilege: privilege, finalize: finalize)
|
|
152
|
+
privilege: privilege, finalize: finalize, on_receipt: on_receipt)
|
|
150
153
|
end
|
|
151
154
|
|
|
152
155
|
# 返回已确认的提示,供业务层为只读采集绑定准确的结束条件。
|
|
@@ -260,7 +263,8 @@ module Net
|
|
|
260
263
|
|
|
261
264
|
# 在会话锁内运行脚本及业务回调,保留已完成步骤和统一错误边界。
|
|
262
265
|
def perform_script(script, operation: nil, prompt: nil, after_command: nil, privilege: true,
|
|
263
|
-
finalize: nil, &on_step)
|
|
266
|
+
finalize: nil, on_receipt: nil, &on_step)
|
|
267
|
+
raise ArgumentError, "on_receipt must respond to call" unless on_receipt.nil? || on_receipt.respond_to?(:call)
|
|
264
268
|
return Result.new if script.empty?
|
|
265
269
|
|
|
266
270
|
execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
|
|
@@ -269,7 +273,7 @@ module Net
|
|
|
269
273
|
@session.perform(:script) do
|
|
270
274
|
@session.log_script(operation: operation, steps: execution.steps) do
|
|
271
275
|
@session.with_sensitive_output(output_sensitive) { before_batch(execution) }
|
|
272
|
-
result = execution.execute_script(script, &on_step)
|
|
276
|
+
result = execution.execute_script(script, on_receipt: on_receipt, &on_step)
|
|
273
277
|
sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
|
|
274
278
|
@session.with_sensitive_output(sensitive_result) do
|
|
275
279
|
result = finalize_script_result(result, finalize)
|
|
@@ -5,13 +5,13 @@ module Net
|
|
|
5
5
|
# 保存命令及能力入口,响应语义由通用执行器负责。
|
|
6
6
|
module SaveConfig
|
|
7
7
|
# 执行厂商保存配置命令;不支持时返回显式失败结果。
|
|
8
|
-
def save_config
|
|
8
|
+
def save_config(on_receipt: nil)
|
|
9
9
|
if save_commands.empty?
|
|
10
10
|
return Result.new(error: @session.build_error(UnsupportedOperation, "saving configuration is not supported",
|
|
11
11
|
phase: :save))
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
execute_script(save_commands)
|
|
14
|
+
on_receipt.nil? ? execute_script(save_commands) : execute_script(save_commands, on_receipt: on_receipt)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# 返回保存配置命令;空数组表示设备不支持保存。
|
|
@@ -25,7 +25,8 @@ module Net
|
|
|
25
25
|
Net::Connector::Netdisco::Tftp::Archive::Unavailable Net::Connector::Netdisco::Tftp::Archive::Failure]).freeze
|
|
26
26
|
CODES = (CONNECTOR_TYPES.map { |name| name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym } +
|
|
27
27
|
%i[authentication_failed connection_failed connection_refused no_route connection_reset
|
|
28
|
-
connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe
|
|
28
|
+
connection_timeout host_key_changed host_key_untrusted known_hosts_busy known_hosts_unsafe known_hosts_inherited
|
|
29
|
+
rsa_too_small unsupported_cipher
|
|
29
30
|
ambiguous_description ambiguous_neighbor confirmation_required description_stages_unsupported
|
|
30
31
|
description_unconfirmed description_unsupported incomplete_configuration interface_missing
|
|
31
32
|
neighbor_discovery_unsupported parse_failed invalid_output_encoding persistence_unconfirmed stale_plan startup_config_missing
|
|
@@ -28,7 +28,7 @@ module Net
|
|
|
28
28
|
def sensitive? = @sensitive
|
|
29
29
|
|
|
30
30
|
# 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
|
|
31
|
-
def execute_script(script)
|
|
31
|
+
def execute_script(script, on_receipt: nil)
|
|
32
32
|
script.each do |original_command|
|
|
33
33
|
@session.with_command_redaction(original_command) do
|
|
34
34
|
@current_command = original_command
|
|
@@ -39,15 +39,7 @@ module Net
|
|
|
39
39
|
raise build_error("prepare_command must return a Command or nil") unless command.is_a?(Command)
|
|
40
40
|
|
|
41
41
|
@current_command = command
|
|
42
|
-
|
|
43
|
-
step = nil
|
|
44
|
-
response = execute_command(command) do |received|
|
|
45
|
-
step = Command::Result.new(command: command, output: received.output, prompt: received.prompt,
|
|
46
|
-
duration: Expect.monotonic - started)
|
|
47
|
-
steps << step
|
|
48
|
-
end
|
|
49
|
-
# 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
|
|
50
|
-
@after_command.call(command, response, self)
|
|
42
|
+
step = execute_step(command, on_receipt: on_receipt)
|
|
51
43
|
yield step if block_given?
|
|
52
44
|
validate_response_budget!(@last_executed_command)
|
|
53
45
|
rescue => error
|
|
@@ -97,6 +89,41 @@ module Net
|
|
|
97
89
|
|
|
98
90
|
private
|
|
99
91
|
|
|
92
|
+
# 完成回执独立于成功进度;日志、后处理、预算或取消不撤销已收到的响应。
|
|
93
|
+
def execute_step(command, on_receipt:)
|
|
94
|
+
started = Expect.monotonic
|
|
95
|
+
step, failure = nil, nil
|
|
96
|
+
finished = false
|
|
97
|
+
begin
|
|
98
|
+
response = execute_command(command) do |received|
|
|
99
|
+
step = Command::Result.new(command: command, output: received.output, prompt: received.prompt,
|
|
100
|
+
duration: Expect.monotonic - started)
|
|
101
|
+
steps << step
|
|
102
|
+
end
|
|
103
|
+
@after_command.call(command, response, self)
|
|
104
|
+
finished = true
|
|
105
|
+
step
|
|
106
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 完成事实先交付,原始取消随后继续传播。
|
|
107
|
+
failure = error
|
|
108
|
+
raise
|
|
109
|
+
ensure
|
|
110
|
+
deliver_receipt(step, on_receipt, failure, unwinding: !finished) if step && on_receipt
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 收据处理不能用自身异常替换此前的设备失败或取消,也不自动重复回调。
|
|
115
|
+
def deliver_receipt(step, callback, failure, unwinding:)
|
|
116
|
+
callback.call(step)
|
|
117
|
+
ensure
|
|
118
|
+
# ensure 覆盖回调内的 throw/kill;不能让二次退出替换外层正在传播的取消。
|
|
119
|
+
if unwinding
|
|
120
|
+
raise failure, cause: failure.cause if failure
|
|
121
|
+
|
|
122
|
+
# 仅结束这个回调子方法,让 execute_step 原有的非局部退出继续。
|
|
123
|
+
return # rubocop:disable Lint/EnsureReturn -- 保留调用方正在进行的 throw、return 或线程终止。
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
100
127
|
# 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
|
|
101
128
|
def validate_send_budget!(command)
|
|
102
129
|
return unless @output_limit && @output_bytes >= @output_limit
|
|
@@ -14,6 +14,7 @@ module Net
|
|
|
14
14
|
|
|
15
15
|
# 建立本次连接的私有主机密钥快照,初始化失败时清理已取得的资源。
|
|
16
16
|
def initialize(configuration, replace: false)
|
|
17
|
+
@owner_pid = Process.pid
|
|
17
18
|
destination = configuration.known_hosts || File.join(Dir.home, ".ssh", "known_hosts")
|
|
18
19
|
FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
|
|
19
20
|
@destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
|
|
@@ -35,6 +36,11 @@ module Net
|
|
|
35
36
|
# 在目标文件锁内合并新增信任;检测并发密钥变化后原子写回。
|
|
36
37
|
# 会话只写私有快照;登记时重读共享信任并检查冲突,避免并发连接覆盖已有主机密钥。
|
|
37
38
|
def commit
|
|
39
|
+
unless @owner_pid == Process.pid
|
|
40
|
+
raise ConnectionError.new("inherited known_hosts snapshot cannot be committed; reopen the SSH connection",
|
|
41
|
+
code: :known_hosts_inherited, host: @host, phase: :login), cause: nil
|
|
42
|
+
end
|
|
43
|
+
|
|
38
44
|
# 纯会话引擎加载不触及文件业务;实际登记信任时才加载持久化协议。
|
|
39
45
|
require_relative "../storage/atomic_file"
|
|
40
46
|
begin
|
|
@@ -60,10 +66,10 @@ module Net
|
|
|
60
66
|
end
|
|
61
67
|
end
|
|
62
68
|
|
|
63
|
-
#
|
|
69
|
+
# 关闭私有密钥快照,创建进程同时删除文件;延迟取消直至清理完成。
|
|
64
70
|
def close
|
|
65
71
|
failure = nil
|
|
66
|
-
#
|
|
72
|
+
# 关闭句柄和删除快照期间延后异步取消,完成清理后再释放引用。
|
|
67
73
|
Thread.handle_interrupt(Exception => :never) do
|
|
68
74
|
close_snapshot
|
|
69
75
|
rescue Exception => error # rubocop:disable Lint/RescueException -- 保存同步清理故障,退出屏蔽范围后继续传播。
|
|
@@ -104,17 +110,24 @@ module Net
|
|
|
104
110
|
raise failure, cause: nil if failure
|
|
105
111
|
end
|
|
106
112
|
|
|
107
|
-
#
|
|
113
|
+
# 创建进程负责删除文件;fork 副本只关闭继承句柄,失败时保留清理引用。
|
|
108
114
|
def close_snapshot
|
|
109
115
|
file = @file
|
|
110
|
-
|
|
111
|
-
File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
|
|
116
|
+
owned = @owner_pid == Process.pid
|
|
117
|
+
File.unlink(@path + ".old") if owned && @path && File.exist?(@path + ".old")
|
|
112
118
|
rescue Exception # rubocop:disable Lint/RescueException -- 删除附属文件失败或中断后仍须关闭私有快照。
|
|
113
119
|
failed = true
|
|
114
120
|
raise
|
|
115
121
|
ensure
|
|
116
122
|
begin
|
|
117
|
-
|
|
123
|
+
if owned
|
|
124
|
+
file&.close!
|
|
125
|
+
# Tempfile 会吞掉删除时的权限错误;路径仍存在表示删除责任尚未完成。
|
|
126
|
+
raise IOError, "known_hosts snapshot removal is incomplete" if file&.path
|
|
127
|
+
else
|
|
128
|
+
file&.close
|
|
129
|
+
end
|
|
130
|
+
@file = nil
|
|
118
131
|
rescue Exception # rubocop:disable Lint/RescueException -- 私有快照清理不能覆盖先发生的删除故障。
|
|
119
132
|
raise unless failed
|
|
120
133
|
end
|
|
@@ -11,36 +11,36 @@ module Net
|
|
|
11
11
|
def format(name, fields)
|
|
12
12
|
case name
|
|
13
13
|
when "connect"
|
|
14
|
-
"
|
|
14
|
+
"Connecting to #{fields[:host]}(#{fields[:protocol].to_s.upcase}, user #{fields[:username]})"
|
|
15
15
|
when "connect_failed"
|
|
16
|
-
"
|
|
16
|
+
"Connection failed: #{fields[:message]}(#{fields[:error]})"
|
|
17
17
|
when "login_start"
|
|
18
|
-
"
|
|
18
|
+
"Waiting for the device login prompt"
|
|
19
19
|
when "login_output"
|
|
20
|
-
"
|
|
20
|
+
"Login output (redacted): "
|
|
21
21
|
when "login_complete"
|
|
22
|
-
fields[:status] == "ok" ? "
|
|
23
|
-
"
|
|
22
|
+
fields[:status] == "ok" ? "Login succeeded; device prompt: #{fields[:prompt]}" :
|
|
23
|
+
"Login failed: #{fields[:message]}(#{fields[:error]})"
|
|
24
24
|
when "command_start"
|
|
25
|
-
"
|
|
25
|
+
"Sending command: #{fields[:text]}"
|
|
26
26
|
when "device_output"
|
|
27
|
-
"
|
|
27
|
+
"Device output: "
|
|
28
28
|
when "command_complete"
|
|
29
|
-
fields[:status] == "response_received" ? "
|
|
30
|
-
"
|
|
29
|
+
fields[:status] == "response_received" ? "Command output complete; device prompt received" :
|
|
30
|
+
"Command failed: #{fields[:message]}(#{fields[:error]})"
|
|
31
31
|
when "operation_start"
|
|
32
|
-
"
|
|
32
|
+
"Starting device script"
|
|
33
33
|
when "operation_complete"
|
|
34
|
-
fields[:status] == "completed" ? "
|
|
35
|
-
"
|
|
34
|
+
fields[:status] == "completed" ? "Device script complete" :
|
|
35
|
+
"Device script failed: #{fields[:message]}(#{fields[:error]})"
|
|
36
36
|
when "tftp_backup"
|
|
37
37
|
case fields[:status]
|
|
38
38
|
when "reported_uploaded"
|
|
39
|
-
"TFTP
|
|
39
|
+
"TFTP backup: device reported a successful upload to #{fields[:server]}/#{fields[:path]}; server file has not been verified"
|
|
40
40
|
when "transfer_failed"
|
|
41
|
-
"TFTP
|
|
41
|
+
"TFTP backup failed: device reported a transfer failure; server #{fields[:server]}"
|
|
42
42
|
else
|
|
43
|
-
"TFTP
|
|
43
|
+
"TFTP backup unconfirmed: device did not report a successful upload; server #{fields[:server]}"
|
|
44
44
|
end
|
|
45
45
|
else
|
|
46
46
|
name.to_s
|
|
@@ -404,8 +404,8 @@ module Net
|
|
|
404
404
|
failure = normalize_error(error, phase: phase)
|
|
405
405
|
@log.log_failure(phase == :connect ? "connect_failed" : "login_complete", failure,
|
|
406
406
|
status: "failed", phase: phase, duration_ms: elapsed_ms(started))
|
|
407
|
-
close_preserving_failure
|
|
408
|
-
replacement = attempted_recovery ?
|
|
407
|
+
cleaned = close_preserving_failure
|
|
408
|
+
replacement = cleaned && !attempted_recovery ? @recovery.recover(failure, transport) : nil
|
|
409
409
|
if replacement
|
|
410
410
|
@transport = replacement
|
|
411
411
|
attempted_recovery = true
|
|
@@ -458,12 +458,13 @@ module Net
|
|
|
458
458
|
raise normalize_error(error, phase: :close), cause: nil
|
|
459
459
|
end
|
|
460
460
|
|
|
461
|
-
#
|
|
461
|
+
# 清理结果决定能否移交传输所有权;失败仍保留原操作异常与待清理资源。
|
|
462
462
|
def close_preserving_failure
|
|
463
463
|
close_resources
|
|
464
|
+
true
|
|
464
465
|
rescue Exception # rubocop:disable Lint/RescueException -- 原操作失败时,清理故障或中断均不得覆盖它。
|
|
465
466
|
# 所有清理路径完成后,原始操作异常仍然是权威结果。
|
|
466
|
-
|
|
467
|
+
false
|
|
467
468
|
end
|
|
468
469
|
end
|
|
469
470
|
end
|
|
@@ -21,14 +21,17 @@ module Net
|
|
|
21
21
|
@configuration = configuration
|
|
22
22
|
@channel_factory = channel_factory
|
|
23
23
|
@terminal_size = terminal_size
|
|
24
|
+
@channel_owner = Process.pid
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# 打开伪终端、设置终端大小并启动设备进程。
|
|
27
28
|
def open
|
|
28
29
|
failure = nil
|
|
29
30
|
configuration.validate_endpoint!
|
|
31
|
+
close_resources if @channel || @closing_channel
|
|
30
32
|
# 构造期间的阻塞仍可取消;返回交接先登记信道,再交付异步中断。
|
|
31
33
|
Thread.handle_interrupt(Exception => :on_blocking) do
|
|
34
|
+
@channel_owner = Process.pid
|
|
32
35
|
@channel = if @channel_factory
|
|
33
36
|
@channel_factory.call
|
|
34
37
|
else
|
|
@@ -89,21 +92,10 @@ module Net
|
|
|
89
92
|
@log_target = target
|
|
90
93
|
end
|
|
91
94
|
|
|
92
|
-
#
|
|
95
|
+
# 停止日志交付并关闭信道;失败资源继续由内部账本持有。
|
|
93
96
|
def close
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Thread.handle_interrupt(Exception => :never) do
|
|
97
|
-
channel = @channel
|
|
98
|
-
@channel = nil
|
|
99
|
-
@log_target = nil
|
|
100
|
-
channel&.hard_close
|
|
101
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
|
|
102
|
-
failure = error
|
|
103
|
-
raise
|
|
104
|
-
end
|
|
105
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付时仍保留最先观察到的故障。
|
|
106
|
-
raise failure || error, cause: nil
|
|
97
|
+
@log_target = nil
|
|
98
|
+
close_resources
|
|
107
99
|
end
|
|
108
100
|
|
|
109
101
|
# 把人工交互结果转换为设备 EOF、超时或输入状态。
|
|
@@ -119,6 +111,30 @@ module Net
|
|
|
119
111
|
|
|
120
112
|
private
|
|
121
113
|
|
|
114
|
+
# 活跃信道先退休,只有所属进程确认退出后才释放清理责任;失败可由后续关闭重试。
|
|
115
|
+
# 打开前也使用同一路径,但保留调用方为新连接设置的日志目标。
|
|
116
|
+
def close_resources
|
|
117
|
+
failure = nil
|
|
118
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
119
|
+
@closing_channel = @channel if @channel
|
|
120
|
+
@channel = nil
|
|
121
|
+
status = @closing_channel&.hard_close
|
|
122
|
+
# fork 副本只关闭继承句柄;父进程拥有的子进程不能成为本副本的回收前提。
|
|
123
|
+
if @channel_owner == Process.pid && @closing_channel&.alive?
|
|
124
|
+
raise IOError, "PTY child is still running after hard close"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@closing_channel = nil
|
|
128
|
+
@channel_owner = nil
|
|
129
|
+
status
|
|
130
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 延迟取消不能覆盖底层关闭的首个故障。
|
|
131
|
+
failure = error
|
|
132
|
+
raise
|
|
133
|
+
end
|
|
134
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 先保存完成边界,再交付最先观察到的故障。
|
|
135
|
+
raise failure || error, cause: nil
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
# 已有操作异常时仍尝试清理;次级故障或中断不能覆盖原始失败。
|
|
123
139
|
def close_preserving_failure
|
|
124
140
|
close
|
|
@@ -154,6 +170,7 @@ module Net
|
|
|
154
170
|
def open
|
|
155
171
|
failure = nil
|
|
156
172
|
configuration.validate_endpoint!
|
|
173
|
+
close_resources if @channel || @closing_channel || @known_hosts
|
|
157
174
|
unless configuration.host_key_policy == :strict
|
|
158
175
|
# 临时信任文件的返回交接与信道遵守同一所有权边界。
|
|
159
176
|
Thread.handle_interrupt(Exception => :on_blocking) do
|
|
@@ -175,33 +192,6 @@ module Net
|
|
|
175
192
|
@replace_host_key = false
|
|
176
193
|
end
|
|
177
194
|
|
|
178
|
-
# 同时回收 SSH 通道与私有信任快照,保留先发生的关闭故障。
|
|
179
|
-
def close
|
|
180
|
-
failure = nil
|
|
181
|
-
# 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
|
|
182
|
-
Thread.handle_interrupt(Exception => :never) do
|
|
183
|
-
known_hosts = @known_hosts
|
|
184
|
-
@known_hosts = nil
|
|
185
|
-
begin
|
|
186
|
-
super
|
|
187
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
188
|
-
failure = error
|
|
189
|
-
raise
|
|
190
|
-
ensure
|
|
191
|
-
begin
|
|
192
|
-
known_hosts&.close
|
|
193
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
194
|
-
unless failure
|
|
195
|
-
failure = error
|
|
196
|
-
raise
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
|
|
202
|
-
raise failure || error, cause: nil
|
|
203
|
-
end
|
|
204
|
-
|
|
205
195
|
# 返回 SSH 协议标识。
|
|
206
196
|
def protocol = :ssh
|
|
207
197
|
|
|
@@ -236,6 +226,35 @@ module Net
|
|
|
236
226
|
@replace_host_key = true
|
|
237
227
|
end
|
|
238
228
|
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# 同时回收 SSH 通道与私有信任快照,保留先发生的关闭故障。
|
|
232
|
+
def close_resources
|
|
233
|
+
failure = nil
|
|
234
|
+
# 一次取消须等待信道和信任快照均完成回收;同步故障仍保留首个。
|
|
235
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
236
|
+
known_hosts = @known_hosts
|
|
237
|
+
begin
|
|
238
|
+
super
|
|
239
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 全部资源均须尝试关闭,保留首个失败或中断。
|
|
240
|
+
failure = error
|
|
241
|
+
raise
|
|
242
|
+
ensure
|
|
243
|
+
begin
|
|
244
|
+
known_hosts&.close
|
|
245
|
+
@known_hosts = nil
|
|
246
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 信任文件收尾不能覆盖传输关闭的原始异常。
|
|
247
|
+
unless failure
|
|
248
|
+
failure = error
|
|
249
|
+
raise
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
rescue Exception => error # rubocop:disable Lint/RescueException -- 恢复取消交付也不能盖掉已经发生的关闭故障。
|
|
255
|
+
raise failure || error, cause: nil
|
|
256
|
+
end
|
|
257
|
+
|
|
239
258
|
end
|
|
240
259
|
|
|
241
260
|
# Telnet 只能显式启用;SSH 专用端口不能带入回退连接。
|
|
@@ -33,7 +33,7 @@ module Net
|
|
|
33
33
|
def self.add_options(parser, single_host: true, &accept)
|
|
34
34
|
flags = single_host ? {"--host IP" => "hosts"}.merge(FLAGS) : FLAGS
|
|
35
35
|
flags.each do |flag, key|
|
|
36
|
-
parser.on(flag, "
|
|
36
|
+
parser.on(flag, "Filter inventory; combine conditions of the same type with OR; exclusions take precedence") do |value|
|
|
37
37
|
items = value.split(",", -1).map(&:strip)
|
|
38
38
|
raise OptionParser::InvalidArgument, "filter list contains empty items" if items.empty? || items.any?(&:empty?)
|
|
39
39
|
|
|
@@ -41,7 +41,7 @@ module Net
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
MODES.each do |key|
|
|
44
|
-
parser.on("--#{key.tr("_", "-")} MODE", %w[contains prefix glob], "
|
|
44
|
+
parser.on("--#{key.tr("_", "-")} MODE", %w[contains prefix glob], "Matching mode: contains / prefix / glob") { |value| accept.call(key, value) }
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
end
|