From 4d3e2734055d2fd1429a7ff1ec974b029a4721e9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:25:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(ops):=20alert-ack=20=D0=B2=D1=8B=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D0=BB=D1=8E=D0=B1=D0=BE=D0=B9=20=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Соединение переиспользуется (protocol_version = HTTP/1.1), и Caddy перед сервисом держит пул к апстриму. Ответ 401/404/503 без чтения тела оставлял его в сокете, и следующий запрос по тому же соединению начинался с чужих байт. Поймано на проде: зонд без секрета получил 401, а следующий запрос — уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST'). То есть один отказ съедал следующий НАСТОЯЩИЙ алерт, ровно в том канале, который заводился как резервный. Тело теперь читается один раз в начале do_POST и передаётся вниз. Четыре теста поднимают настоящий сокет и шлют пару запросов по одному соединению — на прежнем коде три из них падают с той же строкой 501. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG --- ops/metrics/alert-ack/app.py | 19 ++-- ops/metrics/alert-ack/test_app.py | 139 ++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/ops/metrics/alert-ack/app.py b/ops/metrics/alert-ack/app.py index 0ca1f2ea..a809fe6d 100644 --- a/ops/metrics/alert-ack/app.py +++ b/ops/metrics/alert-ack/app.py @@ -339,15 +339,24 @@ class Handler(BaseHTTPRequestHandler): self._reply(code, page.encode()) def do_POST(self) -> None: # noqa: N802 — имя из stdlib + # Тело читается ДО любой развилки и ветки отказа. protocol_version = + # HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами + # держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит + # оставить его в сокете — и следующий запрос по тому же соединению + # начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный + # зонд на /glitchtip, а следом законный алерт получил + # 501 Unsupported method ('{"text":"probe"}POST'). То есть один + # отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот + # резервный канал и заводился. parsed = urllib.parse.urlsplit(self.path) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" if parsed.path == "/glitchtip": - self._handle_glitchtip(parsed) + self._handle_glitchtip(parsed, raw) return if parsed.path != "/alertmanager": self._reply(404, b"not found", "text/plain; charset=utf-8") return - length = int(self.headers.get("Content-Length") or 0) - raw = self.rfile.read(length) if length else b"{}" try: payload = json.loads(raw.decode() or "{}") except Exception: # noqa: BLE001 @@ -361,7 +370,7 @@ class Handler(BaseHTTPRequestHandler): self._reply(200, b"accepted", "text/plain; charset=utf-8") threading.Thread(target=_send_alert, args=(payload,), daemon=True).start() - def _handle_glitchtip(self, parsed: urllib.parse.SplitResult) -> None: + def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None: """POST /glitchtip — резервный получатель GlitchTip-алертов (#3471). Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт @@ -381,8 +390,6 @@ class Handler(BaseHTTPRequestHandler): self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8") return - length = int(self.headers.get("Content-Length") or 0) - raw = self.rfile.read(length) if length else b"{}" try: payload = json.loads(raw.decode() or "{}") if not isinstance(payload, dict): diff --git a/ops/metrics/alert-ack/test_app.py b/ops/metrics/alert-ack/test_app.py index 019ff13c..52213917 100644 --- a/ops/metrics/alert-ack/test_app.py +++ b/ops/metrics/alert-ack/test_app.py @@ -115,3 +115,142 @@ def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None: assert sent["message_thread_id"] == alert_ack.TOPIC_ID assert "reply_markup" not in sent assert "boom" in sent["text"] + + +# ── Keep-alive: отказ не должен ронять СЛЕДУЮЩИЙ запрос ────────────────────── +# Эти четыре теста — единственные, что поднимают настоящий сокет. Дефект, +# который они стерегут, живёт именно в транспорте и на уровне функций невидим: +# ветка отказа отвечала, не вычитав тело запроса, а `protocol_version` здесь +# HTTP/1.1, то есть соединение переиспользуется (и Caddy перед сервисом держит +# пул к апстриму). Непрочитанное тело оставалось в сокете, и следующий запрос +# по тому же соединению начинался с чужих байт. +# +# Поймано на проде 12.09.2026: зонд без секрета получил 401, а следующий — +# уже с верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST'). +# То есть один отказ съедал следующий НАСТОЯЩИЙ алерт. + + +def _serve_in_background(monkeypatch): + """Поднимает Handler на эфемерном порту, глушит отправку в Telegram.""" + import threading as _threading + from http.server import ThreadingHTTPServer + + sent: list[dict] = [] + monkeypatch.setattr(alert_ack, "_send_glitchtip_alert", sent.append) + monkeypatch.setattr(alert_ack, "_send_alert", sent.append) + + srv = ThreadingHTTPServer(("127.0.0.1", 0), alert_ack.Handler) + thread = _threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + return srv, sent + + +def _raw_post(sock, path: str, body: bytes, headers: str = "") -> str: + """Шлёт POST по уже открытому сокету и возвращает статусную строку.""" + req = ( + f"POST {path} HTTP/1.1\r\n" + f"Host: localhost\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n" + f"{headers}" + f"\r\n" + ).encode() + body + sock.sendall(req) + # Читаем ровно заголовки: тела короткие, Content-Length всегда проставлен. + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + head, _, rest = buf.partition(b"\r\n\r\n") + length = 0 + for line in head.split(b"\r\n")[1:]: + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":")[1]) + while len(rest) < length: + rest += sock.recv(4096) + return head.split(b"\r\n")[0].decode() + + +def _pair_on_one_connection(monkeypatch, first_headers: str, first_path: str = "/glitchtip"): + import socket + + srv, sent = _serve_in_background(monkeypatch) + try: + sock = socket.create_connection(srv.server_address, timeout=5) + try: + first = _raw_post(sock, first_path, b'{"text":"probe"}', first_headers) + second = _raw_post( + sock, + "/glitchtip", + b'{"text":"real alert"}', + "X-GlitchTip-Secret: correct-secret\r\n", + ) + finally: + sock.close() + finally: + srv.shutdown() + srv.server_close() + return first, second, sent + + +def test_rejected_request_does_not_break_next_one_on_same_connection(monkeypatch) -> None: + """401 без секрета, следом законный алерт по ТОМУ ЖЕ соединению — 200.""" + first, second, sent = _pair_on_one_connection(monkeypatch, "") + assert "401" in first + assert "200" in second, f"второй запрос испорчен первым: {second}" + assert sent == [{"text": "real alert"}] + + +def test_unconfigured_secret_does_not_break_next_request(monkeypatch) -> None: + """503 при пустом секрете тоже обязан вычитать тело.""" + import socket + + monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "") + srv, _sent = _serve_in_background(monkeypatch) + try: + sock = socket.create_connection(srv.server_address, timeout=5) + try: + first = _raw_post(sock, "/glitchtip", b'{"text":"probe"}') + second = _raw_post(sock, "/glitchtip", b'{"text":"again"}') + finally: + sock.close() + finally: + srv.shutdown() + srv.server_close() + assert "503" in first + assert "503" in second, f"второй запрос испорчен первым: {second}" + + +def test_unknown_path_does_not_break_next_request(monkeypatch) -> None: + """404 на чужом пути — та же ветка раннего ответа, то же требование.""" + first, second, sent = _pair_on_one_connection(monkeypatch, "", first_path="/nope") + assert "404" in first + assert "200" in second, f"второй запрос испорчен первым: {second}" + assert sent == [{"text": "real alert"}] + + +def test_bad_json_on_alertmanager_does_not_break_next_request(monkeypatch) -> None: + """400 на неразобранном теле /alertmanager — тело уже вычитано, связь цела.""" + import socket + + srv, sent = _serve_in_background(monkeypatch) + try: + sock = socket.create_connection(srv.server_address, timeout=5) + try: + first = _raw_post(sock, "/alertmanager", b"{not json") + second = _raw_post( + sock, + "/glitchtip", + b'{"text":"real alert"}', + "X-GlitchTip-Secret: correct-secret\r\n", + ) + finally: + sock.close() + finally: + srv.shutdown() + srv.server_close() + assert "400" in first + assert "200" in second, f"второй запрос испорчен первым: {second}" + assert sent == [{"text": "real alert"}] -- 2.45.3