net-connector 0.4.0 → 0.4.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -68
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +140 -95
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -5
  7. data/docs/architecture.md +321 -248
  8. data/lib/net/connector/device/base.rb +12 -4
  9. data/lib/net/connector/device/interface_description.rb +3 -0
  10. data/lib/net/connector/device/profile/builder.rb +236 -0
  11. data/lib/net/connector/device/profile.rb +2 -227
  12. data/lib/net/connector/device/running_config/strategy.rb +5 -0
  13. data/lib/net/connector/device/running_config.rb +7 -0
  14. data/lib/net/connector/engine/command.rb +14 -2
  15. data/lib/net/connector/engine/configuration.rb +10 -7
  16. data/lib/net/connector/engine/dialogue.rb +53 -28
  17. data/lib/net/connector/engine/errors.rb +41 -54
  18. data/lib/net/connector/engine/execution.rb +32 -5
  19. data/lib/net/connector/engine/log.rb +23 -17
  20. data/lib/net/connector/engine/session.rb +39 -7
  21. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  22. data/lib/net/connector/netdisco/batch.rb +31 -3
  23. data/lib/net/connector/netdisco/cli.rb +92 -50
  24. data/lib/net/connector/netdisco/client.rb +206 -73
  25. data/lib/net/connector/netdisco/config_file.rb +26 -4
  26. data/lib/net/connector/netdisco/device.rb +4 -8
  27. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  28. data/lib/net/connector/netdisco/fleet.rb +105 -108
  29. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  30. data/lib/net/connector/netdisco/plan.rb +77 -0
  31. data/lib/net/connector/netdisco/planner.rb +12 -23
  32. data/lib/net/connector/netdisco/report.rb +94 -0
  33. data/lib/net/connector/netdisco/rules.rb +28 -5
  34. data/lib/net/connector/netdisco/settings.rb +194 -85
  35. data/lib/net/connector/netdisco/worker.rb +43 -27
  36. data/lib/net/connector/operations/backup_lock.rb +116 -0
  37. data/lib/net/connector/operations/local_backup.rb +49 -9
  38. data/lib/net/connector/operations/parse_output.rb +20 -3
  39. data/lib/net/connector/operations/private_file.rb +94 -5
  40. data/lib/net/connector/operations/safe_file.rb +62 -0
  41. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  42. data/lib/net/connector/operations/saved_config.rb +40 -10
  43. data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
  44. data/lib/net/connector/operations/tftp/strategy.rb +9 -11
  45. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  46. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  47. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  48. data/lib/net/connector/operations/topology/strategy.rb +34 -0
  49. data/lib/net/connector/operations/topology.rb +98 -29
  50. data/lib/net/connector/operations.rb +6 -0
  51. data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
  52. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  53. data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
  54. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
  55. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  56. data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
  57. data/lib/net/connector/vendor/h3c/topology.rb +17 -3
  58. data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
  59. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
  60. data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
  61. data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
  62. data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
  63. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
  64. data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
  65. data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
  66. data/lib/net/connector/vendor/radware/topology.rb +3 -0
  67. data/lib/net/connector/version.rb +1 -1
  68. metadata +60 -28
@@ -8,13 +8,13 @@ module Net
8
8
  # 显式且不可变的连接设置;环境变量读取由调用库的可执行程序负责。
9
9
  class Configuration
10
10
  attr_reader :host, :username, :password, :enable_password, :protocol, :port, :login_timeout,
11
- :command_timeout, :write_timeout, :max_output_bytes, :log_file, :log_format, :log_level,
11
+ :command_timeout, :write_timeout, :max_output_bytes, :max_script_output_bytes, :log_file, :log_format, :log_level,
12
12
  :known_hosts, :host_key_policy, :challenges, :logger
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
- max_output_bytes: 32 * 1024 * 1024, log_file: nil, logger: nil,
17
+ max_output_bytes: 32 * 1024 * 1024, max_script_output_bytes: nil, log_file: nil, logger: nil,
18
18
  log_format: :text, log_level: :info,
19
19
  known_hosts: nil, host_key_policy: :strict, telnet_fallback: false, legacy_ssh: false,
20
20
  challenges: [])
@@ -32,11 +32,8 @@ module Net
32
32
  @login_timeout = timeout_seconds(login_timeout, :login_timeout)
