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"}]