net-connector 0.4.1 → 0.4.2

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/CONTRIBUTING.md +40 -0
  4. data/README.md +61 -9
  5. data/SECURITY.md +11 -0
  6. data/docs/VERIFICATION.md +129 -6
  7. data/docs/architecture.md +184 -7
  8. data/lib/net/connector/device/base.rb +11 -4
  9. data/lib/net/connector/device/running_config.rb +2 -0
  10. data/lib/net/connector/engine/command.rb +14 -2
  11. data/lib/net/connector/engine/configuration.rb +10 -7
  12. data/lib/net/connector/engine/dialogue.rb +52 -28
  13. data/lib/net/connector/engine/errors.rb +40 -55
  14. data/lib/net/connector/engine/execution.rb +32 -5
  15. data/lib/net/connector/engine/log.rb +11 -9
  16. data/lib/net/connector/engine/session.rb +38 -7
  17. data/lib/net/connector/engine/terminal_renderer.rb +7 -4
  18. data/lib/net/connector/netdisco/batch.rb +31 -3
  19. data/lib/net/connector/netdisco/cli.rb +73 -31
  20. data/lib/net/connector/netdisco/client.rb +206 -73
  21. data/lib/net/connector/netdisco/config_file.rb +26 -4
  22. data/lib/net/connector/netdisco/diagnostic.rb +91 -0
  23. data/lib/net/connector/netdisco/fleet.rb +103 -60
  24. data/lib/net/connector/netdisco/inventory_budget.rb +49 -0
  25. data/lib/net/connector/netdisco/report.rb +94 -0
  26. data/lib/net/connector/netdisco/rules.rb +28 -5
  27. data/lib/net/connector/netdisco/settings.rb +194 -85
  28. data/lib/net/connector/netdisco/worker.rb +26 -17
  29. data/lib/net/connector/operations/backup_lock.rb +116 -0
  30. data/lib/net/connector/operations/local_backup.rb +49 -9
  31. data/lib/net/connector/operations/parse_output.rb +20 -3
  32. data/lib/net/connector/operations/private_file.rb +94 -5
  33. data/lib/net/connector/operations/safe_file.rb +62 -0
  34. data/lib/net/connector/operations/saved_config/legacy_index.rb +109 -0
  35. data/lib/net/connector/operations/saved_config.rb +40 -10
  36. data/lib/net/connector/operations/tftp/file_upload.rb +14 -1
  37. data/lib/net/connector/operations/tftp_backup.rb +78 -15
  38. data/lib/net/connector/operations/tftp_receipt.rb +73 -0
  39. data/lib/net/connector/operations/topology/immediate_strategy.rb +45 -0
  40. data/lib/net/connector/operations/topology/strategy.rb +20 -0
  41. data/lib/net/connector/operations/topology.rb +97 -29
  42. data/lib/net/connector/operations.rb +6 -0
  43. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +9 -2
  44. data/lib/net/connector/vendor/cisco_ios/topology.rb +10 -4
  45. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +8 -1
  46. data/lib/net/connector/vendor/h3c/tftp_backup.rb +5 -0
  47. data/lib/net/connector/vendor/h3c/topology.rb +9 -4
  48. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +12 -3
  49. data/lib/net/connector/vendor/hillstone/topology.rb +9 -4
  50. data/lib/net/connector/vendor/huawei/tftp_backup.rb +6 -0
  51. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +14 -5
  52. data/lib/net/connector/vendor/palo_alto/topology.rb +7 -2
  53. data/lib/net/connector/vendor/radware/tftp_backup.rb +9 -2
  54. data/lib/net/connector/vendor/radware/topology.rb +1 -0
  55. data/lib/net/connector/version.rb +1 -1
  56. metadata +35 -5
@@ -24,51 +24,37 @@ module Net
24
24
  return 0 if options[:done]
25
25
 
26
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)
27
+ mode = options[:export] ? :export : (options[:tftp] ? :tftp : :backup)
28
+ settings = settings_for(options).for_run(mode: mode)
32
29
 
33
30
  if options[:show_config]
34
31
  @output.puts JSON.pretty_generate(settings.public_config)
35
32
  return 0
36
33
  end
37
34
  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
35
+ export_config(settings, options)
41
36
  return 0
42
37
  end