33
33
  @command_timeout = command_timeout.nil? ? nil : timeout_seconds(command_timeout, :command_timeout)
34
34
  @write_timeout = timeout_seconds(write_timeout, :write_timeout)
35
- unless max_output_bytes.is_a?(Integer) && max_output_bytes.positive?
36
- raise ArgumentError, "max_output_bytes must be a positive Integer"
37
- end
38
-
39
- @max_output_bytes = max_output_bytes
35
+ @max_output_bytes = positive_integer(max_output_bytes, :max_output_bytes)
36
+ @max_script_output_bytes = positive_integer(max_script_output_bytes, :max_script_output_bytes) unless max_script_output_bytes.nil?
40
37
  @log_file = absolute_path(log_file)
41
38
  if logger && (!logger.respond_to?(:debug) || !logger.respond_to?(:info) ||
42
39
  !logger.respond_to?(:warn) || !logger.respond_to?(:error) ||
@@ -86,6 +83,12 @@ module Net
86
83
 
87
84
  private
88
85
 
86
+ def positive_integer(value, name)
87
+ raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
88
+
89
+ value
90
+ end
91
+
89
92
  # 将可选值校验为字符串副本。
90
93
  def frozen_string(value)
91
94
  return nil if value.nil?
@@ -86,6 +86,7 @@ module Net
86
86
 
87
87
  private
88
88
 
89
+ # 只截取命中错误模式的当前行,避免把整段设备配置写进诊断信息。
89
90
  def diagnostic_line(output)
90
91
  match = command_errors.lazy.filter_map { |pattern| pattern.match(output) }.first
91
92
  return unless match
@@ -129,47 +130,24 @@ module Net
129
130
  counts = Hash.new(0)
130
131
  bytes = 0
131
132
  loop do
132
- event = @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
133
+ event = read_event(patterns, deadline)
133
134
  raw << event.before unless event.before.empty?
134
135
  output << event.before unless event.before.empty?
135
136
  bytes += event.before.bytesize + event.match.bytesize
136
- if bytes > @session.configuration.max_output_bytes
137
- raise @session.error(OutputLimitExceeded, "device output exceeded max_output_bytes",
138
- phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
139
- end
140
- raise read_error(event, phase, command, raw.join), cause: nil unless event.matched?
137
+ validate_event(event, bytes, raw, phase, command)
141
138
 
142
139
  if event.index == patterns.size
143
140
  raw << event.match
144
141
  output << event.match
145
142
  elsif event.index == patterns.size - 1
146
- if event.match.empty?
147
- raise @session.error(PromptError, "prompt pattern did not consume any output",
148
- phase: phase, command: command, output: raw.join), cause: nil
149
- end
150
-
151
- raw << event.match
152
- output << event.match
153
- raw_text = raw.join
154
- return Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: event.match)
143
+ return finish_response(event.match, raw, output, phase, command)
155
144
  elsif event.index < failures.size
156
- klass, code = failures.values.fetch(event.index)
157
- raise @session.error(klass, "device connection failed (#{code})", phase: phase,
158
- code: code, command: command), cause: nil
145
+ raise connection_error(failures.values.fetch(event.index), phase, command), cause: nil
159
146
  else
160
147
  reply = interactions.fetch(event.index - failures.size)
161
148
  raw << event.match
162
149
  output << event.match if reply.capture?
163
- counts[reply] += 1
164
- if event.match.empty? || (reply.limit && counts[reply] > reply.limit)
165
- raise interaction_error("response rejected or repeated", phase, command), cause: nil
166
- end
167
-
168
- value = interaction_response(reply, event.match, phase, command)
169
- raise interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
170
-
171
- @session.redactor.remember(value.chomp) if reply.sensitive?
172
- @session.write(value, deadline: deadline, phase: phase, command: command)
150
+ respond(reply, event.match, counts, deadline, phase, command)
173
151
  end
174
152
  next if Expect.monotonic < deadline
175
153
 
@@ -179,6 +157,53 @@ module Net
179
157
 
180
158
  private
181
159
 
160
+ # 分页和流式输出只消耗剩余时间,不为下一次读取重新计时。
161
+ def read_event(patterns, deadline)
162
+ @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
163
+ end
164
+
165
+ # 连接失败模式携带稳定错误码,供连接级恢复规则选择处理方式。
166
+ def connection_error(failure, phase, command)
167
+ klass, code = failure
168
+ @session.error(klass, "device connection failed (#{code})", phase: phase, code: code, command: command)
169
+ end
170
+
171
+ # 先限制原始字节数,再处理传输失败,避免错误输出绕过总量限制。
172
+ def validate_event(event, bytes, raw, phase, command)
173
+ if bytes > @session.configuration.max_output_bytes
174
+ raise @session.error(OutputLimitExceeded, "device output exceeded max_output_bytes",
175
+ phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
176
+ end
177
+ raise read_error(event, phase, command, raw.join), cause: nil unless event.matched?
178
+ end
179
+
180
+ # 完整提示符必须消费字节;交互标记可从业务输出排除,但原始响应始终保留。
181
+ def finish_response(prompt, raw, output, phase, command)
182
+ if prompt.empty?
183
+ raise @session.error(PromptError, "prompt pattern did not consume any output",
184
+ phase: phase, command: command, output: raw.join), cause: nil
185
+ end
186
+
187
+ raw << prompt
188
+ output << prompt
189
+ raw_text = raw.join
190
+ Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: prompt)
191
+ end
192
+
193
+ # 重复提示和缺失应答必须在写入前失败;所有应答共用读取操作的截止时间。
194
+ def respond(reply, prompt, counts, deadline, phase, command)
195
+ counts[reply] += 1
196
+ if prompt.empty? || (reply.limit && counts[reply] > reply.limit)
197
+ raise interaction_error("response rejected or repeated", phase, command), cause: nil
198
+ end
199
+
200
+ value = interaction_response(reply, prompt, phase, command)
201
+ raise interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
202
+
203
+ @session.redactor.remember(value.chomp) if reply.sensitive?
204
+ @session.write(value, deadline: deadline, phase: phase, command: command)
205
+ end
206
+
182
207
  # 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
183
208
  # 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
184
209
  def interaction_response(reply, prompt, phase, command)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "expect/redactor"
4
+
3
5
  module Net
4
6
  module Connector
5
- # 操作错误携带已脱敏上下文;原始配置只存在于明确的结果或日志中。
7
+ # 操作错误携带已脱敏上下文;敏感配置正文只存在于明确返回的业务结果中。
6
8
  class Error < StandardError
7
9
  attr_reader :code, :host, :phase, :command, :output, :source, :line, :underlying
8
10
 
@@ -48,6 +50,8 @@ module Net
48
50
 
49
51
  class OutputLimitExceeded < Error; end
50
52
 
53
+ class ScriptOutputLimitExceeded < OutputLimitExceeded; end
54
+
51
55
  class SessionBusy < Error; end
52
56
 
53
57
  class UnsupportedOperation < Error; end
@@ -72,10 +76,16 @@ module Net
72
76
  def inspect = "#<#{self.class} type=#{type}>"
73
77
  end
74
78
 
75
- # 每个会话独立保存脱敏词,包括配置凭据和动态认证响应。
79
+ # 管理连接器的秘密作用域和输出敏感性;字节过滤统一委托给 expect-pty。
76
80
  class Redactor
81
+ MASK = "[REDACTED]".b.freeze
82
+
77
83
  # 保存初始凭据,并建立第一版脱敏词表。
78
84
  def initialize(*secrets)
85
+ unless Expect.constants(false).include?(:Redactor) && Expect::Redactor.respond_to?(:redact)
86
+ raise LoadError, "net-connector requires expect-pty with the public Expect::Redactor API"
87
+ end
88
+
79
89
  @configured = secrets.compact.map(&:b).map(&:freeze).freeze
80
90
  reset
81
91
  end
@@ -83,9 +93,10 @@ module Net
83
93
  # 重置动态词表,同时保留配置中的固定凭据。
84
94
  def reset
85
95
  @secrets = []
86
- @pattern = nil
96
+ @patterns = nil
87
97
  @scoped = false
88
98
  @sensitive = false
99
+ @output_sensitive = false
89
100
  @configured.each { |secret| remember(secret) }
90
101
  end
91
102
 
@@ -94,27 +105,39 @@ module Net
94
105
  return yield if reuse && @scoped
95
106
 
96
107
  previous = @secrets
97
- previous_pattern = @pattern
108
+ previous_patterns = @patterns
98
109
  previous_scoped = @scoped
99
110
  previous_sensitive = @sensitive
111
+ previous_output_sensitive = @output_sensitive
100
112
  @secrets = previous.dup
101
113
  @scoped = true
102
114
  begin
103
115
  yield
104
116
  ensure
105
117
  @secrets = previous
106
- @pattern = previous_pattern
118
+ @patterns = previous_patterns
107
119
  @scoped = previous_scoped
108
120
  @sensitive = previous_sensitive
121
+ @output_sensitive = previous_output_sensitive
109
122
  end
110
123
  end
111
124
 
112
125
  # 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
113
126
  def sensitive! = @sensitive = true
114
127
 
128
+ # 判断当前脱敏范围是否包含敏感交互。
115
129
  def sensitive? = @sensitive
116
130
 
117
- # 记住新的敏感字节,并按长度排序避免短词先匹配。
131
+ # 输出敏感时不把正文加入词表;只在当前范围内屏蔽日志和任意错误正文。
132
+ def output_sensitive!
133
+ @output_sensitive = true
134
+ sensitive!
135
+ end
136
+
137
+ # 后续查询继承输出边界,即使查询命令本身没有敏感标记。
138
+ def output_sensitive? = @output_sensitive
139
+
140
+ # 只维护当前作用域的注册词;匹配次序和重叠区间交给共享过滤器。
118
141
  def remember(secret)
119
142
  return if secret.nil? || secret.empty?
120
143
 
@@ -122,62 +145,26 @@ module Net
122
145
  return if @secrets.include?(bytes)
123
146
 
124
147
  @secrets << bytes.freeze
125
- @secrets.sort_by! { |value| -value.bytesize }
126
- @pattern = nil
127
- end
128
-
129
- # 保留可能跨下一分片的尾部;完整匹配始终作为整体脱敏。
130
- def stream_chunk(bytes, final: false)
131
- return [call(bytes), "".b] if final || @secrets.empty?
132
-
133
- retained = [@secrets.first.bytesize, "[REDACTED]".bytesize].max - 1
134
- boundary = [bytes.bytesize - retained, 0].max
135
- matches(bytes).each do |start, finish|
136
- if start < boundary && finish > boundary
137
- boundary = start
138
- break
139
- end
140
- end
141
- [call(bytes.byteslice(0, boundary)), bytes.byteslice(boundary..)]
148
+ @patterns = nil
142
149
  end
143
150
 
144
- # 单次扫描替换所有敏感词,并保留已有脱敏标记。
151
+ # 一次性诊断不共享流状态;原标记作为不透明区间,避免再次展开它。
145
152
  def call(text)
146
- text = text.to_s.b
147
- return text if @secrets.empty?
148
-
149
- output = +"".b
150
- offset = 0
151
- matches(text).each do |start, finish|
152
- output << text.byteslice(offset, start - offset) << "[REDACTED]"
153
- offset = finish
154
- end
155
- output << text.byteslice(offset..)
153
+ Expect::Redactor.redact(text.to_s.b, patterns, replacement: MASK)
156
154
  end
157
155
 
158
- # 返回脱敏器类型摘要。
159
- def inspect = "#<#{self.class}>"
160
-
161
- private
162
-
163
- # 前瞻保留重叠匹配;跨标记边界的真实秘密不能被已有标记遮蔽。
164
- def pattern
165
- @pattern ||= /(?=(#{Regexp.union((@secrets + ["[REDACTED]"]).uniq.sort_by { |secret| -secret.bytesize })}))/n
156
+ # 每个日志目标独占过滤流,跨分片尾部和重叠掩码由依赖库维护。
157
+ def stream
158
+ Expect::Redactor.new(patterns, replacement: MASK)
166
159
  end
167
160
 
168
- def matches(text)
169
- ranges = []
170
- text.to_enum(:scan, pattern).each do
171
- match = Regexp.last_match
172
- start, finish = match.begin(1), match.end(1)
173
- if ranges.last && start < ranges.last.last
174
- ranges.last[1] = [ranges.last.last, finish].max
175
- else
176
- ranges << [start, finish]
177
- end
178
- end
179
- ranges
161
+ # 临时范围恢复时,日志流可以同步恢复注册词表,不缓存配置正文。
162
+ def patterns
163
+ @patterns ||= (@secrets.empty? ? [] : (@secrets + [MASK]).uniq).freeze
180
164
  end
165
+
166
+ # 返回脱敏器类型摘要。
167
+ def inspect = "#<#{self.class}>"
181
168
  end
182
169
  end
183
170
  end
@@ -18,6 +18,8 @@ module Net
18
18
  @prompt = prompt
19
19
  @context = {}
20
20
  @steps = []
21
+ @output_bytes = 0
22
+ @output_limit = session.configuration.max_script_output_bytes
21
23
  end
22
24
 
23
25
  # 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
@@ -30,24 +32,38 @@ module Net
30
32
 
31
33
  @current_command = command
32
34
  started = Expect.monotonic
33
- response = query(command)
34
- step = CommandResult.new(command: command, output: response.output, prompt: response.prompt,
35
- duration: Expect.monotonic - started)
36
- steps << step
35
+ step = nil
36
+ response = query(command) do |received|
37
+ step = CommandResult.new(command: command, output: received.output, prompt: received.prompt,
38
+ duration: Expect.monotonic - started)
39
+ steps << step
40
+ end
37
41
  # 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
38
42
  @after_command.call(command, response, self)
39
43
  yield step if block_given?
44
+ check_output_budget!(@last_query_command, completed: true)
40
45
  rescue => error
41
46
  raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
42
47
  end
43
48
  end
49
+ check_output_budget!(@last_query_command, completed: true)
44
50
  Result.new(steps: steps)
45
51
  end
46
52
 
47
53
  # 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
48
54
  def query(command)
49
55
  command = Command.new(command) unless command.is_a?(Command)
50
- @session.exchange(command, timeout: @command_timeout, prompt: @prompt&.call(command))
56
+ check_output_budget!(command, completed: false)
57
+ prompt = @prompt&.call(command)
58
+ # 提示符回调也可能追加查询,实际发送前重新检查它消耗的预算。
59
+ check_output_budget!(command, completed: false) if @prompt
60
+ response = @session.exchange(command, timeout: @command_timeout, prompt: prompt)
61
+ @last_query_command = command
62
+ @output_bytes += response.raw.bytesize
63
+ # 主命令先记录完整步骤,再检查超额;追加查询同样计入预算,但不改变原 steps 结构。
64
+ yield response if block_given?
65
+ check_output_budget!(command, completed: true)
66
+ response
51
67
  end
52
68
 
53
69
  # 只有当前会话未特权时才执行特权认证。
@@ -59,6 +75,17 @@ module Net
59
75
  def failure(message)
60
76
  @session.error(ScriptError, message, phase: :script, command: @current_command)
61
77
  end
78
+
79
+ private
80
+
81
+ # 单响应上限仍约束正在读取的命令;累计预算阻止继续发送,不承诺设备尚未执行。
82
+ def check_output_budget!(command, completed:)
83
+ return unless @output_limit && (completed ? @output_bytes > @output_limit : @output_bytes >= @output_limit)
84
+
85
+ raise @session.error(ScriptOutputLimitExceeded,
86
+ "script output reached max_script_output_bytes; commands already sent may have executed",
87
+ phase: :script, command: command), cause: nil
88
+ end
62
89
  end
63
90
  end
64
91
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support"
4
- require "active_support/logger"
5
- require "active_support/tagged_logging"
6
3
  require "fileutils"
4
+ require "logger"
7
5
  require_relative "terminal_renderer"
8
6
  require_relative "log_messages"
9
7
  require_relative "errors"
@@ -29,14 +27,16 @@ module Net
29
27
  if @configuration.log_format == :raw
30
28
  @writer = @output
31
29
  elsif @io || @configuration.logger
32
- base = @configuration.logger || ActiveSupport::Logger.new(@io)
30
+ base = @configuration.logger || ::Logger.new(@io)
33
31
  unless @configuration.logger
34
32
  base.formatter = proc do |severity, time, _program, message|
35
33
  "[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S %:z")}] #{severity} #{message}\n"
