net-connector 0.4.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. metadata +517 -0
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 操作错误携带已脱敏上下文;原始配置只存在于明确的结果或日志中。
6
+ class Error < StandardError
7
+ attr_reader :code, :host, :phase, :command, :output, :source, :line, :underlying
8
+
9
+ # 保存错误阶段、命令和输出尾部,并为缺省错误类生成稳定错误码。
10
+ def initialize(message, code: nil, host: nil, phase: nil, command: nil, output: "".b,
11
+ source: nil, line: nil, underlying: nil)
12
+ super(message)
13
+ @code = code || self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
14
+ @host = host
15
+ @phase = phase
16
+ @command = command
17
+ @output = output.freeze
18
+ @source = source
19
+ @line = line
20
+ @underlying = underlying
21
+ end
22
+
23
+ # 返回不展开命令和输出的错误摘要。
24
+ def inspect = "#<#{self.class} code=#{code.inspect} phase=#{phase.inspect} host=#{host.inspect}>"
25
+ end
26
+
27
+ class ConnectionError < Error; end
28
+
29
+ class AuthenticationError < ConnectionError; end
30
+
31
+ class LoginTimeout < AuthenticationError; end
32
+
33
+ class CommandTimeout < Error; end
34
+
35
+ class WriteTimeout < Error; end
36
+
37
+ class PromptError < Error; end
38
+
39
+ class ConnectionClosed < ConnectionError; end
40
+
41
+ class TransportError < ConnectionError; end
42
+
43
+ class DeviceError < Error; end
44
+
45
+ class ScriptError < Error; end
46
+
47
+ class InternalError < Error; end
48
+
49
+ class OutputLimitExceeded < Error; end
50
+
51
+ class SessionBusy < Error; end
52
+
53
+ class UnsupportedOperation < Error; end
54
+
55
+ class LogError < Error; end
56
+
57
+ class ParsingError < Error; end
58
+
59
+ # 安全保存底层异常,不保留包含凭据的原始消息。
60
+ class UnderlyingError
61
+ attr_reader :type, :message, :backtrace
62
+
63
+ # 保存脱敏后的异常类型、消息和调用栈。
64
+ def initialize(error, redactor, sensitive: false)
65
+ @type = error.is_a?(UnderlyingError) ? error.type : error.class.name
66
+ @message = (sensitive ? "[REDACTED]" : redactor.call(error.message)).freeze
67
+ @backtrace = (sensitive ? [] : Array(error.backtrace).map { |line| redactor.call(line).freeze }).freeze
68
+ freeze
69
+ end
70
+
71
+ # 返回底层异常类型摘要。
72
+ def inspect = "#<#{self.class} type=#{type}>"
73
+ end
74
+
75
+ # 每个会话独立保存脱敏词,包括配置凭据和动态认证响应。
76
+ class Redactor
77
+ # 保存初始凭据,并建立第一版脱敏词表。
78
+ def initialize(*secrets)
79
+ @configured = secrets.compact.map(&:b).map(&:freeze).freeze
80
+ reset
81
+ end
82
+
83
+ # 重置动态词表,同时保留配置中的固定凭据。
84
+ def reset
85
+ @secrets = []
86
+ @pattern = nil
87
+ @scoped = false
88
+ @sensitive = false
89
+ @configured.each { |secret| remember(secret) }
90
+ end
91
+
92
+ # 临时命令响应在错误归一化期间保留,离开作用域后不进入长期会话。
93
+ def scope(reuse: false)
94
+ return yield if reuse && @scoped
95
+
96
+ previous = @secrets
97
+ previous_pattern = @pattern
98
+ previous_scoped = @scoped
99
+ previous_sensitive = @sensitive
100
+ @secrets = previous.dup
101
+ @scoped = true
102
+ begin
103
+ yield
104
+ ensure
105
+ @secrets = previous
106
+ @pattern = previous_pattern
107
+ @scoped = previous_scoped
108
+ @sensitive = previous_sensitive
109
+ end
110
+ end
111
+
112
+ # 敏感命令和交互的异常可能只引用秘密的一部分,不能只依赖完整词匹配。
113
+ def sensitive! = @sensitive = true
114
+
115
+ def sensitive? = @sensitive
116
+
117
+ # 记住新的敏感字节,并按长度排序避免短词先匹配。
118
+ def remember(secret)
119
+ return if secret.nil? || secret.empty?
120
+
121
+ bytes = secret.b
122
+ return if @secrets.include?(bytes)
123
+
124
+ @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..)]
142
+ end
143
+
144
+ # 单次扫描替换所有敏感词,并保留已有脱敏标记。
145
+ 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..)
156
+ end
157
+
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
166
+ end
167
+
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
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+ require_relative "result"
5
+
6
+ module Net
7
+ module Connector
8
+ # 单个脚本的局部状态;执行对象不拥有设备或传输层生命周期。
9
+ class Execution
10
+ attr_reader :context, :steps
11
+
12
+ # 保存命令准备和后处理钩子,并初始化批次上下文及已完成步骤。
13
+ def initialize(session:, timeout:, prepare:, after_command:, prompt: nil)
14
+ @session = session
15
+ @command_timeout = timeout
16
+ @prepare_command = prepare
17
+ @after_command = after_command
18
+ @prompt = prompt
19
+ @context = {}
20
+ @steps = []
21
+ end
22
+
23
+ # 依次准备、执行和记录脚本命令;失败时保留已完成步骤并统一抛错。
24
+ def execute(script)
25
+ script.each do |original|
26
+ @session.command_scope(original) do
27
+ @current_command = original
28
+ command = @prepare_command.call(original, self)
29
+ next unless command
30
+
31
+ @current_command = command
32
+ 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
37
+ # 设备已经完成命令;后处理失败也不能从部分结果中抹去其副作用。
38
+ @after_command.call(command, response, self)
39
+ yield step if block_given?
40
+ rescue => error
41
+ raise @session.normalize_error(error, phase: :script, command: @current_command), cause: nil
42
+ end
43
+ end
44
+ Result.new(steps: steps)
45
+ end
46
+
47
+ # 厂商后续查询复用同一信道和错误处理,不开启新的批处理。
48
+ def query(command)
49
+ command = Command.new(command) unless command.is_a?(Command)
50
+ @session.exchange(command, timeout: @command_timeout, prompt: @prompt&.call(command))
51
+ end
52
+
53
+ # 只有当前会话未特权时才执行特权认证。
54
+ def enable(command, prompt)
55
+ @session.enable(command, prompt) unless @session.privileged?
56
+ end
57
+
58
+ # 根据当前命令建立脚本阶段错误。
59
+ def failure(message)
60
+ @session.error(ScriptError, message, phase: :script, command: @current_command)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/logger"
5
+ require "active_support/tagged_logging"
6
+ require "fileutils"
7
+ require_relative "terminal_renderer"
8
+ require_relative "log_messages"
9
+ require_relative "errors"
10
+
11
+ module Net
12
+ module Connector
13
+ # 单个设备会话的日志,按级别控制是否包含完整回显。
14
+ class Log
15
+ LEVELS = { debug: ::Logger::DEBUG, info: ::Logger::INFO,
16
+ warn: ::Logger::WARN, error: ::Logger::ERROR }.freeze
17
+
18
+ # 保存日志配置和敏感信息脱敏器。
19
+ def initialize(configuration, redactor:)
20
+ @configuration = configuration
21
+ @redactor = redactor
22
+ end
23
+
24
+ # 为会话打开日志文件或应用日志器。
25
+ def open(transport)
26
+ @transport = transport
27
+ @io = open_file(@configuration.log_file) if @configuration.log_file
28
+ @output = RedactingWriter.new(@io, @redactor) if @io
29
+ if @configuration.log_format == :raw
30
+ @writer = @output
31
+ elsif @io || @configuration.logger
32
+ base = @configuration.logger || ActiveSupport::Logger.new(@io)
33
+ unless @configuration.logger
34
+ base.formatter = proc do |severity, time, _program, message|
35
+ "[#{time.getlocal.strftime("%Y-%m-%d %H:%M:%S %:z")}] #{severity} #{message}\n"
36
+ end
37
+ end
38
+ @logger = ActiveSupport::TaggedLogging.new(base).tagged("host=#{@configuration.host}")
39
+ @logger.level = [base.level, LEVELS.fetch(@configuration.log_level)].max
40
+ if @io && @configuration.log_level == :debug
41
+ @writer = TerminalRenderer.new(@output, max_line_bytes: @configuration.max_output_bytes)
42
+ end
43
+ end
44
+ @line_open = false
45
+ rescue => error
46
+ close_preserving_error
47
+ raise failure("unable to open session log", error), cause: nil
48
+ end
49
+
50
+ # 将终端回显接入当前会话日志。
51
+ def attach
52
+ return unless @transport && @writer
53
+
54
+ @transport.log_output = self
55
+ @attached = true
56
+ end
57
+
58
+ # 判断当前日志级别是否需要完整设备回显。
59
+ def detailed? = @configuration.log_level == :debug && !!(@writer || @logger)
60
+
61
+ # 按日志级别写入已脱敏的业务事件。
62
+ def event(name, level: :info, **fields)
63
+ return unless @logger
64
+
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, LogMessages.format(safe_name, values))
71
+ rescue Error
72
+ raise
73
+ rescue => error
74
+ raise failure("unable to write session event", error), cause: nil
75
+ end
76
+
77
+ # 登录完成并收集动态口令后,再记录已脱敏的认证回显。
78
+ def authentication_output(bytes)
79
+ return unless detailed?
80
+
81
+ event("login_output", level: :debug)
82
+ if @writer
83
+ write(@redactor.call(bytes))
84
+ flush
85
+ else
86
+ response_output(bytes)
87
+ end
88
+ end
89
+
90
+ # 应用注入的日志器没有终端写入器,改由日志器记录完整回显。
91
+ def response_output(bytes)
92
+ return unless detailed? && !@writer
93
+
94
+ @redactor.call(TerminalRenderer.render(@redactor.call(bytes))).each_line do |line|
95
+ @logger.debug(" #{line.chomp}")
96
+ end
97
+ rescue Error
98
+ raise
99
+ rescue => error
100
+ raise failure("unable to write device output", error), cause: nil
101
+ end
102
+
103
+ # 向终端日志写入已处理的回显字节。
104
+ def write(bytes)
105
+ return unless @writer
106
+
107
+ @writer.write(bytes)
108
+ @line_open = !bytes.end_with?("\n") unless bytes.empty?
109
+ rescue Error
110
+ raise
111
+ rescue => error
112
+ raise failure("unable to write session log", error), cause: nil
113
+ end
114
+
115
+ # 刷新日志缓冲区。
116
+ def flush
117
+ @writer&.flush
118
+ @io&.flush
119
+ rescue Error
120
+ raise
121
+ rescue => error
122
+ raise failure("unable to flush session log", error), cause: nil
123
+ end
124
+
125
+ # 执行代码块期间暂停自动记录传输回显。
126
+ def pause
127
+ finish_output
128
+ @transport.log_output = nil if @transport
129
+ yield
130
+ ensure
131
+ @transport.log_output = self if @transport && @writer && @attached
132
+ end
133
+
134
+ # 断开回显记录并关闭日志资源。
135
+ def close
136
+ begin
137
+ @transport.log_output = nil if @transport
138
+ finish_output
139
+ ensure
140
+ begin
141
+ @io&.close
142
+ ensure
143
+ @writer = @output = @logger = @io = @transport = nil
144
+ @attached = false
145
+ end
146
+ end
147
+ rescue Error
148
+ raise
149
+ rescue => error
150
+ raise failure("unable to close session log", error), cause: nil
151
+ end
152
+
153
+ private
154
+
155
+ # 结束未换行的回显,保持事件独占一行。
156
+ def finish_line
157
+ return unless @writer
158
+
159
+ finish_output
160
+ @io.write("\n") if @line_open
161
+ @line_open = false
162
+ end
163
+
164
+ # 先完成终端渲染,再写出脱敏流的最后几个字节。
165
+ def finish_output
166
+ @writer.finish if @writer.respond_to?(:finish)
167
+ @output&.finish unless @output.equal?(@writer)
168
+ end
169
+
170
+ # 文件写入边界保留短尾部,避免分片或终端控制符拼出明文凭据。
171
+ class RedactingWriter
172
+ def initialize(target, redactor)
173
+ @target = target
174
+ @redactor = redactor
175
+ @pending = "".b
176
+ end
177
+
178
+ def write(bytes)
179
+ @pending << bytes.b
180
+ safe, @pending = @redactor.stream_chunk(@pending)
181
+ @target.write(safe)
182
+ bytes.bytesize
183
+ end
184
+
185
+ def flush = @target.flush
186
+
187
+ def finish
188
+ safe, @pending = @redactor.stream_chunk(@pending, final: true)
189
+ @target.write(safe)
190
+ flush
191
+ end
192
+ end
193
+ private_constant :RedactingWriter
194
+
195
+ # 以私有权限打开设备日志文件。
196
+ def open_file(path)
197
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
198
+ file = File.open(path, File::WRONLY | File::CREAT | File::APPEND | File::NOFOLLOW, 0o600)
199
+ begin
200
+ file.chmod(0o600)
201
+ file.binmode
202
+ file.write("\n") if file.size.positive? && @configuration.log_format == :text
203
+ file
204
+ rescue Exception # rubocop:disable Lint/RescueException -- Release partially configured files on interrupts too.
205
+ begin
206
+ file.close
207
+ rescue
208
+ # 保留设置失败的原始原因。
209
+ end
210
+ raise
211
+ end
212
+ end
213
+
214
+ # 在日志打开失败后尽力释放资源。
215
+ def close_preserving_error
216
+ close
217
+ rescue
218
+ nil
219
+ end
220
+
221
+ # 将日志异常包装成统一错误并保留脱敏原因。
222
+ def failure(message, error)
223
+ LogError.new(message, phase: :logging, underlying: UnderlyingError.new(error, @redactor))
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 独立于日志目标的人类可读事件文案。
6
+ module LogMessages
7
+ module_function
8
+
9
+ # 将结构化会话事件转换为便于人工阅读的中文日志。
10
+ def format(name, fields)
11
+ case name
12
+ when "connect"
13
+ "开始连接 #{fields[:host]}(#{fields[:protocol].to_s.upcase},账号 #{fields[:username]})"
14
+ when "connect_failed"
15
+ "连接失败:#{fields[:message]}(#{fields[:error]})"
16
+ when "login_start"
17
+ "等待设备登录提示"
18
+ when "login_output"
19
+ "登录过程回显(已脱敏):"
20
+ when "login_complete"
21
+ fields[:status] == "ok" ? "登录成功,设备提示符:#{fields[:prompt]}" :
22
+ "登录失败:#{fields[:message]}(#{fields[:error]})"
23
+ when "command_start"
24
+ "下发命令:#{fields[:text]}"
25
+ when "device_output"
26
+ "设备回显:"
27
+ when "command_complete"
28
+ fields[:status] == "response_received" ? "命令回显结束,已收到设备提示符" :
29
+ "命令执行失败:#{fields[:message]}(#{fields[:error]})"
30
+ when "command_detail"
31
+ "命令耗时 #{fields[:duration_ms]} 毫秒,收到 #{fields[:response_bytes]} 字节回显"
32
+ when "tftp_backup"
33
+ case fields[:status]
34
+ when "reported_uploaded"
35
+ "TFTP 备份:设备报告上传成功,目标 #{fields[:server]}/#{fields[:path]};服务器文件尚未核验"
36
+ when "transfer_failed"
37
+ "TFTP 备份失败:设备报告传输失败,服务器 #{fields[:server]}"
38
+ else
39
+ "TFTP 备份未确认:设备未报告上传成功,服务器 #{fields[:server]}"
40
+ end
41
+ else
42
+ name.to_s
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 旧加载路径保留为转发入口;设备定义位于 device/。
4
+ require_relative "../device/profile"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Connector
5
+ # 只选择一次连接级恢复;任何设备命令都不会重放。
6
+ class Recovery
7
+ # 只有明确允许的 SSH 拒绝才会建立一次 Telnet 连接。
8
+ class TelnetFallback
9
+ # 将连接被拒绝转换为 Telnet 传输,否则不提供恢复。
10
+ def recover(error, transport)
11
+ transport.as_telnet if error.code == :connection_refused && transport.respond_to?(:as_telnet)
12
+ end
13
+ end
14
+
15
+ # 主机密钥替换仅限调用方指定的 known_hosts 文件。
16
+ class HostKeyReplacement
17
+ # 删除旧密钥并返回原传输对象,其他错误不触发替换。
18
+ def recover(error, transport)
19
+ return unless error.code == :host_key_changed && transport.respond_to?(:replace_host_key)
20
+
21
+ transport.replace_host_key
22
+ transport
23
+ end
24
+ end
25
+
26
+ # 旧版协商参数来自厂商配置,不能由设备输出决定。
27
+ class LegacySsh
28
+ # 保存厂商提供的旧版参数。
29
+ def initialize(arguments)
30
+ @arguments = arguments
31
+ end
32
+
33
+ # 根据明确错误码创建一次旧版 SSH 传输。
34
+ def recover(error, transport)
35
+ return unless transport.respond_to?(:with_legacy)
36
+
37
+ arguments = { rsa_too_small: @arguments, unsupported_cipher: ["-c", "des"] }[error.code]
38
+ transport.with_legacy(arguments) if arguments
39
+ end
40
+ end
41
+
42
+ # 按配置顺序建立可用的连接级恢复规则。
43
+ def initialize(configuration, legacy_arguments:)
44
+ @rules = []
45
+ @rules << TelnetFallback.new if configuration.telnet_fallback?
46
+ @rules << HostKeyReplacement.new if configuration.host_key_policy == :replace
47
+ @rules << LegacySsh.new(legacy_arguments) if configuration.legacy_ssh?
48
+ @rules.freeze
49
+ end
50
+
51
+ # 依次尝试恢复规则,并只返回第一个新传输对象。
52
+ def recover(error, transport)
53
+ @rules.lazy.filter_map { |rule| rule.recover(error, transport) }.first
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Net
6
+ module Connector
7
+ # 一条已完成命令的结果;后续命令失败时仍保留已完成步骤。
8
+ class CommandResult
9
+ attr_reader :command, :output, :prompt, :duration
10
+
11
+ # 保存命令输出、提示符和耗时,并冻结结果。
12
+ def initialize(command:, output:, prompt:, duration:)
13
+ @command = command
14
+ @output = output.frozen? ? output : output.dup.freeze
15
+ @prompt = prompt&.dup&.freeze
16
+ @duration = duration
17
+ freeze
18
+ end
19
+
20
+ # 返回输出字节数和耗时摘要。
21
+ def inspect = "#<#{self.class} bytes=#{output.bytesize} duration=#{duration.round(6)}>"
22
+ end
23
+
24
+ # 不可变执行结果;构造结果不会复制已经累计的命令输出前缀。
25
+ class Result
26
+ include Enumerable
27
+
28
+ attr_reader :steps, :error, :config
29
+
30
+ # 校验步骤、错误和配置文本,并冻结结果容器。
31
+ def initialize(steps: [], error: nil, config: nil)
32
+ raise ArgumentError, "error must be a Connector::Error or nil" unless error.nil? || error.is_a?(Error)
33
+ unless steps.is_a?(Array) && steps.all?(CommandResult)
34
+ raise ArgumentError, "steps must be an Array of CommandResult objects"
35
+ end
36
+ raise ArgumentError, "config must be a String or nil" unless config.nil? || config.is_a?(String)
37
+
38
+ @steps = steps.dup.freeze
39
+ @error = error
40
+ @config = config&.dup&.freeze
41
+ freeze
42
+ end
43
+
44
+ # 判断执行是否成功。
45
+ def success? = error.nil?
46
+
47
+ # 判断执行是否失败。
48
+ def failure? = !success?
49
+
50
+ # 遍历已完成命令步骤。
51
+ def each(&) = steps.each(&)
52
+
53
+ # 拼接所有已完成步骤的输出字节。
54
+ def output = steps.map(&:output).join.b
55
+
56
+ # 成功时返回配置或输出,失败时重新抛出领域错误。
57
+ def value!
58
+ raise error, cause: nil if failure?
59
+
60
+ config || output
61
+ end
62
+
63
+ # 返回结果成功状态、步骤数量和错误码摘要。
64
+ def inspect = "#<#{self.class} success=#{success?} steps=#{steps.size} code=#{error&.code.inspect}>"
65
+ end
66
+ end
67
+ end