net-connector 0.4.2 → 0.5.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 +17 -1
- data/README.md +42 -19
- data/docs/VERIFICATION.md +17 -7
- data/docs/architecture.md +96 -64
- 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 +1 -1
- data/lib/net/connector/engine/configuration.rb +1 -1
- data/lib/net/connector/engine/dialogue.rb +17 -17
- 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 +15 -8
- 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 +7 -9
- data/lib/net/connector/netdisco/client.rb +2 -2
- 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 +0 -6
- 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 +25 -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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../engine/errors"
|
|
5
|
+
require_relative "../storage"
|
|
6
|
+
|
|
7
|
+
module Net
|
|
8
|
+
module Connector
|
|
9
|
+
Backup = Data.define(:path, :bytes, :sha256, :collected_at, :change, :previous_sha256) do
|
|
10
|
+
# 保存配置备份的路径、摘要和变更状态。
|
|
11
|
+
def initialize(path:, bytes:, sha256:, collected_at:, change: nil, previous_sha256: nil)
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# 判断本次采集是否产生新的配置内容。
|
|
16
|
+
def changed? = [:created, :changed].include?(change)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# 只携带库自身的提交回执和配置元数据,不携带正文或原始文件系统异常。
|
|
20
|
+
class BackupPersistenceError < Error
|
|
21
|
+
attr_reader :backup, :receipt, :underlying_type
|
|
22
|
+
|
|
23
|
+
def initialize(backup:, write_error:, host: nil)
|
|
24
|
+
unless backup.instance_of?(Backup) && Storage::PrivateFile.receipt_error?(write_error) &&
|
|
25
|
+
write_error.receipt.committed? && write_error.receipt.path == backup.path
|
|
26
|
+
raise ArgumentError, "backup persistence error requires a matching committed backup receipt"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@backup, @receipt = backup, write_error.receipt
|
|
30
|
+
@underlying_type = write_error.underlying_type
|
|
31
|
+
code = if write_error.instance_of?(Storage::PrivateFile::DirectorySyncUnsupported)
|
|
32
|
+
:backup_durability_unsupported
|
|
33
|
+
elsif receipt.durable?
|
|
34
|
+
:backup_finalize_failed
|
|
35
|
+
else
|
|
36
|
+
:backup_persistence_unconfirmed
|
|
37
|
+
end
|
|
38
|
+
super(write_error.message, code: code, host: host, phase: :backup)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class LocalBackup
|
|
43
|
+
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
44
|
+
module Capability
|
|
45
|
+
# 采集配置并以原子方式保存为私有文件。
|
|
46
|
+
# 采集失败时保留已有备份文件。
|
|
47
|
+
def backup(path:, lock_timeout: 0)
|
|
48
|
+
@session.assert_path_lock_order!(:backup)
|
|
49
|
+
LocalBackup.new(self).call(path: path, lock_timeout: lock_timeout)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 保存执行配置采集的设备对象。
|
|
54
|
+
def initialize(device) = @device = device
|
|
55
|
+
|
|
56
|
+
# 采集配置、识别变更并原子写入私有文件。
|
|
57
|
+
def call(path:, lock_timeout: 0)
|
|
58
|
+
host = @device.host if @device.respond_to?(:host)
|
|
59
|
+
Storage::BackupLock.synchronize(path, timeout: lock_timeout, host: host) { |lock| collect_and_write(lock.path) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def collect_and_write(destination)
|
|
65
|
+
contents = @device.running_config.value!
|
|
66
|
+
digest = Digest::SHA256.hexdigest(contents)
|
|
67
|
+
previous = Storage::SafeFile.fingerprint(destination, missing: true, replace_symlink: true)
|
|
68
|
+
previous_digest = previous&.sha256
|
|
69
|
+
change = if previous_digest.nil?
|
|
70
|
+
:created
|
|
71
|
+
elsif previous_digest == digest
|
|
72
|
+
:unchanged
|
|
73
|
+
else
|
|
74
|
+
:changed
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
backup = Backup.new(path: destination, bytes: contents.bytesize,
|
|
78
|
+
sha256: digest, collected_at: Time.now.utc, change: change,
|
|
79
|
+
previous_sha256: previous_digest)
|
|
80
|
+
if change != :unchanged || previous.mode != 0o600 || !Storage::SafeFile.same_entry?(destination, previous)
|
|
81
|
+
write_backup(backup, contents)
|
|
82
|
+
end
|
|
83
|
+
backup
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_backup(backup, contents)
|
|
87
|
+
Storage::PrivateFile.write(backup.path, contents)
|
|
88
|
+
rescue Storage::PrivateFile::WriteError => error
|
|
89
|
+
raise unless Storage::PrivateFile.receipt_error?(error) && error.receipt.committed?
|
|
90
|
+
|
|
91
|
+
host = @device.host if @device.respond_to?(:host)
|
|
92
|
+
raise BackupPersistenceError.new(backup: backup, write_error: error, host: host), cause: nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -65,13 +65,18 @@ module Net
|
|
|
65
65
|
@command_timeout = duration(command_timeout, :command_timeout)
|
|
66
66
|
@terminal_size = validate_terminal_size(terminal_size)
|
|
67
67
|
@running_config_strategy = strategy_class(running_config_strategy, :running_config_strategy,
|
|
68
|
-
%i[clean result_step prompt_text
|
|
68
|
+
%i[clean result_step prompt_text validate_response!])
|
|
69
69
|
@tftp_strategy = strategy_class(tftp_strategy, :tftp_strategy,
|
|
70
|
-
%i[
|
|
70
|
+
%i[resolve_source_file default_path remote_path script device_reported_complete? validate_options! receipt_metadata])
|
|
71
|
+
if @tftp_strategy && !@tftp_strategy.respond_to?(:filename)
|
|
72
|
+
raise ArgumentError, "tftp_strategy must expose filename"
|
|
73
|
+
end
|
|
71
74
|
@topology_strategy = strategy_class(topology_strategy, :topology_strategy,
|
|
72
75
|
%i[neighbor_command neighbor_template expected_neighbor_count empty_neighbor_output?
|
|
73
76
|
protocol description_template interface_key configuration_interface
|
|
74
|
-
decode_description
|
|
77
|
+
decode_description validate_descriptions! change_error_code supports?
|
|
78
|
+
enter_configuration_command change_commands script_command
|
|
79
|
+
leave_configuration_commands verification_commands persistence_commands persistence_confirmed?])
|
|
75
80
|
if @topology_strategy && !@topology_strategy.respond_to?(:supports?)
|
|
76
81
|
raise ArgumentError, "topology_strategy must expose supports?"
|
|
77
82
|
end
|
|
@@ -7,6 +7,45 @@ require_relative "running_config/strategy"
|
|
|
7
7
|
module Net
|
|
8
8
|
module Connector
|
|
9
9
|
class RunningConfig
|
|
10
|
+
# 设备只引入此能力;采集入口和策略作用域与采集流程一起维护。
|
|
11
|
+
module Capability
|
|
12
|
+
# 每次采集使用独立执行对象,设备只提供这一采集入口。
|
|
13
|
+
def running_config = RunningConfig.new(self).call
|
|
14
|
+
|
|
15
|
+
# 返回读取运行配置所需的设备命令;厂商必须声明。
|
|
16
|
+
def config_commands
|
|
17
|
+
commands = profile.config_commands
|
|
18
|
+
return commands if commands
|
|
19
|
+
|
|
20
|
+
raise NotImplementedError, "#{self.class} must define running configuration commands"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 子类可覆盖并调用 super,复用本次响应检查积累的策略状态。
|
|
24
|
+
def clean_config(text) = config_strategy.clean(text)
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
# 默认取最后一个完成步骤;厂商策略可选择配置所在的业务步骤。
|
|
29
|
+
def config_result_step(result) = config_strategy.result_step(result)
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# 仅在持有会话锁的结果处理阶段绑定策略,退出时恢复原有作用域。
|
|
34
|
+
# 同一设备上其他 Fiber 的离线清理不能借用此次采集状态。
|
|
35
|
+
def with_config_strategy(strategy)
|
|
36
|
+
previous = @config_strategy_scope
|
|
37
|
+
@config_strategy_scope = [Fiber.current, strategy]
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
@config_strategy_scope = previous
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def config_strategy
|
|
44
|
+
scope = @config_strategy_scope
|
|
45
|
+
scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
10
49
|
# 保存需要读取运行配置的设备对象。
|
|
11
50
|
def initialize(device)
|
|
12
51
|
@device = device
|
|
@@ -20,14 +59,14 @@ module Net
|
|
|
20
59
|
# 每次采集创建独立策略;响应校验、选择与清理共享状态,均在同一脚本锁内完成。
|
|
21
60
|
def call
|
|
22
61
|
script = Script.new(@device.config_commands)
|
|
23
|
-
return Result.new(error:
|
|
62
|
+
return Result.new(error: build_incomplete_error("configuration collection has no commands")) if script.empty?
|
|
24
63
|
|
|
25
64
|
# 所有厂商的采集步骤都收紧输出边界,包括候选差异、模式切换及扩展查询。
|
|
26
65
|
script = Script.new(script.map(&:with_output_sensitive))
|
|
27
66
|
strategy = self.class.strategy(@device)
|
|
28
67
|
prompt = ->(command) { prompt_for(command, strategy) }
|
|
29
68
|
@device.execute_operation(script, name: :running_config, prompt: prompt,
|
|
30
|
-
after_command: strategy.method(:
|
|
69
|
+
after_command: strategy.method(:validate_response!)) do |result|
|
|
31
70
|
@device.send(:with_config_strategy, strategy) do
|
|
32
71
|
finish(result)
|
|
33
72
|
end
|
|
@@ -39,11 +78,11 @@ module Net
|
|
|
39
78
|
# 只从完成的配置步骤提取非空内容,并保留此前所有步骤。
|
|
40
79
|
def finish(result)
|
|
41
80
|
step = @device.send(:config_result_step, result)
|
|
42
|
-
raise
|
|
81
|
+
raise build_incomplete_error("configuration collection has no completed configuration step") unless step
|
|
43
82
|
|
|
44
83
|
content = @device.clean_config(step.output)
|
|
45
84
|
unless content.is_a?(String) && !content.strip.empty? && content?(step)
|
|
46
|
-
raise
|
|
85
|
+
raise build_incomplete_error("configuration collection returned empty content")
|
|
47
86
|
end
|
|
48
87
|
Result.new(steps: result.steps, config: content)
|
|
49
88
|
end
|
|
@@ -68,7 +107,7 @@ module Net
|
|
|
68
107
|
end
|
|
69
108
|
|
|
70
109
|
# 将缺少配置的情况统一映射为可识别的设备错误码。
|
|
71
|
-
def
|
|
110
|
+
def build_incomplete_error(message)
|
|
72
111
|
DeviceError.new(message, code: :incomplete_configuration, host: @device.host, phase: :collect)
|
|
73
112
|
end
|
|
74
113
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Net
|
|
4
|
+
module Connector
|
|
5
|
+
# 保存命令及能力入口,响应语义由通用执行器负责。
|
|
6
|
+
module SaveConfig
|
|
7
|
+
# 执行厂商保存配置命令;不支持时返回显式失败结果。
|
|
8
|
+
def save_config
|
|
9
|
+
if save_commands.empty?
|
|
10
|
+
return Result.new(error: @session.build_error(UnsupportedOperation, "saving configuration is not supported",
|
|
11
|
+
phase: :save))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
execute_script(save_commands)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 返回保存配置命令;空数组表示设备不支持保存。
|
|
18
|
+
def save_commands = profile.save_commands
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "strategy"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Tftp
|
|
8
|
+
# H3C 和华为共用已保存文件上传流程,源文件发现仍由厂商策略负责。
|
|
9
|
+
class FileUpload < Strategy
|
|
10
|
+
# 未指定目标名时,设备使用配置源文件的 basename。
|
|
11
|
+
def default_path(source_file) = File.basename(source_file)
|
|
12
|
+
|
|
13
|
+
# H3C 探测源文件之前先拒绝不支持的参数;探测本身不能放进纯校验钩子。
|
|
14
|
+
def validate_options!(_target, source_file:, vrf: nil)
|
|
15
|
+
raise ArgumentError, "TFTP file upload does not use vrf" unless vrf.nil?
|
|
16
|
+
|
|
17
|
+
TftpTarget.validate_source_file!(source_file) unless source_file.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 指定文件的内容与格式未知,不能仅凭 .cfg 后缀声称是当前运行配置。
|
|
21
|
+
def receipt_metadata(_target, source_file:, **)
|
|
22
|
+
{ configuration_kind: :saved_file, source_file: source_file, format: :unknown }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 仅在调用方指定目标名时追加参数,保留设备原生命名行为。
|
|
26
|
+
def script(target, source_file:, vrf: nil)
|
|
27
|
+
validate_options!(target, source_file: source_file, vrf: vrf)
|
|
28
|
+
TftpTarget.validate_source_file!(source_file)
|
|
29
|
+
|
|
30
|
+
destination = target.explicit_path? ? " #{target.path}" : ""
|
|
31
|
+
Script.new([Command.new("tftp #{target.host} put #{source_file}#{destination}", timeout: 180)])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 只接受明确的成功消息、非零传输字节数或完整进度行。
|
|
35
|
+
def device_reported_complete?(result)
|
|
36
|
+
completion_lines(result).any? { |line| completed_upload?(line) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# 两个厂商的上传完成消息使用相同的文本及进度格式。
|
|
42
|
+
def completed_upload?(line)
|
|
43
|
+
line.match?(/\A
|
|
44
|
+
(?:File\s+)?(?:transfer|upload)(?:\s+(?:is|was))?\s+
|
|
45
|
+
(?:success(?:ful(?:ly)?)?|succeeded|complete(?:d)?(?:\s+successfully)?)[.!]?
|
|
46
|
+
\z/ix) ||
|
|
47
|
+
line.match?(/\A[1-9]\d*\s+bytes?\s+sent(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?))?[.!]?\z/i) ||
|
|
48
|
+
completed_progress?(line)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "../../engine/errors"
|
|
4
|
+
require_relative "target"
|
|
4
5
|
|
|
5
6
|
module Net
|
|
6
7
|
module Connector
|
|
7
|
-
#
|
|
8
|
+
# 一次设备上报上传的不可变回执;path 是实际目标,未知时保持 nil。
|
|
9
|
+
TftpReceipt = Data.define(:server, :path, :completed_at, :configuration_kind, :source_file,
|
|
10
|
+
:format, :requested_path, :verification, :server_sha256)
|
|
11
|
+
|
|
8
12
|
class TftpReceipt
|
|
9
13
|
KINDS = %i[running startup saved_file native_archive unknown].freeze
|
|
10
14
|
FORMATS = %i[cfg xml dat tgz unknown].freeze
|
|
11
15
|
private_constant :KINDS, :FORMATS
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
def initialize(server:, path:, completed_at:, configuration_kind: :unknown, source_file: nil,
|
|
18
|
+
format: :unknown, requested_path: nil, verification: :device_reported, server_sha256: nil)
|
|
19
|
+
raise ArgumentError, "completed_at must be a Time" unless completed_at.is_a?(Time)
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
requested_path: nil, actual_path:, verification: :device_reported, server_sha256: nil)
|
|
18
|
-
unless transfer.instance_of?(TftpBackup) && transfer.completed_at.is_a?(Time) && transfer.path == actual_path
|
|
19
|
-
raise ArgumentError, "TFTP receipt requires a matching transfer"
|
|
20
|
-
end
|
|
21
|
-
TftpTarget.new(host: transfer.server, path: actual_path.nil? ? "unconfirmed" : actual_path)
|
|
21
|
+
TftpTarget.new(host: server, path: path.nil? ? "unconfirmed" : path)
|
|
22
22
|
TftpTarget.validate_path!(requested_path) unless requested_path.nil?
|
|
23
23
|
TftpTarget.validate_source_file!(source_file) unless source_file.nil?
|
|
24
24
|
unless KINDS.include?(configuration_kind) && FORMATS.include?(format)
|
|
25
25
|
raise ArgumentError, "invalid TFTP configuration kind or format"
|
|
26
26
|
end
|
|
27
27
|
valid_verification = (verification == :device_reported && server_sha256.nil?) ||
|
|
28
|
-
(verification == :server_verified && !
|
|
28
|
+
(verification == :server_verified && !path.nil? && server_sha256.is_a?(String) &&
|
|
29
29
|
server_sha256.match?(/\A[0-9a-f]{64}\z/))
|
|
30
30
|
raise ArgumentError, "invalid TFTP verification evidence" unless valid_verification
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
completed_at: transfer.completed_at.dup.freeze)
|
|
37
|
-
freeze
|
|
32
|
+
super(server: server.dup.freeze, path: path&.dup&.freeze, completed_at: completed_at.dup.freeze,
|
|
33
|
+
configuration_kind: configuration_kind, source_file: source_file&.dup&.freeze, format: format,
|
|
34
|
+
requested_path: requested_path&.dup&.freeze, verification: verification,
|
|
35
|
+
server_sha256: server_sha256&.dup&.freeze)
|
|
38
36
|
end
|
|
39
37
|
|
|
40
|
-
#
|
|
38
|
+
# Ruby 3.2 的 Data#with 不调用 initialize;所有更新都重新校验并冻结字段。
|
|
39
|
+
def with(**attributes) = self.class.new(**to_h.merge(attributes))
|
|
40
|
+
|
|
41
41
|
def inspect = "#<#{self.class} kind=#{configuration_kind} format=#{format} verification=#{verification}>"
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -66,8 +66,6 @@ module Net
|
|
|
66
66
|
@underlying_type = type&.match?(/\A[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*\z/) && type.bytesize <= 128 ? type.freeze : nil
|
|
67
67
|
super(MESSAGES.fetch(code), code: code, host: host, phase: :tftp_backup)
|
|
68
68
|
end
|
|
69
|
-
|
|
70
|
-
def transfer = receipt.transfer
|
|
71
69
|
end
|
|
72
70
|
end
|
|
73
71
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../engine/command"
|
|
4
|
+
require_relative "target"
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
class Tftp
|
|
8
|
+
class Strategy
|
|
9
|
+
# 厂商只声明格式差异,清单规划与设备直连共用同一命名规则。
|
|
10
|
+
def self.file_extension = "cfg"
|
|
11
|
+
|
|
12
|
+
# 不创建会话即可生成目标名,供批量计划检查文件覆盖风险。
|
|
13
|
+
def self.filename(host, label: nil)
|
|
14
|
+
TftpTarget.filename(host, extension: file_extension, label: label)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 保存 TFTP 厂商策略使用的设备对象。
|
|
18
|
+
def initialize(device)
|
|
19
|
+
@device = device
|
|
20
|
+
@responses = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 返回调用方指定的配置源文件。
|
|
24
|
+
def resolve_source_file(value) = value
|
|
25
|
+
|
|
26
|
+
# 根据设备地址生成默认远端文件名。
|
|
27
|
+
def default_path(_source_file) = self.class.filename(@device.host)
|
|
28
|
+
|
|
29
|
+
# 返回目标中指定的远端文件名。
|
|
30
|
+
def remote_path(target, _result) = target.path
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# 记录脚本的静态响应,避免文件名或 VRF 回显成为传输完成证据。
|
|
35
|
+
def interaction(pattern, response)
|
|
36
|
+
@responses << response.chomp
|
|
37
|
+
Interaction.new(pattern, response, capture: false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 只从设备消息中核对完成状态;命令、交互响应和最终提示均不是传输证据。
|
|
41
|
+
def completion_lines(result)
|
|
42
|
+
step = result.steps.last
|
|
43
|
+
return [] unless step
|
|
44
|
+
|
|
45
|
+
output = TerminalRenderer.render(step.output)
|
|
46
|
+
prompt = TerminalRenderer.render(step.prompt.to_s)
|
|
47
|
+
output = output.delete_suffix(prompt) unless prompt.empty?
|
|
48
|
+
echoes = [step.command.text, "#{prompt.strip}#{step.command.text}", *@responses]
|
|
49
|
+
output.each_line.map(&:strip).reject { |line| line.empty? || echoes.include?(line) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 只接受字节数非零、已发送量与总量相同的 curl 完成进度行。
|
|
53
|
+
def completed_progress?(line)
|
|
54
|
+
line.match?(/\A100\s+([1-9]\d*(?:\.\d+)?[kMGT]?)\s+0\s+0\s+100\s+\1(?:\s+[\d.:kMGT-]+)*\z/i)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cisco 明确报告复制完成的字节数,可附带耗时和速率。
|
|
58
|
+
def copied_bytes?(line)
|
|
59
|
+
line.match?(/\A
|
|
60
|
+
[1-9]\d*\s+bytes?\s+copied
|
|
61
|
+
(?:\s+in\s+[\d.]+\s+(?:secs?|seconds?)(?:\s+\([\d.]+\s+bytes\/sec\))?)?[.!]?
|
|
62
|
+
\z/ix)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
# 设备主动上传使用的 TFTP 目标,服务器必须能被设备访问。
|
|
8
|
+
class TftpTarget
|
|
9
|
+
MAX_PATH_BYTES = 220
|
|
10
|
+
|
|
11
|
+
attr_reader :host, :path
|
|
12
|
+
|
|
13
|
+
# 校验并保存 TFTP 服务器、远端路径及显式文件名标记。
|
|
14
|
+
def initialize(host:, path:, explicit_path: true)
|
|
15
|
+
unless host.is_a?(String) && host.bytesize <= 253 &&
|
|
16
|
+
host.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\z/) && !host.include?("..")
|
|
17
|
+
raise ArgumentError, "TFTP host must be an IPv4 address or DNS name"
|
|
18
|
+
end
|
|
19
|
+
self.class.validate_path!(path)
|
|
20
|
+
@host, @path = host.dup.freeze, path.dup.freeze
|
|
21
|
+
@explicit_path = explicit_path
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 判断调用方是否明确指定了远端文件名。
|
|
26
|
+
def explicit_path? = @explicit_path
|
|
27
|
+
|
|
28
|
+
# 校验设备或调用方给出的远端相对路径。
|
|
29
|
+
def self.validate_path!(path)
|
|
30
|
+
unless path.is_a?(String) && path.bytesize <= MAX_PATH_BYTES &&
|
|
31
|
+
path.match?(/\A[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\z/) &&
|
|
32
|
+
path.split("/").none? { |part| %w[. ..].include?(part) }
|
|
33
|
+
raise ArgumentError, "TFTP path must be a relative filename or path without traversal"
|
|
34
|
+
end
|
|
35
|
+
path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 设备地址和可选清单名称共用目标长度限制;极长地址用稳定摘要表示。
|
|
39
|
+
def self.filename(host, extension:, label: nil)
|
|
40
|
+
if label && (!label.is_a?(String) || !label.ascii_only?)
|
|
41
|
+
raise ArgumentError, "TFTP label must be an ASCII String"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
address = host.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
45
|
+
suffix = "#{address}.#{extension}"
|
|
46
|
+
if suffix.bytesize > MAX_PATH_BYTES - (label ? 2 : 0)
|
|
47
|
+
suffix = "#{Digest::SHA256.hexdigest(host)}.#{extension}"
|
|
48
|
+
end
|
|
49
|
+
path = label ? "#{label.byteslice(0, MAX_PATH_BYTES - suffix.bytesize - 1)}-#{suffix}" : suffix
|
|
50
|
+
validate_path!(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 校验设备源文件路径是否符合命令安全约束。
|
|
54
|
+
def self.validate_source_file!(value)
|
|
55
|
+
unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9._:\/-]+\z/) &&
|
|
56
|
+
!value.split("/").include?("..")
|
|
57
|
+
raise ArgumentError, "source_file must be a device file path without spaces or traversal"
|
|
58
|
+
end
|
|
59
|
+
value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tftp/strategy"
|
|
4
|
+
require_relative "tftp/receipt"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class Tftp
|
|
9
|
+
# 设备入口与功能实现共置,Base 只组合能力,不重复业务流程。
|
|
10
|
+
module Capability
|
|
11
|
+
# 要求设备直接向 TFTP 服务器导出原生配置。
|
|
12
|
+
# 完成仅表示设备报告传输成功,未读取服务器端文件。
|
|
13
|
+
def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
|
|
14
|
+
Tftp.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 保存执行 TFTP 导出的设备对象。
|
|
19
|
+
def initialize(device) = @device = device
|
|
20
|
+
|
|
21
|
+
# 参数先纯校验;源探测、上传、证据核对及收尾共用一次会话租约。
|
|
22
|
+
def call(host:, path: nil, source_file: nil, vrf: nil)
|
|
23
|
+
receipt = nil
|
|
24
|
+
strategy_class = @device.profile.tftp_strategy
|
|
25
|
+
unless strategy_class
|
|
26
|
+
raise UnsupportedOperation.new("native TFTP backup is not supported for this vendor",
|
|
27
|
+
host: @device.host, phase: :tftp_backup)
|
|
28
|
+
end
|
|
29
|
+
strategy = strategy_class.new(@device)
|
|
30
|
+
if !vrf.nil? && (!vrf.is_a?(String) || !vrf.match?(/\A[A-Za-z0-9_][A-Za-z0-9_.-]*\z/))
|
|
31
|
+
raise ArgumentError, "vrf must be a single safe name"
|
|
32
|
+
end
|
|
33
|
+
source_file = TftpTarget.validate_source_file!(source_file).dup.freeze unless source_file.nil?
|
|
34
|
+
vrf = vrf&.dup&.freeze
|
|
35
|
+
target = TftpTarget.new(host: host, path: path.nil? ? "preflight.cfg" : path, explicit_path: !path.nil?)
|
|
36
|
+
strategy.validate_options!(target, source_file: source_file, vrf: vrf)
|
|
37
|
+
@device.with_operation(:tftp_backup) do
|
|
38
|
+
source = strategy.resolve_source_file(source_file)
|
|
39
|
+
source = TftpTarget.validate_source_file!(source).dup.freeze unless source.nil?
|
|
40
|
+
target = TftpTarget.new(host: target.host, path: strategy.default_path(source), explicit_path: false) if path.nil?
|
|
41
|
+
result = @device.execute_operation(strategy.script(target, source_file: source, vrf: vrf),
|
|
42
|
+
name: :tftp_backup, privilege: false)
|
|
43
|
+
confirm_transfer!(strategy, target, result)
|
|
44
|
+
# 一旦看到明确完成证据就保存回执;路径解析或用户钩子失败也不能抹去上传事实。
|
|
45
|
+
requested_path = target.explicit_path? ? target.path : nil
|
|
46
|
+
receipt = TftpReceipt.new(server: target.host, path: nil, completed_at: Time.now.utc,
|
|
47
|
+
requested_path: requested_path, source_file: source)
|
|
48
|
+
actual_path = remote_path(strategy, target, result, receipt)
|
|
49
|
+
receipt = receipt.with(path: actual_path)
|
|
50
|
+
receipt = build_receipt(strategy, target, source, !source_file.nil?, receipt)
|
|
51
|
+
if target.explicit_path? && target.path != receipt.path
|
|
52
|
+
raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_mismatch, host: @device.host), cause: nil
|
|
53
|
+
end
|
|
54
|
+
# 已完成步骤仍可能伴随后处理/清理错误;上面的回执要先构造,再重新抛出。
|
|
55
|
+
result.value! if result.failure?
|
|
56
|
+
@device.log_event("tftp_backup", status: "reported_uploaded", phase: :tftp_backup,
|
|
57
|
+
server: target.host, path: receipt.path)
|
|
58
|
+
receipt
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => error
|
|
61
|
+
raise unless receipt
|
|
62
|
+
raise if error.instance_of?(TftpCompletionError) && error.receipt.equal?(receipt)
|
|
63
|
+
|
|
64
|
+
raise TftpCompletionError.new(receipt: receipt, host: @device.host, underlying: error), cause: nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_receipt(strategy, target, source_file, explicit_source, receipt)
|
|
70
|
+
metadata = strategy.receipt_metadata(target, source_file: source_file, explicit_source: explicit_source)
|
|
71
|
+
unless metadata.is_a?(Hash) && (metadata.keys - %i[configuration_kind source_file format requested_path]).empty?
|
|
72
|
+
raise ArgumentError, "invalid TFTP strategy receipt metadata"
|
|
73
|
+
end
|
|
74
|
+
receipt.with(**{ source_file: source_file, requested_path: target.path }.merge(metadata))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remote_path(strategy, target, result, receipt)
|
|
78
|
+
TftpTarget.validate_path!(strategy.remote_path(target, result))
|
|
79
|
+
rescue StandardError => error
|
|
80
|
+
raise TftpCompletionError.new(receipt: receipt, code: :transfer_path_unconfirmed,
|
|
81
|
+
host: @device.host, underlying: error), cause: nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 失败证据优先;步骤中的明确完成行可在随后清理失败时证明设备已经报告上传。
|
|
85
|
+
def confirm_transfer!(strategy, target, result)
|
|
86
|
+
if transfer_failed?(result)
|
|
87
|
+
@device.log_event("tftp_backup", level: :error, status: "transfer_failed", phase: :tftp_backup,
|
|
88
|
+
code: :transfer_failed, server: target.host)
|
|
89
|
+
raise DeviceError.new("device reported TFTP backup failure",
|
|
90
|
+
code: :transfer_failed, host: @device.host, phase: :tftp_backup)
|
|
91
|
+
end
|
|
92
|
+
unless strategy.device_reported_complete?(result)
|
|
93
|
+
result.value! if result.failure?
|
|
94
|
+
@device.log_event("tftp_backup", level: :error, status: "transfer_unconfirmed", phase: :tftp_backup,
|
|
95
|
+
code: :transfer_unconfirmed, server: target.host)
|
|
96
|
+
raise DeviceError.new("device did not confirm TFTP backup completion",
|
|
97
|
+
code: :transfer_unconfirmed, host: @device.host, phase: :tftp_backup)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 识别设备回显中的传输失败信息。
|
|
102
|
+
def transfer_failed?(result)
|
|
103
|
+
result.steps.any? do |step|
|
|
104
|
+
# 文件名中的 failed.cfg、error-backup 等不属于诊断词;句末标点仍是词边界。
|
|
105
|
+
pattern = %r{(?<![\w./-])
|
|
106
|
+
(?:error|failed|failure|aborted|denied|timed\ out|timeout|not\ found|no\ such\ file|
|
|
107
|
+
unable\ to|network\ is\ unreachable|can't\ open)
|
|
108
|
+
(?![\w/-]|\.[\w])}ix
|
|
109
|
+
# 原始诊断不能被回车抹除,颜色控制符也不能掩盖失败词。
|
|
110
|
+
history = step.output.gsub(/\r(?!\n)/, "\n")
|
|
111
|
+
step.output.match?(pattern) || TerminalRenderer.render(history).match?(pattern) ||
|
|
112
|
+
TerminalRenderer.render(step.output).match?(pattern)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "strategy"
|
|
4
|
+
require_relative "../../engine/terminal_renderer"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
class Topology
|
|
9
|
+
# 接口描述立即生效的设备:退出视图、读回、保存是不同阶段,不能在 finish 中提前保存。
|
|
10
|
+
class ImmediateStrategy < Strategy
|
|
11
|
+
def verification_commands = @device.config_commands
|
|
12
|
+
def persistence_commands = @device.profile.save_commands
|
|
13
|
+
|
|
14
|
+
# TextFSM 不能静默跳过未知接口头;否则目标行正确也不足以证明读回证据完整。
|
|
15
|
+
def validate_descriptions!(config, rows)
|
|
16
|
+
expected = config.scan(/^interface[ \t]+([^\r\n]*)/).flatten.map(&:strip)
|
|
17
|
+
return if expected == rows.map { |row| row.fetch("INTERFACE").strip }
|
|
18
|
+
|
|
19
|
+
raise ParsingError.new("interface description output was not fully recognized", code: :unrecognized_output,
|
|
20
|
+
host: @device.host, phase: :verify)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# 完成提示必须来自保存步骤;原文和渲染文本中的失败优先,单独提示符/进度不算确认。
|
|
24
|
+
def persistence_confirmed?(result)
|
|
25
|
+
!result.steps.empty? && result.steps.all? do |step|
|
|
26
|
+
raw = step.output.delete_suffix(step.prompt.to_s)
|
|
27
|
+
rendered = TerminalRenderer.render(raw)
|
|
28
|
+
failure = /\b(?:error|failed|failure|denied|cannot|unable|unsuccessful|aborted)\b/i
|
|
29
|
+
next false if raw.match?(failure) || rendered.match?(failure)
|
|
30
|
+
|
|
31
|
+
rendered.lines.any? { |line| line.strip.match?(persistence_pattern) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 没有明确完成证据的策略不能借用一个泛化的成功关键词。
|
|
36
|
+
def persistence_pattern = /\A\b\B\z/
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|