36
34
  end
37
35
  end
38
- @logger = ActiveSupport::TaggedLogging.new(base).tagged("host=#{@configuration.host}")
39
- @logger.level = [base.level, LEVELS.fetch(@configuration.log_level)].max
36
+ # 前缀直接写入消息,不克隆或修改调用方注入的日志器。
37
+ @logger = base
38
+ @event_level = [base.level, LEVELS.fetch(@configuration.log_level)].max
39
+ @tag = "[host=#{@configuration.host}] "
40
40
  if @io && @configuration.log_level == :debug
41
41
  @writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
42
42
  end
@@ -67,7 +67,7 @@ module Net
67
67
  end
68
68
  safe_name = @redactor.call(name.to_s).scrub.gsub(/[[:cntrl:]]+/, " ").strip
69
69
  finish_line
70
- @logger.public_send(level, LogMessages.format(safe_name, values))
70
+ @logger.public_send(level, "#{@tag}#{LogMessages.format(safe_name, values)}") if LEVELS.fetch(level) >= @event_level
71
71
  rescue Error
72
72
  raise
73
73
  rescue => error
@@ -89,10 +89,10 @@ module Net
89
89
 
90
90
  # 应用注入的日志器没有终端写入器,改由日志器记录完整回显。
91
91
  def response_output(bytes)
