Compare commits
No commits in common. "d74121d74c84b79bfbbd0f15449d0170b39a6654" and "694bf13d3d3cb6053cded588018909b8b1840012" have entirely different histories.
d74121d74c
...
694bf13d3d
2 changed files with 6 additions and 152 deletions
|
|
@ -339,24 +339,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(code, page.encode())
|
self._reply(code, page.encode())
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
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)
|
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":
|
if parsed.path == "/glitchtip":
|
||||||
self._handle_glitchtip(parsed, raw)
|
self._handle_glitchtip(parsed)
|
||||||
return
|
return
|
||||||
if parsed.path != "/alertmanager":
|
if parsed.path != "/alertmanager":
|
||||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
|
@ -370,7 +361,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||||
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
||||||
|
|
||||||
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult, raw: bytes) -> None:
|
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult) -> None:
|
||||||
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
||||||
|
|
||||||
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
||||||
|
|
@ -390,6 +381,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw.decode() or "{}")
|
payload = json.loads(raw.decode() or "{}")
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
|
|
||||||
|
|
@ -115,142 +115,3 @@ def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
||||||
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
||||||
assert "reply_markup" not in sent
|
assert "reply_markup" not in sent
|
||||||
assert "boom" in sent["text"]
|
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"}]
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue