evo-cli 0.32.0__tar.gz → 0.33.0__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 (156) hide show
  1. {evo_cli-0.32.0 → evo_cli-0.33.0}/PKG-INFO +1 -1
  2. evo_cli-0.33.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/__init__.py +1 -1
  4. evo_cli-0.33.0/evo_cli/commands/route/_page.py +96 -0
  5. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/_probe.py +28 -9
  6. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/_spoofdpi.py +6 -5
  7. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/check.py +5 -0
  8. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/fix.py +64 -6
  9. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/probe.py +3 -3
  10. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/PKG-INFO +1 -1
  11. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/SOURCES.txt +1 -0
  12. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_route.py +202 -2
  13. evo_cli-0.32.0/evo_cli/VERSION +0 -1
  14. {evo_cli-0.32.0 → evo_cli-0.33.0}/Containerfile +0 -0
  15. {evo_cli-0.32.0 → evo_cli-0.33.0}/HISTORY.md +0 -0
  16. {evo_cli-0.32.0 → evo_cli-0.33.0}/LICENSE +0 -0
  17. {evo_cli-0.32.0 → evo_cli-0.33.0}/MANIFEST.in +0 -0
  18. {evo_cli-0.32.0 → evo_cli-0.33.0}/README.md +0 -0
  19. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/__init__.py +0 -0
  20. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/__main__.py +0 -0
  21. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/base.py +0 -0
  22. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/cli.py +0 -0
  23. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/__init__.py +0 -0
  24. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/agent_toy.py +0 -0
  25. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/claude_code.py +0 -0
  26. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/cloudflare.py +0 -0
  27. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/cred.py +0 -0
  28. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/download.py +0 -0
  29. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/fix_claude.py +0 -0
  30. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/gdrive.py +0 -0
  31. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/gh.py +0 -0
  32. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/__init__.py +0 -0
  33. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_dag.py +0 -0
  34. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_git.py +0 -0
  35. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_hub.py +0 -0
  36. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_mirror.py +0 -0
  37. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_model.py +0 -0
  38. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_mutate.py +0 -0
  39. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_paths.py +0 -0
  40. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_render.py +0 -0
  41. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/_server.py +0 -0
  42. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/check.py +0 -0
  43. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/clone.py +0 -0
  44. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/edit.py +0 -0
  45. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/pull.py +0 -0
  46. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/serve.py +0 -0
  47. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/show.py +0 -0
  48. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/views.py +0 -0
  49. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  50. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  51. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  52. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  53. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  54. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  55. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  56. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  57. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  58. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  59. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  60. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  61. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  62. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  63. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/harness/web/index.html +0 -0
  64. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/hwid.py +0 -0
  65. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/hwid_reset.py +0 -0
  66. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/image.py +0 -0
  67. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/localproxy.py +0 -0
  68. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/mcp.py +0 -0
  69. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/miniconda.py +0 -0
  70. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/netcheck.py +0 -0
  71. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/ngrok.py +0 -0
  72. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/opencode.py +0 -0
  73. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/openvpn.py +0 -0
  74. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/plantuml.py +0 -0
  75. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/_dns.py +0 -0
  76. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/_dpi.py +0 -0
  77. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/_hosts.py +0 -0
  78. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/route/status.py +0 -0
  79. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/serp.py +0 -0
  80. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/site2s.py +0 -0
  81. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/ssh.py +0 -0
  82. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/storage.py +0 -0
  83. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/sysmon.py +0 -0
  84. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/tts.py +0 -0
  85. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/update.py +0 -0
  86. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/commands/wifi.py +0 -0
  87. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/console.py +0 -0
  88. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/__init__.py +0 -0
  89. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/doctor.py +0 -0
  90. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/google_oauth.py +0 -0
  91. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/migrate.py +0 -0
  92. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/oauth_flow.py +0 -0
  93. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/registry.py +0 -0
  94. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/store.py +0 -0
  95. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/credentials/sync.py +0 -0
  96. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/__init__.py +0 -0
  97. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/align.py +0 -0
  98. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/core.py +0 -0
  99. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/creds.py +0 -0
  100. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/errors.py +0 -0
  101. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/gemini.py +0 -0
  102. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/imaging/ncnn.py +0 -0
  103. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/mcp_registry.py +0 -0
  104. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/openvpn.py +0 -0
  105. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/__init__.py +0 -0
  106. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/client.py +0 -0
  107. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/creds.py +0 -0
  108. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/errors.py +0 -0
  109. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/install.py +0 -0
  110. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/serp/render.py +0 -0
  111. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/__init__.py +0 -0
  112. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/chunking.py +0 -0
  113. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/core.py +0 -0
  114. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/creds.py +0 -0
  115. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/errors.py +0 -0
  116. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/gemini.py +0 -0
  117. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/http.py +0 -0
  118. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/openai.py +0 -0
  119. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/player.py +0 -0
  120. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli/tts/vbee.py +0 -0
  121. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  122. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/entry_points.txt +0 -0
  123. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/requires.txt +0 -0
  124. {evo_cli-0.32.0 → evo_cli-0.33.0}/evo_cli.egg-info/top_level.txt +0 -0
  125. {evo_cli-0.32.0 → evo_cli-0.33.0}/pyproject.toml +0 -0
  126. {evo_cli-0.32.0 → evo_cli-0.33.0}/setup.cfg +0 -0
  127. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/__init__.py +0 -0
  128. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/fake_evo_agents.py +0 -0
  129. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  130. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  131. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_cli.py +0 -0
  134. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_console.py +0 -0
  135. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_cred.py +0 -0
  136. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_download.py +0 -0
  137. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_gh.py +0 -0
  140. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_harness.py +0 -0
  141. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_harness_hub.py +0 -0
  144. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_harness_show.py +0 -0
  145. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_image.py +0 -0
  146. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_mcp.py +0 -0
  147. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_ngrok.py +0 -0
  148. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_opencode.py +0 -0
  149. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_openvpn.py +0 -0
  150. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_plantuml.py +0 -0
  151. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_serp.py +0 -0
  152. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_storage.py +0 -0
  153. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_sysmon.py +0 -0
  154. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_tts.py +0 -0
  155. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_update.py +0 -0
  156. {evo_cli-0.32.0 → evo_cli-0.33.0}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.32.0
