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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Netdisco
|
|
8
|
+
# 单台设备的最终结果,保留状态、配置产物和错误类型。
|
|
9
|
+
Outcome = Data.define(:device, :status, :backup, :error_code, :error_type,
|
|
10
|
+
:started_at, :finished_at) do
|
|
11
|
+
# 创建结果,允许计划阶段尚无执行时间。
|
|
12
|
+
def initialize(device:, status:, backup:, error_code:, error_type:, started_at: nil, finished_at: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 判断设备是否完成配置保存或上报上传成功。
|
|
17
|
+
def success? = [:backed_up, :reported_uploaded].include?(status)
|
|
18
|
+
|
|
19
|
+
# 判断配置已产生但收尾过程是否出错。
|
|
20
|
+
def partial? = [:saved_with_error, :reported_with_error].include?(status)
|
|
21
|
+
|
|
22
|
+
# 判断设备是否因为清单或采样规则未执行。
|
|
23
|
+
def skipped? = !success? && !partial? && status != :failed
|
|
24
|
+
|
|
25
|
+
# 计算设备任务的耗时,计划阶段没有时间时返回空值。
|
|
26
|
+
def duration_ms
|
|
27
|
+
((finished_at - started_at) * 1000).round if started_at && finished_at
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 汇总一次批量任务的设备结果、回调故障和报告写入状态。
|
|
32
|
+
Batch = Data.define(:mode, :outcomes, :started_at, :finished_at, :callback_errors,
|
|
33
|
+
:report_location, :report_error) do
|
|
34
|
+
# 按设备最终状态统计数量。
|
|
35
|
+
def counts = outcomes.group_by(&:status).transform_values(&:size)
|
|
36
|
+
|
|
37
|
+
# 仅在所有设备成功且回调、报告均正常时判定整批成功。
|
|
38
|
+
def success? = !outcomes.empty? && outcomes.all?(&:success?) && callback_errors.empty? && report_error.nil?
|
|
39
|
+
|
|
40
|
+
# 空清单与已尝试但未全部成功的批次分别标记。
|
|
41
|
+
def status = outcomes.empty? ? :no_devices : (success? ? :succeeded : :incomplete)
|
|
42
|
+
|
|
43
|
+
# 生成可写入报告及供命令行展示的结构化摘要。
|
|
44
|
+
def summary
|
|
45
|
+
{
|
|
46
|
+
mode: mode, status: status, started_at: started_at.iso8601, finished_at: finished_at.iso8601,
|
|
47
|
+
total: outcomes.size,
|
|
48
|
+
succeeded: outcomes.count(&:success?),
|
|
49
|
+
partial: outcomes.count(&:partial?),
|
|
50
|
+
failed: outcomes.count { |item| item.status == :failed },
|
|
51
|
+
skipped: outcomes.count(&:skipped?),
|
|
52
|
+
counts: counts,
|
|
53
|
+
callback_errors: callback_errors,
|
|
54
|
+
devices: outcomes.map do |item|
|
|
55
|
+
{ host: item.device.host || item.device.source_ip, name: item.device.name,
|
|
56
|
+
vendor: item.device.vendor,
|
|
57
|
+
status: item.status, path: item.backup&.path,
|
|
58
|
+
bytes: item.backup.is_a?(Backup) ? item.backup.bytes : nil,
|
|
59
|
+
sha256: item.backup.is_a?(Backup) ? item.backup.sha256 : nil,
|
|
60
|
+
change: item.backup.is_a?(Backup) ? item.backup.change : nil,
|
|
61
|
+
previous_sha256: item.backup.is_a?(Backup) ? item.backup.previous_sha256 : nil,
|
|
62
|
+
started_at: item.started_at&.iso8601, finished_at: item.finished_at&.iso8601,
|
|
63
|
+
duration_ms: item.duration_ms,
|
|
64
|
+
error_code: item.error_code, error_type: item.error_type }
|
|
65
|
+
end
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
module Net
|
|
8
|
+
module Connector
|
|
9
|
+
module Netdisco
|
|
10
|
+
class CLI
|
|
11
|
+
# 保存命令行输入输出和清单任务工厂。
|
|
12
|
+
def initialize(argv: ARGV, env: ENV, output: $stdout, error: $stderr,
|
|
13
|
+
fleet_factory: ->(settings) { Fleet.new(settings: settings) })
|
|
14
|
+
@argv = argv.dup
|
|
15
|
+
@env = env
|
|
16
|
+
@output = output
|
|
17
|
+
@error = error
|
|
18
|
+
@fleet_factory = fleet_factory
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# 解析参数并执行预览、导出或批量备份。
|
|
22
|
+
def run
|
|
23
|
+
options = parse_options
|
|
24
|
+
return 0 if options[:done]
|
|
25
|
+
|
|
26
|
+
validate_options!(options)
|
|
27
|
+
values = options[:config] ? ConfigFile.load(options[:config]).merge(@env.to_h) : @env.to_h
|
|
28
|
+
values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
|
|
29
|
+
values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
|
|
30
|
+
values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
|
|
31
|
+
settings = Settings.new(env: values)
|
|
32
|
+
|
|
33
|
+
if options[:show_config]
|
|
34
|
+
@output.puts JSON.pretty_generate(settings.public_config)
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
if options[:export]
|
|
38
|
+
path = Operations::SavedConfig.new(directory: settings.backup_directory)
|
|
39
|
+
.export(host: options[:export], output: options[:output], io: @output)
|
|
40
|
+
@output.puts JSON.generate(host: options[:export], output: path) if path
|
|
41
|
+
return 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
fleet = @fleet_factory.call(settings)
|
|
45
|
+
limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
|
|
46
|
+
plan = if options[:tftp]
|
|
47
|
+
fleet.plan_tftp_backup(limit_per_vendor: limit)
|
|
48
|
+
else
|
|
49
|
+
fleet.plan_backup(limit_per_vendor: limit)
|
|
50
|
+
end
|
|
51
|
+
if options[:host]
|
|
52
|
+
host = IPAddr.new(options[:host]).to_s
|
|
53
|
+
raise ArgumentError, "host #{options[:host]} was not found in inventory" unless plan.inventory.any? { |device| device.host == host }
|
|
54
|
+
end
|
|
55
|
+
if options[:plan]
|
|
56
|
+
@output.puts JSON.pretty_generate(plan_summary(plan))
|
|
57
|
+
return 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
batch = if options[:tftp]
|
|
61
|
+
fleet.tftp_backup_all(plan: plan, server: values.fetch("TFTP_HOST"),
|
|
62
|
+
concurrency: settings.concurrency,
|
|
63
|
+
vrfs: settings.tftp_vrfs,
|
|
64
|
+
source_files: settings.tftp_source_files)
|
|
65
|
+
else
|
|
66
|
+
fleet.backup_all(plan: plan, directory: settings.backup_directory,
|
|
67
|
+
concurrency: settings.concurrency)
|
|
68
|
+
end
|
|
69
|
+
@output.puts JSON.pretty_generate(batch.summary.merge(report_location: batch.report_location,
|
|
70
|
+
report_error: batch.report_error))
|
|
71
|
+
batch.success? ? 0 : 1
|
|
72
|
+
rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
|
|
73
|
+
Psych::Exception, JSON::ParserError, Client::Error => exception
|
|
74
|
+
@error.puts "net-connector-backup: #{exception.message}"
|
|
75
|
+
2
|
|
76
|
+
rescue StandardError => exception
|
|
77
|
+
@error.puts "net-connector-backup failed (#{exception.class})"
|
|
78
|
+
2
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# 解析命令行参数并处理帮助及版本信息。
|
|
84
|
+
def parse_options
|
|
85
|
+
options = { config: @env["NET_CONNECTOR_CONFIG"] }
|
|
86
|
+
parser = OptionParser.new do |args|
|
|
87
|
+
args.banner = "Usage: net-connector-backup [--config FILE] [--plan | --show-config | --export IP] [--tftp]"
|
|
88
|
+
args.on("--config FILE", "Read non-secret YAML settings") { |value| options[:config] = value }
|
|
89
|
+
args.on("--show-config", "Print effective non-secret settings") { options[:show_config] = true }
|
|
90
|
+
args.on("--plan", "Preview selected inventory without device connections") { options[:plan] = true }
|
|
91
|
+
args.on("--tftp", "Use device-initiated TFTP backup") { options[:tftp] = true }
|
|
92
|
+
args.on("--export IP", "Export a saved local configuration") { |value| options[:export] = value }
|
|
93
|
+
args.on("--output FILE", "Write exported configuration to a private file") { |value| options[:output] = value }
|
|
94
|
+
args.on("--directory PATH", "Backup and report directory") { |value| options[:directory] = value }
|
|
95
|
+
args.on("--host IP", "Select one inventory device by management IP") { |value| options[:host] = value }
|
|
96
|
+
args.on("--concurrency N", Integer, "Maximum simultaneous devices") { |value| options[:concurrency] = value }
|
|
97
|
+
args.on("--limit-per-vendor N", Integer, "Select N devices per vendor (1..5)") do |value|
|
|
98
|
+
options[:limit] = value
|
|
99
|
+
end
|
|
100
|
+
args.on("--all", "Select all ready devices") { options[:all] = true }
|
|
101
|
+
args.on("-v", "--version", "Print version") do
|
|
102
|
+
@output.puts Net::Connector::VERSION
|
|
103
|
+
options[:done] = true
|
|
104
|
+
end
|
|
105
|
+
args.on("-h", "--help", "Show help") do
|
|
106
|
+
@output.puts args
|
|
107
|
+
options[:done] = true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
parser.parse!(@argv)
|
|
111
|
+
raise OptionParser::InvalidOption, @argv.join(" ") unless @argv.empty?
|
|
112
|
+
|
|
113
|
+
options
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 拒绝互斥或缺少依赖的命令行选项。
|
|
117
|
+
def validate_options!(options)
|
|
118
|
+
modes = [:show_config, :plan, :export].count { |key| options[key] }
|
|
119
|
+
raise ArgumentError, "choose one of --show-config, --plan, or --export" if modes > 1
|
|
120
|
+
raise ArgumentError, "--output requires --export" if options[:output] && !options[:export]
|
|
121
|
+
raise ArgumentError, "--all and --limit-per-vendor cannot be combined" if options[:all] && options[:limit]
|
|
122
|
+
if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host])
|
|
123
|
+
raise ArgumentError, "--export cannot be combined with backup options"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# 整理备份计划供命令行预览。
|
|
128
|
+
def plan_summary(plan)
|
|
129
|
+
{
|
|
130
|
+
total: plan.inventory.size,
|
|
131
|
+
ready: plan.inventory.count(&:ready?),
|
|
132
|
+
selected: plan.selected.map do |device|
|
|
133
|
+
{ host: device.host, name: device.name, connector: device.vendor,
|
|
134
|
+
filename: plan.mode == :backup ? device.backup_filename : device.tftp_filename }
|
|
135
|
+
end,
|
|
136
|
+
skipped: plan.outcomes.compact.group_by(&:status).transform_values(&:size)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Net
|
|
9
|
+
module Connector
|
|
10
|
+
module Netdisco
|
|
11
|
+
# 独立于 Rails 的 Netdisco 设备清单客户端。
|
|
12
|
+
class Client
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
class QueryRequired < Error; end
|
|
16
|
+
|
|
17
|
+
FIELDS = %w[ip name dns vendor os model os_ver serial].freeze
|
|
18
|
+
DEFAULT_MAX_PAGES = 10_000
|
|
19
|
+
|
|
20
|
+
# 校验 Netdisco 地址与凭据并建立请求入口。
|
|
21
|
+
def initialize(url:, username: nil, password: nil, api_key: nil, page_size: 500,
|
|
22
|
+
max_pages: DEFAULT_MAX_PAGES, requester: nil)
|
|
23
|
+
@url = URI.parse(url.to_s)
|
|
24
|
+
unless @url.is_a?(URI::HTTP) && @url.host && !@url.userinfo && !@url.query && !@url.fragment
|
|
25
|
+
raise ArgumentError, "url must be an HTTP(S) base URL without credentials, query or fragment"
|
|
26
|
+
end
|
|
27
|
+
unless (api_key.nil? && present?(username) && present?(password)) ||
|
|
28
|
+
(present?(api_key) && username.nil? && password.nil?)
|
|
29
|
+
raise ArgumentError, "provide either username and password or api_key"
|
|
30
|
+
end
|
|
31
|
+
if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n]/) }
|
|
32
|
+
raise ArgumentError, "credentials must be single-line strings"
|
|
33
|
+
end
|
|
34
|
+
unless page_size.is_a?(Integer) && page_size.positive? && max_pages.is_a?(Integer) && max_pages.positive?
|
|
35
|
+
raise ArgumentError, "page_size and max_pages must be positive integers"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@username, @password, @api_key = username, password, api_key
|
|
39
|
+
@page_size, @max_pages = page_size, max_pages
|
|
40
|
+
@requester = requester || method(:default_request)
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
raise ArgumentError, "url is invalid", cause: nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 逐页获取并校验完整设备清单。
|
|
46
|
+
def devices
|
|
47
|
+
token = @api_key || authenticate
|
|
48
|
+
rows = []
|
|
49
|
+
seen_ips = {}
|
|
50
|
+
offset = 0
|
|
51
|
+
@max_pages.times do
|
|
52
|
+
uri = endpoint("api/v1/search/device")
|
|
53
|
+
uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @page_size, offset: offset)
|
|
54
|
+
page = request_json(uri, authorized_get(uri, token), allow_empty: true)
|
|
55
|
+
validate_rows!(page)
|
|
56
|
+
return rows.uniq if page.empty?
|
|
57
|
+
|
|
58
|
+
raise Error, "Netdisco pagination did not advance" if page.all? { |row| seen_ips[row.fetch("ip")] }
|
|
59
|
+
|
|
60
|
+
page.each { |row| seen_ips[row.fetch("ip")] = true }
|
|
61
|
+
rows.concat(page)
|
|
62
|
+
offset += @page_size
|
|
63
|
+
end
|
|
64
|
+
raise Error, "Netdisco inventory exceeded max_pages"
|
|
65
|
+
rescue QueryRequired
|
|
66
|
+
legacy_devices(token)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 仅显示服务地址,避免凭据进入调试输出。
|
|
70
|
+
def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# 在服务要求查询参数时使用兼容查询获取清单。
|
|
75
|
+
def legacy_devices(token)
|
|
76
|
+
uri = endpoint("api/v1/search/device")
|
|
77
|
+
uri.query = URI.encode_www_form(q: "%", seeallcolumns: true)
|
|
78
|
+
rows = request_json(uri, authorized_get(uri, token), allow_empty: true)
|
|
79
|
+
validate_rows!(rows)
|
|
80
|
+
rows.map { |row| row.slice(*FIELDS) }.uniq
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 使用账号密码登录并取得 API 密钥。
|
|
84
|
+
def authenticate
|
|
85
|
+
uri = endpoint("login")
|
|
86
|
+
request = ::Net::HTTP::Post.new(uri)
|
|
87
|
+
request.basic_auth(@username, @password)
|
|
88
|
+
data = request_json(uri, request)
|
|
89
|
+
token = data["api_key"] if data.is_a?(Hash)
|
|
90
|
+
raise Error, "Netdisco login did not return an API key" unless present?(token) && !token.match?(/[\r\n]/)
|
|
91
|
+
|
|
92
|
+
token
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 为清单查询建立带 API 密钥的请求。
|
|
96
|
+
def authorized_get(uri, token)
|
|
97
|
+
request = ::Net::HTTP::Get.new(uri)
|
|
98
|
+
request["Authorization"] = "Apikey #{token}"
|
|
99
|
+
request
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# 以服务根地址构建 API 地址。
|
|
103
|
+
def endpoint(path)
|
|
104
|
+
uri = @url.dup
|
|
105
|
+
uri.path = "#{uri.path.chomp("/")}/#{path}"
|
|
106
|
+
uri
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 发起请求并将 HTTP 和 JSON 故障转换为客户端错误。
|
|
110
|
+
def request_json(uri, request, allow_empty: false)
|
|
111
|
+
request["Accept"] = "application/json"
|
|
112
|
+
response = @requester.call(uri, request)
|
|
113
|
+
body = response.body.to_s
|
|
114
|
+
if response.code == "400"
|
|
115
|
+
parsed = JSON.parse(body)
|
|
116
|
+
raise QueryRequired, "Netdisco requires a search query" if parsed.is_a?(Hash) && parsed["error"] == "Missing query"
|
|
117
|
+
end
|
|
118
|
+
raise Error, "Netdisco request failed (HTTP #{response.code})" unless response.is_a?(::Net::HTTPSuccess)
|
|
119
|
+
return [] if allow_empty && body.strip.empty?
|
|
120
|
+
|
|
121
|
+
JSON.parse(body)
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
raise Error, "Netdisco returned invalid JSON", cause: nil
|
|
124
|
+
rescue IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError => error
|
|
125
|
+
raise Error, "Netdisco connection failed (#{error.class})", cause: nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 使用标准库 HTTP 客户端发送请求。
|
|
129
|
+
def default_request(uri, request)
|
|
130
|
+
::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
131
|
+
open_timeout: 10, read_timeout: 60, write_timeout: 10) { |http| http.request(request) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# 校验清单的字段类型和设备地址。
|
|
135
|
+
def validate_rows!(rows)
|
|
136
|
+
valid = rows.is_a?(Array) && rows.all? do |row|
|
|
137
|
+
row.is_a?(Hash) && present?(row["ip"]) &&
|
|
138
|
+
FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
139
|
+
end
|
|
140
|
+
raise Error, "Netdisco returned an invalid device inventory" unless valid
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# 判断凭据字段是否为非空字符串。
|
|
144
|
+
def present?(value) = value.is_a?(String) && !value.strip.empty?
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
# 命令行使用的无凭据 YAML 配置,环境变量优先。
|
|
10
|
+
class ConfigFile
|
|
11
|
+
FIELDS = {
|
|
12
|
+
"netdisco" => { "url" => ["NETDISCO_URL", :string],
|
|
13
|
+
"page_size" => ["NETDISCO_PAGE_SIZE", :integer] },
|
|
14
|
+
"backup" => { "directory" => ["NET_CONNECTOR_BACKUP_DIRECTORY", :string],
|
|
15
|
+
"concurrency" => ["NET_CONNECTOR_CONCURRENCY", :integer],
|
|
16
|
+
"limit_per_vendor" => ["NET_CONNECTOR_SAMPLE_PER_VENDOR", :integer] },
|
|
17
|
+
"inventory" => { "include_hosts" => ["NET_CONNECTOR_INCLUDE_HOSTS", :list],
|
|
18
|
+
"exclude_hosts" => ["NET_CONNECTOR_EXCLUDE_HOSTS", :list],
|
|
19
|
+
"include_vendors" => ["NET_CONNECTOR_INCLUDE_VENDORS", :list],
|
|
20
|
+
"vendor_overrides" => ["NET_CONNECTOR_VENDOR_OVERRIDES", :json],
|
|
21
|
+
"host_overrides" => ["NET_CONNECTOR_HOST_OVERRIDES", :json],
|
|
22
|
+
"device_rules" => ["NET_CONNECTOR_DEVICE_RULES", :json] },
|
|
23
|
+
"ssh" => { "protocol" => ["NET_CONNECTOR_PROTOCOL", :string],
|
|
24
|
+
"known_hosts" => ["NET_CONNECTOR_KNOWN_HOSTS", :string],
|
|
25
|
+
"host_key_policy" => ["NET_CONNECTOR_HOST_KEY_POLICY", :string],
|
|
26
|
+
"log_directory" => ["NET_CONNECTOR_LOG_DIRECTORY", :string],
|
|
27
|
+
"log_level" => ["NET_CONNECTOR_LOG_LEVEL", :string] },
|
|
28
|
+
"tftp" => { "server" => ["TFTP_HOST", :string],
|
|
29
|
+
"vrfs" => ["NET_CONNECTOR_TFTP_VRFS", :json] }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# 安全读取 YAML 配置并映射到环境变量格式。
|
|
33
|
+
def self.load(path)
|
|
34
|
+
raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
35
|
+
|
|
36
|
+
document = YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
37
|
+
raise ArgumentError, "config must be a YAML mapping" unless document.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
unknown_sections = document.keys - FIELDS.keys
|
|
40
|
+
raise ArgumentError, "unknown config sections: #{unknown_sections.join(", ")}" unless unknown_sections.empty?
|
|
41
|
+
|
|
42
|
+
document.each_with_object({}) do |(section, values), result|
|
|
43
|
+
fields = FIELDS.fetch(section)
|
|
44
|
+
raise ArgumentError, "#{section} must be a mapping" unless values.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
unknown = values.keys - fields.keys
|
|
47
|
+
raise ArgumentError, "unknown #{section} settings: #{unknown.join(", ")}" unless unknown.empty?
|
|
48
|
+
|
|
49
|
+
values.each do |name, value|
|
|
50
|
+
key, type = fields.fetch(name)
|
|
51
|
+
result[key] = encode(section, name, type, value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
rescue Psych::Exception => error
|
|
55
|
+
raise ArgumentError, "invalid YAML config (#{error.class})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# 按字段类型校验并编码单项配置。
|
|
59
|
+
def self.encode(section, name, type, value)
|
|
60
|
+
case type
|
|
61
|
+
when :string
|
|
62
|
+
raise ArgumentError, "#{section}.#{name} must be a nonempty String" unless value.is_a?(String) && !value.strip.empty?
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
when :integer
|
|
66
|
+
raise ArgumentError, "#{section}.#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
67
|
+
|
|
68
|
+
value.to_s
|
|
69
|
+
when :list
|
|
70
|
+
unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? && !item.include?(",") }
|
|
71
|
+
raise ArgumentError, "#{section}.#{name} must be a list of strings without commas"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
value.join(",")
|
|
75
|
+
when :json
|
|
76
|
+
expected = name == "device_rules" ? Array : Hash
|
|
77
|
+
raise ArgumentError, "#{section}.#{name} must be a #{expected}" unless value.is_a?(expected)
|
|
78
|
+
|
|
79
|
+
JSON.generate(value)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method :encode
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require_relative "../operations/saved_config"
|
|
5
|
+
|
|
6
|
+
module Net
|
|
7
|
+
module Connector
|
|
8
|
+
module Netdisco
|
|
9
|
+
# 仅保存清单元数据,设备凭据由调用方管理。
|
|
10
|
+
Device = Data.define(:host, :source_ip, :name, :vendor, :source_vendor, :model, :os, :os_version, :serial, :issue) do
|
|
11
|
+
# 清单行来自可变的外部数据;设备快照不共享其中的字符串。
|
|
12
|
+
def initialize(*values, **attributes)
|
|
13
|
+
frozen_values = values.map { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
14
|
+
frozen_attributes = attributes.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
15
|
+
super(*frozen_values, **frozen_attributes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 将 Netdisco 清单记录转换为可规划的设备对象。
|
|
19
|
+
def self.from_row(row, rules:)
|
|
20
|
+
valid = row.is_a?(Hash) && row["ip"].is_a?(String) && !row["ip"].empty? &&
|
|
21
|
+
Client::FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
|
|
22
|
+
raise Client::Error, "Netdisco returned an invalid device inventory" unless valid
|
|
23
|
+
|
|
24
|
+
source_ip = row.fetch("ip")
|
|
25
|
+
host = nil
|
|
26
|
+
issue = nil
|
|
27
|
+
begin
|
|
28
|
+
raise IPAddr::InvalidAddressError, "address includes a prefix" if source_ip.include?("/")
|
|
29
|
+
|
|
30
|
+
host = IPAddr.new(source_ip).to_s
|
|
31
|
+
rescue IPAddr::Error
|
|
32
|
+
issue = :invalid_address
|
|
33
|
+
end
|
|
34
|
+
vendor = rules.resolve(row)
|
|
35
|
+
issue ||= :unsupported_vendor unless vendor
|
|
36
|
+
issue ||= :filtered unless rules.selected?(host, vendor)
|
|
37
|
+
name = [row["name"], row["dns"]].find { |value| value && !value.strip.empty? }
|
|
38
|
+
new(host: host, source_ip: source_ip, name: name&.strip, vendor: vendor,
|
|
39
|
+
source_vendor: row["vendor"], model: row["model"], os: row["os"],
|
|
40
|
+
os_version: row["os_ver"], serial: row["serial"], issue: issue)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 判断设备是否满足备份选择条件。
|
|
44
|
+
def ready? = issue.nil?
|
|
45
|
+
|
|
46
|
+
# 生成不随名称或厂商信息改变的本地文本文件名。
|
|
47
|
+
def backup_filename = Operations::SavedConfig.filename(host)
|
|
48
|
+
|
|
49
|
+
# 生成设备 CLI 可用的 ASCII 远端文件名。
|
|
50
|
+
def tftp_filename
|
|
51
|
+
return "running-config.xml" if vendor == :palo_alto
|
|
52
|
+
|
|
53
|
+
label = name.to_s.encode(Encoding::US_ASCII, invalid: :replace, undef: :replace, replace: "-")
|
|
54
|
+
.gsub(/[^A-Za-z0-9._-]+/, "-").byteslice(0, 180)
|
|
55
|
+
.gsub(/\A[._-]+|[._-]+\z/, "")
|
|
56
|
+
label = vendor&.to_s || "device" if label.empty?
|
|
57
|
+
extension = case vendor
|
|
58
|
+
when :radware then "tgz"
|
|
59
|
+
when :hillstone then "dat"
|
|
60
|
+
else "cfg"
|
|
61
|
+
end
|
|
62
|
+
TftpTarget.filename(host, extension: extension, label: label)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# 按厂商及设备地址实例化连接器。
|
|
66
|
+
def connector(**settings)
|
|
67
|
+
raise ArgumentError, "device is not ready: #{issue}" unless ready?
|
|
68
|
+
raise ArgumentError, "host cannot be overridden" if settings.key?(:host)
|
|
69
|
+
|
|
70
|
+
Net::Connector.build(vendor, host: host, **settings)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|