zabbix_manager 6.0.0 → 6.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40a4744edcbba667b1c548c576b6451e637dee80bce28755a151f58e02a553b
4
- data.tar.gz: 5d5ee350721f10da7ea9f93582bf06fc6c5b6ac4e5a9bbaa445058f43c205a45
3
+ metadata.gz: ad8094e06c6d99c1ff334dd2cf9d86c40bb9f45467d811e53be92d1ffcf05bb3
4
+ data.tar.gz: 90375dfe1473875e17e2b8e82b3f947818076239b1ceb3d2f868001b600f4c12
5
5
  SHA512:
6
- metadata.gz: db558e058564904b417225b8f59df28a741b7f73d787f104180dd878e017899fadfe077d4194b2e2f2b2995639cf9c42ab585abf25f0d06308af9f8a6549eeba
7
- data.tar.gz: 5c6a2e519171dec793941f13ba766aad04679a008196dad090b784614fa59882c851c810554c0c8ee479ba8aa7493779c5c2f770bfc1d24c0efb8da4a730da7d
6
+ metadata.gz: 629f1278b1f79497c95de696db6c46169b75995c34e69993508c12672227b2ebf3f8264a9ba228e001c83a68f2c352f3aa80a771ff729effd95b7d50e6ca0e2f
7
+ data.tar.gz: d0d7377ba225db508bcc46bf177a65518dcdade2beb6190b92ed2d5bb1b17367748f2f2f3a005daf46af329437c8ec4d80f3e452f626bda440bd96b60a442945
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 6.0.1 (2026-10-05)
4
+
5
+ * Discover batch item keys once per host and validate returned ownership, identities and duplicate targets before writes.
6
+ * Snapshot item, interface and trigger definitions; reject non-scalar or non-positive IDs in single-host operations.
7
+ * Report malformed high-level resource query responses as ProtocolError so batch reconciliation can isolate discovery failures.
8
+ * Correlate request logs by request_id, suppress rejected method text, and avoid tag/serialization overhead for disabled severities.
9
+ * Validate method encodings, Bearer-compatible tokens and logger severity setters before use while retaining Unicode password authentication.
10
+
11
+ * Filter native SNMPv3 authentication/privacy passphrases and complete escaped quoted credentials in diagnostic logs.
12
+ * Snapshot line definitions before discovery so caller mutations cannot change validated interfaces, identities or trigger attributes.
13
+ * Use an ID index for traffic metadata ownership checks, avoiding quadratic lookups while preserving query order and missing-item states.
14
+ * Correct Ruby layout violations to restore the existing RuboCop gate.
15
+
3
16
  ## 6.0.0 (2026-10-01)
4
17
 
5
18
  * Fix role ID queries and host-scoped value map identities, keeping immutable host fields out of updates.
data/README.md CHANGED
@@ -89,9 +89,9 @@ zabbix = ZabbixManager.connect(
89
89
  )
