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