38
+ if options[:tftp] && !options[:plan] && !settings.tftp_server
39
+ raise ArgumentError, "TFTP_HOST is required"
40
+ end
43
41
 
44
42
  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, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
54
- end
43
+ plan = backup_plan(fleet, settings, options)
55
44
  if options[:plan]
56
45
  @output.puts JSON.pretty_generate(plan_summary(plan))
57
46
  return 0
58
47
  end
59
48
 
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
49
+ batch = backup(fleet, plan, settings, options)
50
+ summary = batch.summary
51
+ summary = summary.merge(report_location: batch.report_location, report_error: batch.report_error) unless batch.instance_of?(Report)
52
+ @output.puts JSON.pretty_generate(summary)
53
+ (batch.instance_of?(Report) ? batch.policy_success? : batch.success?) ? 0 : 1
54
+ rescue Operations::PrivateFile::WriteError => exception
55
+ message = Operations::PrivateFile.receipt_error?(exception) ? exception.message : exception.class.name
56
+ @error.puts "net-connector-backup: #{message}"
57
+ 2
72
58
  rescue OptionParser::ParseError, ArgumentError, KeyError, Errno::ENOENT,
73
59
  Psych::Exception, JSON::ParserError, Client::Error => exception
74
60
  @error.puts "net-connector-backup: #{exception.message}"
@@ -80,6 +66,55 @@ module Net
80
66
 
81
67
  private
82
68
 
69
+ # 文件、环境变量、命令行按优先级覆盖,凭据仍只来自环境变量。
70
+ def settings_for(options)
71
+ defaults = options[:config] ? ConfigFile.load(options[:config]) : {}
72
+ values = {}
73
+ values["NET_CONNECTOR_BACKUP_DIRECTORY"] = options[:directory] if options[:directory]
74
+ values["NET_CONNECTOR_CONCURRENCY"] = options[:concurrency].to_s if options[:concurrency]
75
+ values["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES"] = options[:max_script_output_bytes].to_s if options[:max_script_output_bytes]
76
+ values["NET_CONNECTOR_INCLUDE_HOSTS"] = options[:host] if options[:host]
77
+ values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = options[:limit].to_s if options[:limit]
78
+ values["NET_CONNECTOR_SAMPLE_PER_VENDOR"] = nil if options[:all]
79
+ Settings.new(env: @env, defaults: defaults, overrides: values)
80
+ end
81
+
82
+ # 导出已有配置不创建清单或设备连接。
83
+ def export_config(settings, options)
84
+ path = Operations::SavedConfig.new(directory: settings.backup_directory)
85
+ .export(host: options[:export], output: options[:output], io: @output)
86
+ @output.puts JSON.generate(host: options[:export], output: path) if path
87
+ end
88
+
89
+ # 预览与执行共用计划和主机存在性检查。
90
+ def backup_plan(fleet, settings, options)
91
+ limit = options[:all] ? nil : options[:limit] || settings.limit_per_vendor(tftp: options[:tftp])
92
+ plan = if options[:tftp]
93
+ fleet.plan_tftp_backup(limit_per_vendor: limit)
94
+ else
95
+ fleet.plan_backup(limit_per_vendor: limit)
96
+ end
97
+ if options[:host]
98
+ host = IPAddr.new(options[:host]).to_s
99
+ raise ArgumentError, "清单中未找到设备 #{options[:host]}" unless plan.inventory.any? { |device| device.host == host }
100
+ end
101
+ plan
102
+ end
103
+
104
+ # 仅执行已经校验的计划,保留本地与 TFTP 两种结果语义。
105
+ def backup(fleet, plan, settings, options)
106
+ reporting = options.slice(:success_policy, :report_schema)
107
+ if options[:tftp]
108
+ fleet.tftp_backup_all(plan: plan, server: settings.tftp_server,
109
+ concurrency: settings.concurrency,
110
+ vrfs: settings.tftp_vrfs,
111
+ source_files: settings.tftp_source_files, **reporting)
112
+ else
113
+ fleet.backup_all(plan: plan, directory: settings.backup_directory,
114
+ concurrency: settings.concurrency, **reporting)
115
+ end
116
+ end
117
+
83
118
  # 解析命令行参数并处理帮助及版本信息。
84
119
  def parse_options
85
120
  options = { config: @env["NET_CONNECTOR_CONFIG"] }