3
+ Version: 0.33.0
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.33.0
@@ -15,7 +15,7 @@ edit can touch it.
15
15
 
16
16
  - `check <host>` - what stands in the way, and which of the two it is
17
17
  - `probe <host>` - every address for the name, ranked by what answers
18
- - `open <host>` - apply the fix the diagnosis calls for
18
+ - `open <host>` - apply the fix the diagnosis calls for, and see to what its page loads
19
19
  - `close <host>` - undo it again
20
20
  - `status` - which fixes are currently in place
21
21
 
@@ -0,0 +1,96 @@
1
+ """What a page loads besides itself: a site is not open until its stylesheets and images are."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import urllib.parse
7
+ import urllib.request
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from html.parser import HTMLParser
10
+
11
+ from evo_cli.commands.route import _probe
12
+
13
+ # Far past any <head>, and the rest of a long page repeats the same hosts.
14
+ MAX_PAGE = 2 * 1024 * 1024
15
+ WORKERS = 8
16
+ USER_AGENT = (
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
18
+ "(KHTML, like Gecko) Version/17.6 Safari/605.1.15"
19
+ )
20
+
21
+ # The <link> kinds a browser acts on by itself, unlike canonical or alternate.
22
+ LINK_RELS = {"stylesheet", "preload", "modulepreload", "preconnect", "dns-prefetch", "icon"}
23
+ SOURCE_TAGS = {"script", "img", "source", "video", "audio", "iframe"}
24
+
25
+ # Under a country code these are a public suffix, not a site: example.com.vn, example.co.uk.
26
+ COUNTRY_SECOND_LEVEL = {"ac", "co", "com", "edu", "gov", "net", "org"}
27
+
28
+
29
+ class _AssetParser(HTMLParser):
30
+ def __init__(self):
31
+ super().__init__()
32
+ self.urls = []
33
+
34
+ def handle_starttag(self, tag, attrs):
35
+ fields = dict(attrs)
36
+ if tag == "link":
37
+ if LINK_RELS & set((fields.get("rel") or "").lower().split()):
38
+ self.urls.append(fields.get("href"))
39
+ elif tag in SOURCE_TAGS:
40
+ self.urls.append(fields.get("src"))
41
+ self.urls.extend(part.split()[0] for part in (fields.get("srcset") or "").split(",") if part.strip())
42
+
43
+
44
+ def _is_ip(name):
45
+ try:
46
+ ipaddress.ip_address(name)
47
+ except ValueError:
48
+ return False
49
+ return True
50
+
51
+
52
+ def asset_hosts(html, base_url):
53
+ """The names a browser would connect to for this page, ignoring what only a click would reach."""
54
+ parser = _AssetParser()
55
+ parser.feed(html)
56
+ hosts = []
57
+ for url in parser.urls:
58
+ try:
59
+ name = urllib.parse.urlsplit(urllib.parse.urljoin(base_url, url or "")).hostname
60
+ except ValueError:
61
+ continue
62
+ if name and "." in name and not _is_ip(name) and name not in hosts:
63
+ hosts.append(name)
64
+ return hosts
65
+
66
+
67
+ def fetch(host, proxy=None, timeout=6.0):
68
+ """Read the front page of ``host``, through ``proxy`` when there is one; returns where it ended up and the HTML."""
69
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({"http": proxy, "https": proxy} if proxy else {}))
70
+ request = urllib.request.Request(f"https://{host}/", headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
71
+ with opener.open(request, timeout=timeout) as response:
72
+ return response.geturl(), response.read(MAX_PAGE).decode("utf-8", "replace")
73
+
74
+
75
+ def registrable(host):
76
+ labels = host.split(".")
77
+ keep = 3 if len(labels[-1]) == 2 and labels[-2] in COUNTRY_SECOND_LEVEL else 2
78
+ return ".".join(labels[-keep:])
79
+
80
+
81
+ def covers(opened, host):
82
+ return any(host == name or host.endswith(f".{name}") for name in opened)
83
+
84
+
85
+ def blocked_hosts(host, proxy, opened, timeout=6.0):
86
+ """The hosts the page at ``host`` needs that a browser cannot reach, leaving out what ``opened`` already covers.
87
+
88
+ The site's own family is in the running because a page calls its API and sign-in
89
+ hosts from script, where no tag shows them.
90
+ """
91
+ final_url, html = fetch(host, proxy=proxy, timeout=timeout)
92
+ names = dict.fromkeys([*asset_hosts(html, final_url), registrable(host)])
93
+ names = [name for name in names if not covers([host, *opened], name)]
94
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
95
+ alive = list(pool.map(lambda name: _probe.reachable(name, timeout=timeout), names))
96
+ return [name for name, ok in zip(names, alive) if not ok]
@@ -13,6 +13,7 @@ CONTROL_SNI = "example.com"
13
13
 
14
14
  OPEN = "open"
15
15
  DNS_POISONED = "dns-poisoned"
16
+ ADDRESS_BLOCKED = "address-blocked"
16
17
  SNI_BLOCKED = "sni-blocked"
17
18
  IP_BLOCKED = "ip-blocked"
18
19
  UNREACHABLE = "unreachable"
@@ -20,6 +21,7 @@ UNREACHABLE = "unreachable"
20
21
  VERDICT_TEXT = {
21
22
  OPEN: "reachable - nothing in the way",
22
23
  DNS_POISONED: "DNS is poisoned, the route itself is open",
24
+ ADDRESS_BLOCKED: "the address your system resolves is blocked, another one works",
23
25
  SNI_BLOCKED: "DPI blocks the TLS handshake by server name",
24
26
  IP_BLOCKED: "the address itself is blocked or dead",
25
27
  UNREACHABLE: "no address answers at all",
@@ -103,24 +105,38 @@ def rank(host, candidates, timeout=6.0):
103
105
  return rows
104
106
 
105
107
 
108
+ def reachable(host, timeout=6.0):
109
+ """Whether a browser going straight to what the system resolves gets a handshake."""
110
+ ips = [ip for ip in _dns.system_resolve(host) or [] if not _dns.is_bogus(ip)]
111
+ return any(tls_probe(ip, host, timeout=timeout)["tls"] for ip in ips)
112
+
113
+
114
+ def candidates(answers):
115
+ """The system's own addresses first, since those are what a browser connects to, then every trusted one."""
116
+ system = [ip for ip in answers["system"] or [] if not _dns.is_bogus(ip)]
117
+ return system + [ip for ip in answers["trusted"] if ip not in system]
118
+
119
+
106
120
  def diagnose(host, timeout=6.0, include_vn=True):