92
- return unless detailed? && !@writer
92
+ return unless detailed? && !@writer && !@redactor.output_sensitive?
93
93
 
94
94
  @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
95
- @logger.debug(" #{line.chomp}")
95
+ @logger.debug("#{@tag} #{line.chomp}") if ::Logger::DEBUG >= @event_level
96
96
  end
97
97
  rescue Error
98
98
  raise
@@ -140,7 +140,7 @@ module Net
140
140
  begin
141
141
  @io&.close
142
142
  ensure
143
- @writer = @output = @logger = @io = @transport = nil
143
+ @writer = @output = @logger = @io = @transport = @tag = @event_level = nil
144
144
  @attached = false
145
145
  end
146
146
  end
@@ -169,27 +169,32 @@ module Net
169
169
 
170
170
  # 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
171
171
  class RedactingWriter
172
+ # 保存目标与作用域;每个日志目标独占 expect-pty 的过滤流。
172
173
  def initialize(target, redactor)
173
174
  @target = target
174
175
  @redactor = redactor
175
- @pending = "".b
176
+ @filter = redactor.stream
176
177
  end
177
178
 
179
+ # 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
178
180
  def write(bytes)
179
- @pending << bytes.b
180
- safe, @pending = @redactor.stream_chunk(@pending)
181
- @target.write(safe)
181
+ @filter.patterns = @redactor.patterns
182
+ @target.write(@filter.append(bytes.b))
182
183
  bytes.bytesize
