net-connector 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/CONTRIBUTING.md +20 -0
  4. data/README.md +153 -42
  5. data/docs/VERIFICATION.md +42 -1
  6. data/docs/architecture.md +20 -1
  7. data/examples/backup.yml +40 -0
  8. data/examples/inventory_sql.yml +16 -0
  9. data/lib/net/connector/device/base.rb +30 -38
  10. data/lib/net/connector/device/profile/builder.rb +8 -8
  11. data/lib/net/connector/device/profile.rb +2 -2
  12. data/lib/net/connector/device/running_config.rb +2 -2
  13. data/lib/net/connector/device/tftp/receipt.rb +12 -3
  14. data/lib/net/connector/device/tftp.rb +7 -7
  15. data/lib/net/connector/device/topology.rb +4 -4
  16. data/lib/net/connector/engine/authentication.rb +1 -1
  17. data/lib/net/connector/engine/command.rb +2 -2
  18. data/lib/net/connector/engine/configuration.rb +5 -2
  19. data/lib/net/connector/engine/dialogue.rb +4 -4
  20. data/lib/net/connector/engine/execution.rb +27 -16
  21. data/lib/net/connector/engine/known_hosts.rb +112 -0
  22. data/lib/net/connector/engine/log.rb +10 -7
  23. data/lib/net/connector/engine/session.rb +117 -128
  24. data/lib/net/connector/engine/transport.rb +32 -7
  25. data/lib/net/connector/netdisco/backup_run.rb +132 -0
  26. data/lib/net/connector/netdisco/batch.rb +1 -1
  27. data/lib/net/connector/netdisco/cli/options.rb +139 -0
  28. data/lib/net/connector/netdisco/cli.rb +20 -24
  29. data/lib/net/connector/netdisco/client.rb +8 -7
  30. data/lib/net/connector/netdisco/config_file.rb +26 -22
  31. data/lib/net/connector/netdisco/connection.rb +38 -0
  32. data/lib/net/connector/netdisco/database_client.rb +197 -0
  33. data/lib/net/connector/netdisco/device.rb +11 -1
  34. data/lib/net/connector/netdisco/fleet.rb +68 -47
  35. data/lib/net/connector/netdisco/inventory_budget.rb +4 -4
  36. data/lib/net/connector/netdisco/plan.rb +14 -9
  37. data/lib/net/connector/netdisco/planner.rb +18 -14
  38. data/lib/net/connector/netdisco/progress.rb +253 -0
  39. data/lib/net/connector/netdisco/report/files.rb +41 -0
  40. data/lib/net/connector/netdisco/report/text.rb +45 -0
  41. data/lib/net/connector/netdisco/report.rb +53 -31
  42. data/lib/net/connector/netdisco/result_store.rb +3 -1
  43. data/lib/net/connector/netdisco/rules.rb +10 -2
  44. data/lib/net/connector/netdisco/settings.rb +136 -63
  45. data/lib/net/connector/netdisco/tftp_archive.rb +175 -0
  46. data/lib/net/connector/netdisco/tftp_history.rb +16 -0
  47. data/lib/net/connector/netdisco/tftp_verification.rb +58 -0
  48. data/lib/net/connector/netdisco/worker.rb +8 -3
  49. data/lib/net/connector/netdisco.rb +11 -0
  50. data/lib/net/connector/storage/backup_lock.rb +1 -1
  51. data/lib/net/connector/storage/batch_directory.rb +29 -0
  52. data/lib/net/connector/storage.rb +1 -0
  53. data/lib/net/connector/vendor/h3c.rb +2 -1
  54. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +3 -3
  55. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +3 -3
  56. data/lib/net/connector/vendor/radware/running_config.rb +19 -0
  57. data/lib/net/connector/vendor/radware.rb +4 -3
  58. data/lib/net/connector/version.rb +1 -1
  59. data/lib/net/connector.rb +2 -2
  60. metadata +57 -104
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../storage/backup_lock"
5
+ require_relative "../storage/private_file"
6
+ require_relative "../storage/safe_file"
7
+ require_relative "tftp_verification"
8
+
9
+ module Net
10
+ module Connector
11
+ module Netdisco
12
+ # 设备先上传到服务器根目录,核验后按批次归档;固定文件名不会覆盖历史文件。
13
+ class TftpArchive
14
+ BATCH_DIRECTORY_NAME = /\A\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_\d{2})?\z/
15
+ private_constant :BATCH_DIRECTORY_NAME
16
+
17
+ class Unavailable < Net::Connector::Error
18
+ def initialize(host: nil)
19
+ super("a local TFTP root is required to retain a fixed-name upload", code: :tftp_history_unavailable,
20
+ host: host, phase: :tftp_backup)
21
+ end
22
+ end
23
+
24
+ class ArchiveFailed < Net::Connector::Error
25
+ def initialize(host: nil)
26
+ super("the uploaded file could not be retained in the batch", code: :tftp_archive_failed,
27
+ host: host, phase: :tftp_backup)
28
+ end
29
+ end
30
+
31
+ def initialize(directory:, root: nil)
32
+ @report_directory = File.expand_path(directory)
33
+ @server_root = root && File.realpath(root)
34
+ @server_archive_directory = create_server_archive_directory if @server_root
35
+ @remote_suffix = reserve_remote_suffix unless @server_root
36
+ @verifier = TftpVerification.new(root: @server_root)
37
+ end
38
+
39
+ def upload_filename(device)
40
+ name = device.tftp_filename
41
+ return name if @server_root || device.vendor == :palo_alto
42
+
43
+ extension = File.extname(name)
44
+ stem = File.basename(name, extension)
45
+ suffix = "-#{@remote_suffix}#{extension}"
46
+ TftpTarget.validate_path!("#{stem.byteslice(0, TftpTarget::MAX_PATH_BYTES - suffix.bytesize)}#{suffix}")
47
+ end
48
+
49
+ def upload_and_archive(device, started_at:)
50
+ raise Unavailable.new(host: device.host) if device.vendor == :palo_alto && !@server_root
51
+
52
+ remote_filename = upload_filename(device)
53
+ return yield remote_filename unless @server_root
54
+
55
+ # The lock spans the upload and copy, including a fixed-name PAN-OS upload.
56
+ Storage::BackupLock.synchronize(File.join(@server_root, remote_filename), host: device.host, timeout: 240) do
57
+ archive_existing_file(device, remote_filename)
58
+ outcome = yield remote_filename
59
+ receipt = outcome.backup
60
+ return outcome unless receipt.is_a?(TftpReceipt) && receipt.path
61
+
62
+ verified = @verifier.verify_outcome(outcome.with(started_at: started_at))
63
+ return outcome.success? ? archive_failure(outcome) : outcome unless verified.backup.verification == :server_verified
64
+
65
+ archive_uploaded_file(verified)
66
+ end
67
+ end
68
+
69
+ alias path_for upload_filename
70
+ alias capture upload_and_archive
71
+
72
+ private
73
+
74
+ # 首次采用固定远端名时,先保留服务器上原有文件,再允许设备覆盖。
75
+ def archive_existing_file(device, remote_filename)
76
+ existing_path = File.join(@server_root, remote_filename)
77
+ bytes = Storage::SafeFile.open(existing_path, missing: true) { |file, _stat| file.read }
78
+ return unless bytes
79
+
80
+ previous_directory = File.join(@server_archive_directory, "previous")
81
+ FileUtils.mkdir_p(previous_directory, mode: 0o700)
82
+ archive_path = File.join(previous_directory, archive_filename(device, File.extname(remote_filename)))
83
+ Storage::PrivateFile.write(archive_path, bytes)
84
+ rescue StandardError
85
+ raise ArchiveFailed.new(host: device.host), cause: nil
86
+ end
87
+
88
+ def create_server_archive_directory
89
+ parent = File.join(@server_root, "archive")
90
+ raise ArgumentError, "TFTP archive directory must not be a symlink" if File.symlink?(parent)
91
+
92
+ FileUtils.mkdir_p(parent, mode: 0o700)
93
+ raise ArgumentError, "TFTP archive directory escaped its root" unless File.realpath(parent) == parent
94
+
95
+ base = batch_name
96
+ number = 0
97
+ loop do
98
+ candidate = number.zero? ? base : "#{base}_#{format("%02d", number)}"
99
+ path = File.join(parent, candidate)
100
+ begin
101
+ Dir.mkdir(path, 0o700)
102
+ return path
103
+ rescue Errno::EEXIST
104
+ number += 1
105
+ end
106
+ end
107
+ end
108
+
109
+ # 同一目录重跑时用原子预留的序号区分运行,不依赖随机数或进程 ID。
110
+ def reserve_remote_suffix
111
+ base = batch_name
112
+ number = 0
113
+ loop do
114
+ candidate = number.zero? ? base : "#{base}_#{format("%02d", number)}"
115
+ marker = File.join(@report_directory, ".tftp-run-#{candidate}")
116
+ begin
117
+ File.open(marker, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write("\n") }
118
+ return candidate
119
+ rescue Errno::EEXIST
120
+ number += 1
121
+ end
122
+ end
123
+ end
124
+
125
+ def batch_name
126
+ name = File.basename(@report_directory)
127
+ name.match?(BATCH_DIRECTORY_NAME) ? name : Time.now.getlocal("+08:00").strftime("%Y-%m-%d_%H-%M-%S")
128
+ end
129
+
130
+ def archive_uploaded_file(outcome)
131
+ receipt = outcome.backup
132
+ saved_batch_path = nil
133
+ batch_directory = File.join(@report_directory, "tftp")
134
+ FileUtils.mkdir_p(batch_directory, mode: 0o700)
135
+ extension = File.extname(receipt.path)
136
+ filename = archive_filename(outcome.device, extension)
137
+ batch_archive_path = File.join(batch_directory, filename)
138
+ server_archive_path = File.join(@server_archive_directory, filename)
139
+ uploaded_path = receipt.local_path
140
+ bytes = Storage::SafeFile.open(uploaded_path) do |file, stat|
141
+ raise ArchiveFailed.new(host: outcome.device.host) unless stat.size == receipt.server_bytes
142
+
143
+ file.read
144
+ end
145
+ raise ArchiveFailed.new(host: outcome.device.host) unless Digest::SHA256.hexdigest(bytes) == receipt.server_sha256
146
+
147
+ Storage::PrivateFile.write(batch_archive_path, bytes)
148
+ saved_batch_path = batch_archive_path
149
+ Storage::PrivateFile.write(server_archive_path, bytes)
150
+ if outcome.success?
151
+ uploaded_fingerprint = Storage::SafeFile.fingerprint(uploaded_path)
152
+ raise ArchiveFailed.new(host: outcome.device.host) unless uploaded_fingerprint.sha256 == receipt.server_sha256
153
+ raise ArchiveFailed.new(host: outcome.device.host) unless Storage::SafeFile.same_entry?(uploaded_path, uploaded_fingerprint)
154
+
155
+ File.unlink(uploaded_path)
156
+ end
157
+ outcome.with(backup: receipt.with(local_path: batch_archive_path, archive_path: server_archive_path))
158
+ rescue StandardError
159
+ retained = outcome.with(backup: receipt.with(local_path: saved_batch_path))
160
+ outcome.success? ? archive_failure(retained) : retained
161
+ end
162
+
163
+ def archive_failure(outcome)
164
+ error = ArchiveFailed.new(host: outcome.device.host)
165
+ outcome.with(status: :reported_with_error, error_code: error.code, error_type: error.class.name,
166
+ diagnostic: Diagnostic.from(error, backup: outcome.backup))
167
+ end
168
+
169
+ def archive_filename(device, extension)
170
+ "#{device.backup_filename(style: :hostname_ip).delete_suffix(".txt")}#{extension}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tftp_archive"
4
+
5
+ module Net
6
+ module Connector
7
+ module Netdisco
8
+ # Compatibility with the first batch archive entry point.
9
+ class TftpHistory < TftpArchive
10
+ def initialize(server:, **options) # rubocop:disable Lint/UnusedMethodArgument -- Accept the former constructor keyword.
11
+ super(**options)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../storage/safe_file"
4
+
5
+ module Net
6
+ module Connector
7
+ module Netdisco
8
+ # 只核验本次设备回执指向的本地服务器文件;远端可读不等于本次上传证据。
9
+ class TftpVerification
10
+ def initialize(root: nil)
11
+ @root = root && File.realpath(root)
12
+ end
13
+
14
+ def call(report)
15
+ return report unless @root
16
+
17
+ outcomes = report.outcomes.map do |outcome|
18
+ receipt = outcome.backup
19
+ next outcome unless receipt.is_a?(TftpReceipt) && receipt.path && outcome.started_at
20
+
21
+ verify(outcome, receipt)
22
+ end
23
+ report.with(outcomes: outcomes.freeze)
24
+ end
25
+
26
+ def verify_outcome(outcome)
27
+ return outcome unless @root && outcome.started_at && outcome.backup.is_a?(TftpReceipt) && outcome.backup.path
28
+
29
+ verify(outcome, outcome.backup)
30
+ end
31
+
32
+ private
33
+
34
+ def verify(outcome, receipt)
35
+ path = File.join(@root, receipt.path)
36
+ parent = File.realpath(File.dirname(path))
37
+ return outcome unless parent == @root || parent.start_with?(@root + File::SEPARATOR)
38
+
39
+ size = nil
40
+ fingerprint = Storage::SafeFile.open(path, missing: true) do |file, stat|
41
+ next unless stat.size.positive? && stat.mtime >= outcome.started_at
42
+
43
+ size = stat.size
44
+ value = Storage::SafeFile.fingerprint_io(path, file, stat)
45
+ after = file.stat
46
+ value if [stat.size, stat.mtime, stat.ctime] == [after.size, after.mtime, after.ctime]
47
+ end
48
+ return outcome unless fingerprint && Storage::SafeFile.same_entry?(path, fingerprint)
49
+
50
+ outcome.with(backup: receipt.with(verification: :server_verified, server_sha256: fingerprint.sha256, server_bytes: size, local_path: path))
51
+ rescue IOError, SystemCallError, ArgumentError
52
+ # 核验失败不抹去设备上传回执;要求服务器证据的策略仍判为未完成。
53
+ outcome
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -5,13 +5,18 @@ module Net
5
5
  module Netdisco