107
121
  answers = _dns.gather(host, include_vn=include_vn)
108
122
  system_ips = answers["system"] or []
109
123
  trusted = answers["trusted"]
110
124
 
111
- poisoned = bool(system_ips) and all(_dns.is_bogus(ip) for ip in system_ips)
125
+ # A name the system cannot resolve at all, while trusted resolvers can, is as dead to a browser as a bogus one.
126
+ poisoned = (bool(system_ips) and all(_dns.is_bogus(ip) for ip in system_ips)) or (not system_ips and bool(trusted))
112
127
  if not poisoned and system_ips and trusted:
113
128
  poisoned = not set(system_ips) & set(trusted) and any(_dns.is_bogus(ip) for ip in system_ips)
114
129
 
115
- candidates = trusted or [ip for ip in system_ips if not _dns.is_bogus(ip)]
116
- probes = rank(host, candidates, timeout=timeout) if candidates else []
130
+ addresses = candidates(answers)
131
+ probes = rank(host, addresses, timeout=timeout) if addresses else []
117
132
  working = [row for row in probes if row["tls"]]
133
+ system_ok = any(row["ip"] in system_ips for row in working)
118
134
 
119
135
  control = None
120
- if candidates and not working:
121
- control = tls_probe(candidates[0], CONTROL_SNI, timeout=timeout)
136
+ if addresses and not working:
137
+ control = tls_probe(addresses[0], CONTROL_SNI, timeout=timeout)
122
138
 
