evo-cli 0.32.1__tar.gz → 0.33.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.32.1 → evo_cli-0.33.0}/PKG-INFO +1 -1
- evo_cli-0.33.0/evo_cli/VERSION +1 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/__init__.py +1 -1
- evo_cli-0.33.0/evo_cli/commands/route/_page.py +96 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/_probe.py +6 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/_spoofdpi.py +6 -5
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/fix.py +63 -5
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/SOURCES.txt +1 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_route.py +156 -2
- evo_cli-0.32.1/evo_cli/VERSION +0 -1
- {evo_cli-0.32.1 → evo_cli-0.33.0}/Containerfile +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/HISTORY.md +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/LICENSE +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/MANIFEST.in +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/README.md +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/base.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/cli.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_hub.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_mirror.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/openvpn.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/console.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/openvpn.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/pyproject.toml +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/setup.cfg +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/__init__.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/fake_evo_agents.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_cli.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_console.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_cred.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_download.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_gdrive.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_gh.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_harness.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_harness_hub.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_harness_show.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_image.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_ngrok.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_openvpn.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_serp.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_storage.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_tts.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_update.py +0 -0
- {evo_cli-0.32.1 → evo_cli-0.33.0}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.33.0
|
|
@@ -15,7 +15,7 @@ edit can touch it.
|
|
|
15
15
|
|
|
16
16
|
- `check <host>` - what stands in the way, and which of the two it is
|
|
17
17
|
- `probe <host>` - every address for the name, ranked by what answers
|
|
18
|
-
- `open <host>` - apply the fix the diagnosis calls for
|
|
18
|
+
- `open <host>` - apply the fix the diagnosis calls for, and see to what its page loads
|
|
19
19
|
- `close <host>` - undo it again
|
|
20
20
|
- `status` - which fixes are currently in place
|
|
21
21
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""What a page loads besides itself: a site is not open until its stylesheets and images are."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
|
|
11
|
+
from evo_cli.commands.route import _probe
|
|
12
|
+
|
|
13
|
+
# Far past any <head>, and the rest of a long page repeats the same hosts.
|
|
14
|
+
MAX_PAGE = 2 * 1024 * 1024
|
|
15
|
+
WORKERS = 8
|
|
16
|
+
USER_AGENT = (
|
|
17
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
|
|
18
|
+
"(KHTML, like Gecko) Version/17.6 Safari/605.1.15"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# The <link> kinds a browser acts on by itself, unlike canonical or alternate.
|
|
22
|
+
LINK_RELS = {"stylesheet", "preload", "modulepreload", "preconnect", "dns-prefetch", "icon"}
|
|
23
|
+
SOURCE_TAGS = {"script", "img", "source", "video", "audio", "iframe"}
|
|
24
|
+
|
|
25
|
+
# Under a country code these are a public suffix, not a site: example.com.vn, example.co.uk.
|
|
26
|
+
COUNTRY_SECOND_LEVEL = {"ac", "co", "com", "edu", "gov", "net", "org"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _AssetParser(HTMLParser):
|
|
30
|
+
def __init__(self):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.urls = []
|
|
33
|
+
|
|
34
|
+
def handle_starttag(self, tag, attrs):
|
|
35
|
+
fields = dict(attrs)
|
|
36
|
+
if tag == "link":
|
|
37
|
+
if LINK_RELS & set((fields.get("rel") or "").lower().split()):
|
|
38
|
+
self.urls.append(fields.get("href"))
|
|
39
|
+
elif tag in SOURCE_TAGS:
|
|
40
|
+
self.urls.append(fields.get("src"))
|
|
41
|
+
self.urls.extend(part.split()[0] for part in (fields.get("srcset") or "").split(",") if part.strip())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_ip(name):
|
|
45
|
+
try:
|
|
46
|
+
ipaddress.ip_address(name)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def asset_hosts(html, base_url):
|
|
53
|
+
"""The names a browser would connect to for this page, ignoring what only a click would reach."""
|
|
54
|
+
parser = _AssetParser()
|
|
55
|
+
parser.feed(html)
|
|
56
|
+
hosts = []
|
|
57
|
+
for url in parser.urls:
|
|
58
|
+
try:
|
|
59
|
+
name = urllib.parse.urlsplit(urllib.parse.urljoin(base_url, url or "")).hostname
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
if name and "." in name and not _is_ip(name) and name not in hosts:
|
|
63
|
+
hosts.append(name)
|
|
64
|
+
return hosts
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch(host, proxy=None, timeout=6.0):
|
|
68
|
+
"""Read the front page of ``host``, through ``proxy`` when there is one; returns where it ended up and the HTML."""
|
|
69
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({"http": proxy, "https": proxy} if proxy else {}))
|
|
70
|
+
request = urllib.request.Request(f"https://{host}/", headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
|
71
|
+
with opener.open(request, timeout=timeout) as response:
|
|
72
|
+
return response.geturl(), response.read(MAX_PAGE).decode("utf-8", "replace")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def registrable(host):
|
|
76
|
+
labels = host.split(".")
|
|
77
|
+
keep = 3 if len(labels[-1]) == 2 and labels[-2] in COUNTRY_SECOND_LEVEL else 2
|
|
78
|
+
return ".".join(labels[-keep:])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def covers(opened, host):
|
|
82
|
+
return any(host == name or host.endswith(f".{name}") for name in opened)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def blocked_hosts(host, proxy, opened, timeout=6.0):
|
|
86
|
+
"""The hosts the page at ``host`` needs that a browser cannot reach, leaving out what ``opened`` already covers.
|
|
87
|
+
|
|
88
|
+
The site's own family is in the running because a page calls its API and sign-in
|
|
89
|
+
hosts from script, where no tag shows them.
|
|
90
|
+
"""
|
|
91
|
+
final_url, html = fetch(host, proxy=proxy, timeout=timeout)
|
|
92
|
+
names = dict.fromkeys([*asset_hosts(html, final_url), registrable(host)])
|
|
93
|
+
names = [name for name in names if not covers([host, *opened], name)]
|
|
94
|
+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
|
95
|
+
alive = list(pool.map(lambda name: _probe.reachable(name, timeout=timeout), names))
|
|
96
|
+
return [name for name, ok in zip(names, alive) if not ok]
|
|
@@ -105,6 +105,12 @@ def rank(host, candidates, timeout=6.0):
|
|
|
105
105
|
return rows
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def reachable(host, timeout=6.0):
|
|
109
|
+
"""Whether a browser going straight to what the system resolves gets a handshake."""
|
|
110
|
+
ips = [ip for ip in _dns.system_resolve(host) or [] if not _dns.is_bogus(ip)]
|
|
111
|
+
return any(tls_probe(ip, host, timeout=timeout)["tls"] for ip in ips)
|
|
112
|
+
|
|
113
|
+
|
|
108
114
|
def candidates(answers):
|
|
109
115
|
"""The system's own addresses first, since those are what a browser connects to, then every trusted one."""
|
|
110
116
|
system = [ip for ip in answers["system"] or [] if not _dns.is_bogus(ip)]
|
|
@@ -84,12 +84,13 @@ def opened():
|
|
|
84
84
|
lines = hosts_path().read_text(encoding="utf-8").splitlines()
|
|
85
85
|
except FileNotFoundError:
|
|
86
86
|
return []
|
|
87
|
-
return [line for line in lines if line and not line.startswith(("#", "
|
|
87
|
+
return [line for line in lines if line and not line.startswith(("#", "*"))]
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
def _write_hosts(hosts):
|
|
91
91
|
home().mkdir(parents=True, exist_ok=True)
|
|
92
|
-
|
|
92
|
+
# `*` stands for one label and `**` for any number: a CDN name like store.fastly.example.com needs the second.
|
|
93
|
+
lines = [pattern for host in sorted(set(hosts)) for pattern in (host, f"**.{host}")]
|
|
93
94
|
hosts_path().write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
|
|
94
95
|
|
|
95
96
|
|
|
@@ -310,15 +311,15 @@ def service_state():
|
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
|
|
313
|
-
def ensure(
|
|
314
|
-
"""Put ``
|
|
314
|
+
def ensure(*hosts):
|
|
315
|
+
"""Put ``hosts`` through the bypass: install SpoofDPI, (re)start its agent, point the system proxy at it."""
|
|
315
316
|
conflicts = _proxy_conflicts()
|
|
316
317
|
if conflicts:
|
|
317
318
|
raise RuntimeError(f"A proxy is already set on {', '.join(conflicts)}; evo will not replace it.")
|
|
318
319
|
exe = executable() or install()
|
|
319
320
|
home().mkdir(parents=True, exist_ok=True)
|
|
320
321
|
config_path().write_text(CONFIG, encoding="utf-8")
|
|
321
|
-
_write_hosts([*opened(),
|
|
322
|
+
_write_hosts([*opened(), *hosts])
|
|
322
323
|
if not _start(reload=_write_agent(exe)):
|
|
323
324
|
return service_state()
|
|
324
325
|
set_system_proxy()
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import http.client
|
|
3
4
|
import sys
|
|
4
5
|
|
|
5
6
|
import rich_click as click
|
|
6
7
|
from rich.text import Text
|
|
7
8
|
|
|
8
|
-
from evo_cli.commands.route import _dpi, _hosts, _probe, _spoofdpi
|
|
9
|
+
from evo_cli.commands.route import _dpi, _hosts, _page, _probe, _spoofdpi
|
|
9
10
|
from evo_cli.console import console, error, info, step, success, warning
|
|
10
11
|
|
|
11
12
|
OPEN_EPILOG = Text.from_markup(
|
|
@@ -13,9 +14,12 @@ OPEN_EPILOG = Text.from_markup(
|
|
|
13
14
|
" [cyan]evo route open store.steampowered.com[/cyan] pick the fix that fits\n"
|
|
14
15
|
" [cyan]evo route open medium.com --dry-run[/cyan] say what it would do\n"
|
|
15
16
|
" [cyan]evo route open x.com --method hosts[/cyan] force the address pin\n"
|
|
16
|
-
" [cyan]evo route open x.com --method dpi[/cyan] force the DPI bypass\n
|
|
17
|
+
" [cyan]evo route open x.com --method dpi[/cyan] force the DPI bypass\n"
|
|
18
|
+
" [cyan]evo route open x.com --host-only[/cyan] leave what its page loads alone\n\n"
|
|
17
19
|
"[dim]Diagnoses first, then applies only the fix that matches: pinning an address\n"
|
|
18
20
|
"for a poisoned name, or the packet-level bypass for a name-keyed block.\n"
|
|
21
|
+
"Then reads the page: the bypass takes in the blocked hosts it loads from,\n"
|
|
22
|
+
"whole domains at a time because CDNs rotate; a pin only names them.\n"
|
|
19
23
|
"Needs administrator rights because it edits hosts / installs a service;\n"
|
|
20
24
|
"on macOS the bypass is SpoofDPI as your own LaunchAgent and needs no sudo.[/dim]"
|
|
21
25
|
)
|
|
@@ -98,6 +102,51 @@ def _apply_dpi(report, dry_run):
|
|
|
98
102
|
return state
|
|
99
103
|
|
|
100
104
|
|
|
105
|
+
def _page_hosts(host, proxy, timeout):
|
|
106
|
+
"""The blocked hosts ``host``'s page needs, or None when the page cannot be read."""
|
|
107
|
+
with console.status(f"[info]reading the page at {host}...[/info]", spinner="dots"):
|
|
108
|
+
try:
|
|
109
|
+
return _page.blocked_hosts(host, proxy, _spoofdpi.opened(), timeout=timeout)
|
|
110
|
+
except (OSError, http.client.HTTPException, ValueError) as exc:
|
|
111
|
+
warning(f"Could not read the page at {host}, so what it loads is unchecked: {exc}")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _open_page_hosts(host, timeout):
|
|
116
|
+
"""The macOS bypass lists hosts by name, so everything the page loads has to go on the list too."""
|
|
117
|
+
proxy = (_spoofdpi.LISTEN_HOST, _spoofdpi.LISTEN_PORT)
|
|
118
|
+
blocked = _page_hosts(host, f"http://{_spoofdpi.listen()}", timeout)
|
|
119
|
+
if blocked is None:
|
|
120
|
+
return
|
|
121
|
+
if not blocked:
|
|
122
|
+
success(f"Nothing the page at {host} loads is blocked")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
info(f"The page at {host} also needs {', '.join(blocked)}")
|
|
126
|
+
domains = sorted({_page.registrable(name) for name in blocked})
|
|
127
|
+
try:
|
|
128
|
+
state = _spoofdpi.ensure(*domains)
|
|
129
|
+
except RuntimeError as exc:
|
|
130
|
+
error(str(exc))
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
if not state["running"]:
|
|
133
|
+
error(f"SpoofDPI would not restart - see {_spoofdpi.log_path()}")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
|
|
136
|
+
with console.status("[info]re-checking through the proxy...[/info]", spinner="dots"):
|
|
137
|
+
failed = [name for name in blocked if not _probe.proxy_probe(name, proxy, timeout=timeout)["tls"]]
|
|
138
|
+
if failed:
|
|
139
|
+
warning(f"Still failing through the bypass: {', '.join(failed)}. See {_spoofdpi.log_path()}")
|
|
140
|
+
success(f"Added {', '.join(domains)} to the bypass - `evo route close <host>` takes one back out")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _warn_page_hosts(host, timeout):
|
|
144
|
+
"""A pin cannot reach past the name it pins, and the bypass will not start under sudo, so this only reports."""
|
|
145
|
+
blocked = _page_hosts(host, None, timeout)
|
|
146
|
+
if blocked:
|
|
147
|
+
warning(f"The page at {host} also needs {', '.join(blocked)} - run `evo route open <host>` on each")
|
|
148
|
+
|
|
149
|
+
|
|
101
150
|
@click.command("open", epilog=OPEN_EPILOG)
|
|
102
151
|
@click.argument("host")
|
|
103
152
|
@click.option(
|
|
@@ -109,14 +158,16 @@ def _apply_dpi(report, dry_run):
|
|
|
109
158
|
)
|
|
110
159
|
@click.option("-n", "--dry-run", is_flag=True, help="Say what would change, change nothing.")
|
|
111
160
|
@click.option("-t", "--timeout", default=6.0, show_default=True, help="Per-connection timeout (seconds).")
|
|
112
|
-
|
|
161
|
+
@click.option("--host-only", is_flag=True, help="Open HOST alone, not what its page loads from.")
|
|
162
|
+
def open_route(host, method, dry_run, timeout, host_only):
|
|
113
163
|
"""Open a route to `HOST` by applying the fix its diagnosis calls for.
|
|
114
164
|
|
|
115
165
|
A poisoned name gets its working address pinned in hosts. A name-keyed DPI
|
|
116
166
|
block gets the packet-level bypass, because no amount of hosts or DNS
|
|
117
167
|
editing survives a filter that reads the server name out of the handshake.
|
|
118
168
|
|
|
119
|
-
Re-checks afterwards and reports whether the route actually opened
|
|
169
|
+
Re-checks afterwards and reports whether the route actually opened, then
|
|
170
|
+
reads the page, because a site with its CDN still blocked is not open.
|
|
120
171
|
"""
|
|
121
172
|
step(f"evo route open {host}")
|
|
122
173
|
|
|
@@ -144,15 +195,22 @@ def open_route(host, method, dry_run, timeout):
|
|
|
144
195
|
_apply_dpi(report, dry_run)
|
|
145
196
|
|
|
146
197
|
if dry_run:
|
|
198
|
+
if method == "dpi" and _dpi.is_macos() and not host_only:
|
|
199
|
+
info("Would then read its page and add the blocked hosts it loads from")
|
|
147
200
|
return report
|
|
148
201
|
|
|
149
202
|
step("Verify")
|
|
150
203
|
if method == "dpi" and _dpi.is_macos():
|
|
151
|
-
|
|
204
|
+
row = _verify_through_proxy(host, timeout)
|
|
205
|
+
if row["tls"] and not host_only:
|
|
206
|
+
_open_page_hosts(host, timeout)
|
|
207
|
+
return row
|
|
152
208
|
with console.status(f"[info]re-checking {host}...[/info]", spinner="dots"):
|
|
153
209
|
after = _probe.diagnose(host, timeout=timeout)
|
|
154
210
|
if after["verdict"] in (_probe.OPEN, _probe.DNS_POISONED) and after["working"]:
|
|
155
211
|
success(f"{host} is reachable now ({after['best']})")
|
|
212
|
+
if method == "hosts" and not host_only:
|
|
213
|
+
_warn_page_hosts(host, timeout)
|
|
156
214
|
else:
|
|
157
215
|
warning(f"{host} still reports [bold]{after['verdict']}[/bold]. Run `evo route check {host}` for detail.")
|
|
158
216
|
return after
|
|
@@ -82,6 +82,7 @@ evo_cli/commands/route/__init__.py
|
|
|
82
82
|
evo_cli/commands/route/_dns.py
|
|
83
83
|
evo_cli/commands/route/_dpi.py
|
|
84
84
|
evo_cli/commands/route/_hosts.py
|
|
85
|
+
evo_cli/commands/route/_page.py
|
|
85
86
|
evo_cli/commands/route/_probe.py
|
|
86
87
|
evo_cli/commands/route/_spoofdpi.py
|
|
87
88
|
evo_cli/commands/route/check.py
|
|
@@ -8,7 +8,7 @@ import pytest
|
|
|
8
8
|
from click.testing import CliRunner
|
|
9
9
|
|
|
10
10
|
from evo_cli.cli import cli
|
|
11
|
-
from evo_cli.commands.route import _dns, _dpi, _hosts, _probe, _spoofdpi
|
|
11
|
+
from evo_cli.commands.route import _dns, _dpi, _hosts, _page, _probe, _spoofdpi
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def test_route_registered():
|
|
@@ -257,10 +257,16 @@ def _fake_networksetup(proxies, calls):
|
|
|
257
257
|
def test_spoofdpi_host_list_round_trips_with_subdomains(tmp_path, monkeypatch):
|
|
258
258
|
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
|
|
259
259
|
_spoofdpi._write_hosts(["medium.com", "x.com", "medium.com"])
|
|
260
|
-
assert (tmp_path / "hosts.txt").read_text() == "medium.com\n
|
|
260
|
+
assert (tmp_path / "hosts.txt").read_text() == "medium.com\n**.medium.com\nx.com\n**.x.com\n"
|
|
261
261
|
assert _spoofdpi.opened() == ["medium.com", "x.com"]
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def test_spoofdpi_reads_a_host_list_from_before_the_double_star(tmp_path, monkeypatch):
|
|
265
|
+
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
|
|
266
|
+
(tmp_path / "hosts.txt").write_text("medium.com\n*.medium.com\n")
|
|
267
|
+
assert _spoofdpi.opened() == ["medium.com"]
|
|
268
|
+
|
|
269
|
+
|
|
264
270
|
def test_spoofdpi_config_bypasses_only_the_opened_hosts():
|
|
265
271
|
"""Every other name goes straight through, so names only a VPN resolves keep working."""
|
|
266
272
|
assert 'mode = "system"' in _spoofdpi.CONFIG
|
|
@@ -339,6 +345,154 @@ def test_proxy_probe_reports_a_refused_connect():
|
|
|
339
345
|
assert "403" in row["error"]
|
|
340
346
|
|
|
341
347
|
|
|
348
|
+
PAGE_HTML = """
|
|
349
|
+
<html><head>
|
|
350
|
+
<link rel="canonical" href="https://other.example/">
|
|
351
|
+
<link rel="stylesheet" href="https://store.fastly.steamstatic.com/a.css">
|
|
352
|
+
<link rel="preconnect" href="//shared.fastly.steamstatic.com">
|
|
353
|
+
<link rel="icon" href="/favicon.ico">
|
|
354
|
+
<script src="https://cdn.fastly.steamstatic.com/a.js"></script>
|
|
355
|
+
</head><body>
|
|
356
|
+
<a href="https://steamcommunity.com/">a link is not a load</a>
|
|
357
|
+
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
|
|
358
|
+
<img src="https://1.2.3.4/a.png" srcset="https://img.example.org/a.png 1x, https://img2.example.org/a.png 2x">
|
|
359
|
+
</body></html>
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_asset_hosts_are_what_a_browser_loads_by_itself():
|
|
364
|
+
assert _page.asset_hosts(PAGE_HTML, "https://store.steampowered.com/") == [
|
|
365
|
+
"store.fastly.steamstatic.com",
|
|
366
|
+
"shared.fastly.steamstatic.com",
|
|
367
|
+
"store.steampowered.com",
|
|
368
|
+
"cdn.fastly.steamstatic.com",
|
|
369
|
+
"img.example.org",
|
|
370
|
+
"img2.example.org",
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.parametrize(
|
|
375
|
+
"host, family",
|
|
376
|
+
[
|
|
377
|
+
("store.fastly.steamstatic.com", "steamstatic.com"),
|
|
378
|
+
("medium.com", "medium.com"),
|
|
379
|
+
("static.example.com.vn", "example.com.vn"),
|
|
380
|
+
("www.example.co.uk", "example.co.uk"),
|
|
381
|
+
("a.b.example.io", "example.io"),
|
|
382
|
+
],
|
|
383
|
+
)
|
|
384
|
+
def test_registrable(host, family):
|
|
385
|
+
assert _page.registrable(host) == family
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _fake_page(monkeypatch, html, dead=(), final_url="https://store.steampowered.com/"):
|
|
389
|
+
seen = []
|
|
390
|
+
|
|
391
|
+
def reachable(name, timeout=6.0):
|
|
392
|
+
seen.append(name)
|
|
393
|
+
return name not in dead
|
|
394
|
+
|
|
395
|
+
monkeypatch.setattr(_page, "fetch", lambda host, proxy=None, timeout=6.0: (final_url, html))
|
|
396
|
+
monkeypatch.setattr(_probe, "reachable", reachable)
|
|
397
|
+
return seen
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_blocked_hosts_keeps_only_what_fails_and_is_not_covered(monkeypatch):
|
|
401
|
+
dead = {"store.fastly.steamstatic.com", "cdn.fastly.steamstatic.com", "steampowered.com"}
|
|
402
|
+
seen = _fake_page(monkeypatch, PAGE_HTML, dead)
|
|
403
|
+
blocked = _page.blocked_hosts("store.steampowered.com", None, ["example.org"])
|
|
404
|
+
assert blocked == ["store.fastly.steamstatic.com", "cdn.fastly.steamstatic.com", "steampowered.com"]
|
|
405
|
+
# The host itself and what the bypass already covers read as blocked over a raw socket, so they are not asked.
|
|
406
|
+
assert "store.steampowered.com" not in seen and "img.example.org" not in seen
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_reachable_ignores_a_bogus_answer_and_tries_each_address(monkeypatch):
|
|
410
|
+
monkeypatch.setattr(_dns, "system_resolve", lambda host: ["127.0.0.1", "1.2.3.4", "5.6.7.8"])
|
|
411
|
+
monkeypatch.setattr(_probe, "tls_probe", lambda ip, sni, timeout=6.0: _probe_row(ip, tls=ip == "5.6.7.8"))
|
|
412
|
+
assert _probe.reachable("example.org")
|
|
413
|
+
monkeypatch.setattr(_dns, "system_resolve", lambda host: ["127.0.0.1"])
|
|
414
|
+
assert not _probe.reachable("example.org")
|
|
415
|
+
monkeypatch.setattr(_dns, "system_resolve", lambda host: None)
|
|
416
|
+
assert not _probe.reachable("example.org")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _fake_macos_bypass(monkeypatch, tmp_path, proxy_fails=()):
|
|
420
|
+
"""A sni-blocked host on macOS; ``ensure`` only records what it was asked to open."""
|
|
421
|
+
opened = []
|
|
422
|
+
|
|
423
|
+
def ensure(*hosts):
|
|
424
|
+
opened.append(hosts)
|
|
425
|
+
return {"running": True, "proxy": ["Wi-Fi"]}
|
|
426
|
+
|
|
427
|
+
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
|
|
428
|
+
monkeypatch.setattr(_dpi, "is_macos", lambda: True)
|
|
429
|
+
monkeypatch.setattr(_dpi, "is_admin", lambda: False)
|
|
430
|
+
monkeypatch.setattr(_probe, "diagnose", lambda host, timeout: {"host": host, "verdict": _probe.SNI_BLOCKED})
|
|
431
|
+
monkeypatch.setattr(_spoofdpi, "ensure", ensure)
|
|
432
|
+
monkeypatch.setattr(_spoofdpi, "browser_overrides", lambda: [])
|
|
433
|
+
monkeypatch.setattr(
|
|
434
|
+
_probe, "proxy_probe", lambda host, proxy, timeout=6.0: _probe_row(host, tls=host not in proxy_fails)
|
|
435
|
+
)
|
|
436
|
+
return opened
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_open_adds_the_domains_a_page_needs_to_the_bypass(monkeypatch, tmp_path):
|
|
440
|
+
opened = _fake_macos_bypass(monkeypatch, tmp_path)
|
|
441
|
+
_fake_page(monkeypatch, PAGE_HTML, {"store.fastly.steamstatic.com", "cdn.fastly.steamstatic.com"})
|
|
442
|
+
result = CliRunner().invoke(cli, ["route", "open", "store.steampowered.com"])
|
|
443
|
+
assert result.exit_code == 0, result.output
|
|
444
|
+
assert opened == [("store.steampowered.com",), ("steamstatic.com",)]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_open_host_only_never_reads_the_page(monkeypatch, tmp_path):
|
|
448
|
+
opened = _fake_macos_bypass(monkeypatch, tmp_path)
|
|
449
|
+
seen = _fake_page(monkeypatch, PAGE_HTML, {"store.fastly.steamstatic.com"})
|
|
450
|
+
result = CliRunner().invoke(cli, ["route", "open", "store.steampowered.com", "--host-only"])
|
|
451
|
+
assert result.exit_code == 0, result.output
|
|
452
|
+
assert opened == [("store.steampowered.com",)]
|
|
453
|
+
assert seen == []
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_open_says_when_a_page_host_stays_blocked(monkeypatch, tmp_path):
|
|
457
|
+
_fake_macos_bypass(monkeypatch, tmp_path, proxy_fails={"cdn.fastly.steamstatic.com"})
|
|
458
|
+
_fake_page(monkeypatch, PAGE_HTML, {"cdn.fastly.steamstatic.com"})
|
|
459
|
+
result = CliRunner().invoke(cli, ["route", "open", "store.steampowered.com"])
|
|
460
|
+
assert result.exit_code == 0, result.output
|
|
461
|
+
assert "Still failing through the bypass" in result.output
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_open_survives_a_page_it_cannot_read(monkeypatch, tmp_path):
|
|
465
|
+
opened = _fake_macos_bypass(monkeypatch, tmp_path)
|
|
466
|
+
|
|
467
|
+
def refuse(host, proxy=None, timeout=6.0):
|
|
468
|
+
raise OSError("HTTP Error 403: Forbidden")
|
|
469
|
+
|
|
470
|
+
monkeypatch.setattr(_page, "fetch", refuse)
|
|
471
|
+
result = CliRunner().invoke(cli, ["route", "open", "medium.com"])
|
|
472
|
+
assert result.exit_code == 0, result.output
|
|
473
|
+
assert opened == [("medium.com",)]
|
|
474
|
+
assert "unchecked" in result.output
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_open_with_a_pin_reports_the_page_hosts_it_cannot_reach(monkeypatch, tmp_path):
|
|
478
|
+
monkeypatch.setenv("EVO_ROUTE_HOME", str(tmp_path))
|
|
479
|
+
monkeypatch.setattr(_dpi, "is_admin", lambda: True)
|
|
480
|
+
monkeypatch.setattr(_hosts, "pin", lambda host, ip: {host: ip})
|
|
481
|
+
report = {
|
|
482
|
+
"host": "www.example.org",
|
|
483
|
+
"verdict": _probe.ADDRESS_BLOCKED,
|
|
484
|
+
"best": "5.6.7.8",
|
|
485
|
+
"working": [_probe_row("5.6.7.8", tls=True)],
|
|
486
|
+
}
|
|
487
|
+
after = {**report, "verdict": _probe.OPEN}
|
|
488
|
+
reports = iter([report, after])
|
|
489
|
+
monkeypatch.setattr(_probe, "diagnose", lambda host, timeout: next(reports))
|
|
490
|
+
_fake_page(monkeypatch, PAGE_HTML, {"cdn.fastly.steamstatic.com"})
|
|
491
|
+
result = CliRunner().invoke(cli, ["route", "open", "www.example.org"])
|
|
492
|
+
assert result.exit_code == 0, result.output
|
|
493
|
+
assert "cdn.fastly.steamstatic.com" in result.output
|
|
494
|
+
|
|
495
|
+
|
|
342
496
|
def test_open_on_linux_names_the_supported_systems(monkeypatch):
|
|
343
497
|
monkeypatch.setattr(_dpi, "is_macos", lambda: False)
|
|
344
498
|
monkeypatch.setattr(_dpi, "is_windows", lambda: False)
|
evo_cli-0.32.1/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.32.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|