6
6
  # 以固定并发上限执行相互隔离的设备任务。
7
7
  class Worker
8
- # 校验并保存最大设备并发数。
9
- def initialize(concurrency:)
8
+ # 内部预检与实际执行共用规则,不为校验创建工作线程池对象。
9
+ def self.validate_concurrency!(concurrency)
10
10
  unless concurrency.is_a?(Integer) && (1..Settings::MAX_CONCURRENCY).cover?(concurrency)
11
11
  raise ArgumentError, "concurrency must be an Integer in 1..#{Settings::MAX_CONCURRENCY}"
12
12
  end
13
13
 
14
- @concurrency = concurrency
14
+ concurrency
15
+ end
16
+
17
+ # 校验并保存最大设备并发数。
18
+ def initialize(concurrency:)
19
+ @concurrency = Worker.validate_concurrency!(concurrency)
15
20
  end
16
21
 
17
22
  # 按清单顺序收集结果,并隔离设备及回调异常。
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../connector"
4
4
  require_relative "netdisco/client"
5
+ require_relative "netdisco/database_client"
5
6
  require_relative "netdisco/rules"
6
7
  require_relative "netdisco/device"
7
8
  require_relative "netdisco/config_file"
@@ -11,4 +12,14 @@ require_relative "netdisco/result_store"
11
12
  require_relative "netdisco/batch"
