zabbix_manager 5.1.6 → 6.0.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -1
  3. data/README.md +69 -46
  4. data/lib/zabbix_manager/classes/actions.rb +3 -11
  5. data/lib/zabbix_manager/classes/applications.rb +2 -8
  6. data/lib/zabbix_manager/classes/configurations.rb +4 -9
  7. data/lib/zabbix_manager/classes/{drules.rb → discovery_rules.rb} +2 -8
  8. data/lib/zabbix_manager/classes/errors.rb +3 -0
  9. data/lib/zabbix_manager/classes/events.rb +2 -1
  10. data/lib/zabbix_manager/classes/graphs.rb +19 -15
  11. data/lib/zabbix_manager/classes/{hostgroups.rb → host_groups.rb} +5 -11
  12. data/lib/zabbix_manager/classes/host_interfaces.rb +261 -0
  13. data/lib/zabbix_manager/classes/hosts.rb +135 -89
  14. data/lib/zabbix_manager/classes/{httptests.rb → http_tests.rb} +2 -8
  15. data/lib/zabbix_manager/classes/items.rb +91 -87
  16. data/lib/zabbix_manager/classes/maintenance.rb +2 -1
  17. data/lib/zabbix_manager/classes/{mediatypes.rb → media_types.rb} +2 -7
  18. data/lib/zabbix_manager/classes/problems.rb +11 -40
  19. data/lib/zabbix_manager/classes/proxies.rb +5 -15
  20. data/lib/zabbix_manager/classes/proxy_groups.rb +16 -0
  21. data/lib/zabbix_manager/classes/roles.rb +13 -19
  22. data/lib/zabbix_manager/classes/screens.rb +11 -30
  23. data/lib/zabbix_manager/classes/scripts.rb +2 -8
  24. data/lib/zabbix_manager/classes/server.rb +1 -0
  25. data/lib/zabbix_manager/classes/templates.rb +2 -65
  26. data/lib/zabbix_manager/classes/triggers.rb +42 -45
  27. data/lib/zabbix_manager/classes/user_groups.rb +63 -0
  28. data/lib/zabbix_manager/classes/user_macros.rb +110 -0
  29. data/lib/zabbix_manager/classes/users.rb +15 -54
  30. data/lib/zabbix_manager/classes/value_maps.rb +52 -0
  31. data/lib/zabbix_manager/client.rb +148 -277
  32. data/lib/zabbix_manager/configuration.rb +107 -0
  33. data/lib/zabbix_manager/http_transport.rb +145 -97
  34. data/lib/zabbix_manager/log_sanitizer.rb +9 -19
  35. data/lib/zabbix_manager/monitoring/device.rb +393 -0
  36. data/lib/zabbix_manager/monitoring/expressions.rb +36 -0
  37. data/lib/zabbix_manager/monitoring/line.rb +175 -0
  38. data/lib/zabbix_manager/monitoring/line_items.rb +110 -0
  39. data/lib/zabbix_manager/monitoring/line_lifecycle.rb +169 -0
  40. data/lib/zabbix_manager/monitoring/line_plan.rb +113 -0
  41. data/lib/zabbix_manager/monitoring/thresholds.rb +182 -0
  42. data/lib/zabbix_manager/monitoring/traffic_items.rb +132 -0
  43. data/lib/zabbix_manager/monitoring/validation.rb +92 -0
  44. data/lib/zabbix_manager/monitoring.rb +216 -642
  45. data/lib/zabbix_manager/resource.rb +235 -0
  46. data/lib/zabbix_manager/traffic.rb +238 -0
  47. data/lib/zabbix_manager/version.rb +1 -1
  48. data/lib/zabbix_manager.rb +66 -179
  49. data/zabbix_manager.gemspec +6 -22
  50. metadata +41 -190
  51. data/lib/zabbix_manager/basic/basic_alias.rb +0 -42
  52. data/lib/zabbix_manager/basic/basic_func.rb +0 -55
  53. data/lib/zabbix_manager/basic/basic_init.rb +0 -50
  54. data/lib/zabbix_manager/basic/basic_logic.rb +0 -239
  55. data/lib/zabbix_manager/classes/hostinterfaces.rb +0 -189
  56. data/lib/zabbix_manager/classes/usergroups.rb +0 -75
  57. data/lib/zabbix_manager/classes/usermacros.rb +0 -222
  58. data/lib/zabbix_manager/classes/valuemaps.rb +0 -26
