net-connector 0.4.2 → 0.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +42 -19
  4. data/docs/VERIFICATION.md +17 -7
  5. data/docs/architecture.md +96 -64
  6. data/lib/net/connector/device/base.rb +27 -107
  7. data/lib/net/connector/device/local_backup.rb +96 -0
  8. data/lib/net/connector/device/profile.rb +8 -3
  9. data/lib/net/connector/device/running_config/strategy.rb +1 -1
  10. data/lib/net/connector/device/running_config.rb +44 -5
  11. data/lib/net/connector/device/save_config.rb +21 -0
  12. data/lib/net/connector/device/tftp/file_upload.rb +53 -0
  13. data/lib/net/connector/{operations/tftp_receipt.rb → device/tftp/receipt.rb} +18 -20
  14. data/lib/net/connector/device/tftp/strategy.rb +67 -0
  15. data/lib/net/connector/device/tftp/target.rb +63 -0
  16. data/lib/net/connector/device/tftp.rb +117 -0
  17. data/lib/net/connector/device/topology/immediate_strategy.rb +40 -0
  18. data/lib/net/connector/device/topology/interface_description.rb +46 -0
  19. data/lib/net/connector/device/topology/interface_name.rb +58 -0
  20. data/lib/net/connector/device/topology/strategy.rb +75 -0
  21. data/lib/net/connector/device/topology.rb +277 -0
  22. data/lib/net/connector/engine/authentication.rb +1 -1
  23. data/lib/net/connector/engine/configuration.rb +1 -1
  24. data/lib/net/connector/engine/dialogue.rb +17 -17
  25. data/lib/net/connector/engine/error_metadata.rb +50 -0
  26. data/lib/net/connector/engine/errors.rb +15 -1
  27. data/lib/net/connector/engine/execution.rb +15 -8
  28. data/lib/net/connector/engine/log/event.rb +66 -0
  29. data/lib/net/connector/engine/log/formatter.rb +16 -0
  30. data/lib/net/connector/engine/log/messages.rb +52 -0
  31. data/lib/net/connector/engine/log/stream.rb +56 -0
  32. data/lib/net/connector/engine/log.rb +105 -84
  33. data/lib/net/connector/engine/session.rb +83 -42
  34. data/lib/net/connector/engine/terminal_text.rb +33 -0
  35. data/lib/net/connector/engine/transport.rb +2 -2
  36. data/lib/net/connector/netdisco/batch.rb +9 -45
  37. data/lib/net/connector/netdisco/cli.rb +7 -9
  38. data/lib/net/connector/netdisco/client.rb +2 -2
  39. data/lib/net/connector/netdisco/device.rb +4 -5
  40. data/lib/net/connector/netdisco/diagnostic.rb +9 -39
  41. data/lib/net/connector/netdisco/fleet.rb +26 -39
  42. data/lib/net/connector/netdisco/report.rb +37 -16
  43. data/lib/net/connector/netdisco/result_store.rb +2 -2
  44. data/lib/net/connector/netdisco/settings.rb +0 -6
  45. data/lib/net/connector/{operations → storage}/backup_lock.rb +2 -2
  46. data/lib/net/connector/{operations → storage}/private_file.rb +2 -7
  47. data/lib/net/connector/{operations → storage}/safe_file.rb +1 -1
  48. data/lib/net/connector/{operations → storage}/saved_config.rb +10 -35
  49. data/lib/net/connector/storage.rb +15 -0
  50. data/lib/net/connector/textfsm.rb +72 -0
  51. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +3 -3
  52. data/lib/net/connector/vendor/cisco_ios/topology.rb +5 -5
  53. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +3 -3
  54. data/lib/net/connector/vendor/h3c/tftp_backup.rb +4 -4
  55. data/lib/net/connector/vendor/h3c/topology.rb +5 -5
  56. data/lib/net/connector/vendor/h3c.rb +3 -3
  57. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  58. data/lib/net/connector/vendor/hillstone/topology.rb +5 -5
  59. data/lib/net/connector/vendor/huawei/tftp_backup.rb +3 -3
  60. data/lib/net/connector/vendor/palo_alto/running_config.rb +1 -1
  61. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  62. data/lib/net/connector/vendor/palo_alto/topology.rb +3 -6
  63. data/lib/net/connector/vendor/radware/tftp_backup.rb +3 -3
  64. data/lib/net/connector/vendor/radware/topology.rb +2 -2
  65. data/lib/net/connector/version.rb +1 -1
  66. data/lib/net/connector.rb +1 -6
  67. metadata +25 -39
  68. data/lib/net/connector/device/interface_description.rb +0 -44
  69. data/lib/net/connector/device/interface_name.rb +0 -56
  70. data/lib/net/connector/engine/base.rb +0 -4
  71. data/lib/net/connector/engine/log_messages.rb +0 -47
  72. data/lib/net/connector/engine/profile.rb +0 -4
  73. data/lib/net/connector/engine.rb +0 -4
  74. data/lib/net/connector/operations/local_backup.rb +0 -89
  75. data/lib/net/connector/operations/parse_output.rb +0 -77
  76. data/lib/net/connector/operations/running_config/cisco.rb +0 -14
  77. data/lib/net/connector/operations/running_config/hillstone.rb +0 -12
  78. data/lib/net/connector/operations/running_config/palo_alto.rb +0 -12
  79. data/lib/net/connector/operations/running_config/strategy.rb +0 -3
  80. data/lib/net/connector/operations/running_config.rb +0 -16
  81. data/lib/net/connector/operations/saved_config/legacy_index.rb +0 -109
  82. data/lib/net/connector/operations/tftp/cisco_ios.rb +0 -11
  83. data/lib/net/connector/operations/tftp/cisco_nxos.rb +0 -11
  84. data/lib/net/connector/operations/tftp/file_upload.rb +0 -55
  85. data/lib/net/connector/operations/tftp/h3c.rb +0 -11
  86. data/lib/net/connector/operations/tftp/hillstone.rb +0 -11
  87. data/lib/net/connector/operations/tftp/huawei.rb +0 -11
  88. data/lib/net/connector/operations/tftp/palo_alto.rb +0 -11
  89. data/lib/net/connector/operations/tftp/radware.rb +0 -11
  90. data/lib/net/connector/operations/tftp/strategy.rb +0 -75
  91. data/lib/net/connector/operations/tftp_backup.rb +0 -183
  92. data/lib/net/connector/operations/topology/cisco.rb +0 -11
  93. data/lib/net/connector/operations/topology/h3c.rb +0 -11
  94. data/lib/net/connector/operations/topology/hillstone.rb +0 -11
  95. data/lib/net/connector/operations/topology/immediate_strategy.rb +0 -45
  96. data/lib/net/connector/operations/topology/palo_alto.rb +0 -11
  97. data/lib/net/connector/operations/topology/radware.rb +0 -11
  98. data/lib/net/connector/operations/topology/strategy.rb +0 -87
  99. data/lib/net/connector/operations/topology.rb +0 -261
  100. data/lib/net/connector/operations.rb +0 -26
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "expect/redactor"
4
+ require_relative "error_metadata"
4
5
 