@@ -94,10 +129,13 @@ module Net
94
129
  args.on("--directory PATH", "备份和报告目录") { |value| options[:directory] = value }
95
130
  args.on("--host IP", "按管理地址选择一台设备") { |value| options[:host] = value }
96
131
  args.on("--concurrency N", Integer, "最大并发设备数") { |value| options[:concurrency] = value }
132
+ args.on("--max-script-output-bytes N", Integer, "每个脚本的累计响应字节上限") { |value| options[:max_script_output_bytes] = value }
97
133
  args.on("--limit-per-vendor N", Integer, "每厂商选择 N 台设备(1 至 5)") do |value|
98
134
  options[:limit] = value
99
135
  end
100
136
  args.on("--all", "选择全部就绪设备") { options[:all] = true }
137
+ args.on("--success-policy POLICY", %w[strict selected], "成功判定:strict(默认)或 selected") { |value| options[:success_policy] = value.to_sym }
138
+ args.on("--report-schema N", Integer, "报告版本:1(默认)或 2;selected 使用 2") { |value| options[:report_schema] = value }
101
139
  args.on("-v", "--version", "显示版本") do
102
140
  @output.puts Net::Connector::VERSION
103
141
  options[:done] = true
@@ -115,11 +153,15 @@ module Net
115
153
 
116
154
  # 拒绝互斥或缺少依赖的命令行选项。
117
155
  def validate_options!(options)
156
+ Report.options(policy: options.fetch(:success_policy, :strict), schema: options[:report_schema])
157
+ Planner.validate_limit!(options[:limit])
158
+ Worker.new(concurrency: options[:concurrency]) if options[:concurrency]
118
159
  modes = [:show_config, :plan, :export].count { |key| options[key] }
119
160
  raise ArgumentError, "--show-config、--plan 和 --export 只能选择一项" if modes > 1
120
161
  raise ArgumentError, "--output 需要同时指定 --export" if options[:output] && !options[:export]
121
162
  raise ArgumentError, "--all 不能与 --limit-per-vendor 同时使用" if options[:all] && options[:limit]
122
- if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host])
163
+ if options[:export] && (options[:tftp] || options[:all] || options[:limit] || options[:concurrency] || options[:host] ||
164
+ options[:success_policy] || options[:report_schema] || options[:max_script_output_bytes])
123
165
  raise ArgumentError, "--export 不能与备份选项同时使用"
124
166
  end
125
167
  end
@@ -3,144 +3,277 @@
3
3
  require "json"
4
4
  require "net/http"
5
5
  require "openssl"
6
+ require "timeout"
6
7
  require "uri"
8
+ require_relative "inventory_budget"
7
9
 
8
10
  module Net
9
11
  module Connector
10
12
  module Netdisco
11
- # 独立于 Rails 的 Netdisco 设备清单客户端。
13
+ # 独立于 Rails 的 Netdisco 设备清单客户端;失败时不交付部分清单。
12
14
  class Client
13
- class Error < StandardError; end
15
+ class Error < StandardError
16
+ attr_reader :code
17
+
18
+ def initialize(message = nil, code: :client_error)
19
+ @code = code
20
+ super(message)
21
+ end
22
+ end
14
23
 
15
24
  class QueryRequired < Error; end
16
25
 
26
+ class InventoryTimeout < Error
27
+ def initialize(_message = nil)
28
+ super("Netdisco inventory exceeded inventory_timeout", code: :inventory_timeout)
29
+ end
30
+ end
31
+
17
32
  FIELDS = %w[ip name dns vendor os model os_ver serial].freeze
18
33
  DEFAULT_MAX_PAGES = 10_000
34
+ DEFAULTS = { page_size: 500, max_pages: DEFAULT_MAX_PAGES, max_response_bytes: 16 * 1024 * 1024,
35
+ max_inventory_bytes: 128 * 1024 * 1024, max_devices: 100_000,
36
+ inventory_timeout: 300, allow_insecure_http: true }.freeze
19
37
 
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"
38
+ # 此纯校验入口也供 Settings 使用,避免配置预览与实际执行使用两套范围规则。
39
+ def self.options(**values)
40
+ raise ArgumentError, "unknown Netdisco client options" unless (values.keys - DEFAULTS.keys).empty?
41
+
42
+ options = DEFAULTS.merge(values)
43
+ %i[page_size max_pages max_response_bytes max_inventory_bytes max_devices].each do |name|
44
+ value = options.fetch(name)
45
+ raise ArgumentError, "#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
30
46
  end
