Skip to content

Commit 322ad10

Browse files
committed
bug fix
1 parent 6a60ef7 commit 322ad10

4 files changed

Lines changed: 142 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ graph_count = 3
6363
allow_legacy_url_auth = no
6464
```
6565

66-
The local `http://127.0.0.1/<omd_site_name>` URL is recommended for the graph export. The bot first tries CheckMK's legacy internal graph renderer. If that renderer is not available, it uses the CheckMK Web `graph_image.py` PNG export endpoint with the automation credentials above. The bridge authenticates with HTTP auth headers so secrets are not sent in the URL. If a loopback HTTPS URL fails because the certificate is not valid for `127.0.0.1` or `localhost`, the bridge retries over local HTTP and, if the local web server redirects back to HTTPS, as a last resort retries the loopback HTTPS request without certificate verification. This relaxed TLS fallback is only used for loopback hosts. Missing graph support will not stop the bot or bridge service.
66+
The local `http://127.0.0.1/<omd_site_name>` URL is recommended for the graph export. The bot first tries CheckMK's legacy internal graph renderer. If that renderer is not available, it tries CheckMK's notification graph endpoint `ajax_graph_images.py` and then the public `graph_image.py` PNG export endpoint. The bridge authenticates with HTTP auth headers so secrets are not sent in the URL. If a loopback HTTPS URL fails because the certificate is not valid for `127.0.0.1` or `localhost`, the bridge retries over local HTTP and, if the local web server redirects back to HTTPS, as a last resort retries the loopback HTTPS request without certificate verification. This relaxed TLS fallback is only used for loopback hosts. Missing graph support will not stop the bot or bridge service.
6767

6868
Existing installations are migrated automatically. Legacy configuration files are backed up before changes are made, and old site-local application files are moved to a `legacy-<timestamp>` directory below `/omd/sites/<omd_site_name>/local/share/checkmk-telegram-plus`.
6969

checkmk/bridge/checkmk_bridge.py

Lines changed: 133 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,59 @@ def service_graphs(params: dict[str, Any]) -> list[str]:
133133
return render_graphs_internal(hostname, service)
134134
except Exception as internal_exc:
135135
try:
136-
return fetch_graphs_from_web(hostname, service)
137-
except Exception as web_exc:
138-
raise RuntimeError(
139-
"Could not render Checkmk service graphs. "
140-
f"Internal renderer failed: {internal_exc}. "
141-
f"Web graph export failed: {web_exc}"
142-
) from web_exc
136+
return fetch_graphs_from_ajax(hostname, service)
137+
except Exception as ajax_exc:
138+
try:
139+
return fetch_graphs_from_web(hostname, service)
140+
except Exception as web_exc:
141+
raise RuntimeError(
142+
"Could not render Checkmk service graphs. "
143+
f"Internal renderer failed: {internal_exc}. "
144+
f"Notification graph endpoint failed: {ajax_exc}. "
145+
f"Web graph export failed: {web_exc}"
146+
) from web_exc
147+
148+
149+
def fetch_graphs_from_ajax(hostname: str, service: str) -> list[str]:
150+
config = read_web_config()
151+
base_url = config.get("base_url", "")
152+
username = config.get("automation_user", "")
153+
secret = config.get("automation_secret", "")
154+
if not base_url:
155+
raise RuntimeError(f"checkmk_web.base_url is not configured in {CONFIG_PATH}")
156+
157+
try:
158+
graph_count = max(1, min(int(config.get("graph_count", "3")), 10))
159+
except ValueError:
160+
graph_count = 3
161+
162+
errors = []
163+
for candidate_base_url, verify_tls in graph_fetch_attempts(base_url):
164+
for auth_method in graph_auth_methods(
165+
config.get("allow_legacy_url_auth", "").lower()
166+
in {"1", "true", "yes", "on"}
167+
):
168+
if auth_method != "none" and (not username or not secret):
169+
continue
170+
try:
171+
graphs = fetch_ajax_graph_images(
172+
candidate_base_url,
173+
username,
174+
secret,
175+
hostname,
176+
service,
177+
graph_count,
178+
verify_tls=verify_tls,
179+
auth_method=auth_method,
180+
)
181+
if graphs:
182+
return graphs
183+
errors.append("ajax_graph_images.py returned no graphs")
184+
except Exception as exc:
185+
errors.append(str(exc))
186+
if not should_try_next_graph_auth_method(exc):
187+
break
188+
raise RuntimeError("; ".join(errors[-4:]) or "no graph images returned")
143189