@@ -2,38 +2,42 @@
2
2
 
3
3
  require "json"
4
4
  require "logger"
5
+ require "active_support"
6
+ require "active_support/logger"
7
+ require "active_support/tagged_logging"
5
8
 
6
9
  class ZabbixManager
10
+ # 持有一个已认证连接,管理 JSON-RPC 请求的完整生命周期。
7
11
  class Client
8
- SUPPORTED_MAJOR_VERSIONS = (4..7).freeze
12
+ SUPPORTED_MAJOR_VERSIONS = (4..7)
9
13
  UNAUTHENTICATED_METHODS = %w[apiinfo.version user.login].freeze
10
- BLANK_NORMALIZED_OPTIONS = %i[api_token username user password http_user http_password].freeze
11
14
  UPSERT_MUTEX_STRIPES = 64
12
- DEFAULT_UNCERTAIN_WRITE_DELAYS = [0, 0.25, 1, 2].freeze
13
-
14
- attr_reader :options, :sanitized_options, :logger
15
-
16
- # 构建按服务端版本选择认证方式并复用 HTTP 会话的客户端。
17
- # @return [Client]
18
- # @api public
19
- def initialize(options = {})
20
- @raw_options = normalize_options(options)
21
- validate_credentials!
22
- validate_uncertain_write_delays!
23
- @sanitized_options = sanitize_options(@raw_options).freeze
24
- @options = @sanitized_options
15
+
16
+ attr_reader :options, :logger, :api_version
17
+
18
+ # 校验配置后立即查询服务器版本并完成认证。
19
+ # 初始化失败时关闭连接;成功后的客户端生命周期由调用方负责。
20
+ # @param options [Hash] 显式连接、凭据、日志及锁配置
21
+ # @raise [Invalid] 连接配置无效或相互冲突
22
+ # @raise [ApiError, TransportError, ProtocolError] 版本查询或认证失败
23
+ def initialize(**options)
24
+ @settings = Configuration.parse(options)
25
+ @options = sanitized_options.freeze
25
26
  @logger = build_logger
26
27
  @id_mutex = Mutex.new
27
28
  @next_id = 0
28
29
  @upsert_mutexes = Array.new(UPSERT_MUTEX_STRIPES) { Mutex.new }
29
- validate_upsert_lock!
30
- @transport = HttpTransport.new(@raw_options)
30
+ @transport = HttpTransport.new(@settings)
31
31
  validate_token_transport!
32
- @api_version = api_version
32
+ @api_version = api_request(method: "apiinfo.version")
33
33
  validate_api_version!
34
34
  validate_http_auth_compatibility!
35
- @credential_type = @raw_options[:api_token] ? :api_token : :user_session
36
- @auth_token = @raw_options[:api_token] || auth
35
+ @credential_type = @settings[:api_token] ? :api_token : :user_session
36
+ @auth_token = @settings[:api_token] || login
37
+ unless @auth_token.is_a?(String) && !@auth_token.empty?
38
+ raise ProtocolError, "Invalid authentication response"
39
+ end
40
+
37
41
  log(:info, "client.connected", version: @api_version, credential_type: @credential_type)
38
42
  log(:warn, "tls.verification_disabled", url: @transport.safe_url) if insecure_https?
39
43
  log(:warn, "token.sent_over_http", url: @transport.safe_url) if insecure_token_transport?
@@ -42,324 +46,191 @@ class ZabbixManager
42
46
  raise
43
47
  end
44
48
 
