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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- 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,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
|