evo-cli 0.34.3__tar.gz → 0.35.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.34.3 → evo_cli-0.35.1}/PKG-INFO +1 -1
- evo_cli-0.35.1/evo_cli/VERSION +1 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/openvpn.py +6 -1
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_spoofdpi.py +60 -3
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/openvpn.py +83 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_openvpn.py +99 -1
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_route.py +30 -0
- evo_cli-0.34.3/evo_cli/VERSION +0 -1
- {evo_cli-0.34.3 → evo_cli-0.35.1}/Containerfile +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/HISTORY.md +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/LICENSE +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/MANIFEST.in +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/README.md +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/base.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/cli.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_hub.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_mirror.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_page.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/_probe.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/fix.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/console.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/SOURCES.txt +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/pyproject.toml +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/setup.cfg +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/__init__.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/fake_evo_agents.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/fixtures/hub/contract-0.2.0.json +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/fixtures/hub/mirror-demo.yaml +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_cli.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_console.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_cred.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_download.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_gdrive.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_gh.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_harness.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_harness_hub.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_harness_show.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_image.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_ngrok.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_serp.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_storage.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_tts.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_update.py +0 -0
- {evo_cli-0.34.3 → evo_cli-0.35.1}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.35.1
|
|
@@ -390,7 +390,12 @@ def connect(name, timeout, sudo_password_stdin, watch):
|
|
|
390
390
|
process.stdin.write((password + "\n").encode())
|
|
391
391
|
process.stdin.close()
|
|
392
392
|
password = ""
|
|
393
|
-
|
|
393
|
+
dns = (
|
|
394
|
+
"Names the server resolves (split DNS) go to its DNS while connected; other DNS is unchanged."
|
|
395
|
+
if sys.platform == "darwin"
|
|
396
|
+
else "System DNS is not modified."
|
|
397
|
+
)
|
|
398
|
+
click.echo(f"Connecting {name}; disconnect any external GUI VPN first. {dns}")
|
|
394
399
|
deadline = time.monotonic() + timeout + 20
|
|
395
400
|
shown = None
|
|
396
401
|
try:
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
GoodbyeDPI has no macOS build. SpoofDPI is a local HTTP proxy that splits the
|
|
4
4
|
TLS ClientHello, so the filter never reads the server name in one piece. It only
|
|
5
5
|
does that for the hosts `evo route open` named: every other name passes through
|
|
6
|
-
untouched and resolves with the system
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
untouched and resolves with the system's DNS server. The named hosts resolve over
|
|
7
|
+
DoH instead, because a name the filter blocks usually has its DNS answer poisoned
|
|
8
|
+
too. SpoofDPI's resolver never reads /etc/resolver, so names a connected evo VPN
|
|
9
|
+
resolves through split DNS skip the proxy altogether (`sync_vpn_bypass`).
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -46,6 +47,10 @@ dns = {{ mode = "https", cache = true }}
|
|
|
46
47
|
https = {{ skip = false, split-mode = "{SPLIT_MODE}" }}
|
|
47
48
|
"""
|
|
48
49
|
|
|
50
|
+
# The evo openvpn DNS hook starts each /etc/resolver file it writes with this tag.
|
|
51
|
+
RESOLVER = Path("/etc/resolver")
|
|
52
|
+
VPN_TAG = "# evo openvpn "
|
|
53
|
+
|
|
49
54
|
# networksetup names both proxies by these words: -getwebproxy, -setsecurewebproxystate, ...
|
|
50
55
|
PROXY_KINDS = ("webproxy", "securewebproxy")
|
|
51
56
|
|
|
@@ -258,6 +263,56 @@ def clear_system_proxy():
|
|
|
258
263
|
return cleared
|
|
259
264
|
|
|
260
265
|
|
|
266
|
+
def vpn_domains():
|
|
267
|
+
"""Domains a connected evo VPN resolves with its own DNS: its tagged files under /etc/resolver."""
|
|
268
|
+
domains = []
|
|
269
|
+
for path in sorted(RESOLVER.glob("*")):
|
|
270
|
+
try:
|
|
271
|
+
with path.open(encoding="utf-8") as handle:
|
|
272
|
+
tagged = handle.readline().startswith(VPN_TAG)
|
|
273
|
+
except (OSError, UnicodeDecodeError):
|
|
274
|
+
continue
|
|
275
|
+
if tagged:
|
|
276
|
+
domains.append(path.name)
|
|
277
|
+
return domains
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _bypass(service):
|
|
281
|
+
lines = _networksetup("-getproxybypassdomains", service).stdout.splitlines()
|
|
282
|
+
return [line.strip() for line in lines if line.strip() and not line.startswith("There aren't any")]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def sync_vpn_bypass():
|
|
286
|
+
"""Let the names a connected evo VPN resolves skip the proxy, and take them out again once it is down.
|
|
287
|
+
|
|
288
|
+
SpoofDPI would ask the system's DNS server for them and fail. Each service whose proxy is ours bypasses those
|
|
289
|
+
domains; what this added is recorded per service, so only that comes out again and a user's entries stay.
|
|
290
|
+
"""
|
|
291
|
+
record = home() / "vpn-bypass.json"
|
|
292
|
+
try:
|
|
293
|
+
added = json.loads(record.read_text())
|
|
294
|
+
except (OSError, ValueError):
|
|
295
|
+
added = {}
|
|
296
|
+
wanted = [entry for domain in vpn_domains() for entry in (domain, f"*.{domain}")]
|
|
297
|
+
proxied = proxied_services()
|
|
298
|
+
now = {}
|
|
299
|
+
for service in network_services():
|
|
300
|
+
mine = added.get(service, [])
|
|
301
|
+
if not mine and service not in proxied:
|
|
302
|
+
continue
|
|
303
|
+
current = _bypass(service)
|
|
304
|
+
kept = [entry for entry in current if entry not in mine]
|
|
305
|
+
new = [entry for entry in wanted if entry not in kept] if service in proxied else []
|
|
306
|
+
if kept + new != current:
|
|
307
|
+
_networksetup("-setproxybypassdomains", service, *(kept + new or ["Empty"]))
|
|
308
|
+
if new:
|
|
309
|
+
now[service] = new
|
|
310
|
+
if now or added:
|
|
311
|
+
home().mkdir(parents=True, exist_ok=True)
|
|
312
|
+
record.write_text(json.dumps(now))
|
|
313
|
+
return now
|
|
314
|
+
|
|
315
|
+
|
|
261
316
|
def _extension_name(profile, ext_id):
|
|
262
317
|
for path in sorted((profile / "Extensions" / ext_id).glob("*/manifest.json")):
|
|
263
318
|
try:
|
|
@@ -323,6 +378,7 @@ def ensure(*hosts):
|
|
|
323
378
|
if not _start(reload=_write_agent(exe)):
|
|
324
379
|
return service_state()
|
|
325
380
|
set_system_proxy()
|
|
381
|
+
sync_vpn_bypass()
|
|
326
382
|
return service_state()
|
|
327
383
|
|
|
328
384
|
|
|
@@ -339,6 +395,7 @@ def forget(host):
|
|
|
339
395
|
def remove():
|
|
340
396
|
"""Clear the system proxy first, so nothing points at a proxy that is gone, then stop and drop the agent."""
|
|
341
397
|
clear_system_proxy()
|
|
398
|
+
sync_vpn_bypass()
|
|
342
399
|
_launchctl("bootout", f"{_domain()}/{LABEL}")
|
|
343
400
|
agent_path().unlink(missing_ok=True)
|
|
344
401
|
hosts_path().unlink(missing_ok=True)
|
|
@@ -290,6 +290,83 @@ def pin_remotes(command, remotes):
|
|
|
290
290
|
return ["sh", "-c", script, "sh", *command]
|
|
291
291
|
|
|
292
292
|
|
|
293
|
+
# Run by OpenVPN as root (--route-up, --down) from a root-owned copy. It gives each domain a server resolves
|
|
294
|
+
# (pushed "dns server N resolve-domains") an /etc/resolver file naming that server, so macOS sends only those
|
|
295
|
+
# names into the tunnel. Files carry a tag line; a file without this profile's tag is never touched. Values come
|
|
296
|
+
# from the server, so only plain domains and addresses on port 53 are used, and eval sees only digits.
|
|
297
|
+
DNS_HOOK = r"""
|
|
298
|
+
umask 022
|
|
299
|
+
tag={tag}
|
|
300
|
+
dir={directory}
|
|
301
|
+
for f in "$dir"/*; do
|
|
302
|
+
[ -f "$f" ] && [ "$(head -n 1 "$f")" = "$tag" ] && rm -f "$f"
|
|
303
|
+
done
|
|
304
|
+
if [ "$1" = up ]; then
|
|
305
|
+
mkdir -p "$dir"
|
|
306
|
+
for n in $(env | sed -n 's/^dns_server_\([0-9]\{{1,3\}}\)_address_1=.*/\1/p' | sort -n); do
|
|
307
|
+
servers= m=1
|
|
308
|
+
while [ $m -le 8 ]; do
|
|
309
|
+
eval "a=\${{dns_server_${{n}}_address_$m-}} p=\${{dns_server_${{n}}_port_$m-53}}"
|
|
310
|
+
case $a$p in *[!0-9A-Fa-f:.]*) ;; *) [ -n "$a" ] && [ "$p" = 53 ] && servers="$servers $a" ;; esac
|
|
311
|
+
m=$((m + 1))
|
|
312
|
+
done
|
|
313
|
+
m=1
|
|
314
|
+
while eval "d=\${{dns_server_${{n}}_resolve_domain_$m-}}"; [ -n "$d" ]; do
|
|
315
|
+
m=$((m + 1))
|
|
316
|
+
[ -n "$servers" ] || continue
|
|
317
|
+
case $d in .*|*.|*..*|*[!A-Za-z0-9.-]*) continue ;; esac
|
|
318
|
+
[ -e "$dir/$d" ] || printf '%s\n' "$tag" > "$dir/$d"
|
|
319
|
+
[ "$(head -n 1 "$dir/$d")" = "$tag" ] || continue
|
|
320
|
+
for a in $servers; do printf 'nameserver %s\n' "$a" >> "$dir/$d"; done
|
|
321
|
+
done
|
|
322
|
+
done
|
|
323
|
+
fi
|
|
324
|
+
killall -HUP mDNSResponder 2>/dev/null
|
|
325
|
+
exit 0
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def split_dns(command, name, hooks=Path("/var/run"), resolver=Path("/etc/resolver")):
|
|
330
|
+
"""Wrap OpenVPN so the split DNS its server pushes applies while it runs, and is undone however it ends.
|
|
331
|
+
|
|
332
|
+
OpenVPN 2.6 leaves pushed DNS to scripts on macOS. The wrapper runs as root: it writes the hook where only
|
|
333
|
+
root can write (never a user-writable file run as root), lets OpenVPN call it after routes are up and on
|
|
334
|
+
close, and runs it once more after OpenVPN exits, so a crash leaves no resolver pointing into the tunnel.
|
|
335
|
+
"""
|
|
336
|
+
hook = shlex.quote(str(hooks / f"evo-openvpn-{session_path(name).name}.sh"))
|
|
337
|
+
script = DNS_HOOK.format(tag=shlex.quote(f"# evo openvpn {name}"), directory=shlex.quote(str(resolver)))
|
|
338
|
+
wrapper = "; ".join(
|
|
339
|
+
[
|
|
340
|
+
f'rm -f {hook} && (umask 077 && printf "%s" "$1" > {hook}) || exit 1',
|
|
341
|
+
"shift",
|
|
342
|
+
# Leftovers of a run that died with its wrapper would send the remote's name into a tunnel not up.
|
|
343
|
+
f"/bin/sh {hook} down",
|
|
344
|
+
"trap : HUP INT TERM",
|
|
345
|
+
'"$@"',
|
|
346
|
+
"code=$?",
|
|
347
|
+
f"/bin/sh {hook} down",
|
|
348
|
+
f"rm -f {hook}",
|
|
349
|
+
'exit "$code"',
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
hooked = list(command)
|
|
353
|
+
hooked[hooked.index("--script-security") + 1] = "2"
|
|
354
|
+
hooked += ["--route-up", f"/bin/sh {hook} up", "--down", f"/bin/sh {hook} down"]
|
|
355
|
+
return ["sh", "-c", wrapper, "sh", script, *hooked]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def sync_proxy_bypass():
|
|
359
|
+
"""On macOS, let the names this VPN resolves skip evo route's proxy; never fatal to the connection."""
|
|
360
|
+
if sys.platform != "darwin":
|
|
361
|
+
return
|
|
362
|
+
try:
|
|
363
|
+
from evo_cli.commands.route import _spoofdpi
|
|
364
|
+
|
|
365
|
+
_spoofdpi.sync_vpn_bypass()
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
|
|
293
370
|
def parse_totp(value):
|
|
294
371
|
value = value.strip()
|
|
295
372
|
settings = {"secret": value, "algorithm": "SHA1", "digits": 6, "period": 30}
|
|
@@ -657,6 +734,8 @@ def worker(name, timeout):
|
|
|
657
734
|
command += ["--setenv", "IV_SSO", "openurl,webauth"]
|
|
658
735
|
if ciphers := data_ciphers(profile["config"]):
|
|
659
736
|
command += ["--data-ciphers", ciphers]
|
|
737
|
+
if sys.platform == "darwin":
|
|
738
|
+
command = split_dns(command, name)
|
|
660
739
|
pinned = [] if takes_default_route(profile["config"]) else remotes
|
|
661
740
|
if profile.get("otp_url"):
|
|
662
741
|
pinned = [*pinned, urlparse(profile["otp_url"]).hostname]
|
|
@@ -744,6 +823,8 @@ def worker(name, timeout):
|
|
|
744
823
|
state["since"] = time.time()
|
|
745
824
|
state.pop("auth_url", None)
|
|
746
825
|
state.pop("auth_expires", None)
|
|
826
|
+
# The DNS hook ran before CONNECTED (route-up); its domains now exist.
|
|
827
|
+
sync_proxy_bypass()
|
|
747
828
|
# Once up, let OpenVPN retry for as long as the network is gone.
|
|
748
829
|
deadline = float("inf")
|
|
749
830
|
elif fields[1] != "CONNECTED":
|
|
@@ -795,6 +876,8 @@ def worker(name, timeout):
|
|
|
795
876
|
"error": "OpenVPN did not exit; inspect processes manually.",
|
|
796
877
|
}
|
|
797
878
|
publish()
|
|
879
|
+
# OpenVPN has exited and its DNS hook has removed the domains; the proxy takes them back.
|
|
880
|
+
sync_proxy_bypass()
|
|
798
881
|
selector.close()
|
|
799
882
|
control.close()
|
|
800
883
|
listener.close()
|
|
@@ -18,6 +18,14 @@ CONFIG = "client\ndev tun\nremote vpn.example 1194 udp\nauth-user-pass\n<ca>\nte
|
|
|
18
18
|
SECRET = base64.b32encode(b"12345678901234567890").decode()
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def no_proxy_sync(monkeypatch):
|
|
23
|
+
"""The worker would otherwise change this machine's proxy bypass list."""
|
|
24
|
+
synced = []
|
|
25
|
+
monkeypatch.setattr(vpn, "sync_proxy_bypass", lambda: synced.append(True))
|
|
26
|
+
return synced
|
|
27
|
+
|
|
28
|
+
|
|
21
29
|
@pytest.fixture
|
|
22
30
|
def store(tmp_path, monkeypatch):
|
|
23
31
|
if sys.platform == "win32":
|
|
@@ -398,6 +406,94 @@ def test_worker_passes_the_profile_cipher(store, monkeypatch):
|
|
|
398
406
|
assert command[command.index("--data-ciphers") + 1].endswith(":AES-256-CBC")
|
|
399
407
|
|
|
400
408
|
|
|
409
|
+
SPLIT_DNS_ENV = {
|
|
410
|
+
"dns_server_10_address_1": "10.0.0.1",
|
|
411
|
+
"dns_server_10_resolve_domain_1": "corp.example",
|
|
412
|
+
"dns_server_10_resolve_domain_2": "../evil",
|
|
413
|
+
"dns_server_10_resolve_domain_3": "a b",
|
|
414
|
+
"dns_server_10_resolve_domain_4": "mine.example",
|
|
415
|
+
"dns_server_11_address_1": "10.0.0.2",
|
|
416
|
+
"dns_server_11_resolve_domain_1": "corp.example",
|
|
417
|
+
"dns_server_12_address_1": "10.0.0.3",
|
|
418
|
+
"dns_server_12_port_1": "5353",
|
|
419
|
+
"dns_server_12_resolve_domain_1": "odd.example",
|
|
420
|
+
"dns_server_13_address_1": "10.0.0.4; touch pwned",
|
|
421
|
+
"dns_server_13_resolve_domain_1": "bad.example",
|
|
422
|
+
"dns_server_14_address_1": "10.0.0.5",
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell hook")
|
|
427
|
+
def test_dns_hook_writes_split_dns_and_removes_only_its_own(tmp_path):
|
|
428
|
+
resolver = tmp_path / "resolver"
|
|
429
|
+
resolver.mkdir()
|
|
430
|
+
(resolver / "mine.example").write_text("nameserver 192.0.2.1\n")
|
|
431
|
+
(resolver / "other.example").write_text("# evo openvpn other\nnameserver 192.0.2.2\n")
|
|
432
|
+
script = vpn.DNS_HOOK.format(tag="'# evo openvpn m1'", directory=str(resolver))
|
|
433
|
+
|
|
434
|
+
def hook(stage):
|
|
435
|
+
env = {"PATH": "/usr/bin:/bin", **SPLIT_DNS_ENV}
|
|
436
|
+
subprocess.run(["sh", "-c", script, "hook", stage, "utun9", "1420", "init"], env=env, cwd=tmp_path, check=True)
|
|
437
|
+
|
|
438
|
+
hook("up")
|
|
439
|
+
files = {path.name: path.read_text() for path in resolver.iterdir()}
|
|
440
|
+
# Both servers of a domain, in server order; no file for a bad name, port, address or a server without domains.
|
|
441
|
+
assert files["corp.example"] == "# evo openvpn m1\nnameserver 10.0.0.1\nnameserver 10.0.0.2\n"
|
|
442
|
+
assert set(files) == {"corp.example", "mine.example", "other.example"}
|
|
443
|
+
assert files["mine.example"] == "nameserver 192.0.2.1\n"
|
|
444
|
+
assert not (tmp_path / "evil").exists() and not (tmp_path / "pwned").exists()
|
|
445
|
+
hook("up") # a restart replaces its own files instead of appending to them
|
|
446
|
+
assert (resolver / "corp.example").read_text().count("nameserver") == 2
|
|
447
|
+
hook("down")
|
|
448
|
+
assert sorted(path.name for path in resolver.iterdir()) == ["mine.example", "other.example"]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
FAKE_HOOKED_OPENVPN = """
|
|
452
|
+
import os, shlex, subprocess, sys
|
|
453
|
+
resolver = sys.argv[1]
|
|
454
|
+
args = sys.argv[2:]
|
|
455
|
+
assert not os.path.exists(os.path.join(resolver, "stale.example")), "leftover not cleared before start"
|
|
456
|
+
assert args[args.index("--script-security") + 1] == "2"
|
|
457
|
+
command = shlex.split(args[args.index("--route-up") + 1]) + ["utun9", "1420", "init"]
|
|
458
|
+
subprocess.run(command, env={**os.environ, "dns_server_10_address_1": "10.0.0.1",
|
|
459
|
+
"dns_server_10_resolve_domain_1": "corp.example"}, check=True)
|
|
460
|
+
assert open(os.path.join(resolver, "corp.example")).read().endswith("nameserver 10.0.0.1\\n")
|
|
461
|
+
sys.exit(7) # dies without running --down
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_split_dns_wrapper_undoes_everything_when_openvpn_dies(store, tmp_path):
|
|
466
|
+
hooks, resolver = tmp_path / "run", tmp_path / "resolver"
|
|
467
|
+
hooks.mkdir()
|
|
468
|
+
resolver.mkdir()
|
|
469
|
+
(resolver / "stale.example").write_text("# evo openvpn m1\nnameserver 10.9.9.9\n")
|
|
470
|
+
command = [sys.executable, "-c", FAKE_HOOKED_OPENVPN, str(resolver), "--script-security", "1", "--verb", "3"]
|
|
471
|
+
wrapped = vpn.split_dns(command, "m1", hooks=hooks, resolver=resolver)
|
|
472
|
+
result = subprocess.run(wrapped, capture_output=True, text=True, check=False)
|
|
473
|
+
assert result.returncode == 7, result.stderr
|
|
474
|
+
assert list(resolver.iterdir()) == [] and list(hooks.iterdir()) == []
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@pytest.mark.parametrize("platform,hooked", [("darwin", True), ("linux", False)])
|
|
478
|
+
def test_worker_applies_split_dns_on_macos_only(store, monkeypatch, platform, hooked):
|
|
479
|
+
vpn.save_profile("m1", {"config": CERT_ONLY})
|
|
480
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("\n"))
|
|
481
|
+
monkeypatch.setattr(vpn.os, "geteuid", lambda: 0)
|
|
482
|
+
monkeypatch.setattr(vpn.shutil, "which", lambda _: "/fake/openvpn")
|
|
483
|
+
monkeypatch.setattr(vpn.sys, "platform", platform)
|
|
484
|
+
monkeypatch.setattr(vpn, "pin_remotes", lambda command, hosts: command)
|
|
485
|
+
started = []
|
|
486
|
+
|
|
487
|
+
def popen(command, **_):
|
|
488
|
+
started.append(command)
|
|
489
|
+
raise OSError("stop before running anything")
|
|
490
|
+
|
|
491
|
+
monkeypatch.setattr(vpn.subprocess, "Popen", popen)
|
|
492
|
+
vpn.worker("m1", 10)
|
|
493
|
+
assert ("--route-up" in started[0]) == hooked
|
|
494
|
+
assert started[0][started[0].index("--script-security") + 1] == ("2" if hooked else "1")
|
|
495
|
+
|
|
496
|
+
|
|
401
497
|
@pytest.mark.parametrize(
|
|
402
498
|
"message,expected",
|
|
403
499
|
[
|
|
@@ -555,7 +651,7 @@ with socket.socket(socket.AF_UNIX) as s:
|
|
|
555
651
|
|
|
556
652
|
|
|
557
653
|
@pytest.mark.parametrize("mode", ["ok", "fail", "reconnect", "webauth"])
|
|
558
|
-
def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode):
|
|
654
|
+
def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode, no_proxy_sync):
|
|
559
655
|
fail, webauth = mode == "fail", mode == "webauth"
|
|
560
656
|
config = CERT_ONLY if webauth else CONFIG
|
|
561
657
|
vpn.save_profile(
|
|
@@ -602,6 +698,8 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
|
|
|
602
698
|
thread.join(5)
|
|
603
699
|
assert not thread.is_alive()
|
|
604
700
|
assert opened == (["https://vpn.example/auth/1"] if webauth else [])
|
|
701
|
+
# The proxy bypass follows the DNS hook: on each CONNECTED, and once more after OpenVPN exits.
|
|
702
|
+
assert len(no_proxy_sync) == {"fail": 1, "reconnect": 3}.get(mode, 2)
|
|
605
703
|
assert vpn.request("m1")["state"] == "STOPPED"
|
|
606
704
|
base = vpn.session_path("m1")
|
|
607
705
|
state = json.loads(Path(str(base) + ".json").read_text())
|
|
@@ -254,6 +254,36 @@ def _fake_networksetup(proxies, calls):
|
|
|
254
254
|
return run
|
|
255
255
|
|
|
256
256
|
|
|
257
|
+
def test_vpn_split_dns_domains_skip_the_proxy_and_only_ours_come_out(tmp_path, monkeypatch):
|
|
258
|
+
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path / "route"))
|
|
259
|
+
resolver = tmp_path / "resolver"
|
|
260
|
+
resolver.mkdir()
|
|
261
|
+
monkeypatch.setattr(_spoofdpi, "RESOLVER", resolver)
|
|
262
|
+
(resolver / "corp.example").write_text("# evo openvpn m1\nnameserver 10.0.0.1\n")
|
|
263
|
+
(resolver / "mine.example").write_text("nameserver 192.0.2.1\n")
|
|
264
|
+
ours = (True, _spoofdpi.LISTEN_HOST, str(_spoofdpi.LISTEN_PORT))
|
|
265
|
+
proxies = {("Wi-Fi", "webproxy"): ours, ("Wi-Fi", "securewebproxy"): ours}
|
|
266
|
+
bypass = {"Wi-Fi": ["*.local", "*.corp.example"], "USB LAN": ["*.local"]}
|
|
267
|
+
fake = _fake_networksetup(proxies, [])
|
|
268
|
+
|
|
269
|
+
def run(*args):
|
|
270
|
+
if args[0] == "-getproxybypassdomains":
|
|
271
|
+
out = "\n".join(bypass[args[1]]) or f"There aren't any bypass domains set on {args[1]}."
|
|
272
|
+
return subprocess.CompletedProcess(args, 0, out + "\n", "")
|
|
273
|
+
if args[0] == "-setproxybypassdomains":
|
|
274
|
+
bypass[args[1]] = [] if args[2:] == ("Empty",) else list(args[2:])
|
|
275
|
+
return subprocess.CompletedProcess(args, 0, "", "")
|
|
276
|
+
return fake(*args)
|
|
277
|
+
|
|
278
|
+
monkeypatch.setattr(_spoofdpi, "_networksetup", run)
|
|
279
|
+
# Only the tagged domain, only on the proxied service; the user's own *.corp.example is not added twice.
|
|
280
|
+
assert _spoofdpi.sync_vpn_bypass() == {"Wi-Fi": ["corp.example"]}
|
|
281
|
+
assert bypass == {"Wi-Fi": ["*.local", "*.corp.example", "corp.example"], "USB LAN": ["*.local"]}
|
|
282
|
+
(resolver / "corp.example").unlink() # the VPN is down
|
|
283
|
+
assert _spoofdpi.sync_vpn_bypass() == {}
|
|
284
|
+
assert bypass == {"Wi-Fi": ["*.local", "*.corp.example"], "USB LAN": ["*.local"]}
|
|
285
|
+
|
|
286
|
+
|
|
257
287
|
def test_spoofdpi_host_list_round_trips_with_subdomains(tmp_path, monkeypatch):
|
|
258
288
|
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
|
|
259
289
|
_spoofdpi._write_hosts(["medium.com", "x.com", "medium.com"])
|
evo_cli-0.34.3/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.34.3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|