5
6
  module Net
6
7
  module Connector
@@ -22,6 +23,19 @@ module Net
22
23
  @underlying = underlying
23
24
  end
24
25
 
26
+ # 内置错误保留业务回执,但不复制可能含秘密的原生 cause 和调用栈。
27
+ # 自定义子类重新构造,避免其 message 等方法继续读取未脱敏的私有字段。
28
+ def with_diagnostics(message:, **context)
29
+ context = { code: code, host: host, phase: phase, command: command, output: output,
30
+ source: source, line: line, underlying: underlying }.merge(context)
31
+ return self.class.new(message, **context) unless ErrorMetadata.known_type?(self.class.name)
32
+
33
+ copy = self.class.allocate
34
+ instance_variables.each { |name| copy.instance_variable_set(name, instance_variable_get(name)) }
35
+ Error.instance_method(:initialize).bind_call(copy, message, **context)
36
+ copy
37
+ end
38
+
25
39
  # 返回不展开命令和输出的错误摘要。
26
40
  def inspect = "#<#{self.class} code=#{code.inspect} phase=#{phase.inspect} host=#{host.inspect}>"
27
41
  end
@@ -101,7 +115,7 @@ module Net
101
115
  end
102
116
 
103
117
  # 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
104
- def scope(reuse: false)
118
+ def with_scope(reuse: false)
105
119
  return yield if reuse && @scoped
106
120
 
107
121
  previous = @secrets