31
- if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n]/) }
32
- raise ArgumentError, "credentials must be single-line strings"
47
+ duration = options.fetch(:inventory_timeout)
48
+ unless duration.is_a?(Numeric) && duration.real? && duration.finite? && duration.positive? && duration.to_f.finite?
49
+ raise ArgumentError, "inventory_timeout must be a positive finite number"
33
50
  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"
51
+ unless [true, false].include?(options.fetch(:allow_insecure_http))
52
+ raise ArgumentError, "allow_insecure_http must be true or false"
36
53
  end
54
+ options.freeze
55
+ end
37
56
 
38
- @username, @password, @api_key = username, password, api_key
39
- @page_size, @max_pages = page_size, max_pages
40
- @requester = requester || method(:default_request)
57
+ def self.validate_url!(url, allow_insecure_http: true)
58
+ uri = URI.parse(url.to_s)
59
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
60
+ raise ArgumentError, "url must be an HTTP(S) base URL without credentials, query or fragment"
61
+ end
62
+ if uri.scheme == "http" && !allow_insecure_http
63
+ raise ArgumentError, "HTTP requires allow_insecure_http: true; use HTTPS"
64
+ end
65
+ uri
41
66
  rescue URI::InvalidURIError
42
67
  raise ArgumentError, "url is invalid", cause: nil
43
68
  end
44
69
 
45
- # 逐页获取并校验完整设备清单。
70
+ # requester 仍接收 (uri, request),其自行阻塞的时间由注入方负责。
71
+ def initialize(url:, username: nil, password: nil, api_key: nil, requester: nil, **options)
72
+ @options = self.class.options(**options)
73
+ @url = self.class.validate_url!(url, allow_insecure_http: @options.fetch(:allow_insecure_http)).freeze
74
+ validate_credentials!(username, password, api_key)
75
+ raise ArgumentError, "requester must respond to call" if requester && !requester.respond_to?(:call)
76
+
77
+ @username, @password, @api_key = [username, password, api_key].map { |value| value&.dup&.freeze }
78
+ @requester = requester
79
+ end
80
+
46
81
  def devices
47
- token = @api_key || authenticate
82
+ budget = InventoryBudget.new(@options, clock: method(:monotonic))
83
+ token = @api_key || authenticate(budget)
84
+ rows = inventory(token, budget)
85
+ budget.remaining
86
+ rows
87
+ end
88
+
89
+ def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
90
+
91
+ private
92
+
93
+ def inventory(token, budget)
48
94
  rows = []
49
95
  seen_ips = {}
50
- offset = 0
51
- @max_pages.times do
96
+ @options.fetch(:max_pages).times do |index|
52
97
  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)
98
+ uri.query = URI.encode_www_form(fields: FIELDS.join(","), limit: @options.fetch(:page_size),
99
+ offset: index * @options.fetch(:page_size))
100
+ page = request_json(uri, authorized_get(uri, token), budget, allow_empty: true, query_fallback: true)
101
+ validate_rows!(page, budget)
56
102
  return rows.uniq if page.empty?
57
103
 
58
- raise Error, "Netdisco pagination did not advance" if page.all? { |row| seen_ips[row.fetch("ip")] }
59
-
104
+ if page.all? { |row| seen_ips[row.fetch("ip")] }
105
+ raise Error.new("Netdisco pagination did not advance", code: :pagination_stalled), cause: nil
106
+ end
60
107
  page.each { |row| seen_ips[row.fetch("ip")] = true }
61
108
  rows.concat(page)
62
- offset += @page_size
63
109
  end
64
- raise Error, "Netdisco inventory exceeded max_pages"
110
+ raise Error.new("Netdisco inventory exceeded max_pages", code: :max_pages), cause: nil
65
111
  rescue QueryRequired
66
- legacy_devices(token)
112
+ legacy_devices(token, budget)
67
113
  end
68
114
 
