evo-cli 0.27.0__tar.gz → 0.28.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 (147) hide show
  1. {evo_cli-0.27.0 → evo_cli-0.28.0}/PKG-INFO +9 -3
  2. {evo_cli-0.27.0 → evo_cli-0.28.0}/README.md +8 -2
  3. evo_cli-0.28.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/openvpn.py +11 -4
  5. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/openvpn.py +101 -7
  6. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/PKG-INFO +9 -3
  7. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_openvpn.py +125 -0
  8. evo_cli-0.27.0/evo_cli/VERSION +0 -1
  9. {evo_cli-0.27.0 → evo_cli-0.28.0}/Containerfile +0 -0
  10. {evo_cli-0.27.0 → evo_cli-0.28.0}/HISTORY.md +0 -0
  11. {evo_cli-0.27.0 → evo_cli-0.28.0}/LICENSE +0 -0
  12. {evo_cli-0.27.0 → evo_cli-0.28.0}/MANIFEST.in +0 -0
  13. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/__init__.py +0 -0
  14. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/__main__.py +0 -0
  15. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/base.py +0 -0
  16. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/cli.py +0 -0
  17. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/agent_toy.py +0 -0
  19. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/claude_code.py +0 -0
  20. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/cred.py +0 -0
  22. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/__init__.py +0 -0
  27. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_dag.py +0 -0
  28. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_git.py +0 -0
  29. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_model.py +0 -0
  30. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_mutate.py +0 -0
  31. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_paths.py +0 -0
  32. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_render.py +0 -0
  33. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_server.py +0 -0
  34. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/check.py +0 -0
  35. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/clone.py +0 -0
  36. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/edit.py +0 -0
  37. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/pull.py +0 -0
  38. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/serve.py +0 -0
  39. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/show.py +0 -0
  40. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/views.py +0 -0
  41. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  42. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  43. {evo_cli-0.27.0 → evo_cli-0.28.0}/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.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  45. {evo_cli-0.27.0 → evo_cli-0.28.0}/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.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  47. {evo_cli-0.27.0 → evo_cli-0.28.0}/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.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  49. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  50. {evo_cli-0.27.0 → evo_cli-0.28.0}/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.0}/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.0}/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.0}/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.0}/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.0}/evo_cli/commands/harness/web/index.html +0 -0
  56. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/hwid.py +0 -0
  57. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/hwid_reset.py +0 -0
  58. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/image.py +0 -0
  59. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/localproxy.py +0 -0
  60. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/mcp.py +0 -0
  61. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/miniconda.py +0 -0
  62. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/netcheck.py +0 -0
  63. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/ngrok.py +0 -0
  64. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/opencode.py +0 -0
  65. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/plantuml.py +0 -0
  66. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/__init__.py +0 -0
  67. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_dns.py +0 -0
  68. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_dpi.py +0 -0
  69. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_hosts.py +0 -0
  70. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_probe.py +0 -0
  71. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/check.py +0 -0
  72. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/fix.py +0 -0
  73. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/probe.py +0 -0
  74. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/status.py +0 -0
  75. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/serp.py +0 -0
  76. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/site2s.py +0 -0
  77. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/ssh.py +0 -0
  78. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/storage.py +0 -0
  79. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/sysmon.py +0 -0
  80. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/tts.py +0 -0
  81. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/update.py +0 -0
  82. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/wifi.py +0 -0
  83. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/console.py +0 -0
  84. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/__init__.py +0 -0
  85. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/doctor.py +0 -0
  86. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/google_oauth.py +0 -0
  87. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/migrate.py +0 -0
  88. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/oauth_flow.py +0 -0
  89. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/registry.py +0 -0
  90. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/store.py +0 -0
  91. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/sync.py +0 -0
  92. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/__init__.py +0 -0
  93. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/align.py +0 -0
  94. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/core.py +0 -0
  95. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/creds.py +0 -0
  96. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/errors.py +0 -0
  97. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/gemini.py +0 -0
  98. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/ncnn.py +0 -0
  99. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/mcp_registry.py +0 -0
  100. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/__init__.py +0 -0
  101. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/client.py +0 -0
  102. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/creds.py +0 -0
  103. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/errors.py +0 -0
  104. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/install.py +0 -0
  105. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/render.py +0 -0
  106. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/__init__.py +0 -0
  107. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/chunking.py +0 -0
  108. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/core.py +0 -0
  109. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/creds.py +0 -0
  110. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/errors.py +0 -0
  111. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/gemini.py +0 -0
  112. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/http.py +0 -0
  113. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/openai.py +0 -0
  114. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/player.py +0 -0
  115. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/vbee.py +0 -0
  116. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/SOURCES.txt +0 -0
  117. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  118. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/entry_points.txt +0 -0
  119. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/requires.txt +0 -0
  120. {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/top_level.txt +0 -0
  121. {evo_cli-0.27.0 → evo_cli-0.28.0}/pyproject.toml +0 -0
  122. {evo_cli-0.27.0 → evo_cli-0.28.0}/setup.cfg +0 -0
  123. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/__init__.py +0 -0
  124. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_agent_toy.py +0 -0
  125. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_claude_code.py +0 -0
  126. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_cli.py +0 -0
  127. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_console.py +0 -0
  128. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_cred.py +0 -0
  129. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_download.py +0 -0
  130. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_fix_claude.py +0 -0
  131. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_gdrive.py +0 -0
  132. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_gh.py +0 -0
  133. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness.py +0 -0
  134. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness_clone.py +0 -0
  135. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness_dag.py +0 -0
  136. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_image.py +0 -0
  137. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_mcp.py +0 -0
  138. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_ngrok.py +0 -0
  139. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_opencode.py +0 -0
  140. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_plantuml.py +0 -0
  141. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_route.py +0 -0
  142. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_serp.py +0 -0
  143. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_storage.py +0 -0
  144. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_sysmon.py +0 -0
  145. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_tts.py +0 -0
  146. {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_update.py +0 -0
  147. {evo_cli-0.27.0 → evo_cli-0.28.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.27.0
3
+ Version: 0.28.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
@@ -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.0
@@ -324,10 +324,17 @@ def connect(name, timeout, sudo_password_stdin, watch):
324
324
  raise click.ClickException("Missing credentials; run evo openvpn credentials PROFILE.")
325
325
  if _guard(vpn.request, name)["state"] != "STOPPED":
326
326
  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.")
327
+ # Two tunnels that may both take the default route would fight over it; a split-tunnel profile stacks
328
+ # beside any other. The external GUI is never controlled here.
329
+ full = _guard(vpn.takes_default_route, profile["config"])
330
+ for other, data in _guard(vpn.profiles).items():
331
+ if other == name or _guard(vpn.request, other)["state"] == "STOPPED":
332
+ continue
333
+ if full and _guard(vpn.takes_default_route, data.get("config", "")):
334
+ raise click.ClickException(
335
+ f"Disconnect '{other}' first: both may take the default route. To run them together, add "
336
+ 'pull-filter ignore "redirect-gateway" (plus the routes you need) to one of them.'
337
+ )
331
338
  password = ""
332
339
  if os.geteuid() != 0:
333
340
  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}
@@ -341,8 +432,9 @@ def worker(name, timeout):
341
432
  state = {"profile": name, "state": "STARTING"}
342
433
  # Served over the control socket for diagnosis; kept in memory only, gone with the worker.
343
434
  log = deque(maxlen=200)
435
+ # One worker per profile; connect decides whether profiles may run side by side.
344
436
  # Held for the worker's whole life (flock) and closed in the outer finally.
345
- lock = open(runtime_dir() / "active.lock", "a") # noqa: SIM115
437
+ lock = open(str(base) + ".lock", "a") # noqa: SIM115
346
438
  try:
347
439
  fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
348
440
  except BlockingIOError:
@@ -357,7 +449,7 @@ def worker(name, timeout):
357
449
 
358
450
  try:
359
451
  profile = get_profile(name)
360
- validate_config(profile["config"])
452
+ remotes = validate_config(profile["config"])
361
453
  private_write(config, profile["config"])
362
454
  for path, server in ((control_path, control), (management_path, listener)):
363
455
  Path(path).unlink(missing_ok=True)
@@ -398,6 +490,8 @@ def worker(name, timeout):
398
490
  "--verb",
399
491
  "3",
400
492
  ]
493
+ if not takes_default_route(profile["config"]):
494
+ command = pin_remotes(command, remotes)
401
495
  if os.geteuid() != 0:
402
496
  command = ["sudo", "-S", "-p", "", "--"] + command
403
497
  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.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
@@ -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):
@@ -284,6 +369,46 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
284
369
  thread.join(5)