183
184
  end
184
185
 
186
+ # 刷新目标流,仍不提前写出待判断的尾部。
185
187
  def flush = @target.flush
186
188
 
189
+ # 在日志结束时脱敏并写出剩余尾部。
187
190
  def finish
188
- safe, @pending = @redactor.stream_chunk(@pending, final: true)
189
- @target.write(safe)
191
+ @filter.patterns = @redactor.patterns
192
+ # 保留旧日志的完整词匹配契约;配置正文由 Session 的敏感范围直接隔离。
193
+ @target.write(@filter.finish(partial: false))
190
194
  flush
191
195
  end
192
196
  end
197
+
193
198
  private_constant :RedactingWriter
194
199
 
195
200
  # 以私有权限打开设备日志文件。
@@ -220,7 +225,8 @@ module Net
220
225
 
221
226
  # 将日志异常包装成统一错误并保留脱敏原因。
222
227
  def failure(message, error)
223
- LogError.new(message, phase: :logging, underlying: UnderlyingError.new(error, @redactor))
228
+ LogError.new(message, phase: :logging,
229
+ underlying: UnderlyingError.new(error, @redactor, sensitive: @redactor.sensitive?))
224
230
  end
225
231
  end
226
232
  end
@@ -10,6 +10,8 @@ module Net
10
10
  module Connector