69
- # 仅显示服务地址,避免凭据进入调试输出。
70
- def inspect = "#<#{self.class} url=#{@url.scheme}://#{@url.host}>"
71
-
72
- private
73
-
74
- # 在服务要求查询参数时使用兼容查询获取清单。
75
- def legacy_devices(token)
115
+ def legacy_devices(token, budget)
76
116
  uri = endpoint("api/v1/search/device")
77
117
  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)
118
+ rows = request_json(uri, authorized_get(uri, token), budget, allow_empty: true)
119
+ validate_rows!(rows, budget)
80
120
  rows.map { |row| row.slice(*FIELDS) }.uniq
81
121
  end
82
122
 
83
- # 使用账号密码登录并取得 API 密钥。
84
- def authenticate
123
+ def authenticate(budget)
85
124
  uri = endpoint("login")
86
125
  request = ::Net::HTTP::Post.new(uri)
87
126
  request.basic_auth(@username, @password)
88
- data = request_json(uri, request)
127
+ data = request_json(uri, request, budget)
89
128
  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]/)
129
+ raise Error, "Netdisco login did not return an API key" unless present?(token) && !token.match?(/[\r\n\x00]/)
91
130
 
92
131
  token
93
132
  end
94
133
 
95
- # 为清单查询建立带 API 密钥的请求。
96
134
  def authorized_get(uri, token)
97
- request = ::Net::HTTP::Get.new(uri)
98
- request["Authorization"] = "Apikey #{token}"
99
- request
135
+ ::Net::HTTP::Get.new(uri).tap { |request| request["Authorization"] = "Apikey #{token}" }
100
136
  end
101
137
 
102
- # 以服务根地址构建 API 地址。
103
138
  def endpoint(path)
104
- uri = @url.dup
105
- uri.path = "#{uri.path.chomp("/")}/#{path}"
106
- uri
139
+ @url.dup.tap { |uri| uri.path = "#{uri.path.chomp("/")}/#{path}" }
107
140
  end
108
141
 
109
- # 发起请求并将 HTTP 和 JSON 故障转换为客户端错误。
110
- def request_json(uri, request, allow_empty: false)
142
+ # 只保留稳定错误码及类型,不将服务正文、URI 或底层异常 cause 带进报告。
143
+ def request_json(uri, request, budget, allow_empty: false, query_fallback: false)
111
144
  request["Accept"] = "application/json"
112
- response = @requester.call(uri, request)
113
- body = response.body.to_s
114
- if response.code == "400"
145
+ budget.remaining
146
+ response, body = request_body(uri, request, budget)
147
+ if query_fallback && response.code == "400"
115
148
  parsed = JSON.parse(body)
116
149
  raise QueryRequired, "Netdisco requires a search query" if parsed.is_a?(Hash) && parsed["error"] == "Missing query"
117
150
  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)
151
+ unless response.is_a?(::Net::HTTPSuccess)
152
+ status = response.code.to_s.match?(/\A\d{3}\z/) ? response.code : "invalid"
153
+ raise Error.new("Netdisco request failed (HTTP #{status})", code: :request_failed), cause: nil
154
+ end
155
+ data = allow_empty && body.strip.empty? ? [] : JSON.parse(body)
156
+ budget.remaining
157
+ data
158
+ rescue Error => error
159
+ raise error, cause: nil
122
160
  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
161
+ raise Error.new("Netdisco returned invalid JSON", code: :invalid_json), cause: nil
162
+ rescue StandardError => error
163
+ budget.remaining
164
+ raise Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
165
+ end
166
+
167
+ def request_body(uri, request, budget)
168
+ return default_request(uri, request, budget) unless @requester
169
+
170
+ response = injected_request(uri, request, budget)
171
+ budget.remaining
172
+ body = response.body.to_s
173
+ budget.consume_bytes(body.bytesize, response_bytes: 0)
174
+ [response, body]
126
175
  end
127
176
 
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) }
177
+ # 回调自己抛出的 Client::Error 也可能带正文,不能当成内部安全错误直接透传。
178
+ def injected_request(uri, request, budget)
179
+ @requester.call(uri, request)
180
+ rescue StandardError => error
181
+ budget.remaining
182
+ raise Error.new("Netdisco connection failed (#{error.class})", code: :connection_failed), cause: nil
132
183
  end
133
184
 
