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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -68
- data/CONTRIBUTING.md +40 -0
- data/README.md +140 -95
- data/SECURITY.md +11 -0
- data/docs/VERIFICATION.md +129 -5
- data/docs/architecture.md +321 -248
- data/lib/net/connector/device/base.rb +12 -4
- data/lib/net/connector/device/interface_description.rb +3 -0
- data/lib/net/connector/device/profile/builder.rb +236 -0
- data/lib/net/connector/device/profile.rb +2 -227
- data/lib/net/connector/device/running_config/strategy.rb +5 -0
- data/lib/net/connector/device/running_config.rb +7 -0
- data/lib/net/connector/engine/command.rb +14 -2
- data/lib/net/connector/engine/configuration.rb +10 -7
- data/lib/net/connector/engine/dialogue.rb +53 -28
- data/lib/net/connector/engine/errors.rb +41 -54
- data/lib/net/connector/engine/execution.rb +32 -5
- data/lib/net/connector/engine/log.rb +23 -17
- data/lib/net/connector/engine/session.rb +39 -7
- data/lib/net/connector/engine/terminal_renderer.rb +7 -4
- data/lib/net/connector/netdisco/batch.rb +31 -3
- data/lib/net/connector/netdisco/cli.rb +92 -50
- data/lib/net/connector/netdisco/client.rb +206 -73
- data/lib/net/connector/netdisco/config_file.rb +26 -4
- data/lib/net/connector/netdisco/device.rb +4 -8
- data/lib/net/connector/netdisco/diagnostic.rb +91 -0
- data/lib/net/connector/netdisco/fleet.rb +105 -108
- data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
- data/lib/net/connector/netdisco/plan.rb +77 -0
- data/lib/net/connector/netdisco/planner.rb +12 -23
- data/lib/net/connector/netdisco/report.rb +94 -0
- data/lib/net/connector/netdisco/rules.rb +28 -5
- data/lib/net/connector/netdisco/settings.rb +194 -85
- data/lib/net/connector/netdisco/worker.rb +43 -27
- data/lib/net/connector/operations/backup_lock.rb +116 -0
- data/lib/net/connector/operations/local_backup.rb +49 -9
- data/lib/net/connector/operations/parse_output.rb +20 -3
- data/lib/net/connector/operations/private_file.rb +94 -5
- data/lib/net/connector/operations/safe_file.rb +62 -0
- data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
- data/lib/net/connector/operations/saved_config.rb +40 -10
- data/lib/net/connector/operations/tftp/file_upload.rb +55 -0
- data/lib/net/connector/operations/tftp/strategy.rb +9 -11
- data/lib/net/connector/operations/tftp_backup.rb +78 -15
- data/lib/net/connector/operations/tftp_receipt.rb +73 -0
- data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
- data/lib/net/connector/operations/topology/strategy.rb +34 -0
- data/lib/net/connector/operations/topology.rb +98 -29
- data/lib/net/connector/operations.rb +6 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
- data/lib/net/connector/vendor/cisco_ios/topology.rb +18 -3
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +1 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +7 -18
- data/lib/net/connector/vendor/h3c/topology.rb +17 -3
- data/lib/net/connector/vendor/hillstone/running_config.rb +1 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +16 -3
- data/lib/net/connector/vendor/hillstone/topology.rb +17 -3
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +8 -18
- data/lib/net/connector/vendor/palo_alto/running_config.rb +4 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +19 -8
- data/lib/net/connector/vendor/palo_alto/topology.rb +16 -1
- data/lib/net/connector/vendor/radware/tftp_backup.rb +11 -4
- data/lib/net/connector/vendor/radware/topology.rb +3 -0
- data/lib/net/connector/version.rb +1 -1
- 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
|
-
|
|
36
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
39
|
-
@logger
|
|
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
|
-
@
|
|
176
|
+
@filter = redactor.stream
|
|
176
177
|
end
|
|
177
178
|
|
|
179
|
+
# 写入已确认安全的前缀,暂存可能与下一分片组成秘密的尾部。
|
|
178
180
|
def write(bytes)
|
|
179
|
-
@
|
|
180
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|