45
- # 分配线程安全的 JSON-RPC 请求编号。
46
- # @return [Integer]
47
- # @api semipublic
48
- def id
49
- @id_mutex.synchronize { @next_id += 1 }
50
- end
51
-
52
- # 返回首次查询后缓存的远端 Zabbix API 版本。
53
- # @return [String]
54
- # @api public
55
- def api_version
56
- @api_version ||= api_request(method: "apiinfo.version", params: {})
57
- end
58
-
59
- # 使用用户名和密码创建 Zabbix 会话。
60
- # @return [String] Zabbix session token
61
- # @api semipublic
62
- def auth
63
- api_request(
64
- method: "user.login",
65
- params: {
66
- login_parameter => username,
67
- password: @raw_options[:password]
68
- }
69
- )
70
- end
71
-
72
- # 判断是否启用调试日志。
73
- # @return [Boolean]
74
- # @api semipublic
75
- def debug?
76
- @raw_options[:debug] == true
77
- end
78
-
79
- # 序列化 JSON-RPC 请求并按版本加入请求体认证。
80
- # @return [String]
81
- # @api semipublic
82
- def message_json(body)
83
- method = fetch_body_value(body, :method)
84
- message = {
85
- method: method,
86
- params: fetch_body_value(body, :params) || {},
87
- id: id,
88
- jsonrpc: "2.0"
89
- }
90
- message[:auth] = @auth_token if @auth_token && body_authentication?(method)
91
- JSON.generate(message)
92
- end
93
-
94
- # 通过持久连接发送请求,并按版本加入 Bearer 令牌。
95
- # @return [String] raw response body
96
- # @api semipublic
97
- def http_request(body)
98
- method = JSON.parse(body).fetch("method")
99
- @transport.request(body, bearer_token: bearer_token_for(method))
100
- end
101
-
102
- # 解析 JSON-RPC 响应并统一转换协议错误。
103
- # @return [Object]
104
- # @api semipublic
105
- def _request(body)
106
- parsed = JSON.parse(http_request(body))
107
- raise_api_error(parsed, body) if parsed["error"]
108
- unless parsed.key?("result")
109
- raise ApiError.new("Invalid JSON-RPC response: missing result", LogSanitizer.sanitize(parsed))
110
- end
111
-
112
- parsed["result"]
113
- rescue JSON::ParserError => e
114
- raise ApiError, "Invalid JSON response: #{e.message}"
115
- end
116
-
117
- # 格式化请求摘要并隐藏全部参数值。
118
- # @return [String]
119
- # @api semipublic
120
- def pretty_body(body)
121
- parsed = JSON.parse(body)
122
- parsed["params"] = LogSanitizer::REDACTED if parsed.key?("params")
123
- JSON.pretty_generate(LogSanitizer.sanitize(parsed))
124
- end
125
-
126
- # 执行 Zabbix JSON-RPC 方法并记录脱敏耗时日志。
127
- # @return [Object] decoded result value
128
- # @api public
129
- def api_request(body)
130
- method = fetch_body_value(body, :method)
49
+ # 执行一次请求,不重放失败或结果未确认的变更。
50
+ # @param method [String] 原生 API 方法,例如 "history.get"
51
+ # @param params [Hash, Array] 原生 Zabbix 参数,不将参数值写入日志
52
+ # @return [Object] 解码后的 JSON-RPC 结果,保留服务器返回的标量类型
53
+ # @raise [Invalid] 方法名或参数容器无效
54
+ # @raise [ApiError] 服务器明确拒绝请求
55
+ # @raise [TransportError, ProtocolError] 未收到能够确认结果的有效响应
56
+ def api_request(method:, params: {})
57
+ validate_request!(method, params)
131
58
  started_at = monotonic_time
59
+ message = request_message(method, params)
132
60
  log(:debug, "request.started", method: method)
133
- result = _request(message_json(body))
61
+ body = @transport.request(JSON.generate(message), bearer_token: bearer_token_for(method))
62
+ result = parse_response(body, message.fetch(:id))
134
63
  log(:info, "request.completed", method: method, duration_ms: elapsed_ms(started_at))
135
64
  result
136
- rescue StandardError => e
137
- log(:warn, "request.failed", method: method, duration_ms: elapsed_ms(started_at), error: e.class.name)
65
+ rescue StandardError => error
66
+ log(:warn, "request.failed", method: method, duration_ms: elapsed_ms(started_at), error: error.class.name)
138
67
  raise
