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,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,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,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
|