evo-cli 0.34.2__tar.gz → 0.35.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.34.2 → evo_cli-0.35.0}/PKG-INFO +1 -1
  2. evo_cli-0.35.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/openvpn.py +6 -1
  4. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_spoofdpi.py +60 -3
  5. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/openvpn.py +83 -0
  6. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/PKG-INFO +1 -1
  7. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_openvpn.py +99 -1
  8. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_route.py +31 -1
  9. evo_cli-0.34.2/evo_cli/VERSION +0 -1
  10. {evo_cli-0.34.2 → evo_cli-0.35.0}/Containerfile +0 -0
  11. {evo_cli-0.34.2 → evo_cli-0.35.0}/HISTORY.md +0 -0
  12. {evo_cli-0.34.2 → evo_cli-0.35.0}/LICENSE +0 -0
  13. {evo_cli-0.34.2 → evo_cli-0.35.0}/MANIFEST.in +0 -0
  14. {evo_cli-0.34.2 → evo_cli-0.35.0}/README.md +0 -0
  15. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/__init__.py +0 -0
  16. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/__main__.py +0 -0
  17. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/base.py +0 -0
  18. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/cli.py +0 -0
  19. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/agent_toy.py +0 -0
  21. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/claude_code.py +0 -0
  22. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/cloudflare.py +0 -0
  23. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/cred.py +0 -0
  24. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/download.py +0 -0
  25. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/fix_claude.py +0 -0
  26. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/gdrive.py +0 -0
  27. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/gh.py +0 -0
  28. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/__init__.py +0 -0
  29. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_dag.py +0 -0
  30. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_git.py +0 -0
  31. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_hub.py +0 -0
  32. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_mirror.py +0 -0
  33. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_model.py +0 -0
  34. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_mutate.py +0 -0
  35. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_paths.py +0 -0
  36. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_render.py +0 -0
  37. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/_server.py +0 -0
  38. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/check.py +0 -0
  39. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/clone.py +0 -0
  40. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/edit.py +0 -0
  41. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/pull.py +0 -0
  42. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/serve.py +0 -0
  43. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/show.py +0 -0
  44. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/views.py +0 -0
  45. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  46. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  47. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  48. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  49. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  50. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  51. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  52. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  53. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  54. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  55. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  56. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  57. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  58. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  59. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/harness/web/index.html +0 -0
  60. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/hwid.py +0 -0
  61. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/hwid_reset.py +0 -0
  62. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/image.py +0 -0
  63. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/localproxy.py +0 -0
  64. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/mcp.py +0 -0
  65. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/miniconda.py +0 -0
  66. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/netcheck.py +0 -0
  67. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/ngrok.py +0 -0
  68. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/opencode.py +0 -0
  69. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/plantuml.py +0 -0
  70. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/__init__.py +0 -0
  71. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_dns.py +0 -0
  72. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_dpi.py +0 -0
  73. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_hosts.py +0 -0
  74. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_page.py +0 -0
  75. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/_probe.py +0 -0
  76. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/check.py +0 -0
  77. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/fix.py +0 -0
  78. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/probe.py +0 -0
  79. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/route/status.py +0 -0
  80. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/serp.py +0 -0
  81. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/site2s.py +0 -0
  82. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/ssh.py +0 -0
  83. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/storage.py +0 -0
  84. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/sysmon.py +0 -0
  85. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/tts.py +0 -0
  86. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/update.py +0 -0
  87. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/commands/wifi.py +0 -0
  88. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/console.py +0 -0
  89. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/__init__.py +0 -0
  90. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/doctor.py +0 -0
  91. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/google_oauth.py +0 -0
  92. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/migrate.py +0 -0
  93. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/oauth_flow.py +0 -0
  94. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/registry.py +0 -0
  95. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/store.py +0 -0
  96. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/credentials/sync.py +0 -0
  97. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/__init__.py +0 -0
  98. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/align.py +0 -0
  99. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/core.py +0 -0
  100. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/creds.py +0 -0
  101. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/errors.py +0 -0
  102. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/gemini.py +0 -0
  103. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/imaging/ncnn.py +0 -0
  104. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/mcp_registry.py +0 -0
  105. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/__init__.py +0 -0
  106. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/client.py +0 -0
  107. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/creds.py +0 -0
  108. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/errors.py +0 -0
  109. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/install.py +0 -0
  110. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/serp/render.py +0 -0
  111. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/__init__.py +0 -0
  112. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/chunking.py +0 -0
  113. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/core.py +0 -0
  114. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/creds.py +0 -0
  115. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/errors.py +0 -0
  116. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/gemini.py +0 -0
  117. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/http.py +0 -0
  118. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/openai.py +0 -0
  119. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/player.py +0 -0
  120. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli/tts/vbee.py +0 -0
  121. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/SOURCES.txt +0 -0
  122. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  123. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/entry_points.txt +0 -0
  124. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/requires.txt +0 -0
  125. {evo_cli-0.34.2 → evo_cli-0.35.0}/evo_cli.egg-info/top_level.txt +0 -0
  126. {evo_cli-0.34.2 → evo_cli-0.35.0}/pyproject.toml +0 -0
  127. {evo_cli-0.34.2 → evo_cli-0.35.0}/setup.cfg +0 -0
  128. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/__init__.py +0 -0
  129. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/fake_evo_agents.py +0 -0
  130. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  131. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  132. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_agent_toy.py +0 -0
  133. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_claude_code.py +0 -0
  134. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_cli.py +0 -0
  135. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_console.py +0 -0
  136. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_cred.py +0 -0
  137. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_download.py +0 -0
  138. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_fix_claude.py +0 -0
  139. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_gdrive.py +0 -0
  140. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_gh.py +0 -0
  141. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_harness.py +0 -0
  142. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_harness_clone.py +0 -0
  143. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_harness_dag.py +0 -0
  144. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_harness_hub.py +0 -0
  145. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_harness_show.py +0 -0
  146. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_image.py +0 -0
  147. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_mcp.py +0 -0
  148. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_ngrok.py +0 -0
  149. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_opencode.py +0 -0
  150. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_plantuml.py +0 -0
  151. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_serp.py +0 -0
  152. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_storage.py +0 -0
  153. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_sysmon.py +0 -0
  154. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_tts.py +0 -0
  155. {evo_cli-0.34.2 → evo_cli-0.35.0}/tests/test_update.py +0 -0
  156. {evo_cli-0.34.2 → evo_cli-0.35.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.34.2
3
+ Version: 0.35.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.35.0
@@ -390,7 +390,12 @@ def connect(name, timeout, sudo_password_stdin, watch):
390
390
  process.stdin.write((password + "\n").encode())
391
391
  process.stdin.close()
392
392
  password = ""
393
- click.echo(f"Connecting {name}; disconnect any external GUI VPN first. System DNS is not modified.")
393
+ dns = (
394
+ "Names the server resolves (split DNS) go to its DNS while connected; other DNS is unchanged."
395
+ if sys.platform == "darwin"
396
+ else "System DNS is not modified."
397
+ )
398
+ click.echo(f"Connecting {name}; disconnect any external GUI VPN first. {dns}")
394
399
  deadline = time.monotonic() + timeout + 20
395
400
  shown = None
396
401
  try:
@@ -3,9 +3,10 @@
3
3
  GoodbyeDPI has no macOS build. SpoofDPI is a local HTTP proxy that splits the
4
4
  TLS ClientHello, so the filter never reads the server name in one piece. It only
5
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.
6
+ untouched and resolves with the system's DNS server. The named hosts resolve over
7
+ DoH instead, because a name the filter blocks usually has its DNS answer poisoned
8
+ too. SpoofDPI's resolver never reads /etc/resolver, so names a connected evo VPN
9
+ resolves through split DNS skip the proxy altogether (`sync_vpn_bypass`).
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -46,6 +47,10 @@ dns = {{ mode = "https", cache = true }}
46
47
  https = {{ skip = false, split-mode = "{SPLIT_MODE}" }}
47
48
  """
48
49
 
50
+ # The evo openvpn DNS hook starts each /etc/resolver file it writes with this tag.
51
+ RESOLVER = Path("/etc/resolver")
52
+ VPN_TAG = "# evo openvpn "
53
+
49
54
  # networksetup names both proxies by these words: -getwebproxy, -setsecurewebproxystate, ...
50
55
  PROXY_KINDS = ("webproxy", "securewebproxy")
51
56
 
@@ -258,6 +263,56 @@ def clear_system_proxy():
258
263
  return cleared
259
264
 
260
265
 
266
+ def vpn_domains():
267
+ """Domains a connected evo VPN resolves with its own DNS: its tagged files under /etc/resolver."""
268
+ domains = []
269
+ for path in sorted(RESOLVER.glob("*")):
270
+ try:
271
+ with path.open(encoding="utf-8") as handle:
272
+ tagged = handle.readline().startswith(VPN_TAG)
273
+ except (OSError, UnicodeDecodeError):
274
+ continue
275
+ if tagged:
276
+ domains.append(path.name)
277
+ return domains
278
+
279
+
280
+ def _bypass(service):
281
+ lines = _networksetup("-getproxybypassdomains", service).stdout.splitlines()
282
+ return [line.strip() for line in lines if line.strip() and not line.startswith("There aren't any")]
283
+
284
+
285
+ def sync_vpn_bypass():
286
+ """Let the names a connected evo VPN resolves skip the proxy, and take them out again once it is down.
287
+
288
+ SpoofDPI would ask the system's DNS server for them and fail. Each service whose proxy is ours bypasses those
289
+ domains; what this added is recorded per service, so only that comes out again and a user's entries stay.
290
+ """
291
+ record = home() / "vpn-bypass.json"
292
+ try:
293
+ added = json.loads(record.read_text())
294
+ except (OSError, ValueError):
295
+ added = {}
296
+ wanted = [entry for domain in vpn_domains() for entry in (domain, f"*.{domain}")]
297
+ proxied = proxied_services()
298
+ now = {}
299
+ for service in network_services():
300
+ mine = added.get(service, [])
301
+ if not mine and service not in proxied:
302
+ continue
303
+ current = _bypass(service)
304
+ kept = [entry for entry in current if entry not in mine]
305
+ new = [entry for entry in wanted if entry not in kept] if service in proxied else []
306
+ if kept + new != current:
307
+ _networksetup("-setproxybypassdomains", service, *(kept + new or ["Empty"]))
308
+ if new:
309
+ now[service] = new
310
+ if now or added:
311
+ home().mkdir(parents=True, exist_ok=True)
312
+ record.write_text(json.dumps(now))
313
+ return now
314
+
315
+
261
316
  def _extension_name(profile, ext_id):
262
317
  for path in sorted((profile / "Extensions" / ext_id).glob("*/manifest.json")):
263
318
  try:
@@ -323,6 +378,7 @@ def ensure(*hosts):
323
378
  if not _start(reload=_write_agent(exe)):
324
379
  return service_state()
325
380
  set_system_proxy()
381
+ sync_vpn_bypass()
326
382
  return service_state()
327
383
 
328
384
 
@@ -339,6 +395,7 @@ def forget(host):
339
395
  def remove():
340
396
  """Clear the system proxy first, so nothing points at a proxy that is gone, then stop and drop the agent."""
341
397
  clear_system_proxy()
398
+ sync_vpn_bypass()
342
399
  _launchctl("bootout", f"{_domain()}/{LABEL}")
343
400
  agent_path().unlink(missing_ok=True)
344
401
  hosts_path().unlink(missing_ok=True)
@@ -290,6 +290,83 @@ def pin_remotes(command, remotes):
290
290
  return ["sh", "-c", script, "sh", *command]
291
291
 
292
292
 
293
+ # Run by OpenVPN as root (--route-up, --down) from a root-owned copy. It gives each domain a server resolves
294
+ # (pushed "dns server N resolve-domains") an /etc/resolver file naming that server, so macOS sends only those
295
+ # names into the tunnel. Files carry a tag line; a file without this profile's tag is never touched. Values come
296
+ # from the server, so only plain domains and addresses on port 53 are used, and eval sees only digits.
297
+ DNS_HOOK = r"""
298
+ umask 022
299
+ tag={tag}
300
+ dir={directory}
301
+ for f in "$dir"/*; do
302
+ [ -f "$f" ] && [ "$(head -n 1 "$f")" = "$tag" ] && rm -f "$f"
303
+ done
304
+ if [ "$1" = up ]; then
305
+ mkdir -p "$dir"
306
+ for n in $(env | sed -n 's/^dns_server_\([0-9]\{{1,3\}}\)_address_1=.*/\1/p' | sort -n); do
307
+ servers= m=1
308
+ while [ $m -le 8 ]; do
309
+ eval "a=\${{dns_server_${{n}}_address_$m-}} p=\${{dns_server_${{n}}_port_$m-53}}"
310
+ case $a$p in *[!0-9A-Fa-f:.]*) ;; *) [ -n "$a" ] && [ "$p" = 53 ] && servers="$servers $a" ;; esac
311
+ m=$((m + 1))
312
+ done
313
+ m=1
314
+ while eval "d=\${{dns_server_${{n}}_resolve_domain_$m-}}"; [ -n "$d" ]; do
315
+ m=$((m + 1))
316
+ [ -n "$servers" ] || continue
317
+ case $d in .*|*.|*..*|*[!A-Za-z0-9.-]*) continue ;; esac
318
+ [ -e "$dir/$d" ] || printf '%s\n' "$tag" > "$dir/$d"
319
+ [ "$(head -n 1 "$dir/$d")" = "$tag" ] || continue
320
+ for a in $servers; do printf 'nameserver %s\n' "$a" >> "$dir/$d"; done
321
+ done
322
+ done
323
+ fi
324
+ killall -HUP mDNSResponder 2>/dev/null
325
+ exit 0
326
+ """
327
+
328
+
329
+ def split_dns(command, name, hooks=Path("/var/run"), resolver=Path("/etc/resolver")):
330
+ """Wrap OpenVPN so the split DNS its server pushes applies while it runs, and is undone however it ends.
331
+
332
+ OpenVPN 2.6 leaves pushed DNS to scripts on macOS. The wrapper runs as root: it writes the hook where only
333
+ root can write (never a user-writable file run as root), lets OpenVPN call it after routes are up and on
334
+ close, and runs it once more after OpenVPN exits, so a crash leaves no resolver pointing into the tunnel.
335
+ """
336
+ hook = shlex.quote(str(hooks / f"evo-openvpn-{session_path(name).name}.sh"))
337
+ script = DNS_HOOK.format(tag=shlex.quote(f"# evo openvpn {name}"), directory=shlex.quote(str(resolver)))
338
+ wrapper = "; ".join(
339
+ [
340
+ f'rm -f {hook} && (umask 077 && printf "%s" "$1" > {hook}) || exit 1',
341
+ "shift",
342
+ # Leftovers of a run that died with its wrapper would send the remote's name into a tunnel not up.
343
+ f"/bin/sh {hook} down",
344
+ "trap : HUP INT TERM",
345
+ '"$@"',
346
+ "code=$?",
347
+ f"/bin/sh {hook} down",
348
+ f"rm -f {hook}",
349
+ 'exit "$code"',
350
+ ]
351
+ )
352
+ hooked = list(command)
353
+ hooked[hooked.index("--script-security") + 1] = "2"
354
+ hooked += ["--route-up", f"/bin/sh {hook} up", "--down", f"/bin/sh {hook} down"]
355
+ return ["sh", "-c", wrapper, "sh", script, *hooked]
356
+
357
+
358
+ def sync_proxy_bypass():
359
+ """On macOS, let the names this VPN resolves skip evo route's proxy; never fatal to the connection."""
360
+ if sys.platform != "darwin":
361
+ return
362
+ try:
363
+ from evo_cli.commands.route import _spoofdpi
364
+
365
+ _spoofdpi.sync_vpn_bypass()
366
+ except Exception:
367
+ pass
368
+
369
+
293
370
  def parse_totp(value):