285
370
 
286
371
 
372
+ def test_worker_lock_is_per_profile(store, monkeypatch):
373
+ import fcntl
374
+
375
+ monkeypatch.setattr(sys, "stdin", io.StringIO("\n\n"))
376
+ with open(str(vpn.session_path("m1")) + ".lock", "a") as held:
377
+ fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
378
+ vpn.worker("m1", 10) # a second worker for a running profile leaves at once
379
+ assert not Path(str(vpn.session_path("m1")) + ".json").exists()
380
+ vpn.worker("m2", 10) # another profile gets past the lock (and fails: it does not exist)
381
+ assert json.loads(Path(str(vpn.session_path("m2")) + ".json").read_text())["state"] == "FAILED"
382
+
383
+
384
+ def test_connect_stacks_a_split_tunnel_beside_a_full_one_only(store, monkeypatch):
385
+ split = CONFIG + 'pull-filter ignore "redirect-gateway"\n'
386
+ for name, config in (("full", CONFIG), ("full-2", CONFIG), ("split", split)):
387
+ vpn.save_profile(name, {"config": config, "username": "u", "password": "p"})
388
+ monkeypatch.setattr(
389
+ vpn, "request", lambda name, command="status": {"state": "CONNECTED" if name == "full" else "STOPPED"}
390
+ )
391
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
392
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
393
+ started = []
394
+
395
+ class Exited:
396
+ def __init__(self):
397
+ self.stdin = io.BytesIO()
398
+
399
+ def poll(self):
400
+ return 1
401
+
402
+ monkeypatch.setattr(
403
+ "evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: started.append(command) or Exited()
404
+ )
405
+ runner = CliRunner()
406
+ result = runner.invoke(cli, ["openvpn", "connect", "full-2", "--no-watch"])
407
+ assert result.exit_code != 0 and "both may take the default route" in result.output and not started
408
+ result = runner.invoke(cli, ["openvpn", "connect", "split", "--no-watch"])
409
+ assert "default route" not in result.output and started, result.output
410
+
411
+
287
412
  def test_pick_profile_by_number_single_or_prompt(store, monkeypatch):
288
413
  runner = CliRunner()
289
414
  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