diff options
author | Ismaël Bouya <ismael.bouya@normalesup.org> | 2023-10-04 01:35:06 +0200 |
---|---|---|
committer | Ismaël Bouya <ismael.bouya@normalesup.org> | 2023-10-04 02:11:48 +0200 |
commit | 1a64deeb894dc95e2645a75771732c6cc53a79ad (patch) | |
tree | 1b9df4838f894577a09b9b260151756272efeb53 /systems/monitoring-1/status | |
parent | fa25ffd4583cc362075cd5e1b4130f33306103f0 (diff) | |
download | Nix-1a64deeb894dc95e2645a75771732c6cc53a79ad.tar.gz Nix-1a64deeb894dc95e2645a75771732c6cc53a79ad.tar.zst Nix-1a64deeb894dc95e2645a75771732c6cc53a79ad.zip |
Squash changes containing private information
There were a lot of changes since the previous commit, but a lot of them
contained personnal information about users. All thos changes got
stashed into a single commit (history is kept in a different place) and
private information was moved in a separate private repository
Diffstat (limited to 'systems/monitoring-1/status')
-rwxr-xr-x | systems/monitoring-1/status/app.py | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/systems/monitoring-1/status/app.py b/systems/monitoring-1/status/app.py new file mode 100755 index 0000000..ff92891 --- /dev/null +++ b/systems/monitoring-1/status/app.py | |||
@@ -0,0 +1,414 @@ | |||
1 | from flask import Flask, request, render_template_string, jsonify, make_response | ||
2 | from flask_login import LoginManager, UserMixin, login_required | ||
3 | import socket | ||
4 | import json | ||
5 | import time | ||
6 | import os | ||
7 | |||
8 | login_manager = LoginManager() | ||
9 | app = Flask(__name__) | ||
10 | login_manager.init_app(app) | ||
11 | |||
12 | STATUS = [ | ||
13 | "ok", | ||
14 | "warning", | ||
15 | "error", | ||
16 | "unknown" | ||
17 | ] | ||
18 | |||
19 | HOST_STATUS = [ | ||
20 | "up", | ||
21 | "down", | ||
22 | "unreachable", | ||
23 | ] | ||
24 | |||
25 | #### Push | ||
26 | AUTHORIZED_KEYS = os.environ.get("TOKENS", "").split() | ||
27 | COMMAND_FILE = "/var/run/naemon/naemon.cmd" | ||
28 | |||
29 | ERROR_NO_REQUEST_HANDLER="NO REQUEST HANDLER" | ||
30 | ERROR_NO_TOKEN_SUPPLIED="NO TOKEN" | ||
31 | ERROR_BAD_TOKEN_SUPPLIED="BAD TOKEN" | ||
32 | |||
33 | ERROR_BAD_COMMAND_FILE="BAD COMMAND FILE" | ||
34 | ERROR_COMMAND_FILE_OPEN_WRITE="COMMAND FILE UNWRITEABLE" | ||
35 | ERROR_COMMAND_FILE_OPEN="CANNOT OPEN COMMAND FILE" | ||
36 | ERROR_BAD_WRITE="WRITE ERROR" | ||
37 | |||
38 | ERROR_BAD_DATA="BAD DATA" | ||
39 | ERROR_BAD_JSON="BAD JSON" | ||
40 | |||
41 | ERROR_NO_CORRECT_STATUS="NO STATUS WAS CORRECT" | ||
42 | #### /Push | ||
43 | |||
44 | def get_lq(request): | ||
45 | # https://mathias-kettner.de/checkmk_livestatus.html | ||
46 | socket_path="/var/run/naemon/live" | ||
47 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | ||
48 | s.connect(socket_path) | ||
49 | s.send(request.encode()) | ||
50 | s.shutdown(socket.SHUT_WR) | ||
51 | chunks = [] | ||
52 | while len(chunks) == 0 or len(chunks[-1]) > 0: | ||
53 | chunks.append(s.recv(4096)) | ||
54 | s.close() | ||
55 | return b"".join(chunks).decode() | ||
56 | |||
57 | class Host: | ||
58 | def __init__(self, name, alias, status, webname, vhost): | ||
59 | self.name = name | ||
60 | self.alias = alias | ||
61 | self.webname = webname or alias | ||
62 | self.vhost = vhost | ||
63 | self.status = status | ||
64 | self.services = [] | ||
65 | |||
66 | @classmethod | ||
67 | def parse_hosts(cls, payload, vhost): | ||
68 | parsed = filter(lambda x: x.vhost == vhost, [cls.parse(p) for p in json.loads(payload)]) | ||
69 | return {p.name: p for p in parsed} | ||
70 | |||
71 | @classmethod | ||
72 | def parse(cls, payload): | ||
73 | return cls(payload[0], payload[1], HOST_STATUS[payload[2]], payload[3].get("WEBSTATUS_NAME"), payload[3].get("WEBSTATUS_VHOST")) | ||
74 | |||
75 | def __repr__(self): | ||
76 | return "Host {}: {} ({})".format(self.name, self.alias, self.webname) | ||
77 | |||
78 | @classmethod | ||
79 | def query(cls, vhost): | ||
80 | answer = get_lq("""GET hosts | ||
81 | Filter: groups >= webstatus-hosts | ||
82 | Columns: name alias state custom_variables | ||
83 | OutputFormat: json | ||
84 | """) | ||
85 | return cls.parse_hosts(answer, vhost) | ||
86 | |||
87 | def fill_services(self, services): | ||
88 | self.services = [service for service in services if service.host == self.name] | ||
89 | |||
90 | class ServiceGroup: | ||
91 | def __init__(self, name, alias): | ||
92 | self.name = name | ||
93 | self.alias = alias | ||
94 | self.services = [] | ||
95 | |||
96 | @classmethod | ||
97 | def parse_groups(cls, payload): | ||
98 | parsed = [cls.parse(p) for p in json.loads(payload)] | ||
99 | return {p.name: p for p in parsed} | ||
100 | |||
101 | @classmethod | ||
102 | def parse(cls, payload): | ||
103 | return cls(payload[0], payload[1]) | ||
104 | |||
105 | @classmethod | ||
106 | def query(cls): | ||
107 | answer = get_lq("""GET servicegroups | ||
108 | Filter: name ~ ^webstatus- | ||
109 | Columns: name alias custom_variables | ||
110 | OutputFormat: json | ||
111 | """) | ||
112 | return cls.parse_groups(answer) | ||
113 | |||
114 | def fill_services(self, services, hosts): | ||
115 | self.services = [service for service in services if any([group == self.name for group in service.groups]) and service.host in hosts] | ||
116 | |||
117 | def __repr__(self): | ||
118 | return "ServiceGroup {}: {}".format(self.name, self.alias) | ||
119 | |||
120 | class Service: | ||
121 | def __init__(self, name, host, groups, status, webname, url, description, infos): | ||
122 | self.name = name | ||
123 | self.host = host | ||
124 | self.groups = groups | ||
125 | self.status = status | ||
126 | self.webname = webname | ||
127 | self.url = url | ||
128 | self.description = description | ||
129 | self.infos = infos | ||
130 | |||
131 | @classmethod | ||
132 | def parse_services(cls, payload): | ||
133 | parsed = json.loads(payload) | ||
134 | return [cls.parse(p) for p in parsed if cls.valid(p[2])] | ||
135 | |||
136 | @staticmethod | ||
137 | def valid(groups): | ||
138 | return any([b.startswith("webstatus-") for b in groups]) | ||
139 | |||
140 | @classmethod | ||
141 | def parse(cls, payload): | ||
142 | return cls(payload[0], | ||
143 | payload[1], | ||
144 | payload[2], | ||
145 | STATUS[payload[3]], | ||
146 | payload[4].get("WEBSTATUS_NAME"), | ||
147 | payload[4].get("WEBSTATUS_URL"), | ||
148 | payload[5], | ||
149 | payload[6]) | ||
150 | |||
151 | @classmethod | ||
152 | def query(cls): | ||
153 | answer = get_lq("""GET services | ||
154 | Columns: display_name host_name groups state custom_variables description plugin_output | ||
155 | OutputFormat: json | ||
156 | """) | ||
157 | return cls.parse_services(answer) | ||
158 | |||
159 | def __repr__(self): | ||
160 | return "Service {}: {}".format(self.name, self.webname) | ||
161 | |||
162 | def get_infos(vhost): | ||
163 | hosts = Host.query(vhost) | ||
164 | servicegroups = ServiceGroup.query() | ||
165 | services = Service.query() | ||
166 | |||
167 | for host in hosts: | ||
168 | hosts[host].fill_services(services) | ||
169 | for group in servicegroups: | ||
170 | servicegroups[group].fill_services(services, hosts) | ||
171 | return (hosts, servicegroups, services) | ||
172 | |||
173 | TEMPLATE='''<?xml version="1.0" encoding="UTF-8"?> | ||
174 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> | ||
175 | <html lang="en" xmlns="http://www.w3.org/1999/xhtml"> | ||
176 | <head> | ||
177 | <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||
178 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
179 | <title>Status</title> | ||
180 | <meta name="referrer" content="no-referrer" /> | ||
181 | <style type="text/css"> | ||
182 | ul { | ||
183 | list-style: none; | ||
184 | margin: 0px; | ||
185 | } | ||
186 | ul li:nth-child(2n) { | ||
187 | background-color: rgb(240, 240, 240); | ||
188 | } | ||
189 | li.resource, li.service { | ||
190 | margin: 1px 0px; | ||
191 | } | ||
192 | span.status { | ||
193 | display: inline-block; | ||
194 | width: 150px; | ||
195 | text-align: center; | ||
196 | margin-right: 5px; | ||
197 | font-variant: small-caps; | ||
198 | font-size: 1.2em; | ||
199 | } | ||
200 | .status_ok,.status_up { | ||
201 | background-color: rgba(0, 255, 0, 0.5);; | ||
202 | } | ||
203 | .status_warning { | ||
204 | background-color: rgba(255, 255, 0, 0.5);; | ||
205 | } | ||
206 | .status_error,.status_down { | ||
207 | background-color: rgba(255, 0, 0, 0.5);; | ||
208 | } | ||
209 | .status_unknown,.status_unreachable { | ||
210 | background-color: rgba(0, 0, 255, 0.5);; | ||
211 | } | ||
212 | .infos { | ||
213 | margin-left: 40px; | ||
214 | color: rgb(100, 100, 100); | ||
215 | } | ||
216 | div#services { | ||
217 | column-count: auto; | ||
218 | column-width: 36em; | ||
219 | } | ||
220 | div.servicegroup { | ||
221 | -webkit-column-break-inside: avoid; | ||
222 | break-inside: avoid; | ||
223 | } | ||
224 | h3.servicegroup_title, h3.host_title { | ||
225 | margin: 1px 0px; | ||
226 | } | ||
227 | span.service_host, span.infos { | ||
228 | float: right; | ||
229 | display: inline-block; | ||
230 | color: rgb(100, 100, 100); | ||
231 | } | ||
232 | </style> | ||
233 | </head> | ||
234 | <body> | ||
235 | <h2>Hosts</h2> | ||
236 | {%- for host in hosts.values() %} | ||
237 | <h3 class="host_title"> | ||
238 | <span class="status status_{{ host.status }}">{{ host.status }}</span> | ||
239 | <span class="host">{{ host.webname }}</span> | ||
240 | </h3> | ||
241 | {%- for service in servicegroups["webstatus-resources"].services if service.host == host.name -%} | ||
242 | {%- if loop.first %} | ||
243 | <ul class="resources"> | ||
244 | {% endif %} | ||
245 | |||
246 | <li class="resource"> | ||
247 | <span class="status status_{{ service.status }}">{{ service.status }}</span> | ||
248 | <span class="description">{{ service.description }}</span> | ||
249 | <span class="infos">{{ service.infos }}</span> | ||
250 | </li> | ||
251 | |||
252 | {%- if loop.last %} | ||
253 | </ul> | ||
254 | {% endif %} | ||
255 | {% endfor %} | ||
256 | {%- endfor %} | ||
257 | |||
258 | {%- for group in servicegroups.values() if group.services and group.name != "webstatus-resources" %} | ||
259 | {%- if loop.first %} | ||
260 | <h2>Services</h2> | ||
261 | <div id="services"> | ||
262 | {%- endif %} | ||
263 | <div class="servicegroup"> | ||
264 | <h3 class="servicegroup_title">{{ group.alias }}</h3> | ||
265 | {%- for service in group.services if service.host in hosts -%} | ||
266 | {%- if loop.first %} | ||
267 | <ul class="services"> | ||
268 | {% endif %} | ||
269 | |||
270 | <li class="service" title="{{ service.infos }}"> | ||
271 | <span class="status status_{{ service.status }}">{{ service.status }}</span> | ||
272 | <span class="description"> | ||
273 | {% if service.url and service.url.startswith("https://") %} | ||
274 | <a href="{{ service.url }}">{{ service.webname or service.description }}</a> | ||
275 | {% else %} | ||
276 | {{ service.webname or service.description }} | ||
277 | {% endif %} | ||
278 | </span> | ||
279 | <span class="service_host">{{ hosts[service.host].webname }}</span> | ||
280 | </li> | ||
281 | |||
282 | {%- if loop.last %} | ||
283 | </ul> | ||
284 | {% endif %} | ||
285 | {%- endfor -%} | ||
286 | </div> | ||
287 | {%- if loop.last %} | ||
288 | </div> | ||
289 | {% endif %} | ||
290 | {%- endfor %} | ||
291 | </body> | ||
292 | </html> | ||
293 | ''' | ||
294 | |||
295 | @login_manager.request_loader | ||
296 | def load_user_from_request(request): | ||
297 | api_key = request.headers.get('Token') | ||
298 | if api_key in AUTHORIZED_KEYS: | ||
299 | return UserMixin() | ||
300 | content = request.get_json(force=True, silent=True) | ||
301 | if content is not None and content.get("token") in AUTHORIZED_KEYS: | ||
302 | return UserMixin() | ||
303 | |||
304 | @app.route("/live", methods=["POST"]) | ||
305 | @login_required | ||
306 | def live(): | ||
307 | query = request.get_data() | ||
308 | result = get_lq(query.decode() + "\n") | ||
309 | resp = make_response(result) | ||
310 | resp.content_type = "text/plain" | ||
311 | return resp | ||
312 | |||
313 | @app.route("/", methods=["GET"]) | ||
314 | def get(): | ||
315 | (hosts, servicegroups, services) = get_infos(request.host) | ||
316 | resp = make_response(render_template_string(TEMPLATE, hosts=hosts, servicegroups=servicegroups)) | ||
317 | resp.content_type = "text/html" | ||
318 | return resp | ||
319 | |||
320 | @app.route("/", methods=["POST"]) | ||
321 | @login_required | ||
322 | def push(): | ||
323 | content = request.get_json(force=True, silent=True) | ||
324 | if content is None: | ||
325 | return ERROR_BAD_JSON | ||
326 | if content.get("cmd") != "submitcheck": | ||
327 | return render_error(ERROR_NO_REQUEST_HANDLER) | ||
328 | if "checkresult" not in content or not isinstance(content["checkresult"], list): | ||
329 | return render_error(ERROR_BAD_DATA) | ||
330 | |||
331 | checks = 0 | ||
332 | errors = 0 | ||
333 | for check in map(lambda x: CheckResult.from_json(x), content["checkresult"]): | ||
334 | if check is None: | ||
335 | errors += 1 | ||
336 | continue | ||
337 | try: | ||
338 | write_check_output(check) | ||
339 | except Exception as e: | ||
340 | return render_error(str(e)) | ||
341 | checks += 1 | ||
342 | return render_response(checks, errors) | ||
343 | |||
344 | def write_check_output(check): | ||
345 | if check.type== "service": | ||
346 | command = "[{time}] PROCESS_SERVICE_CHECK_RESULT;{hostname};{servicename};{state};{output}"; | ||
347 | else: | ||
348 | command = "[{time}] PROCESS_HOST_CHECK_RESULT;{hostname};{state};{output}"; | ||
349 | formatted = command.format( | ||
350 | time=int(time.time()), | ||
351 | hostname=check.hostname, | ||
352 | state=check.state, | ||
353 | output=check.output, | ||
354 | servicename=check.servicename, | ||
355 | ) | ||
356 | |||
357 | if not os.path.exists(COMMAND_FILE): | ||
358 | raise Exception(ERROR_BAD_COMMAND_FILE) | ||
359 | if not os.access(COMMAND_FILE, os.W_OK): | ||
360 | raise Exception(ERROR_COMMAND_FILE_OPEN_WRITE) | ||
361 | if not os.access(COMMAND_FILE, os.W_OK): | ||
362 | raise Exception(ERROR_COMMAND_FILE_OPEN_WRITE) | ||
363 | try: | ||
364 | with open(COMMAND_FILE, "w") as c: | ||
365 | c.write(formatted + "\n") | ||
366 | except Exception as e: | ||
367 | raise Exception(ERROR_BAD_WRITE) | ||
368 | |||
369 | def render_error(error): | ||
370 | return jsonify({ | ||
371 | "status": "error", | ||
372 | "message": error, | ||
373 | }) | ||
374 | |||
375 | def render_response(checks, errors): | ||
376 | if checks > 0: | ||
377 | return jsonify({ | ||
378 | "status": "ok", | ||
379 | "result": { | ||
380 | "checks": checks, | ||
381 | "errors": errors, | ||
382 | } | ||
383 | }) | ||
384 | else: | ||
385 | return jsonify({ | ||
386 | "status": "error", | ||
387 | "message": ERROR_NO_CORRECT_STATUS, | ||
388 | }) | ||
389 | |||
390 | class CheckResult: | ||
391 | def __init__(self, hostname, state, output, servicename, checktype): | ||
392 | self.hostname = hostname | ||
393 | self.state = state | ||
394 | self.output = output | ||
395 | self.servicename = servicename | ||
396 | self.type = checktype | ||
397 | |||
398 | @classmethod | ||
399 | def from_json(klass, j): | ||
400 | if not isinstance(j, dict): | ||
401 | return None | ||
402 | for key in ["hostname", "state", "output"]: | ||
403 | if key not in j or not isinstance(j[key], str): | ||
404 | return None | ||
405 | for key in ["servicename", "type"]: | ||
406 | if key in j and not isinstance(j[key], str): | ||
407 | return None | ||
408 | return klass( | ||
409 | j["hostname"], | ||
410 | j["state"], | ||
411 | j["output"], | ||
412 | j.get("servicename", ""), | ||
413 | j.get("type", "host")) | ||
414 | |||