123
- verdict = _verdict(candidates, probes, working, control, poisoned)
139
+ verdict = _verdict(addresses, probes, working, control, poisoned, system_ok)
124
140
 
125
141
  return {
126
142
  "host": host,
@@ -128,7 +144,7 @@ def diagnose(host, timeout=6.0, include_vn=True):
128
144
  "poisoned": poisoned,
129
145
  "system_ips": system_ips,
130
146
  "answers": answers["resolvers"],
131
- "candidates": candidates,
147
+ "candidates": addresses,
132
148
  "probes": probes,
133
149
  "working": working,
134
150
  "control": control,
@@ -136,11 +152,14 @@ def diagnose(host, timeout=6.0, include_vn=True):
136
152
  }
137
153
 
138
154
 
139
- def _verdict(candidates, probes, working, control, poisoned):
155
+ def _verdict(candidates, probes, working, control, poisoned, system_ok=True):
140
156
  if not candidates:
141
157
  return UNREACHABLE
142
158
  if working:
143
- return DNS_POISONED if poisoned else OPEN
159
+ if poisoned:
160
+ return DNS_POISONED
161
+ # Some address works, but not the one the system hands the browser: the name only opens once it is pinned.
162
+ return OPEN if system_ok else ADDRESS_BLOCKED
144
163
  if control and control["tls"]:
145
164
  return SNI_BLOCKED
146
165
  if any(row["tcp"] for row in probes):
@@ -84,12 +84,13 @@ def opened():
84
84
  lines = hosts_path().read_text(encoding="utf-8").splitlines()
85
85
  except FileNotFoundError:
86
86
  return []
87
- return [line for line in lines if line and not line.startswith(("#", "*."))]
87
+ return [line for line in lines if line and not line.startswith(("#", "*"))]
88
88
 
89
89
 
90
90
  def _write_hosts(hosts):
91
91
  home().mkdir(parents=True, exist_ok=True)
92
- lines = [pattern for host in sorted(set(hosts)) for pattern in (host, f"*.{host}")]
92
+ # `*` stands for one label and `**` for any number: a CDN name like store.fastly.example.com needs the second.
93
+ lines = [pattern for host in sorted(set(hosts)) for pattern in (host, f"**.{host}")]
93
94
  hosts_path().write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
