dns-shield 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dns_shield/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """dns-shield -- detect ISP DNS poisoning and transparently work around it.
2
+
3
+ Whose problem this solves
4
+ -------------------------
5
+ An ISP that answers DNS with a false address makes a reachable service look
6
+ blocked. The site appears "geo-blocked" or "banned", the user gives up, and the
7
+ conclusion is wrong: only name resolution was broken. This library measures the
8
+ difference and, when it is safe to say so, works around it.
9
+
10
+ The documented motivating case: Biznet (Indonesia) answered every
11
+ ``*.binance.com`` name with a single bogus address whose connections died, while
12
+ the real hosts sat on CloudFront and answered HTTP 200.
13
+
14
+ Zero side effects on import
15
+ ---------------------------
16
+ Importing this package performs **no** network I/O, reads no environment
17
+ variables, starts no threads, and registers no atexit hooks. Network calls only
18
+ happen when you explicitly call something. Tests rely on this and assert it.
19
+
20
+ Quick start
21
+ -----------
22
+ ::
23
+
24
+ from dns_shield import diagnose, resolve_and_call
25
+
26
+ diagnosis = diagnose("fapi.binance.com")
27
+ print(diagnosis.verdict, diagnosis.summary)
28
+
29
+ response = resolve_and_call("https://fapi.binance.com/fapi/v1/ping")
30
+ print(response.status, response.text[:80])
31
+
32
+ Licence: MIT.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from .detect import (
38
+ Diagnosis,
39
+ Evidence,
40
+ FailureKind,
41
+ ProbeResult,
42
+ Verdict,
43
+ classify,
44
+ diagnose,
45
+ probe_connect,
46
+ probe_http,
47
+ system_resolve,
48
+ )
49
+ from .patch import ShieldSession, resolve_and_call, shielded_client
50
+ from .resolve import (
51
+ PROVIDERS,
52
+ DohProvider,
53
+ DohResolutionError,
54
+ DohResolver,
55
+ RecordSet,
56
+ ResolvedHost,
57
+ )
58
+ from .transport import (
59
+ BINANCE_SUCCESS_CONTRACT,
60
+ ShieldResponse,
61
+ SniHTTPClient,
62
+ SuccessContract,
63
+ TransportError,
64
+ )
65
+
66
+ __version__ = "0.1.1"
67
+
68
+ __all__ = [
69
+ "BINANCE_SUCCESS_CONTRACT",
70
+ "Diagnosis",
71
+ "DohProvider",
72
+ "DohResolutionError",
73
+ "DohResolver",
74
+ "Evidence",
75
+ "FailureKind",
76
+ "PROVIDERS",
77
+ "ProbeResult",
78
+ "RecordSet",
79
+ "ResolvedHost",
80
+ "ShieldResponse",
81
+ "ShieldSession",
82
+ "SniHTTPClient",
83
+ "SuccessContract",
84
+ "TransportError",
85
+ "Verdict",
86
+ "__version__",
87
+ "classify",
88
+ "diagnose",
89
+ "probe_connect",
90
+ "probe_http",
91
+ "resolve_and_call",
92
+ "shielded_client",
93
+ "system_resolve",
94
+ ]
dns_shield/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """Command-line interface for dns-shield.
2
+
3
+ Exit codes are part of the contract and are stable for use in scripts and CI:
4
+
5
+ ===== ==========================================================
6
+ Code Meaning
7
+ ===== ==========================================================
8
+ 0 healthy -- resolvers agree and the host responds
9
+ 1 poisoned or suspicious -- the resolver is lying to you
10
+ 2 unreachable or unknown -- the host is genuinely down, or
11
+ there was not enough evidence to conclude anything
12
+ 3 bad usage -- invalid arguments or an unsupported operation
13
+ ===== ==========================================================
14
+
15
+ Every subcommand supports ``--json``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from typing import Sequence
24
+
25
+ from . import __version__
26
+ from .detect import Diagnosis, Verdict, diagnose
27
+ from .resolve import PROVIDERS, DohResolutionError, DohResolver
28
+ from .transport import SniHTTPClient, TransportError
29
+
30
+ __all__ = ["EXIT_OK", "EXIT_POISONED", "EXIT_INCONCLUSIVE", "EXIT_USAGE", "main"]
31
+
32
+ EXIT_OK = 0
33
+ EXIT_POISONED = 1
34
+ EXIT_INCONCLUSIVE = 2
35
+ EXIT_USAGE = 3
36
+
37
+ #: Hostnames probed alongside the target to expose a shared bogus address.
38
+ _DEFAULT_SIBLINGS: tuple[str, ...] = ()
39
+
40
+
41
+ def _build_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="dns-shield",
44
+ description=(
45
+ "Detect ISP DNS poisoning and work around it. "
46
+ "Diagnoses whether a resolver is returning false answers, and can "
47
+ "fetch a URL through a DNS-over-HTTPS verified path."
48
+ ),
49
+ )
50
+ parser.add_argument("--version", action="version", version=f"dns-shield {__version__}")
51
+ sub = parser.add_subparsers(dest="command", required=True)
52
+
53
+ # -- check -----------------------------------------------------------
54
+ check = sub.add_parser(
55
+ "check",
56
+ help="diagnose whether a hostname is being DNS-poisoned",
57
+ description=(
58
+ "Compare the system resolver against DoH, probe both answers, and "
59
+ "classify the failure. Says 'unreachable' when the host is "
60
+ "genuinely down rather than blaming the resolver."
61
+ ),
62
+ )
63
+ check.add_argument("host", help="hostname to diagnose, e.g. fapi.binance.com")
64
+ check.add_argument("--port", type=int, default=443, help="port to probe (default: 443)")
65
+ check.add_argument("--path", default="/", help="HTTP path to probe (default: /)")
66
+ check.add_argument(
67
+ "--timeout",
68
+ type=float,
69
+ default=3.0,
70
+ help="per-probe timeout in seconds (default: 3)",
71
+ )
72
+ check.add_argument(
73
+ "--provider",
74
+ action="append",
75
+ choices=sorted(PROVIDERS),
76
+ help="DoH provider to trust; repeatable, tried in order",
77
+ )
78
+ check.add_argument(
79
+ "--sibling",
80
+ action="append",
81
+ default=None,
82
+ help=(
83
+ "another hostname expected to share the bogus address; repeatable. "
84
+ "Used to demonstrate the shared-IP pattern."
85
+ ),
86
+ )
87
+ check.add_argument(
88
+ "--connect-only",
89
+ action="store_true",
90
+ help="skip the TLS/HTTP probe and only test raw TCP reachability",
91
+ )
92
+ check.add_argument("--json", action="store_true", help="emit machine-readable JSON")
93
+
94
+ # -- fetch -----------------------------------------------------------
95
+ fetch = sub.add_parser(
96
+ "fetch",
97
+ help="perform a GET through the shield (DoH + SNI-preserving dial)",
98
+ description=(
99
+ "Resolve the URL's host over DoH, then connect to that address while "
100
+ "preserving SNI and Host. This bypasses a poisoned system resolver "
101
+ "for this one request."
102
+ ),
103
+ )
104
+ fetch.add_argument("url", help="absolute URL to fetch")
105
+ fetch.add_argument(
106
+ "--snippet", type=int, default=400, help="bytes of body to print (default: 400)"
107
+ )
108
+ fetch.add_argument(
109
+ "--timeout", type=float, default=8.0, help="retry sleep base, in seconds"
110
+ )
111
+ fetch.add_argument(
112
+ "--provider",
113
+ action="append",
114
+ choices=sorted(PROVIDERS),
115
+ help="DoH provider to use; repeatable, tried in order",
116
+ )
117
+ fetch.add_argument("--json", action="store_true", help="emit machine-readable JSON")
118
+
119
+ # -- hosts -----------------------------------------------------------
120
+ hosts = sub.add_parser(
121
+ "hosts",
122
+ help="print the real addresses for a hostname (and an optional hosts fragment)",
123
+ description=(
124
+ "Show the addresses DoH returns. With --hosts-file, emit a fragment "
125
+ "suitable for /etc/hosts. That fragment is a FRAGILE workaround: the "
126
+ "addresses rotate and stale entries cause outages."
127
+ ),
128
+ )
129
+ hosts.add_argument("host", help="hostname to resolve over DoH")
130
+ hosts.add_argument(
131
+ "--provider",
132
+ action="append",
133
+ choices=sorted(PROVIDERS),
134
+ help="DoH provider to use; repeatable, tried in order",
135
+ )
136
+ hosts.add_argument(
137
+ "--hosts-file",
138
+ action="store_true",
139
+ help="emit an /etc/hosts fragment instead of plain addresses",
140
+ )
141
+ hosts.add_argument("--json", action="store_true", help="emit machine-readable JSON")
142
+
143
+ return parser
144
+
145
+
146
+ def _resolver_from(providers: Sequence[str] | None) -> DohResolver:
147
+ if providers:
148
+ return DohResolver(list(providers))
149
+ return DohResolver()
150
+
151
+
152
+ # -- check -----------------------------------------------------------------
153
+
154
+
155
+ def _print_check(diagnosis: Diagnosis) -> None:
156
+ ev = diagnosis.evidence
157
+ print(f"dns-shield check: {diagnosis.host}")
158
+ print("=" * 68)
159
+ print(f"system DNS : {', '.join(ev.system_ips) or '(no answer)'}")
160
+ if ev.system_error:
161
+ print(f" error: {ev.system_error}")
162
+ print(
163
+ f"DoH ({ev.doh_provider or 'n/a'}) : {', '.join(ev.doh_ips) or '(no answer)'}"
164
+ )
165
+ if ev.cnames:
166
+ print(f" CNAME -> {' -> '.join(ev.cnames)}")
167
+ if ev.doh_error:
168
+ print(f" error: {ev.doh_error}")
169
+ print()
170
+ if ev.system_probe:
171
+ print(f"probe sys : {ev.system_probe.describe()}")
172
+ if ev.doh_probe:
173
+ print(f"probe doh : {ev.doh_probe.describe()}")
174
+ if ev.shared_ip_hosts:
175
+ print(f"shared IP : also served for {', '.join(ev.shared_ip_hosts)}")
176
+ print()
177
+ print("evidence:")
178
+ for reason in diagnosis.reasons:
179
+ print(f" - {reason}")
180
+ if ev.notes:
181
+ for note in ev.notes:
182
+ print(f" ! {note}")
183
+ print()
184
+ print(f"VERDICT: {diagnosis.verdict.value.upper()}")
185
+ print(f" {diagnosis.summary}")
186
+ if diagnosis.verdict is Verdict.POISONED:
187
+ print()
188
+ print(" Work around it for one request:")
189
+ print(f" dns-shield fetch https://{diagnosis.host}/")
190
+
191
+
192
+ def _cmd_check(args: argparse.Namespace) -> int:
193
+ resolver = _resolver_from(args.provider)
194
+ siblings = args.sibling if args.sibling is not None else list(_DEFAULT_SIBLINGS)
195
+ diagnosis = diagnose(
196
+ args.host,
197
+ resolver=resolver,
198
+ port=args.port,
199
+ path=args.path,
200
+ timeout_s=args.timeout,
201
+ compare_hosts=siblings,
202
+ do_http_probe=not args.connect_only,
203
+ )
204
+ if args.json:
205
+ print(json.dumps(diagnosis.to_dict(), indent=2, sort_keys=True))
206
+ else:
207
+ _print_check(diagnosis)
208
+ return diagnosis.exit_code
209
+
210
+
211
+ # -- fetch -----------------------------------------------------------------
212
+
213
+
214
+ def _cmd_fetch(args: argparse.Namespace) -> int:
215
+ resolver = _resolver_from(args.provider)
216
+ client = SniHTTPClient(resolver=resolver)
217
+ try:
218
+ response = client.send("GET", args.url, raise_for_status=False)
219
+ except TransportError as exc:
220
+ if args.json:
221
+ print(
222
+ json.dumps(
223
+ {
224
+ "url": args.url,
225
+ "ok": False,
226
+ "error": str(exc),
227
+ "kind": exc.kind,
228
+ "address": exc.address,
229
+ },
230
+ indent=2,
231
+ sort_keys=True,
232
+ )
233
+ )
234
+ else:
235
+ print(f"FETCH FAILED: {exc}", file=sys.stderr)
236
+ if exc.kind:
237
+ print(f" failure kind: {exc.kind}", file=sys.stderr)
238
+ if exc.address:
239
+ print(f" dialled address: {exc.address}", file=sys.stderr)
240
+ return EXIT_INCONCLUSIVE
241
+
242
+ snippet = response.text[: args.snippet]
243
+ if args.json:
244
+ print(
245
+ json.dumps(
246
+ {
247
+ "url": args.url,
248
+ "ok": response.ok,
249
+ "status": response.status,
250
+ "address": response.address,
251
+ "snippet": snippet,
252
+ "body_bytes": len(response.text),
253
+ },
254
+ indent=2,
255
+ sort_keys=True,
256
+ )
257
+ )
258
+ else:
259
+ print(f"HTTP {response.status} via {response.address}")
260
+ print(f"url: {args.url}")
261
+ print("-" * 68)
262
+ print(snippet)
263
+ if len(response.text) > len(snippet):
264
+ print(f"... [{len(response.text) - len(snippet)} more bytes]")
265
+ return EXIT_OK if response.ok else EXIT_INCONCLUSIVE
266
+
267
+
268
+ # -- hosts -----------------------------------------------------------------
269
+
270
+
271
+ def _cmd_hosts(args: argparse.Namespace) -> int:
272
+ resolver = _resolver_from(args.provider)
273
+ try:
274
+ records = resolver.query_records(args.host, "A")
275
+ except DohResolutionError as exc:
276
+ if args.json:
277
+ print(json.dumps({"host": args.host, "ok": False, "error": str(exc)}, indent=2))
278
+ else:
279
+ print(f"resolution failed: {exc}", file=sys.stderr)
280
+ return EXIT_INCONCLUSIVE
281
+
282
+ if args.json:
283
+ print(
284
+ json.dumps(
285
+ {
286
+ "host": args.host,
287
+ "ok": not records.is_empty,
288
+ "provider": records.provider,
289
+ "addresses": list(records.addresses),
290
+ "cnames": list(records.cnames),
291
+ "ttl_s": records.ttl_s,
292
+ "fragment": (
293
+ _hosts_fragment(args.host, records.addresses)
294
+ if args.hosts_file
295
+ else None
296
+ ),
297
+ "fragile": args.hosts_file,
298
+ },
299
+ indent=2,
300
+ sort_keys=True,
301
+ )
302
+ )
303
+ return EXIT_OK if records.addresses else EXIT_INCONCLUSIVE
304
+
305
+ if not records.addresses:
306
+ print(f"no A records for {args.host}", file=sys.stderr)
307
+ return EXIT_INCONCLUSIVE
308
+
309
+ if args.hosts_file:
310
+ print("# dns-shield /etc/hosts fragment -- FRAGILE, read the warning below.")
311
+ print("# These addresses rotate. Stale entries cause hard-to-debug outages.")
312
+ print("# Prefer `dns-shield fetch` for a targeted, always-fresh override.")
313
+ print()
314
+ print(_hosts_fragment(args.host, records.addresses))
315
+ print()
316
+ print("# To apply (requires sudo):")
317
+ print(f"# dns-shield hosts {args.host} --hosts-file | sudo tee -a /etc/hosts")
318
+ print("# To undo:")
319
+ print(f"# sudo sed -i '' '/dns-shield/d' /etc/hosts # macOS")
320
+ print(f"# sudo sed -i '/dns-shield/d' /etc/hosts # Linux")
321
+ else:
322
+ print(f"{args.host} (via {records.provider})")
323
+ if records.cnames:
324
+ print(f" CNAME: {' -> '.join(records.cnames)}")
325
+ for address in records.addresses:
326
+ print(f" {address}")
327
+ if records.ttl_s:
328
+ print(f" TTL: {records.ttl_s:.0f}s")
329
+ return EXIT_OK
330
+
331
+
332
+ def _hosts_fragment(host: str, addresses: Sequence[str]) -> str:
333
+ lines = [f"# dns-shield {host} (regenerated {_now_iso()})"]
334
+ for address in addresses:
335
+ lines.append(f"{address}\t{host}")
336
+ return "\n".join(lines)
337
+
338
+
339
+ def _now_iso() -> str:
340
+ import datetime
341
+
342
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
343
+
344
+
345
+ # -- entry point -----------------------------------------------------------
346
+
347
+
348
+ def main(argv: Sequence[str] | None = None) -> int:
349
+ """Run the CLI. Returns the process exit code."""
350
+ parser = _build_parser()
351
+ args = parser.parse_args(argv)
352
+
353
+ if args.command == "check":
354
+ return _cmd_check(args)
355
+ if args.command == "fetch":
356
+ return _cmd_fetch(args)
357
+ if args.command == "hosts":
358
+ return _cmd_hosts(args)
359
+
360
+ parser.error(f"unknown command {args.command!r}") # pragma: no cover
361
+ return EXIT_USAGE # pragma: no cover
362
+
363
+
364
+ if __name__ == "__main__":
365
+ raise SystemExit(main())
dns_shield/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Tunable defaults for :mod:`dns_shield`.
2
+
3
+ Every value here is a *default*. Nothing is read from the environment at import
4
+ time, and importing this module has no side effects -- see the package
5
+ ``__init__`` docstring for the no-side-effects guarantee.
6
+
7
+ Values are plain module constants rather than a settings class so they can be
8
+ overridden by keyword argument on the object that consumes them, without any
9
+ global mutation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Final
15
+
16
+ __all__ = [
17
+ "BACKOFF_BASE_S",
18
+ "BACKOFF_MAX_S",
19
+ "CONNECT_TIMEOUT_S",
20
+ "DOH_ENDPOINT",
21
+ "DOH_TIMEOUT_S",
22
+ "DNS_CACHE_TTL_S",
23
+ "MAX_RETRIES",
24
+ "MITM_PROBE_HOST",
25
+ "READ_TIMEOUT_S",
26
+ "RETRYABLE_STATUS",
27
+ "RST_LATENCY_FAST_MS",
28
+ "RST_LATENCY_SLOW_MS",
29
+ "USER_AGENT",
30
+ ]
31
+
32
+ #: Version reported to servers. Kept generic: it should not advertise the tool.
33
+ USER_AGENT: Final[str] = "dns-shield/0.1.1 (+https://github.com/beduldul/dns-shield)"
34
+
35
+ # -- Resolution ------------------------------------------------------------
36
+
37
+ #: Default DNS-over-HTTPS JSON endpoint (RFC 8484 @ ``application/dns-json``).
38
+ DOH_ENDPOINT: Final[str] = "https://cloudflare-dns.com/dns-query"
39
+
40
+ #: Per-query socket timeout for a DoH lookup.
41
+ DOH_TIMEOUT_S: Final[float] = 8.0
42
+
43
+ #: How long a resolved answer is trusted before it is re-queried.
44
+ DNS_CACHE_TTL_S: Final[float] = 120.0
45
+
46
+ # -- Transport -------------------------------------------------------------
47
+
48
+ #: TCP connect timeout for a shielded request.
49
+ CONNECT_TIMEOUT_S: Final[float] = 8.0
50
+
51
+ #: Socket read timeout for a shielded request.
52
+ READ_TIMEOUT_S: Final[float] = 20.0
53
+
54
+ #: Total attempts (not retries) for a shielded request.
55
+ MAX_RETRIES: Final[int] = 4
56
+
57
+ #: Base of the exponential backoff, in seconds.
58
+ BACKOFF_BASE_S: Final[float] = 0.8
59
+
60
+ #: Ceiling for a single backoff sleep, in seconds.
61
+ BACKOFF_MAX_S: Final[float] = 30.0
62
+
63
+ #: Statuses that are worth retrying. A considered set: these are the codes
64
+ #: that mean "the server could not serve you *right now*". Notably absent are
65
+ #: 401/403/404/451, which are deterministic answers and must not be retried --
66
+ #: retrying a 451 would hammer a host that is legally or administratively
67
+ #: refusing service.
68
+ RETRYABLE_STATUS: Final[frozenset[int]] = frozenset({408, 425, 429, 500, 502, 503, 504})
69
+
70
+ # -- Diagnosis thresholds --------------------------------------------------
71
+
72
+ #: A TCP refusal (RST) at or below this latency is "instant" -- the signature of
73
+ #: a synthetic reset from a blackhole appliance rather than a real server.
74
+ RST_LATENCY_FAST_MS: Final[float] = 250.0
75
+
76
+ #: Above this, a refusal looks like it traversed real network distance.
77
+ RST_LATENCY_SLOW_MS: Final[float] = 1500.0
78
+
79
+ #: Host used to detect TLS interception by an on-path proxy.
80
+ MITM_PROBE_HOST: Final[str] = "example.com"