evo-cli 0.24.0__tar.gz → 0.25.1__tar.gz

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.
Files changed (142) hide show
  1. {evo_cli-0.24.0 → evo_cli-0.25.1}/PKG-INFO +1 -1
  2. evo_cli-0.25.1/evo_cli/VERSION +1 -0
  3. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/cli.py +2 -0
  4. evo_cli-0.25.1/evo_cli/commands/route/__init__.py +33 -0
  5. evo_cli-0.25.1/evo_cli/commands/route/_dns.py +142 -0
  6. evo_cli-0.25.1/evo_cli/commands/route/_dpi.py +160 -0
  7. evo_cli-0.25.1/evo_cli/commands/route/_hosts.py +91 -0
  8. evo_cli-0.25.1/evo_cli/commands/route/_probe.py +122 -0
  9. evo_cli-0.25.1/evo_cli/commands/route/check.py +147 -0
  10. evo_cli-0.25.1/evo_cli/commands/route/fix.py +170 -0
  11. evo_cli-0.25.1/evo_cli/commands/route/probe.py +91 -0
  12. evo_cli-0.25.1/evo_cli/commands/route/status.py +72 -0
  13. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/PKG-INFO +1 -1
  14. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/SOURCES.txt +10 -0
  15. evo_cli-0.25.1/tests/test_route.py +188 -0
  16. evo_cli-0.24.0/evo_cli/VERSION +0 -1
  17. {evo_cli-0.24.0 → evo_cli-0.25.1}/Containerfile +0 -0
  18. {evo_cli-0.24.0 → evo_cli-0.25.1}/HISTORY.md +0 -0
  19. {evo_cli-0.24.0 → evo_cli-0.25.1}/LICENSE +0 -0
  20. {evo_cli-0.24.0 → evo_cli-0.25.1}/MANIFEST.in +0 -0
  21. {evo_cli-0.24.0 → evo_cli-0.25.1}/README.md +0 -0
  22. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/__init__.py +0 -0
  23. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/__main__.py +0 -0
  24. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/base.py +0 -0
  25. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/__init__.py +0 -0
  26. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/agent_toy.py +0 -0
  27. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/claude_code.py +0 -0
  28. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/cloudflare.py +0 -0
  29. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/cred.py +0 -0
  30. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/download.py +0 -0
  31. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/fix_claude.py +0 -0
  32. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/gdrive.py +0 -0
  33. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/gh.py +0 -0
  34. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/__init__.py +0 -0
  35. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_dag.py +0 -0
  36. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_git.py +0 -0
  37. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_model.py +0 -0
  38. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_mutate.py +0 -0
  39. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_paths.py +0 -0
  40. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_render.py +0 -0
  41. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_server.py +0 -0
  42. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/check.py +0 -0
  43. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/clone.py +0 -0
  44. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/edit.py +0 -0
  45. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/pull.py +0 -0
  46. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/serve.py +0 -0
  47. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/show.py +0 -0
  48. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/views.py +0 -0
  49. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  50. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  51. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  52. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  53. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  54. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  55. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  56. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  57. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  58. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  59. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  60. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  61. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  62. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  63. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/index.html +0 -0
  64. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/hwid.py +0 -0
  65. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/hwid_reset.py +0 -0
  66. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/image.py +0 -0
  67. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/localproxy.py +0 -0
  68. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/mcp.py +0 -0
  69. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/miniconda.py +0 -0
  70. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/netcheck.py +0 -0
  71. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/ngrok.py +0 -0
  72. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/opencode.py +0 -0
  73. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/plantuml.py +0 -0
  74. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/serp.py +0 -0
  75. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/site2s.py +0 -0
  76. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/ssh.py +0 -0
  77. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/sysmon.py +0 -0
  78. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/tts.py +0 -0
  79. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/update.py +0 -0
  80. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/wifi.py +0 -0
  81. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/console.py +0 -0
  82. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/__init__.py +0 -0
  83. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/doctor.py +0 -0
  84. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/google_oauth.py +0 -0
  85. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/migrate.py +0 -0
  86. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/oauth_flow.py +0 -0
  87. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/registry.py +0 -0
  88. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/store.py +0 -0
  89. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/sync.py +0 -0
  90. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/__init__.py +0 -0
  91. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/align.py +0 -0
  92. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/core.py +0 -0
  93. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/creds.py +0 -0
  94. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/errors.py +0 -0
  95. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/gemini.py +0 -0
  96. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/ncnn.py +0 -0
  97. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/mcp_registry.py +0 -0
  98. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/__init__.py +0 -0
  99. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/client.py +0 -0
  100. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/creds.py +0 -0
  101. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/errors.py +0 -0
  102. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/install.py +0 -0
  103. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/render.py +0 -0
  104. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/__init__.py +0 -0
  105. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/chunking.py +0 -0
  106. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/core.py +0 -0
  107. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/creds.py +0 -0
  108. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/errors.py +0 -0
  109. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/gemini.py +0 -0
  110. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/http.py +0 -0
  111. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/openai.py +0 -0
  112. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/player.py +0 -0
  113. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/vbee.py +0 -0
  114. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  115. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/entry_points.txt +0 -0
  116. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/requires.txt +0 -0
  117. {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/top_level.txt +0 -0
  118. {evo_cli-0.24.0 → evo_cli-0.25.1}/pyproject.toml +0 -0
  119. {evo_cli-0.24.0 → evo_cli-0.25.1}/setup.cfg +0 -0
  120. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/__init__.py +0 -0
  121. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_agent_toy.py +0 -0
  122. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_claude_code.py +0 -0
  123. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_cli.py +0 -0
  124. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_console.py +0 -0
  125. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_cred.py +0 -0
  126. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_download.py +0 -0
  127. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_fix_claude.py +0 -0
  128. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_gdrive.py +0 -0
  129. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_gh.py +0 -0
  130. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness.py +0 -0
  131. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness_clone.py +0 -0
  132. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness_dag.py +0 -0
  133. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_image.py +0 -0
  134. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_mcp.py +0 -0
  135. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_ngrok.py +0 -0
  136. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_opencode.py +0 -0
  137. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_plantuml.py +0 -0
  138. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_serp.py +0 -0
  139. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_sysmon.py +0 -0
  140. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_tts.py +0 -0
  141. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_update.py +0 -0
  142. {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.24.0
3
+ Version: 0.25.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -0,0 +1 @@
1
+ 0.25.1
@@ -20,6 +20,7 @@ from evo_cli.commands.netcheck import netcheck
20
20
  from evo_cli.commands.ngrok import setup_ngrok
21
21
  from evo_cli.commands.opencode import setup_opencode
22
22
  from evo_cli.commands.plantuml import plantuml
23
+ from evo_cli.commands.route import route_group
23
24
  from evo_cli.commands.serp import serp_group, setup_serp
24
25
  from evo_cli.commands.site2s import site2s
25
26
  from evo_cli.commands.ssh import setupssh
@@ -79,6 +80,7 @@ cli.add_command(localproxy)
79
80
  cli.add_command(mcp_group)
80
81
  cli.add_command(netcheck)
81
82
  cli.add_command(plantuml)
83
+ cli.add_command(route_group)
82
84
  cli.add_command(serp_group)
83
85
  cli.add_command(sysmon)
84
86
  cli.add_command(tts_group)
@@ -0,0 +1,33 @@
1
+ import rich_click as click
2
+
3
+ from evo_cli.commands.route.check import check
4
+ from evo_cli.commands.route.fix import close_route, open_route
5
+ from evo_cli.commands.route.probe import probe
6
+ from evo_cli.commands.route.status import status
7
+
8
+ HELP = """Find a way through to somewhere the network is keeping from you.
9
+
10
+ Two blocks look identical from a browser and need opposite fixes. A **poisoned
11
+ DNS** answer hands you a dead address while the route itself stays open, so
12
+ pinning the real address fixes it. A **DPI filter** reading the server name out
13
+ of your TLS handshake blocks every address for that name, and no hosts or DNS
14
+ edit can touch it.
15
+
16
+ - `check <host>` - what stands in the way, and which of the two it is
17
+ - `probe <host>` - every address for the name, ranked by what answers
18
+ - `open <host>` - apply the fix the diagnosis calls for
19
+ - `close <host>` - undo it again
20
+ - `status` - which fixes are currently in place
21
+
22
+ `check` and `probe` are pure Python sockets and need no privileges. `open` and
23
+ `close` edit hosts or manage a service, so they want administrator rights.
24
+ """
25
+
26
+
27
+ @click.group("route", help=HELP)
28
+ def route_group():
29
+ pass
30
+
31
+
32
+ for command in (check, probe, open_route, close_route, status):
33
+ route_group.add_command(command)
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json as jsonlib
4
+ import socket
5
+ import struct
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+
10
+ PORT = 443
11
+
12
+ PUBLIC_RESOLVERS = [
13
+ ("8.8.8.8", "google"),
14
+ ("1.1.1.1", "cloudflare"),
15
+ ("9.9.9.9", "quad9"),
16
+ ("208.67.222.222", "opendns"),
17
+ ]
18
+
19
+ VN_RESOLVERS = [
20
+ ("210.245.0.10", "fpt"),
21
+ ("203.113.131.1", "vnpt"),
22
+ ("183.91.160.11", "viettel"),
23
+ ]
24
+
25
+ DOH_ENDPOINT = "https://dns.google/resolve"
26
+
27
+ # A resolver that answers with one of these is not answering, it is lying: an
28
+ # ISP hijack points the name at the machine itself or at the resolver's own
29
+ # address so the connection dies locally instead of visibly being blocked.
30
+ BOGUS_PREFIXES = ("127.", "0.")
31
+
32
+
33
+ def _encode_qname(host):
34
+ out = bytearray()
35
+ for label in host.rstrip(".").split("."):
36
+ raw = label.encode("idna") if any(ord(c) > 127 for c in label) else label.encode()
37
+ out.append(len(raw))
38
+ out.extend(raw)
39
+ out.append(0)
40
+ return bytes(out)
41
+
42
+
43
+ def _skip_name(data, off):
44
+ while off < len(data):
45
+ length = data[off]
46
+ if length == 0:
47
+ return off + 1
48
+ if length & 0xC0 == 0xC0:
49
+ return off + 2
50
+ off += 1 + length
51
+ return off
52
+
53
+
54
+ def query_a(host, resolver, timeout=2.5):
55
+ header = struct.pack(">HHHHHH", 0x4242, 0x0100, 1, 0, 0, 0)
56
+ packet = header + _encode_qname(host) + struct.pack(">HH", 1, 1)
57
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
58
+ sock.settimeout(timeout)
59
+ try:
60
+ sock.sendto(packet, (resolver, 53))
61
+ data, _ = sock.recvfrom(4096)
62
+ except OSError:
63
+ return None
64
+ finally:
65
+ sock.close()
66
+
67
+ if len(data) < 12:
68
+ return None
69
+ ancount = struct.unpack(">H", data[6:8])[0]
70
+ off = _skip_name(data, 12) + 4
71
+ found = []
72
+ for _ in range(ancount):
73
+ off = _skip_name(data, off)
74
+ if off + 10 > len(data):
75
+ break
76
+ rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[off : off + 10])
77
+ off += 10
78
+ rdata = data[off : off + rdlength]
79
+ off += rdlength
80
+ if rtype == 1 and rdlength == 4:
81
+ found.append(socket.inet_ntop(socket.AF_INET, rdata))
82
+ return found
83
+
84
+
85
+ def system_resolve(host):
86
+ try:
87
+ infos = socket.getaddrinfo(host, PORT, socket.AF_INET, socket.SOCK_STREAM)
88
+ except socket.gaierror:
89
+ return None
90
+ seen = []
91
+ for info in infos:
92
+ ip = info[4][0]
93
+ if ip not in seen:
94
+ seen.append(ip)
95
+ return seen
96
+
97
+
98
+ def doh_resolve(host, timeout=8.0):
99
+ url = f"{DOH_ENDPOINT}?name={urllib.parse.quote(host)}&type=A"
100
+ request = urllib.request.Request(url, headers={"accept": "application/dns-json", "User-Agent": "evo-cli/route"})
101
+ try:
102
+ with urllib.request.urlopen(request, timeout=timeout) as response:
103
+ payload = jsonlib.loads(response.read().decode("utf-8", "replace"))
104
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError):
105
+ return None
106
+ return [a["data"] for a in payload.get("Answer") or [] if a.get("type") == 1]
107
+
108
+
109
+ def is_bogus(ip):
110
+ return any(ip.startswith(prefix) for prefix in BOGUS_PREFIXES)
111
+
112
+
113
+ def gather(host, timeout=2.5, include_vn=True):
114
+ resolvers = list(PUBLIC_RESOLVERS)
115
+ if include_vn:
116
+ resolvers += VN_RESOLVERS
117
+
118
+ answers = {}
119
+ for address, label in resolvers:
120
+ answers[label] = query_a(host, address, timeout)
121
+
122
+ doh = doh_resolve(host)
123
+ if doh is not None:
124
+ answers["doh"] = doh
125
+
126
+ return {
127
+ "system": system_resolve(host),
128
+ "resolvers": answers,
129
+ "trusted": _trusted_union(answers),
130
+ }
131
+
132
+
133
+ def _trusted_union(answers):
134
+ trusted_labels = {label for _, label in PUBLIC_RESOLVERS} | {"doh"}
135
+ out = []
136
+ for label, ips in answers.items():
137
+ if label not in trusted_labels or not ips:
138
+ continue
139
+ for ip in ips:
140
+ if not is_bogus(ip) and ip not in out:
141
+ out.append(ip)
142
+ return out
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from evo_cli.console import download_file, info, resolve_executable
11
+
12
+ RELEASE_URL = "https://github.com/ValdikSS/GoodbyeDPI/releases/download/0.2.2/goodbyedpi-0.2.2.zip"
13
+ RELEASE_SIZE = 635551
14
+ RELEASE_SHA256 = "00a2f8b99cd817f8c7fc4c449033015f039d18af213de78cb66bf202277c0628"
15
+
16
+ SERVICE_NAME = "GoodbyeDPI"
17
+
18
+ # Deliberately NOT the bundled `-5` preset. `-5` is these flags plus
19
+ # `--max-payload`, whose 1200-byte ceiling skips the very packet that matters:
20
+ # a modern Chrome ClientHello carries a post-quantum key share and runs past
21
+ # 1200 bytes, so it sails through untouched while curl - whose ClientHello is
22
+ # small - looks like it works. Without --max-payload both are handled.
23
+ DEFAULT_ARGS = ["-f", "2", "-e", "2", "--auto-ttl", "--reverse-frag"]
24
+
25
+
26
+ def is_windows():
27
+ return os.name == "nt"
28
+
29
+
30
+ def is_admin():
31
+ if not is_windows():
32
+ return os.geteuid() == 0 if hasattr(os, "geteuid") else False
33
+ try:
34
+ import ctypes
35
+
36
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ def install_dir():
42
+ root = os.environ.get("ProgramFiles", r"C:\Program Files")
43
+ return Path(root) / "GoodbyeDPI"
44
+
45
+
46
+ def executable():
47
+ return install_dir() / "x86_64" / "goodbyedpi.exe"
48
+
49
+
50
+ def _sc(*args, check=False):
51
+ cmd = resolve_executable(["sc.exe", *args])
52
+ return subprocess.run(
53
+ cmd,
54
+ capture_output=True,
55
+ text=True,
56
+ encoding="utf-8",
57
+ errors="replace",
58
+ check=check,
59
+ )
60
+
61
+
62
+ def service_state():
63
+ if not is_windows():
64
+ return {"installed": False, "running": False, "args": None, "reason": "windows only"}
65
+ result = _sc("qc", SERVICE_NAME)
66
+ if result.returncode != 0:
67
+ return {"installed": False, "running": False, "args": None, "reason": None}
68
+
69
+ binary = None
70
+ for line in (result.stdout or "").splitlines():
71
+ if "BINARY_PATH_NAME" in line:
72
+ binary = line.split(":", 1)[1].strip()
73
+ break
74
+
75
+ query = _sc("query", SERVICE_NAME)
76
+ running = "RUNNING" in (query.stdout or "")
77
+ return {"installed": True, "running": running, "args": binary, "reason": None}
78
+
79
+
80
+ def verify_download(path):
81
+ size = Path(path).stat().st_size
82
+ if size != RELEASE_SIZE:
83
+ return False, f"size mismatch: got {size}, expected {RELEASE_SIZE}"
84
+ digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()
85
+ if digest != RELEASE_SHA256:
86
+ return False, f"sha256 mismatch: got {digest}"
87
+ return True, None
88
+
89
+
90
+ def fetch(destination=None):
91
+ destination = Path(destination or (Path(tempfile.gettempdir()) / "goodbyedpi-0.2.2.zip"))
92
+ download_file(RELEASE_URL, destination, description="GoodbyeDPI 0.2.2")
93
+ ok, reason = verify_download(destination)
94
+ if not ok:
95
+ destination.unlink(missing_ok=True)
96
+ raise RuntimeError(f"refusing to install a tampered download - {reason}")
97
+ return destination
98
+
99
+
100
+ def unpack(archive, target=None):
101
+ target = Path(target or install_dir())
102
+ target.mkdir(parents=True, exist_ok=True)
103
+ with tempfile.TemporaryDirectory() as tmp:
104
+ with zipfile.ZipFile(archive) as zf:
105
+ zf.extractall(tmp)
106
+ root = next(Path(tmp).glob("goodbyedpi-*"))
107
+ source = root / "x86_64"
108
+ dest = target / "x86_64"
109
+ dest.mkdir(parents=True, exist_ok=True)
110
+ for item in source.iterdir():
111
+ dest.joinpath(item.name).write_bytes(item.read_bytes())
112
+ return target
113
+
114
+
115
+ def install_service(args=None):
116
+ args = list(args or DEFAULT_ARGS)
117
+ binary = f'"{executable()}" ' + " ".join(args)
118
+
119
+ existing = service_state()
120
+ if existing["installed"]:
121
+ _sc("stop", SERVICE_NAME)
122
+ _sc("delete", SERVICE_NAME)
123
+
124
+ # sc.exe wants "binPath=" and its value as two separate argv entries. Passing
125
+ # an explicit list keeps that shape; a shell would glue them and sc would
126
+ # silently print its usage instead of creating anything.
127
+ created = _sc("create", SERVICE_NAME, "binPath=", binary, "start=", "auto")
128
+ if created.returncode != 0:
129
+ raise RuntimeError((created.stdout or created.stderr or "sc create failed").strip())
130
+ _sc("description", SERVICE_NAME, "Passive DPI blocker and Active DPI circumvention utility")
131
+ return binary
132
+
133
+
134
+ def start():
135
+ result = _sc("start", SERVICE_NAME)
136
+ return result.returncode == 0 or "ALREADY_RUNNING" in (result.stdout or "")
137
+
138
+
139
+ def stop():
140
+ result = _sc("stop", SERVICE_NAME)
141
+ return result.returncode == 0 or "NOT_ACTIVE" in (result.stdout or "")
142
+
143
+
144
+ def remove():
145
+ stop()
146
+ result = _sc("delete", SERVICE_NAME)
147
+ return result.returncode == 0
148
+
149
+
150
+ def ensure(args=None):
151
+ state = service_state()
152
+ if not executable().exists():
153
+ info("GoodbyeDPI is not installed yet, fetching it")
154
+ archive = fetch()
155
+ unpack(archive)
156
+ Path(archive).unlink(missing_ok=True)
157
+ if not state["installed"]:
158
+ install_service(args)
159
+ start()
160
+ return service_state()
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import time
7
+ from pathlib import Path
8
+
9
+ MARK_START = "# >>> evo route >>>"
10
+ MARK_END = "# <<< evo route <<<"
11
+
12
+ BLOCK_RE = re.compile(
13
+ re.escape(MARK_START) + r".*?" + re.escape(MARK_END) + r"\r?\n?",
14
+ re.DOTALL,
15
+ )
16
+
17
+
18
+ def hosts_path():
19
+ if os.name == "nt":
20
+ root = os.environ.get("SystemRoot", r"C:\Windows")
21
+ return Path(root) / "System32" / "drivers" / "etc" / "hosts"
22
+ return Path("/etc/hosts")
23
+
24
+
25
+ def read_block(path=None):
26
+ path = Path(path) if path else hosts_path()
27
+ try:
28
+ raw = path.read_text(encoding="utf-8", errors="replace")
29
+ except OSError:
30
+ return {}
31
+ match = BLOCK_RE.search(raw)
32
+ if not match:
33
+ return {}
34
+ entries = {}
35
+ for line in match.group(0).splitlines():
36
+ line = line.strip()
37
+ if not line or line.startswith("#"):
38
+ continue
39
+ parts = line.split()
40
+ if len(parts) >= 2:
41
+ entries[parts[1]] = parts[0]
42
+ return entries
43
+
44
+
45
+ def backup(path=None):
46
+ path = Path(path) if path else hosts_path()
47
+ stamp = time.strftime("%Y%m%d-%H%M%S")
48
+ target = path.with_name(path.name + f".evo-route.{stamp}")
49
+ shutil.copy2(path, target)
50
+ return target
51
+
52
+
53
+ def write_block(entries, path=None, make_backup=True):
54
+ path = Path(path) if path else hosts_path()
55
+ raw = path.read_text(encoding="utf-8", errors="replace")
56
+ if make_backup:
57
+ backup(path)
58
+
59
+ raw = BLOCK_RE.sub("", raw)
60
+ if raw and not raw.endswith("\n"):
61
+ raw += "\n"
62
+
63
+ if entries:
64
+ lines = [MARK_START]
65
+ for host in sorted(entries):
66
+ lines.append(f"{entries[host]} {host}")
67
+ lines.append(MARK_END)
68
+ raw += "\n".join(lines) + "\n"
69
+
70
+ path.write_text(raw, encoding="utf-8")
71
+ return path
72
+
73
+
74
+ def pin(host, ip, path=None, make_backup=True):
75
+ entries = read_block(path)
76
+ entries[host] = ip
77
+ write_block(entries, path=path, make_backup=make_backup)
78
+ return entries
79
+
80
+
81
+ def unpin(host, path=None, make_backup=True):
82
+ entries = read_block(path)
83
+ if host not in entries:
84
+ return entries, False
85
+ entries.pop(host)
86
+ write_block(entries, path=path, make_backup=make_backup)
87
+ return entries, True
88
+
89
+
90
+ def clear(path=None, make_backup=True):
91
+ write_block({}, path=path, make_backup=make_backup)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import ssl
5
+ import time
6
+
7
+ from evo_cli.commands.route import _dns
8
+
9
+ PORT = 443
10
+
11
+ # A name nobody blocks, used as the control in the SNI experiment below.
12
+ CONTROL_SNI = "example.com"
13
+
14
+ OPEN = "open"
15
+ DNS_POISONED = "dns-poisoned"
16
+ SNI_BLOCKED = "sni-blocked"
17
+ IP_BLOCKED = "ip-blocked"
18
+ UNREACHABLE = "unreachable"
19
+
20
+ VERDICT_TEXT = {
21
+ OPEN: "reachable - nothing in the way",
22
+ DNS_POISONED: "DNS is poisoned, the route itself is open",
23
+ SNI_BLOCKED: "DPI blocks the TLS handshake by server name",
24
+ IP_BLOCKED: "the address itself is blocked or dead",
25
+ UNREACHABLE: "no address answers at all",
26
+ }
27
+
28
+
29
+ def _ms(start):
30
+ return (time.perf_counter() - start) * 1000
31
+
32
+
33
+ def tls_probe(ip, sni, timeout=6.0):
34
+ """Open one TLS connection to ``ip`` announcing ``sni``.
35
+
36
+ Certificate checking is off on purpose: the question is whether the
37
+ handshake is allowed to finish, not whether the certificate matches the
38
+ name we announced.
39
+ """
40
+ result = {"ip": ip, "sni": sni, "tcp": False, "tls": False, "ms": None, "error": None, "reset": False}
41
+ started = time.perf_counter()
42
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
43
+ sock.settimeout(timeout)
44
+ try:
45
+ sock.connect((ip, PORT))
46
+ result["tcp"] = True
47
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
48
+ context.check_hostname = False
49
+ context.verify_mode = ssl.CERT_NONE
50
+ with context.wrap_socket(sock, server_hostname=sni):
51
+ result["tls"] = True
52
+ result["ms"] = _ms(started)
53
+ except ConnectionResetError as exc:
54
+ result["error"] = str(exc)
55
+ result["reset"] = True
56
+ except TimeoutError as exc:
57
+ result["error"] = f"timeout: {exc}"
58
+ except ssl.SSLError as exc:
59
+ result["error"] = str(exc)
60
+ result["reset"] = "reset" in str(exc).lower() or "ILLEGAL_MESSAGE" in str(exc)
61
+ except OSError as exc:
62
+ result["error"] = str(exc)
63
+ finally:
64
+ try:
65
+ sock.close()
66
+ except OSError:
67
+ pass
68
+ return result
69
+
70
+
71
+ def rank(host, candidates, timeout=6.0):
72
+ rows = []
73
+ for ip in candidates:
74
+ row = tls_probe(ip, host, timeout=timeout)
75
+ rows.append(row)
76
+ rows.sort(key=lambda r: (not r["tls"], r["ms"] if r["ms"] is not None else 1e9))
77
+ return rows
78
+
79
+
80
+ def diagnose(host, timeout=6.0, include_vn=True):
81
+ answers = _dns.gather(host, include_vn=include_vn)
82
+ system_ips = answers["system"] or []
83
+ trusted = answers["trusted"]
84
+
85
+ poisoned = bool(system_ips) and all(_dns.is_bogus(ip) for ip in system_ips)
86
+ if not poisoned and system_ips and trusted:
87
+ poisoned = not set(system_ips) & set(trusted) and any(_dns.is_bogus(ip) for ip in system_ips)
88
+
89
+ candidates = trusted or [ip for ip in system_ips if not _dns.is_bogus(ip)]
90
+ probes = rank(host, candidates, timeout=timeout) if candidates else []
91
+ working = [row for row in probes if row["tls"]]
92
+
93
+ control = None
94
+ if candidates and not working:
95
+ control = tls_probe(candidates[0], CONTROL_SNI, timeout=timeout)
96
+
97
+ verdict = _verdict(candidates, probes, working, control, poisoned)
98
+
99
+ return {
100
+ "host": host,
101
+ "verdict": verdict,
102
+ "poisoned": poisoned,
103
+ "system_ips": system_ips,
104
+ "answers": answers["resolvers"],
105
+ "candidates": candidates,
106
+ "probes": probes,
107
+ "working": working,
108
+ "control": control,
109
+ "best": working[0]["ip"] if working else None,
110
+ }
111
+
112
+
113
+ def _verdict(candidates, probes, working, control, poisoned):
114
+ if not candidates:
115
+ return UNREACHABLE
116
+ if working:
117
+ return DNS_POISONED if poisoned else OPEN
118
+ if control and control["tls"]:
119
+ return SNI_BLOCKED
120
+ if any(row["tcp"] for row in probes):
121
+ return IP_BLOCKED
122
+ return UNREACHABLE