zabbix_manager 5.1.4 → 5.1.6

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -9
  3. data/LICENSE +1 -1
  4. data/README.md +202 -8
  5. data/lib/zabbix_manager/basic/basic_alias.rb +25 -3
  6. data/lib/zabbix_manager/basic/basic_func.rb +32 -61
  7. data/lib/zabbix_manager/basic/basic_init.rb +28 -13
  8. data/lib/zabbix_manager/basic/basic_logic.rb +172 -128
  9. data/lib/zabbix_manager/classes/actions.rb +21 -11
  10. data/lib/zabbix_manager/classes/applications.rb +12 -17
  11. data/lib/zabbix_manager/classes/configurations.rb +20 -5
  12. data/lib/zabbix_manager/classes/drules.rb +11 -23
  13. data/lib/zabbix_manager/classes/errors.rb +16 -28
  14. data/lib/zabbix_manager/classes/events.rb +6 -2
  15. data/lib/zabbix_manager/classes/graphs.rb +41 -34
  16. data/lib/zabbix_manager/classes/hostgroups.rb +53 -3
  17. data/lib/zabbix_manager/classes/hostinterfaces.rb +172 -12
  18. data/lib/zabbix_manager/classes/hosts.rb +197 -68
  19. data/lib/zabbix_manager/classes/httptests.rb +18 -20
  20. data/lib/zabbix_manager/classes/items.rb +199 -72
  21. data/lib/zabbix_manager/classes/maintenance.rb +6 -2
  22. data/lib/zabbix_manager/classes/mediatypes.rb +7 -63
  23. data/lib/zabbix_manager/classes/problems.rb +75 -57
  24. data/lib/zabbix_manager/classes/proxies.rb +48 -7
  25. data/lib/zabbix_manager/classes/roles.rb +47 -36
  26. data/lib/zabbix_manager/classes/screens.rb +45 -14
  27. data/lib/zabbix_manager/classes/scripts.rb +17 -7
  28. data/lib/zabbix_manager/classes/server.rb +7 -1
  29. data/lib/zabbix_manager/classes/templates.rb +59 -21
  30. data/lib/zabbix_manager/classes/triggers.rb +253 -66
  31. data/lib/zabbix_manager/classes/usergroups.rb +28 -6
  32. data/lib/zabbix_manager/classes/usermacros.rb +119 -33
  33. data/lib/zabbix_manager/classes/users.rb +39 -7
  34. data/lib/zabbix_manager/classes/valuemaps.rb +13 -19
  35. data/lib/zabbix_manager/client.rb +311 -135
  36. data/lib/zabbix_manager/http_transport.rb +192 -0
  37. data/lib/zabbix_manager/log_sanitizer.rb +68 -0
  38. data/lib/zabbix_manager/monitoring.rb +691 -0
  39. data/lib/zabbix_manager/version.rb +1 -1
  40. data/lib/zabbix_manager.rb +118 -40
  41. data/zabbix_manager.gemspec +21 -8
  42. metadata +208 -22
  43. data/lib/zabbix_manager/classes/unusable.rb +0 -11
@@ -1,189 +1,365 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
3
  require "json"
5
- require "openssl"
4
+ require "logger"
6
5
 
7
6
  class ZabbixManager
8
7
  class Client
9
- # 用于读取配置选项
10
- attr_reader :options
8
+ SUPPORTED_MAJOR_VERSIONS = (4..7).freeze
9
+ UNAUTHENTICATED_METHODS = %w[apiinfo.version user.login].freeze
10
+ BLANK_NORMALIZED_OPTIONS = %i[api_token username user password http_user http_password].freeze
11
+ UPSERT_MUTEX_STRIPES = 64
12
+ DEFAULT_UNCERTAIN_WRITE_DELAYS = [0, 0.25, 1, 2].freeze
11
13
 
12
- # 生成一个随机的请求标识符
13
- def id
14
- @id ||= rand 100_00
15
- end
14
+ attr_reader :options, :sanitized_options, :logger
16
15
 
