net-connector 0.6.0 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/CONTRIBUTING.md +20 -0
- data/README.md +112 -50
- data/docs/VERIFICATION.md +42 -1
- data/docs/architecture.md +15 -2
- data/examples/backup.yml +40 -0
- data/lib/net/connector/device/base.rb +30 -38
- data/lib/net/connector/device/profile/builder.rb +8 -8
- data/lib/net/connector/device/profile.rb +2 -2
- data/lib/net/connector/device/running_config.rb +2 -2
- data/lib/net/connector/device/tftp/receipt.rb +12 -3
- data/lib/net/connector/device/tftp.rb +7 -7
- data/lib/net/connector/device/topology.rb +4 -4
- data/lib/net/connector/engine/command.rb +2 -2
- data/lib/net/connector/engine/configuration.rb +5 -2
- data/lib/net/connector/engine/execution.rb +25 -14
- data/lib/net/connector/engine/known_hosts.rb +112 -0
- data/lib/net/connector/engine/log.rb +10 -7
- data/lib/net/connector/engine/session.rb +117 -128
- data/lib/net/connector/engine/transport.rb +32 -7
- data/lib/net/connector/netdisco/backup_run.rb +132 -0
- data/lib/net/connector/netdisco/batch.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +139 -0
- data/lib/net/connector/netdisco/cli.rb +13 -26
- data/lib/net/connector/netdisco/client.rb +6 -6
- data/lib/net/connector/netdisco/config_file.rb +20 -19
- data/lib/net/connector/netdisco/connection.rb +38 -0
- data/lib/net/connector/netdisco/database_client.rb +2 -2
- data/lib/net/connector/netdisco/device.rb +11 -1
- data/lib/net/connector/netdisco/fleet.rb +68 -47
- data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
- data/lib/net/connector/netdisco/plan.rb +14 -9
- data/lib/net/connector/netdisco/planner.rb +18 -14
- data/lib/net/connector/netdisco/progress.rb +253 -0
- data/lib/net/connector/netdisco/report/files.rb +41 -0
- data/lib/net/connector/netdisco/report/text.rb +45 -0
- data/lib/net/connector/netdisco/report.rb +53 -31
- data/lib/net/connector/netdisco/result_store.rb +3 -1
- data/lib/net/connector/netdisco/rules.rb +10 -2
- data/lib/net/connector/netdisco/settings.rb +118 -68
- data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
- data/lib/net/connector/netdisco/tftp_history.rb +16 -0
- data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
- data/lib/net/connector/netdisco/worker.rb +8 -3
- data/lib/net/connector/netdisco.rb +10 -0
- data/lib/net/connector/storage/backup_lock.rb +1 -1
- data/lib/net/connector/storage/batch_directory.rb +29 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c.rb +2 -1
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
- data/lib/net/connector/vendor/radware/running_config.rb +19 -0
- data/lib/net/connector/vendor/radware.rb +4 -3
- data/lib/net/connector/version.rb +1 -1
- data/lib/net/connector.rb +2 -2
- metadata +42 -111
- /data/examples/{netdisco_database.yml → inventory_sql.yml} +0 -0
|
@@ -25,13 +25,10 @@ module Net
|
|
|
25
25
|
|
|
26
26
|
attr_reader :configuration, :command_timeout
|
|
27
27
|
|
|
28
|
-
# 将连接状态查询委托给当前设备会话。
|
|
29
28
|
def_delegators :@session, :connected?, :privileged?, :state
|
|
30
|
-
# 将设备地址、账号和日志路径委托给连接配置。
|
|
31
29
|
def_delegators :@configuration, :host, :username, :log_file
|
|
32
30
|
|
|
33
31
|
class << self
|
|
34
|
-
# 读取或声明连接器的厂商标识。
|
|
35
32
|
def vendor(key = nil)
|
|
36
33
|
return @vendor = key if key
|
|
37
34
|
|
|
@@ -98,7 +95,6 @@ module Net
|
|
|
98
95
|
# 返回当前连接器使用的不可变设备档案,便于检查厂商声明。
|
|
99
96
|
def profile = self.class.profile || Profile.default
|
|
100
97
|
|
|
101
|
-
# 读取或声明连接器的厂商标识。
|
|
102
98
|
def vendor = self.class.vendor
|
|
103
99
|
|
|
104
100
|
# 只查询实现能力,不连接设备;不代表现场权限或固件验证成功。
|
|
@@ -141,7 +137,7 @@ module Net
|
|
|
141
137
|
end
|
|
142
138
|
|
|
143
139
|
# 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
|
|
144
|
-
def with_operation(name, &
|
|
140
|
+
def with_operation(name, &) = @session.with_operation(name, &)
|
|
145
141
|
|
|
146
142
|
# 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
|
|
147
143
|
def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
|
|
@@ -168,48 +164,36 @@ module Net
|
|
|
168
164
|
@session.interact(input: input, output: output, escape: escape, timeout: timeout)
|
|
169
165
|
end
|
|
170
166
|
|
|
171
|
-
# 返回不包含凭据的连接状态摘要。
|
|
172
167
|
def inspect = "#<#{self.class} host=#{host.inspect} state=#{state}>"
|
|
173
168
|
|
|
174
169
|
protected
|
|
175
170
|
|
|
176
|
-
# 匹配设备分页提示,供对话层自动发送翻页响应。
|
|
177
171
|
def pager_pattern = profile.pager_pattern
|
|
178
172
|
|
|
179
|
-
# 返回分页提示对应的响应字节。
|
|
180
173
|
def pager_response = profile.pager_response
|
|
181
174
|
|
|
182
|
-
# 匹配密码输入提示。
|
|
183
175
|
def password_prompt = profile.password_prompt
|
|
184
176
|
|
|
185
|
-
# 匹配用户名输入提示。
|
|
186
177
|
def username_prompt = profile.username_prompt
|
|
187
178
|
|
|
188
|
-
# 返回认证失败的设备输出模式。
|
|
189
179
|
def authentication_error_patterns = profile.authentication_error_patterns
|
|
190
180
|
|
|
191
|
-
# 返回命令失败的设备输出模式。
|
|
192
181
|
def command_error_patterns = profile.command_error_patterns
|
|
193
182
|
|
|
194
183
|
# 返回命令期间需要自动应答的确认对话。
|
|
195
|
-
def
|
|
184
|
+
def confirmation_interactions = profile.confirmation_interactions
|
|
196
185
|
|
|
197
186
|
# 返回登录期间需要自动应答的附加对话。
|
|
198
|
-
def
|
|
187
|
+
def login_interactions = profile.login_interactions
|
|
199
188
|
|
|
200
|
-
# 返回旧版 SSH 恢复使用的协商参数。
|
|
201
189
|
def legacy_ssh_arguments = profile.legacy_ssh_arguments
|
|
202
190
|
|
|
203
|
-
# 返回进入特权模式的命令;nil 表示不支持。
|
|
204
191
|
def enable_command = profile.privilege_command
|
|
205
192
|
|
|
206
|
-
# 匹配特权模式提示符。
|
|
207
193
|
def enable_prompt = profile.privilege_prompt
|
|
208
194
|
|
|
209
|
-
# 返回默认单条命令超时秒数。
|
|
210
195
|
def default_command_timeout = profile.command_timeout
|
|
211
196
|
|
|
212
|
-
# 返回可选终端大小。
|
|
213
197
|
def terminal_size = profile.terminal_size
|
|
214
198
|
|
|
215
199
|
# 返回登录完成提示;厂商必须实现。
|
|
@@ -252,9 +236,9 @@ module Net
|
|
|
252
236
|
login_prompt: login_prompt, command_prompt: command_prompt,
|
|
253
237
|
password_prompt: password_prompt, username_prompt: username_prompt, enable_prompt: enable_prompt,
|
|
254
238
|
authentication_errors: authentication_error_patterns, command_errors: command_error_patterns,
|
|
255
|
-
login_interactions:
|
|
239
|
+
login_interactions: login_interactions,
|
|
256
240
|
command_interactions: [Interaction.new(pager_pattern, pager_response, capture: false),
|
|
257
|
-
*
|
|
241
|
+
*confirmation_interactions]
|
|
258
242
|
)
|
|
259
243
|
end
|
|
260
244
|
|
|
@@ -263,34 +247,42 @@ module Net
|
|
|
263
247
|
finalize: nil, &on_step)
|
|
264
248
|
return Result.new if script.empty?
|
|
265
249
|
|
|
266
|
-
|
|
267
|
-
self.after_command(command, response, context)
|
|
268
|
-
after_command&.call(command, response, context)
|
|
269
|
-
end
|
|
270
|
-
execution = Execution.new(session: @session, timeout: command_timeout,
|
|
271
|
-
prepare: method(:prepare_command), after_command: finish_step, prompt: prompt)
|
|
272
|
-
execution.context[:operation] = operation if operation
|
|
273
|
-
execution.context[:privilege] = privilege
|
|
250
|
+
execution = build_execution(operation: operation, prompt: prompt, after_command: after_command, privilege: privilege)
|
|
274
251
|
output_sensitive = script.any?(&:output_sensitive?)
|
|
275
252
|
@session.perform(:script) do
|
|
276
253
|
@session.log_script(operation: operation, steps: execution.steps) do
|
|
277
254
|
@session.with_sensitive_output(output_sensitive) { before_batch(execution) }
|
|
278
255
|
result = execution.execute_script(script, &on_step)
|
|
279
|
-
|
|
280
|
-
@session.with_sensitive_output(
|
|
281
|
-
result
|
|
282
|
-
# 回调也可直接返回失败;与抛错共用脱敏边界,保留已完成步骤及业务配置。
|
|
283
|
-
if result.is_a?(Result) && result.failure?
|
|
284
|
-
result = Result.new(steps: result.steps, config: result.config,
|
|
285
|
-
error: @session.normalize_error(result.error, phase: :script))
|
|
286
|
-
end
|
|
287
|
-
result
|
|
256
|
+
sensitive_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
|
|
257
|
+
@session.with_sensitive_output(sensitive_result) do
|
|
258
|
+
finalize_script_result(result, finalize)
|
|
288
259
|
end
|
|
289
260
|
end
|
|
290
261
|
end
|
|
291
262
|
rescue Error => error
|
|
292
263
|
Result.new(steps: execution ? execution.steps : [], error: error)
|
|
293
264
|
end
|
|
265
|
+
|
|
266
|
+
def build_execution(operation:, prompt:, after_command:, privilege:)
|
|
267
|
+
finish_step = lambda do |command, response, context|
|
|
268
|
+
self.after_command(command, response, context)
|
|
269
|
+
after_command&.call(command, response, context)
|
|
270
|
+
end
|
|
271
|
+
execution = Execution.new(session: @session, timeout: command_timeout,
|
|
272
|
+
prepare: method(:prepare_command), after_command: finish_step, prompt: prompt)
|
|
273
|
+
execution.context[:operation] = operation if operation
|
|
274
|
+
execution.context[:privilege] = privilege
|
|
275
|
+
execution
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# 回调也可直接返回失败;与抛错共用脱敏边界,保留已完成步骤及业务配置。
|
|
279
|
+
def finalize_script_result(result, finalize)
|
|
280
|
+
result = finalize ? finalize.call(result) : result
|
|
281
|
+
return result unless result.is_a?(Result) && result.failure?
|
|
282
|
+
|
|
283
|
+
Result.new(steps: result.steps, config: result.config,
|
|
284
|
+
error: @session.normalize_error(result.error, phase: :script))
|
|
285
|
+
end
|
|
294
286
|
end
|
|
295
287
|
end
|
|
296
288
|
end
|
|
@@ -12,7 +12,7 @@ module Net
|
|
|
12
12
|
|
|
13
13
|
# 声明运行配置和保存配置命令。
|
|
14
14
|
def commands(&definition)
|
|
15
|
-
|
|
15
|
+
validate_block!(:commands, definition)
|
|
16
16
|
commands = Commands.new(
|
|
17
17
|
@values[:config_commands], @values[:save_commands]
|
|
18
18
|
)
|
|
@@ -24,28 +24,28 @@ module Net
|
|
|
24
24
|
|
|
25
25
|
# 声明登录、命令、凭据和提权提示符。
|
|
26
26
|
def prompts(&definition)
|
|
27
|
-
|
|
27
|
+
validate_block!(:prompts, definition)
|
|
28
28
|
Prompts.new(@values).instance_eval(&definition)
|
|
29
29
|
self
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# 声明分页模式及自动翻页响应。
|
|
33
33
|
def pager(&definition)
|
|
34
|
-
|
|
34
|
+
validate_block!(:pager, definition)
|
|
35
35
|
Pager.new(@values).instance_eval(&definition)
|
|
36
36
|
self
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# 声明认证失败和命令失败的输出模式。
|
|
40
40
|
def errors(&definition)
|
|
41
|
-
|
|
41
|
+
validate_block!(:errors, definition)
|
|
42
42
|
Errors.new(@values).instance_eval(&definition)
|
|
43
43
|
self
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# 声明登录挑战和命令确认交互。
|
|
47
47
|
def interactions(&definition)
|
|
48
|
-
|
|
48
|
+
validate_block!(:interactions, definition)
|
|
49
49
|
interactions = Interactions.new(@values)
|
|
50
50
|
interactions.instance_eval(&definition)
|
|
51
51
|
@values[:login_interactions] = interactions.login_interactions
|
|
@@ -55,14 +55,14 @@ module Net
|
|
|
55
55
|
|
|
56
56
|
# 声明进入特权模式所需的命令和提示符。
|
|
57
57
|
def privilege(&definition)
|
|
58
|
-
|
|
58
|
+
validate_block!(:privilege, definition)
|
|
59
59
|
Privilege.new(@values).instance_eval(&definition)
|
|
60
60
|
self
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# 声明旧版 SSH 协商参数。
|
|
64
64
|
def ssh(&definition)
|
|
65
|
-
|
|
65
|
+
validate_block!(:ssh, definition)
|
|
66
66
|
Ssh.new(@values).instance_eval(&definition)
|
|
67
67
|
self
|
|
68
68
|
end
|
|
@@ -103,7 +103,7 @@ module Net
|
|
|
103
103
|
private
|
|
104
104
|
|
|
105
105
|
# 块式声明入口必须有明确的子 DSL,避免静默忽略配置。
|
|
106
|
-
def
|
|
106
|
+
def validate_block!(name, definition)
|
|
107
107
|
raise ArgumentError, "#{name} requires a block" unless definition
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -63,7 +63,7 @@ module Net
|
|
|
63
63
|
@legacy_ssh_arguments = strings(legacy_ssh_arguments, :legacy_ssh_arguments)
|
|
64
64
|
@privilege_command = optional_text(privilege_command, :privilege_command)
|
|
65
65
|
@command_timeout = duration(command_timeout, :command_timeout)
|
|
66
|
-
@terminal_size = validate_terminal_size(terminal_size)
|
|
66
|
+
@terminal_size = validate_terminal_size!(terminal_size)
|
|
67
67
|
@running_config_strategy = strategy_class(running_config_strategy, :running_config_strategy,
|
|
68
68
|
%i[clean result_step prompt_text validate_response!])
|
|
69
69
|
@tftp_strategy = strategy_class(tftp_strategy, :tftp_strategy,
|
|
@@ -211,7 +211,7 @@ module Net
|
|
|
211
211
|
end
|
|
212
212
|
|
|
213
213
|
# 校验终端宽高并冻结数组。
|
|
214
|
-
def validate_terminal_size(value)
|
|
214
|
+
def validate_terminal_size!(value)
|
|
215
215
|
return if value.nil?
|
|
216
216
|
unless value.is_a?(Array) && value.size == 2 && value.all? { |dimension| dimension.is_a?(Integer) && dimension.positive? }
|
|
217
217
|
raise ArgumentError, "terminal_size must contain two positive Integers"
|
|
@@ -81,7 +81,7 @@ module Net
|
|
|
81
81
|
raise build_incomplete_error("configuration collection has no completed configuration step") unless step
|
|
82
82
|
|
|
83
83
|
content = @device.clean_config(step.output)
|
|
84
|
-
unless content.is_a?(String) && !content.strip.empty? &&
|
|
84
|
+
unless content.is_a?(String) && !content.strip.empty? && config_body?(step)
|
|
85
85
|
raise build_incomplete_error("configuration collection returned empty content")
|
|
86
86
|
end
|
|
87
87
|
Result.new(steps: result.steps, config: content)
|
|
@@ -99,7 +99,7 @@ module Net
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# 排除命令回显和提示符,确认响应确实包含配置正文。
|
|
102
|
-
def
|
|
102
|
+
def config_body?(step)
|
|
103
103
|
body = TerminalRenderer.render(step.output.delete_suffix(step.prompt.to_s)).strip
|
|
104
104
|
lines = body.lines
|
|
105
105
|
lines.shift if lines.first&.strip == step.command.text
|
|
@@ -7,7 +7,8 @@ module Net
|
|
|
7
7
|
module Connector
|
|
8
8
|
# 一次设备上报上传的不可变回执;path 是实际目标,未知时保持 nil。
|
|
9
9
|
TftpReceipt = Data.define(:server, :path, :completed_at, :configuration_kind, :source_file,
|
|
10
|
-
:format, :requested_path, :verification, :server_sha256
|
|
10
|
+
:format, :requested_path, :verification, :server_sha256, :server_bytes, :local_path,
|
|
11
|
+
:archive_path)
|
|
11
12
|
|
|
12
13
|
class TftpReceipt
|
|
13
14
|
KINDS = %i[running startup saved_file native_archive unknown].freeze
|
|
@@ -15,7 +16,8 @@ module Net
|
|
|
15
16
|
private_constant :KINDS, :FORMATS
|
|
16
17
|
|
|
17
18
|
def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
|
|
18
|
-
format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil
|
|
19
|
+
format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil,
|
|
20
|
+
server_bytes: nil, local_path: nil, archive_path: nil)
|
|
19
21
|
raise ArgumentError, "completed_at must be a Time" unless completed_at.is_a?(Time)
|
|
20
22
|
|
|
21
23
|
TftpTarget.new(host: server, path: path.nil? ? "unconfirmed" : path)
|
|
@@ -29,10 +31,17 @@ module Net
|
|
|
29
31
|
server_sha256.match?(/\A[0-9a-f]{64}\z/))
|
|
30
32
|
raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
|
|
31
33
|
|
|
34
|
+
if (!server_bytes.nil? && (!server_bytes.is_a?(Integer) || !server_bytes.positive?)) ||
|
|
35
|
+
(!local_path.nil? && (!local_path.is_a?(String) || local_path.empty?)) ||
|
|
36
|
+
(!archive_path.nil? && (!archive_path.is_a?(String) || archive_path.empty?)) ||
|
|
37
|
+
(verification != :server_verified && (server_bytes || local_path || archive_path))
|
|
38
|
+
raise ArgumentError, "invalid server file metadata"
|
|
39
|
+
end
|
|
32
40
|
super(server: server.dup.freeze, path: path&.dup&.freeze, completed_at: completed_at.dup.freeze,
|
|
33
41
|
configuration_kind: configuration_kind, source_file: source_file&.dup&.freeze, format: format,
|
|
34
42
|
requested_path: requested_path&.dup&.freeze, verification: verification,
|
|
35
|
-
server_sha256: server_sha256&.dup&.freeze
|
|
43
|
+
server_sha256: server_sha256&.dup&.freeze, server_bytes: server_bytes, local_path: local_path&.dup&.freeze,
|
|
44
|
+
archive_path: archive_path&.dup&.freeze)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
# Ruby 3.2 的 Data#with 不调用 initialize;所有更新都重新校验并冻结字段。
|
|
@@ -89,13 +89,13 @@ module Net
|
|
|
89
89
|
raise DeviceError.new("device reported TFTP backup failure",
|
|
90
90
|
code: :transfer_failed, host: @device.host, phase: :tftp_backup)
|
|
91
91
|
end
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
return if strategy.device_reported_complete?(result)
|
|
93
|
+
|
|
94
|
+
result.value! if result.failure?
|
|
95
|
+
@device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
|
|
96
|
+
code: :transfer_unconfirmed, server: target.host)
|
|
97
|
+
raise DeviceError.new("device did not confirm TFTP backup completion",
|
|
98
|
+
code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# 识别设备回显中的传输失败信息。
|
|
@@ -180,10 +180,10 @@ module Net
|
|
|
180
180
|
code: :verification_plan_changed, host: @device.host, phase: :verify)
|
|
181
181
|
end
|
|
182
182
|
end
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
return if changes.all? { |change| updated[@strategy.interface_key(change.interface)] == change.new_description }
|
|
184
|
+
|
|
185
|
+
raise DeviceError.new("interface descriptions were not confirmed by readback",
|
|
186
|
+
code: :description_unconfirmed, host: @device.host, phase: :verify)
|
|
187
187
|
end
|
|
188
188
|
|
|
189
189
|
# 重验失败仍在写入前直接抛异常,保持 stale_plan 的既有契约。
|
|
@@ -70,11 +70,11 @@ module Net
|
|
|
70
70
|
def self.parse(text, name: nil)
|
|
71
71
|
raise ArgumentError, "script must be a String" unless text.is_a?(String)
|
|
72
72
|
|
|
73
|
-
commands = text.each_line.with_index(1).filter_map do |line,
|
|
73
|
+
commands = text.each_line.with_index(1).filter_map do |line, line_number|
|
|
74
74
|
line = line.chomp
|
|
75
75
|
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
76
76
|
|
|
77
|
-
Command.new(line, source: name, line:
|
|
77
|
+
Command.new(line, source: name, line: line_number)
|
|
78
78
|
end
|
|
79
79
|
new(commands, name: name)
|
|
80
80
|
end
|
|
@@ -9,13 +9,13 @@ module Net
|
|
|
9
9
|
class Configuration
|
|
10
10
|
attr_reader :host, :username, :password, :enable_password, :protocol, :port, :login_timeout,
|
|
11
11
|
:command_timeout, :write_timeout, :max_output_bytes, :max_script_output_bytes, :log_file, :log_format, :log_level,
|
|
12
|
-
:known_hosts, :host_key_policy, :challenges, :logger
|
|
12
|
+
:known_hosts, :host_key_policy, :challenges, :logger, :on_event
|
|
13
13
|
|
|
14
14
|
# 校验端点、凭据、超时、日志、主机密钥和挑战配置,然后冻结设置。
|
|
15
15
|
def initialize(host: nil, username: nil, password: nil, enable_password: nil, protocol: :ssh, port: nil,
|
|
16
16
|
login_timeout: 10, command_timeout: nil, write_timeout: 10,
|
|
17
17
|
max_output_bytes: 32 * 1024 * 1024, max_script_output_bytes: nil, log_file: nil, logger: nil,
|
|
18
|
-
log_format: :text, log_level: :info,
|
|
18
|
+
log_format: :text, log_level: :info, on_event: nil,
|
|
19
19
|
known_hosts: nil, host_key_policy: :strict, telnet_fallback: false, legacy_ssh: false,
|
|
20
20
|
challenges: [])
|
|
21
21
|
@host = frozen_string(host)
|
|
@@ -45,6 +45,9 @@ module Net
|
|
|
45
45
|
raise ArgumentError, "logger cannot be combined with raw logging" if logger && @log_format == :raw
|
|
46
46
|
|
|
47
47
|
@logger = logger
|
|
48
|
+
raise ArgumentError, "on_event must respond to call" if on_event && !on_event.respond_to?(:call)
|
|
49
|
+
|
|
50
|
+
@on_event = on_event
|
|
48
51
|
@log_level = enum_value(log_level, %i[debug info warn error], :log_level)
|
|
49
52
|
@known_hosts = absolute_path(known_hosts)
|
|
50
53
|
@host_key_policy = enum_value(host_key_policy, %i[strict accept_new replace], :host_key_policy)
|
|
@@ -15,7 +15,7 @@ module Net
|
|
|
15
15
|
@command_timeout = timeout
|
|
16
16
|
@prepare_command = prepare
|
|
17
17
|
@after_command = after_command
|
|
18
|
-
@
|
|
18
|
+
@prompt_resolver = prompt
|
|
19
19
|
@context = {}
|
|
20
20
|
@steps = []
|
|
21
21
|
@output_bytes = 0
|
|
@@ -28,10 +28,10 @@ module Net
|
|
|
28
28
|
|
|
29
29
|
# 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
|
|
30
30
|
def execute_script(script)
|
|
31
|
-
script.each do |
|
|
32
|
-
@session.with_command_redaction(
|
|
33
|
-
@current_command =
|
|
34
|
-
command = @prepare_command.call(
|
|
31
|
+
script.each do |original_command|
|
|
32
|
+
@session.with_command_redaction(original_command) do
|
|
33
|
+
@current_command = original_command
|
|
34
|
+
command = @prepare_command.call(original_command, self)
|
|
35
35
|
next unless command
|
|
36
36
|
|
|
37
37
|
@current_command = command
|
|
@@ -45,31 +45,31 @@ module Net
|
|
|
45
45
|
# 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
|
|
46
46
|
@after_command.call(command, response, self)
|
|
47
47
|
yield step if block_given?
|
|
48
|
-
|
|
48
|
+
validate_response_budget!(@last_executed_command)
|
|
49
49
|
rescue => error
|
|
50
50
|
raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
|
-
|
|
53
|
+
validate_response_budget!(@last_executed_command)
|
|
54
54
|
Result.new(steps: steps)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
|
|
58
58
|
def execute_command(command)
|
|
59
59
|
command = Command.new(command) unless command.is_a?(Command)
|
|
60
|
-
|
|
61
|
-
prompt = @
|
|
60
|
+
validate_send_budget!(command)
|
|
61
|
+
prompt = @prompt_resolver&.call(command)
|
|
62
62
|
# 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
|
|
63
|
-
|
|
63
|
+
validate_send_budget!(command) if @prompt_resolver
|
|
64
64
|
response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do
|
|
65
65
|
# 必须在 Session 恢复命令词表前读取;最终处理只能继承敏感性,不长期保留秘密。
|
|
66
66
|
@sensitive ||= @session.redactor.sensitive?
|
|
67
67
|
end
|
|
68
|
-
@
|
|
68
|
+
@last_executed_command = command
|
|
69
69
|
@output_bytes += response.raw.bytesize
|
|
70
70
|
# 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
|
|
71
71
|
yield response if block_given?
|
|
72
|
-
|
|
72
|
+
validate_response_budget!(command)
|
|
73
73
|
response
|
|
74
74
|
end
|
|
75
75
|
|
|
@@ -86,9 +86,20 @@ module Net
|
|
|
86
86
|
private
|
|
87
87
|
|
|
88
88
|
# 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
|
|
89
|
-
def
|
|
90
|
-
return unless @output_limit &&
|
|
89
|
+
def validate_send_budget!(command)
|
|
90
|
+
return unless @output_limit && @output_bytes >= @output_limit
|
|
91
91
|
|
|
92
|
+
raise_output_limit!(command)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 恰好达到预算的已完成响应仍然有效;下一次发送由发送前检查阻止。
|
|
96
|
+
def validate_response_budget!(command)
|
|
97
|
+
return unless @output_limit && @output_bytes > @output_limit
|
|
98
|
+
|
|
99
|
+
raise_output_limit!(command)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def raise_output_limit!(command)
|
|
92
103
|
raise @session.build_error(ScriptOutputLimitExceeded,
|
|
93
104
|
"script output reached max_script_output_bytes; commands already sent may have executed",
|
|
94
105
|
phase: :script, command: command), cause: nil
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Net
|
|
9
|
+
module Connector
|
|
10
|
+
# SSH 只写会话副本;认证后在跨进程锁内合并,网络等待不占用共享文件锁。
|
|
11
|
+
class KnownHosts
|
|
12
|
+
attr_reader :path
|
|
13
|
+
|
|
14
|
+
def initialize(configuration, replace: false)
|
|
15
|
+
destination = configuration.known_hosts || File.join(Dir.home, ".ssh", "known_hosts")
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
|
|
17
|
+
@destination = File.join(File.realpath(File.dirname(destination)), File.basename(destination))
|
|
18
|
+
@host = configuration.host
|
|
19
|
+
@host = "[#{@host}]:#{configuration.port}" if configuration.port && configuration.port != 22
|
|
20
|
+
@replace = replace
|
|
21
|
+
@original = read_shared
|
|
22
|
+
@file = Tempfile.new([".nc-known-hosts-", ""], File.dirname(@destination))
|
|
23
|
+
@path = @file.path
|
|
24
|
+
@file.write(@original)
|
|
25
|
+
@file.flush
|
|
26
|
+
remove_host(@path) if @replace
|
|
27
|
+
rescue Exception # rubocop:disable Lint/RescueException -- Remove the private snapshot on interruption.
|
|
28
|
+
close
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def commit
|
|
33
|
+
additions = File.binread(path).lines - @original.lines
|
|
34
|
+
return if additions.empty?
|
|
35
|
+
|
|
36
|
+
with_lock do
|
|
37
|
+
current = read_shared
|
|
38
|
+
Tempfile.create([".nc-known-hosts-merge-", ""], File.dirname(@destination)) do |file|
|
|
39
|
+
file.write(current)
|
|
40
|
+
file.flush
|
|
41
|
+
reject_changed_key!(file.path) unless @replace
|
|
42
|
+
remove_host(file.path) if @replace
|
|
43
|
+
contents = File.binread(file.path)
|
|
44
|
+
entries = contents.lines
|
|
45
|
+
additions.each { |line| entries << line unless entries.include?(line) }
|
|
46
|
+
File.open(file.path, "wb", 0o600) do |replacement|
|
|
47
|
+
replacement.write(entries.map { |line| line.end_with?("\n") ? line : line + "\n" }.join)
|
|
48
|
+
replacement.flush
|
|
49
|
+
replacement.fsync
|
|
50
|
+
end
|
|
51
|
+
File.rename(file.path, @destination)
|
|
52
|
+
ensure
|
|
53
|
+
File.unlink(file.path + ".old") if file && File.exist?(file.path + ".old")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close
|
|
59
|
+
File.unlink(@path + ".old") if @path && File.exist?(@path + ".old")
|
|
60
|
+
@file&.close!
|
|
61
|
+
@file = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def read_shared
|
|
67
|
+
File.open(@destination, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
68
|
+
raise IOError, "known_hosts must be a regular file" unless file.stat.file?
|
|
69
|
+
|
|
70
|
+
file.read
|
|
71
|
+
end
|
|
72
|
+
rescue Errno::ENOENT
|
|
73
|
+
""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reject_changed_key!(current_path)
|
|
77
|
+
current = host_keys(current_path)
|
|
78
|
+
staged = host_keys(path)
|
|
79
|
+
return if current.empty? || (staged - current).empty?
|
|
80
|
+
|
|
81
|
+
raise ConnectionError.new("host key changed during concurrent registration", code: :host_key_changed)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def host_keys(path)
|
|
85
|
+
output, status = Open3.capture2e("ssh-keygen", "-f", path, "-F", @host)
|
|
86
|
+
raise IOError, "reading device host key failed" unless [0, 1].include?(status.exitstatus)
|
|
87
|
+
|
|
88
|
+
output.lines.reject { |line| line.start_with?("#") }.map { |line| line.split[1, 2] }.uniq
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def remove_host(path)
|
|
92
|
+
_output, status = Open3.capture2e("ssh-keygen", "-f", path, "-R", @host)
|
|
93
|
+
raise IOError, "removing device host key failed" unless status.success?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def with_lock
|
|
97
|
+
lock_path = @destination + ".nc-lock"
|
|
98
|
+
File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600) do |file|
|
|
99
|
+
stat = file.stat
|
|
100
|
+
unless stat.file? && stat.uid == Process.euid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
101
|
+
raise IOError, "known_hosts lock must be an owned private regular file"
|
|
102
|
+
end
|
|
103
|
+
file.flock(File::LOCK_EX)
|
|
104
|
+
current = File.lstat(lock_path)
|
|
105
|
+
raise IOError, "known_hosts lock changed" unless current.dev == stat.dev && current.ino == stat.ino
|
|
106
|
+
|
|
107
|
+
yield
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -57,15 +57,15 @@ module Net
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def with_operation_context(name, &
|
|
61
|
-
with_context(operation: name || @context[:operation] || :script, &
|
|
60
|
+
def with_operation_context(name, &)
|
|
61
|
+
with_context(operation: name || @context[:operation] || :script, &)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def with_command_context(command, &
|
|
64
|
+
def with_command_context(command, &)
|
|
65
65
|
@command_sequence += 1
|
|
66
66
|
with_context(command_id: @command_sequence, phase: :command,
|
|
67
67
|
text: command.sensitive? ? "[REDACTED]" : command.text,
|
|
68
|
-
source: command.source, line: command.line, &
|
|
68
|
+
source: command.source, line: command.line, &)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# 将终端回显接入当前会话日志。
|
|
@@ -81,7 +81,7 @@ module Net
|
|
|
81
81
|
|
|
82
82
|
# 按日志级别写入已脱敏的业务事件。
|
|
83
83
|
def log_event(name, level: :info, **fields)
|
|
84
|
-
return unless @logger
|
|
84
|
+
return unless @logger || @configuration.on_event
|
|
85
85
|
|
|
86
86
|
finish_output
|
|
87
87
|
write_event(name, level: level, **fields)
|
|
@@ -190,7 +190,8 @@ module Net
|
|
|
190
190
|
|
|
191
191
|
# 每次读取调用方的当前阈值,不缓存级别、不改写共享 Logger。
|
|
192
192
|
def enabled?(level)
|
|
193
|
-
|
|
193
|
+
LEVELS.fetch(level) >= LEVELS.fetch(@configuration.log_level) &&
|
|
194
|
+
(@configuration.on_event || (@logger && LEVELS.fetch(level) >= @logger.level))
|
|
194
195
|
end
|
|
195
196
|
|
|
196
197
|
def write_event(name, level:, **fields)
|
|
@@ -198,7 +199,9 @@ module Net
|
|
|
198
199
|
|
|
199
200
|
values = @context.compact.merge(fields.reject { |key, _| CONTEXT_FIELDS.include?(key.to_s) })
|
|
200
201
|
values = values.merge(host: @configuration.host, session_id: @session_id)
|
|
201
|
-
|
|
202
|
+
event = Event.new(name, values, redactor: @redactor)
|
|
203
|
+
@logger.public_send(level, event) if @logger && LEVELS.fetch(level) >= @logger.level
|
|
204
|
+
@configuration.on_event&.call(event)
|
|
202
205
|
end
|
|
203
206
|
|
|
204
207
|
def open_transcript
|