139
68
  end
140
- alias manager_request api_request
141
69
 
142
- # 注销用户名会话并关闭持久连接;API 令牌无需远端注销。
143
- # @return [Object] logout result
144
- # @api public
70
+ # 注销密码认证会话;API token 仅需清理本地状态。
71
+ # 远端注销失败时仍清除本地认证令牌并关闭传输连接。
72
+ # @return [Boolean] 服务器注销结果;API token 模式返回 true
73
+ # @raise [ApiError, TransportError] 远端会话注销失败
145
74
  def logout
146
- result = @credential_type == :user_session && @auth_token ? api_request(method: "user.logout", params: []) : true
147
- result
75
+ @credential_type == :user_session && @auth_token ? api_request(method: "user.logout", params: []) : true
148
76
  ensure
149
77
  @auth_token = nil
150
78
  close
151
79
  end
152
80
 
153
- # 关闭持久 HTTP 连接。
154
- # @return [Object]
155
- # @api public
81
+ # 关闭连接但不撤销凭据;后续请求按需重新连接。
82
+ # @return [true]
156
83
  def close
157
84
  @transport.close
158
85
  end
159
86
 
160
- # 输出结构化且已脱敏的客户端事件。
161
- # @return [void]
162
- # @api semipublic
87
+ # 延迟构造日志元数据;日志失败不能改变远端写入结果。
88
+ # @param level [Symbol] Ruby Logger 日志级别方法
89
+ # @param event [String] 稳定的事件名称
90
+ # @param data [Hash] 序列化之前经过脱敏的元数据
91
+ # @return [Object, nil] 日志返回值;日志关闭或失败时返回 nil
163
92
  def log(level, event, data = {})
164
93
  return unless @logger
165
94
 
166
- sanitized = LogSanitizer.sanitize(data)
167
- details = sanitized.map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
168
- @logger.public_send(level, ["[zabbix_manager]", event, details].reject(&:empty?).join(" "))
95
+ @logger.tagged("zabbix_manager") do
96
+ @logger.public_send(level) { JSON.generate(LogSanitizer.sanitize(data.merge(event: event))) }
97
+ end
98
+ rescue StandardError
99
+ nil
169
100
  end
170
101
 
171
- # 返回不包含凭据的客户端摘要,避免对象检查时泄漏配置
172
- # @return [String]
173
- # @api public
102
+ # @return [String] 仅包含脱敏配置的诊断摘要
174
103
  def inspect
175
- "#<#{self.class} options=#{@sanitized_options.inspect}>"
104
+ "#<#{self.class} options=#{@options.inspect}>"
176
105
  end
177
106
 
178
- # 以业务键串行执行幂等写入,并可接入跨进程锁实现
179
- # @return [Object] 代码块结果
180
- # @api semipublic
107
+ # 应用层锁适配器可将本地串行化扩展到多个工作进程。
108
+ # 锁不可重入;调用方不能在块内再次获取 upsert 锁。
109
+ # @param key [String] 被保护操作的稳定、非秘密身份
110
+ # @yield 需要串行执行的读取、修改和写入操作
111
+ # @return [Object] 块的返回值
181
112
  def with_upsert_lock(key, &block)
182
- mutex = @upsert_mutexes[key.hash % UPSERT_MUTEX_STRIPES]
183
- mutex.synchronize do
184
- coordinator = @raw_options[:upsert_lock]
113
+ @upsert_mutexes[key.hash % UPSERT_MUTEX_STRIPES].synchronize do
114
+ coordinator = @settings[:upsert_lock]
185
115
  coordinator ? coordinator.call(key, &block) : yield
186
116
  end
187
117
  end
188
118
 
189
119
  private
190
120
 
