evo-cli 0.31.1__tar.gz → 0.31.3__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 (155) hide show
  1. {evo_cli-0.31.1 → evo_cli-0.31.3}/PKG-INFO +1 -1
  2. evo_cli-0.31.3/evo_cli/VERSION +1 -0
  3. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/__init__.py +3 -1
  4. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/_dpi.py +7 -1
  5. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/_probe.py +27 -1
  6. evo_cli-0.31.3/evo_cli/commands/route/_spoofdpi.py +344 -0
  7. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/fix.py +63 -10
  8. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/status.py +6 -2
  9. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/update.py +162 -7
  10. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/PKG-INFO +1 -1
  11. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/SOURCES.txt +1 -0
  12. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_route.py +146 -1
  13. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_update.py +127 -2
  14. evo_cli-0.31.1/evo_cli/VERSION +0 -1
  15. {evo_cli-0.31.1 → evo_cli-0.31.3}/Containerfile +0 -0
  16. {evo_cli-0.31.1 → evo_cli-0.31.3}/HISTORY.md +0 -0
  17. {evo_cli-0.31.1 → evo_cli-0.31.3}/LICENSE +0 -0
  18. {evo_cli-0.31.1 → evo_cli-0.31.3}/MANIFEST.in +0 -0
  19. {evo_cli-0.31.1 → evo_cli-0.31.3}/README.md +0 -0
  20. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/__init__.py +0 -0
  21. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/__main__.py +0 -0
  22. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/base.py +0 -0
  23. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/cli.py +0 -0
  24. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/__init__.py +0 -0
  25. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/agent_toy.py +0 -0
  26. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/claude_code.py +0 -0
  27. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/cloudflare.py +0 -0
  28. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/cred.py +0 -0
  29. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/download.py +0 -0
  30. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/fix_claude.py +0 -0
  31. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/gdrive.py +0 -0
  32. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/gh.py +0 -0
  33. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/__init__.py +0 -0
  34. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_dag.py +0 -0
  35. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_git.py +0 -0
  36. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_hub.py +0 -0
  37. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_mirror.py +0 -0
  38. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_model.py +0 -0
  39. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_mutate.py +0 -0
  40. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_paths.py +0 -0
  41. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_render.py +0 -0
  42. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/_server.py +0 -0
  43. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/check.py +0 -0
  44. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/clone.py +0 -0
  45. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/edit.py +0 -0
  46. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/pull.py +0 -0
  47. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/serve.py +0 -0
  48. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/show.py +0 -0
  49. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/views.py +0 -0
  50. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  51. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  52. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  53. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  54. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  55. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  56. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  57. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  58. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  59. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  60. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  61. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  62. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  63. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  64. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/harness/web/index.html +0 -0
  65. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/hwid.py +0 -0
  66. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/hwid_reset.py +0 -0
  67. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/image.py +0 -0
  68. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/localproxy.py +0 -0
  69. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/mcp.py +0 -0
  70. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/miniconda.py +0 -0
  71. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/netcheck.py +0 -0
  72. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/ngrok.py +0 -0
  73. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/opencode.py +0 -0
  74. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/openvpn.py +0 -0
  75. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/plantuml.py +0 -0
  76. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/_dns.py +0 -0
  77. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/_hosts.py +0 -0
  78. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/check.py +0 -0
  79. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/route/probe.py +0 -0
  80. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/serp.py +0 -0
  81. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/site2s.py +0 -0
  82. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/ssh.py +0 -0
  83. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/storage.py +0 -0
  84. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/sysmon.py +0 -0
  85. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/tts.py +0 -0
  86. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/commands/wifi.py +0 -0
  87. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/console.py +0 -0
  88. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/__init__.py +0 -0
  89. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/doctor.py +0 -0
  90. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/google_oauth.py +0 -0
  91. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/migrate.py +0 -0
  92. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/oauth_flow.py +0 -0
  93. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/registry.py +0 -0
  94. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/store.py +0 -0
  95. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/credentials/sync.py +0 -0
  96. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/__init__.py +0 -0
  97. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/align.py +0 -0
  98. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/core.py +0 -0
  99. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/creds.py +0 -0
  100. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/errors.py +0 -0
  101. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/gemini.py +0 -0
  102. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/imaging/ncnn.py +0 -0
  103. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/mcp_registry.py +0 -0
  104. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/openvpn.py +0 -0
  105. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/__init__.py +0 -0
  106. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/client.py +0 -0
  107. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/creds.py +0 -0
  108. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/errors.py +0 -0
  109. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/install.py +0 -0
  110. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/serp/render.py +0 -0
  111. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/__init__.py +0 -0
  112. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/chunking.py +0 -0
  113. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/core.py +0 -0
  114. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/creds.py +0 -0
  115. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/errors.py +0 -0
  116. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/gemini.py +0 -0
  117. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/http.py +0 -0
  118. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/openai.py +0 -0
  119. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/player.py +0 -0
  120. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli/tts/vbee.py +0 -0
  121. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/dependency_links.txt +0 -0
  122. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/entry_points.txt +0 -0
  123. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/requires.txt +0 -0
  124. {evo_cli-0.31.1 → evo_cli-0.31.3}/evo_cli.egg-info/top_level.txt +0 -0
  125. {evo_cli-0.31.1 → evo_cli-0.31.3}/pyproject.toml +0 -0
  126. {evo_cli-0.31.1 → evo_cli-0.31.3}/setup.cfg +0 -0
  127. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/__init__.py +0 -0
  128. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/fake_evo_agents.py +0 -0
  129. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  130. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  131. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_cli.py +0 -0
  134. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_console.py +0 -0
  135. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_cred.py +0 -0
  136. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_download.py +0 -0
  137. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_gh.py +0 -0
  140. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_harness.py +0 -0
  141. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_harness_hub.py +0 -0
  144. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_harness_show.py +0 -0
  145. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_image.py +0 -0
  146. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_mcp.py +0 -0
  147. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_ngrok.py +0 -0
  148. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_opencode.py +0 -0
  149. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_openvpn.py +0 -0
  150. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_plantuml.py +0 -0
  151. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_serp.py +0 -0
  152. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_storage.py +0 -0
  153. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_sysmon.py +0 -0
  154. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_tts.py +0 -0
  155. {evo_cli-0.31.1 → evo_cli-0.31.3}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.31.1