@@ -20,12 +20,16 @@ module Net
20
20
  @steps = []
21
21
  @output_bytes = 0
22
22
  @output_limit = session.configuration.max_script_output_bytes
23
+ @sensitive = false
23
24
  end
24
25
 
26
+ # 汇总实际执行的命令及交互,包括批次准备和厂商追加查询。
27
+ def sensitive? = @sensitive
28
+
25
29
  # 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
26
- def execute(script)
30
+ def execute_script(script)
27
31
  script.each do |original|
28
- @session.command_scope(original) do
32
+ @session.with_command_redaction(original) do
29
33
  @current_command = original
30
34
  command = @prepare_command.call(original, self)
31
35
  next unless command
@@ -33,7 +37,7 @@ module Net
33
37
  @current_command = command
34
38
  started = Expect.monotonic
35
39
  step = nil
36
- response = query(command) do |received|
40
+ response = execute_command(command) do |received|
37
41
  step = CommandResult.new(command: command, output: received.output, prompt: received.prompt,
38
42
  duration: Expect.monotonic - started)
39
43
  steps << step
@@ -51,13 +55,16 @@ module Net
51
55
  end
52
56
 
53
57
  # 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
54
- def query(command)
58
+ def execute_command(command)
55
59
  command = Command.new(command) unless command.is_a?(Command)
56
60
  check_output_budget!(command, completed: false)
57
61
  prompt = @prompt&.call(command)
58
62
  # 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
59
63
  check_output_budget!(command, completed: false) if @prompt
60
- response = @session.exchange(command, timeout: @command_timeout, prompt: prompt)
64
+ response = @session.execute_command(command, timeout: @command_timeout, prompt: prompt) do
65
+ # 必须在 Session 恢复命令词表前读取;最终处理只能继承敏感性,不长期保留秘密。
66
+ @sensitive ||= @session.redactor.sensitive?
67
+ end
61
68
  @last_query_command = command
62
69
  @output_bytes += response.raw.bytesize
63
70
  # 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
@@ -72,8 +79,8 @@ module Net
72
79
  end
73
80
 
74
81
  # 根据当前命令建立脚本阶段错误。
75
- def failure(message)
76
- @session.error(ScriptError, message, phase: :script, command: @current_command)
82
+ def build_error(message)
83
+ @session.build_error(ScriptError, message, phase: :script, command: @current_command)
77
84
  end
78
85
 
79
86
  private
@@ -82,7 +89,7 @@ module Net
82
89
  def check_output_budget!(command, completed:)
83
90
  return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @output_limit)
84
91
 