144190

145191
def render_graphs_internal(hostname: str, service: str) -> list[str]:
@@ -325,6 +371,52 @@ def graph_image_requests(hostname: str, service: str, graph_index: int) -> list[
325371
]
326372

327373

374+
def fetch_ajax_graph_images(
375+
base_url: str,
376+
username: str,
377+
secret: str,
378+
hostname: str,
379+
service: str,
380+
graph_count: int,
381+
*,
382+
verify_tls: bool,
383+
auth_method: str,
384+
) -> list[str]:
385+
query = parse.urlencode(
386+
{
387+
"site": SITE,
388+
"host": hostname,
389+
"service": service,
390+
"num_graphs": str(graph_count),
391+
}
392+
)
393+
url = f"{base_url}/check_mk/ajax_graph_images.py?{query}"
394+
headers = {"Accept": "application/json"}
395+
add_auth_headers(headers, username, secret, auth_method)
396+
req = request.Request(url, headers=headers)
397+
body, content_type = open_graph_url(
398+
req, base_url, verify_tls=verify_tls, auth_method=auth_method
399+
)
400+
if "json" not in content_type.lower():
401+
raise RuntimeError(
402+
"ajax_graph_images.py did not return JSON using "
403+
f"{auth_method} auth (Content-Type: {content_type}): "
404+
f"{short_html_error(body.decode('utf-8', 'replace'))}"
405+
)
406+
try:
407+
payload = json.loads(body.decode("utf-8"))
408+
except json.JSONDecodeError as exc:
409+
raise RuntimeError(f"ajax_graph_images.py returned invalid JSON: {exc}") from exc
410+
if not isinstance(payload, list):
411+
raise RuntimeError(
412+
f"ajax_graph_images.py returned unexpected payload: {type(payload).__name__}"
413+
)
414+
graphs = [item for item in payload if isinstance(item, str) and item]
415+
if not graphs:
416+
raise RuntimeError("ajax_graph_images.py returned no graph images")
417+
return graphs
418+
419+
328420
def fetch_graph_image(
329421
base_url: str,
330422
username: str,
@@ -341,14 +433,42 @@ def fetch_graph_image(
341433
query = parse.urlencode(query_values)
342434
url = f"{base_url}/check_mk/graph_image.py?{query}"
343435
headers = {"Accept": "image/png"}
436+
add_auth_headers(headers, username, secret, auth_method)
437+
req = request.Request(url, headers=headers)
438+
body, content_type = open_graph_url(
439+
req, base_url, verify_tls=verify_tls, auth_method=auth_method
440+
)
441+
if "image" not in content_type.lower() or not body.startswith(b"\x89PNG"):
442+
preview = short_html_error(body.decode("utf-8", "replace"))
443+
raise RuntimeError(
444+
f"graph_image.py did not return a PNG image using {auth_method} auth "
445+
f"(Content-Type: {content_type}): {preview}"
446+
)
447+
return base64.b64encode(body).decode("ascii")
448+
449+
450+
def add_auth_headers(
451+
headers: dict[str, str],
452+
username: str,
453+
secret: str,
454+
auth_method: str,
455+
) -> None:
344456
if auth_method == "basic":
345457
token = base64.b64encode(f"{username}:{secret}".encode("utf-8")).decode(
346458
"ascii"
347459
)
348460
headers["Authorization"] = f"Basic {token}"
349461
elif auth_method == "bearer":
350462
headers["Authorization"] = f"Bearer {username} {secret}"
351-
req = request.Request(url, headers=headers)
463+
464+
465+
def open_graph_url(
466+
req: request.Request,
467+
base_url: str,
468+
*,
469+
verify_tls: bool,
470+
auth_method: str,
471+
) -> tuple[bytes, str]:
352472
context = None
353473
if parse.urlsplit(base_url).scheme == "https" and not verify_tls:
354474
context = ssl._create_unverified_context()
@@ -359,23 +479,18 @@ def fetch_graph_image(
359479
except error.HTTPError as exc:
360480
body = exc.read(500).decode("utf-8", "replace")
361481
raise RuntimeError(
362-
f"graph_image.py returned HTTP {exc.code} using {auth_method} auth: "
482+
f"{Path(parse.urlsplit(req.full_url).path).name} returned HTTP "
483+
f"{exc.code} using {auth_method} auth: "
363484
f"{short_html_error(body)}"
364485
) from exc
365486
except error.URLError as exc:
366487
verification = " without TLS verification" if not verify_tls else ""
367488
raise RuntimeError(
368-
f"graph_image.py request failed using {auth_method} auth"
489+
f"{Path(parse.urlsplit(req.full_url).path).name} request failed "
490+
f"using {auth_method} auth"
369491
f"{verification}: {exc.reason}"
370492
) from exc
371-
372-
if "image" not in content_type.lower() or not body.startswith(b"\x89PNG"):
373-
preview = short_html_error(body.decode("utf-8", "replace"))
374-
raise RuntimeError(
375-
f"graph_image.py did not return a PNG image using {auth_method} auth "
376-
f"(Content-Type: {content_type}): {preview}"
377-
)
378-
return base64.b64encode(body).decode("ascii")
493+
return body, content_type
379494

380495

381496
def short_html_error(text: str) -> str:

docs/ARCHITECTURE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,23 @@ Graph rendering is version tolerant:
6161
1. The bridge first tries Checkmk's legacy internal notification graph renderer,
6262
if the installed Checkmk version still exposes it.
6363
2. If that renderer is unavailable, the bridge falls back to Checkmk Web's
64-
`graph_image.py` PNG export endpoint.
64+
notification graph endpoint `ajax_graph_images.py`.
6565
3. The web export requires `[checkmk_web]` settings in
6666
`/etc/checkmk-telegram-plus/<site>.ini`: `base_url`, `automation_user` and
6767
`automation_secret`. The bridge uses HTTP auth headers by default so secrets
6868
are not embedded in URLs. Legacy URL authentication can be enabled explicitly
6969
with `allow_legacy_url_auth = yes` for old Checkmk installations if needed.
70-
4. The recommended `base_url` is the local HTTP URL
70+
4. If the notification graph endpoint is unavailable or rejected by the Checkmk
71+
version, the bridge tries the public `graph_image.py` PNG export endpoint
72+
with multiple known request formats.
73+
5. The recommended `base_url` is the local HTTP URL
7174
`http://127.0.0.1/<site>`. If a loopback HTTPS URL fails certificate
7275
verification because the certificate is not valid for `127.0.0.1`,
7376
`localhost` or `::1`, the bridge retries the local request over HTTP. If
7477
the local web server redirects that HTTP request back to HTTPS, the bridge
7578
retries loopback HTTPS without certificate verification as a last resort.
7679
This relaxed TLS fallback is never used for non-loopback hosts.
77-
5. If neither method is available, only the graph request fails with a clear
80+
6. If neither method is available, only the graph request fails with a clear
7881
error message. The bridge service and the bot keep running.
7982

8083
### External app

tests/test_security_static.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def test_graphs_have_web_export_fallback(self):
4646
)
4747
config = (ROOT / "resources" / "config.ini").read_text(encoding="utf-8")
4848
self.assertIn("fetch_graphs_from_web", bridge)
49+
self.assertIn("fetch_graphs_from_ajax", bridge)
50+
self.assertIn("ajax_graph_images.py", bridge)
4951
self.assertIn("graph_image.py", bridge)
5052
self.assertIn("graph_fetch_attempts", bridge)
5153
self.assertIn("should_retry_graph_fetch", bridge)

0 commit comments

Comments
 (0)