17
- # 获取 Zabbix API 版本
18
- def api_version
19
- api_request(method: "apiinfo.version", params: {})
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
25
+ @logger = build_logger
26
+ @id_mutex = Mutex.new
27
+ @next_id = 0
28
+ @upsert_mutexes = Array.new(UPSERT_MUTEX_STRIPES) { Mutex.new }
29
+ validate_upsert_lock!
30
+ @transport = HttpTransport.new(@raw_options)
31
+ validate_token_transport!
32
+ @api_version = api_version
33
+ validate_api_version!
34
+ validate_http_auth_compatibility!
35
+ @credential_type = @raw_options[:api_token] ? :api_token : :user_session
36
+ @auth_token = @raw_options[:api_token] || auth
37
+ log(:info, "client.connected", version: @api_version, credential_type: @credential_type)
38
+ log(:warn, "tls.verification_disabled", url: @transport.safe_url) if insecure_https?
39
+ log(:warn, "token.sent_over_http", url: @transport.safe_url) if insecure_token_transport?
40
+ rescue StandardError
41
+ @transport&.close
42
+ raise
20
43
  end
21
44
 
22
- # 是否启用 debug 模式
23
- def debug?
24
- !@options || @options[:debug]
45
+ # 分配线程安全的 JSON-RPC 请求编号。
46
+ # @return [Integer]
47
+ # @api semipublic
48
+ def id
49
+ @id_mutex.synchronize { @next_id += 1 }
25
50
  end
26
51
 
27
- # Debug 打印信息
28
- def log(message)
29
- puts message if debug?
52
+ # 返回首次查询后缓存的远端 Zabbix API 版本。
53
+ # @return [String]
54
+ # @api public
55
+ def api_version
56
+ @api_version ||= api_request(method: "apiinfo.version", params: {})
30
57
  end
31
58
 
32
- # 用户鉴权方法,登录成功即可获取令牌
59
+ # 使用用户名和密码创建 Zabbix 会话。
60
+ # @return [String] Zabbix session token
61
+ # @api semipublic
33
62
  def auth
34
- # 发起 "user.login" 方法的 API 请求, 并传递用户信息(用户名和密码)作为参数
35
63
  api_request(
36
64
  method: "user.login",
37
65
  params: {
38
- user: @options[:user],
39
- password: @options[:password]
66
+ login_parameter => username,
67
+ password: @raw_options[:password]
40
68
  }
41
69
  )
42
70
  end
43
71
 
44
- # HTTP 客户端初始化方法,同时进行用户权限签证
45
- def initialize(options = {})
46
- # 设置实例变量 @options 以便在整个类中访问
47
- @options = options
48
-
49
- # 检查必要的配置属性是否存在
50
- raise ZbxError, "必须正确提供 :url 属性" unless @options.key?(:url)
51
- raise ZbxError, "必须正确提供 :user 属性" unless @options.key?(:user)
52
- raise ZbxError, "必须正确提供 :password 属性" unless @options.key?(:password)
53
-
54
- # 配置代理信息
55
- if !ENV["http_proxy"].nil? && options[:no_proxy] != true
56
- @proxy_uri = URI.parse(ENV["http_proxy"])
57
- @proxy_host = @proxy_uri.host
58
- @proxy_port = @proxy_uri.port
59
- @proxy_user, @proxy_pass = @proxy_uri.userinfo&.split(/:/) if @proxy_uri.userinfo
60
- end
61
-
62
- # 检查 Zabbix API 版本是否受支持
63
- unless api_version.match?(/^(234567)\.\d+\.\d+$/)
64
- message = "Zabbix API版本:#{api_version} 不受此版本的 zabbix_manager 支持"
65
- if @options[:ignore_version]
66
- log "[WARNING] #{message}"
67
- else
68
- raise ZbxError, message
69
- end
70
- end
71
-
72
- # 进行用户鉴权,获取令牌
73
- @auth_hash = auth
74
- log "[DEBUG] Auth token: #{@auth_hash}"
72
+ # 判断是否启用调试日志。
73
+ # @return [Boolean]
74
+ # @api semipublic
75
+ def debug?
76
+ @raw_options[:debug] == true
75
77
  end
76
78
 
