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 +94 -0
- dns_shield/cli.py +365 -0
- dns_shield/config.py +80 -0
- dns_shield/detect.py +916 -0
- dns_shield/patch.py +232 -0
- dns_shield/py.typed +0 -0
- dns_shield/resolve.py +372 -0
- dns_shield/transport.py +624 -0
- dns_shield-0.1.1.dist-info/METADATA +563 -0
- dns_shield-0.1.1.dist-info/RECORD +13 -0
- dns_shield-0.1.1.dist-info/WHEEL +4 -0
- dns_shield-0.1.1.dist-info/entry_points.txt +2 -0
- dns_shield-0.1.1.dist-info/licenses/LICENSE +21 -0
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"
|