294
371
  value = value.strip()
295
372
  settings = {"secret": value, "algorithm": "SHA1", "digits": 6, "period": 30}
@@ -657,6 +734,8 @@ def worker(name, timeout):
657
734
  command += ["--setenv", "IV_SSO", "openurl,webauth"]
658
735
  if ciphers := data_ciphers(profile["config"]):
659
736
  command += ["--data-ciphers", ciphers]
737
+ if sys.platform == "darwin":
738
+ command = split_dns(command, name)
660
739
  pinned = [] if takes_default_route(profile["config"]) else remotes
661
740
  if profile.get("otp_url"):
662
741
  pinned = [*pinned, urlparse(profile["otp_url"]).hostname]
@@ -744,6 +823,8 @@ def worker(name, timeout):
744
823
  state["since"] = time.time()
745
824
  state.pop("auth_url", None)
746
825
  state.pop("auth_expires", None)
826
+ # The DNS hook ran before CONNECTED (route-up); its domains now exist.
827
+ sync_proxy_bypass()
747
828
  # Once up, let OpenVPN retry for as long as the network is gone.
748
829
  deadline = float("inf")
749
830
  elif fields[1] != "CONNECTED":
@@ -795,6 +876,8 @@ def worker(name, timeout):
795
876
  "error": "OpenVPN did not exit; inspect processes manually.",