191
- # 统一配置键并把页面提交的空凭据归一化为 nil
192
- # @return [Hash]
193
- # @api private
194
- def normalize_options(options)
195
- unless options.respond_to?(:each_pair)
196
- raise Invalid, "options must be a hash-like object"
197
- end
198
-
199
- normalized = options.to_h.transform_keys(&:to_sym)
200
- BLANK_NORMALIZED_OPTIONS.each do |key|
201
- value = normalized[key].presence
202
- value ? normalized[key] = value : normalized.delete(key)
203
- end
204
- normalized
205
- end
206
-
207
- # 脱敏普通凭据,并清理 URL 和代理地址中的 userinfo 与查询串。
208
- def sanitize_options(options)
209
- sanitized = LogSanitizer.sanitize(options)
121
+ def sanitized_options
122
+ LogSanitizer.sanitize(@settings.except(:logger, :upsert_lock)).tap do |safe|
123
+ safe[:uncertain_write_delays].freeze
210
124
  %i[url proxy].each do |key|
211
- sanitized[key] = LogSanitizer.sanitize_url(options[key]) if options[key]
125
+ safe[key] = LogSanitizer.sanitize_url(@settings[key]) if @settings[key]
212
126
  end
213
- sanitized
214
127
  end
128
+ end
215
129
 
216
- # 校验页面配置只能选择 API 令牌或用户名密码之一。
217
- # @return [void]
218
- # @api private
219
- def validate_credentials!
220
- raise Invalid, "url is required" if @raw_options[:url].nil? || @raw_options[:url].to_s.empty?
221
-
222
- token_present = @raw_options[:api_token].present?
223
- username_present = username.present?
224
- password_present = @raw_options[:password].present?
225
- if token_present && (username_present || password_present)
226
- raise Invalid, "api_token cannot be combined with username/password"
227
- end
228
- return if token_present || (username_present && password_present)
130
+ def build_logger
131
+ source = @settings[:logger]
132
+ return if source.nil? && @settings[:log_level].nil?
229
133
 
230
- raise Invalid, "provide api_token or both username and password"
134
+ source ||= ActiveSupport::Logger.new($stderr)
135
+ ActiveSupport::TaggedLogging.new(source).tap do |logger|
136
+ logger.level = @settings[:log_level] if @settings.key?(:log_level)
231
137
  end
138
+ rescue ArgumentError, TypeError
139
+ raise Invalid, "log_level must be a Ruby Logger severity", cause: nil
140
+ end
232
141
 
233
- # 校验传输结果不确定时的只读回查退避窗口。
234
- def validate_uncertain_write_delays!
235
- values = @raw_options.fetch(:uncertain_write_delays, DEFAULT_UNCERTAIN_WRITE_DELAYS)
236
- delays = Array(values).map { |value| Float(value) }
237
- unless delays.any? && delays.length <= 10 && delays.all? { |delay| delay >= 0 } && delays.sum <= 60
238
- raise Invalid, "uncertain_write_delays must contain 1 to 10 non-negative seconds totaling at most 60"
239
- end
240
-
241
- @raw_options[:uncertain_write_delays] = delays.freeze
242
- rescue ArgumentError, TypeError
243
- raise Invalid, "uncertain_write_delays must contain numeric seconds"
142
+ def validate_request!(method, params)
143
+ unless method.is_a?(String) && method.match?(/\A[a-z][a-z0-9_]*\.[a-z][a-zA-Z0-9_]*\z/)
144
+ raise Invalid, "method must be a Zabbix API method name"
244
145
  end
146
+ raise Invalid, "params must be a Hash or Array" unless params.is_a?(Hash) || params.is_a?(Array)
147
+ end
245
148
 
246
- # 校验可选的跨进程幂等锁适配器。
247
- # @return [void]
248
- # @api private
249
- def validate_upsert_lock!
250
- lock = @raw_options[:upsert_lock]
251
- return if lock.nil? || lock.respond_to?(:call)
252
-
253
- raise Invalid, "upsert_lock must respond to call(key, &block)"
149
+ def request_message(method, params)
150
+ request_id = @id_mutex.synchronize { @next_id += 1 }
151
+ { method: method, params: params, id: request_id, jsonrpc: "2.0" }.tap do |message|
152
+ message[:auth] = @auth_token if @auth_token && authenticated_method?(method) && api_major_version < 7
254
153
  end
