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