796
877
  }
797
878
  publish()
879
+ # OpenVPN has exited and its DNS hook has removed the domains; the proxy takes them back.
880
+ sync_proxy_bypass()
798
881
  selector.close()
799
882
  control.close()
800
883
  listener.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.34.2
3
+ Version: 0.35.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
@@ -18,6 +18,14 @@ CONFIG = "client\ndev tun\nremote vpn.example 1194 udp\nauth-user-pass\n<ca>\nte
18
18
  SECRET = base64.b32encode(b"12345678901234567890").decode()
19
19
 
20
20
 
21
+ @pytest.fixture(autouse=True)
22
+ def no_proxy_sync(monkeypatch):
23
+ """The worker would otherwise change this machine's proxy bypass list."""
24
+ synced = []
25
+ monkeypatch.setattr(vpn, "sync_proxy_bypass", lambda: synced.append(True))
26
+ return synced
27
+
28
+
21
29
  @pytest.fixture
22
30
  def store(tmp_path, monkeypatch):
23
31
  if sys.platform == "win32":
@@ -398,6 +406,94 @@ def test_worker_passes_the_profile_cipher(store, monkeypatch):
398
406
  assert command[command.index("--data-ciphers") + 1].endswith(":AES-256-CBC")
399
407
 
400
408
 