154
+ end
255
155
 
256
- # 校验服务端版本格式及支持范围。
257
- # @return [void]
258
- # @api private
259
- def validate_api_version!
260
- match = @api_version.to_s.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
261
- supported = match && SUPPORTED_MAJOR_VERSIONS.cover?(match[1].to_i)
262
- return if supported
263
-
264
- message = "Zabbix API version #{@api_version.inspect} is not supported"
265
- if @raw_options[:ignore_version]
266
- log(:warn, "version.unsupported", version: @api_version)
267
- else
268
- raise ApiError, message
269
- end
156
+ def parse_response(body, request_id)
157
+ response = JSON.parse(body)
158
+ unless response.is_a?(Hash) && response["jsonrpc"] == "2.0" && response["id"] == request_id
159
+ raise ProtocolError, "Invalid JSON-RPC response envelope or request ID"
270
160
  end
271
-
272
- # 拒绝 Zabbix 7 Bearer 认证与 HTTP Basic 复用同一认证头。
273
- # @return [void]
274
- # @api private
275
- def validate_http_auth_compatibility!
276
- return unless api_major_version >= 7 && @raw_options[:http_user].present?
277
-
278
- raise Invalid, "HTTP Basic authentication cannot be combined with Zabbix 7 Bearer authentication"
161
+ unless response.key?("result") ^ response.key?("error")
162
+ raise ProtocolError, "Invalid JSON-RPC response: expected exactly one result or error"
279
163
  end
280
164
 
281
- # 默认阻止 API 令牌通过明文 HTTP 发送
282
- # @return [void]
283
- # @api private
284
- def validate_token_transport!
285
- return unless @raw_options[:api_token] && insecure_token_transport?
286
- return if @raw_options[:allow_insecure_http] == true
165
+ raise_api_error(response) if response.key?("error")
166
+ response.fetch("result")
167
+ rescue JSON::ParserError, TypeError
168
+ raise ProtocolError, "Invalid JSON response", cause: nil
169
+ end
287
170
 
288
- raise Invalid, "api_token requires HTTPS unless allow_insecure_http is true"
171
+ def raise_api_error(response)
172
+ error = response["error"]
173
+ unless error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
174
+ raise ProtocolError, "Invalid JSON-RPC error response"
289
175
  end
290
176
 
291
- # 判断当前令牌连接是否使用明文 HTTP
292
- # @return [Boolean]
293
- # @api private
294
- def insecure_token_transport?
295
- @raw_options[:api_token].present? && @transport.uri.scheme == "http"
296
- end
177
+ # 服务器文本可能回显任意请求值,包括宏及秘密。
178
+ safe_response = response.slice("jsonrpc", "id").merge("error" => error.slice("code"))
179
+ raise ApiError.new("Zabbix API error #{error.fetch("code")}", safe_response)
180
+ end
297
181
 
298
- # 提取已缓存 API 版本的主版本号。
299
- def api_major_version
300
- @api_version.to_s.split(".", 2).first.to_i
301
- end
182
+ def login
183
+ parameter = api_major_version >= 6 ? :username : :user
184
+ api_request(method: "user.login", params: { parameter => @settings[:username], password: @settings[:password] })
185
+ end
302
186
 
303
- # 根据服务端版本选择登录用户名字段。
304
- def login_parameter
305
- api_major_version >= 6 ? :username : :user
306
- end
187
+ def validate_api_version!
188
+ match = @api_version.is_a?(String) && @api_version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
189
+ raise ProtocolError, "Invalid API version response" unless match
190
+ return if SUPPORTED_MAJOR_VERSIONS.cover?(match[1].to_i)
191
+ raise ApiError, "Zabbix API version is not supported" unless @settings[:ignore_version]
307
192
 
308
- # 返回统一后的登录用户名。
309
- def username
310
- @raw_options[:username] || @raw_options[:user]
311
- end
193
+ log(:warn, "version.unsupported", version: @api_version)
194
+ end
312
195
 
