1 | <?php |
||
2 | /* Copyright (C) 2020 Adam Bishop <[email protected]> |
||
3 | * This program is free software: you can redistribute it and/or modify |
||
4 | * it under the terms of the GNU General Public License as published by |
||
5 | * the Free Software Foundation, either version 3 of the License, or |
||
6 | * (at your option) any later version. |
||
7 | * |
||
8 | * This program is distributed in the hope that it will be useful, |
||
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
10 | * GNU General Public License for more details. |
||
11 | * |
||
12 | * You should have received a copy of the GNU General Public License |
||
13 | * along with this program. If not, see <https://www.gnu.org/licenses/>. */ |
||
14 | |||
15 | /** |
||
16 | * API Transport |
||
17 | * |
||
18 | * @author Adam Bishop <[email protected]> |
||
19 | * @copyright 2020 Adam Bishop, LibreNMS |
||
20 | * @license GPL |
||
21 | */ |
||
22 | |||
23 | namespace LibreNMS\Alert\Transport; |
||
24 | |||
25 | use GuzzleHttp\Client; |
||
26 | use GuzzleHttp\Exception\GuzzleException; |
||
27 | use Illuminate\Support\Facades\Log; |
||
28 | use LibreNMS\Alert\Transport; |
||
29 | use LibreNMS\Config; |
||
30 | use LibreNMS\Enum\AlertState; |
||
31 | |||
32 | class Sensu extends Transport |
||
33 | { |
||
34 | // Sensu alert coding |
||
35 | const OK = 0; |
||
36 | const WARNING = 1; |
||
37 | const CRITICAL = 2; |
||
38 | const UNKNOWN = 3; |
||
39 | |||
40 | private static $status = [ |
||
41 | 'ok' => Sensu::OK, |
||
42 | 'warning' => Sensu::WARNING, |
||
43 | 'critical' => Sensu::CRITICAL, |
||
44 | ]; |
||
45 | |||
46 | private static $severity = [ |
||
0 ignored issues
–
show
introduced
by
Loading history...
|
|||
47 | 'recovered' => AlertState::RECOVERED, |
||
48 | 'alert' => AlertState::ACTIVE, |
||
49 | 'acknowledged' => AlertState::ACKNOWLEDGED, |
||
50 | 'worse' => AlertState::WORSE, |
||
51 | 'better' => AlertState::BETTER, |
||
52 | ]; |
||
53 | |||
54 | private static $client = null; |
||
55 | |||
56 | public function deliverAlert($obj, $opts) |
||
57 | { |
||
58 | $sensu_opts = []; |
||
59 | $sensu_opts['url'] = $this->config['sensu-url'] ? $this->config['sensu-url'] : 'http://127.0.0.1:3031'; |
||
60 | $sensu_opts['namespace'] = $this->config['sensu-namespace'] ? $this->config['sensu-namespace'] : 'default'; |
||
61 | $sensu_opts['prefix'] = $this->config['sensu-prefix']; |
||
62 | $sensu_opts['source-key'] = $this->config['sensu-source-key']; |
||
63 | |||
64 | Sensu::$client = new Client(); |
||
65 | |||
66 | try { |
||
67 | return $this->contactSensu($obj, $sensu_opts); |
||
68 | } catch (GuzzleException $e) { |
||
69 | return 'Sending event to Sensu failed: ' . $e->getMessage(); |
||
70 | } |
||
71 | } |
||
72 | |||
73 | public static function contactSensu($obj, $opts) |
||
74 | { |
||
75 | // The Sensu agent should be running on the poller - events can be sent directly to the backend but this has not been tested, and likely needs mTLS. |
||
76 | // The agent API is documented at https://docs.sensu.io/sensu-go/latest/reference/agent/#create-monitoring-events-using-the-agent-api |
||
77 | if (Sensu::$client->request('GET', $opts['url'] . '/healthz')->getStatusCode() !== 200) { |
||
78 | return 'Sensu API is not responding'; |
||
79 | } |
||
80 | |||
81 | if ($obj['state'] !== AlertState::RECOVERED && $obj['state'] !== AlertState::ACKNOWLEDGED && $obj['alerted'] === 0) { |
||
82 | // If this is the first event, send a forced "ok" dated (rrd.step / 2) seconds ago to tell Sensu the last time the check was healthy |
||
83 | $data = Sensu::generateData($obj, $opts, Sensu::OK, round(Config::get('rrd.step', 300) / 2)); |
||
84 | Log::debug('Sensu transport sent last good event to socket: ', $data); |
||
85 | |||
86 | $result = Sensu::$client->request('POST', $opts['url'] . '/events', ['json' => $data]); |
||
87 | if ($result->getStatusCode() !== 202) { |
||
88 | return $result->getReasonPhrase(); |
||
89 | } |
||
90 | |||
91 | sleep(5); |
||
92 | } |
||
93 | |||
94 | $data = Sensu::generateData($obj, $opts, Sensu::calculateStatus($obj['state'], $obj['severity'])); |
||
95 | Log::debug('Sensu transport sent event to socket: ', $data); |
||
96 | |||
97 | $result = Sensu::$client->request('POST', $opts['url'] . '/events', ['json' => $data]); |
||
98 | if ($result->getStatusCode() === 202) { |
||
99 | return true; |
||
100 | } |
||
101 | |||
102 | return $result->getReasonPhrase(); |
||
103 | } |
||
104 | |||
105 | public static function generateData($obj, $opts, $status, $offset = 0) |
||
106 | { |
||
107 | return [ |
||
108 | 'check' => [ |
||
109 | 'metadata' => [ |
||
110 | 'name' => Sensu::checkName($opts['prefix'], $obj['name']), |
||
111 | 'namespace' => $opts['namespace'], |
||
112 | 'annotations' => Sensu::generateAnnotations($obj), |
||
113 | ], |
||
114 | 'command' => sprintf('LibreNMS: %s', $obj['builder']), |
||
115 | 'executed' => time() - $offset, |
||
116 | 'interval' => Config::get('rrd.step', 300), |
||
117 | 'issued' => time() - $offset, |
||
118 | 'output' => $obj['msg'], |
||
119 | 'status' => $status, |
||
120 | ], |
||
121 | 'entity' => [ |
||
122 | 'metadata' => [ |
||
123 | 'name' => Sensu::getEntityName($obj, $opts['source-key']), |
||
124 | 'namespace' => $opts['namespace'], |
||
125 | ], |
||
126 | 'system' => [ |
||
127 | 'hostname' => $obj['hostname'], |
||
128 | 'os' => $obj['os'], |
||
129 | ], |
||
130 | ], |
||
131 | ]; |
||
132 | } |
||
133 | |||
134 | public static function generateAnnotations($obj) |
||
135 | { |
||
136 | return array_filter([ |
||
137 | 'generated-by' => 'LibreNMS', |
||
138 | 'acknowledged' => $obj['state'] === AlertState::ACKNOWLEDGED ? 'true' : 'false', |
||
139 | 'contact' => $obj['sysContact'], |
||
140 | 'description' => $obj['sysDescr'], |
||
141 | 'location' => $obj['location'], |
||
142 | 'documentation' => $obj['proc'], |
||
143 | 'librenms-notes' => $obj['notes'], |
||
144 | 'librenms-device-id' => strval($obj['device_id']), |
||
145 | 'librenms-rule-id' => strval($obj['rule_id']), |
||
146 | 'librenms-status-reason' => $obj['status_reason'], |
||
147 | ], function (string $s): bool { |
||
148 | return (bool) strlen($s); // strlen returns 0 for null, false or '', but 1 for integer 0 - unlike empty() |
||
149 | }); |
||
150 | } |
||
151 | |||
152 | public static function calculateStatus($state, $severity) |
||
153 | { |
||
154 | // Sensu only has a single short (status) to indicate both severity and status, so we need to map LibreNMS' state and severity onto it |
||
155 | |||
156 | if ($state === AlertState::RECOVERED) { |
||
157 | // LibreNMS alert is resolved, send ok |
||
158 | return Sensu::OK; |
||
159 | } |
||
160 | |||
161 | if (array_key_exists($severity, Sensu::$status)) { |
||
162 | // Severity is known, map the LibreNMS severity to the Sensu status |
||
163 | return Sensu::$status[$severity]; |
||
164 | } |
||
165 | |||
166 | // LibreNMS severity does not map to Sensu, send unknown |
||
167 | return Sensu::UNKNOWN; |
||
168 | } |
||
169 | |||
170 | public static function getEntityName($obj, $key) |
||
171 | { |
||
172 | if ($key === 'shortname') { |
||
173 | return Sensu::shortenName($obj['hostname']); |
||
174 | } |
||
175 | |||
176 | return $obj[$key]; |
||
177 | } |
||
178 | |||
179 | public static function shortenName($name) |
||
180 | { |
||
181 | // Shrink the last domain components - e.g. librenms.corp.example.net becomes librenms.cen |
||
182 | $components = explode('.', $name); |
||
183 | $count = count($components); |
||
184 | $trim = min([3, $count - 1]); |
||
185 | $result = ''; |
||
186 | |||
187 | if ($count <= 2) { // Can't be shortened |
||
188 | return $name; |
||
189 | } |
||
190 | |||
191 | for ($i = $count - 1; $i >= $count - $trim; $i--) { |
||
192 | // Walk the array in reverse order, taking the first letter from the $trim sections |
||
193 | $result = sprintf('%s%s', substr($components[$i], 0, 1), $result); |
||
194 | unset($components[$i]); |
||
195 | } |
||
196 | |||
197 | return sprintf('%s.%s', implode('.', $components), $result); |
||
198 | } |
||
199 | |||
200 | public static function checkName($prefix, $name) |
||
201 | { |
||
202 | $check = strtolower(str_replace(' ', '-', $name)); |
||
203 | |||
204 | if ($prefix) { |
||
205 | return sprintf('%s-%s', $prefix, $check); |
||
206 | } |
||
207 | |||
208 | return $check; |
||
209 | } |
||
210 | |||
211 | public static function configTemplate() |
||
212 | { |
||
213 | return [ |
||
214 | 'config' => [ |
||
215 | [ |
||
216 | 'title' => 'Sensu Endpoint', |
||
217 | 'name' => 'sensu-url', |
||
218 | 'descr' => 'To configure the agent API, see https://docs.sensu.io/sensu-go/latest/reference/agent/#api-configuration-flags (default: "http://localhost:3031")', |
||
219 | 'type' => 'text', |
||
220 | ], |
||
221 | [ |
||
222 | 'title' => 'Sensu Namespace', |
||
223 | 'name' => 'sensu-namespace', |
||
224 | 'descr' => 'The Sensu namespace that hosts exist in (default: "default")', |
||
225 | 'type' => 'text', |
||
226 | ], |
||
227 | [ |
||
228 | 'title' => 'Check Prefix', |
||
229 | 'name' => 'sensu-prefix', |
||
230 | 'descr' => 'An optional string to prefix the checks with', |
||
231 | 'type' => 'text', |
||
232 | ], |
||
233 | [ |
||
234 | 'title' => 'Source Key', |
||
235 | 'name' => 'sensu-source-key', |
||
236 | 'descr' => 'Should events be attributed to entities by hostname, sysName or shortname (default: hostname)', |
||
237 | 'type' => 'select', |
||
238 | 'options' => [ |
||
239 | 'hostname' => 'hostname', |
||
240 | 'sysName' => 'sysName', |
||
241 | 'shortname' => 'shortname', |
||
242 | ], |
||
243 | 'default' => 'hostname', |
||
244 | ], |
||
245 | ], |
||
246 | 'validation' => [ |
||
247 | 'sensu-url' => 'url', |
||
248 | 'sensu-source-key' => 'required|in:hostname,sysName,shortname', |
||
249 | ], |
||
250 | ]; |
||
251 | } |
||
252 | } |
||
253 |