12
13
  require_relative "netdisco/planner"
13
14
  require_relative "netdisco/fleet"
15
+ require_relative "netdisco/progress"
14
16
  require_relative "netdisco/cli"
17
+ require_relative "storage/batch_directory"
18
+ require_relative "netdisco/report/text"
19
+ require_relative "netdisco/connection"
20
+ require_relative "netdisco/cli/options"
21
+ require_relative "netdisco/report/files"
22
+ require_relative "netdisco/tftp_verification"
23
+ require_relative "netdisco/tftp_archive"
24
+ require_relative "netdisco/tftp_history"
25
+ require_relative "netdisco/backup_run"
@@ -25,7 +25,7 @@ module Net
25
25
  File.join(directory, ".net-connector-#{Digest::SHA256.hexdigest(filename)}.lock")
26
26
  end
27
27
 
28
- def self.synchronize(path, **options, &block) = new(path, **options).synchronize(&block)
28
+ def self.synchronize(path, **options, &) = new(path, **options).synchronize(&)
29
29
 
30
30
  def initialize(path, timeout: 0, host: nil)
31
31
  raise ArgumentError, "path must be a nonempty String" unless path.is_a?(String) && !path.empty?
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Net
6
+ module Connector
7
+ module Storage
8
+ # 使用本地业务时区命名,原子创建互不覆盖的批次目录。
9
+ class BatchDirectory
10
+ # 固定 UTC+8,不依赖运行主机时区;mkdir 原子防止并发任务覆盖。
11
+ def self.create(root, time: Time.now)
12
+ FileUtils.mkdir_p(root, mode: 0o700)
13
+ stamp = time.getlocal("+08:00").strftime("%Y-%m-%d_%H-%M-%S")
14
+ suffix = 0
15
+ loop do
16
+ name = suffix.zero? ? stamp : "#{stamp}_#{format("%02d", suffix)}"
17
+ path = File.join(root, name)
18
+ begin
19
+ Dir.mkdir(path, 0o700)
20
+ return path
21
+ rescue Errno::EEXIST
22
+ suffix += 1
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -4,6 +4,7 @@ module Net
4
4
  module Connector