11
11
  # 连接状态、传输层和日志的唯一所有者;同一时刻只允许一个操作占用会话。
12
12
  class Session
13
+ MAX_ERROR_OUTPUT_BYTES = 4096
14
+
13
15
  attr_reader :configuration, :transport, :reader, :redactor, :state, :prompt
14
16
 
15
17
  # 创建认证、响应读取、脱敏和日志对象,并把会话置于关闭状态。
@@ -75,6 +77,14 @@ module Net
75
77
  end
76
78
  end
77
79
 
80
+ # 文件业务必须先取得路径锁,再调用会话;不能持有会话租约去等待另一个备份者。
81
+ def assert_path_lock_order!(phase)
82
+ return unless @lock.locked?
83
+
84
+ raise error(SessionBusy, "backup path ownership must be acquired before a session operation", phase: phase), cause: nil
85
+ end
86
+
87
+ # 一次操作从连接到结果处理始终持锁;异常或 throw 中断都关闭未完成会话。
78
88
  def perform_locked(phase)
79
89
  @performing = true
80
90
  completed = false
@@ -139,6 +149,19 @@ module Net
139
149
  end
140
150
  end
141
151
 
152
+ # 批次准备和最终清理也可能读取配置;在作用域退出前归一化其异常,
153
+ # 防止清理钩子的消息或回溯将已完成步骤中的正文带回诊断通道。
154
+ def output_scope(enabled)
155
+ return yield unless enabled
156
+
157
+ redactor.scope do
158
+ redactor.output_sensitive!
159
+ yield
160
+ rescue => error
161
+ raise normalize_error(error, phase: :script), cause: nil
162
+ end
163
+ end
164
+
142
165
  # 复用完整命令的脱敏范围;厂商后续查询产生的秘密保留到外层回调结束。