409
+ SPLIT_DNS_ENV = {
410
+ "dns_server_10_address_1": "10.0.0.1",
411
+ "dns_server_10_resolve_domain_1": "corp.example",
412
+ "dns_server_10_resolve_domain_2": "../evil",
413
+ "dns_server_10_resolve_domain_3": "a b",
414
+ "dns_server_10_resolve_domain_4": "mine.example",
415
+ "dns_server_11_address_1": "10.0.0.2",
416
+ "dns_server_11_resolve_domain_1": "corp.example",
417
+ "dns_server_12_address_1": "10.0.0.3",
418
+ "dns_server_12_port_1": "5353",
419
+ "dns_server_12_resolve_domain_1": "odd.example",
420
+ "dns_server_13_address_1": "10.0.0.4; touch pwned",
421
+ "dns_server_13_resolve_domain_1": "bad.example",
422
+ "dns_server_14_address_1": "10.0.0.5",
423
+ }
424
+
425
+
426
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell hook")
427
+ def test_dns_hook_writes_split_dns_and_removes_only_its_own(tmp_path):
428
+ resolver = tmp_path / "resolver"
429
+ resolver.mkdir()
430
+ (resolver / "mine.example").write_text("nameserver 192.0.2.1\n")
431
+ (resolver / "other.example").write_text("# evo openvpn other\nnameserver 192.0.2.2\n")
432
+ script = vpn.DNS_HOOK.format(tag="'# evo openvpn m1'", directory=str(resolver))
433
+
434
+ def hook(stage):
435
+ env = {"PATH": "/usr/bin:/bin", **SPLIT_DNS_ENV}
436
+ subprocess.run(["sh", "-c", script, "hook", stage, "utun9", "1420", "init"], env=env, cwd=tmp_path, check=True)
437
+
438
+ hook("up")
439
+ files = {path.name: path.read_text() for path in resolver.iterdir()}
440
+ # Both servers of a domain, in server order; no file for a bad name, port, address or a server without domains.
441
+ assert files["corp.example"] == "# evo openvpn m1\nnameserver 10.0.0.1\nnameserver 10.0.0.2\n"
442
+ assert set(files) == {"corp.example", "mine.example", "other.example"}
443
+ assert files["mine.example"] == "nameserver 192.0.2.1\n"
444
+ assert not (tmp_path / "evil").exists() and not (tmp_path / "pwned").exists()
445
+ hook("up") # a restart replaces its own files instead of appending to them
446
+ assert (resolver / "corp.example").read_text().count("nameserver") == 2
447
+ hook("down")
448
+ assert sorted(path.name for path in resolver.iterdir()) == ["mine.example", "other.example"]
449
+
450
+
451
+ FAKE_HOOKED_OPENVPN = """
452
+ import os, shlex, subprocess, sys
453
+ resolver = sys.argv[1]
454
+ args = sys.argv[2:]
455
+ assert not os.path.exists(os.path.join(resolver, "stale.example")), "leftover not cleared before start"
456
+ assert args[args.index("--script-security") + 1] == "2"
457
+ command = shlex.split(args[args.index("--route-up") + 1]) + ["utun9", "1420", "init"]
458
+ subprocess.run(command, env={**os.environ, "dns_server_10_address_1": "10.0.0.1",
459
+ "dns_server_10_resolve_domain_1": "corp.example"}, check=True)
460
+ assert open(os.path.join(resolver, "corp.example")).read().endswith("nameserver 10.0.0.1\\n")
461
+ sys.exit(7) # dies without running --down
462
+ """
463
+
464
+
465
+ def test_split_dns_wrapper_undoes_everything_when_openvpn_dies(store, tmp_path):
466
+ hooks, resolver = tmp_path / "run", tmp_path / "resolver"
467
+ hooks.mkdir()
468
+ resolver.mkdir()
469
+ (resolver / "stale.example").write_text("# evo openvpn m1\nnameserver 10.9.9.9\n")
470
+ command = [sys.executable, "-c", FAKE_HOOKED_OPENVPN, str(resolver), "--script-security", "1", "--verb", "3"]
471
+ wrapped = vpn.split_dns(command, "m1", hooks=hooks, resolver=resolver)
472
+ result = subprocess.run(wrapped, capture_output=True, text=True, check=False)
473
+ assert result.returncode == 7, result.stderr
474
+ assert list(resolver.iterdir()) == [] and list(hooks.iterdir()) == []
475
+
476
+
477
+ @pytest.mark.parametrize("platform,hooked", [("darwin", True), ("linux", False)])
478
+ def test_worker_applies_split_dns_on_macos_only(store, monkeypatch, platform, hooked):
479
+ vpn.save_profile("m1", {"config": CERT_ONLY})
480
+ monkeypatch.setattr(sys, "stdin", io.StringIO("\n"))
481
+ monkeypatch.setattr(vpn.os, "geteuid", lambda: 0)
482
+ monkeypatch.setattr(vpn.shutil, "which", lambda _: "/fake/openvpn")
483
+ monkeypatch.setattr(vpn.sys, "platform", platform)
484
+ monkeypatch.setattr(vpn, "pin_remotes", lambda command, hosts: command)
485
+ started = []
486
+
487
+ def popen(command, **_):
488
+ started.append(command)
489
+ raise OSError("stop before running anything")
490
+
491
+ monkeypatch.setattr(vpn.subprocess, "Popen", popen)
492
+ vpn.worker("m1", 10)
493
+ assert ("--route-up" in started[0]) == hooked
494
+ assert started[0][started[0].index("--script-security") + 1] == ("2" if hooked else "1")
495
+
496
+
401
497
  @pytest.mark.parametrize(
402
498
  "message,expected",
403
499
  [
@@ -555,7 +651,7 @@ with socket.socket(socket.AF_UNIX) as s:
555
651
 
556
652
 
557
653
  @pytest.mark.parametrize("mode", ["ok", "fail", "reconnect", "webauth"])
558
- def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode):
654
+ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode, no_proxy_sync):
559
655
  fail, webauth = mode == "fail", mode == "webauth"