5
5
  # 文件存储不依赖设备连接;备份、离线导出和批量报告共享这些原语。
6
6
  module Storage
7
+ autoload :BatchDirectory, File.expand_path("storage/batch_directory", __dir__)
7
8
  autoload :BackupLock, File.expand_path("storage/backup_lock", __dir__)
8
9
  autoload :PrivateFile, File.expand_path("storage/private_file", __dir__)
9
10
  autoload :SafeFile, File.expand_path("storage/safe_file", __dir__)
@@ -38,7 +38,8 @@ module Net
38
38
  )
39
39
  end
40
40
 
41
- command_timeout 10
41
+ # 核心交换机配置采集实测超过 10 秒,保留有界的整条命令预算。
42
+ command_timeout 60
42
43
 
43
44
  interactions do
44
45
  confirm %r{Are\s+you\s+sure\?\s*\[Y/N\]}i, response: "y\n"
@@ -12,9 +12,9 @@ module Net
12
12
  # 在连接前拒绝源文件及远端目录;未指定文件名时仍由设备生成。
13
13
  def validate_options!(target, source_file:, **)
14
14
  raise ArgumentError, "Hillstone TFTP export does not use source_file" unless source_file.nil?
15
- if target.explicit_path? && target.path.include?("/")
16
- raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
17
- end
15
+ return unless target.explicit_path? && target.path.include?("/")
16
+
17
+ raise ArgumentError, "Hillstone TFTP filename cannot contain a directory"
18
18
  end