313
- # 判断当前方法是否应在 JSON-RPC 请求体中携带认证值。
314
- def body_authentication?(method)
315
- authenticated_method?(method) && api_major_version < 7
316
- end
196
+ def validate_http_auth_compatibility!
197
+ return unless api_major_version >= 7 && @settings[:http_user]
317
198
 
318
- # 判断当前方法是否应使用 Bearer 认证头。
319
- def bearer_token_for(method)
320
- @auth_token if authenticated_method?(method) && api_major_version >= 7
321
- end
199
+ raise Invalid, "HTTP Basic authentication cannot be combined with Zabbix 7 Bearer authentication"
200
+ end
322
201
 
323
- # 排除版本查询和登录方法,判断请求是否需要认证。
324
- def authenticated_method?(method)
325
- !UNAUTHENTICATED_METHODS.include?(method.to_s)
326
- end
202
+ def validate_token_transport!
203
+ return unless insecure_token_transport? && @settings[:allow_insecure_http] != true
327
204
 
328
- # 同时读取符号键和字符串键的请求字段。
329
- def fetch_body_value(body, key)
330
- body[key] || body[key.to_s]
331
- end
205
+ raise Invalid, "api_token requires HTTPS unless allow_insecure_http is true"
206
+ end
332
207
 
333
- # 使用脱敏响应和请求摘要构造 API 异常。
334
- def raise_api_error(parsed, request_body)
335
- safe_response = LogSanitizer.sanitize(parsed)
336
- error = safe_response["error"]
337
- message = "Zabbix API error #{error["code"]}: #{error["message"]}"
338
- message = "#{message} (#{error["data"]})" if error["data"]
339
- raise ApiError.new("#{message}\nRequest:\n#{pretty_body(request_body)}", safe_response)
340
- end
208
+ def insecure_token_transport?
209
+ @settings[:api_token] && @transport.uri.scheme == "http"
210
+ end
341
211
 
342
- # 优先使用调用方日志器,调试模式下才创建默认日志器。
343
- def build_logger
344
- return @raw_options[:logger] if @raw_options[:logger]
345
- return unless debug?
212
+ def insecure_https?
213
+ @transport.uri.scheme == "https" && @settings[:verify_ssl] != true
214
+ end
346
215
 
347
- Logger.new($stdout).tap { |value| value.level = Logger::DEBUG }
348
- end
216
+ def api_major_version
217
+ @api_version.to_s.split(".", 2).first.to_i
218
+ end
349
219
 
350
- # 判断 HTTPS 是否按项目要求关闭证书校验。
351
- def insecure_https?
352
- @transport.uri.scheme == "https" && @raw_options[:verify_ssl] != true
353
- end
220
+ def bearer_token_for(method)
221
+ @auth_token if authenticated_method?(method) && api_major_version >= 7
222
+ end
354
223
 
355
- # 读取不受系统时间校准影响的单调时钟。
356
- def monotonic_time
357
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
358
- end
224
+ def authenticated_method?(method)
225
+ !UNAUTHENTICATED_METHODS.include?(method)
226
+ end
359
227
 
360
- # 计算并格式化请求耗时毫秒数。
361
- def elapsed_ms(started_at)
362
- ((monotonic_time - started_at) * 1_000).round(1)
363
- end
228
+ def monotonic_time
229
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+ end
231
+
232
+ def elapsed_ms(started_at)
233
+ ((monotonic_time - started_at) * 1000).round(2) if started_at
234
+ end
364
235
  end