85
- raise @session.error(ScriptOutputLimitExceeded,
92
+ raise @session.build_error(ScriptOutputLimitExceeded,
86
93
  "script output reached max_script_output_bytes; commands already sent may have executed",
87
94
  phase: :script, command: command), cause: nil
88
95
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../terminal_renderer"
4
+ require_relative "messages"
5
+
6
+ module Net
7
+ module Connector
8
+ class Log
9
+ # 在敏感作用域结束前冻结安全副本;应用 formatter 可直接读取字段。
10
+ class Event
11
+ attr_reader :name, :fields
12
+
13
+ def initialize(name, fields, redactor:)
14
+ @name = safe_text(name.is_a?(String) || name.is_a?(Symbol) ? name.to_s : "custom", redactor).freeze
15
+ @fields = fields.each_with_object({}) do |(key, value), result|
16
+ next unless (key.is_a?(Symbol) || key.is_a?(String)) && key.to_s.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
17
+ next unless redactor.call(key.to_s) == key.to_s
18
+ next if key.to_s == "event"
19
+
20
+ result[key.to_sym] = safe_value(value, redactor)
21
+ end.freeze
22
+ freeze
23
+ end
24
+
25
+ # 与文本呈现共用同一安全记录,调用方不能借 formatter 取得原始秘密。
26
+ def to_h = { event: name, **fields }.freeze
27
+
28
+ def to_s
29
+ details = to_h.reject { |key, _| key == :host }.map { |key, value| "#{key}=#{token(value)}" }.join(" ")
30
+ "[host=#{token(fields[:host])}] #{Messages.format(name, fields)} | #{details}"
31
+ end
32
+
33
+ # Ruby Logger 的默认 formatter 对非 String 消息调用 inspect。
34
+ alias inspect to_s
35
+
36
+ private
37
+
38
+ def safe_value(value, redactor)
39
+ case value
40
+ when String, Symbol
41
+ safe_text(value.to_s, redactor).freeze
42
+ when Float
43
+ value.finite? && redactor.call(value.to_s) == value.to_s ? value : "[REDACTED]"
44
+ when Integer, TrueClass, FalseClass, NilClass
45
+ redactor.call(value.to_s) == value.to_s ? value : "[REDACTED]"
46
+ else
47
+ # 不调用任意对象的 inspect/to_s,避免展开异常、配置对象或容器中的秘密。
48
+ "[REDACTED]"
49
+ end
50
+ end
51
+
52
+ def safe_text(text, redactor)
53
+ rendered = TerminalRenderer.render(redactor.call(text))
54
+ redactor.call(rendered).force_encoding(Encoding::UTF_8).scrub.gsub(/[[:cntrl:]]+/, " ").strip
55
+ end
56
+
57
+ def token(value)
58
+ return "nil" if value.nil?
59
+
60
+ text = value.to_s
61
+ text.match?(/\A[A-Za-z0-9_.:\/-]+\z/) ? text : text.inspect
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Net
6
+ module Connector
7
+ class Log
8
+ # 只安装在连接器自有的 Logger 上;注入的日志器保留应用自己的格式。
9
+ class Formatter < ::Logger::Formatter
10
+ def call(severity, time, _program, message)
11
+ "[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S.%L %:z")}] #{severity} #{message}\n"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ class Log
6
+ # 独立于日志目标的人类可读事件文案。
7
+ module Messages
8
+ module_function
9
+
10
+ # 将结构化会话事件转换为便于人工阅读的中文日志。
11
+ def format(name, fields)
12
+ case name
13
+ when "connect"
14
+ "开始连接 #{fields[:host]}(#{fields[:protocol].to_s.upcase},账号 #{fields[:username]})"
15
+ when "connect_failed"
16
+ "连接失败:#{fields[:message]}(#{fields[:error]})"
17
+ when "login_start"
18
+ "等待设备登录提示"
19
+ when "login_output"
20
+ "登录过程回显(已脱敏):"
21
+ when "login_complete"
22
+ fields[:status] == "ok" ? "登录成功,设备提示符:#{fields[:prompt]}" :
23
+ "登录失败:#{fields[:message]}(#{fields[:error]})"
24
+ when "command_start"
25
+ "下发命令:#{fields[:text]}"
26
+ when "device_output"
27
+ "设备回显:"
28
+ when "command_complete"
29
+ fields[:status] == "response_received" ? "命令回显结束,已收到设备提示符" :
30
+ "命令执行失败:#{fields[:message]}(#{fields[:error]})"
31
+ when "operation_start"
32
+ "开始处理设备脚本"
33
+ when "operation_complete"
34
+ fields[:status] == "completed" ? "设备脚本处理完成" :
35
+ "设备脚本处理失败:#{fields[:message]}(#{fields[:error]})"
36
+ when "tftp_backup"
37
+ case fields[:status]
38
+ when "reported_uploaded"
39
+ "TFTP 备份:设备报告上传成功,目标 #{fields[:server]}/#{fields[:path]};服务器文件尚未核验"
40
+ when "transfer_failed"
41
+ "TFTP 备份失败:设备报告传输失败,服务器 #{fields[:server]}"
42
+ else
43
+ "TFTP 备份未确认:设备未报告上传成功,服务器 #{fields[:server]}"
44
+ end
45
+ else
46
+ name.to_s
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ class Log
6
+ # 每个目标独占 expect-pty 的过滤流;此层只适配 IO,不实现匹配算法。
7
+ class RedactingWriter
8
+ def initialize(target, redactor)
9
+ @target = target
10
+ @redactor = redactor
11
+ @filter = redactor.stream
12
+ end
13
+
14
+ def write(bytes)
15
+ @filter.patterns = @redactor.patterns
16
+ @target.write(@filter.append(bytes.b))
17
+ bytes.bytesize
18
+ end
19
+
20
+ def flush = @target.flush
21
+
22
+ def finish
23
+ @filter.patterns = @redactor.patterns
24
+ # 配置正文由 Session 的敏感范围隔离;保留完整词匹配的收尾契约。
25
+ @target.write(@filter.finish(partial: false))
26
+ flush
27
+ end
28
+ end
29
+
30
+ # 已渲染、已脱敏的字节按行变成事件,半行在上下文切换前收尾。
31
+ class Transcript
32
+ def initialize(&on_line)
33
+ @on_line = on_line
34
+ @buffer = +"".b
35
+ end
36
+
37
+ def write(bytes)
38
+ @buffer << bytes
39
+ while (ending = @buffer.index("\n"))
40
+ @on_line.call(@buffer.slice!(0, ending + 1).chomp)
41
+ end
42
+ bytes.bytesize
43
+ end
44
+
45
+ def flush; end
46
+
47
+ def finish
48
+ @on_line.call(@buffer) unless @buffer.empty?
49
+ @buffer = +"".b
50
+ end
51
+ end
52
+
53
+ private_constant :RedactingWriter, :Transcript
54
+ end
55
+ end
56
+ end
@@ -2,9 +2,13 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "logger"
5
+ require "securerandom"
5
6
  require_relative "terminal_renderer"