3
+ Version: 0.31.3
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.31.3
@@ -20,7 +20,9 @@ edit can touch it.
20
20
  - `status` - which fixes are currently in place
21
21
 
22
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.
23
+ `close` edit hosts or manage a service, so they want administrator rights. On
24
+ macOS the DPI bypass is SpoofDPI run as your own LaunchAgent behind the system
25
+ proxy, which needs none.
24
26
  """
25
27
 
26
28
 
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import os
5
5
  import subprocess
6
+ import sys
6
7
  import tempfile
7
8
  import zipfile
8
9
  from pathlib import Path
@@ -27,6 +28,11 @@ def is_windows():
27
28
  return os.name == "nt"
28
29
 
29
30
 
31
+ def is_macos():
32
+ """macOS gets SpoofDPI instead of GoodbyeDPI; see _spoofdpi."""
33
+ return sys.platform == "darwin"
34
+
35
+
30
36
  def is_admin():
31
37
  if not is_windows():
32
38
  return os.geteuid() == 0 if hasattr(os, "geteuid") else False
@@ -61,7 +67,7 @@ def _sc(*args, check=False):
61
67
 
62
68
  def service_state():
63
69
  if not is_windows():
64
- return {"installed": False, "running": False, "args": None, "reason": "windows only"}
70
+ return {"installed": False, "running": False, "args": None, "reason": "Windows and macOS only"}
65
71
  result = _sc("qc", SERVICE_NAME)
66
72
  if result.returncode != 0:
67
73
  return {"installed": False, "running": False, "args": None, "reason": None}
@@ -37,12 +37,38 @@ def tls_probe(ip, sni, timeout=6.0):
37
37
  handshake is allowed to finish, not whether the certificate matches the
38
38
  name we announced.
39
39
  """