365
236
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ZabbixManager
4
+ # 在首次网络访问之前统一校验连接配置。
5
+ class Configuration
6
+ ENVIRONMENT_KEYS = {
7
+ url: "ZABBIX_URL", api_token: "ZABBIX_API_TOKEN",
8
+ username: "ZABBIX_USERNAME", password: "ZABBIX_PASSWORD"
9
+ }.freeze
10
+ CREDENTIALS = %i[api_token username password http_user http_password].freeze
11
+ BOOLEAN_OPTIONS = %i[verify_ssl allow_insecure_http no_proxy ignore_version].freeze
12
+ OPTIONS = (CREDENTIALS + BOOLEAN_OPTIONS + %i[
13
+ url proxy ca_file timeout request_timeout max_response_bytes
14
+ open_timeout read_timeout write_timeout keep_alive_timeout
15
+ logger log_level upsert_lock uncertain_write_delays
16
+ ]).freeze
17
+ DEFAULT_UNCERTAIN_WRITE_DELAYS = [0, 0.25, 1, 2].freeze
18
+
19
+ # 仅从指定环境映射读取连接身份字段。
20
+ # 显式选项优先于环境值,包含用于清除凭据的 nil。
21
+ def self.from_env(env, overrides)
22
+ ENVIRONMENT_KEYS.to_h { |key, name| [key, env[name]] }.merge(overrides)
23
+ end
24
+
25
+ # 返回已校验的配置,不修改调用方持有的值。
26
+ def self.parse(options)
27
+ new(options).to_h
28
+ end
29
+
30
+ def initialize(options)
31
+ raise Invalid, "options must be a Hash" unless options.is_a?(Hash)
32
+
33
+ @options = options.symbolize_keys
34
+ @options.assert_valid_keys(*OPTIONS)
35
+ @options.transform_values! { |value| value.is_a?(String) ? value.dup.freeze : value }
36
+ validate_credentials!
37
+ validate_booleans!
38
+ validate_delays!
39
+ validate_collaborators!
40
+ rescue ArgumentError => error
41
+ raise Invalid, error.message, cause: nil
42
+ end
43
+
44
+ def to_h
45
+ @options.freeze
46
+ end
47
+
48
+ def inspect
49
+ "#<#{self.class}>"
50
+ end
51
+
52
+ private
53
+
54
+ def validate_credentials!
55
+ CREDENTIALS.each do |key|
56
+ value = @options[key]
57
+ raise Invalid, "#{key} must be a String" unless value.nil? || value.is_a?(String)
58
+
59
+ @options.delete(key) if value.blank?
60
+ end
61
+ raise Invalid, "url is required" if @options[:url].blank?
62
+
63
+ token = @options[:api_token]
64
+ username = @options[:username]
65
+ password = @options[:password]
66
+ if token && (username || password)
67
+ raise Invalid, "api_token cannot be combined with username/password"
68
+ end
69
+ raise Invalid, "provide api_token or both username and password" unless token || (username && password)
70
+ return unless @options[:http_password] && !@options[:http_user]
71
+
72
+ raise Invalid, "http_password requires http_user"
73
+ end
74
+
75
+ def validate_booleans!
76
+ BOOLEAN_OPTIONS.each do |key|
77
+ next unless @options.key?(key)
78
+ next if [true, false].include?(@options[key])
79
+
80
+ raise Invalid, "#{key} must be true or false"
81
+ end
82
+ end
83
+
84
+ def validate_delays!
85
+ values = @options.fetch(:uncertain_write_delays, DEFAULT_UNCERTAIN_WRITE_DELAYS)
86
+ raise Invalid, "uncertain_write_delays must be an Array" unless values.is_a?(Array)
87
+
88
+ delays = values.map { |value| Float(value, exception: false) }
89
+ finite_nonnegative = delays.all? { |value| value && value.finite? && value >= 0 }
90
+ unless (1..10).cover?(delays.size) && finite_nonnegative && delays.sum <= 60
91
+ raise Invalid, "uncertain_write_delays must contain 1 to 10 non-negative seconds totaling at most 60"
92
+ end
93
+
94
+ @options[:uncertain_write_delays] = delays.freeze
95
+ end
96
+
97
+ def validate_collaborators!
98
+ lock = @options[:upsert_lock]
99
+ raise Invalid, "upsert_lock must respond to call(key, &block)" if lock && !lock.respond_to?(:call)
100
+
101
+ logger = @options[:logger]
102
+ return if logger.nil? || %i[debug info warn formatter formatter=].all? { |method| logger.respond_to?(method) }
103
+
104
+ raise Invalid, "logger must support the Ruby Logger interface"
105
+ end
106
+ end
107
+ end