6
- require_relative "log_messages"
7
+ require_relative "log/event"
8
+ require_relative "log/formatter"
9
+ require_relative "log/stream"
7
10
  require_relative "errors"
11
+ require_relative "error_metadata"
8
12
 
9
13
  module Net
10
14
  module Connector
@@ -12,39 +16,56 @@ module Net
12
16
  class Log
13
17
  LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO,
14
18
  warn: ::Logger::WARN, error: ::Logger::ERROR }.freeze
19
+ CONTEXT_FIELDS = %w[host session_id operation command_id text source line].freeze
20
+ private_constant :CONTEXT_FIELDS
15
21
 
16
22
  # 保存日志配置和敏感信息脱敏器。
17
23
  def initialize(configuration, redactor:)
18
24
  @configuration = configuration
19
25
  @redactor = redactor
26
+ @context = {}.freeze
20
27
  end
21
28
 
22
29
  # 为会话打开日志文件或应用日志器。
23
30
  def open(transport)
24
31
  @transport = transport
32
+ @session_id = SecureRandom.hex(8).freeze
33
+ @command_sequence = 0
25
34
  @io = open_file(@configuration.log_file) if @configuration.log_file
26
- @output = RedactingWriter.new(@io, @redactor) if @io
27
35
  if @configuration.log_format == :raw
28
- @writer = @output
36
+ @writer = @output = RedactingWriter.new(@io, @redactor) if @io
29
37
  elsif @io || @configuration.logger
30
- base = @configuration.logger || ::Logger.new(@io)
31
- unless @configuration.logger
32
- base.formatter = proc do |severity, time, _program, message|
33
- "[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S %:z")}] #{severity} #{message}\n"
34
- end
35
- end
36
- # 前缀直接写入消息,不克隆或修改调用方注入的日志器。
37
- @logger = base
38
- @event_level = [base.level, LEVELS.fetch(@configuration.log_level)].max
39
- @tag = "[host=#{@configuration.host}] "
40
- if @io && @configuration.log_level == :debug
41
- @writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
42
- end
38
+ @logger = @configuration.logger || ::Logger.new(@io, formatter: Formatter.new)
39
+ open_transcript if @io && @configuration.log_level == :debug
43
40
  end
44
- @line_open = false
45
41
  rescue => error
46
42
  close_preserving_error
47
- raise failure("unable to open session log", error), cause: nil
43
+ raise build_error("unable to open session log", error), cause: nil
44
+ end
45
+
46
+ # 上下文只在持有会话锁期间使用;收尾必须先于恢复父上下文。
47
+ def with_context(**fields)
48
+ previous = @context
49
+ finish_output
50
+ @context = @context.merge(fields).freeze
51
+ yield
52
+ ensure
53
+ begin
54
+ finish_output
55
+ ensure
56
+ @context = previous
57
+ end
58
+ end
59
+
60
+ def with_operation_context(name, &block)
61
+ with_context(operation: name || @context[:operation] || :script, &block)
62
+ end
63
+
64
+ def with_command_context(command, &block)
65
+ @command_sequence += 1
66
+ with_context(command_id: @command_sequence, phase: :command,
67
+ text: command.sensitive? ? "[REDACTED]" : command.text,
68
+ source: command.source, line: command.line, &block)
48
69
  end
49
70
 
50
71
  # 将终端回显接入当前会话日志。