90
90
  ```
91
91
 
92
- Logging uses `ActiveSupport::Logger` and `ActiveSupport::TaggedLogging`; structured credential filtering uses `ActiveSupport::ParameterFilter`. Pass `log_level: :info` to create a logger on standard error, or inject your application's logger. Logging is disabled unless one of these options is supplied, and logging failures do not change API outcomes.
92
+ Logging uses `ActiveSupport::Logger` and `ActiveSupport::TaggedLogging`; structured credential filtering uses `ActiveSupport::ParameterFilter`. Pass `log_level: :info` to create a logger on standard error, or inject your application's logger. Logging is disabled unless one of these options is supplied, and logging failures do not change API outcomes. Request lifecycle events include a per-client `request_id` for correlation; request parameters and rejected method text are never logged. Disabled log severities skip tag construction and serialization. API tokens must contain printable ASCII characters without whitespace; username/password authentication continues to support Unicode.
93
93
 
94
- Passwords, API tokens, authorization values, cookies, and session IDs are filtered. Request parameters and response bodies are not logged. API exceptions expose the server error code and request ID, without remote messages or data that might echo arbitrary secrets. Malformed or mismatched JSON-RPC responses and invalid mutation ID receipts raise `ProtocolError < TransportError`: a write may already have happened, so it must not be blindly replayed.
94
+ Passwords, API tokens, SNMPv3 authentication/privacy passphrases, authorization values, cookies, and session IDs are filtered. Quoted diagnostic credentials are filtered as complete values, including escaped quotes and backslashes. Request parameters and response bodies are not logged. API exceptions expose the server error code and request ID, without remote messages or data that might echo arbitrary secrets. Malformed or mismatched JSON-RPC responses and invalid mutation ID receipts raise `ProtocolError < TransportError`: a write may already have happened, so it must not be blindly replayed.
95
95
 
96
96
  HTTPS certificate verification is disabled by default as required by this project. Set `verify_ssl: true` (and optionally `ca_file:`) to enable peer verification.
97
97
 
@@ -232,6 +232,8 @@ Invalid caller input raises `ZabbixManager::Invalid`, ambiguous remote ownership
232
232
 
233
233
  `traffic.series` reads explicit item IDs; `traffic.for_interface` discovers both interface directions. Both expose missing data and query truncation, preserve numeric precision, and keep raw history separate from hourly trends. They never substitute zero or a stale last value for missing samples.
234
234
 
235
+ Device and line definitions are copied before validation and discovery. Changes to caller-owned strings or nested fields during reconciliation do not alter the validated definition.
236
+
235
237
  Multiword resource accessors use Ruby snake_case: `host_groups`, `host_interfaces`, `http_tests`, `media_types`, `proxy_groups`, `user_groups`, `user_macros`, `value_maps`, and `discovery_rules`.
236
238
 
237
239
  The focused modules expose explicit operations:
@@ -243,8 +245,11 @@ The focused modules expose explicit operations:
243
245
  * `user_groups.replace_users`, `user_groups.replace_host_group_permissions`
244
246
  * `host_interfaces.for_host`, `host_interfaces.reconcile_for_host`, `host_interfaces.delete_many`
245
247
  * `items.for_host`, `items.upsert_by_key`, `items.upsert_many`, `items.set_status`, `items.delete_many`
248
+
246
249
  * `triggers.for_host`, `triggers.upsert_for_host`, `triggers.add_dependencies`, `triggers.replace_dependencies`, `triggers.set_status`, `triggers.delete_many`
247
250
 
251
+ `items.upsert_many` discovers existing keys once per host before any write, then preserves input write order. Item and interface batch definitions are copied before discovery, including nested strings. Single-host helpers require a positive integer `hostid` or its decimal string representation. Identity lookup and batch helpers reject malformed query containers or required IDs with `ProtocolError`; `get_raw` preserves native response pass-through.
252
+
248
253
 
249
254
  ## Supported Ruby Versions
250
255
  The minimum Ruby version is **3.4**. CI runs Ruby 3.4 and 4.0.
@@ -24,11 +24,11 @@ class ZabbixManager
24
24
  # 查询指定主机的全部接口,可选择同时返回关联监控项。
25
25
  # @return [Array<Hash>]
26
26
  def for_host(hostid, select_items: nil)
27
- raise Invalid, "hostid is required" if hostid.blank?
27
+ positive_id_attribute(hostid, "hostid")
28
28
 
29
29
  params = { hostids: hostid, output: "extend" }
30
30
  params[:selectItems] = select_items if select_items
31
- @client.api_request(method: "hostinterface.get", params: params)
31
+ response_objects(@client.api_request(method: "hostinterface.get", params: params))
32
32
  end
33
33
 
34
34
  # 规范化并校验接口定义,不执行远端查询或写入。
@@ -67,7 +67,7 @@ class ZabbixManager
67
67
  def delete_many(hostid:, interfaceids:)
68
68
  ids = normalized_ids(interfaceids)
69
69
 
70
- owned_ids = for_host(hostid).map { |interface| interface.fetch("interfaceid").to_s }
70
+ owned_ids = for_host(hostid).map { |interface| response_identifier(interface["interfaceid"]).to_s }
71
71
  foreign_ids = ids - owned_ids
72
72
  raise Conflict, "interfaces do not belong to host #{hostid}: #{foreign_ids.join(", ")}" if foreign_ids.any?
73
73
 
@@ -104,7 +104,7 @@ class ZabbixManager
104
104
  def normalize_definition(interface)
105
105
  raise Invalid, "interface must be a hash" unless interface.is_a?(Hash)
106
106
 
107
- attributes = interface.deep_symbolize_keys
107
+ attributes = interface.deep_symbolize_keys.deep_dup
108
108
  if attributes.key?(:type)
109
109
  type = integer_attribute(attributes[:type], "interface type")
110
110
  raise Invalid, "interface type must be 1, 2, 3, or 4" unless [1, 2, 3, 4].include?(type)
@@ -145,6 +145,7 @@ class ZabbixManager
145
145
  # 按主机 ID 查询唯一主机,并返回表达式需要的技术名称。
146
146
  # @return [Hash, nil]
147
147
  def find_by_id(hostid)
148
+ positive_id_attribute(hostid, "hostid")
148
149
  find_host_by_filter(hostid: hostid)
149
150
  end
150
151
 
@@ -233,6 +234,7 @@ class ZabbixManager
233
234
  selectInterfaces: %w[interfaceid ip dns]
234
235
  }
235
236
  )
237
+ response_objects(result)
236
238
  raise Conflict, "host lookup is ambiguous for #{filter.inspect}" if result.length > 1
237
239
 
238
240
  response_identifier(result.first["hostid"]) if result.first
@@ -247,6 +249,7 @@ class ZabbixManager
247
249
  method: "hostinterface.get",
248
250
  params: { filter: { field => candidate }, output: ["hostid"] }
249
251
  )
252
+ response_objects(result).each { |interface| response_identifier(interface["hostid"], "hostid") }
250
253
  matches.concat(result)
251
254
  end
252
255
  matches.uniq! { |interface| interface.fetch("hostid") }
@@ -264,6 +267,7 @@ class ZabbixManager
264
267
  filter: filter
265
268
  }
266
269
  )
270
+ response_objects(result)
267
271
  raise Conflict, "host lookup is ambiguous for #{filter.inspect}" if result.length > 1
268
272
 
269
273
  response_identifier(result.first["hostid"]).to_s if result.first
@@ -41,22 +41,20 @@ class ZabbixManager
41
41
  # 查询主机监控项,可按稳定 key 集合过滤并选择关联对象。
42
42
  # @return [Array<Hash>]
43
43
  def for_host(hostid, keys: nil, output: "extend", select_preprocessing: nil, select_tags: nil)
44
- raise Invalid, "hostid is required" if hostid.blank?
44
+ positive_id_attribute(hostid, "hostid")
45
45
 
46
46
  params = { hostids: hostid, output: output }
47
47
  params[:filter] = { key_: keys } if keys.present?
48
48
  params[:selectPreprocessing] = select_preprocessing if select_preprocessing
49
49
  params[:selectTags] = select_tags if select_tags
50
- @client.api_request(method: "item.get", params: params)
50
+ response_objects(@client.api_request(method: "item.get", params: params))
51
51
  end
52
52
 
53
53
  # 按主机和稳定 key 查询唯一监控项。
54
54
  # @return [Hash, nil]
55
55
  def find_by_key(hostid:, key:)
56
56
  result = for_host(hostid, keys: key, output: "extend", select_preprocessing: "extend")
57
- raise Conflict, "multiple items use key #{key} on host #{hostid}" if result.length > 1
58
-
59
- result.first
57
+ index_items(result, hostid, [key])[key]
60
58
  end
61
59
 
62
60
  # 按 hostid + key_ 幂等创建或更新监控项。
@@ -70,11 +68,15 @@ class ZabbixManager
70
68
  # @return [Array<Integer>]
71
69
  def upsert_many(collection)
72
70
  items = Array(collection).map { |data| validate_item!(data) }
73
- identities = items.map { |item| [item[:hostid].to_s, item[:key_].to_s] }
71
+ identities = items.map { |item| [positive_id_attribute(item[:hostid], "hostid").to_s, item[:key_]] }
74
72
  duplicate = identities.tally.find { |_identity, count| count > 1 }&.first
75
73
  raise Invalid, "duplicate hostid + key_ identity #{duplicate.join(":")}" if duplicate
76
74
 
77
- plans = items.map { |item| plan_item(item) }
75
+ existing = batch_items(items)
76
+ plans = items.map do |item|
77
+ identity = [positive_id_attribute(item[:hostid], "hostid").to_s, item[:key_]]
78
+ plan_item(item, existing[identity])
79
+ end
78
80
  if block_given?
79
81
  effective = plans.map do |attributes, current|
80
82
  if current && !%w[type value_type].all? { |field| current.key?(field) }
@@ -114,6 +116,7 @@ class ZabbixManager
114
116
  # 方向和接口精确匹配由 Monitoring 业务层负责。
115
117
  # @return [Array<Hash>]
116
118
  def monitored_traffic_candidates(hostid)
119
+ positive_id_attribute(hostid, "hostid")
117
120
  @client.api_request(
118
121
  method: "item.get",
119
122
  params: {
@@ -150,9 +153,44 @@ class ZabbixManager
150
153
 
151
154
  private
152
155
 
156
+ # 每个主机只读取一次期望 key 集合,全部发现和校验完成后才开始写入。
157
+ def batch_items(items)
158
+ groups = items.group_by { |item| positive_id_attribute(item[:hostid], "hostid").to_s }
159
+ ids = {}
160
+ groups.each_with_object({}) do |(owner, definitions), result|
161
+ hostid = definitions.first[:hostid]
162
+ keys = definitions.map { |item| item[:key_] }
163
+ rows = for_host(hostid, keys: keys, select_preprocessing: "extend")
164
+ index_items(rows, hostid, keys).each do |key, row|
165
+ id = response_identifier(row["itemid"])
166
+ raise ProtocolError, "item.get returned duplicate item IDs across hosts" if ids.key?(id)
167
+
168
+ ids[id] = true
169
+ result[[owner, key]] = row
170
+ end
171
+ end
172
+ end
173
+
174
+ # 不信任服务端过滤器:返回项必须位于所请求的主机和 key 集合中。
175
+ def index_items(rows, hostid, keys)
176
+ owner = positive_id_attribute(hostid, "hostid")
177
+ requested = keys.to_h { |key| [key, true] }
178
+ ids = {}
179
+ response_objects(rows).each_with_object({}) do |row, result|
180
+ id = response_identifier(row["itemid"])
181
+ unless response_identifier(row["hostid"], "hostid") == owner && requested.key?(row["key_"])
182
+ raise ProtocolError, "item.get returned an item outside the requested host and keys"
183
+ end
184
+ raise Conflict, "multiple items use the same key on host #{hostid}" if result.key?(row["key_"])
185
+ raise ProtocolError, "item.get returned duplicate item IDs" if ids.key?(id)
186
+
187
+ ids[id] = true
188
+ result[row["key_"]] = row
189
+ end
190
+ end
191
+
153
192
  # 创建必填字段必须在整批写入开始前校验。
154
- def plan_item(attributes)
155
- current = find_by_key(hostid: attributes[:hostid], key: attributes[:key_])
193
+ def plan_item(attributes, current = find_by_key(hostid: attributes[:hostid], key: attributes[:key_]))
156
194
  validate_item_type!(attributes[:type]) if attributes.key?(:type)
157
195
  response_identifier(current["itemid"]) if current
158
196
  unless current
@@ -177,10 +215,15 @@ class ZabbixManager
177
215
  # @return [Hash]
178
216
  # @api private
179
217
  def validate_item!(data)
180
- attributes = data.deep_symbolize_keys
181
- raise Invalid, "hostid is required" if attributes[:hostid].blank?
182
- raise Invalid, "key_ is required" if attributes[:key_].blank?
183
- raise Invalid, "name is required" if attributes[:name].blank?
218
+ raise Invalid, "item must be a hash" unless data.is_a?(Hash)
219
+
220
+ attributes = data.deep_symbolize_keys.deep_dup
221
+ positive_id_attribute(attributes[:hostid], "hostid")
222
+ %i[key_ name].each do |field|
223
+ unless attributes[field].is_a?(String) && attributes[field].present?
224
+ raise Invalid, "#{field} must be a non-empty string"
225
+ end
226
+ end
184
227
 
185
228
  attributes
186
229
  end
@@ -216,12 +259,12 @@ class ZabbixManager
216
259
 
217
260
  # 回查监控项归属,阻止共享高权限令牌跨主机修改。
218
261
  def verify_host_ownership!(hostid, ids)
219
- raise Invalid, "hostid is required" if hostid.blank?
262
+ positive_id_attribute(hostid, "hostid")
220
263
 
221
264
  result = @client.api_request(
222
265
  method: "item.get", params: { hostids: hostid, itemids: ids, output: ["itemid"] }
223
266
  )
224
- owned_ids = result.map { |item| item.fetch("itemid").to_s }
267
+ owned_ids = response_objects(result).map { |item| response_identifier(item["itemid"]).to_s }
225
268
  foreign_ids = ids - owned_ids
226
269
  raise Conflict, "items do not belong to host #{hostid}: #{foreign_ids.join(", ")}" if foreign_ids.any?
227
270
  end
@@ -54,6 +54,8 @@ class ZabbixManager
54
54
  }
55
55
  }
56
56
  )
57
+ result = response_objects(result)
58
+ result.each { |proxy_data| response_identifier(proxy_data["proxyid"]) }
57
59
  raise Conflict, "proxy lookup is ambiguous for #{proxy}" if result.length > 1
58
60
 
59
61
  result.first&.fetch("proxyid", nil)
@@ -43,7 +43,7 @@ class ZabbixManager
43
43
  # @return [Array<Hash>]
44
44
  def for_host(hostid, output: "extend", select_items: nil, select_functions: nil,
45
45
  select_dependencies: nil, select_tags: nil, filter: nil)
46
- raise Invalid, "hostid is required" if hostid.blank?
46
+ positive_id_attribute(hostid, "hostid")
47
47
 
48
48
  params = { hostids: hostid, output: output }
49
49
  params[:selectItems] = select_items if select_items
@@ -51,12 +51,13 @@ class ZabbixManager
51
51
  params[:selectDependencies] = select_dependencies if select_dependencies
52
52
  params[:selectTags] = select_tags if select_tags
53
53
  params[:filter] = filter if filter.present?
54
- @client.api_request(method: "trigger.get", params: params)
54
+ response_objects(@client.api_request(method: "trigger.get", params: params))
55
55
  end
56
56
 
57
57
  # 按主机、描述和可选标签查询唯一触发器。
58
58
  # @return [Hash, nil]
59
59
  def find_for_host(hostid:, description:, tags: nil)
60
+ positive_id_attribute(hostid, "hostid")
60
61
  params = {
61
62
  hostids: hostid,
62
63
  filter: { description: description },
@@ -68,6 +69,8 @@ class ZabbixManager
68
69
  method: "trigger.get",
69
70
  params: params
70
71
  )
72
+ response_objects(result)
73
+ result.each { |trigger| response_identifier(trigger["triggerid"]) }
71
74
  raise Conflict, "multiple triggers match #{description} on host #{hostid}" if result.length > 1
72
75
 
73
76
  result.first
@@ -79,6 +82,7 @@ class ZabbixManager
79
82
  # @param managed_key [String] 稳定、非秘密的受管身份
80
83
  # @return [Hash, nil]
81
84
  def find_managed_for_host(hostid:, managed_key:)
85
+ positive_id_attribute(hostid, "hostid")
82
86
  result = @client.api_request(
83
87
  method: "trigger.get",
84
88
  params: {
@@ -90,6 +94,8 @@ class ZabbixManager
90
94
  selectTags: "extend"
91
95
  }
92
96
  )
97
+ response_objects(result)
98
+ result.each { |trigger| response_identifier(trigger["triggerid"]) }
93
99
  raise Conflict, "multiple triggers use managed key #{managed_key}" if result.length > 1
94
100
 
95
101
  result.first
@@ -101,7 +107,12 @@ class ZabbixManager
101
107
  id = normalized_ids([triggerid]).first
102
108
  params = { triggerids: id, output: "extend" }
103
109
  params[:selectDependencies] = select_dependencies if select_dependencies
104
- result = @client.api_request(method: "trigger.get", params: params)
110
+ result = response_objects(@client.api_request(method: "trigger.get", params: params))
111
+ result.each do |trigger|
112
+ unless response_identifier(trigger["triggerid"]).to_s == id
113
+ raise ProtocolError, "trigger.get returned an unexpected trigger ID"
114
+ end
115
+ end
105
116
  raise Conflict, "multiple triggers use triggerid #{id}" if result.length > 1
106
117
 
107
118
  result.first
@@ -111,7 +122,8 @@ class ZabbixManager
111
122
  # 可通过 Client 的 upsert_lock 接入跨进程锁。
112
123
  # @return [Integer]
113
124
  def upsert_for_host(data)
114
- attributes = data.deep_symbolize_keys
125
+ attributes = data.deep_symbolize_keys.deep_dup
126
+ positive_id_attribute(attributes[:hostid], "hostid")
115
127
  lock_key = "trigger:#{attributes[:hostid]}:#{attributes[:managed_key] || attributes[:description]}"
116
128
  @client.with_upsert_lock(lock_key) { perform_upsert_for_host(attributes) }
117
129
  end
@@ -185,7 +197,7 @@ class ZabbixManager
185
197
  attributes = data.deep_symbolize_keys
186
198
  hostid = attributes.delete(:hostid)
187
199
  managed_key = attributes.delete(:managed_key)&.to_s
188
- raise Invalid, "hostid is required" if hostid.blank?
200
+ positive_id_attribute(hostid, "hostid")
189
201
  raise Invalid, "description is required" if attributes[:description].blank?
190
202
  raise Invalid, "expression is required" if attributes[:expression].blank?
191
203
 
@@ -228,7 +240,7 @@ class ZabbixManager
228
240
 
229
241
  raise ResultUnknown,
230
242
  "trigger create result is unknown; inspect managed key #{managed_key.inspect} before retrying: " \
231
- "#{original_error.message}"
243
+ "#{original_error.message}"
232
244
  end
233
245
 
234
246
  # 对结果不确定的创建执行短暂退避回读,不重放写请求。
@@ -269,12 +281,12 @@ class ZabbixManager
269
281
 
270
282
  # 回查触发器归属,阻止共享高权限令牌跨主机修改。
271
283
  private def verify_host_ownership!(hostid, ids)
272
- raise Invalid, "hostid is required" if hostid.blank?
284
+ positive_id_attribute(hostid, "hostid")
273
285
 
274
286
  result = @client.api_request(
275
287
  method: "trigger.get", params: { hostids: hostid, triggerids: ids, output: ["triggerid"] }
276
288
  )
277
- owned_ids = result.map { |trigger| trigger.fetch("triggerid").to_s }
289
+ owned_ids = response_objects(result).map { |trigger| response_identifier(trigger["triggerid"]).to_s }
278
290
  foreign_ids = ids - owned_ids
279
291
  raise Conflict, "triggers do not belong to host #{hostid}: #{foreign_ids.join(", ")}" if foreign_ids.any?
280
292
  end
@@ -40,7 +40,7 @@ class ZabbixManager
40
40
  group_ids = collection_ids(user_group_ids, "user_group_ids")
41
41
  users = collection_ids(user_ids, "user_ids", allow_empty: true)
42
42
  membership = api_version >= Gem::Version.new("6.0") ?
43
- { users: users.map { |id| { userid: id } } } : { userids: users }
43
+ { users: users.map { |id| { userid: id } } } : { userids: users }
44
44
  result = @client.api_request(
45
45
  method: "usergroup.update",
46
46
  params: group_ids.map { |id| membership.merge(usrgrpid: id) }
@@ -96,6 +96,7 @@ class ZabbixManager
96
96
  private
97
97
 
98
98
  def unique_macro_id(result, id_field)
99
+ response_objects(result)
99
100
  raise Conflict, "multiple macros match the requested identity" if result.length > 1
100
101
 
101
102
  response_identifier(result.first[id_field], id_field) if result.first
@@ -12,6 +12,9 @@ class ZabbixManager
12
12
  SUPPORTED_MAJOR_VERSIONS = (4..7)
13
13
  UNAUTHENTICATED_METHODS = %w[apiinfo.version user.login].freeze
14
14
  UPSERT_MUTEX_STRIPES = 64
15
+ LOG_LEVELS = { debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN,
16
+ error: Logger::ERROR, fatal: Logger::FATAL, unknown: Logger::UNKNOWN }.freeze
17
+ private_constant :LOG_LEVELS
15
18
 
16
19
  attr_reader :options, :logger, :api_version
17
20
 
@@ -54,16 +57,20 @@ class ZabbixManager
54
57
  # @raise [ApiError] 服务器明确拒绝请求
55
58
  # @raise [TransportError, ProtocolError] 未收到能够确认结果的有效响应
56
59
  def api_request(method:, params: {})
60
+ method = method.dup.freeze if method.is_a?(String)
57
61
  validate_request!(method, params)
58
62
  started_at = monotonic_time
59
63
  message = request_message(method, params)
60
- log(:debug, "request.started", method: method)
64
+ log(:debug, "request.started", method: method, request_id: message[:id])
61
65
  body = @transport.request(JSON.generate(message), bearer_token: bearer_token_for(method))
62
66
  result = parse_response(body, message.fetch(:id))
63
- log(:info, "request.completed", method: method, duration_ms: elapsed_ms(started_at))
67
+ log(:info, "request.completed", method: method, request_id: message[:id], duration_ms: elapsed_ms(started_at))
64
68
  result
65
69
  rescue StandardError => error
66
- log(:warn, "request.failed", method: method, duration_ms: elapsed_ms(started_at), error: error.class.name)
70
+ if started_at
71
+ log(:warn, "request.failed", method: method, request_id: message&.fetch(:id),
72
+ duration_ms: elapsed_ms(started_at), error: error.class.name)
73
+ end
67
74
  raise
68
75
  end
69
76
 
@@ -92,6 +99,9 @@ class ZabbixManager
92
99
  def log(level, event, data = {})
93
100
  return unless @logger
94
101
 
102
+ severity = LOG_LEVELS[level]
103
+ return if severity && @logger.respond_to?(:level) && @logger.level > severity
104
+
95
105
  @logger.tagged("zabbix_manager") do
96
106
  @logger.public_send(level) { JSON.generate(LogSanitizer.sanitize(data.merge(event: event))) }
97
107
  end
@@ -140,7 +150,9 @@ class ZabbixManager
140
150
  end
141
151
 
142
152
  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/)
153
+ valid_method = method.is_a?(String) && method.valid_encoding? && method.ascii_only? &&
154
+ method.match?(/\A[a-z][a-z0-9_]*\.[a-z][a-zA-Z0-9_]*\z/)
155
+ unless valid_method
144
156
  raise Invalid, "method must be a Zabbix API method name"
145
157
  end
146
158
  raise Invalid, "params must be a Hash or Array" unless params.is_a?(Hash) || params.is_a?(Array)
@@ -56,6 +56,12 @@ class ZabbixManager
56
56
  value = @options[key]
57
57
  raise Invalid, "#{key} must be a String" unless value.nil? || value.is_a?(String)
58
58
 
59
+ if key == :api_token && value && !value.empty?
60
+ unless value.valid_encoding? && value.ascii_only? && value.match?(/\A[!-~]+\z/)
61
+ raise Invalid, "api_token must contain printable ASCII characters without whitespace"
62
+ end
63
+ end
64
+
59
65
  @options.delete(key) if value.blank?
60
66
  end
61
67
  raise Invalid, "url is required" if @options[:url].blank?
@@ -99,7 +105,9 @@ class ZabbixManager
99
105
  raise Invalid, "upsert_lock must respond to call(key, &block)" if lock && !lock.respond_to?(:call)
100
106
 
101
107
  logger = @options[:logger]
102
- return if logger.nil? || %i[debug info warn formatter formatter=].all? { |method| logger.respond_to?(method) }
108
+ required = %i[debug info warn formatter formatter=]
109
+ required << :level= if @options.key?(:log_level)
110
+ return if logger.nil? || required.all? { |method| logger.respond_to?(method) }
103
111
 
104
112
  raise Invalid, "logger must support the Ruby Logger interface"
105
113
  end
@@ -9,9 +9,9 @@ class ZabbixManager
9
9
  module LogSanitizer
10
10
  REDACTED = "[FILTERED]"
11
11
  MAX_STRING_LENGTH = 2_000
12
- SENSITIVE_KEY_NAMES = "auth|authorization|api_token|community|cookie|http_password|password|passwd|" \
13
- "privatekey|secret|sessionid|snmp_community|snmpv3_authpassphrase|" \
14
- "snmpv3_privpassphrase|tls_psk|tls_psk_identity|token"
12
+ SENSITIVE_KEY_NAMES = "auth|authorization|authpassphrase|api_token|community|cookie|" \
13
+ "http_password|password|passwd|privatekey|privpassphrase|secret|sessionid|snmp_community|snmpv3_authpassphrase|" \
14
+ "snmpv3_privpassphrase|tls_psk|tls_psk_identity|token"
15
15
  SENSITIVE_KEYS = /\A(?:#{SENSITIVE_KEY_NAMES})\z/i
16
16
  FILTER = ActiveSupport::ParameterFilter.new(
17
17
  [SENSITIVE_KEYS, ->(_key, value) { value.replace(sanitize_string(value)) if value.is_a?(String) }]
@@ -39,19 +39,18 @@ class ZabbixManager
39
39
  # 清理字符串中的认证头和常见敏感键值。
40
40
  def sanitize_string(value)
41
41
  sanitized = value.dup
42
- sanitized.gsub!(/\b(Bearer|Basic)\s+[^\s,;]+/i, "\\1 #{REDACTED}")
42
+ # 先处理完整引号值,避免认证头规则在值内的分隔符处截断秘密。
43
43
  sanitized.gsub!(
44
- /((?:authorization)["']?\s*(?:=>|:|=)\s*)[^,;}\]]+/i,
45
- "\\1#{REDACTED}"
46
- )
47
- sanitized.gsub!(
48
- /((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*)(["'])(.*?)\2/i,
44
+ /((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*)(["'])((?:\\.|(?!\2)[^\\])*)\2/im,
49
45
  "\\1\\2#{REDACTED}\\2"
50
46
  )
51
47
  sanitized.gsub!(
52
- /((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*)(?!["'])[^,;}\]]+/i,
48
+ /((?:#{SENSITIVE_KEY_NAMES})["']?\s*(?:=>|:|=)\s*+)
49
+ (?!["']|#{Regexp.escape(REDACTED)}(?=[,;}\]]|\z))
50
+ (?:#{Regexp.escape(REDACTED)}|[^,;}\]])+/ix,
53
51
  "\\1#{REDACTED}"
54
52
  )
53
+ sanitized.gsub!(/\b(Bearer|Basic)\s+[^\s,;]+/i, "\\1 #{REDACTED}")
55
54
  sanitized.length > MAX_STRING_LENGTH ? "#{sanitized[0, MAX_STRING_LENGTH]}...[TRUNCATED]" : sanitized
56
55
  end
57
56
  end
@@ -213,8 +213,9 @@ class ZabbixManager
213
213
 
214
214
  @attributes[:macros] = Validation.array!(@attributes[:macros], "macros").map do |macro|
215
215
  value = Validation.hash!(macro, "macro")
216
- raise Invalid, "macros accept macro, value, type and description" if
217
- (value.keys - %i[macro value type description]).any?
216
+ if (value.keys - %i[macro value type description]).any?
217
+ raise Invalid, "macros accept macro, value, type and description"
218
+ end
218
219
  unless value[:macro].is_a?(String) && value[:macro].match?(/\A\{\$[^{}\r\n]+\}\z/)
219
220
  raise Invalid, "macro name must use {$NAME} syntax"
220
221
  end
@@ -14,11 +14,12 @@ class ZabbixManager
14
14
  attr_reader :host_reference, :interface_name, :threshold, :low_traffic, :status, :speed,
15
15
  :reachability_target, :metadata
16
16
 
17
+ # 保存调用方定义的独立快照;等待锁和远端发现期间不重新读取可变输入。
17
18
  # 仅解析本地定义,不访问服务器;status/speed 为开关或显式 itemid 选择器。
18
19
  # @param attributes [Hash] 规范化后的单端点清单字段
19
20
  # @raise [Invalid] 字段、阈值或目标格式不合法
20
21
  def initialize(attributes)
21
- @attributes = Validation.hash!(attributes, "line")
22
+ @attributes = Validation.hash!(attributes, "line").deep_dup
22
23
  @attributes.assert_valid_keys(*FIELDS)
23
24
  @interface_name = Validation.text!(@attributes[:interface_name], "line interface_name")
24
25
  validate_identity!
@@ -88,7 +89,7 @@ class ZabbixManager
88
89
  end
89
90
 
90
91
  candidates = @attributes.key?(:host_candidates) ?
91
- Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
92
+ Validation.array!(@attributes[:host_candidates], "host_candidates") : [host]
92
93
  candidates = candidates.map { |value| Validation.text!(value, "line host candidate").strip }.uniq
93
94
  raise Invalid, "line host candidate is required" if candidates.empty?
94
95
 
@@ -102,6 +102,7 @@ class ZabbixManager
102
102
  counter_direction(item[:snmp_oid]) || item[:key_].to_s.match?(/if(?:hc)?(in|out)octets/i) ||
103
103
  (%w[0 7].include?(item[:type].to_s) && item[:key_].match?(/\Anet\.if\.(in|out)\[/))
104
104
  end
105
+
105
106
  private_class_method :raw_counter?
106
107
 
107
108
  private
@@ -73,7 +73,9 @@ class ZabbixManager
73
73
 
74
74
  normalized_ids([id])
75
75
 
76
- current = dump_by_id(key.to_sym => id).find { |item| item.fetch(key).to_s == id.to_s }
76
+ current = response_objects(dump_by_id(key.to_sym => id)).find do |item|
77
+ response_identifier(item[key]) == id.to_i
78
+ end
77
79
  return id.to_i if current && attributes_match?(current, attributes)
78
80
 
79
81
  response_id(update_raw([attributes]), expected: [id])
@@ -83,7 +85,7 @@ class ZabbixManager
83
85
  # @param data [Hash] 包含 identify 指定的业务标识
84
86
  # @return [Array<Hash>]
85
87
  def get_full_data(data)
86
- get_raw(filter: identity_filter(data), output: "extend")
88
+ response_objects(get_raw(filter: identity_filter(data), output: "extend"))
87
89
  end
88
90
 
89
91
  # 按对象 ID 查询完整对象。
@@ -94,13 +96,16 @@ class ZabbixManager
94
96
  id = attributes[key.to_sym]
95
97
  raise Invalid, "#{key} is required" if id.blank?
96
98
 
97
- get_raw(filter: { key.to_sym => id }, output: "extend")
99
+ response_objects(get_raw(filter: { key.to_sym => id }, output: "extend"))
98
100
  end
99
101
 
100
102
  # @return [Hash] 业务标识到原生字符串 ID 的映射
101
103
  # @raise [Conflict] 多个资源具有相同业务标识
102
104
  def all
103
- get_raw(output: "extend").each_with_object({}) do |item, result|
105
+ response_objects(get_raw(output: "extend")).each_with_object({}) do |item, result|
106
+ response_identifier(item[key])
107
+ raise ProtocolError, "invalid #{method_name} response: #{identify} is missing" unless item.key?(identify)
108
+
104
109
  name = item.fetch(identify)
105
110
  raise Conflict, "multiple #{method_name} objects share the same identity" if result.key?(name)
106
111
 
@@ -116,7 +121,12 @@ class ZabbixManager
116
121
  name = attributes[identify.to_sym]
117
122
  raise Invalid, "#{identify} not supplied in call to get_id" if name.nil?
118
123
 
119
- matches = get_raw(filter: attributes, output: [key, identify]).select do |item|
124
+ rows = response_objects(get_raw(filter: attributes, output: [key, identify]))
125
+ rows.each do |item|
126
+ response_identifier(item[key])
127
+ raise ProtocolError, "invalid #{method_name} response: #{identify} is missing" unless item.key?(identify)
128
+ end
129
+ matches = rows.select do |item|
120
130
  item[identify].to_s == name.to_s
121
131
  end
122
132
  raise Conflict, "multiple #{method_name} objects match the requested identity" if matches.length > 1
@@ -163,6 +173,23 @@ class ZabbixManager
163
173
 
164
174
  private
165
175
 
176
+ # 高层查询必须区分空结果与损坏的响应;原生 get_raw 保持透传。
177
+ def response_objects(result)
178
+ unless result.is_a?(Array) && result.all? { |object| object.is_a?(Hash) }
179
+ raise ProtocolError, "invalid #{method_name} response: expected an array of objects"
180
+ end
181
+
182
+ result
183
+ end
184
+
185
+ # 单对象查询边界不能接受 ID 集合、布尔值或浮点数。
186
+ def positive_id_attribute(value, name)
187
+ id = integer_attribute(value, name)
188
+ raise Invalid, "#{name} must be a positive integer" unless id.positive?
189
+
190
+ id
191
+ end
192
+
166
193
  def validate_boolean!(value, name)
167
194
  raise Invalid, "#{name} must be true or false" unless value == true || value == false
168
195
  end
@@ -108,6 +108,7 @@ class ZabbixManager
108
108
 
109
109
  # 显式主机过滤和响应归属检查保证外部 itemid 不能越过查询边界。
110
110
  def items_for_host(hostid, ids)
111
+ requested_ids = ids.to_h { |id| [id, true] }
111
112
  rows = @manager.query(method: "item.get", params: {
112
113
  hostids: [hostid], itemids: ids, monitored: true,
113
114
  output: %w[itemid hostid name units value_type]
@@ -115,7 +116,7 @@ class ZabbixManager
115
116
  response_rows!(rows, "item.get").each_with_object({}) do |item, result|
116
117
  id = response_integer!(item["itemid"], "itemid", positive: true).to_s
117
118
  owner = response_integer!(item["hostid"], "hostid", positive: true).to_s
118
- unless ids.include?(id) && owner == hostid && !result.key?(id)
119
+ unless requested_ids.key?(id) && owner == hostid && !result.key?(id)
119
120
  raise ProtocolError, "item.get returned unexpected or duplicate item ownership"
120
121
  end
121
122
  unless item["name"].is_a?(String) && item["units"].is_a?(String)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ZabbixManager
4
- VERSION = "6.0.0"
4
+ VERSION = "6.0.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zabbix_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 6.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - WENWU YAN