40
+ return _tls_over(lambda sock: sock.connect((ip, PORT)), ip, sni, timeout)
41
+
42
+
43
+ def proxy_probe(host, proxy, timeout=6.0):
44
+ """Like ``tls_probe``, but through the HTTP proxy at ``proxy``: CONNECT first, then the handshake.
45
+
46
+ A raw socket never sees a bypass that lives in a proxy, so this is how to tell whether it works.
47
+ """
48
+
49
+ def connect(sock):
50
+ sock.connect(proxy)
51
+ sock.sendall(f"CONNECT {host}:{PORT} HTTP/1.1\r\nHost: {host}:{PORT}\r\n\r\n".encode())
52
+ reply = b""
53
+ while b"\r\n\r\n" not in reply:
54
+ chunk = sock.recv(4096)
55
+ if not chunk:
56
+ break
57
+ reply += chunk
58
+ status = reply.split(b"\r\n", 1)[0].decode(errors="replace")
59
+ if status.split()[1:2] != ["200"]:
60
+ raise OSError(f"proxy answered {status or 'nothing'}")
61
+
62
+ return _tls_over(connect, f"{proxy[0]}:{proxy[1]}", host, timeout)
63
+
64
+
65
+ def _tls_over(connect, ip, sni, timeout):
40
66
  result = {"ip": ip, "sni": sni, "tcp": False, "tls": False, "ms": None, "error": None, "reset": False}
41
67
  started = time.perf_counter()
42
68
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
43
69
  sock.settimeout(timeout)
44
70
  try:
45
- sock.connect((ip, PORT))
71
+ connect(sock)
46
72
  result["tcp"] = True
47
73
  context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
48
74
  context.check_hostname = False
