evo-cli 0.31.2__tar.gz → 0.32.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 (155) hide show
  1. {evo_cli-0.31.2 → evo_cli-0.32.0}/PKG-INFO +3 -1
  2. {evo_cli-0.31.2 → evo_cli-0.32.0}/README.md +2 -0
  3. evo_cli-0.32.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/openvpn.py +39 -14
  5. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/__init__.py +3 -1
  6. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_dpi.py +7 -1
  7. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_probe.py +27 -1
  8. evo_cli-0.32.0/evo_cli/commands/route/_spoofdpi.py +344 -0
  9. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/fix.py +63 -10
  10. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/status.py +6 -2
  11. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/openvpn.py +76 -24
  12. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/PKG-INFO +3 -1
  13. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/SOURCES.txt +1 -0
  14. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_openvpn.py +120 -5
  15. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_route.py +146 -1
  16. evo_cli-0.31.2/evo_cli/VERSION +0 -1
  17. {evo_cli-0.31.2 → evo_cli-0.32.0}/Containerfile +0 -0
  18. {evo_cli-0.31.2 → evo_cli-0.32.0}/HISTORY.md +0 -0
  19. {evo_cli-0.31.2 → evo_cli-0.32.0}/LICENSE +0 -0
  20. {evo_cli-0.31.2 → evo_cli-0.32.0}/MANIFEST.in +0 -0
  21. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/__init__.py +0 -0
  22. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/__main__.py +0 -0
  23. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/base.py +0 -0
  24. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/cli.py +0 -0
  25. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/__init__.py +0 -0
  26. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/agent_toy.py +0 -0
  27. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/claude_code.py +0 -0
  28. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/cloudflare.py +0 -0
  29. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/cred.py +0 -0
  30. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/download.py +0 -0
  31. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/fix_claude.py +0 -0
  32. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/gdrive.py +0 -0
  33. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/gh.py +0 -0
  34. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/__init__.py +0 -0
  35. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_dag.py +0 -0
  36. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_git.py +0 -0
  37. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_hub.py +0 -0
  38. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_mirror.py +0 -0
  39. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_model.py +0 -0
  40. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_mutate.py +0 -0
  41. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_paths.py +0 -0
  42. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_render.py +0 -0
  43. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_server.py +0 -0
  44. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/check.py +0 -0
  45. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/clone.py +0 -0
  46. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/edit.py +0 -0
  47. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/pull.py +0 -0
  48. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/serve.py +0 -0
  49. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/show.py +0 -0
  50. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/views.py +0 -0
  51. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  52. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  53. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  54. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  55. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  56. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  57. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  58. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  59. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  60. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  61. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  62. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  63. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  64. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  65. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/index.html +0 -0
  66. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/hwid.py +0 -0
  67. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/hwid_reset.py +0 -0
  68. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/image.py +0 -0
  69. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/localproxy.py +0 -0
  70. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/mcp.py +0 -0
  71. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/miniconda.py +0 -0
  72. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/netcheck.py +0 -0
  73. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/ngrok.py +0 -0
  74. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/opencode.py +0 -0
  75. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/plantuml.py +0 -0
  76. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_dns.py +0 -0
  77. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_hosts.py +0 -0
  78. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/check.py +0 -0
  79. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/probe.py +0 -0
  80. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/serp.py +0 -0
  81. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/site2s.py +0 -0
  82. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/ssh.py +0 -0
  83. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/storage.py +0 -0
  84. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/sysmon.py +0 -0
  85. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/tts.py +0 -0
  86. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/update.py +0 -0
  87. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/wifi.py +0 -0
  88. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/console.py +0 -0
  89. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/__init__.py +0 -0
  90. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/doctor.py +0 -0
  91. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/google_oauth.py +0 -0
  92. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/migrate.py +0 -0
  93. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/oauth_flow.py +0 -0
  94. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/registry.py +0 -0
  95. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/store.py +0 -0
  96. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/sync.py +0 -0
  97. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/__init__.py +0 -0
  98. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/align.py +0 -0
  99. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/core.py +0 -0
  100. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/creds.py +0 -0
  101. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/errors.py +0 -0
  102. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/gemini.py +0 -0
  103. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/ncnn.py +0 -0
  104. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/mcp_registry.py +0 -0
  105. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/__init__.py +0 -0
  106. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/client.py +0 -0
  107. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/creds.py +0 -0
  108. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/errors.py +0 -0
  109. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/install.py +0 -0
  110. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/render.py +0 -0
  111. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/__init__.py +0 -0
  112. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/chunking.py +0 -0
  113. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/core.py +0 -0
  114. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/creds.py +0 -0
  115. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/errors.py +0 -0
  116. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/gemini.py +0 -0
  117. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/http.py +0 -0
  118. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/openai.py +0 -0
  119. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/player.py +0 -0
  120. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/vbee.py +0 -0
  121. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  122. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/entry_points.txt +0 -0
  123. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/requires.txt +0 -0
  124. {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/top_level.txt +0 -0
  125. {evo_cli-0.31.2 → evo_cli-0.32.0}/pyproject.toml +0 -0
  126. {evo_cli-0.31.2 → evo_cli-0.32.0}/setup.cfg +0 -0
  127. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/__init__.py +0 -0
  128. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fake_evo_agents.py +0 -0
  129. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  130. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  131. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_cli.py +0 -0
  134. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_console.py +0 -0
  135. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_cred.py +0 -0
  136. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_download.py +0 -0
  137. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_gh.py +0 -0
  140. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness.py +0 -0
  141. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_hub.py +0 -0
  144. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_show.py +0 -0
  145. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_image.py +0 -0
  146. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_mcp.py +0 -0
  147. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_ngrok.py +0 -0
  148. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_opencode.py +0 -0
  149. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_plantuml.py +0 -0
  150. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_serp.py +0 -0
  151. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_storage.py +0 -0
  152. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_sysmon.py +0 -0
  153. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_tts.py +0 -0
  154. {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_update.py +0 -0
  155. {evo_cli-0.31.2 → evo_cli-0.32.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.31.2
3
+ Version: 0.32.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
@@ -159,6 +159,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
159
159
  a public repository or treat storing the password and OTP together as independent factors.
160
160
 
161
161
  Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
162
+ For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
163
+ https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
162
164
  `evo openvpn otp NAME` explicitly prints the current code; list and status never print
163
165
  secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
164
166
  with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
@@ -126,6 +126,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
126
126
  a public repository or treat storing the password and OTP together as independent factors.
127
127
 
128
128
  Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
129
+ For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
130
+ https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
129
131
  `evo openvpn otp NAME` explicitly prints the current code; list and status never print
130
132
  secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
131
133
  with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
@@ -0,0 +1 @@
1
+ 0.32.0
@@ -43,7 +43,7 @@ def _table(rows):
43
43
  state.get("vpn_ip", ""),
44
44
  (profile.get("remotes") or [""])[0],
45
45
  _creds(profile),
46
- "yes" if profile.get("totp") else "no",
46
+ "url" if profile.get("otp_url") else "yes" if profile.get("totp") else "no",
47
47
  )
48
48
  return table
49
49
 
@@ -58,6 +58,17 @@ def _creds(profile):
58
58
  return "no"
59
59
 
60
60
 
61
+ def _ask(name, command="status"):
62
+ """vpn.request for polling loops, where a missed 2s reply is no verdict.
63
+
64
+ A worker stops answering for a moment while it fetches an OTP or waits for OpenVPN to exit.
65
+ """
66
+ try:
67
+ return _guard(vpn.request, name, command)
68
+ except OSError:
69
+ return {"profile": name, "state": "UNKNOWN", "error": "Worker did not answer in time."}
70
+
71
+
61
72
  def _rows():
62
73
  profiles = _guard(vpn.profiles)
63
74
  return [(name, profiles[name], _guard(vpn.request, name)) for name in sorted(profiles)]
@@ -233,7 +244,9 @@ def import_profile(name, config, replace):
233
244
  click.echo(f"Imported {name} into evo cred: openvpn.profiles.{name}")
234
245
 
235
246
 
236
- @openvpn_group.command("credentials", help="Save username/password and optionally a TOTP enrollment; input is hidden.")
247
+ @openvpn_group.command(
248
+ "credentials", help="Save username/password and optionally an OTP source (TOTP or URL); input is hidden."
249
+ )
237
250
  @click.argument("name", required=False)
238
251
  @click.option("--username", help="VPN account name; password is prompted, never passed as an argument.")
239
252
  @click.option("--password-stdin", is_flag=True, help="Read VPN password from stdin rather than the hidden prompt.")
@@ -241,22 +254,34 @@ def import_profile(name, config, replace):
241
254
  @click.option(
242
255
  "--qr", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Read OTP from a local QR image."
243
256
  )
244
- def credentials(name, username, password_stdin, otp, qr):
245
- if otp and qr:
246
- raise click.UsageError("Use either --otp or --qr, not both.")
257
+ @click.option(
258
+ "--otp-url",
259
+ is_flag=True,
260
+ help="Prompt for a link that answers with the current OTP; each login sends the password (PIN) plus a fresh code.",
261
+ )
262
+ def credentials(name, username, password_stdin, otp, qr, otp_url):
263
+ if otp + bool(qr) + otp_url > 1:
264
+ raise click.UsageError("Use only one of --otp, --qr and --otp-url.")
247
265
  name = _pick(name)
248
266
  profile = _guard(vpn.get_profile, name)
249
267
  username = username or click.prompt("Username", default=profile.get("username", ""))
250
- password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass("VPN password (hidden): ")
268
+ label = "VPN password or PIN, without the OTP" if otp_url or profile.get("otp_url") else "VPN password"
269
+ password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass(f"{label} (hidden): ")
251
270
  if not password:
252
271
  raise click.ClickException("Empty password; nothing saved.")
253
272
  _guard(vpn.quote, username)
254
273
  _guard(vpn.quote, password)
255
274
  profile.update(username=username, password=password)
275
+ # One OTP source per profile.
256
276
  if qr:
257
277
  profile["totp"] = _guard(vpn.qr_totp, qr)
278
+ profile.pop("otp_url", None)
258
279
  elif otp:
259
280
  profile["totp"] = _guard(vpn.parse_totp, getpass.getpass("OTP enrollment secret/URI (hidden): "))
281
+ profile.pop("otp_url", None)
282
+ elif otp_url:
283
+ profile["otp_url"] = _guard(vpn.parse_otp_url, getpass.getpass("OTP URL (hidden): "))
284
+ profile.pop("totp", None)
260
285
  _guard(vpn.save_profile, name, profile)
261
286
  click.echo(f"Saved credentials for {name}. Sync explicitly with: evo cred sync push")
262
287
 
@@ -265,10 +290,10 @@ def credentials(name, username, password_stdin, otp, qr):
265
290
  @click.argument("name", required=False)
266
291
  def otp_code(name):
267
292
  name = _pick(name)
268
- settings = _guard(vpn.get_profile, name).get("totp")
269
- if not settings:
293
+ profile = _guard(vpn.get_profile, name)
294
+ if not profile.get("totp") and not profile.get("otp_url"):
270
295
  raise click.ClickException("No OTP enrollment for this profile.")
271
- click.echo(_guard(vpn.totp, settings))
296
+ click.echo(_guard(vpn.current_otp, profile))
272
297
 
273
298
 
274
299
  @openvpn_group.command("status", help="Query the live worker; stale status files never count as connected.")
@@ -303,13 +328,13 @@ def disconnect(name):
303
328
 
304
329
 
305
330
  def _disconnect(name):
306
- state = _guard(vpn.request, name, "disconnect")
331
+ state = _ask(name, "disconnect")
307
332
  if state["state"] == "STOPPED" and state.get("error"):
308
333
  click.echo(f"{name} was not running; last error: {state['error']}", err=True)
309
334
  deadline = time.monotonic() + 20
310
335
  while state["state"] != "STOPPED" and time.monotonic() < deadline:
311
336
  time.sleep(0.2)
312
- state = _guard(vpn.request, name)
337
+ state = _ask(name)
313
338
  if state["state"] != "STOPPED":
314
339
  raise click.ClickException(state.get("error") or "Disconnect not confirmed; inspect evo openvpn status.")
315
340
  click.echo(f"{name}: stopped (OpenVPN Connect profiles unchanged).")
@@ -369,7 +394,7 @@ def connect(name, timeout, sudo_password_stdin, watch):
369
394
  deadline = time.monotonic() + timeout + 20
370
395
  try:
371
396
  while time.monotonic() < deadline:
372
- state = _guard(vpn.request, name)
397
+ state = _ask(name)
373
398
  if state["state"] == "CONNECTED":
374
399
  click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
375
400
  break
@@ -380,8 +405,8 @@ def connect(name, timeout, sudo_password_stdin, watch):
380
405
  raise click.ClickException("OpenVPN worker exited before startup.")
381
406
  time.sleep(0.2)
382
407
  finally:
383
- if _guard(vpn.request, name)["state"] != "CONNECTED":
384
- _guard(vpn.request, name, "disconnect")
408
+ if _ask(name)["state"] != "CONNECTED":
409
+ _ask(name, "disconnect")
385
410
  if state["state"] != "CONNECTED":
386
411
  raise click.ClickException("Connection not confirmed before timeout; disconnect requested.")
387
412
  _guard(vpn.remember, name)
@@ -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