evo-cli 0.24.0__tar.gz → 0.25.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.24.0 → evo_cli-0.25.1}/PKG-INFO +1 -1
- evo_cli-0.25.1/evo_cli/VERSION +1 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/cli.py +2 -0
- evo_cli-0.25.1/evo_cli/commands/route/__init__.py +33 -0
- evo_cli-0.25.1/evo_cli/commands/route/_dns.py +142 -0
- evo_cli-0.25.1/evo_cli/commands/route/_dpi.py +160 -0
- evo_cli-0.25.1/evo_cli/commands/route/_hosts.py +91 -0
- evo_cli-0.25.1/evo_cli/commands/route/_probe.py +122 -0
- evo_cli-0.25.1/evo_cli/commands/route/check.py +147 -0
- evo_cli-0.25.1/evo_cli/commands/route/fix.py +170 -0
- evo_cli-0.25.1/evo_cli/commands/route/probe.py +91 -0
- evo_cli-0.25.1/evo_cli/commands/route/status.py +72 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/SOURCES.txt +10 -0
- evo_cli-0.25.1/tests/test_route.py +188 -0
- evo_cli-0.24.0/evo_cli/VERSION +0 -1
- {evo_cli-0.24.0 → evo_cli-0.25.1}/Containerfile +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/HISTORY.md +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/LICENSE +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/MANIFEST.in +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/README.md +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/base.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/console.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/pyproject.toml +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/setup.cfg +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/__init__.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_cli.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_console.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_cred.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_download.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_gdrive.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_gh.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_image.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_ngrok.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_serp.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_tts.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_update.py +0 -0
- {evo_cli-0.24.0 → evo_cli-0.25.1}/tests/test_wifi.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.25.1
|
|
@@ -20,6 +20,7 @@ from evo_cli.commands.netcheck import netcheck
|
|
|
20
20
|
from evo_cli.commands.ngrok import setup_ngrok
|
|
21
21
|
from evo_cli.commands.opencode import setup_opencode
|
|
22
22
|
from evo_cli.commands.plantuml import plantuml
|
|
23
|
+
from evo_cli.commands.route import route_group
|
|
23
24
|
from evo_cli.commands.serp import serp_group, setup_serp
|
|
24
25
|
from evo_cli.commands.site2s import site2s
|
|
25
26
|
from evo_cli.commands.ssh import setupssh
|
|
@@ -79,6 +80,7 @@ cli.add_command(localproxy)
|
|
|
79
80
|
cli.add_command(mcp_group)
|
|
80
81
|
cli.add_command(netcheck)
|
|
81
82
|
cli.add_command(plantuml)
|
|
83
|
+
cli.add_command(route_group)
|
|
82
84
|
cli.add_command(serp_group)
|
|
83
85
|
cli.add_command(sysmon)
|
|
84
86
|
cli.add_command(tts_group)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
from evo_cli.commands.route.check import check
|
|
4
|
+
from evo_cli.commands.route.fix import close_route, open_route
|
|
5
|
+
from evo_cli.commands.route.probe import probe
|
|
6
|
+
from evo_cli.commands.route.status import status
|
|
7
|
+
|
|
8
|
+
HELP = """Find a way through to somewhere the network is keeping from you.
|
|
9
|
+
|
|
10
|
+
Two blocks look identical from a browser and need opposite fixes. A **poisoned
|
|
11
|
+
DNS** answer hands you a dead address while the route itself stays open, so
|
|
12
|
+
pinning the real address fixes it. A **DPI filter** reading the server name out
|
|
13
|
+
of your TLS handshake blocks every address for that name, and no hosts or DNS
|
|
14
|
+
edit can touch it.
|
|
15
|
+
|
|
16
|
+
- `check <host>` - what stands in the way, and which of the two it is
|
|
17
|
+
- `probe <host>` - every address for the name, ranked by what answers
|
|
18
|
+
- `open <host>` - apply the fix the diagnosis calls for
|
|
19
|
+
- `close <host>` - undo it again
|
|
20
|
+
- `status` - which fixes are currently in place
|
|
21
|
+
|
|
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.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group("route", help=HELP)
|
|
28
|
+
def route_group():
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
for command in (check, probe, open_route, close_route, status):
|
|
33
|
+
route_group.add_command(command)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as jsonlib
|
|
4
|
+
import socket
|
|
5
|
+
import struct
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
PORT = 443
|
|
11
|
+
|
|
12
|
+
PUBLIC_RESOLVERS = [
|
|
13
|
+
("8.8.8.8", "google"),
|
|
14
|
+
("1.1.1.1", "cloudflare"),
|
|
15
|
+
("9.9.9.9", "quad9"),
|
|
16
|
+
("208.67.222.222", "opendns"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
VN_RESOLVERS = [
|
|
20
|
+
("210.245.0.10", "fpt"),
|
|
21
|
+
("203.113.131.1", "vnpt"),
|
|
22
|
+
("183.91.160.11", "viettel"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
DOH_ENDPOINT = "https://dns.google/resolve"
|
|
26
|
+
|
|
27
|
+
# A resolver that answers with one of these is not answering, it is lying: an
|
|
28
|
+
# ISP hijack points the name at the machine itself or at the resolver's own
|
|
29
|
+
# address so the connection dies locally instead of visibly being blocked.
|
|
30
|
+
BOGUS_PREFIXES = ("127.", "0.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _encode_qname(host):
|
|
34
|
+
out = bytearray()
|
|
35
|
+
for label in host.rstrip(".").split("."):
|
|
36
|
+
raw = label.encode("idna") if any(ord(c) > 127 for c in label) else label.encode()
|
|
37
|
+
out.append(len(raw))
|
|
38
|
+
out.extend(raw)
|
|
39
|
+
out.append(0)
|
|
40
|
+
return bytes(out)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _skip_name(data, off):
|
|
44
|
+
while off < len(data):
|
|
45
|
+
length = data[off]
|
|
46
|
+
if length == 0:
|
|
47
|
+
return off + 1
|
|
48
|
+
if length & 0xC0 == 0xC0:
|
|
49
|
+
return off + 2
|
|
50
|
+
off += 1 + length
|
|
51
|
+
return off
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def query_a(host, resolver, timeout=2.5):
|
|
55
|
+
header = struct.pack(">HHHHHH", 0x4242, 0x0100, 1, 0, 0, 0)
|
|
56
|
+
packet = header + _encode_qname(host) + struct.pack(">HH", 1, 1)
|
|
57
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
58
|
+
sock.settimeout(timeout)
|
|
59
|
+
try:
|
|
60
|
+
sock.sendto(packet, (resolver, 53))
|
|
61
|
+
data, _ = sock.recvfrom(4096)
|
|
62
|
+
except OSError:
|
|
63
|
+
return None
|
|
64
|
+
finally:
|
|
65
|
+
sock.close()
|
|
66
|
+
|
|
67
|
+
if len(data) < 12:
|
|
68
|
+
return None
|
|
69
|
+
ancount = struct.unpack(">H", data[6:8])[0]
|
|
70
|
+
off = _skip_name(data, 12) + 4
|
|
71
|
+
found = []
|
|
72
|
+
for _ in range(ancount):
|
|
73
|
+
off = _skip_name(data, off)
|
|
74
|
+
if off + 10 > len(data):
|
|
75
|
+
break
|
|
76
|
+
rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[off : off + 10])
|
|
77
|
+
off += 10
|
|
78
|
+
rdata = data[off : off + rdlength]
|
|
79
|
+
off += rdlength
|
|
80
|
+
if rtype == 1 and rdlength == 4:
|
|
81
|
+
found.append(socket.inet_ntop(socket.AF_INET, rdata))
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def system_resolve(host):
|
|
86
|
+
try:
|
|
87
|
+
infos = socket.getaddrinfo(host, PORT, socket.AF_INET, socket.SOCK_STREAM)
|
|
88
|
+
except socket.gaierror:
|
|
89
|
+
return None
|
|
90
|
+
seen = []
|
|
91
|
+
for info in infos:
|
|
92
|
+
ip = info[4][0]
|
|
93
|
+
if ip not in seen:
|
|
94
|
+
seen.append(ip)
|
|
95
|
+
return seen
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def doh_resolve(host, timeout=8.0):
|
|
99
|
+
url = f"{DOH_ENDPOINT}?name={urllib.parse.quote(host)}&type=A"
|
|
100
|
+
request = urllib.request.Request(url, headers={"accept": "application/dns-json", "User-Agent": "evo-cli/route"})
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
103
|
+
payload = jsonlib.loads(response.read().decode("utf-8", "replace"))
|
|
104
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
|
105
|
+
return None
|
|
106
|
+
return [a["data"] for a in payload.get("Answer") or [] if a.get("type") == 1]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_bogus(ip):
|
|
110
|
+
return any(ip.startswith(prefix) for prefix in BOGUS_PREFIXES)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def gather(host, timeout=2.5, include_vn=True):
|
|
114
|
+
resolvers = list(PUBLIC_RESOLVERS)
|
|
115
|
+
if include_vn:
|
|
116
|
+
resolvers += VN_RESOLVERS
|
|
117
|
+
|
|
118
|
+
answers = {}
|
|
119
|
+
for address, label in resolvers:
|
|
120
|
+
answers[label] = query_a(host, address, timeout)
|
|
121
|
+
|
|
122
|
+
doh = doh_resolve(host)
|
|
123
|
+
if doh is not None:
|
|
124
|
+
answers["doh"] = doh
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"system": system_resolve(host),
|
|
128
|
+
"resolvers": answers,
|
|
129
|
+
"trusted": _trusted_union(answers),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _trusted_union(answers):
|
|
134
|
+
trusted_labels = {label for _, label in PUBLIC_RESOLVERS} | {"doh"}
|
|
135
|
+
out = []
|
|
136
|
+
for label, ips in answers.items():
|
|
137
|
+
if label not in trusted_labels or not ips:
|
|
138
|
+
continue
|
|
139
|
+
for ip in ips:
|
|
140
|
+
if not is_bogus(ip) and ip not in out:
|
|
141
|
+
out.append(ip)
|
|
142
|
+
return out
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from evo_cli.console import download_file, info, resolve_executable
|
|
11
|
+
|
|
12
|
+
RELEASE_URL = "https://github.com/ValdikSS/GoodbyeDPI/releases/download/0.2.2/goodbyedpi-0.2.2.zip"
|
|
13
|
+
RELEASE_SIZE = 635551
|
|
14
|
+
RELEASE_SHA256 = "00a2f8b99cd817f8c7fc4c449033015f039d18af213de78cb66bf202277c0628"
|
|
15
|
+
|
|
16
|
+
SERVICE_NAME = "GoodbyeDPI"
|
|
17
|
+
|
|
18
|
+
# Deliberately NOT the bundled `-5` preset. `-5` is these flags plus
|
|
19
|
+
# `--max-payload`, whose 1200-byte ceiling skips the very packet that matters:
|
|
20
|
+
# a modern Chrome ClientHello carries a post-quantum key share and runs past
|
|
21
|
+
# 1200 bytes, so it sails through untouched while curl - whose ClientHello is
|
|
22
|
+
# small - looks like it works. Without --max-payload both are handled.
|
|
23
|
+
DEFAULT_ARGS = ["-f", "2", "-e", "2", "--auto-ttl", "--reverse-frag"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_windows():
|
|
27
|
+
return os.name == "nt"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_admin():
|
|
31
|
+
if not is_windows():
|
|
32
|
+
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
|
|
33
|
+
try:
|
|
34
|
+
import ctypes
|
|
35
|
+
|
|
36
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def install_dir():
|
|
42
|
+
root = os.environ.get("ProgramFiles", r"C:\Program Files")
|
|
43
|
+
return Path(root) / "GoodbyeDPI"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def executable():
|
|
47
|
+
return install_dir() / "x86_64" / "goodbyedpi.exe"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sc(*args, check=False):
|
|
51
|
+
cmd = resolve_executable(["sc.exe", *args])
|
|
52
|
+
return subprocess.run(
|
|
53
|
+
cmd,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
58
|
+
check=check,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def service_state():
|
|
63
|
+
if not is_windows():
|
|
64
|
+
return {"installed": False, "running": False, "args": None, "reason": "windows only"}
|
|
65
|
+
result = _sc("qc", SERVICE_NAME)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
return {"installed": False, "running": False, "args": None, "reason": None}
|
|
68
|
+
|
|
69
|
+
binary = None
|
|
70
|
+
for line in (result.stdout or "").splitlines():
|
|
71
|
+
if "BINARY_PATH_NAME" in line:
|
|
72
|
+
binary = line.split(":", 1)[1].strip()
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
query = _sc("query", SERVICE_NAME)
|
|
76
|
+
running = "RUNNING" in (query.stdout or "")
|
|
77
|
+
return {"installed": True, "running": running, "args": binary, "reason": None}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def verify_download(path):
|
|
81
|
+
size = Path(path).stat().st_size
|
|
82
|
+
if size != RELEASE_SIZE:
|
|
83
|
+
return False, f"size mismatch: got {size}, expected {RELEASE_SIZE}"
|
|
84
|
+
digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
85
|
+
if digest != RELEASE_SHA256:
|
|
86
|
+
return False, f"sha256 mismatch: got {digest}"
|
|
87
|
+
return True, None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fetch(destination=None):
|
|
91
|
+
destination = Path(destination or (Path(tempfile.gettempdir()) / "goodbyedpi-0.2.2.zip"))
|
|
92
|
+
download_file(RELEASE_URL, destination, description="GoodbyeDPI 0.2.2")
|
|
93
|
+
ok, reason = verify_download(destination)
|
|
94
|
+
if not ok:
|
|
95
|
+
destination.unlink(missing_ok=True)
|
|
96
|
+
raise RuntimeError(f"refusing to install a tampered download - {reason}")
|
|
97
|
+
return destination
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unpack(archive, target=None):
|
|
101
|
+
target = Path(target or install_dir())
|
|
102
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
104
|
+
with zipfile.ZipFile(archive) as zf:
|
|
105
|
+
zf.extractall(tmp)
|
|
106
|
+
root = next(Path(tmp).glob("goodbyedpi-*"))
|
|
107
|
+
source = root / "x86_64"
|
|
108
|
+
dest = target / "x86_64"
|
|
109
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
for item in source.iterdir():
|
|
111
|
+
dest.joinpath(item.name).write_bytes(item.read_bytes())
|
|
112
|
+
return target
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install_service(args=None):
|
|
116
|
+
args = list(args or DEFAULT_ARGS)
|
|
117
|
+
binary = f'"{executable()}" ' + " ".join(args)
|
|
118
|
+
|
|
119
|
+
existing = service_state()
|
|
120
|
+
if existing["installed"]:
|
|
121
|
+
_sc("stop", SERVICE_NAME)
|
|
122
|
+
_sc("delete", SERVICE_NAME)
|
|
123
|
+
|
|
124
|
+
# sc.exe wants "binPath=" and its value as two separate argv entries. Passing
|
|
125
|
+
# an explicit list keeps that shape; a shell would glue them and sc would
|
|
126
|
+
# silently print its usage instead of creating anything.
|
|
127
|
+
created = _sc("create", SERVICE_NAME, "binPath=", binary, "start=", "auto")
|
|
128
|
+
if created.returncode != 0:
|
|
129
|
+
raise RuntimeError((created.stdout or created.stderr or "sc create failed").strip())
|
|
130
|
+
_sc("description", SERVICE_NAME, "Passive DPI blocker and Active DPI circumvention utility")
|
|
131
|
+
return binary
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def start():
|
|
135
|
+
result = _sc("start", SERVICE_NAME)
|
|
136
|
+
return result.returncode == 0 or "ALREADY_RUNNING" in (result.stdout or "")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def stop():
|
|
140
|
+
result = _sc("stop", SERVICE_NAME)
|
|
141
|
+
return result.returncode == 0 or "NOT_ACTIVE" in (result.stdout or "")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def remove():
|
|
145
|
+
stop()
|
|
146
|
+
result = _sc("delete", SERVICE_NAME)
|
|
147
|
+
return result.returncode == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def ensure(args=None):
|
|
151
|
+
state = service_state()
|
|
152
|
+
if not executable().exists():
|
|
153
|
+
info("GoodbyeDPI is not installed yet, fetching it")
|
|
154
|
+
archive = fetch()
|
|
155
|
+
unpack(archive)
|
|
156
|
+
Path(archive).unlink(missing_ok=True)
|
|
157
|
+
if not state["installed"]:
|
|
158
|
+
install_service(args)
|
|
159
|
+
start()
|
|
160
|
+
return service_state()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
MARK_START = "# >>> evo route >>>"
|
|
10
|
+
MARK_END = "# <<< evo route <<<"
|
|
11
|
+
|
|
12
|
+
BLOCK_RE = re.compile(
|
|
13
|
+
re.escape(MARK_START) + r".*?" + re.escape(MARK_END) + r"\r?\n?",
|
|
14
|
+
re.DOTALL,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hosts_path():
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
21
|
+
return Path(root) / "System32" / "drivers" / "etc" / "hosts"
|
|
22
|
+
return Path("/etc/hosts")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_block(path=None):
|
|
26
|
+
path = Path(path) if path else hosts_path()
|
|
27
|
+
try:
|
|
28
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
29
|
+
except OSError:
|
|
30
|
+
return {}
|
|
31
|
+
match = BLOCK_RE.search(raw)
|
|
32
|
+
if not match:
|
|
33
|
+
return {}
|
|
34
|
+
entries = {}
|
|
35
|
+
for line in match.group(0).splitlines():
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if not line or line.startswith("#"):
|
|
38
|
+
continue
|
|
39
|
+
parts = line.split()
|
|
40
|
+
if len(parts) >= 2:
|
|
41
|
+
entries[parts[1]] = parts[0]
|
|
42
|
+
return entries
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def backup(path=None):
|
|
46
|
+
path = Path(path) if path else hosts_path()
|
|
47
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
48
|
+
target = path.with_name(path.name + f".evo-route.{stamp}")
|
|
49
|
+
shutil.copy2(path, target)
|
|
50
|
+
return target
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_block(entries, path=None, make_backup=True):
|
|
54
|
+
path = Path(path) if path else hosts_path()
|
|
55
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
if make_backup:
|
|
57
|
+
backup(path)
|
|
58
|
+
|
|
59
|
+
raw = BLOCK_RE.sub("", raw)
|
|
60
|
+
if raw and not raw.endswith("\n"):
|
|
61
|
+
raw += "\n"
|
|
62
|
+
|
|
63
|
+
if entries:
|
|
64
|
+
lines = [MARK_START]
|
|
65
|
+
for host in sorted(entries):
|
|
66
|
+
lines.append(f"{entries[host]} {host}")
|
|
67
|
+
lines.append(MARK_END)
|
|
68
|
+
raw += "\n".join(lines) + "\n"
|
|
69
|
+
|
|
70
|
+
path.write_text(raw, encoding="utf-8")
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pin(host, ip, path=None, make_backup=True):
|
|
75
|
+
entries = read_block(path)
|
|
76
|
+
entries[host] = ip
|
|
77
|
+
write_block(entries, path=path, make_backup=make_backup)
|
|
78
|
+
return entries
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def unpin(host, path=None, make_backup=True):
|
|
82
|
+
entries = read_block(path)
|
|
83
|
+
if host not in entries:
|
|
84
|
+
return entries, False
|
|
85
|
+
entries.pop(host)
|
|
86
|
+
write_block(entries, path=path, make_backup=make_backup)
|
|
87
|
+
return entries, True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear(path=None, make_backup=True):
|
|
91
|
+
write_block({}, path=path, make_backup=make_backup)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from evo_cli.commands.route import _dns
|
|
8
|
+
|
|
9
|
+
PORT = 443
|
|
10
|
+
|
|
11
|
+
# A name nobody blocks, used as the control in the SNI experiment below.
|
|
12
|
+
CONTROL_SNI = "example.com"
|
|
13
|
+
|
|
14
|
+
OPEN = "open"
|
|
15
|
+
DNS_POISONED = "dns-poisoned"
|
|
16
|
+
SNI_BLOCKED = "sni-blocked"
|
|
17
|
+
IP_BLOCKED = "ip-blocked"
|
|
18
|
+
UNREACHABLE = "unreachable"
|
|
19
|
+
|
|
20
|
+
VERDICT_TEXT = {
|
|
21
|
+
OPEN: "reachable - nothing in the way",
|
|
22
|
+
DNS_POISONED: "DNS is poisoned, the route itself is open",
|
|
23
|
+
SNI_BLOCKED: "DPI blocks the TLS handshake by server name",
|
|
24
|
+
IP_BLOCKED: "the address itself is blocked or dead",
|
|
25
|
+
UNREACHABLE: "no address answers at all",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ms(start):
|
|
30
|
+
return (time.perf_counter() - start) * 1000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tls_probe(ip, sni, timeout=6.0):
|
|
34
|
+
"""Open one TLS connection to ``ip`` announcing ``sni``.
|
|
35
|
+
|
|
36
|
+
Certificate checking is off on purpose: the question is whether the
|
|
37
|
+
handshake is allowed to finish, not whether the certificate matches the
|
|
38
|
+
name we announced.
|
|
39
|
+
"""
|
|
40
|
+
result = {"ip": ip, "sni": sni, "tcp": False, "tls": False, "ms": None, "error": None, "reset": False}
|
|
41
|
+
started = time.perf_counter()
|
|
42
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
43
|
+
sock.settimeout(timeout)
|
|
44
|
+
try:
|
|
45
|
+
sock.connect((ip, PORT))
|
|
46
|
+
result["tcp"] = True
|
|
47
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
48
|
+
context.check_hostname = False
|
|
49
|
+
context.verify_mode = ssl.CERT_NONE
|
|
50
|
+
with context.wrap_socket(sock, server_hostname=sni):
|
|
51
|
+
result["tls"] = True
|
|
52
|
+
result["ms"] = _ms(started)
|
|
53
|
+
except ConnectionResetError as exc:
|
|
54
|
+
result["error"] = str(exc)
|
|
55
|
+
result["reset"] = True
|
|
56
|
+
except TimeoutError as exc:
|
|
57
|
+
result["error"] = f"timeout: {exc}"
|
|
58
|
+
except ssl.SSLError as exc:
|
|
59
|
+
result["error"] = str(exc)
|
|
60
|
+
result["reset"] = "reset" in str(exc).lower() or "ILLEGAL_MESSAGE" in str(exc)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
result["error"] = str(exc)
|
|
63
|
+
finally:
|
|
64
|
+
try:
|
|
65
|
+
sock.close()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def rank(host, candidates, timeout=6.0):
|
|
72
|
+
rows = []
|
|
73
|
+
for ip in candidates:
|
|
74
|
+
row = tls_probe(ip, host, timeout=timeout)
|
|
75
|
+
rows.append(row)
|
|
76
|
+
rows.sort(key=lambda r: (not r["tls"], r["ms"] if r["ms"] is not None else 1e9))
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def diagnose(host, timeout=6.0, include_vn=True):
|
|
81
|
+
answers = _dns.gather(host, include_vn=include_vn)
|
|
82
|
+
system_ips = answers["system"] or []
|
|
83
|
+
trusted = answers["trusted"]
|
|
84
|
+
|
|
85
|
+
poisoned = bool(system_ips) and all(_dns.is_bogus(ip) for ip in system_ips)
|
|
86
|
+
if not poisoned and system_ips and trusted:
|
|
87
|
+
poisoned = not set(system_ips) & set(trusted) and any(_dns.is_bogus(ip) for ip in system_ips)
|
|
88
|
+
|
|
89
|
+
candidates = trusted or [ip for ip in system_ips if not _dns.is_bogus(ip)]
|
|
90
|
+
probes = rank(host, candidates, timeout=timeout) if candidates else []
|
|
91
|
+
working = [row for row in probes if row["tls"]]
|
|
92
|
+
|
|
93
|
+
control = None
|
|
94
|
+
if candidates and not working:
|
|
95
|
+
control = tls_probe(candidates[0], CONTROL_SNI, timeout=timeout)
|
|
96
|
+
|
|
97
|
+
verdict = _verdict(candidates, probes, working, control, poisoned)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"host": host,
|
|
101
|
+
"verdict": verdict,
|
|
102
|
+
"poisoned": poisoned,
|
|
103
|
+
"system_ips": system_ips,
|
|
104
|
+
"answers": answers["resolvers"],
|
|
105
|
+
"candidates": candidates,
|
|
106
|
+
"probes": probes,
|
|
107
|
+
"working": working,
|
|
108
|
+
"control": control,
|
|
109
|
+
"best": working[0]["ip"] if working else None,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _verdict(candidates, probes, working, control, poisoned):
|
|
114
|
+
if not candidates:
|
|
115
|
+
return UNREACHABLE
|
|
116
|
+
if working:
|
|
117
|
+
return DNS_POISONED if poisoned else OPEN
|
|
118
|
+
if control and control["tls"]:
|
|
119
|
+
return SNI_BLOCKED
|
|
120
|
+
if any(row["tcp"] for row in probes):
|
|
121
|
+
return IP_BLOCKED
|
|
122
|
+
return UNREACHABLE
|