560
656
  config = CERT_ONLY if webauth else CONFIG
561
657
  vpn.save_profile(
@@ -602,6 +698,8 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
602
698
  thread.join(5)
603
699
  assert not thread.is_alive()
604
700
  assert opened == (["https://vpn.example/auth/1"] if webauth else [])
701
+ # The proxy bypass follows the DNS hook: on each CONNECTED, and once more after OpenVPN exits.
702
+ assert len(no_proxy_sync) == {"fail": 1, "reconnect": 3}.get(mode, 2)
605
703
  assert vpn.request("m1")["state"] == "STOPPED"
606
704
  base = vpn.session_path("m1")
607
705
  state = json.loads(Path(str(base) + ".json").read_text())
@@ -254,6 +254,36 @@ def _fake_networksetup(proxies, calls):
254
254
  return run
255
255
 
256
256
 
257
+ def test_vpn_split_dns_domains_skip_the_proxy_and_only_ours_come_out(tmp_path, monkeypatch):
258
+ monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path / "route"))
259
+ resolver = tmp_path / "resolver"
260
+ resolver.mkdir()
261
+ monkeypatch.setattr(_spoofdpi, "RESOLVER", resolver)
262
+ (resolver / "corp.example").write_text("# evo openvpn m1\nnameserver 10.0.0.1\n")
263
+ (resolver / "mine.example").write_text("nameserver 192.0.2.1\n")
264
+ ours = (True, _spoofdpi.LISTEN_HOST, str(_spoofdpi.LISTEN_PORT))
265
+ proxies = {("Wi-Fi", "webproxy"): ours, ("Wi-Fi", "securewebproxy"): ours}
266
+ bypass = {"Wi-Fi": ["*.local", "*.corp.example"], "USB LAN": ["*.local"]}
267
+ fake = _fake_networksetup(proxies, [])
268
+
269
+ def run(*args):
270
+ if args[0] == "-getproxybypassdomains":
271
+ out = "\n".join(bypass[args[1]]) or f"There aren't any bypass domains set on {args[1]}."
272
+ return subprocess.CompletedProcess(args, 0, out + "\n", "")
273
+ if args[0] == "-setproxybypassdomains":
274
+ bypass[args[1]] = [] if args[2:] == ("Empty",) else list(args[2:])
275
+ return subprocess.CompletedProcess(args, 0, "", "")
276
+ return fake(*args)
277
+
278
+ monkeypatch.setattr(_spoofdpi, "_networksetup", run)
279
+ # Only the tagged domain, only on the proxied service; the user's own *.corp.example is not added twice.
280
+ assert _spoofdpi.sync_vpn_bypass() == {"Wi-Fi": ["corp.example"]}
281
+ assert bypass == {"Wi-Fi": ["*.local", "*.corp.example", "corp.example"], "USB LAN": ["*.local"]}
282
+ (resolver / "corp.example").unlink() # the VPN is down
283
+ assert _spoofdpi.sync_vpn_bypass() == {}
284
+ assert bypass == {"Wi-Fi": ["*.local", "*.corp.example"], "USB LAN": ["*.local"]}
285
+
286
+
257
287
  def test_spoofdpi_host_list_round_trips_with_subdomains(tmp_path, monkeypatch):
258
288
  monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
259
289
  _spoofdpi._write_hosts(["medium.com", "x.com", "medium.com"])
@@ -429,7 +459,7 @@ def _fake_macos_bypass(monkeypatch, tmp_path, proxy_fails=()):
429
459
  monkeypatch.setattr(_dpi, "is_admin", lambda: False)
430
460
  monkeypatch.setattr(_probe, "diagnose", lambda host, timeout: {"host": host, "verdict": _probe.SNI_BLOCKED})
431
461
  monkeypatch.setattr(_spoofdpi, "ensure", ensure)
432
- monkeypatch.setattr(_spoofdpi, "browser_overrides", lambda: [])
462
+ monkeypatch.setattr(_spoofdpi, "browser_overrides", list)
433
463
  monkeypatch.setattr(
434
464
  _probe, "proxy_probe", lambda host, proxy, timeout=6.0: _probe_row(host, tls=host not in proxy_fails)
435
465
  )
@@ -1 +0,0 @@
1
- 0.34.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes