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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +112 -50
  5. data/docs/VERIFICATION.md +42 -1
  6. data/docs/architecture.md +15 -2
  7. data/examples/backup.yml +40 -0
  8. data/lib/net/connector/device/base.rb +30 -38
  9. data/lib/net/connector/device/profile/builder.rb +8 -8
  10. data/lib/net/connector/device/profile.rb +2 -2
  11. data/lib/net/connector/device/running_config.rb +2 -2
  12. data/lib/net/connector/device/tftp/receipt.rb +12 -3
  13. data/lib/net/connector/device/tftp.rb +7 -7
  14. data/lib/net/connector/device/topology.rb +4 -4
  15. data/lib/net/connector/engine/command.rb +2 -2
  16. data/lib/net/connector/engine/configuration.rb +5 -2
  17. data/lib/net/connector/engine/execution.rb +25 -14
  18. data/lib/net/connector/engine/known_hosts.rb +112 -0
  19. data/lib/net/connector/engine/log.rb +10 -7
  20. data/lib/net/connector/engine/session.rb +117 -128
  21. data/lib/net/connector/engine/transport.rb +32 -7
  22. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  23. data/lib/net/connector/netdisco/batch.rb +1 -1
  24. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  25. data/lib/net/connector/netdisco/cli.rb +13 -26
  26. data/lib/net/connector/netdisco/client.rb +6 -6
  27. data/lib/net/connector/netdisco/config_file.rb +20 -19
  28. data/lib/net/connector/netdisco/connection.rb +38 -0
  29. data/lib/net/connector/netdisco/database_client.rb +2 -2
  30. data/lib/net/connector/netdisco/device.rb +11 -1
  31. data/lib/net/connector/netdisco/fleet.rb +68 -47
  32. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  33. data/lib/net/connector/netdisco/plan.rb +14 -9
  34. data/lib/net/connector/netdisco/planner.rb +18 -14
  35. data/lib/net/connector/netdisco/progress.rb +253 -0
  36. data/lib/net/connector/netdisco/report/files.rb +41 -0
  37. data/lib/net/connector/netdisco/report/text.rb +45 -0
  38. data/lib/net/connector/netdisco/report.rb +53 -31
  39. data/lib/net/connector/netdisco/result_store.rb +3 -1
  40. data/lib/net/connector/netdisco/rules.rb +10 -2
  41. data/lib/net/connector/netdisco/settings.rb +118 -68
  42. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  43. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  44. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  45. data/lib/net/connector/netdisco/worker.rb +8 -3
  46. data/lib/net/connector/netdisco.rb +10 -0
  47. data/lib/net/connector/storage/backup_lock.rb +1 -1
  48. data/lib/net/connector/storage/batch_directory.rb +29 -0
  49. data/lib/net/connector/storage.rb +1 -0
  50. data/lib/net/connector/vendor/h3c.rb +2 -1
  51. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  53. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  54. data/lib/net/connector/vendor/radware.rb +4 -3
  55. data/lib/net/connector/version.rb +1 -1
  56. data/lib/net/connector.rb +2 -2
  57. metadata +42 -111
  58. /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, &block) = @session.with_operation(name, &block)
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 confirmation_dialogues = profile.confirmation_interactions
184
+ def confirmation_interactions = profile.confirmation_interactions
196
185
 
197
186
  # 返回登录期间需要自动应答的附加对话。
198
- def login_dialogues = profile.login_interactions
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: login_dialogues,
239
+ login_interactions: login_interactions,
256
240
  command_interactions: [Interaction.new(pager_pattern, pager_response, capture: false),
257
- *confirmation_dialogues]
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
- finish_step = lambda do |command, response, context|
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
- private_result = output_sensitive || script.any?(&:sensitive?) || execution.sensitive?
280
- @session.with_sensitive_output(private_result) do
281
- result = finalize ? finalize.call(result) : 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
- check_block!(:commands, definition)
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
- check_block!(:prompts, definition)
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
- check_block!(:pager, definition)
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
- check_block!(:errors, definition)
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
- check_block!(:interactions, definition)
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
- check_block!(:privilege, definition)
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
- check_block!(:ssh, definition)
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 check_block!(name, definition)
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? && content?(step)
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 content?(step)
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
- unless strategy.device_reported_complete?(result)
93
- result.value! if result.failure?
94
- @device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
95
- code: :transfer_unconfirmed, server: target.host)
96
- raise DeviceError.new("device did not confirm TFTP backup completion",
97
- code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
98
- end
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
- unless changes.all? { |change| updated[@strategy.interface_key(change.interface)] == change.new_description }
184
- raise DeviceError.new("interface descriptions were not confirmed by readback",
185
- code: :description_unconfirmed, host: @device.host, phase: :verify)
186
- end
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, number|
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: number)
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
- @prompt = prompt
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 |original|
32
- @session.with_command_redaction(original) do
33
- @current_command = original
34
- command = @prepare_command.call(original, self)
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
- check_output_budget!(@last_query_command, completed: true)
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
- check_output_budget!(@last_query_command, completed: true)
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
- check_output_budget!(command, completed: false)
61
- prompt = @prompt&.call(command)
60
+ validate_send_budget!(command)
61
+ prompt = @prompt_resolver&.call(command)
62
62
  # 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
63
- check_output_budget!(command, completed: false) if @prompt
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
- @last_query_command = command
68
+ @last_executed_command = command
69
69
  @output_bytes += response.raw.bytesize
70
70
  # 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
71
71
  yield response if block_given?
72
- check_output_budget!(command, completed: true)
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 check_output_budget!(command, completed:)
90
- return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @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, &block)
61
- with_context(operation: name || @context[:operation] || :script, &block)
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, &block)
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, &block)
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
- @logger && LEVELS.fetch(level) >= [@logger.level, LEVELS.fetch(@configuration.log_level)].max
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
- @logger.public_send(level, Event.new(name, values, redactor: @redactor))
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