134
- # 校验清单的字段类型和设备地址。
135
- def validate_rows!(rows)
136
- valid = rows.is_a?(Array) && rows.all? do |row|
185
+ # 仅对自有 HTTP 传输施加总期限,覆盖响应头的持续慢速输入;不异步中断用户回调。
186
+ # start 的块负责关闭连接;禁用隐式 GET 重试,所有阶段同时使用剩余的原生超时。
187
+ def default_request(uri, request, budget)
188
+ Timeout.timeout(budget.remaining, InventoryTimeout) do
189
+ ::Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", max_retries: 0,
190
+ **http_timeouts(budget)) do |http|
191
+ apply_timeouts(http, budget)
192
+ with_http_deadline(http, budget) { read_response(http, request, budget) }
193
+ end
194
+ end
195
+ end
196
+
197
+ # 异步超时展开栈时,Net::HTTP 的 chunked ensure 可能再次等待读取。
198
+ # 只关闭本次自有连接来解除这类阻塞;结束时唤醒并 join 观察线程,不遗留后台任务。
199
+ def with_http_deadline(http, budget)
200
+ mutex = Mutex.new
201
+ changed = ConditionVariable.new
202
+ finished = false
203
+ timer = Thread.new do
204
+ begin
205
+ mutex.synchronize { changed.wait(mutex, budget.remaining) until finished }
206
+ rescue InventoryTimeout
207
+ begin
208
+ http.finish
209
+ rescue IOError, SystemCallError
210
+ # 主请求可能已先关闭;外层 start 仍负责最终清理,保持原始预算错误。
211
+ nil
212
+ end
213
+ end
214
+ end
215
+ yield
216
+ ensure
217
+ mutex&.synchronize { finished = true; changed.broadcast }
218
+ timer&.join
219
+ end
220
+
221
+ def read_response(http, request, budget)
222
+ body = "".b
223
+ failure = nil
224
+ response = http.request(request) do |reply|
225
+ apply_timeouts(http, budget)
226
+ reply.read_body do |chunk|
227
+ budget.consume_bytes(chunk.bytesize, response_bytes: body.bytesize)
228
+ body << chunk
229
+ apply_timeouts(http, budget)
230
+ rescue Error => error
231
+ failure = error
232
+ # Net::HTTP 的 chunked 收尾仍会读取分隔符;先关闭连接才能立即停止超额下载。
233
+ http.finish
234
+ raise
235
+ end
236
+ end
237
+ budget.remaining
238
+ [response, body]
239
+ rescue StandardError => error
240
+ raise failure || error, cause: nil
241
+ end
242
+
243
+ def http_timeouts(budget)
244
+ remaining = budget.remaining
245
+ { open_timeout: [10, remaining].min, read_timeout: [60, remaining].min, write_timeout: [10, remaining].min }
246
+ end
247
+
248
+ def apply_timeouts(http, budget)
249
+ http_timeouts(budget).each { |name, value| http.public_send(:"#{name}=", value) }
250
+ end
251
+
252
+ def validate_rows!(rows, budget)
253
+ unless rows.is_a?(Array)
254
+ raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
255
+ end
256
+ budget.consume_devices(rows.size)
257
+ valid = rows.all? do |row|
137
258
  row.is_a?(Hash) && present?(row["ip"]) &&
138
259
  FIELDS.drop(1).all? { |field| row[field].nil? || row[field].is_a?(String) }
139
260
  end
140
- raise Error, "Netdisco returned an invalid device inventory" unless valid
261
+ unless valid
262
+ raise Error.new("Netdisco returned an invalid device inventory", code: :invalid_inventory), cause: nil
263
+ end
264
+ end
265
+
266
+ def validate_credentials!(username, password, api_key)
267
+ unless (api_key.nil? && present?(username) && present?(password)) ||
268
+ (present?(api_key) && username.nil? && password.nil?)
269
+ raise ArgumentError, "provide either username and password or api_key"
270
+ end
271
+ if [username, password, api_key].compact.any? { |value| value.match?(/[\r\n\x00]/) }
272
+ raise ArgumentError, "credentials must be single-line strings"
273
+ end
141
274
  end
142
275
 
143
- # 判断凭据字段是否为非空字符串。
276
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
277
  def present?(value) = value.is_a?(String) && !value.strip.empty?
145
278
  end
146
279
  end
@@ -10,7 +10,13 @@ module Net
10
10
  class ConfigFile