94
95
 
95
96
 
@@ -310,15 +311,15 @@ def service_state():
310
311
  }
311
312
 
312
313
 
313
- def ensure(host):
314
- """Put ``host`` through the bypass: install SpoofDPI, (re)start its agent, point the system proxy at it."""
314
+ def ensure(*hosts):
315
+ """Put ``hosts`` through the bypass: install SpoofDPI, (re)start its agent, point the system proxy at it."""
315
316
  conflicts = _proxy_conflicts()
316
317
  if conflicts:
317
318
  raise RuntimeError(f"A proxy is already set on {', '.join(conflicts)}; evo will not replace it.")
318
319
  exe = executable() or install()
319
320
  home().mkdir(parents=True, exist_ok=True)
320
321
  config_path().write_text(CONFIG, encoding="utf-8")
321
- _write_hosts([*opened(), host])
322
+ _write_hosts([*opened(), *hosts])
322
323
  if not _start(reload=_write_agent(exe)):
323
324
  return service_state()
324
325
  set_system_proxy()
@@ -24,6 +24,7 @@ EPILOG = Text.from_markup(
24
24
  VERDICT_STYLE = {
25
25
  _probe.OPEN: "success",
26
26
  _probe.DNS_POISONED: "warning",
27
+ _probe.ADDRESS_BLOCKED: "warning",
27
28
  _probe.SNI_BLOCKED: "error",
28
29
  _probe.IP_BLOCKED: "error",
29
30
  _probe.UNREACHABLE: "error",
@@ -32,6 +33,7 @@ VERDICT_STYLE = {
32
33
  ADVICE = {
33
34
  _probe.OPEN: "Nothing to do.",
34
35
  _probe.DNS_POISONED: "Run `evo route open {host}` to pin the working address, or fix DNS at the source.",
36
+ _probe.ADDRESS_BLOCKED: "Run `evo route open {host}` to pin the working address.",
35
37
  _probe.SNI_BLOCKED: "hosts and DNS cannot help here. Run `evo route open {host}` to bring up the DPI bypass.",
36
38
  _probe.IP_BLOCKED: "The address answers TCP but nothing completes - you need a tunnel or VPN.",
37
39
  _probe.UNREACHABLE: "No address resolved or answered. Check the name, then your link.",
@@ -92,6 +94,9 @@ def render(report):
92
94
  success(f"{host} is reachable. {message}")
93
95
  elif verdict == _probe.DNS_POISONED:
94
96
  warning(f"DNS hands back a bogus address for {host}, but {report['best']} works. {message}")
97
+ elif verdict == _probe.ADDRESS_BLOCKED:
98
+ dead = ", ".join(report["system_ips"])
99
+ warning(f"{host} resolves to {dead}, which is blocked, but {report['best']} works. {message}")
95
100
  else:
96
101
  error(f"{host}: {_probe.VERDICT_TEXT[verdict]}. {message}")
97
102
 
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import http.client
3
4
  import sys
4
5
 
5
6
  import rich_click as click
6
7
  from rich.text import Text
7
8
 
8
- from evo_cli.commands.route import _dpi, _hosts, _probe, _spoofdpi
9
+ from evo_cli.commands.route import _dpi, _hosts, _page, _probe, _spoofdpi
9
10
  from evo_cli.console import console, error, info, step, success, warning
10
11
 
11
12
  OPEN_EPILOG = Text.from_markup(
@@ -13,9 +14,12 @@ OPEN_EPILOG = Text.from_markup(
13
14
  " [cyan]evo route open store.steampowered.com[/cyan] pick the fix that fits\n"
14
15
  " [cyan]evo route open medium.com --dry-run[/cyan] say what it would do\n"
15
16
  " [cyan]evo route open x.com --method hosts[/cyan] force the address pin\n"
16
- " [cyan]evo route open x.com --method dpi[/cyan] force the DPI bypass\n\n"
17
+ " [cyan]evo route open x.com --method dpi[/cyan] force the DPI bypass\n"
18
+ " [cyan]evo route open x.com --host-only[/cyan] leave what its page loads alone\n\n"
17
19
  "[dim]Diagnoses first, then applies only the fix that matches: pinning an address\n"
18
20
  "for a poisoned name, or the packet-level bypass for a name-keyed block.\n"
21
+ "Then reads the page: the bypass takes in the blocked hosts it loads from,\n"
22
+ "whole domains at a time because CDNs rotate; a pin only names them.\n"
19
23
  "Needs administrator rights because it edits hosts / installs a service;\n"
20
24
  "on macOS the bypass is SpoofDPI as your own LaunchAgent and needs no sudo.[/dim]"
21
25
  )
@@ -98,6 +102,51 @@ def _apply_dpi(report, dry_run):
98
102
  return state
99
103
 
100
104
 
105
+ def _page_hosts(host, proxy, timeout):
106
+ """The blocked hosts ``host``'s page needs, or None when the page cannot be read."""
107
+ with console.status(f"[info]reading the page at {host}...[/info]", spinner="dots"):
108
+ try:
109
+ return _page.blocked_hosts(host, proxy, _spoofdpi.opened(), timeout=timeout)
110
+ except (OSError, http.client.HTTPException, ValueError) as exc:
111
+ warning(f"Could not read the page at {host}, so what it loads is unchecked: {exc}")
112
+ return None
113
+
114
+
115
+ def _open_page_hosts(host, timeout):
116
+ """The macOS bypass lists hosts by name, so everything the page loads has to go on the list too."""
117
+ proxy = (_spoofdpi.LISTEN_HOST, _spoofdpi.LISTEN_PORT)
118
+ blocked = _page_hosts(host, f"http://{_spoofdpi.listen()}", timeout)
119
+ if blocked is None:
120
+ return
121
+ if not blocked:
122
+ success(f"Nothing the page at {host} loads is blocked")
123
+ return
124
+
125
+ info(f"The page at {host} also needs {', '.join(blocked)}")
126
+ domains = sorted({_page.registrable(name) for name in blocked})
127
+ try:
128
+ state = _spoofdpi.ensure(*domains)
129
+ except RuntimeError as exc:
130
+ error(str(exc))
131
+ sys.exit(1)
132
+ if not state["running"]:
133
+ error(f"SpoofDPI would not restart - see {_spoofdpi.log_path()}")
134
+ sys.exit(1)
135
+
136
+ with console.status("[info]re-checking through the proxy...[/info]", spinner="dots"):
137
+ failed = [name for name in blocked if not _probe.proxy_probe(name, proxy, timeout=timeout)["tls"]]
138
+ if failed:
139
+ warning(f"Still failing through the bypass: {', '.join(failed)}. See {_spoofdpi.log_path()}")
140
+ success(f"Added {', '.join(domains)} to the bypass - `evo route close <host>` takes one back out")
141
+
142
+
143
+ def _warn_page_hosts(host, timeout):
144
+ """A pin cannot reach past the name it pins, and the bypass will not start under sudo, so this only reports."""
145
+ blocked = _page_hosts(host, None, timeout)
146
+ if blocked:
147
+ warning(f"The page at {host} also needs {', '.join(blocked)} - run `evo route open <host>` on each")
148
+
149
+
101
150
  @click.command("open", epilog=OPEN_EPILOG)
102
151
  @click.argument("host")
103
152
  @click.option(
@@ -109,14 +158,16 @@ def _apply_dpi(report, dry_run):
109
158
  )
110
159
  @click.option("-n", "--dry-run", is_flag=True, help="Say what would change, change nothing.")
111
160
  @click.option("-t", "--timeout", default=6.0, show_default=True, help="Per-connection timeout (seconds).")
112
- def open_route(host, method, dry_run, timeout):
161
+ @click.option("--host-only", is_flag=True, help="Open HOST alone, not what its page loads from.")
162
+ def open_route(host, method, dry_run, timeout, host_only):
113
163
  """Open a route to `HOST` by applying the fix its diagnosis calls for.
114
164
 
115
165
  A poisoned name gets its working address pinned in hosts. A name-keyed DPI
116
166
  block gets the packet-level bypass, because no amount of hosts or DNS
117
167
  editing survives a filter that reads the server name out of the handshake.
118
168
 
119
- Re-checks afterwards and reports whether the route actually opened.
169
+ Re-checks afterwards and reports whether the route actually opened, then
170
+ reads the page, because a site with its CDN still blocked is not open.
120
171
  """
121
172
  step(f"evo route open {host}")
122
173
 
@@ -130,7 +181,7 @@ def open_route(host, method, dry_run, timeout):
130
181
  if verdict == _probe.OPEN:
131
182
  success(f"{host} is already reachable - nothing to do.")
132
183
  return report
133
- if verdict == _probe.DNS_POISONED:
184
+ if verdict in (_probe.DNS_POISONED, _probe.ADDRESS_BLOCKED):
134
185
  method = "hosts"
135
186
  elif verdict == _probe.SNI_BLOCKED:
136
187
  method = "dpi"
@@ -144,15 +195,22 @@ def open_route(host, method, dry_run, timeout):
144
195
  _apply_dpi(report, dry_run)
145
196
 
146
197
  if dry_run:
198
+ if method == "dpi" and _dpi.is_macos() and not host_only:
199
+ info("Would then read its page and add the blocked hosts it loads from")
147
200
  return report
148
201
 
149
202
  step("Verify")
150
203
  if method == "dpi" and _dpi.is_macos():
151
- return _verify_through_proxy(host, timeout)
204
+ row = _verify_through_proxy(host, timeout)
205
+ if row["tls"] and not host_only:
206
+ _open_page_hosts(host, timeout)
207
+ return row
152
208
  with console.status(f"[info]re-checking {host}...[/info]", spinner="dots"):
153
209
  after = _probe.diagnose(host, timeout=timeout)
154
210
  if after["verdict"] in (_probe.OPEN, _probe.DNS_POISONED) and after["working"]:
155
211
  success(f"{host} is reachable now ({after['best']})")
212
+ if method == "hosts" and not host_only:
213
+ _warn_page_hosts(host, timeout)
156
214
  else:
157
215
  warning(f"{host} still reports [bold]{after['verdict']}[/bold]. Run `evo route check {host}` for detail.")
158
216
  return after
@@ -37,7 +37,7 @@ def probe(host, timeout, show_all, no_vn, as_json):
37
37
  with console.status(f"[info]collecting addresses for {host}...[/info]", spinner="dots"):
38
38
  answers = _dns.gather(host, include_vn=not no_vn)
39
39
 
40
- candidates = answers["trusted"] or [ip for ip in (answers["system"] or []) if not _dns.is_bogus(ip)]
40
+ candidates = _probe.candidates(answers)
41
41
  if not candidates:
42
42
  error(f"No usable address for {host}.")
43
43
  sys.exit(1)
@@ -61,13 +61,13 @@ def probe(host, timeout, show_all, no_vn, as_json):
61
61
  table.add_column("time")
62
62
  table.add_column("seen from")
63
63
 
64
- origin = _origin_map(answers["resolvers"])
64
+ origin = _origin_map({"system": answers["system"], **answers["resolvers"]})
65
65
  for row in shown:
66
66
  table.add_row(
67
67
  row["ip"],
68
68
  "[success]ok[/success]" if row["tls"] else "[error]blocked[/error]",
69
69
  f"{row['ms']:.0f}ms" if row["ms"] is not None else "-",
70
- ", ".join(origin.get(row["ip"], [])) or "[dim]system[/dim]",
70
+ ", ".join(origin.get(row["ip"], [])),
71
71
  )
72
72
  console.print()
73
73
  console.print(table)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.32.0
3
+ Version: 0.33.0
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
@@ -82,6 +82,7 @@ evo_cli/commands/route/__init__.py
82
82
  evo_cli/commands/route/_dns.py
83
83
  evo_cli/commands/route/_dpi.py
84
84
  evo_cli/commands/route/_hosts.py
85
+ evo_cli/commands/route/_page.py
85
86
  evo_cli/commands/route/_probe.py
86
87
  evo_cli/commands/route/_spoofdpi.py
87
88
  evo_cli/commands/route/check.py