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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dialogue"
4
+
5
+ module Net
6
+ module Connector
7
+ # 密码或密钥登录与显式特权认证共用有界对话流程。
8
+ class Authentication
9
+ CONNECTION_FAILURES = {
10
+ /REMOTE HOST IDEN|IDENTIFICATION CHANGED/i => [ConnectionError, :host_key_changed],
11
+ /Host key verification failed/i => [ConnectionError, :host_key_untrusted],
12
+ /RSA modulus too small/i => [ConnectionError, :rsa_too_small],
13
+ /Selected cipher type <unknown> not supported by server/i => [ConnectionError, :unsupported_cipher],
14
+ /Connection refused/i => [ConnectionError, :connection_refused],
15
+ /No route to host/i => [ConnectionError, :no_route],
16
+ /Connection reset/i => [ConnectionError, :connection_reset],
17
+ /(?:Operation|Connection) timed out/i => [ConnectionError, :connection_timeout]
18
+ }.freeze
19
+ SSH_CONFIRMATION = %r{\(yes/no(?:/\[fingerprint\])?\)\?\s*\z}i
20
+
21
+ # 保存会话配置,并合并传输失败和设备认证失败模式。
22
+ def initialize(session, dialogue)
23
+ @session = session
24
+ @dialogue = dialogue
25
+ @config = session.configuration
26
+ @failures = CONNECTION_FAILURES.merge(
27
+ dialogue.authentication_errors.to_h { |pattern| [pattern, [AuthenticationError, :authentication_failed]] }
28
+ ).freeze
29
+ end
30
+
31
+ # 在登录提示或连接失败模式出现前读取登录响应。
32
+ def login
33
+ @session.reader.read(
34
+ prompt: @dialogue.login_prompt, interactions: login_interactions, failures: @failures,
35
+ timeout: @config.login_timeout, phase: :login
36
+ )
37
+ end
38
+
39
+ # 发送特权命令,并在截止时间内完成凭据交互。
40
+ def enable(command, prompt)
41
+ deadline = Expect.monotonic + @config.login_timeout
42
+ @session.write("#{command}\n", deadline: deadline, phase: :enable)
43
+ secret = @config.enable_password || @config.password
44
+ @session.reader.read(prompt: prompt, interactions: credential_interactions(secret), failures: @failures,
45
+ deadline: deadline, phase: :enable)
46
+ end
47
+
48
+ private
49
+
50
+ # 为用户名和密码创建一次性敏感交互规则。
51
+ def credential_interactions(secret)
52
+ [
53
+ Interaction.new(@dialogue.username_prompt, ->(_prompt) { input_line(@config.username) },
54
+ sensitive: true, limit: 1, capture: false),
55
+ Interaction.new(@dialogue.password_prompt, ->(_prompt) { input_line(secret) },
56
+ sensitive: true, limit: 1, capture: false)
57
+ ]
58
+ end
59
+
60
+ # 组装主机密钥确认、厂商挑战和凭据交互。
61
+ def login_interactions
62
+ host_key = Interaction.new(SSH_CONFIRMATION, lambda { |_prompt|
63
+ if @config.host_key_policy == :strict
64
+ raise @session.error(ConnectionError, "host key confirmation rejected",
65
+ phase: :login, code: :host_key_untrusted), cause: nil
66
+ end
67
+ "yes\n"
68
+ }, limit: 1)
69
+ [host_key, *@dialogue.login_interactions, *@config.challenges.map(&:challenge),
70
+ *credential_interactions(@config.password)]
71
+ end
72
+
73
+ # 将可选凭据编码为带换行的设备输入。
74
+ def input_line(secret) = secret && "#{secret}\n"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 旧加载路径保留为转发入口;设备定义位于 device/。
4
+ require_relative "../device/base"
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "dialogue"
5
+ require "expect/pty"
6
+
7
+ module Net
8
+ module Connector
9
+ # 一条设备命令行,可附带独立截止时间和交互规则。
10
+ class Command
11
+ attr_reader :text, :timeout, :interactions, :prompt, :source, :line
12
+
13
+ # 在任何输入输出前校验命令、提示、交互和敏感标记,并冻结命令。
14
+ def initialize(text, timeout: nil, interactions: [], prompt: nil, sensitive: false, source: nil, line: nil)
15
+ unless text.is_a?(String) && !text.strip.empty? && !text.match?(/[\r\n\x00]/)
16
+ raise ScriptError.new("a command must contain exactly one nonempty CLI line", phase: :parse,
17
+ source: source, line: line)
18
+ end
19
+ unless interactions.is_a?(Array) && interactions.all?(Interaction)
20
+ raise ArgumentError, "interactions must be an Array of Interaction objects"
21
+ end
22
+ valid_prompt = prompt.nil? || (prompt.is_a?(Regexp) && !prompt.match?(""))
23
+ unless valid_prompt
24
+ raise ArgumentError, "prompt must be a nonempty Regexp or nil"
25
+ end
26
+ raise ArgumentError, "sensitive must be true or false" unless [true, false].include?(sensitive)
27
+
28
+ @text = text.dup.freeze
29
+ @timeout = Expect.duration(timeout)
30
+ @interactions = interactions.dup.freeze
31
+ @prompt = prompt
32
+ @sensitive = sensitive
33
+ @source = source&.dup&.freeze
34
+ @line = line
35
+ freeze
36
+ end
37
+
38
+ # 判断命令文本是否需要脱敏。
39
+ def sensitive? = @sensitive
40
+
41
+ # 复制命令元数据,只替换命令文本。
42
+ def with_text(text)
43
+ self.class.new(text, timeout: timeout, interactions: interactions, prompt: prompt,
44
+ sensitive: sensitive?, source: source, line: line)
45
+ end
46
+
47
+ # 返回命令来源摘要,不展开命令内容。
48
+ def inspect = "#<#{self.class} source=#{source.inspect} line=#{line.inspect}>"
49
+ end
50
+
51
+ # 在任何输入输出前解析,不执行脚本,也不改写设备命令中的行内注释。
52
+ class Script
53
+ include Enumerable
54
+
55
+ attr_reader :name
56
+
57
+ # 按行过滤空行和整行注释,再构造带来源信息的命令。
58
+ def self.parse(text, name: nil)
59
+ raise ArgumentError, "script must be a String" unless text.is_a?(String)
60
+
61
+ commands = text.each_line.with_index(1).filter_map do |line, number|
62
+ line = line.chomp
63
+ next if line.strip.empty? || line.lstrip.start_with?("#")
64
+
65
+ Command.new(line, source: name, line: number)
66
+ end
67
+ new(commands, name: name)
68
+ end
69
+
70
+ # 读取脚本文件;文件错误转换为带来源的脚本错误。
71
+ def self.load(path)
72
+ parse(File.binread(path), name: File.expand_path(path))
73
+ rescue IOError, SystemCallError
74
+ raise ScriptError.new("unable to read script", phase: :parse, source: path.to_s), cause: nil
75
+ end
76
+
77
+ # 校验并冻结命令数组,给非 Command 输入补充来源和行号。
78
+ def initialize(commands, name: nil)
79
+ raise ArgumentError, "commands must be an Array" unless commands.is_a?(Array)
80
+
81
+ @name = name&.dup&.freeze
82
+ @commands = commands.each_with_index.map do |command, index|
83
+ command.is_a?(Command) ? command : Command.new(command, source: name, line: index + 1)
84
+ end.freeze
85
+ freeze
86
+ end
87
+
88
+ # 遍历脚本命令。
89
+ def each(&) = @commands.each(&)
90
+
91
+ # 判断脚本是否没有可执行命令。
92
+ def empty? = @commands.empty?
93
+
94
+ # 返回脚本命令数量。
95
+ def size = @commands.size
96
+
97
+ # 返回脚本名称和命令数量摘要。
98
+ def inspect = "#<#{self.class} name=#{name.inspect} commands=#{size}>"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expect/pty"
4
+ require_relative "dialogue"
5
+
6
+ module Net
7
+ module Connector
8
+ # 显式且不可变的连接设置;环境变量读取由调用库的可执行程序负责。
9
+ class Configuration
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,
12
+ :known_hosts, :host_key_policy, :challenges, :logger
13
+
14
+ # 校验端点、凭据、超时、日志、主机密钥和挑战配置,然后冻结设置。
15
+ def initialize(host: nil, username: nil, password: nil, enable_password: nil, protocol: :ssh, port: nil,
16
+ login_timeout: 10, command_timeout: nil, write_timeout: 10,
17
+ max_output_bytes: 32 * 1024 * 1024, log_file: nil, logger: nil,
18
+ log_format: :text, log_level: :info,
19
+ known_hosts: nil, host_key_policy: :strict, telnet_fallback: false, legacy_ssh: false,
20
+ challenges: [])
21
+ @host = frozen_string(host)
22
+ @username = frozen_string(username)
23
+ @password = single_line_credential(password)
24
+ @enable_password = single_line_credential(enable_password)
25
+ @protocol = enum_value(protocol, %i[ssh telnet], :protocol)
26
+ @port = port
27
+ valid_port = port.nil? || (port.is_a?(Integer) && (1..65_535).cover?(port))
28
+ unless valid_port
29
+ raise ArgumentError, "port must be in 1..65535"
30
+ end
31
+
32
+ @login_timeout = timeout_seconds(login_timeout, :login_timeout)
33
+ @command_timeout = command_timeout.nil? ? nil : timeout_seconds(command_timeout, :command_timeout)
34
+ @write_timeout = timeout_seconds(write_timeout, :write_timeout)
35
+ unless max_output_bytes.is_a?(Integer) && max_output_bytes.positive?
36
+ raise ArgumentError, "max_output_bytes must be a positive Integer"
37
+ end
38
+
39
+ @max_output_bytes = max_output_bytes
40
+ @log_file = absolute_path(log_file)
41
+ if logger && (!logger.respond_to?(:debug) || !logger.respond_to?(:info) ||
42
+ !logger.respond_to?(:warn) || !logger.respond_to?(:error) ||
43
+ !logger.respond_to?(:formatter) || !logger.respond_to?(:level))
44
+ raise ArgumentError, "logger must be Logger-compatible"
45
+ end
46
+ raise ArgumentError, "logger and log_file cannot be combined" if logger && @log_file
47
+ @log_format = enum_value(log_format, %i[raw text], :log_format)
48
+ raise ArgumentError, "logger cannot be combined with raw logging" if logger && @log_format == :raw
49
+
50
+ @logger = logger
51
+ @log_level = enum_value(log_level, %i[debug info warn error], :log_level)
52
+ @known_hosts = absolute_path(known_hosts)
53
+ @host_key_policy = enum_value(host_key_policy, %i[strict accept_new replace], :host_key_policy)
54
+ if @host_key_policy == :replace && !@known_hosts
55
+ raise ArgumentError, "host_key_policy :replace requires an explicit known_hosts file"
56
+ end
57
+
58
+ @telnet_fallback = boolean_value(telnet_fallback)
59
+ @legacy_ssh = boolean_value(legacy_ssh)
60
+ unless challenges.is_a?(Array) && challenges.all?(Interaction)
61
+ raise ArgumentError, "challenges must be an Array of Interaction objects"
62
+ end
63
+
64
+ @challenges = challenges.dup.freeze
65
+ freeze
66
+ end
67
+
68
+ # 判断是否允许 SSH 失败后回退到 Telnet。
69
+ def telnet_fallback? = @telnet_fallback
70
+
71
+ # 判断是否允许使用旧版 SSH 协商参数恢复。
72
+ def legacy_ssh? = @legacy_ssh
73
+
74
+ # 校验最终连接主机和用户名能安全进入外部命令参数。
75
+ def validate_endpoint!
76
+ unless host.is_a?(String) && /\A[A-Za-z0-9:][A-Za-z0-9.:%_-]*\z/.match?(host)
77
+ raise ArgumentError, "host must be a device address or hostname"
78
+ end
79
+ return if username.is_a?(String) && /\A[A-Za-z0-9_][A-Za-z0-9_.@$\\-]*\z/.match?(username)
80
+
81
+ raise ArgumentError, "username is empty or contains unsupported characters"
82
+ end
83
+
84
+ # 返回不包含凭据的连接配置摘要。
85
+ def inspect = "#<#{self.class} host=#{host.inspect} protocol=#{protocol.inspect}>"
86
+
87
+ private
88
+
89
+ # 将可选值校验为字符串副本。
90
+ def frozen_string(value)
91
+ return nil if value.nil?
92
+ raise ArgumentError, "expected a String or nil" unless value.is_a?(String)
93
+
94
+ value.dup.freeze
95
+ end
96
+
97
+ # 校验凭据是单行文本。
98
+ def single_line_credential(value)
99
+ value = frozen_string(value)
100
+ raise ArgumentError, "credentials must be a single line" if value&.match?(/[\r\n\x00]/)
101
+
102
+ value
103
+ end
104
+
105
+ # 将路径转换为绝对冻结文本。
106
+ def absolute_path(value) = value.nil? ? nil : File.expand_path(frozen_string(value)).freeze
107
+
108
+ # 将超时转换为有限秒数。
109
+ def timeout_seconds(value, name)
110
+ result = Expect.duration(value)
111
+ raise ArgumentError, "#{name} must be finite" unless result
112
+
113
+ result
114
+ end
115
+
116
+ # 校验布尔设置只能是 true 或 false。
117
+ def boolean_value(value)
118
+ raise ArgumentError, "expected true or false" unless [true, false].include?(value)
119
+
120
+ value
121
+ end
122
+
123
+ # 将枚举输入转换为允许的符号。
124
+ def enum_value(value, choices, name)
125
+ return value.to_sym if value.respond_to?(:to_sym) && choices.include?(value.to_sym)
126
+
127
+ raise ArgumentError, "#{name} must be one of #{choices.join(", ")}"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "dialogue"
5
+ require_relative "session"
6
+ require_relative "execution"
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "terminal_renderer"
5
+ require "expect/pty"
6
+
7
+ module Net
8
+ module Connector
9
+ # 一组提示与响应规则;可调用响应支持一次性密码等动态挑战。
10
+ class Interaction
11
+ attr_reader :pattern, :limit
12
+
13
+ # 校验提示模式、响应类型和次数限制,然后冻结规则定义。
14
+ def initialize(pattern, response, sensitive: false, limit: nil, capture: true)
15
+ unless pattern.is_a?(Regexp) && !pattern.match?("")
16
+ raise ArgumentError, "interaction pattern must be a nonempty Regexp"
17
+ end
18
+ unless response.is_a?(String) || response.respond_to?(:call)
19
+ raise ArgumentError, "response must be a String or callable"
20
+ end
21
+ valid_limit = limit.nil? || (limit.is_a?(Integer) && limit.positive?)
22
+ unless valid_limit
23
+ raise ArgumentError, "limit must be positive or nil"
24
+ end
25
+ unless [true, false].include?(sensitive) && [true, false].include?(capture)
26
+ raise ArgumentError, "sensitive and capture must be true or false"
27
+ end
28
+
29
+ @pattern = pattern
30
+ @response = response.is_a?(String) ? response.dup.freeze : response
31
+ @sensitive = sensitive
32
+ @limit = limit
33
+ @capture = capture
34
+ freeze
35
+ end
36
+
37
+ # 根据当前提示计算响应文本或调用动态响应函数。
38
+ def response(prompt) = @response.respond_to?(:call) ? @response.call(prompt) : @response
39
+
40
+ # 判断响应是否需要从日志和错误上下文中脱敏。
41
+ def sensitive? = @sensitive
42
+
43
+ # 判断提示文本是否应保留在响应输出中。
44
+ def capture? = @capture
45
+
46
+ # 返回不包含响应内容的规则摘要。
47
+ def inspect = "#<#{self.class} sensitive=#{sensitive?}>"
48
+
49
+ # 将普通交互转换为一次性的敏感挑战。
50
+ def challenge
51
+ self.class.new(pattern, @response, sensitive: true, limit: limit || 1, capture: false)
52
+ end
53
+ end
54
+
55
+ # 每台设备一份不可变对话语法,由厂商方法组装而不是合并默认值。
56
+ class Dialogue
57
+ attr_reader :login_prompt, :command_prompt, :password_prompt, :username_prompt, :enable_prompt,
58
+ :authentication_errors, :command_errors, :login_interactions, :command_interactions
59
+
60
+ # 校验提示和交互列表,并冻结整套设备对话语法。
61
+ def initialize(login_prompt:, command_prompt:, password_prompt:, username_prompt:, enable_prompt:,
62
+ authentication_errors:, command_errors:, login_interactions:, command_interactions:)
63
+ @login_prompt = login_prompt
64
+ @command_prompt = command_prompt
65
+ @password_prompt = password_prompt
66
+ @username_prompt = username_prompt
67
+ @enable_prompt = enable_prompt
68
+ [login_prompt, command_prompt, password_prompt, username_prompt, enable_prompt].each do |pattern|
69
+ raise ArgumentError, "prompt must be a nonempty Regexp" unless pattern.is_a?(Regexp) && !pattern.match?("")
70
+ end
71
+ @authentication_errors = authentication_errors.freeze
72
+ @command_errors = command_errors.freeze
73
+ @login_interactions = login_interactions.freeze
74
+ @command_interactions = command_interactions.freeze
75
+ freeze
76
+ end
77
+
78
+ # 从命令输出中返回包含错误模式的诊断行,而不是光标或此前全部输出。
79
+ def diagnostic(output)
80
+ return if command_errors.empty?
81
+
82
+ # 先保留被回车覆写的失败,再识别颜色或退格处理后的可见诊断。
83
+ raw = output.gsub(/\r(?!\n)/, "\n")
84
+ diagnostic_line(raw) || (diagnostic_line(TerminalRenderer.render(output)) if output.match?(/[\e\b]/))
85
+ end
86
+
87
+ private
88
+
89
+ def diagnostic_line(output)
90
+ match = command_errors.lazy.filter_map { |pattern| pattern.match(output) }.first
91
+ return unless match
92
+
93
+ start = match.begin(0).positive? ? output.rindex(/[\r\n]/, match.begin(0) - 1) : nil
94
+ start = start ? start + 1 : 0
95
+ finish = output.index(/[\r\n]/, match.end(0)) || output.bytesize
96
+ output.byteslice(start, finish - start).strip
97
+ end
98
+ end
99
+
100
+ # 在厂商清理前保留原始字节,供诊断和日志使用。
101
+ class Response
102
+ attr_reader :raw, :output, :prompt
103
+
104
+ # 保存原始响应、清理后输出和命令结束提示,并冻结三者。
105
+ def initialize(raw:, output:, prompt:)
106
+ @raw = raw.freeze
107
+ @output = output.freeze
108
+ @prompt = prompt.freeze
109
+ freeze
110
+ end
111
+
112
+ # 返回响应大小摘要,不展开原始内容。
113
+ def inspect = "#<#{self.class} bytes=#{raw.bytesize}>"
114
+ end
115
+
116
+ # 在传输层上执行有界对话;收到字节或分页标记不会重置截止时间。
117
+ class ResponseReader
118
+ # 保存会话引用,以便读取器使用传输、配置和脱敏上下文。
119
+ def initialize(session)
120
+ @session = session
121
+ end
122
+
123
+ # 读取到结束提示、失败模式或超时,并处理交互响应和输出上限。
124
+ def read(prompt:, interactions:, phase:, timeout: nil, deadline: nil, command: nil, failures: {})
125
+ deadline ||= Expect.monotonic + timeout
126
+ patterns = [*failures.keys, *interactions.map(&:pattern), prompt]
127
+ raw = []
128
+ output = []
129
+ counts = Hash.new(0)
130
+ bytes = 0
131
+ loop do
132
+ event = @session.transport.read(patterns, timeout: [deadline - Expect.monotonic, 0].max)
133
+ raw << event.before unless event.before.empty?
134
+ output << event.before unless event.before.empty?
135
+ bytes += event.before.bytesize + event.match.bytesize
136
+ if bytes > @session.configuration.max_output_bytes
137
+ raise @session.error(OutputLimitExceeded, "device output exceeded max_output_bytes",
138
+ phase: phase, command: command, output: (raw + [event.match]).join), cause: nil
139
+ end
140
+ raise read_error(event, phase, command, raw.join), cause: nil unless event.matched?
141
+
142
+ if event.index == patterns.size
143
+ raw << event.match
144
+ output << event.match
145
+ elsif event.index == patterns.size - 1
146
+ if event.match.empty?
147
+ raise @session.error(PromptError, "prompt pattern did not consume any output",
148
+ phase: phase, command: command, output: raw.join), cause: nil
149
+ end
150
+
151
+ raw << event.match
152
+ output << event.match
153
+ raw_text = raw.join
154
+ return Response.new(raw: raw_text, output: (output == raw) ? raw_text : output.join, prompt: event.match)
155
+ elsif event.index < failures.size
156
+ klass, code = failures.values.fetch(event.index)
157
+ raise @session.error(klass, "device connection failed (#{code})", phase: phase,
158
+ code: code, command: command), cause: nil
159
+ else
160
+ reply = interactions.fetch(event.index - failures.size)
161
+ raw << event.match
162
+ output << event.match if reply.capture?
163
+ counts[reply] += 1
164
+ if event.match.empty? || (reply.limit && counts[reply] > reply.limit)
165
+ raise interaction_error("response rejected or repeated", phase, command), cause: nil
166
+ end
167
+
168
+ value = interaction_response(reply, event.match, phase, command)
169
+ raise interaction_error("response unavailable", phase, command), cause: nil unless value.is_a?(String)
170
+
171
+ @session.redactor.remember(value.chomp) if reply.sensitive?
172
+ @session.write(value, deadline: deadline, phase: phase, command: command)
173
+ end
174
+ next if Expect.monotonic < deadline
175
+
176
+ raise read_error(nil, phase, command, raw.join), cause: nil
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ # 动态口令尚未返回时无法加入词表,须在敏感范围内先归一化回调异常。
183
+ # 成功后由调用方登记响应,临时敏感标记不会影响后续普通命令的诊断。
184
+ def interaction_response(reply, prompt, phase, command)
185
+ return reply.response(prompt) unless reply.sensitive?
186
+
187
+ @session.redactor.scope(reuse: true) do
188
+ @session.redactor.sensitive!
189
+ reply.response(prompt)
190
+ rescue => error
191
+ raise @session.normalize_error(error, phase: phase, command: command), cause: nil
192
+ end
193
+ end
194
+
195
+ # 将交互拒绝或响应缺失转换为认证或脚本错误。
196
+ def interaction_error(message, phase, command)
197
+ authenticating = %i[login enable].include?(phase)
198
+ klass = authenticating ? AuthenticationError : ScriptError
199
+ label = authenticating ? "authentication" : "command interaction"
200
+ @session.error(klass, "#{label} #{message}", phase: phase, command: command)
201
+ end
202
+
203
+ # 根据 EOF、提示符、阶段和底层异常选择具体读取错误类型。
204
+ def read_error(event, phase, command, output)
205
+ if event&.error.is_a?(Exception)
206
+ return @session.error(TransportError, "transport read failed", phase: phase, command: command,
207
+ underlying: event.error, output: output)
208
+ end
209
+ klass = if event&.error == :eof
210
+ ConnectionClosed
211
+ elsif command&.prompt
212
+ PromptError
213
+ elsif %i[login enable].include?(phase)
214
+ LoginTimeout
215
+ else
216
+ CommandTimeout
217
+ end
218
+ @session.error(klass, "device response #{event&.error || :timeout}", phase: phase,
219
+ command: command, output: output)
220
+ end
221
+ end
222
+ end
223
+ end