77
- # 定义一个方法,用于将 Ruby 数据结构转换为 JSON 字符串,并根据条件添加令牌
78
- def json_payload(body)
79
- # 构建基本的 JSON 负载,包括方法名、参数、请求 ID 和 JSON-RPC 版本
80
- payload = {
81
- method: body[:method],
82
- params: body[:params],
83
- id: id,
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,
84
88
  jsonrpc: "2.0"
85
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
86
101
 
87
- # 检查请求方法是否是 "apiinfo.version" 或 "user.login",如果不是,则添加令牌信息
88
- payload[:auth] = @auth_hash unless body[:method] == "apiinfo.version" || body[:method] == "user.login"
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
89
111
 
90
- # 将构建好的负载转换为 JSON 字符串并返回
91
- JSON(payload)
112
+ parsed["result"]
113
+ rescue JSON::ParserError => e
114
+ raise ApiError, "Invalid JSON response: #{e.message}"
92
115
  end
93
116
 
94
- # 实例化 Net::HTTP 对象,并发起 API 请求的方法
95
- def http_request(body)
96
- uri = URI.parse(@options[:url])
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)
131
+ started_at = monotonic_time
132
+ log(:debug, "request.started", method: method)
133
+ result = _request(message_json(body))
134
+ log(:info, "request.completed", method: method, duration_ms: elapsed_ms(started_at))
135
+ result
136
+ rescue StandardError => e
137
+ log(:warn, "request.failed", method: method, duration_ms: elapsed_ms(started_at), error: e.class.name)
138
+ raise
139
+ end
140
+ alias manager_request api_request
141
+
142
+ # 注销用户名会话并关闭持久连接;API 令牌无需远端注销。
143
+ # @return [Object] logout result
144
+ # @api public
145
+ def logout
146
+ result = @credential_type == :user_session && @auth_token ? api_request(method: "user.logout", params: []) : true
147
+ result
148
+ ensure
149
+ @auth_token = nil
150
+ close
151
+ end
152
+
153
+ # 关闭持久 HTTP 连接。
154
+ # @return [Object]
155
+ # @api public
156
+ def close
157
+ @transport.close
158
+ end
159
+
160
+ # 输出结构化且已脱敏的客户端事件。
161
+ # @return [void]
162
+ # @api semipublic
163
+ def log(level, event, data = {})
164
+ return unless @logger
165
+
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(" "))
169
+ end
170
+
171
+ # 返回不包含凭据的客户端摘要,避免对象检查时泄漏配置
172
+ # @return [String]
173
+ # @api public
174
+ def inspect
175
+ "#<#{self.class} options=#{@sanitized_options.inspect}>"
176
+ end
177
+
178
+ # 以业务键串行执行幂等写入,并可接入跨进程锁实现
179
+ # @return [Object] 代码块结果
180
+ # @api semipublic
181
+ 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]
185
+ coordinator ? coordinator.call(key, &block) : yield
186
+ end
187
+ end
188
+
189
+ private
190
+
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
97
198
 
98
- # 设置超时时间,默认为 60 秒
99
- timeout = @options[:timeout].nil? ? 60 : @options[:timeout]
100
- log "[DEBUG] Timeout for request set to #{timeout} seconds"
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)
210
+ %i[url proxy].each do |key|
211
+ sanitized[key] = LogSanitizer.sanitize_url(options[key]) if options[key]
212
+ end
213
+ sanitized
214
+ end
215
+
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)
229
+
230
+ raise Invalid, "provide api_token or both username and password"
231
+ end
232
+
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"
244
+ end
245
+
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)"
254
+ end
101
255
 
102
- # 创建 Net::HTTP 对象,支持代理设置
103
- http =
104
- if @proxy_uri
105
- Net::HTTP.Proxy(@proxy_host, @proxy_port, @proxy_user, @proxy_pass).new(uri.host, uri.port)
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)
106
267
  else
107
- Net::HTTP.new(uri.host, uri.port)
268
+ raise ApiError, message
108
269
  end
270
+ 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?
109
277
 
110
- # 配置 HTTPS 请求
111
- if uri.scheme == "https"
112
- http.use_ssl = true
113
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE
278
+ raise Invalid, "HTTP Basic authentication cannot be combined with Zabbix 7 Bearer authentication"
114
279
  end
115
280
 
116
- # 配置请求超时时间
117
- http.open_timeout = timeout
118
- http.read_timeout = timeout
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
119
287
 
120
- # 创建 POST 请求
121
- request = Net::HTTP::Post.new(uri.request_uri)
288
+ raise Invalid, "api_token requires HTTPS unless allow_insecure_http is true"
289
+ end
122
290
 
123
- # 添加基本身份验证信息(如果提供了用户名和密码)
124
- request.basic_auth(@options[:http_user], @options[:http_password]) if @options[:http_user]
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
125
297
 
126
- # 设置请求头的 Content-Type 为 application/json-rpc
127
- request.add_field("Content-Type", "application/json-rpc")
298
+ # 提取已缓存 API 版本的主版本号。
299
+ def api_major_version
300
+ @api_version.to_s.split(".", 2).first.to_i
301
+ end
128
302
 
