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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +7 -2
- data/lib/zabbix_manager/classes/host_interfaces.rb +4 -4
- data/lib/zabbix_manager/classes/hosts.rb +4 -0
- data/lib/zabbix_manager/classes/items.rb +58 -15
- data/lib/zabbix_manager/classes/proxies.rb +2 -0
- data/lib/zabbix_manager/classes/triggers.rb +20 -8
- data/lib/zabbix_manager/classes/user_groups.rb +1 -1
- data/lib/zabbix_manager/classes/user_macros.rb +1 -0
- data/lib/zabbix_manager/client.rb +16 -4
- data/lib/zabbix_manager/configuration.rb +9 -1
- data/lib/zabbix_manager/log_sanitizer.rb +9 -10
- data/lib/zabbix_manager/monitoring/device.rb +3 -2
- data/lib/zabbix_manager/monitoring/line.rb +3 -2
- data/lib/zabbix_manager/monitoring/traffic_items.rb +1 -0
- data/lib/zabbix_manager/resource.rb +32 -5
- data/lib/zabbix_manager/traffic.rb +2 -1
- data/lib/zabbix_manager/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad8094e06c6d99c1ff334dd2cf9d86c40bb9f45467d811e53be92d1ffcf05bb3
|
|
4
|
+
data.tar.gz: 90375dfe1473875e17e2b8e82b3f947818076239b1ceb3d2f868001b600f4c12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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_]
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
42
|
+
# 先处理完整引号值,避免认证头规则在值内的分隔符处截断秘密。
|
|
43
43
|
sanitized.gsub!(
|
|
44
|
-
/((
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|