evo-cli 0.27.0__tar.gz → 0.28.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. {evo_cli-0.27.0 → evo_cli-0.28.1}/PKG-INFO +9 -3
  2. {evo_cli-0.27.0 → evo_cli-0.28.1}/README.md +8 -2
  3. evo_cli-0.28.1/evo_cli/VERSION +1 -0
  4. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/openvpn.py +14 -5
  5. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/openvpn.py +154 -12
  6. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/PKG-INFO +9 -3
  7. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_openvpn.py +169 -3
  8. evo_cli-0.27.0/evo_cli/VERSION +0 -1
  9. {evo_cli-0.27.0 → evo_cli-0.28.1}/Containerfile +0 -0
  10. {evo_cli-0.27.0 → evo_cli-0.28.1}/HISTORY.md +0 -0
  11. {evo_cli-0.27.0 → evo_cli-0.28.1}/LICENSE +0 -0
  12. {evo_cli-0.27.0 → evo_cli-0.28.1}/MANIFEST.in +0 -0
  13. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/__init__.py +0 -0
  14. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/__main__.py +0 -0
  15. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/base.py +0 -0
  16. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/cli.py +0 -0
  17. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/agent_toy.py +0 -0
  19. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/claude_code.py +0 -0
  20. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/cred.py +0 -0
  22. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/__init__.py +0 -0
  27. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_dag.py +0 -0
  28. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_git.py +0 -0
  29. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_model.py +0 -0
  30. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_mutate.py +0 -0
  31. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_paths.py +0 -0
  32. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_render.py +0 -0
  33. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/_server.py +0 -0
  34. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/check.py +0 -0
  35. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/clone.py +0 -0
  36. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/edit.py +0 -0
  37. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/pull.py +0 -0
  38. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/serve.py +0 -0
  39. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/show.py +0 -0
  40. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/views.py +0 -0
  41. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  42. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  43. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  44. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  45. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  46. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  47. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  48. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  49. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  50. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  51. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  52. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  53. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  54. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  55. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/harness/web/index.html +0 -0
  56. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/hwid.py +0 -0
  57. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/hwid_reset.py +0 -0
  58. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/image.py +0 -0
  59. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/localproxy.py +0 -0
  60. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/mcp.py +0 -0
  61. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/miniconda.py +0 -0
  62. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/netcheck.py +0 -0
  63. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/ngrok.py +0 -0
  64. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/opencode.py +0 -0
  65. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/plantuml.py +0 -0
  66. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/__init__.py +0 -0
  67. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/_dns.py +0 -0
  68. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/_dpi.py +0 -0
  69. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/_hosts.py +0 -0
  70. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/_probe.py +0 -0
  71. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/check.py +0 -0
  72. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/fix.py +0 -0
  73. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/probe.py +0 -0
  74. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/route/status.py +0 -0
  75. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/serp.py +0 -0
  76. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/site2s.py +0 -0
  77. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/ssh.py +0 -0
  78. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/storage.py +0 -0
  79. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/sysmon.py +0 -0
  80. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/tts.py +0 -0
  81. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/update.py +0 -0
  82. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/commands/wifi.py +0 -0
  83. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/console.py +0 -0
  84. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/__init__.py +0 -0
  85. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/doctor.py +0 -0
  86. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/google_oauth.py +0 -0
  87. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/migrate.py +0 -0
  88. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/oauth_flow.py +0 -0
  89. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/registry.py +0 -0
  90. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/store.py +0 -0
  91. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/credentials/sync.py +0 -0
  92. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/__init__.py +0 -0
  93. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/align.py +0 -0
  94. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/core.py +0 -0
  95. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/creds.py +0 -0
  96. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/errors.py +0 -0
  97. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/gemini.py +0 -0
  98. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/imaging/ncnn.py +0 -0
  99. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/mcp_registry.py +0 -0
  100. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/__init__.py +0 -0
  101. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/client.py +0 -0
  102. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/creds.py +0 -0
  103. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/errors.py +0 -0
  104. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/install.py +0 -0
  105. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/serp/render.py +0 -0
  106. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/__init__.py +0 -0
  107. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/chunking.py +0 -0
  108. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/core.py +0 -0
  109. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/creds.py +0 -0
  110. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/errors.py +0 -0
  111. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/gemini.py +0 -0
  112. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/http.py +0 -0
  113. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/openai.py +0 -0
  114. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/player.py +0 -0
  115. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli/tts/vbee.py +0 -0
  116. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/SOURCES.txt +0 -0
  117. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  118. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/entry_points.txt +0 -0
  119. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/requires.txt +0 -0
  120. {evo_cli-0.27.0 → evo_cli-0.28.1}/evo_cli.egg-info/top_level.txt +0 -0
  121. {evo_cli-0.27.0 → evo_cli-0.28.1}/pyproject.toml +0 -0
  122. {evo_cli-0.27.0 → evo_cli-0.28.1}/setup.cfg +0 -0
  123. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/__init__.py +0 -0
  124. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_agent_toy.py +0 -0
  125. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_claude_code.py +0 -0
  126. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_cli.py +0 -0
  127. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_console.py +0 -0
  128. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_cred.py +0 -0
  129. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_download.py +0 -0
  130. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_fix_claude.py +0 -0
  131. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_gdrive.py +0 -0
  132. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_gh.py +0 -0
  133. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_harness.py +0 -0
  134. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_harness_clone.py +0 -0
  135. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_harness_dag.py +0 -0
  136. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_image.py +0 -0
  137. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_mcp.py +0 -0
  138. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_ngrok.py +0 -0
  139. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_opencode.py +0 -0
  140. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_plantuml.py +0 -0
  141. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_route.py +0 -0
  142. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_serp.py +0 -0
  143. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_storage.py +0 -0
  144. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_sysmon.py +0 -0
  145. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_tts.py +0 -0
  146. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_update.py +0 -0
  147. {evo_cli-0.27.0 → evo_cli-0.28.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.27.0
3
+ Version: 0.28.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -170,8 +170,14 @@ supported. Only trusted, self-contained TUN client profiles are accepted; script
170
170
  plugins and external certificate/key files are refused. Runtime data uses
171
171
  `~/.evo/openvpn` (override with `EVO_OPENVPN_HOME`).
172
172
 
173
- Only one evo-managed tunnel runs at a time. Disconnect any GUI VPN before connecting
174
- through evo; GUI profiles are never removed or modified. Server routes are applied,
173
+ Several evo-managed tunnels can run at once, as long as at most one of them may take the
174
+ default route. A profile is split-tunnel when it has `route-nopull` or
175
+ `pull-filter ignore "redirect-gateway"` (add `route` lines for the networks it should
176
+ carry); any other profile might receive a pushed `redirect-gateway`, so `connect` refuses
177
+ to stack two of those. While a split-tunnel profile runs, its server is pinned to the
178
+ physical gateway with a host route, so a full-tunnel VPN connected before or after it
179
+ never carries its handshake. Disconnect any GUI VPN before connecting through evo; GUI
180
+ profiles are never removed or modified. Server routes are applied,
175
181
  but this backend does not change system DNS. Internal IP access works through the
176
182
  tunnel; private DNS names may need your organization's DNS configuration. `CONNECTED`
177
183
  confirms the tunnel, not the availability of a particular internal application.
@@ -137,8 +137,14 @@ supported. Only trusted, self-contained TUN client profiles are accepted; script
137
137
  plugins and external certificate/key files are refused. Runtime data uses
138
138
  `~/.evo/openvpn` (override with `EVO_OPENVPN_HOME`).
139
139
 
140
- Only one evo-managed tunnel runs at a time. Disconnect any GUI VPN before connecting
141
- through evo; GUI profiles are never removed or modified. Server routes are applied,
140
+ Several evo-managed tunnels can run at once, as long as at most one of them may take the
141
+ default route. A profile is split-tunnel when it has `route-nopull` or
142
+ `pull-filter ignore "redirect-gateway"` (add `route` lines for the networks it should
143
+ carry); any other profile might receive a pushed `redirect-gateway`, so `connect` refuses
144
+ to stack two of those. While a split-tunnel profile runs, its server is pinned to the
145
+ physical gateway with a host route, so a full-tunnel VPN connected before or after it
146
+ never carries its handshake. Disconnect any GUI VPN before connecting through evo; GUI
147
+ profiles are never removed or modified. Server routes are applied,
142
148
  but this backend does not change system DNS. Internal IP access works through the
143
149
  tunnel; private DNS names may need your organization's DNS configuration. `CONNECTED`
144
150
  confirms the tunnel, not the availability of a particular internal application.
@@ -0,0 +1 @@
1
+ 0.28.1
@@ -294,12 +294,14 @@ def disconnect(name):
294
294
 
295
295
  def _disconnect(name):
296
296
  state = _guard(vpn.request, name, "disconnect")
297
+ if state["state"] == "STOPPED" and state.get("error"):
298
+ click.echo(f"{name} was not running; last error: {state['error']}", err=True)
297
299
  deadline = time.monotonic() + 20
298
300
  while state["state"] != "STOPPED" and time.monotonic() < deadline:
299
301
  time.sleep(0.2)
300
302
  state = _guard(vpn.request, name)
301
303
  if state["state"] != "STOPPED":
302
- raise click.ClickException("Disconnect not confirmed; inspect evo openvpn status.")
304
+ raise click.ClickException(state.get("error") or "Disconnect not confirmed; inspect evo openvpn status.")
303
305
  click.echo(f"{name}: stopped (OpenVPN Connect profiles unchanged).")
304
306
 
305
307
 
@@ -324,10 +326,17 @@ def connect(name, timeout, sudo_password_stdin, watch):
324
326
  raise click.ClickException("Missing credentials; run evo openvpn credentials PROFILE.")
325
327
  if _guard(vpn.request, name)["state"] != "STOPPED":
326
328
  raise click.ClickException("This profile already has a worker; use status or disconnect first.")
327
- # Avoid unintentionally stacking two route sets. The external GUI is never controlled here.
328
- for other in _guard(vpn.profiles):
329
- if other != name and _guard(vpn.request, other)["state"] != "STOPPED":
330
- raise click.ClickException(f"Disconnect '{other}' first to avoid conflicting VPN routes.")
329
+ # Two tunnels that may both take the default route would fight over it; a split-tunnel profile stacks
330
+ # beside any other. The external GUI is never controlled here.
331
+ full = _guard(vpn.takes_default_route, profile["config"])
332
+ for other, data in _guard(vpn.profiles).items():
333
+ if other == name or _guard(vpn.request, other)["state"] == "STOPPED":
334
+ continue
335
+ if full and _guard(vpn.takes_default_route, data.get("config", "")):
336
+ raise click.ClickException(
337
+ f"Disconnect '{other}' first: both may take the default route. To run them together, add "
338
+ 'pull-filter ignore "redirect-gateway" (plus the routes you need) to one of them.'
339
+ )
331
340
  password = ""
332
341
  if os.geteuid() != 0:
333
342
  if sudo_password_stdin:
@@ -4,6 +4,7 @@ import base64
4
4
  import binascii
5
5
  import hashlib
6
6
  import hmac
7
+ import ipaddress
7
8
  import json
8
9
  import os
9
10
  import re
@@ -128,10 +129,9 @@ def save_profile(name, data):
128
129
  os.umask(previous_umask)
129
130
 
130
131
 
131
- def validate_config(text):
132
- """Validate without exposing private key material in errors; return endpoint metadata."""
132
+ def _directives(text):
133
+ """Yield (line number, words) per directive; inline blocks are skipped, never parsed."""
133
134
  block = None
134
- remotes = []
135
135
  for number, raw in enumerate(text.splitlines(), 1):
136
136
  line = raw.strip()
137
137
  if block:
@@ -149,6 +149,15 @@ def validate_config(text):
149
149
  words = shlex.split(line, comments=True)
150
150
  except ValueError:
151
151
  raise CredentialError(f"Invalid OpenVPN syntax at line {number}.") from None
152
+ yield number, words
153
+ if block:
154
+ raise CredentialError("Unclosed inline block in OpenVPN profile.")
155
+
156
+
157
+ def validate_config(text):
158
+ """Validate without exposing private key material in errors; return endpoint metadata."""
159
+ remotes = []
160
+ for number, words in _directives(text):
152
161
  option = words[0].removeprefix("--")
153
162
  if option not in OPTIONS:
154
163
  raise CredentialError(
@@ -164,13 +173,95 @@ def validate_config(text):
164
173
  if len(words) not in (2, 3, 4):
165
174
  raise CredentialError(f"Invalid remote at line {number}.")
166
175
  remotes.append(" ".join(words[1:]))
167
- if block:
168
- raise CredentialError("Unclosed inline block in OpenVPN profile.")
169
176
  if not remotes:
170
177
  raise CredentialError("No remote endpoint in OpenVPN profile.")
171
178
  return remotes
172
179
 
173
180
 
181
+ def takes_default_route(text):
182
+ """Whether a profile may take the default route. Only split-tunnel profiles can run beside another VPN.
183
+
184
+ Split means route-nopull, or a pull-filter that ignores a pushed redirect-gateway; anything else might
185
+ receive one, so it counts as full-tunnel.
186
+ """
187
+ split, filtered = False, False
188
+ for _, words in _directives(text):
189
+ option = words[0].removeprefix("--")
190
+ if option == "redirect-gateway":
191
+ return True
192
+ if option == "route-nopull":
193
+ split = True
194
+ # OpenVPN applies only the first pull-filter whose text starts the pushed option.
195
+ elif option == "pull-filter" and not filtered and len(words) == 3 and words[2]:
196
+ filtered = "redirect-gateway".startswith(words[2])
197
+ split = split or (filtered and words[1] == "ignore")
198
+ return not split
199
+
200
+
201
+ def default_gateway():
202
+ """The physical IPv4 gateway of the 0/0 route, which def1 VPN routes (0/1, 128/1) leave in place."""
203
+ if sys.platform == "darwin":
204
+ command, pattern = ["route", "-n", "get", "default"], r"gateway:\s*(\S+)[\s\S]*?interface:\s*(\S+)"
205
+ else:
206
+ command, pattern = ["ip", "-4", "route", "show", "default"], r"via\s+(\S+)\s+dev\s+(\S+)"
207
+ try:
208
+ output = subprocess.run(command, capture_output=True, text=True, timeout=5, check=False).stdout
209
+ except (OSError, subprocess.TimeoutExpired):
210
+ return None
211
+ match = re.search(pattern, output)
212
+ if not match or match.group(2).startswith(("tun", "utun")):
213
+ return None
214
+ try:
215
+ return str(ipaddress.IPv4Address(match.group(1)))
216
+ except ValueError:
217
+ return None
218
+
219
+
220
+ def remote_addresses(remotes):
221
+ """IPv4 addresses of the profile's servers, resolved just before OpenVPN resolves them itself."""
222
+ addresses = []
223
+ for remote in remotes:
224
+ try:
225
+ infos = socket.getaddrinfo(remote.split()[0], None, socket.AF_INET)
226
+ except OSError:
227
+ continue
228
+ for *_, (address, _) in infos:
229
+ address = str(ipaddress.IPv4Address(address))
230
+ if address not in addresses:
231
+ addresses.append(address)
232
+ return addresses
233
+
234
+
235
+ def pin_remotes(command, remotes):
236
+ """Wrap OpenVPN so the profile's servers stay on the physical gateway while it runs.
237
+
238
+ A split-tunnel profile adds no host route for its own server, so a full-tunnel VPN that is (or later comes)
239
+ up would carry this handshake inside itself and break it on either side's reconnect. The wrapper runs as
240
+ root with OpenVPN: pin each server, run OpenVPN, unpin after it exits. Addresses are validated IPs, so
241
+ they are safe to place in the script; the OpenVPN command is passed as arguments, never interpolated.
242
+ """
243
+ gateway, addresses = default_gateway(), remote_addresses(remotes)
244
+ if not gateway or not addresses:
245
+ return command
246
+ if sys.platform == "darwin":
247
+ pin = "route -n delete -host {0} >/dev/null 2>&1; route -n add -host {0} {1} >/dev/null"
248
+ unpin = "route -n delete -host {0} >/dev/null 2>&1"
249
+ else:
250
+ pin, unpin = "ip route replace {0}/32 via {1}", "ip route del {0}/32 via {1} 2>/dev/null"
251
+ script = "; ".join(
252
+ [
253
+ *(pin.format(address, gateway) for address in addresses),
254
+ # Survive the signals relayed by sudo so the unpin still runs; OpenVPN handles its own.
255
+ "trap : HUP INT TERM",
256
+ '"$@"',
257
+ "code=$?",
258
+ *(unpin.format(address, gateway) for address in addresses),
259
+ 'exit "$code"',
260
+ ]
261
+ )
262
+ return ["sh", "-c", script, "sh", *command]
263
+
264
+
174
265
  def parse_totp(value):
175
266
  value = value.strip()
176
267
  settings = {"secret": value, "algorithm": "SHA1", "digits": 6, "period": 30}
@@ -275,6 +366,58 @@ def last_profile():
275
366
  return None
276
367
 
277
368
 
369
+ def _worker_alive(base):
370
+ """The worker holds its lock for life, and the kernel releases it however the worker dies."""
371
+ import fcntl
372
+
373
+ try:
374
+ with open(str(base) + ".lock") as lock:
375
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
376
+ except FileNotFoundError:
377
+ return False
378
+ except BlockingIOError:
379
+ return True
380
+ return False
381
+
382
+
383
+ def _config_pids(base):
384
+ """PIDs whose command line names this session's config (OpenVPN, its sudo or wrapper); None if unknown."""
385
+ config = str(base) + ".ovpn"
386
+ try:
387
+ output = subprocess.run(
388
+ ["ps", "-A", "-o", "pid=", "-o", "args="], capture_output=True, text=True, timeout=5, check=True
389
+ ).stdout
390
+ except (OSError, subprocess.SubprocessError):
391
+ return None
392
+ return [line.split(None, 1)[0] for line in output.splitlines() if config in line]
393
+
394
+
395
+ def _reap(name, last):
396
+ """Finish the cleanup of a worker that died without it (SIGKILL, SIGTERM), once nothing of it runs.
397
+
398
+ Otherwise its last published state would read as UNKNOWN forever, blocking connect and disconnect.
399
+ """
400
+ base = session_path(name)
401
+ if _worker_alive(base):
402
+ return {"profile": name, "state": "UNKNOWN", "error": "Worker unavailable; inspect OpenVPN processes."}
403
+ pids = _config_pids(base)
404
+ if pids is None:
405
+ return {"profile": name, "state": "UNKNOWN", "error": "Worker gone; cannot list processes to confirm."}
406
+ if pids:
407
+ listed = " ".join(pids)
408
+ error = f"OpenVPN still runs without its worker (PID {listed}); stop it with: sudo kill {listed}"
409
+ return {"profile": name, "state": "UNKNOWN", "error": error}
410
+ for suffix in (".sock", ".mgmt", ".ovpn"):
411
+ Path(str(base) + suffix).unlink(missing_ok=True)
412
+ state = {
413
+ "profile": name,
414
+ "state": "STOPPED",
415
+ "error": f"Worker exited unexpectedly (last state {last['state']}); no OpenVPN process was left running.",
416
+ }
417
+ private_write(Path(str(base) + ".json"), json.dumps(state))
418
+ return state
419
+
420
+
278
421
  def request(name, command="status"):
279
422
  endpoint = str(session_path(name)) + ".sock"
280
423
  with socket.socket(socket.AF_UNIX) as client:
@@ -286,11 +429,7 @@ def request(name, command="status"):
286
429
  if path.exists():
287
430
  last = json.loads(path.read_text())
288
431
  if last["state"] not in ("FAILED", "STOPPED") or last.get("cleanup_failed"):
289
- return {
290
- "profile": name,
291
- "state": "UNKNOWN",
292
- "error": "Worker unavailable; inspect OpenVPN processes.",
293
- }
432
+ return _reap(name, last)
294
433
  if last.get("error"):
295
434
  return {"profile": name, "state": "STOPPED", "error": last["error"]}
296
435
  return {"profile": name, "state": "STOPPED"}
@@ -341,8 +480,9 @@ def worker(name, timeout):
341
480
  state = {"profile": name, "state": "STARTING"}
342
481
  # Served over the control socket for diagnosis; kept in memory only, gone with the worker.
343
482
  log = deque(maxlen=200)
483
+ # One worker per profile; connect decides whether profiles may run side by side.
344
484
  # Held for the worker's whole life (flock) and closed in the outer finally.
345
- lock = open(runtime_dir() / "active.lock", "a") # noqa: SIM115
485
+ lock = open(str(base) + ".lock", "a") # noqa: SIM115
346
486
  try:
347
487
  fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
348
488
  except BlockingIOError:
@@ -357,7 +497,7 @@ def worker(name, timeout):
357
497
 
358
498
  try:
359
499
  profile = get_profile(name)
360
- validate_config(profile["config"])
500
+ remotes = validate_config(profile["config"])
361
501
  private_write(config, profile["config"])
362
502
  for path, server in ((control_path, control), (management_path, listener)):
363
503
  Path(path).unlink(missing_ok=True)
@@ -398,6 +538,8 @@ def worker(name, timeout):
398
538
  "--verb",
399
539
  "3",
400
540
  ]
541
+ if not takes_default_route(profile["config"]):
542
+ command = pin_remotes(command, remotes)
401
543
  if os.geteuid() != 0:
402
544
  command = ["sudo", "-S", "-p", "", "--"] + command
403
545
  process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.27.0
3
+ Version: 0.28.1
4
4
  Summary: Evolution CLI - a developer toolbox for setting up dev machines
5
5
  Author: maycuatroi
6
6
  Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
@@ -170,8 +170,14 @@ supported. Only trusted, self-contained TUN client profiles are accepted; script
170
170
  plugins and external certificate/key files are refused. Runtime data uses
171
171
  `~/.evo/openvpn` (override with `EVO_OPENVPN_HOME`).
172
172
 
173
- Only one evo-managed tunnel runs at a time. Disconnect any GUI VPN before connecting
174
- through evo; GUI profiles are never removed or modified. Server routes are applied,
173
+ Several evo-managed tunnels can run at once, as long as at most one of them may take the
174
+ default route. A profile is split-tunnel when it has `route-nopull` or
175
+ `pull-filter ignore "redirect-gateway"` (add `route` lines for the networks it should
176
+ carry); any other profile might receive a pushed `redirect-gateway`, so `connect` refuses
177
+ to stack two of those. While a split-tunnel profile runs, its server is pinned to the
178
+ physical gateway with a host route, so a full-tunnel VPN connected before or after it
179
+ never carries its handshake. Disconnect any GUI VPN before connecting through evo; GUI
180
+ profiles are never removed or modified. Server routes are applied,
175
181
  but this backend does not change system DNS. Internal IP access works through the
176
182
  tunnel; private DNS names may need your organization's DNS configuration. `CONNECTED`
177
183
  confirms the tunnel, not the availability of a particular internal application.
@@ -108,6 +108,91 @@ def test_config_accepts_inline_key_without_parsing_it():
108
108
  assert vpn.validate_config(CONFIG + "lport 0\n<key>\nPRIVATE SECRET\n</key>\n") == ["vpn.example 1194 udp"]
109
109
 
110
110
 
111
+ @pytest.mark.parametrize(
112
+ "extra,full",
113
+ [
114
+ ("", True),
115
+ ('pull-filter ignore "redirect-gateway"\n', False),
116
+ ("pull-filter ignore redirect\n", False),
117
+ ("route-nopull\n", False),
118
+ ('pull-filter ignore "route"\n', True),
119
+ # The first matching pull-filter wins, and a local redirect-gateway needs no push.
120
+ ('pull-filter accept "redirect-gateway"\npull-filter ignore "redirect-gateway"\n', True),
121
+ ('pull-filter ignore "redirect-gateway"\nredirect-gateway def1\n', True),
122
+ ('<key>\nredirect-gateway def1\n</key>\npull-filter ignore "redirect-gateway"\n', False),
123
+ ],
124
+ )
125
+ def test_split_tunnel_is_only_what_provably_keeps_the_default_route(extra, full):
126
+ assert vpn.takes_default_route(CONFIG + extra) is full
127
+
128
+
129
+ @pytest.mark.parametrize(
130
+ "platform,output,gateway",
131
+ [
132
+ ("darwin", "route to: default\n gateway: 192.0.2.1\n interface: en0\n", "192.0.2.1"),
133
+ ("darwin", "route to: default\n gateway: 10.8.0.1\n interface: utun4\n", None),
134
+ ("linux", "default via 192.0.2.1 dev wlan0 proto dhcp metric 600\n", "192.0.2.1"),
135
+ ("linux", "default via 10.8.0.1 dev tun0\n", None),
136
+ ("linux", "", None),
137
+ ],
138
+ )
139
+ def test_default_gateway_is_the_physical_one(monkeypatch, platform, output, gateway):
140
+ monkeypatch.setattr(vpn.sys, "platform", platform)
141
+ monkeypatch.setattr(vpn.subprocess, "run", lambda *a, **k: subprocess.CompletedProcess(a, 0, output, ""))
142
+ assert vpn.default_gateway() == gateway
143
+
144
+
145
+ @pytest.mark.skipif(sys.platform == "win32", reason="the wrapper is a POSIX shell script")
146
+ @pytest.mark.parametrize(
147
+ "platform,expected",
148
+ [
149
+ (
150
+ "darwin",
151
+ [
152
+ "route -n delete -host 198.51.100.7",
153
+ "route -n add -host 198.51.100.7 192.0.2.1",
154
+ "openvpn --config $(touch pwned)",
155
+ "route -n delete -host 198.51.100.7",
156
+ ],
157
+ ),
158
+ (
159
+ "linux",
160
+ [
161
+ "ip route replace 198.51.100.7/32 via 192.0.2.1",
162
+ "openvpn --config $(touch pwned)",
163
+ "ip route del 198.51.100.7/32 via 192.0.2.1",
164
+ ],
165
+ ),
166
+ ],
167
+ )
168
+ def test_split_tunnel_pins_its_server_around_openvpn(tmp_path, monkeypatch, platform, expected):
169
+ monkeypatch.setattr(vpn.sys, "platform", platform)
170
+ monkeypatch.setattr(vpn, "default_gateway", lambda: "192.0.2.1")
171
+ for tool in ("route", "ip"):
172
+ (tmp_path / tool).write_text(f'#!/bin/sh\necho "{tool} $*" >> "$LOG"\n')
173
+ (tmp_path / tool).chmod(0o755)
174
+ fake = "import os,sys; open(os.environ['LOG'],'a').write(' '.join(['openvpn',*sys.argv[1:]])+'\\n'); sys.exit(3)"
175
+ command = vpn.pin_remotes([sys.executable, "-c", fake, "--config", "$(touch pwned)"], ["198.51.100.7 1194 tcp4"])
176
+ log = tmp_path / "log"
177
+ env = {**vpn.os.environ, "PATH": f"{tmp_path}:{vpn.os.environ['PATH']}", "LOG": str(log)}
178
+ # OpenVPN's exit status survives the unpin, and its arguments never reach the shell as code.
179
+ assert subprocess.run(command, cwd=tmp_path, env=env, check=False).returncode == 3
180
+ assert log.read_text().splitlines() == expected
181
+ assert not (tmp_path / "pwned").exists()
182
+
183
+
184
+ def test_no_pin_without_a_physical_gateway_or_address(monkeypatch):
185
+ monkeypatch.setattr(vpn, "default_gateway", lambda: None)
186
+ assert vpn.pin_remotes(["openvpn"], ["198.51.100.7 1194"]) == ["openvpn"]
187
+ monkeypatch.setattr(vpn, "default_gateway", lambda: "192.0.2.1")
188
+
189
+ def unresolvable(*_):
190
+ raise vpn.socket.gaierror("no such host")
191
+
192
+ monkeypatch.setattr(vpn.socket, "getaddrinfo", unresolvable)
193
+ assert vpn.pin_remotes(["openvpn"], ["vpn.example 1194"]) == ["openvpn"]
194
+
195
+
111
196
  @pytest.mark.parametrize("name", ["../x", "x.y", "-flag", "x/y", "", "x" * 49])
112
197
  def test_rejects_ambiguous_profile_names(name):
113
198
  with pytest.raises(CredentialError):
@@ -173,10 +258,51 @@ def test_plain_password_escaping_and_no_unrequested_otp():
173
258
  vpn.quote("x\nsignal SIGTERM")
174
259
 
175
260
 
176
- def test_stale_state_is_not_connected_or_confirmed_stopped(store):
177
- path = Path(str(vpn.session_path("m1")) + ".json")
178
- vpn.private_write(path, '{"state":"CONNECTED"}')
261
+ def _ps(monkeypatch, output):
262
+ def run(command, **kwargs):
263
+ assert command[0] == "ps"
264
+ if output is None:
265
+ raise OSError("no ps")
266
+ return subprocess.CompletedProcess(command, 0, stdout=output)
267
+
268
+ monkeypatch.setattr(vpn.subprocess, "run", run)
269
+
270
+
271
+ def test_stale_state_is_not_connected_or_confirmed_stopped(store, monkeypatch):
272
+ import fcntl
273
+
274
+ base = str(vpn.session_path("m1"))
275
+ vpn.private_write(Path(base + ".json"), '{"state":"CONNECTED"}')
276
+ _ps(monkeypatch, "")
277
+ with open(base + ".lock", "a") as held:
278
+ fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
279
+ assert vpn.request("m1")["state"] == "UNKNOWN" # the worker lives but does not answer
280
+ _ps(monkeypatch, None)
179
281
  assert vpn.request("m1")["state"] == "UNKNOWN"
282
+ _ps(monkeypatch, f" 4242 /usr/sbin/openvpn --config {base}.ovpn --verb 3\n 4300 -zsh\n")
283
+ state = vpn.request("m1")
284
+ assert state["state"] == "UNKNOWN" and state["error"].endswith("sudo kill 4242")
285
+
286
+
287
+ def test_dead_worker_is_reaped_and_disconnect_confirms_it(store, monkeypatch):
288
+ import socket
289
+
290
+ vpn.save_profile("m1", {"config": CONFIG})
291
+ base = str(vpn.session_path("m1"))
292
+ vpn.private_write(Path(base + ".json"), '{"profile":"m1","state":"CONNECTED","vpn_ip":"10.8.0.2"}')
293
+ # What a SIGKILLed worker leaves: a socket nobody listens on, the management socket and the config.
294
+ with socket.socket(socket.AF_UNIX) as stale:
295
+ stale.bind(base + ".sock")
296
+ for suffix in (".mgmt", ".ovpn", ".lock"):
297
+ Path(base + suffix).touch()
298
+ _ps(monkeypatch, " 4300 -zsh\n")
299
+ result = CliRunner().invoke(cli, ["openvpn", "disconnect", "m1"])
300
+ assert result.exit_code == 0, result.output
301
+ assert "m1 was not running; last error: Worker exited unexpectedly (last state CONNECTED)" in result.output
302
+ assert "m1: stopped" in result.output
303
+ assert not any(Path(base + suffix).exists() for suffix in (".sock", ".mgmt", ".ovpn"))
304
+ assert json.loads(Path(base + ".json").read_text())["state"] == "STOPPED"
305
+ assert vpn.request("m1")["state"] == "STOPPED" # so connect and import accept the profile again
180
306
 
181
307
 
182
308
  FAKE_OPENVPN = """
@@ -284,6 +410,46 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
284
410
  thread.join(5)
285
411
 
286
412
 
413
+ def test_worker_lock_is_per_profile(store, monkeypatch):
414
+ import fcntl
415
+
416
+ monkeypatch.setattr(sys, "stdin", io.StringIO("\n\n"))
417
+ with open(str(vpn.session_path("m1")) + ".lock", "a") as held:
418
+ fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
419
+ vpn.worker("m1", 10) # a second worker for a running profile leaves at once
420
+ assert not Path(str(vpn.session_path("m1")) + ".json").exists()
421
+ vpn.worker("m2", 10) # another profile gets past the lock (and fails: it does not exist)
422
+ assert json.loads(Path(str(vpn.session_path("m2")) + ".json").read_text())["state"] == "FAILED"
423
+
424
+
425
+ def test_connect_stacks_a_split_tunnel_beside_a_full_one_only(store, monkeypatch):
426
+ split = CONFIG + 'pull-filter ignore "redirect-gateway"\n'
427
+ for name, config in (("full", CONFIG), ("full-2", CONFIG), ("split", split)):
428
+ vpn.save_profile(name, {"config": config, "username": "u", "password": "p"})
429
+ monkeypatch.setattr(
430
+ vpn, "request", lambda name, command="status": {"state": "CONNECTED" if name == "full" else "STOPPED"}
431
+ )
432
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
433
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
434
+ started = []
435
+
436
+ class Exited:
437
+ def __init__(self):
438
+ self.stdin = io.BytesIO()
439
+
440
+ def poll(self):
441
+ return 1
442
+
443
+ monkeypatch.setattr(
444
+ "evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: started.append(command) or Exited()
445
+ )
446
+ runner = CliRunner()
447
+ result = runner.invoke(cli, ["openvpn", "connect", "full-2", "--no-watch"])
448
+ assert result.exit_code != 0 and "both may take the default route" in result.output and not started
449
+ result = runner.invoke(cli, ["openvpn", "connect", "split", "--no-watch"])
450
+ assert "default route" not in result.output and started, result.output
451
+
452
+
287
453
  def test_pick_profile_by_number_single_or_prompt(store, monkeypatch):
288
454
  runner = CliRunner()
289
455
  totp = vpn.parse_totp(SECRET)
@@ -1 +0,0 @@
1
- 0.27.0
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