@@ -56,60 +77,75 @@ module Net
56
77
  end
57
78
 
58
79
  # 判断当前日志级别是否需要完整设备回显。
59
- def detailed? = @configuration.log_level == :debug && !!(@writer || @logger)
80
+ def debug? = @configuration.log_level == :debug && (!!@writer || enabled?(:debug))
60
81
 
61
82
  # 按日志级别写入已脱敏的业务事件。
62
- def event(name, level: :info, **fields)
83
+ def log_event(name, level: :info, **fields)
63
84
  return unless @logger
64
85
 
65
- values = fields.transform_values do |value|
66
- value.is_a?(String) ? @redactor.call(value).scrub.gsub(/[[:cntrl:]]+/, " ").strip : value
67
- end
68
- safe_name = @redactor.call(name.to_s).scrub.gsub(/[[:cntrl:]]+/, " ").strip
69
- finish_line
70
- @logger.public_send(level, "#{@tag}#{LogMessages.format(safe_name, values)}") if LEVELS.fetch(level) >= @event_level
86
+ finish_output
87
+ write_event(name, level: level, **fields)
71
88
  rescue Error
72
89
  raise
73
90
  rescue => error
74
- raise failure("unable to write session event", error), cause: nil
91
+ raise build_error("unable to write session event", error), cause: nil
75
92
  end
76
93
 
77
- # 登录完成并收集动态口令后,再记录已脱敏的认证回显。
78
- def authentication_output(bytes)
79
- return unless detailed?
94
+ # 异常正文由会话先脱敏;类型、错误码和阶段只允许共享词表中的值进入日志。
95
+ def log_failure(name, failure, phase: nil, **fields)
96
+ log_event(name, level: :error, **fields,
97
+ error: ErrorMetadata.type(failure.class.name) || "StandardError",
98
+ code: ErrorMetadata.code(failure.code),
99
+ phase: ErrorMetadata.phase(failure.phase) || ErrorMetadata.phase(phase),
100
+ message: failure.message)
101
+ end
80
102
 
81
- event("login_output", level: :debug)
82
- if @writer
83
- write(@redactor.call(bytes))
84
- flush
103
+ # 用户钩子可能把未登记的配置片段放进事件名、字段名或值;敏感范围统一隐藏。
104
+ def log_custom_event(name, level: :info, **fields)
105
+ if @redactor.sensitive?
106
+ log_event("custom", level: level, details: "[REDACTED]")
85
107
  else
86
- response_output(bytes)
108
+ log_event(name, level: level, **fields)
87
109
  end
88
110
  end
89
111
 
90
- # 应用注入的日志器没有终端写入器,改由日志器记录完整回显。
91
- def response_output(bytes)
92
- return unless detailed? && !@writer && !@redactor.output_sensitive?
112
+ # 登录完成并收集动态口令后,再记录已脱敏的认证回显。
113
+ def log_authentication_output(bytes)
114
+ return unless debug?
115
+
116
+ with_context(phase: :login) do
117
+ log_event("login_output", level: :debug)
118
+ if @writer
119
+ write(@redactor.call(bytes))
120
+ flush
121
+ else
122
+ log_response_output(bytes)
123
+ end
124
+ end
125
+ end
126
+
127
+ # 注入日志器与文件回显共用事件格式;两次脱敏覆盖终端控制符拼接。
128
+ def log_response_output(bytes)
129
+ return unless debug? && !@writer && !@redactor.output_sensitive?
93
130
 
94
131
  @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
95
- @logger.debug("#{@tag} #{line.chomp}") if ::Logger::DEBUG >= @event_level
132
+ write_event("device_output", level: :debug, output: line.chomp)
96
133
  end
97
134
  rescue Error
98
135
  raise
99
136
  rescue => error
100
- raise failure("unable to write device output", error), cause: nil
137
+ raise build_error("unable to write device output", error), cause: nil
101
138
  end
102
139
 
103
140
  # 向终端日志写入已处理的回显字节。
104
141
  def write(bytes)
105
- return unless @writer
142
+ return unless @writer && !@redactor.output_sensitive?
106
143
 
107
144
  @writer.write(bytes)
108
- @line_open = !bytes.end_with?("\n") unless bytes.empty?
109
145
  rescue Error
110
146
  raise
111
147
  rescue => error
