evo-cli 0.31.2__tar.gz → 0.32.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {evo_cli-0.31.2 → evo_cli-0.32.0}/PKG-INFO +3 -1
- {evo_cli-0.31.2 → evo_cli-0.32.0}/README.md +2 -0
- evo_cli-0.32.0/evo_cli/VERSION +1 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/openvpn.py +39 -14
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/__init__.py +3 -1
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_dpi.py +7 -1
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_probe.py +27 -1
- evo_cli-0.32.0/evo_cli/commands/route/_spoofdpi.py +344 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/fix.py +63 -10
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/status.py +6 -2
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/openvpn.py +76 -24
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/PKG-INFO +3 -1
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/SOURCES.txt +1 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_openvpn.py +120 -5
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_route.py +146 -1
- evo_cli-0.31.2/evo_cli/VERSION +0 -1
- {evo_cli-0.31.2 → evo_cli-0.32.0}/Containerfile +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/HISTORY.md +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/LICENSE +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/MANIFEST.in +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/base.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/cli.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_hub.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_mirror.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/console.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/pyproject.toml +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/setup.cfg +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/__init__.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fake_evo_agents.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_cli.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_console.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_cred.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_download.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_gdrive.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_gh.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_hub.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_harness_show.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_image.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_ngrok.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_serp.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_storage.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_tts.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_update.py +0 -0
- {evo_cli-0.31.2 → evo_cli-0.32.0}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.32.0
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -159,6 +159,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
|
|
|
159
159
|
a public repository or treat storing the password and OTP together as independent factors.
|
|
160
160
|
|
|
161
161
|
Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
|
|
162
|
+
For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
|
|
163
|
+
https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
|
|
162
164
|
`evo openvpn otp NAME` explicitly prints the current code; list and status never print
|
|
163
165
|
secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
|
|
164
166
|
with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
|
|
@@ -126,6 +126,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
|
|
|
126
126
|
a public repository or treat storing the password and OTP together as independent factors.
|
|
127
127
|
|
|
128
128
|
Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
|
|
129
|
+
For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
|
|
130
|
+
https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
|
|
129
131
|
`evo openvpn otp NAME` explicitly prints the current code; list and status never print
|
|
130
132
|
secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
|
|
131
133
|
with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.32.0
|
|
@@ -43,7 +43,7 @@ def _table(rows):
|
|
|
43
43
|
state.get("vpn_ip", ""),
|
|
44
44
|
(profile.get("remotes") or [""])[0],
|
|
45
45
|
_creds(profile),
|
|
46
|
-
"yes" if profile.get("totp") else "no",
|
|
46
|
+
"url" if profile.get("otp_url") else "yes" if profile.get("totp") else "no",
|
|
47
47
|
)
|
|
48
48
|
return table
|
|
49
49
|
|
|
@@ -58,6 +58,17 @@ def _creds(profile):
|
|
|
58
58
|
return "no"
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _ask(name, command="status"):
|
|
62
|
+
"""vpn.request for polling loops, where a missed 2s reply is no verdict.
|
|
63
|
+
|
|
64
|
+
A worker stops answering for a moment while it fetches an OTP or waits for OpenVPN to exit.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
return _guard(vpn.request, name, command)
|
|
68
|
+
except OSError:
|
|
69
|
+
return {"profile": name, "state": "UNKNOWN", "error": "Worker did not answer in time."}
|
|
70
|
+
|
|
71
|
+
|
|
61
72
|
def _rows():
|
|
62
73
|
profiles = _guard(vpn.profiles)
|
|
63
74
|
return [(name, profiles[name], _guard(vpn.request, name)) for name in sorted(profiles)]
|
|
@@ -233,7 +244,9 @@ def import_profile(name, config, replace):
|
|
|
233
244
|
click.echo(f"Imported {name} into evo cred: openvpn.profiles.{name}")
|
|
234
245
|
|
|
235
246
|
|
|
236
|
-
@openvpn_group.command(
|
|
247
|
+
@openvpn_group.command(
|
|
248
|
+
"credentials", help="Save username/password and optionally an OTP source (TOTP or URL); input is hidden."
|
|
249
|
+
)
|
|
237
250
|
@click.argument("name", required=False)
|
|
238
251
|
@click.option("--username", help="VPN account name; password is prompted, never passed as an argument.")
|
|
239
252
|
@click.option("--password-stdin", is_flag=True, help="Read VPN password from stdin rather than the hidden prompt.")
|
|
@@ -241,22 +254,34 @@ def import_profile(name, config, replace):
|
|
|
241
254
|
@click.option(
|
|
242
255
|
"--qr", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Read OTP from a local QR image."
|
|
243
256
|
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
257
|
+
@click.option(
|
|
258
|
+
"--otp-url",
|
|
259
|
+
is_flag=True,
|
|
260
|
+
help="Prompt for a link that answers with the current OTP; each login sends the password (PIN) plus a fresh code.",
|
|
261
|
+
)
|
|
262
|
+
def credentials(name, username, password_stdin, otp, qr, otp_url):
|
|
263
|
+
if otp + bool(qr) + otp_url > 1:
|
|
264
|
+
raise click.UsageError("Use only one of --otp, --qr and --otp-url.")
|
|
247
265
|
name = _pick(name)
|
|
248
266
|
profile = _guard(vpn.get_profile, name)
|
|
249
267
|
username = username or click.prompt("Username", default=profile.get("username", ""))
|
|
250
|
-
|
|
268
|
+
label = "VPN password or PIN, without the OTP" if otp_url or profile.get("otp_url") else "VPN password"
|
|
269
|
+
password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass(f"{label} (hidden): ")
|
|
251
270
|
if not password:
|
|
252
271
|
raise click.ClickException("Empty password; nothing saved.")
|
|
253
272
|
_guard(vpn.quote, username)
|
|
254
273
|
_guard(vpn.quote, password)
|
|
255
274
|
profile.update(username=username, password=password)
|
|
275
|
+
# One OTP source per profile.
|
|
256
276
|
if qr:
|
|
257
277
|
profile["totp"] = _guard(vpn.qr_totp, qr)
|
|
278
|
+
profile.pop("otp_url", None)
|
|
258
279
|
elif otp:
|
|
259
280
|
profile["totp"] = _guard(vpn.parse_totp, getpass.getpass("OTP enrollment secret/URI (hidden): "))
|
|
281
|
+
profile.pop("otp_url", None)
|
|
282
|
+
elif otp_url:
|
|
283
|
+
profile["otp_url"] = _guard(vpn.parse_otp_url, getpass.getpass("OTP URL (hidden): "))
|
|
284
|
+
profile.pop("totp", None)
|
|
260
285
|
_guard(vpn.save_profile, name, profile)
|
|
261
286
|
click.echo(f"Saved credentials for {name}. Sync explicitly with: evo cred sync push")
|
|
262
287
|
|
|
@@ -265,10 +290,10 @@ def credentials(name, username, password_stdin, otp, qr):
|
|
|
265
290
|
@click.argument("name", required=False)
|
|
266
291
|
def otp_code(name):
|
|
267
292
|
name = _pick(name)
|
|
268
|
-
|
|
269
|
-
if not
|
|
293
|
+
profile = _guard(vpn.get_profile, name)
|
|
294
|
+
if not profile.get("totp") and not profile.get("otp_url"):
|
|
270
295
|
raise click.ClickException("No OTP enrollment for this profile.")
|
|
271
|
-
click.echo(_guard(vpn.
|
|
296
|
+
click.echo(_guard(vpn.current_otp, profile))
|
|
272
297
|
|
|
273
298
|
|
|
274
299
|
@openvpn_group.command("status", help="Query the live worker; stale status files never count as connected.")
|
|
@@ -303,13 +328,13 @@ def disconnect(name):
|
|
|
303
328
|
|
|
304
329
|
|
|
305
330
|
def _disconnect(name):
|
|
306
|
-
state =
|
|
331
|
+
state = _ask(name, "disconnect")
|
|
307
332
|
if state["state"] == "STOPPED" and state.get("error"):
|
|
308
333
|
click.echo(f"{name} was not running; last error: {state['error']}", err=True)
|
|
309
334
|
deadline = time.monotonic() + 20
|
|
310
335
|
while state["state"] != "STOPPED" and time.monotonic() < deadline:
|
|
311
336
|
time.sleep(0.2)
|
|
312
|
-
state =
|
|
337
|
+
state = _ask(name)
|
|
313
338
|
if state["state"] != "STOPPED":
|
|
314
339
|
raise click.ClickException(state.get("error") or "Disconnect not confirmed; inspect evo openvpn status.")
|
|
315
340
|
click.echo(f"{name}: stopped (OpenVPN Connect profiles unchanged).")
|
|
@@ -369,7 +394,7 @@ def connect(name, timeout, sudo_password_stdin, watch):
|
|
|
369
394
|
deadline = time.monotonic() + timeout + 20
|
|
370
395
|
try:
|
|
371
396
|
while time.monotonic() < deadline:
|
|
372
|
-
state =
|
|
397
|
+
state = _ask(name)
|
|
373
398
|
if state["state"] == "CONNECTED":
|
|
374
399
|
click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
|
|
375
400
|
break
|
|
@@ -380,8 +405,8 @@ def connect(name, timeout, sudo_password_stdin, watch):
|
|
|
380
405
|
raise click.ClickException("OpenVPN worker exited before startup.")
|
|
381
406
|
time.sleep(0.2)
|
|
382
407
|
finally:
|
|
383
|
-
if
|
|
384
|
-
|
|
408
|
+
if _ask(name)["state"] != "CONNECTED":
|
|
409
|
+
_ask(name, "disconnect")
|
|
385
410
|
if state["state"] != "CONNECTED":
|
|
386
411
|
raise click.ClickException("Connection not confirmed before timeout; disconnect requested.")
|
|
387
412
|
_guard(vpn.remember, name)
|
|
@@ -20,7 +20,9 @@ edit can touch it.
|
|
|
20
20
|
- `status` - which fixes are currently in place
|
|
21
21
|
|
|
22
22
|
`check` and `probe` are pure Python sockets and need no privileges. `open` and
|
|
23
|
-
`close` edit hosts or manage a service, so they want administrator rights.
|
|
23
|
+
`close` edit hosts or manage a service, so they want administrator rights. On
|
|
24
|
+
macOS the DPI bypass is SpoofDPI run as your own LaunchAgent behind the system
|
|
25
|
+
proxy, which needs none.
|
|
24
26
|
"""
|
|
25
27
|
|
|
26
28
|
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
|
+
import sys
|
|
6
7
|
import tempfile
|
|
7
8
|
import zipfile
|
|
8
9
|
from pathlib import Path
|
|
@@ -27,6 +28,11 @@ def is_windows():
|
|
|
27
28
|
return os.name == "nt"
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def is_macos():
|
|
32
|
+
"""macOS gets SpoofDPI instead of GoodbyeDPI; see _spoofdpi."""
|
|
33
|
+
return sys.platform == "darwin"
|
|
34
|
+
|
|
35
|
+
|
|
30
36
|
def is_admin():
|
|
31
37
|
if not is_windows():
|
|
32
38
|
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
|
|
@@ -61,7 +67,7 @@ def _sc(*args, check=False):
|
|
|
61
67
|
|
|
62
68
|
def service_state():
|
|
63
69
|
if not is_windows():
|
|
64
|
-
return {"installed": False, "running": False, "args": None, "reason": "
|
|
70
|
+
return {"installed": False, "running": False, "args": None, "reason": "Windows and macOS only"}
|
|
65
71
|
result = _sc("qc", SERVICE_NAME)
|
|
66
72
|
if result.returncode != 0:
|
|
67
73
|
return {"installed": False, "running": False, "args": None, "reason": None}
|
|
@@ -37,12 +37,38 @@ def tls_probe(ip, sni, timeout=6.0):
|
|
|
37
37
|
handshake is allowed to finish, not whether the certificate matches the
|
|
38
38
|
name we announced.
|
|
39
39
|
"""
|
|
40
|
+
return _tls_over(lambda sock: sock.connect((ip, PORT)), ip, sni, timeout)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def proxy_probe(host, proxy, timeout=6.0):
|
|
44
|
+
"""Like ``tls_probe``, but through the HTTP proxy at ``proxy``: CONNECT first, then the handshake.
|
|
45
|
+
|
|
46
|
+
A raw socket never sees a bypass that lives in a proxy, so this is how to tell whether it works.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def connect(sock):
|
|
50
|
+
sock.connect(proxy)
|
|
51
|
+
sock.sendall(f"CONNECT {host}:{PORT} HTTP/1.1\r\nHost: {host}:{PORT}\r\n\r\n".encode())
|
|
52
|
+
reply = b""
|
|
53
|
+
while b"\r\n\r\n" not in reply:
|
|
54
|
+
chunk = sock.recv(4096)
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
reply += chunk
|
|
58
|
+
status = reply.split(b"\r\n", 1)[0].decode(errors="replace")
|
|
59
|
+
if status.split()[1:2] != ["200"]:
|
|
60
|
+
raise OSError(f"proxy answered {status or 'nothing'}")
|
|
61
|
+
|
|
62
|
+
return _tls_over(connect, f"{proxy[0]}:{proxy[1]}", host, timeout)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tls_over(connect, ip, sni, timeout):
|
|
40
66
|
result = {"ip": ip, "sni": sni, "tcp": False, "tls": False, "ms": None, "error": None, "reset": False}
|
|
41
67
|
started = time.perf_counter()
|
|
42
68
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
43
69
|
sock.settimeout(timeout)
|
|
44
70
|
try:
|
|
45
|
-
|
|
71
|
+
connect(sock)
|
|
46
72
|
result["tcp"] = True
|
|
47
73
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
48
74
|
context.check_hostname = False
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""The DPI bypass on macOS: SpoofDPI behind the system proxy, run as a LaunchAgent.
|
|
2
|
+
|
|
3
|
+
GoodbyeDPI has no macOS build. SpoofDPI is a local HTTP proxy that splits the
|
|
4
|
+
TLS ClientHello, so the filter never reads the server name in one piece. It only
|
|
5
|
+
does that for the hosts `evo route open` named: every other name passes through
|
|
6
|
+
untouched and resolves with the system resolver, so names only a VPN knows keep
|
|
7
|
+
working. The named hosts resolve over DoH instead, because a name the filter
|
|
8
|
+
blocks usually has its DNS answer poisoned too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import plistlib
|
|
16
|
+
import shutil
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from evo_cli.console import info, run_command
|
|
23
|
+
|
|
24
|
+
LABEL = "com.evo.route.spoofdpi"
|
|
25
|
+
LISTEN_HOST = "127.0.0.1"
|
|
26
|
+
# Off 8080, where development servers live.
|
|
27
|
+
LISTEN_PORT = 18080
|
|
28
|
+
|
|
29
|
+
# `first-byte` cuts the TLS record header off the rest of the ClientHello, and the
|
|
30
|
+
# filter does not put the two back together. SpoofDPI's default `sni` split, which
|
|
31
|
+
# cuts inside the server name, still gets the handshake reset (medium.com, 2026-10).
|
|
32
|
+
SPLIT_MODE = "first-byte"
|
|
33
|
+
|
|
34
|
+
CONFIG = f"""# Written by `evo route open`; the hosts it opened are listed in hosts.txt.
|
|
35
|
+
[dns]
|
|
36
|
+
mode = "system"
|
|
37
|
+
|
|
38
|
+
[https]
|
|
39
|
+
skip = true
|
|
40
|
+
|
|
41
|
+
[[rules]]
|
|
42
|
+
name = "evo route open"
|
|
43
|
+
priority = 50
|
|
44
|
+
match = {{ domains = ["file:hosts.txt"] }}
|
|
45
|
+
dns = {{ mode = "https", cache = true }}
|
|
46
|
+
https = {{ skip = false, split-mode = "{SPLIT_MODE}" }}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# networksetup names both proxies by these words: -getwebproxy, -setsecurewebproxystate, ...
|
|
50
|
+
PROXY_KINDS = ("webproxy", "securewebproxy")
|
|
51
|
+
|
|
52
|
+
# Chromium browsers, under ~/Library/Application Support; each profile keeps extension-held settings
|
|
53
|
+
# in its Secure Preferences.
|
|
54
|
+
BROWSERS = ("Google/Chrome", "BraveSoftware/Brave-Browser", "Microsoft Edge", "Chromium")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def home():
|
|
58
|
+
return Path(os.environ.get("EVO_ROUTE_HOME", str(Path.home() / ".evo" / "route")))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def config_path():
|
|
62
|
+
return home() / "spoofdpi.toml"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hosts_path():
|
|
66
|
+
return home() / "hosts.txt"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def agent_path():
|
|
70
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def log_path():
|
|
74
|
+
return Path.home() / "Library" / "Logs" / "evo-route-spoofdpi.log"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def listen():
|
|
78
|
+
return f"{LISTEN_HOST}:{LISTEN_PORT}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def opened():
|
|
82
|
+
"""The hosts the bypass covers, as `evo route open` named them."""
|
|
83
|
+
try:
|
|
84
|
+
lines = hosts_path().read_text(encoding="utf-8").splitlines()
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
return []
|
|
87
|
+
return [line for line in lines if line and not line.startswith(("#", "*."))]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_hosts(hosts):
|
|
91
|
+
home().mkdir(parents=True, exist_ok=True)
|
|
92
|
+
lines = [pattern for host in sorted(set(hosts)) for pattern in (host, f"*.{host}")]
|
|
93
|
+
hosts_path().write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def executable():
|
|
97
|
+
found = shutil.which("spoofdpi")
|
|
98
|
+
if found:
|
|
99
|
+
return found
|
|
100
|
+
# launchd and some shells start without Homebrew on PATH.
|
|
101
|
+
for candidate in ("/opt/homebrew/bin/spoofdpi", "/usr/local/bin/spoofdpi"):
|
|
102
|
+
if Path(candidate).exists():
|
|
103
|
+
return candidate
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install():
|
|
108
|
+
brew = shutil.which("brew") or next(
|
|
109
|
+
(path for path in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew") if Path(path).exists()), None
|
|
110
|
+
)
|
|
111
|
+
if not brew:
|
|
112
|
+
raise RuntimeError("SpoofDPI is not installed and neither is Homebrew - install one of them first.")
|
|
113
|
+
info("SpoofDPI is not installed yet, installing it with Homebrew")
|
|
114
|
+
run_command([brew, "install", "spoofdpi"])
|
|
115
|
+
found = executable()
|
|
116
|
+
if not found:
|
|
117
|
+
raise RuntimeError("brew installed spoofdpi, but it is not on PATH.")
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _program(exe):
|
|
122
|
+
return [exe, "--config", str(config_path()), "--no-tui", "--listen-addr", listen()]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_agent(exe):
|
|
126
|
+
"""Write the LaunchAgent; True when its content changed."""
|
|
127
|
+
plist = plistlib.dumps(
|
|
128
|
+
{
|
|
129
|
+
"Label": LABEL,
|
|
130
|
+
"ProgramArguments": _program(exe),
|
|
131
|
+
"RunAtLoad": True,
|
|
132
|
+
"KeepAlive": True,
|
|
133
|
+
"StandardOutPath": str(log_path()),
|
|
134
|
+
"StandardErrorPath": str(log_path()),
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
path = agent_path()
|
|
138
|
+
if path.exists() and path.read_bytes() == plist:
|
|
139
|
+
return False
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
log_path().parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
path.write_bytes(plist)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _launchctl(*args):
|
|
147
|
+
return subprocess.run(["launchctl", *args], capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _domain():
|
|
151
|
+
return f"gui/{os.getuid()}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _loaded():
|
|
155
|
+
return _launchctl("print", f"{_domain()}/{LABEL}").returncode == 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _listening(timeout=0.5):
|
|
159
|
+
try:
|
|
160
|
+
with socket.create_connection((LISTEN_HOST, LISTEN_PORT), timeout=timeout):
|
|
161
|
+
return True
|
|
162
|
+
except OSError:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _wait(condition, seconds):
|
|
167
|
+
deadline = time.monotonic() + seconds
|
|
168
|
+
while time.monotonic() < deadline:
|
|
169
|
+
if condition():
|
|
170
|
+
return True
|
|
171
|
+
time.sleep(0.2)
|
|
172
|
+
return condition()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _start(reload):
|
|
176
|
+
"""Start or restart the agent and wait for the proxy port. SpoofDPI reads hosts.txt only when it starts."""
|
|
177
|
+
if _loaded() and not reload:
|
|
178
|
+
_launchctl("kickstart", "-k", f"{_domain()}/{LABEL}")
|
|
179
|
+
else:
|
|
180
|
+
if _loaded():
|
|
181
|
+
_launchctl("bootout", f"{_domain()}/{LABEL}")
|
|
182
|
+
_wait(lambda: not _listening(), 5)
|
|
183
|
+
if _listening():
|
|
184
|
+
raise RuntimeError(f"Something else already listens on {listen()}.")
|
|
185
|
+
result = _launchctl("bootstrap", _domain(), str(agent_path()))
|
|
186
|
+
if result.returncode != 0:
|
|
187
|
+
raise RuntimeError(f"launchctl bootstrap failed: {(result.stderr or result.stdout).strip()}")
|
|
188
|
+
return _wait(_listening, 10)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _networksetup(*args):
|
|
192
|
+
return subprocess.run(
|
|
193
|
+
["networksetup", *args], capture_output=True, text=True, stdin=subprocess.DEVNULL, check=False
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def network_services():
|
|
198
|
+
"""Enabled network services. The first line of the listing explains that `*` marks a disabled one."""
|
|
199
|
+
lines = _networksetup("-listallnetworkservices").stdout.splitlines()[1:]
|
|
200
|
+
return [line for line in lines if line.strip() and not line.startswith("*")]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _proxy(service, kind):
|
|
204
|
+
fields = {}
|
|
205
|
+
for line in _networksetup(f"-get{kind}", service).stdout.splitlines():
|
|
206
|
+
key, _, value = line.partition(":")
|
|
207
|
+
fields[key.strip()] = value.strip()
|
|
208
|
+
return fields.get("Enabled") == "Yes", fields.get("Server", ""), fields.get("Port", "")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ours(server, port):
|
|
212
|
+
return server == LISTEN_HOST and port == str(LISTEN_PORT)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def proxied_services():
|
|
216
|
+
"""The services whose system proxy points at the bypass."""
|
|
217
|
+
services = []
|
|
218
|
+
for service in network_services():
|
|
219
|
+
proxies = [_proxy(service, kind) for kind in PROXY_KINDS]
|
|
220
|
+
if any(enabled and _ours(server, port) for enabled, server, port in proxies):
|
|
221
|
+
services.append(service)
|
|
222
|
+
return services
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _proxy_conflicts():
|
|
226
|
+
conflicts = []
|
|
227
|
+
for service in network_services():
|
|
228
|
+
for kind in PROXY_KINDS:
|
|
229
|
+
enabled, server, port = _proxy(service, kind)
|
|
230
|
+
if enabled and not _ours(server, port):
|
|
231
|
+
conflicts.append(f"{service} ({server}:{port})")
|
|
232
|
+
return conflicts
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_system_proxy():
|
|
236
|
+
services = network_services()
|
|
237
|
+
for service in services:
|
|
238
|
+
for kind in PROXY_KINDS:
|
|
239
|
+
result = _networksetup(f"-set{kind}", service, LISTEN_HOST, str(LISTEN_PORT))
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
f"networksetup -set{kind} {service} failed: {(result.stderr or result.stdout).strip()}"
|
|
243
|
+
)
|
|
244
|
+
return services
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def clear_system_proxy():
|
|
248
|
+
"""Turn off only the proxies that point at the bypass; one the user set stays as it was."""
|
|
249
|
+
cleared = []
|
|
250
|
+
for service in network_services():
|
|
251
|
+
for kind in PROXY_KINDS:
|
|
252
|
+
enabled, server, port = _proxy(service, kind)
|
|
253
|
+
if enabled and _ours(server, port):
|
|
254
|
+
_networksetup(f"-set{kind}state", service, "off")
|
|
255
|
+
if service not in cleared:
|
|
256
|
+
cleared.append(service)
|
|
257
|
+
return cleared
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _extension_name(profile, ext_id):
|
|
261
|
+
for path in sorted((profile / "Extensions" / ext_id).glob("*/manifest.json")):
|
|
262
|
+
try:
|
|
263
|
+
manifest = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
264
|
+
name = manifest.get("name", "")
|
|
265
|
+
if name.startswith("__MSG_"):
|
|
266
|
+
# A localised name: "__MSG_key__" looked up, ignoring case, in the default locale's messages.
|
|
267
|
+
locale = manifest.get("default_locale", "en")
|
|
268
|
+
messages = json.loads(
|
|
269
|
+
(path.parent / "_locales" / locale / "messages.json").read_text(encoding="utf-8-sig")
|
|
270
|
+
)
|
|
271
|
+
key = name[len("__MSG_") : -len("__")].lower()
|
|
272
|
+
name = next((value["message"] for k, value in messages.items() if k.lower() == key), "")
|
|
273
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
274
|
+
continue
|
|
275
|
+
if name:
|
|
276
|
+
return name
|
|
277
|
+
return ext_id
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def browser_overrides(support=None):
|
|
281
|
+
"""Enabled browser extensions that hold the proxy setting, so their browser never sees the system proxy.
|
|
282
|
+
|
|
283
|
+
A VPN extension keeps holding it while switched off, set to `direct`.
|
|
284
|
+
"""
|
|
285
|
+
support = Path(support or Path.home() / "Library" / "Application Support")
|
|
286
|
+
found = []
|
|
287
|
+
for browser in BROWSERS:
|
|
288
|
+
for prefs in sorted((support / browser).glob("*/Secure Preferences")):
|
|
289
|
+
try:
|
|
290
|
+
settings = json.loads(prefs.read_text(encoding="utf-8"))["extensions"]["settings"]
|
|
291
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
292
|
+
continue
|
|
293
|
+
for ext_id, ext in settings.items():
|
|
294
|
+
enabled = not ext.get("disable_reasons") and ext.get("state", 1) != 0
|
|
295
|
+
if enabled and "proxy" in ext.get("preferences", {}):
|
|
296
|
+
name = _extension_name(prefs.parent, ext_id)
|
|
297
|
+
found.append(f"{browser.split('/')[-1]} ({prefs.parent.name}): {name}")
|
|
298
|
+
return found
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def service_state():
|
|
302
|
+
exe = executable()
|
|
303
|
+
return {
|
|
304
|
+
"installed": agent_path().exists(),
|
|
305
|
+
"running": _loaded() and _listening(),
|
|
306
|
+
"args": " ".join(_program(exe or "spoofdpi")),
|
|
307
|
+
"reason": None,
|
|
308
|
+
"hosts": opened(),
|
|
309
|
+
"proxy": proxied_services(),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def ensure(host):
|
|
314
|
+
"""Put ``host`` through the bypass: install SpoofDPI, (re)start its agent, point the system proxy at it."""
|
|
315
|
+
conflicts = _proxy_conflicts()
|
|
316
|
+
if conflicts:
|
|
317
|
+
raise RuntimeError(f"A proxy is already set on {', '.join(conflicts)}; evo will not replace it.")
|
|
318
|
+
exe = executable() or install()
|
|
319
|
+
home().mkdir(parents=True, exist_ok=True)
|
|
320
|
+
config_path().write_text(CONFIG, encoding="utf-8")
|
|
321
|
+
_write_hosts([*opened(), host])
|
|
322
|
+
if not _start(reload=_write_agent(exe)):
|
|
323
|
+
return service_state()
|
|
324
|
+
set_system_proxy()
|
|
325
|
+
return service_state()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def forget(host):
|
|
329
|
+
"""Take ``host`` out of the bypass; the last one out stops it altogether."""
|
|
330
|
+
hosts = [name for name in opened() if name != host]
|
|
331
|
+
if not hosts:
|
|
332
|
+
return remove()
|
|
333
|
+
_write_hosts(hosts)
|
|
334
|
+
_start(reload=False)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def remove():
|
|
339
|
+
"""Clear the system proxy first, so nothing points at a proxy that is gone, then stop and drop the agent."""
|
|
340
|
+
clear_system_proxy()
|
|
341
|
+
_launchctl("bootout", f"{_domain()}/{LABEL}")
|
|
342
|
+
agent_path().unlink(missing_ok=True)
|
|
343
|
+
hosts_path().unlink(missing_ok=True)
|
|
344
|
+
return True
|