19
19
 
20
20
  def receipt_metadata(target, **)
@@ -15,9 +15,9 @@ module Net
15
15
  def validate_options!(target, source_file:, vrf: nil)
16
16
  raise ArgumentError, "PAN-OS does not use source_file" unless source_file.nil?
17
17
  raise ArgumentError, "PAN-OS TFTP backup does not use vrf" unless vrf.nil?
18
- if target.explicit_path? && target.path != FILENAME
19
- raise ArgumentError, "PAN-OS TFTP export uses the fixed filename #{FILENAME}"
20
- end
18
+ return unless target.explicit_path? && target.path != FILENAME
19
+
20
+ raise ArgumentError, "PAN-OS TFTP export uses the fixed filename #{FILENAME}"
21
21
  end
22
22
 
23
23
  def receipt_metadata(_target, **)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../device/running_config/strategy"
4
+
5
+ module Net
6
+ module Connector
7
+ module Radware
8
+ class RunningConfig < Net::Connector::RunningConfig::Rendered
9
+ # /cfg/dump 进入 Configuration 菜单;保留设备前缀,只替换菜单名称。
10
+ def prompt_text(command)
11
+ prompt = super
12
+ return prompt unless command.text == "/cfg/dump"
13
+
14
+ prompt.sub(/\A(>>[ \t]+(?:.* - )?)[^\r\n#]+#\z/, '\1Configuration#')
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../device/base"
4
+ require_relative "radware/running_config"
4
5
  require_relative "radware/tftp_backup"
5
6
  require_relative "radware/topology"
6
7
 
@@ -12,7 +13,7 @@ module Net
12
13
  vendor :radware
13
14
  # 声明菜单式 CLI 的命令、提示、超时、诊断和确认规则。
14
15
  profile do
15
- running_config_strategy Net::Connector::RunningConfig::Rendered
16
+ running_config_strategy Net::Connector::Radware::RunningConfig
16
17
  tftp_strategy Net::Connector::Radware::TftpBackup
17
18
  topology_strategy Net::Connector::Radware::Topology
18
19
  commands do
@@ -21,8 +22,8 @@ module Net
21
22
  end
22
23
 
23
24
  prompts do
24
- login(/(?:\A|(?<=[\r\n]))(?:>>[^\r\n]*|[^\r\n]+[>#])\s*\z/)
25
- command(/(?:\A|(?<=[\r\n]))(?:>>[^\r\n]*|[^\r\n]+#)\s*\z/)
25
+ login(/(?:\A|(?<=[\r\n]))(?:>>[^\r\n]*#|(?!>>)[^\r\n]+[>#])\s*\z/)
26
+ command(/(?:\A|(?<=[\r\n]))[^\r\n]+#\s*\z/)
26
27
  end
27
28
 
28
29
  errors do
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Connector
5
- VERSION = "0.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
data/lib/net/connector.rb CHANGED
@@ -34,10 +34,10 @@ module Net
34
34
  end
35
35
 
36
36
  # 在代码块内连接设备并确保会话关闭。
37
- def self.open(vendor, **settings)
37
+ def self.open(vendor, **settings, &)
38
38
  raise ArgumentError, "open requires a block" unless block_given?
39
39
 
40
- vendor_class(vendor).open(**settings) { |device| yield device }
40
+ vendor_class(vendor).open(**settings, &)
41
41
  end
42
42
  end
43
43
  end