112
- raise failure("unable to write session log", error), cause: nil
148
+ raise build_error("unable to write session log", error), cause: nil
113
149
  end
114
150
 
115
151
  # 刷新日志缓冲区。
@@ -119,7 +155,7 @@ module Net
119
155
  rescue Error
120
156
  raise
121
157
  rescue => error
122
- raise failure("unable to flush session log", error), cause: nil
158
+ raise build_error("unable to flush session log", error), cause: nil
123
159
  end
124
160
 
125
161
  # 执行代码块期间暂停自动记录传输回显。
@@ -140,63 +176,48 @@ module Net
140
176
  begin
141
177
  @io&.close
142
178
  ensure
143
- @writer = @output = @logger = @io = @transport = @tag = @event_level = nil
179
+ @writer = @output = @transcript = @logger = @io = @transport = @session_id = nil
144
180
  @attached = false
145
181
  end
146
182
  end
147
183
  rescue Error
148
184
  raise
149
185
  rescue => error
150
- raise failure("unable to close session log", error), cause: nil
186
+ raise build_error("unable to close session log", error), cause: nil
151
187
  end
152
188
 
153
189
  private
154
190
 
155
- # 结束未换行的回显,保持事件独占一行。
156
- def finish_line
157
- return unless @writer
191
+ # 每次读取调用方的当前阈值,不缓存级别、不改写共享 Logger。
192
+ def enabled?(level)
193
+ @logger && LEVELS.fetch(level) >= [@logger.level, LEVELS.fetch(@configuration.log_level)].max
194
+ end
158
195
 
159
- finish_output
160
- @io.write("\n") if @line_open
161
- @line_open = false
196
+ def write_event(name, level:, **fields)
197
+ return unless enabled?(level)
198
+
199
+ values = @context.compact.merge(fields.reject { |key, _| CONTEXT_FIELDS.include?(key.to_s) })
200
+ values = values.merge(host: @configuration.host, session_id: @session_id)
201
+ @logger.public_send(level, Event.new(name, values, redactor: @redactor))
202
+ end
203
+
204
+ def open_transcript
205
+ @transcript = Transcript.new { |line| write_event("device_output", level: :debug, output: line) }
206
+ @output = RedactingWriter.new(@transcript, @redactor)
207
+ @writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
162
208
  end
163
209
 
164
- # 先完成终端渲染,再写出脱敏流的最后几个字节。
210
+ # 先渲染,再结束过滤流,最后发出半行事件;顺序不能反转。
165
211
  def finish_output
166
212
  @writer.finish if @writer.respond_to?(:finish)
167
213
  @output&.finish unless @output.equal?(@writer)
214
+ @transcript&.finish
215
+ rescue Error
216
+ raise
217
+ rescue => error
218
+ raise build_error("unable to finish session output", error), cause: nil
168
219
  end
169
220
 
170
- # 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
171
- class RedactingWriter
172
- # 保存目标与作用域;每个日志目标独占 expect-pty 的过滤流。
173
- def initialize(target, redactor)
174
- @target = target
175
- @redactor = redactor
176
- @filter = redactor.stream
177
- end
178
-
179
- # 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
180
- def write(bytes)
181
- @filter.patterns = @redactor.patterns
182
- @target.write(@filter.append(bytes.b))
183
- bytes.bytesize
184
- end
185
-
186
- # 刷新目标流,仍不提前写出待判断的尾部。
187
- def flush = @target.flush
188
-
189
- # 在日志结束时脱敏并写出剩余尾部。
190
- def finish
191
- @filter.patterns = @redactor.patterns
192
- # 保留旧日志的完整词匹配契约;配置正文由 Session 的敏感范围直接隔离。
193
- @target.write(@filter.finish(partial: false))
194
- flush
195
- end
196
- end
197
-
198
- private_constant :RedactingWriter
199
-
200
221
  # 以私有权限打开设备日志文件。
201
222
  def open_file(path)
202
223
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
@@ -224,7 +245,7 @@ module Net
224
245
  end
225
246
 
226
247
  # 将日志异常包装成统一错误并保留脱敏原因。
227
- def failure(message, error)
248
+ def build_error(message, error)
228
249
  LogError.new(message, phase: :logging,
229
250
  underlying: UnderlyingError.new(error, @redactor, sensitive: @redactor.sensitive?))
230
251
  end