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.
- {evo_cli-0.27.0 → evo_cli-0.28.0}/PKG-INFO +9 -3
- {evo_cli-0.27.0 → evo_cli-0.28.0}/README.md +8 -2
- evo_cli-0.28.0/evo_cli/VERSION +1 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/openvpn.py +11 -4
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/openvpn.py +101 -7
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/PKG-INFO +9 -3
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_openvpn.py +125 -0
- evo_cli-0.27.0/evo_cli/VERSION +0 -1
- {evo_cli-0.27.0 → evo_cli-0.28.0}/Containerfile +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/HISTORY.md +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/LICENSE +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/MANIFEST.in +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/base.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/cli.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {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
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {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
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {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
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {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
- {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
- {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
- {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
- {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
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/_probe.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/fix.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/console.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/SOURCES.txt +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/pyproject.toml +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/setup.cfg +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/__init__.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_cli.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_console.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_cred.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_download.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_gdrive.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_gh.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_image.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_ngrok.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_route.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_serp.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_storage.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_tts.py +0 -0
- {evo_cli-0.27.0 → evo_cli-0.28.0}/tests/test_update.py +0 -0
- {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.
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
132
|
-
"""
|
|
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(
|
|
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.
|
|
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
|
-
|
|
174
|
-
|
|
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)
|
evo_cli-0.27.0/evo_cli/VERSION
DELETED
|
@@ -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
|
|
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
|
|
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
|
|
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
|