sitcod3 commited on
Commit
fabbde1
·
verified ·
1 Parent(s): c94194a

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. app/main.py +12 -0
  2. app/validator/egress.py +151 -0
  3. tests/test_egress.py +119 -0
app/main.py CHANGED
@@ -5,6 +5,7 @@ from slowapi import Limiter, _rate_limit_exceeded_handler
5
  from slowapi.util import get_remote_address
6
  from slowapi.errors import RateLimitExceeded
7
  from contextlib import asynccontextmanager
 
8
  import time
9
 
10
  from .config import settings
@@ -39,6 +40,17 @@ async def lifespan(app: FastAPI):
39
  print(f"[DB] WARNING: DB check failed — {exc}")
40
  else:
41
  print("[DB] Supabase not configured — persistence disabled (see .env.example)")
 
 
 
 
 
 
 
 
 
 
 
42
  yield
43
  print("BRIDGE Modul - X shutting down...")
44
 
 
5
  from slowapi.util import get_remote_address
6
  from slowapi.errors import RateLimitExceeded
7
  from contextlib import asynccontextmanager
8
+ import asyncio
9
  import time
10
 
11
  from .config import settings
 
40
  print(f"[DB] WARNING: DB check failed — {exc}")
41
  else:
42
  print("[DB] Supabase not configured — persistence disabled (see .env.example)")
43
+
44
+ # Port-25 egress preflight: mailbox verification cannot work without it, so
45
+ # report it at boot instead of letting operators discover it from a wall of
46
+ # "smtp_unreachable" results. Best-effort and never fatal.
47
+ try:
48
+ from .validator.egress import preflight_or_warn
49
+
50
+ await asyncio.to_thread(preflight_or_warn)
51
+ except Exception as exc: # pragma: no cover - defensive
52
+ print(f"[EGRESS] preflight skipped — {exc}")
53
+
54
  yield
55
  print("BRIDGE Modul - X shutting down...")
56
 