11
11
  FIELDS = {
12
12
  "netdisco" => { "url" => ["NETDISCO_URL", :string],
13
- "page_size" => ["NETDISCO_PAGE_SIZE", :integer] },
13
+ "page_size" => ["NETDISCO_PAGE_SIZE", :integer],
14
+ "max_pages" => ["NETDISCO_MAX_PAGES", :integer],
15
+ "max_response_bytes" => ["NETDISCO_MAX_RESPONSE_BYTES", :integer],
16
+ "max_inventory_bytes" => ["NETDISCO_MAX_INVENTORY_BYTES", :integer],
17
+ "max_devices" => ["NETDISCO_MAX_DEVICES", :integer],
18
+ "inventory_timeout" => ["NETDISCO_INVENTORY_TIMEOUT", :number],
19
+ "allow_insecure_http" => ["NETDISCO_ALLOW_INSECURE_HTTP", :boolean] },
14
20
  "backup" => { "directory" => ["NET_CONNECTOR_BACKUP_DIRECTORY", :string],
15
21
  "concurrency" => ["NET_CONNECTOR_CONCURRENCY", :integer],
16
22
  "limit_per_vendor" => ["NET_CONNECTOR_SAMPLE_PER_VENDOR", :integer] },
@@ -21,12 +27,16 @@ module Net
21
27
  "host_overrides" => ["NET_CONNECTOR_HOST_OVERRIDES", :json],
22
28
  "device_rules" => ["NET_CONNECTOR_DEVICE_RULES", :json] },
23
29
  "ssh" => { "protocol" => ["NET_CONNECTOR_PROTOCOL", :string],
30
+ "max_script_output_bytes" => ["NET_CONNECTOR_MAX_SCRIPT_OUTPUT_BYTES", :optional_integer],
24
31
  "known_hosts" => ["NET_CONNECTOR_KNOWN_HOSTS", :string],
25
32
  "host_key_policy" => ["NET_CONNECTOR_HOST_KEY_POLICY", :string],
26
33
  "log_directory" => ["NET_CONNECTOR_LOG_DIRECTORY", :string],
27
34
  "log_level" => ["NET_CONNECTOR_LOG_LEVEL", :string] },
28
35
  "tftp" => { "server" => ["TFTP_HOST", :string],
29
- "vrfs" => ["NET_CONNECTOR_TFTP_VRFS", :json] }
36
+ "vrfs" => ["NET_CONNECTOR_TFTP_VRFS", :json],
37
+ "h3c_source_file" => ["NET_CONNECTOR_H3C_TFTP_SOURCE_FILE", :string],
38
+ "h3c_wireless_source_file" => ["NET_CONNECTOR_H3C_WIRELESS_TFTP_SOURCE_FILE", :string],
39
+ "huawei_source_file" => ["NET_CONNECTOR_HUAWEI_TFTP_SOURCE_FILE", :string] }
30
40
  }.freeze
31
41
 
32
42
  # 安全读取 YAML 配置并映射到环境变量格式。
@@ -52,7 +62,7 @@ module Net
52
62
  end
53
63
  end
54
64
  rescue Psych::Exception => error
55
- raise ArgumentError, "invalid YAML config (#{error.class})"
65
+ raise ArgumentError, "invalid YAML config (#{error.class})", cause: nil
56
66
  end
57
67
 
58
68
  # 按字段类型校验并编码单项配置。
@@ -62,9 +72,21 @@ module Net
62
72
  raise ArgumentError, "#{section}.#{name} must be a nonempty String" unless value.is_a?(String) && !value.strip.empty?
63
73
 
64
74
  value
65
- when :integer
75
+ when :integer, :optional_integer
76
+ return nil if type == :optional_integer && value.nil?
77
+
66
78
  raise ArgumentError, "#{section}.#{name} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
67
79
 
80
+ value.to_s
81
+ when :number
82
+ unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive? && value.to_f.finite?
83
+ raise ArgumentError, "#{section}.#{name} must be a positive finite number"
84
+ end
85
+
86
+ value.to_s
87
+ when :boolean
88
+ raise ArgumentError, "#{section}.#{name} must be true or false" unless [true, false].include?(value)
89
+
68
90
  value.to_s
69
91
  when :list
70
92
  unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? && !item.include?(",") }