143
166
  def exchange(command, timeout:, prompt: nil)
144
167
  redactor.scope(reuse: true) do
@@ -146,9 +169,10 @@ module Net
146
169
  started = Expect.monotonic
147
170
  @log.event("command_start", text: command.sensitive? ? "[REDACTED]" : command.text)
148
171
  sensitive_dialogue = [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
149
- @log.event("device_output", level: :debug) if @log.detailed? && !command.sensitive? && !sensitive_dialogue
172
+ private_output = command.sensitive? || redactor.output_sensitive? || sensitive_dialogue
173
+ @log.event("device_output", level: :debug) if @log.detailed? && !private_output
150
174
  response = exchange_command(command, timeout: timeout, prompt: prompt)
151
- @log.response_output(response.raw) unless command.sensitive? || sensitive_dialogue
175
+ @log.response_output(response.raw) unless private_output
152
176
  @log.event("command_complete", status: "response_received")
153
177
  @log.event("command_detail", level: :debug,
154
178
  duration_ms: ((Expect.monotonic - started) * 1000).round,
@@ -164,6 +188,7 @@ module Net
164
188
 
165
189
  # 在用户钩子运行前标记敏感上下文,动态交互尚未返回时也能保护其异常。
166
190
  def protect_command(command)
191
+ redactor.output_sensitive! if command.output_sensitive?
167
192
  if command.sensitive? || [*command.interactions, *@dialogue.command_interactions].any?(&:sensitive?)
168
193
  redactor.sensitive!
169
194
  end
@@ -188,7 +213,7 @@ module Net
188
213
 
189
214
  response
190
215
  end
191
- if command.sensitive? || interactions.any?(&:sensitive?)
216
+ if command.sensitive? || redactor.output_sensitive? || interactions.any?(&:sensitive?)
192
217
  @log.pause(&operation)
193
218
  else
194
219
  operation.call
@@ -219,20 +244,20 @@ module Net
219
244
 
220
245
  # 创建带脱敏上下文、阶段、命令和输出尾部的领域错误。
221
246
  def error(klass, message, phase:, command: nil, output: "".b, underlying: nil, **context)
222
- sensitive = redactor.sensitive? || command&.sensitive?
247
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
223
248
  message = safe_error_text(message, sensitive: sensitive)
224
249
  output = safe_error_text(output, sensitive: sensitive)
225
250
  klass.new(message, host: configuration.host, phase: phase,
226
251
  command: command && (command.sensitive? ? "[REDACTED]" : redactor.call(command.text)),
227
252
  source: command&.source, line: command&.line,
228
- output: output.byteslice(-4096, 4096) || output,
253
+ output: truncate_output(output),
229
254
  underlying: underlying && UnderlyingError.new(underlying, redactor, sensitive: sensitive), **context)
230
255
  end
231
256
 
232
257
  # 将底层异常映射为连接、传输、超时或内部错误,并保留安全上下文。
233
258
  def normalize_error(exception, phase:, command: nil)
234
259
  if exception.is_a?(Error)
235
- sensitive = redactor.sensitive? || command&.sensitive?
260
+ sensitive = redactor.sensitive? || command&.sensitive? || command&.output_sensitive?
236
261
  failed_command = exception.command || command&.text
237
262
  if command&.sensitive? || (sensitive && exception.command && exception.command != command&.text)
238
263
  failed_command = "[REDACTED]"
@@ -244,7 +269,7 @@ module Net
244
269
  host: exception.host || configuration.host, phase: exception.phase || phase,
245
270
  command: failed_command && redactor.call(failed_command),
246
271
  source: exception.source || command&.source, line: exception.line || command&.line,
247
- output: output.byteslice(-4096, 4096) || output,
272
+ output: truncate_output(output),
248
273
  underlying: exception.underlying && UnderlyingError.new(exception.underlying, redactor, sensitive: sensitive)
249
274
  )
250
275
  end
@@ -264,6 +289,13 @@ module Net
264
289
 
265
290
  private :safe_error_text
266
291
 
292
+ # 必须先脱敏再截取尾部,避免截断凭据后逃过完整词匹配。
293
+ def truncate_output(text)
294
+ text.byteslice(-MAX_ERROR_OUTPUT_BYTES, MAX_ERROR_OUTPUT_BYTES) || text
295
+ end
296
+
297
+ private :truncate_output
298
+
267
299
  # 返回不包含凭据的会话状态摘要。
268
300
  def inspect = "#<#{self.class} state=#{state} host=#{configuration.host.inspect}>"
269
301
 
@@ -8,20 +8,21 @@ module Net
8
8
  # 把终端编辑控制符渲染成可读、兼容 UTF-8 和二进制的逐行日志。
9
9
  class TerminalRenderer
10
10
  # 将字节流完整渲染为字符串,供测试和一次性转换使用。
11
- def self.render(input)
11
+ def self.render(input, strict_utf8: false)
12
12
  output = StringIO.new("".b)
13
- renderer = new(output)
13
+ renderer = new(output, strict_utf8: strict_utf8)
14
14
  renderer.write(input)
15
15
  renderer.finish
16
16
  output.string
17
17
  end
18
18
 
19
19
  # 初始化当前行、光标和转义序列状态。
20
- def initialize(target, max_line_bytes: 32 * 1024 * 1024)
20
+ def initialize(target, max_line_bytes: 32 * 1024 * 1024, strict_utf8: false)
21
21
  raise ArgumentError, "target must respond to write" unless target.respond_to?(:write)
22
22
  unless max_line_bytes.is_a?(Integer) && max_line_bytes.positive?
23
23
  raise ArgumentError, "max_line_bytes must be positive"
24
24
  end
25
+ raise ArgumentError, "strict_utf8 must be true or false" unless [true, false].include?(strict_utf8)
25
26
 
26
27
  @target = target
27
28
  @line = "".b
@@ -29,6 +30,7 @@ module Net
29
30
  @escape_state = nil
30
31
  @csi_parameters = "".b
31
32
  @max_line_bytes = max_line_bytes
33
+ @strict_utf8 = strict_utf8
32
34
  end
33
35
 
34
36
  # 逐字节消费终端输出,并返回输入字节数。
@@ -146,10 +148,11 @@ module Net
146
148
  @cursor = 0
147
149
  end
148
150
 
149
- # 尽量保持有效 UTF-8;非法字节转义为可读的十六进制文本。
151
+ # 日志把非法字节转义;解析必须拒绝终端编辑产生的损坏字节,不能制造可写业务证据。
150
152
  def utf8_safe(line)
151
153
  text = line.dup.force_encoding(Encoding::UTF_8)
152
154
  return text.b if text.valid_encoding?
155
+ raise Encoding::InvalidByteSequenceError, "terminal editing produced invalid UTF-8" if @strict_utf8
153
156
 
154
157
  text.scrub { |invalid| invalid.bytes.map { |byte| format("\\x%02X", byte) }.join }.b
155
158
  end