app/validator/egress.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Outbound port-25 preflight.
2
+
3
+ Mailbox verification *requires* outbound TCP to the recipient's MX on port 25.
4
+ Most PaaS platforms block it — Hugging Face Spaces and AWS (EC2/Lambda) both
5
+ block 25 by default — so an engine that looks correct can still be unable to
6
+ produce a single real verdict.
7
+
8
+ This probe answers the only question that matters before trusting a host:
9
+ "can this machine reach an MX on port 25?" It is deliberately tiny, side-effect
10
+ free (banner + EHLO, then QUIT), and safe to run at boot or from CI.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import socket
15
+ from dataclasses import asdict, dataclass
16
+ from typing import Callable, Optional
17
+
18
+ # A highly-available MX; if this is unreachable, port 25 egress is blocked.
19
+ DEFAULT_TARGET = ("gmail-smtp-in.l.google.com", 25)
20
+ DEFAULT_HELO = "validator.local"
21
+
22
+ _BLOCKED_ADVICE = (
23
+ "Outbound port 25 is blocked. Hugging Face Spaces, AWS (EC2/Lambda) and most "
24
+ "PaaS providers block it by default, so direct SMTP verification cannot run "
25
+ "there. Fix: run this service on a VPS/host with port-25 egress (and set a "
26
+ "PTR record + SMTP_HELO_DOMAIN so strict receivers accept the probes). "
27
+ "Until then every mailbox row is honestly reported as smtp_unreachable."
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class EgressReport:
33
+ target: str
34
+ reachable: bool
35
+ banner: str = ""
36
+ helo_accepted: bool = False
37
+ diagnosis: str = ""
38
+ advice: str = ""
39
+
40
+ @property
41
+ def ok(self) -> bool:
42
+ return self.reachable
43
+
44
+ def as_dict(self) -> dict:
45
+ data = asdict(self)
46
+ data["ok"] = self.ok
47
+ return data
48
+
49
+
50
+ def _default_connect(host: str, port: int, timeout: float):
51
+ sock = socket.create_connection((host, port), timeout=timeout)
52
+ sock.settimeout(timeout)
53
+ return sock
54
+
55
+
56
+ def check_port25_egress(
57
+ connect: Optional[Callable[[str, int, float], object]] = None,
58
+ target: tuple[str, int] = DEFAULT_TARGET,
59
+ helo_domain: str = DEFAULT_HELO,
60
+ timeout: float = 8.0,
61
+ ) -> EgressReport:
62
+ """Probe whether outbound port 25 works from this host."""
63
+ host, port = target
64
+ label = f"{host}:{port}"
65
+ sock = None
66
+ try:
67
+ sock = (connect or _default_connect)(host, port, timeout)
68
+ except Exception as exc:
69
+ # A timeout / refusal / unreachable network on port 25 is the signature
70
+ # of egress filtering. Say so explicitly — "timed out" alone sends the
71
+ # operator hunting for a DNS or app bug instead of a firewall.
72
+ kind = type(exc).__name__
73
+ detail = str(exc)
74
+ if isinstance(exc, socket.timeout) or "timed out" in detail.lower():
75
+ diagnosis = f"connect timed out (port 25 is blocked/filtered): {detail}"
76
+ elif isinstance(exc, ConnectionRefusedError) or "refused" in detail.lower():
77
+ diagnosis = f"connection refused on port 25 (blocked): {detail}"
78
+ elif isinstance(exc, OSError) and getattr(exc, "errno", None) == 101:
79
+ diagnosis = f"network unreachable (port 25 egress blocked): {detail}"
80
+ else:
81
+ diagnosis = f"connect failed, port 25 likely blocked: {kind}: {detail}"
82
+ return EgressReport(
83
+ target=label,
84
+ reachable=False,
85
+ diagnosis=diagnosis,
86
+ advice=_BLOCKED_ADVICE,
87
+ )
88
+
89
+ banner = ""
90
+ helo_accepted = False
91
+ try:
92
+ try:
93
+ raw = sock.recv(1024) # type: ignore[attr-defined]
94
+ banner = raw.decode("utf-8", "replace").strip() if raw else ""
95
+ except Exception as exc:
96
+ return EgressReport(
97
+ target=label,
98
+ reachable=False,
99
+ diagnosis=f"no SMTP banner: {type(exc).__name__}: {exc}",
100
+ advice=_BLOCKED_ADVICE,
101
+ )
102
+
103
+ try:
104
+ sock.sendall(f"EHLO {helo_domain}\r\n".encode()) # type: ignore[attr-defined]
105
+ reply = sock.recv(1024) # type: ignore[attr-defined]
106
+ helo_accepted = b"250" in reply
107
+ except Exception:
108
+ helo_accepted = False
109
+
110
+ try:
111
+ sock.sendall(b"QUIT\r\n") # type: ignore[attr-defined]
112
+ except Exception:
113
+ pass
114
+ finally:
115
+ if sock is not None:
116
+ try:
117
+ sock.close()
118
+ except Exception:
119
+ pass
120
+
121
+ if not banner.startswith("220"):
122
+ return EgressReport(
123
+ target=label,
124
+ reachable=False,
125
+ banner=banner,
126
+ helo_accepted=helo_accepted,
127
+ diagnosis=f"unexpected banner: {banner[:80]!r}",
128
+ advice=_BLOCKED_ADVICE,
129
+ )
130
+
131
+ return EgressReport(
132
+ target=label,
133
+ reachable=True,
134
+ banner=banner,
135
+ helo_accepted=helo_accepted,
136
+ diagnosis="port 25 reachable — real mailbox verification is possible here",
137
+ advice=(
138
+ "Set SMTP_HELO_DOMAIN to this host's real hostname and give that IP a "
139
+ "matching PTR record; strict receivers reject generic HELO names."
140
+ ),
141
+ )
142
+
143
+
144
+ def preflight_or_warn() -> EgressReport:
145
+ """Run the preflight and print a one-line operational summary."""
146
+ report = check_port25_egress()
147
+ status = "OK" if report.ok else "BLOCKED"
148
+ print(f"[EGRESS] port-25 preflight {status} — {report.target} — {report.diagnosis}")
149
+ if not report.ok:
150
+ print(f"[EGRESS] {report.advice}")
151
+ return report
tests/test_egress.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from app.validator.egress import EgressReport, check_port25_egress
5
+
6
+
7
+ class FakeSock:
8
+ def __init__(self, banner=b"220 mx.google.com ESMTP ready\r\n", fail=None):
9
+ self._banner = banner
10
+ self._fail = fail
11
+ self.sent: list[str] = []
12
+ self.closed = False
13
+ self._read = False
14
+
15
+ def settimeout(self, t):
16
+ pass
17
+
18
+ def sendall(self, data):
19
+ self.sent.append(data.decode("utf-8", "replace"))
20
+
21
+ def recv(self, _n):
22
+ if self._fail:
23
+ raise self._fail
24
+ if self._read:
25
+ return b"250 OK\r\n"
26
+ self._read = True
27
+ return self._banner
28
+
29
+ def close(self):
30
+ self.closed = True
31
+
32
+
33
+ def _connector(sock):
34
+ def _connect(host, port, timeout):
35
+ return sock
36
+ return _connect
37
+
38
+
39
+ class EgressPreflightTests(unittest.TestCase):
40
+ """Tells you, before you trust a host, whether outbound port 25 works —
41
+ the single prerequisite for real mailbox verification."""
42
+
43
+ def test_reachable_host_reports_success(self):
44
+ sock = FakeSock()
45
+ report = check_port25_egress(connect=_connector(sock))
46
+ self.assertTrue(report.reachable)
47
+ self.assertTrue(report.ok)
48
+ self.assertIn("220", report.banner)
49
+
50
+ def test_timeout_is_reported_as_blocked_with_actionable_advice(self):
51
+ import socket as _s
52
+
53
+ def _connect(host, port, timeout):
54
+ raise _s.timeout("timed out")
55
+
56
+ report = check_port25_egress(connect=_connect)
57
+ self.assertFalse(report.reachable)
58
+ self.assertFalse(report.ok)
59
+ # It must explain the fix, not just fail.
60
+ self.assertIn("block", report.diagnosis.lower())
61
+ self.assertIn("port 25", report.advice.lower())
62
+
63
+ def test_refused_reports_blocked(self):
64
+ def _connect(host, port, timeout):
65
+ raise ConnectionRefusedError("refused")
66
+
67
+ report = check_port25_egress(connect=_connect)
68
+ self.assertFalse(report.ok)
69
+ self.assertIn("25", report.advice)
70
+
71
+ def test_unreachable_network_reports_blocked(self):
72
+ def _connect(host, port, timeout):
73
+ raise OSError(101, "Network is unreachable")
74
+
75
+ report = check_port25_egress(connect=_connect)
76
+ self.assertFalse(report.ok)
77
+ self.assertTrue(report.diagnosis)
78
+
79
+ def test_helo_accepted_is_recorded(self):
80
+ sock = FakeSock()
81
+ report = check_port25_egress(connect=_connector(sock), helo_domain="validator.test")
82
+ self.assertTrue(report.helo_accepted)
83
+ self.assertTrue(any("EHLO" in s for s in sock.sent))
84
+
85
+ def test_socket_closed_after_probe(self):
86
+ sock = FakeSock()
87
+ check_port25_egress(connect=_connector(sock))
88
+ self.assertTrue(sock.closed, "preflight must not leak the connection")
89
+
90
+ def test_report_serialises_for_ops_output(self):
91
+ report = check_port25_egress(connect=_connector(FakeSock()))
92
+ data = report.as_dict()
93
+ self.assertIn("reachable", data)
94
+ self.assertIn("advice", data)
95
+
96
+ def test_default_target_is_a_reliable_mx(self):
97
+ report = check_port25_egress(connect=_connector(FakeSock()))
98
+ self.assertIn("google", report.target.lower())
99
+
100
+
101
+ class EgressAdviceTests(unittest.TestCase):
102
+ def test_advice_names_the_hosts_that_block_port_25(self):
103
+ def _connect(host, port, timeout):
104
+ raise TimeoutError("timed out")
105
+
106
+ report = check_port25_egress(connect=_connect)
107
+ # The operator should immediately know which platforms this rules out.
108
+ self.assertTrue(
109
+ any(
110
+ name in report.advice.lower()
111
+ for name in ("hugging face", "aws", "paas")
112
+ ),
113
+ f"advice should name blocking platforms, got: {report.advice}",
114
+ )
115
+ self.assertIn("vps", report.advice.lower())
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()