129
- # 设置请求体为传入的 JSON 字符串
130
- request.body = body
131
- log "[DEBUG] HTTP request params: #{pretty_body(request.body)}"
303
+ # 根据服务端版本选择登录用户名字段。
304
+ def login_parameter
305
+ api_major_version >= 6 ? :username : :user
306
+ end
132
307
 
133
- # 发起请求并获取响应
134
- response = http.request(request)
308
+ # 返回统一后的登录用户名。
309
+ def username
310
+ @raw_options[:username] || @raw_options[:user]
311
+ end
135
312
 
136
- # 检查响应状态码,如果不是 200,则抛出 HttpError 异常
137
- raise HttpError.new("HTTP Error: #{response.code} on #{@options[:url]}", response) unless response.code == "200"
313
+ # 判断当前方法是否应在 JSON-RPC 请求体中携带认证值。
314
+ def body_authentication?(method)
315
+ authenticated_method?(method) && api_major_version < 7
316
+ end
138
317
 
139
- log "[DEBUG] HTTP response answer: #{pretty_body(response.body)}"
318
+ # 判断当前方法是否应使用 Bearer 认证头。
319
+ def bearer_token_for(method)
320
+ @auth_token if authenticated_method?(method) && api_major_version >= 7
321
+ end
140
322
 
141
- # 返回响应体
142
- response.body
143
- end
323
+ # 排除版本查询和登录方法,判断请求是否需要认证。
324
+ def authenticated_method?(method)
325
+ !UNAUTHENTICATED_METHODS.include?(method.to_s)
326
+ end
144
327
 
145
- # 对 API 响应结果进行解析的方法
146
- def _request(body)
147
- # 发起 HTTP 请求并解析 JSON 格式的响应
148
- result = JSON.parse(http_request(body))
149
-
150
- # 如果响应结果中包含错误信息,则抛出 ZbxError 异常
151
- if result["error"]
152
- # 构建错误消息,包括服务器返回的错误信息和请求的详细信息
153
- msg = <<~MOJO
154
- ---------------------------------------------------------------
155
- Server answer API error: #{JSON.pretty_unparse(result["error"])}
156
- on request: #{pretty_body(body)}
157
- ---------------------------------------------------------------
158
- MOJO
159
-
160
- # 抛出自定义异常 ZbxError,并传递错误消息和完整的响应结果
161
- raise ZbxError.new(msg, result)
162
- else
163
- # 如果没有错误信息,则返回响应结果中的 "result" 部分
164
- result["result"]
328
+ # 同时读取符号键和字符串键的请求字段。
329
+ def fetch_body_value(body, key)
330
+ body[key] || body[key.to_s]
165
331
  end
166
- end
167
332
 
168
- # 将 Ruby 数据结构美化成 JSON 字符串后打印输出的方法
169
- def pretty_body(body)
170
- # 解析 JSON 字符串
171
- data = JSON.parse(body)
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
341
+
342
+ # 优先使用调用方日志器,调试模式下才创建默认日志器。
343
+ def build_logger
344
+ return @raw_options[:logger] if @raw_options[:logger]
345
+ return unless debug?
172
346
 
173
- # 如果参数中包含密码字段,则将密码字段的值替换为 "***"
174
- if data["params"].is_a?(Hash) && data["params"].key?("password")
175
- data["params"]["password"] = "***"
347
+ Logger.new($stdout).tap { |value| value.level = Logger::DEBUG }
176
348
  end
177
349
 
178
- # 将美化后的数据转换为 JSON 字符串并返回
179
- JSON.pretty_unparse(data)
180
- end
350
+ # 判断 HTTPS 是否按项目要求关闭证书校验。
351
+ def insecure_https?
352
+ @transport.uri.scheme == "https" && @raw_options[:verify_ssl] != true
353
+ end
181
354
 
182
- # 通用的 API 调用发起方法
183
- def api_request(body)
184
- # 调用之前定义的 json_payload 方法,将请求信息转换为 JSON 字符串
185
- # 然后调用之前定义的 _request 方法,发起 API 请求并解析响应结果
186
- _request json_payload(body)
187
- end
355
+ # 读取不受系统时间校准影响的单调时钟。
356
+ def monotonic_time
357
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
358
+ end
359
+
360
+ # 计算并格式化请求耗时毫秒数。
361
+ def elapsed_ms(started_at)
362
+ ((monotonic_time - started_at) * 1_000).round(1)
363
+ end
188
364
  end
189
365
  end