@@ -0,0 +1,344 @@
1
+ """The DPI bypass on macOS: SpoofDPI behind the system proxy, run as a LaunchAgent.
2
+
3
+ GoodbyeDPI has no macOS build. SpoofDPI is a local HTTP proxy that splits the
4
+ TLS ClientHello, so the filter never reads the server name in one piece. It only
5
+ does that for the hosts `evo route open` named: every other name passes through
6
+ untouched and resolves with the system resolver, so names only a VPN knows keep
7
+ working. The named hosts resolve over DoH instead, because a name the filter
8
+ blocks usually has its DNS answer poisoned too.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import plistlib
16
+ import shutil
17
+ import socket
18
+ import subprocess
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from evo_cli.console import info, run_command
23
+
24
+ LABEL = "com.evo.route.spoofdpi"
25
+ LISTEN_HOST = "127.0.0.1"
26
+ # Off 8080, where development servers live.
27
+ LISTEN_PORT = 18080
28
+
29
+ # `first-byte` cuts the TLS record header off the rest of the ClientHello, and the
30
+ # filter does not put the two back together. SpoofDPI's default `sni` split, which
31
+ # cuts inside the server name, still gets the handshake reset (medium.com, 2026-10).
32
+ SPLIT_MODE = "first-byte"
33
+
34
+ CONFIG = f"""# Written by `evo route open`; the hosts it opened are listed in hosts.txt.
35
+ [dns]
36
+ mode = "system"
37
+
38
+ [https]
39
+ skip = true
40
+
41
+ [[rules]]
42
+ name = "evo route open"
43
+ priority = 50
44
+ match = {{ domains = ["file:hosts.txt"] }}
45
+ dns = {{ mode = "https", cache = true }}
46
+ https = {{ skip = false, split-mode = "{SPLIT_MODE}" }}
47
+ """
48
+
49
+ # networksetup names both proxies by these words: -getwebproxy, -setsecurewebproxystate, ...
50
+ PROXY_KINDS = ("webproxy", "securewebproxy")
51
+
52
+ # Chromium browsers, under ~/Library/Application Support; each profile keeps extension-held settings
53
+ # in its Secure Preferences.
54
+ BROWSERS = ("Google/Chrome", "BraveSoftware/Brave-Browser", "Microsoft Edge", "Chromium")
55
+
56
+
57
+ def home():
58
+ return Path(os.environ.get("EVO_ROUTE_HOME", str(Path.home() / ".evo" / "route")))
59
+
60
+
61
+ def config_path():
62
+ return home() / "spoofdpi.toml"
63
+
64
+
65
+ def hosts_path():
66
+ return home() / "hosts.txt"
67
+
68
+
69
+ def agent_path():
70
+ return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
71
+
72
+
73
+ def log_path():
74
+ return Path.home() / "Library" / "Logs" / "evo-route-spoofdpi.log"
75
+
76
+
77
+ def listen():
78
+ return f"{LISTEN_HOST}:{LISTEN_PORT}"
79
+
80
+
81
+ def opened():
82
+ """The hosts the bypass covers, as `evo route open` named them."""
83
+ try:
84
+ lines = hosts_path().read_text(encoding="utf-8").splitlines()
85
+ except FileNotFoundError:
86
+ return []
87
+ return [line for line in lines if line and not line.startswith(("#", "*."))]
88
+
89
+
90
+ def _write_hosts(hosts):
91
+ home().mkdir(parents=True, exist_ok=True)
92
+ lines = [pattern for host in sorted(set(hosts)) for pattern in (host, f"*.{host}")]
93
+ hosts_path().write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
94
+
95
+
96
+ def executable():
97
+ found = shutil.which("spoofdpi")
98
+ if found:
99
+ return found
100
+ # launchd and some shells start without Homebrew on PATH.
101
+ for candidate in ("/opt/homebrew/bin/spoofdpi", "/usr/local/bin/spoofdpi"):
102
+ if Path(candidate).exists():
103
+ return candidate
104
+ return None
105
+
106
+
107
+ def install():
108
+ brew = shutil.which("brew") or next(
109
+ (path for path in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew") if Path(path).exists()), None
110
+ )
111
+ if not brew:
112
+ raise RuntimeError("SpoofDPI is not installed and neither is Homebrew - install one of them first.")
113
+ info("SpoofDPI is not installed yet, installing it with Homebrew")
114
+ run_command([brew, "install", "spoofdpi"])
115
+ found = executable()
116
+ if not found:
117
+ raise RuntimeError("brew installed spoofdpi, but it is not on PATH.")
118
+ return found
119
+
120
+
121
+ def _program(exe):
122
+ return [exe, "--config", str(config_path()), "--no-tui", "--listen-addr", listen()]
123
+
124
+
125
+ def _write_agent(exe):
126
+ """Write the LaunchAgent; True when its content changed."""
127
+ plist = plistlib.dumps(
128
+ {
129
+ "Label": LABEL,
130
+ "ProgramArguments": _program(exe),
131
+ "RunAtLoad": True,
132
+ "KeepAlive": True,
133
+ "StandardOutPath": str(log_path()),
134
+ "StandardErrorPath": str(log_path()),
135
+ }
136
+ )
137
+ path = agent_path()
138
+ if path.exists() and path.read_bytes() == plist:
139
+ return False
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ log_path().parent.mkdir(parents=True, exist_ok=True)
142
+ path.write_bytes(plist)
143
+ return True
144
+
145
+
146
+ def _launchctl(*args):
147
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False)
148
+
149
+
150
+ def _domain():
151
+ return f"gui/{os.getuid()}"
152
+
153
+
154
+ def _loaded():
155
+ return _launchctl("print", f"{_domain()}/{LABEL}").returncode == 0
156
+
157
+
158
+ def _listening(timeout=0.5):
159
+ try:
160
+ with socket.create_connection((LISTEN_HOST, LISTEN_PORT), timeout=timeout):
161
+ return True
162
+ except OSError:
163
+ return False
164
+
165
+
166
+ def _wait(condition, seconds):
167
+ deadline = time.monotonic() + seconds
168
+ while time.monotonic() < deadline:
169
+ if condition():
170
+ return True
171
+ time.sleep(0.2)
172
+ return condition()
173
+
174
+
175
+ def _start(reload):
176
+ """Start or restart the agent and wait for the proxy port. SpoofDPI reads hosts.txt only when it starts."""
177
+ if _loaded() and not reload:
178
+ _launchctl("kickstart", "-k", f"{_domain()}/{LABEL}")
179
+ else:
180
+ if _loaded():
181
+ _launchctl("bootout", f"{_domain()}/{LABEL}")
182
+ _wait(lambda: not _listening(), 5)
183
+ if _listening():
184
+ raise RuntimeError(f"Something else already listens on {listen()}.")
185
+ result = _launchctl("bootstrap", _domain(), str(agent_path()))
186
+ if result.returncode != 0:
187
+ raise RuntimeError(f"launchctl bootstrap failed: {(result.stderr or result.stdout).strip()}")
188
+ return _wait(_listening, 10)
189
+
190
+
191
+ def _networksetup(*args):
192
+ return subprocess.run(
193
+ ["networksetup", *args], capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False
194
+ )
195
+
196
+
197
+ def network_services():
198
+ """Enabled network services. The first line of the listing explains that `*` marks a disabled one."""
199
+ lines = _networksetup("-listallnetworkservices").stdout.splitlines()[1:]
200
+ return [line for line in lines if line.strip() and not line.startswith("*")]
201
+
202
+
203
+ def _proxy(service, kind):
204
+ fields = {}
205
+ for line in _networksetup(f"-get{kind}", service).stdout.splitlines():
206
+ key, _, value = line.partition(":")
207
+ fields[key.strip()] = value.strip()
208
+ return fields.get("Enabled") == "Yes", fields.get("Server", ""), fields.get("Port", "")
209
+
210
+
211
+ def _ours(server, port):
212
+ return server == LISTEN_HOST and port == str(LISTEN_PORT)
213
+
214
+
215
+ def proxied_services():
216
+ """The services whose system proxy points at the bypass."""
217
+ services = []
218
+ for service in network_services():
219
+ proxies = [_proxy(service, kind) for kind in PROXY_KINDS]
220
+ if any(enabled and _ours(server, port) for enabled, server, port in proxies):
221
+ services.append(service)
222
+ return services
223
+
224
+
225
+ def _proxy_conflicts():
226
+ conflicts = []
227
+ for service in network_services():
228
+ for kind in PROXY_KINDS:
229
+ enabled, server, port = _proxy(service, kind)
230
+ if enabled and not _ours(server, port):
231
+ conflicts.append(f"{service} ({server}:{port})")
232
+ return conflicts
233
+
234
+
235
+ def set_system_proxy():
236
+ services = network_services()
237
+ for service in services:
238
+ for kind in PROXY_KINDS:
239
+ result = _networksetup(f"-set{kind}", service, LISTEN_HOST, str(LISTEN_PORT))
240
+ if result.returncode != 0:
241
+ raise RuntimeError(
242
+ f"networksetup -set{kind} {service} failed: {(result.stderr or result.stdout).strip()}"
243
+ )
244
+ return services
245
+
246
+
247
+ def clear_system_proxy():
248
+ """Turn off only the proxies that point at the bypass; one the user set stays as it was."""
249
+ cleared = []
250
+ for service in network_services():
251
+ for kind in PROXY_KINDS:
252
+ enabled, server, port = _proxy(service, kind)
253
+ if enabled and _ours(server, port):
254
+ _networksetup(f"-set{kind}state", service, "off")
255
+ if service not in cleared:
256
+ cleared.append(service)
257
+ return cleared
258
+
259
+
260
+ def _extension_name(profile, ext_id):
261
+ for path in sorted((profile / "Extensions" / ext_id).glob("*/manifest.json")):
262
+ try:
263
+ manifest = json.loads(path.read_text(encoding="utf-8-sig"))
264
+ name = manifest.get("name", "")
265
+ if name.startswith("__MSG_"):
266
+ # A localised name: "__MSG_key__" looked up, ignoring case, in the default locale's messages.
267
+ locale = manifest.get("default_locale", "en")
268
+ messages = json.loads(
269
+ (path.parent / "_locales" / locale / "messages.json").read_text(encoding="utf-8-sig")
270
+ )
271
+ key = name[len("__MSG_") : -len("__")].lower()
272
+ name = next((value["message"] for k, value in messages.items() if k.lower() == key), "")
273
+ except (OSError, ValueError, KeyError, TypeError):
274
+ continue
275
+ if name:
276
+ return name
277
+ return ext_id
278
+
279
+
280
+ def browser_overrides(support=None):
281
+ """Enabled browser extensions that hold the proxy setting, so their browser never sees the system proxy.
282
+
283
+ A VPN extension keeps holding it while switched off, set to `direct`.
284
+ """
285
+ support = Path(support or Path.home() / "Library" / "Application Support")
286
+ found = []
287
+ for browser in BROWSERS:
288
+ for prefs in sorted((support / browser).glob("*/Secure Preferences")):
289
+ try:
290
+ settings = json.loads(prefs.read_text(encoding="utf-8"))["extensions"]["settings"]
291
+ except (OSError, ValueError, KeyError, TypeError):
292
+ continue
293
+ for ext_id, ext in settings.items():
294
+ enabled = not ext.get("disable_reasons") and ext.get("state", 1) != 0
295
+ if enabled and "proxy" in ext.get("preferences", {}):
296
+ name = _extension_name(prefs.parent, ext_id)
297
+ found.append(f"{browser.split('/')[-1]} ({prefs.parent.name}): {name}")
298
+ return found
299
+
300
+
301
+ def service_state():
302
+ exe = executable()
303
+ return {
304
+ "installed": agent_path().exists(),
305
+ "running": _loaded() and _listening(),
306
+ "args": " ".join(_program(exe or "spoofdpi")),
307
+ "reason": None,
308
+ "hosts": opened(),
309
+ "proxy": proxied_services(),
310
+ }
311
+
312
+
313
+ def ensure(host):
314
+ """Put ``host`` through the bypass: install SpoofDPI, (re)start its agent, point the system proxy at it."""
315
+ conflicts = _proxy_conflicts()
316
+ if conflicts:
317
+ raise RuntimeError(f"A proxy is already set on {', '.join(conflicts)}; evo will not replace it.")
318
+ exe = executable() or install()
319
+ home().mkdir(parents=True, exist_ok=True)
320
+ config_path().write_text(CONFIG, encoding="utf-8")
321
+ _write_hosts([*opened(), host])
322
+ if not _start(reload=_write_agent(exe)):
323
+ return service_state()
324
+ set_system_proxy()
325
+ return service_state()
326
+
327
+
328
+ def forget(host):
329
+ """Take ``host`` out of the bypass; the last one out stops it altogether."""
330
+ hosts = [name for name in opened() if name != host]
331
+ if not hosts:
332
+ return remove()
333
+ _write_hosts(hosts)
334
+ _start(reload=False)
335
+ return True
336
+
337
+
338
+ def remove():
339
+ """Clear the system proxy first, so nothing points at a proxy that is gone, then stop and drop the agent."""
340
+ clear_system_proxy()
341
+ _launchctl("bootout", f"{_domain()}/{LABEL}")
342
+ agent_path().unlink(missing_ok=True)
343
+ hosts_path().unlink(missing_ok=True)
344
+ return True
@@ -5,7 +5,7 @@ import sys
5
5
  import rich_click as click
6
6
  from rich.text import Text
7
7
 
8
- from evo_cli.commands.route import _dpi, _hosts, _probe
8
+ from evo_cli.commands.route import _dpi, _hosts, _probe, _spoofdpi
9
9
  from evo_cli.console import console, error, info, step, success, warning
10
10
 
11
11
  OPEN_EPILOG = Text.from_markup(
@@ -16,12 +16,13 @@ OPEN_EPILOG = Text.from_markup(
16
16
  " [cyan]evo route open x.com --method dpi[/cyan] force the DPI bypass\n\n"
17
17
  "[dim]Diagnoses first, then applies only the fix that matches: pinning an address\n"
18
18
  "for a poisoned name, or the packet-level bypass for a name-keyed block.\n"
19
- "Needs administrator rights because it edits hosts / installs a service.[/dim]"
19
+ "Needs administrator rights because it edits hosts / installs a service;\n"
20
+ "on macOS the bypass is SpoofDPI as your own LaunchAgent and needs no sudo.[/dim]"
20
21
  )
21
22
 
22
23
  CLOSE_EPILOG = Text.from_markup(
23
24
  "[bold]Examples[/bold]\n\n"
24
- " [cyan]evo route close medium.com[/cyan] drop that one pinned address\n"
25
+ " [cyan]evo route close medium.com[/cyan] drop its pin, or take it out of the bypass\n"
25
26
  " [cyan]evo route close --all[/cyan] drop every pin evo added\n"
26
27
  " [cyan]evo route close --dpi[/cyan] stop and remove the DPI bypass\n\n"
27
28
  "[dim]Only touches the block evo owns, marked in hosts by `# >>> evo route >>>`.\n"
@@ -51,9 +52,34 @@ def _apply_hosts(report, dry_run):
51
52
  return best
52
53
 
53
54
 
55
+ def _apply_spoofdpi(host, dry_run):
56
+ if dry_run:
57
+ state = _spoofdpi.service_state()
58
+ if state["running"] and host in state["hosts"]:
59
+ info(f"{host} already goes through the DPI bypass - nothing to do.")
60
+ else:
61
+ info(f"Would run SpoofDPI on {_spoofdpi.listen()} as the system proxy, splitting the handshake for {host}")
62
+ return None
63
+ if _dpi.is_admin():
64
+ error("Run this without sudo: SpoofDPI runs as your own LaunchAgent and sets your proxy.")
65
+ sys.exit(1)
66
+ try:
67
+ state = _spoofdpi.ensure(host)
68
+ except RuntimeError as exc:
69
+ error(str(exc))
70
+ sys.exit(1)
71
+ if not state["running"]:
72
+ error(f"SpoofDPI would not start - see {_spoofdpi.log_path()}")
73
+ sys.exit(1)
74
+ success(f"DPI bypass is running on {_spoofdpi.listen()}, system proxy set on {', '.join(state['proxy'])}")
75
+ return state
76
+
77
+
54
78
  def _apply_dpi(report, dry_run):
79
+ if _dpi.is_macos():
80
+ return _apply_spoofdpi(report["host"], dry_run)
55
81
  if not _dpi.is_windows():
56
- error("The DPI bypass is Windows-only. On Linux use a tunnel, or a tool like zapret.")
82
+ error("The DPI bypass runs on Windows and macOS. On Linux use a tunnel, or a tool like zapret.")
57
83
  sys.exit(1)
58
84
  if dry_run:
59
85
  state = _dpi.service_state()
@@ -121,6 +147,8 @@ def open_route(host, method, dry_run, timeout):
121
147
  return report
122
148
 
123
149
  step("Verify")
150
+ if method == "dpi" and _dpi.is_macos():
151
+ return _verify_through_proxy(host, timeout)
124
152
  with console.status(f"[info]re-checking {host}...[/info]", spinner="dots"):
125
153
  after = _probe.diagnose(host, timeout=timeout)
126
154
  if after["verdict"] in (_probe.OPEN, _probe.DNS_POISONED) and after["working"]:
@@ -130,6 +158,21 @@ def open_route(host, method, dry_run, timeout):
130
158
  return after
131
159
 
132
160
 
161
+ def _verify_through_proxy(host, timeout):
162
+ """The macOS bypass lives in a proxy, which a fresh diagnosis over raw sockets would never use."""
163
+ proxy = (_spoofdpi.LISTEN_HOST, _spoofdpi.LISTEN_PORT)
164
+ with console.status(f"[info]re-checking {host} through the proxy...[/info]", spinner="dots"):
165
+ row = _probe.proxy_probe(host, proxy, timeout=timeout)
166
+ if row["tls"]:
167
+ success(f"{host} is reachable through the DPI bypass")
168
+ info(f"Browsers pick it up from the system proxy; curl and other CLIs need -x http://{_spoofdpi.listen()}")
169
+ for override in _spoofdpi.browser_overrides():
170
+ warning(f"{override} holds the browser's proxy setting, so that browser skips the bypass - disable it")
171
+ else:
172
+ warning(f"{host} still fails through the bypass: {row['error']}. See {_spoofdpi.log_path()}")
173
+ return row
174
+
175
+
133
176
  @click.command("close", epilog=CLOSE_EPILOG)
134
177
  @click.argument("host", required=False)
135
178
  @click.option("--all", "drop_all", is_flag=True, help="Remove every address evo pinned.")
@@ -152,16 +195,26 @@ def close_route(host, drop_all, drop_dpi):
152
195
  _hosts.clear()
153
196
  success(f"Removed {len(entries)} pinned address(es)")
154
197
  elif host:
155
- _require_admin("Editing hosts")
156
- _, removed = _hosts.unpin(host)
157
- if removed:
198
+ closed = False
199
+ if _dpi.is_macos() and host in _spoofdpi.opened():
200
+ _spoofdpi.forget(host)
201
+ success(f"Took {host} out of the DPI bypass")
202
+ closed = True
203
+ if host in _hosts.read_block():
204
+ _require_admin("Editing hosts")
205
+ _hosts.unpin(host)
158
206
  success(f"Unpinned {host}")
159
- else:
160
- info(f"{host} was not pinned by evo - nothing changed.")
207
+ closed = True
208
+ if not closed:
209
+ info(f"{host} was not opened by evo - nothing changed.")
161
210
 
162
211
  if drop_dpi:
212
+ if _dpi.is_macos():
213
+ _spoofdpi.remove()
214
+ success("DPI bypass stopped and the system proxy cleared")
215
+ return
163
216
  if not _dpi.is_windows():
164
- error("The DPI bypass is Windows-only.")
217
+ error("The DPI bypass runs on Windows and macOS.")
165
218
  sys.exit(1)
166
219
  _require_admin("Removing the DPI bypass")
167
220
  if _dpi.remove():
@@ -6,7 +6,7 @@ import rich_click as click
6
6
  from rich.table import Table
7
7
  from rich.text import Text
8
8
 
9
- from evo_cli.commands.route import _dpi, _hosts
9
+ from evo_cli.commands.route import _dpi, _hosts, _spoofdpi
10
10
  from evo_cli.console import console, info, step, success
11
11
 
12
12
  EPILOG = Text.from_markup(
@@ -22,7 +22,7 @@ def collect():
22
22
  return {
23
23
  "hosts_file": str(_hosts.hosts_path()),
24
24
  "pinned": _hosts.read_block(),
25
- "dpi": _dpi.service_state(),
25
+ "dpi": _spoofdpi.service_state() if _dpi.is_macos() else _dpi.service_state(),
26
26
  "admin": _dpi.is_admin(),
27
27
  }
28
28
 
@@ -68,5 +68,9 @@ def status(as_json):
68
68
  else:
69
69
  info("Installed but stopped.")
70
70
  console.print(f" [dim]{dpi['args']}[/dim]")
71
+ if dpi.get("hosts"):
72
+ info(f"Hosts: {', '.join(dpi['hosts'])} - remove with `evo route close <host>`")
73
+ if dpi.get("proxy"):
74
+ info(f"System proxy set on {', '.join(dpi['proxy'])}")
71
75
 
72
76
  return state