evo-cli 0.3.1__tar.gz → 0.4.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.3.1 → evo_cli-0.4.0}/PKG-INFO +2 -1
- evo_cli-0.4.0/evo_cli/VERSION +1 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/cli.py +4 -0
- evo_cli-0.4.0/evo_cli/commands/localproxy.py +679 -0
- evo_cli-0.4.0/evo_cli/commands/netcheck.py +396 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/PKG-INFO +2 -1
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/SOURCES.txt +2 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/requires.txt +1 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/pyproject.toml +1 -0
- evo_cli-0.3.1/evo_cli/VERSION +0 -1
- {evo_cli-0.3.1 → evo_cli-0.4.0}/Containerfile +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/HISTORY.md +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/LICENSE +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/MANIFEST.in +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/README.md +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/base.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/console.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/setup.cfg +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/tests/__init__.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/tests/test_cli.py +0 -0
- {evo_cli-0.3.1 → evo_cli-0.4.0}/tests/test_fix_claude.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Requires-Python: >=3.9
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
+
Requires-Dist: cryptography>=41.0
|
|
17
18
|
Requires-Dist: paramiko>=2.7.0
|
|
18
19
|
Requires-Dist: rich>=13.0
|
|
19
20
|
Requires-Dist: rich-click>=1.8
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.4.0
|
|
@@ -4,7 +4,9 @@ from evo_cli import __version__
|
|
|
4
4
|
from evo_cli.commands.cloudflare import cfssh
|
|
5
5
|
from evo_cli.commands.fix_claude import f_claude
|
|
6
6
|
from evo_cli.commands.gdrive import gdrive
|
|
7
|
+
from evo_cli.commands.localproxy import localproxy
|
|
7
8
|
from evo_cli.commands.miniconda import miniconda
|
|
9
|
+
from evo_cli.commands.netcheck import netcheck
|
|
8
10
|
from evo_cli.commands.site2s import site2s
|
|
9
11
|
from evo_cli.commands.ssh import setupssh
|
|
10
12
|
|
|
@@ -35,6 +37,8 @@ cli.add_command(cfssh)
|
|
|
35
37
|
cli.add_command(f_claude)
|
|
36
38
|
cli.add_command(gdrive)
|
|
37
39
|
cli.add_command(site2s)
|
|
40
|
+
cli.add_command(localproxy)
|
|
41
|
+
cli.add_command(netcheck)
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def main():
|
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"""
|
|
2
|
+
evo localproxy — HTTPS reverse proxy mapping https://{domain}.local -> https://{domain}.
|
|
3
|
+
|
|
4
|
+
Use case: browse / test a real HTTPS site under a local alias. A request to
|
|
5
|
+
``https://edunext.ptudev.net.local`` is TLS-terminated locally with a leaf cert
|
|
6
|
+
issued by a small local CA, the ``.local`` suffix is stripped, and the request is
|
|
7
|
+
forwarded over TLS to the real ``https://edunext.ptudev.net``.
|
|
8
|
+
|
|
9
|
+
Rewrites that make logins / redirects stay inside the alias:
|
|
10
|
+
- outbound (request): Host, Origin, Referer {domain}.local -> {domain}
|
|
11
|
+
- inbound (response): Location, Content-Location, Access-Control-Allow-Origin
|
|
12
|
+
{domain} -> {domain}.local ; Set-Cookie Domain= gets a
|
|
13
|
+
trailing ``.local`` so cookies bind to the alias host.
|
|
14
|
+
|
|
15
|
+
Windows-first (no /etc/hosts, lsof, or sudo) but works cross-platform.
|
|
16
|
+
HTTP/1.1 end to end (ALPN pins http/1.1); chunked, keep-alive, Expect:
|
|
17
|
+
100-continue and WebSocket upgrades are handled. Response bodies are streamed
|
|
18
|
+
untouched, so gzip/br pass through transparently.
|
|
19
|
+
"""
|
|
20
|
+
import datetime
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
import threading
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import rich_click as click
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
from evo_cli.console import console, error, info, step, success, warning
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Certificate authority / leaf generation ──────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def _cert_dir(custom=None):
|
|
39
|
+
p = Path(custom) if custom else Path.home() / ".evo" / "proxy-certs"
|
|
40
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_or_create_ca(cert_dir):
|
|
45
|
+
from cryptography import x509
|
|
46
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
47
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
48
|
+
from cryptography.x509.oid import NameOID
|
|
49
|
+
|
|
50
|
+
key_path = cert_dir / "evo-ca-key.pem"
|
|
51
|
+
crt_path = cert_dir / "evo-ca-cert.pem"
|
|
52
|
+
if key_path.exists() and crt_path.exists():
|
|
53
|
+
key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
|
54
|
+
crt = x509.load_pem_x509_certificate(crt_path.read_bytes())
|
|
55
|
+
return key, crt, crt_path
|
|
56
|
+
|
|
57
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
58
|
+
name = x509.Name([
|
|
59
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "Evo Local CA"),
|
|
60
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Evo CLI"),
|
|
61
|
+
])
|
|
62
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
63
|
+
crt = (
|
|
64
|
+
x509.CertificateBuilder()
|
|
65
|
+
.subject_name(name).issuer_name(name)
|
|
66
|
+
.public_key(key.public_key())
|
|
67
|
+
.serial_number(x509.random_serial_number())
|
|
68
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
69
|
+
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
70
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
|
71
|
+
.add_extension(
|
|
72
|
+
x509.KeyUsage(
|
|
73
|
+
digital_signature=True, key_cert_sign=True, crl_sign=True,
|
|
74
|
+
content_commitment=False, key_encipherment=False,
|
|
75
|
+
data_encipherment=False, key_agreement=False,
|
|
76
|
+
encipher_only=False, decipher_only=False,
|
|
77
|
+
),
|
|
78
|
+
critical=True,
|
|
79
|
+
)
|
|
80
|
+
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
|
|
81
|
+
.sign(key, hashes.SHA256())
|
|
82
|
+
)
|
|
83
|
+
key_path.write_bytes(key.private_bytes(
|
|
84
|
+
serialization.Encoding.PEM,
|
|
85
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
86
|
+
serialization.NoEncryption(),
|
|
87
|
+
))
|
|
88
|
+
crt_path.write_bytes(crt.public_bytes(serialization.Encoding.PEM))
|
|
89
|
+
return key, crt, crt_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _make_leaf(cert_dir, ca_key, ca_crt, dns_names):
|
|
93
|
+
from cryptography import x509
|
|
94
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
95
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
96
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
97
|
+
|
|
98
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
99
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
100
|
+
crt = (
|
|
101
|
+
x509.CertificateBuilder()
|
|
102
|
+
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, dns_names[0])]))
|
|
103
|
+
.issuer_name(ca_crt.subject)
|
|
104
|
+
.public_key(key.public_key())
|
|
105
|
+
.serial_number(x509.random_serial_number())
|
|
106
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
107
|
+
.not_valid_after(now + datetime.timedelta(days=825))
|
|
108
|
+
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
109
|
+
.add_extension(x509.SubjectAlternativeName([x509.DNSName(d) for d in dns_names]), critical=False)
|
|
110
|
+
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
|
111
|
+
.sign(ca_key, hashes.SHA256())
|
|
112
|
+
)
|
|
113
|
+
key_path = cert_dir / "leaf-key.pem"
|
|
114
|
+
chain_path = cert_dir / "leaf-chain.pem"
|
|
115
|
+
key_path.write_bytes(key.private_bytes(
|
|
116
|
+
serialization.Encoding.PEM,
|
|
117
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
118
|
+
serialization.NoEncryption(),
|
|
119
|
+
))
|
|
120
|
+
chain_path.write_bytes(
|
|
121
|
+
crt.public_bytes(serialization.Encoding.PEM)
|
|
122
|
+
+ ca_crt.public_bytes(serialization.Encoding.PEM)
|
|
123
|
+
)
|
|
124
|
+
return chain_path, key_path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _server_ctx(chain_path, key_path):
|
|
128
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
129
|
+
ctx.load_cert_chain(certfile=str(chain_path), keyfile=str(key_path))
|
|
130
|
+
try:
|
|
131
|
+
ctx.set_alpn_protocols(["http/1.1"])
|
|
132
|
+
except NotImplementedError:
|
|
133
|
+
pass
|
|
134
|
+
return ctx
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Hosts file + trust store helpers ─────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
_MARKER = "# evo-cli localproxy"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _hosts_path():
|
|
143
|
+
if os.name == "nt":
|
|
144
|
+
root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
145
|
+
return Path(root) / "System32" / "drivers" / "etc" / "hosts"
|
|
146
|
+
return Path("/etc/hosts")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _flush_dns():
|
|
150
|
+
if os.name == "nt":
|
|
151
|
+
try:
|
|
152
|
+
import subprocess
|
|
153
|
+
subprocess.run(["ipconfig", "/flushdns"], capture_output=True)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _setup_hosts(local_hosts):
|
|
159
|
+
path = _hosts_path()
|
|
160
|
+
entries = [f"127.0.0.1 {h} {_MARKER}" for h in local_hosts]
|
|
161
|
+
try:
|
|
162
|
+
content = path.read_text(encoding="utf-8", errors="ignore") if path.exists() else ""
|
|
163
|
+
kept = [
|
|
164
|
+
ln for ln in content.splitlines()
|
|
165
|
+
if _MARKER not in ln and not any(h in ln.split() for h in local_hosts)
|
|
166
|
+
]
|
|
167
|
+
path.write_text("\n".join(kept + entries) + "\n", encoding="utf-8")
|
|
168
|
+
success(f"Updated hosts file ({len(local_hosts)} entries)")
|
|
169
|
+
_flush_dns()
|
|
170
|
+
return True
|
|
171
|
+
except PermissionError:
|
|
172
|
+
warning("Cannot edit the hosts file (need Administrator).")
|
|
173
|
+
warning("Run the terminal as Administrator, or add these lines manually:")
|
|
174
|
+
for e in entries:
|
|
175
|
+
console.print(f" [cmd]{e}[/cmd]")
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cleanup_hosts():
|
|
180
|
+
path = _hosts_path()
|
|
181
|
+
try:
|
|
182
|
+
if not path.exists():
|
|
183
|
+
return
|
|
184
|
+
kept = [ln for ln in path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
185
|
+
if _MARKER not in ln]
|
|
186
|
+
path.write_text("\n".join(kept) + "\n", encoding="utf-8")
|
|
187
|
+
_flush_dns()
|
|
188
|
+
warning("Removed hosts entries.")
|
|
189
|
+
except PermissionError:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _install_ca(ca_crt_path):
|
|
194
|
+
if os.name == "nt":
|
|
195
|
+
import subprocess
|
|
196
|
+
info("Installing CA into the Windows user Root store...")
|
|
197
|
+
r = subprocess.run(["certutil", "-addstore", "-user", "-f", "Root", str(ca_crt_path)],
|
|
198
|
+
capture_output=True, text=True)
|
|
199
|
+
if r.returncode == 0:
|
|
200
|
+
success("CA trusted (Chrome / Edge will accept .local certs)")
|
|
201
|
+
console.print("[cmd]Firefox: set security.enterprise_roots.enabled=true, "
|
|
202
|
+
"or import the CA in its own store.[/cmd]")
|
|
203
|
+
else:
|
|
204
|
+
error(f"certutil failed: {r.stdout.strip()} {r.stderr.strip()}")
|
|
205
|
+
console.print(f'[cmd]Install manually: certutil -addstore -user -f Root "{ca_crt_path}"[/cmd]')
|
|
206
|
+
else:
|
|
207
|
+
warning(f"Import this CA into your OS / browser trust store: {ca_crt_path}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── HTTP/1.1 message helpers ─────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def _parse_headers(raw_lines):
|
|
213
|
+
"""raw_lines: list of header line bytes (CRLF-terminated). -> [[name, value], ...]"""
|
|
214
|
+
parsed = []
|
|
215
|
+
for ln in raw_lines:
|
|
216
|
+
s = ln.rstrip(b"\r\n")
|
|
217
|
+
if not s:
|
|
218
|
+
continue
|
|
219
|
+
if s[:1] in (b" ", b"\t"): # obsolete line folding
|
|
220
|
+
if parsed:
|
|
221
|
+
parsed[-1][1] += b" " + s.strip()
|
|
222
|
+
continue
|
|
223
|
+
name, _, val = s.partition(b":")
|
|
224
|
+
parsed.append([name.strip(), val.strip()])
|
|
225
|
+
return parsed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _read_head(reader):
|
|
229
|
+
"""Read a request/response head. -> (start_line_bytes, parsed_headers) or None on EOF."""
|
|
230
|
+
while True:
|
|
231
|
+
line = reader.readline()
|
|
232
|
+
if not line:
|
|
233
|
+
return None
|
|
234
|
+
start_line = line.rstrip(b"\r\n")
|
|
235
|
+
if start_line: # tolerate stray blank lines between messages
|
|
236
|
+
break
|
|
237
|
+
raw = []
|
|
238
|
+
while True:
|
|
239
|
+
h = reader.readline()
|
|
240
|
+
if not h or h in (b"\r\n", b"\n"):
|
|
241
|
+
break
|
|
242
|
+
raw.append(h)
|
|
243
|
+
return start_line, _parse_headers(raw)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _serialize(start_line, parsed):
|
|
247
|
+
out = [start_line.rstrip(b"\r\n"), b"\r\n"]
|
|
248
|
+
for name, val in parsed:
|
|
249
|
+
out += [name, b": ", val, b"\r\n"]
|
|
250
|
+
out.append(b"\r\n")
|
|
251
|
+
return b"".join(out)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _hget(parsed, name):
|
|
255
|
+
nl = name.lower()
|
|
256
|
+
for n, v in parsed:
|
|
257
|
+
if n.lower() == nl:
|
|
258
|
+
return v
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _hset(parsed, name, value):
|
|
263
|
+
nl = name.lower()
|
|
264
|
+
for pair in parsed:
|
|
265
|
+
if pair[0].lower() == nl:
|
|
266
|
+
pair[1] = value
|
|
267
|
+
return
|
|
268
|
+
parsed.append([name, value])
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _hdel(parsed, name):
|
|
272
|
+
nl = name.lower()
|
|
273
|
+
parsed[:] = [p for p in parsed if p[0].lower() != nl]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _status_code(start_line):
|
|
277
|
+
parts = start_line.split(b" ", 2)
|
|
278
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
279
|
+
return int(parts[1])
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _conn_close(parsed):
|
|
284
|
+
v = _hget(parsed, b"Connection")
|
|
285
|
+
return v is not None and b"close" in v.lower()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_COOKIE_DOMAIN_RE = re.compile(rb"(?i)(;\s*domain=)([^;]+)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _rewrite_cookie_domain(value):
|
|
292
|
+
def repl(m):
|
|
293
|
+
dom = m.group(2).strip()
|
|
294
|
+
if dom.lower().endswith(b".local"):
|
|
295
|
+
return m.group(0)
|
|
296
|
+
return m.group(1) + dom + b".local"
|
|
297
|
+
return _COOKIE_DOMAIN_RE.sub(repl, value)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _safe_close(sock):
|
|
301
|
+
try:
|
|
302
|
+
sock.close()
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _send_simple(sock, code, msg):
|
|
308
|
+
reason = {403: b"Forbidden", 421: b"Misdirected Request",
|
|
309
|
+
502: b"Bad Gateway", 404: b"Not Found"}.get(code, b"Error")
|
|
310
|
+
body = msg + b"\n"
|
|
311
|
+
resp = (b"HTTP/1.1 %d %s\r\n" % (code, reason)
|
|
312
|
+
+ b"Content-Type: text/plain; charset=utf-8\r\n"
|
|
313
|
+
+ b"Content-Length: %d\r\n" % len(body)
|
|
314
|
+
+ b"Connection: close\r\n\r\n" + body)
|
|
315
|
+
try:
|
|
316
|
+
sock.sendall(resp)
|
|
317
|
+
except OSError:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ── Body relaying ────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
def _relay_n(reader, out_sock, n):
|
|
324
|
+
remaining = n
|
|
325
|
+
while remaining > 0:
|
|
326
|
+
chunk = reader.read(min(65536, remaining))
|
|
327
|
+
if not chunk:
|
|
328
|
+
break
|
|
329
|
+
out_sock.sendall(chunk)
|
|
330
|
+
remaining -= len(chunk)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _relay_chunked(reader, out_sock):
|
|
334
|
+
while True:
|
|
335
|
+
size_line = reader.readline()
|
|
336
|
+
if not size_line:
|
|
337
|
+
break
|
|
338
|
+
out_sock.sendall(size_line)
|
|
339
|
+
try:
|
|
340
|
+
size = int(size_line.strip().split(b";", 1)[0], 16)
|
|
341
|
+
except ValueError:
|
|
342
|
+
break
|
|
343
|
+
if size == 0: # last chunk -> relay trailers up to blank line
|
|
344
|
+
while True:
|
|
345
|
+
t = reader.readline()
|
|
346
|
+
out_sock.sendall(t)
|
|
347
|
+
if not t or t in (b"\r\n", b"\n"):
|
|
348
|
+
break
|
|
349
|
+
break
|
|
350
|
+
_relay_n(reader, out_sock, size)
|
|
351
|
+
out_sock.sendall(reader.read(2)) # trailing CRLF
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _relay_until_eof(reader, out_sock):
|
|
355
|
+
while True:
|
|
356
|
+
chunk = reader.read1(65536)
|
|
357
|
+
if not chunk:
|
|
358
|
+
break
|
|
359
|
+
out_sock.sendall(chunk)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _transfer_body(reader, out_sock, parsed, is_request, method=b"GET", status=None):
|
|
363
|
+
"""Relay a message body. Returns True only for a close-delimited response."""
|
|
364
|
+
if not is_request:
|
|
365
|
+
if method == b"HEAD" or status in (204, 304) or (status is not None and 100 <= status < 200):
|
|
366
|
+
return False
|
|
367
|
+
te = (_hget(parsed, b"Transfer-Encoding") or b"").lower()
|
|
368
|
+
if b"chunked" in te:
|
|
369
|
+
_relay_chunked(reader, out_sock)
|
|
370
|
+
return False
|
|
371
|
+
cl = _hget(parsed, b"Content-Length")
|
|
372
|
+
if cl is not None:
|
|
373
|
+
try:
|
|
374
|
+
n = int(cl.strip())
|
|
375
|
+
except ValueError:
|
|
376
|
+
n = 0
|
|
377
|
+
_relay_n(reader, out_sock, n)
|
|
378
|
+
return False
|
|
379
|
+
if is_request:
|
|
380
|
+
return False # no framing => no request body
|
|
381
|
+
_relay_until_eof(reader, out_sock) # response body delimited by connection close
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Reverse proxy ────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
class LocalReverseProxy:
|
|
388
|
+
def __init__(self, domains, listen_port=443, upstream_port=443,
|
|
389
|
+
bind="127.0.0.1", insecure_upstream=False, timeout=30):
|
|
390
|
+
self.domains = domains
|
|
391
|
+
self.listen_port = listen_port
|
|
392
|
+
self.upstream_port = upstream_port
|
|
393
|
+
self.bind = bind
|
|
394
|
+
self.timeout = timeout
|
|
395
|
+
self.running = True
|
|
396
|
+
self.local_to_real = {f"{d}.local": d for d in domains}
|
|
397
|
+
self._ssl_ctx = None
|
|
398
|
+
self._server = None
|
|
399
|
+
|
|
400
|
+
self.up_ctx = ssl.create_default_context()
|
|
401
|
+
if insecure_upstream:
|
|
402
|
+
self.up_ctx.check_hostname = False
|
|
403
|
+
self.up_ctx.verify_mode = ssl.CERT_NONE
|
|
404
|
+
try:
|
|
405
|
+
self.up_ctx.set_alpn_protocols(["http/1.1"])
|
|
406
|
+
except NotImplementedError:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
def _route(self, host_header):
|
|
410
|
+
if not host_header:
|
|
411
|
+
return None
|
|
412
|
+
h = host_header.decode("latin-1").strip().lower().split(":")[0]
|
|
413
|
+
return self.local_to_real.get(h)
|
|
414
|
+
|
|
415
|
+
def _rewrite_request(self, parsed, real_host):
|
|
416
|
+
_hset(parsed, b"Host", real_host.encode())
|
|
417
|
+
for hdr in (b"Origin", b"Referer"):
|
|
418
|
+
v = _hget(parsed, hdr)
|
|
419
|
+
if v is None:
|
|
420
|
+
continue
|
|
421
|
+
nv = v
|
|
422
|
+
for local, real in self.local_to_real.items():
|
|
423
|
+
nv = nv.replace(local.encode(), real.encode())
|
|
424
|
+
if nv != v:
|
|
425
|
+
_hset(parsed, hdr, nv)
|
|
426
|
+
|
|
427
|
+
def _rewrite_response(self, parsed):
|
|
428
|
+
for hdr in (b"Location", b"Content-Location", b"Access-Control-Allow-Origin"):
|
|
429
|
+
v = _hget(parsed, hdr)
|
|
430
|
+
if v is None:
|
|
431
|
+
continue
|
|
432
|
+
nv = v
|
|
433
|
+
for local, real in self.local_to_real.items():
|
|
434
|
+
# real -> local, but skip values already ending in .local
|
|
435
|
+
nv = re.sub(re.escape(real).encode() + rb"(?!\.local)",
|
|
436
|
+
lambda m, _l=local: _l.encode(), nv)
|
|
437
|
+
if nv != v:
|
|
438
|
+
_hset(parsed, hdr, nv)
|
|
439
|
+
for pair in parsed:
|
|
440
|
+
if pair[0].lower() == b"set-cookie":
|
|
441
|
+
pair[1] = _rewrite_cookie_domain(pair[1])
|
|
442
|
+
|
|
443
|
+
def _connect_upstream(self, real_host):
|
|
444
|
+
try:
|
|
445
|
+
s = socket.create_connection((real_host, self.upstream_port), timeout=self.timeout)
|
|
446
|
+
ss = self.up_ctx.wrap_socket(s, server_hostname=real_host)
|
|
447
|
+
ss.settimeout(self.timeout)
|
|
448
|
+
return ss, ss.makefile("rb")
|
|
449
|
+
except (OSError, ssl.SSLError) as e:
|
|
450
|
+
error(f"upstream {real_host}:{self.upstream_port} failed: {e}")
|
|
451
|
+
return None, None
|
|
452
|
+
|
|
453
|
+
def _relay_upgrade(self, tls, creader, usock, ureader):
|
|
454
|
+
def pipe(reader, dst):
|
|
455
|
+
try:
|
|
456
|
+
while True:
|
|
457
|
+
data = reader.read1(65536)
|
|
458
|
+
if not data:
|
|
459
|
+
break
|
|
460
|
+
dst.sendall(data)
|
|
461
|
+
except (OSError, ssl.SSLError):
|
|
462
|
+
pass
|
|
463
|
+
finally:
|
|
464
|
+
try:
|
|
465
|
+
dst.shutdown(socket.SHUT_WR)
|
|
466
|
+
except OSError:
|
|
467
|
+
pass
|
|
468
|
+
t1 = threading.Thread(target=pipe, args=(creader, usock), daemon=True)
|
|
469
|
+
t2 = threading.Thread(target=pipe, args=(ureader, tls), daemon=True)
|
|
470
|
+
t1.start(); t2.start()
|
|
471
|
+
t1.join(); t2.join()
|
|
472
|
+
|
|
473
|
+
def _handle(self, raw_conn, addr):
|
|
474
|
+
try:
|
|
475
|
+
tls = self._ssl_ctx.wrap_socket(raw_conn, server_side=True)
|
|
476
|
+
except (ssl.SSLError, OSError):
|
|
477
|
+
_safe_close(raw_conn)
|
|
478
|
+
return
|
|
479
|
+
tls.settimeout(self.timeout)
|
|
480
|
+
creader = tls.makefile("rb")
|
|
481
|
+
upstream = None # [sock, reader, real_host, reusable]
|
|
482
|
+
try:
|
|
483
|
+
while self.running:
|
|
484
|
+
head = _read_head(creader)
|
|
485
|
+
if head is None:
|
|
486
|
+
break
|
|
487
|
+
start_line, parsed = head
|
|
488
|
+
method = start_line.split(b" ", 1)[0].upper()
|
|
489
|
+
real_host = self._route(_hget(parsed, b"Host"))
|
|
490
|
+
if real_host is None:
|
|
491
|
+
_send_simple(tls, 421, b"Unknown .local host for this proxy")
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
if upstream is None or upstream[2] != real_host or not upstream[3]:
|
|
495
|
+
if upstream is not None:
|
|
496
|
+
_safe_close(upstream[0])
|
|
497
|
+
s, r = self._connect_upstream(real_host)
|
|
498
|
+
if s is None:
|
|
499
|
+
_send_simple(tls, 502, b"Upstream connection failed")
|
|
500
|
+
break
|
|
501
|
+
upstream = [s, r, real_host, True]
|
|
502
|
+
usock, ureader = upstream[0], upstream[1]
|
|
503
|
+
|
|
504
|
+
expect_continue = False
|
|
505
|
+
ev = _hget(parsed, b"Expect")
|
|
506
|
+
if ev is not None and b"100-continue" in ev.lower():
|
|
507
|
+
expect_continue = True
|
|
508
|
+
_hdel(parsed, b"Expect")
|
|
509
|
+
|
|
510
|
+
self._rewrite_request(parsed, real_host)
|
|
511
|
+
try:
|
|
512
|
+
usock.sendall(_serialize(start_line, parsed))
|
|
513
|
+
if expect_continue:
|
|
514
|
+
tls.sendall(b"HTTP/1.1 100 Continue\r\n\r\n")
|
|
515
|
+
_transfer_body(creader, usock, parsed, is_request=True, method=method)
|
|
516
|
+
except (OSError, ssl.SSLError):
|
|
517
|
+
upstream[3] = False
|
|
518
|
+
_send_simple(tls, 502, b"Upstream write failed")
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
# Read the response, transparently passing through 1xx interim heads.
|
|
522
|
+
resp = None
|
|
523
|
+
while True:
|
|
524
|
+
resp = _read_head(ureader)
|
|
525
|
+
if resp is None:
|
|
526
|
+
break
|
|
527
|
+
rstart, rparsed = resp
|
|
528
|
+
status = _status_code(rstart)
|
|
529
|
+
self._rewrite_response(rparsed)
|
|
530
|
+
tls.sendall(_serialize(rstart, rparsed))
|
|
531
|
+
if status is not None and 100 <= status < 200 and status != 101:
|
|
532
|
+
continue
|
|
533
|
+
break
|
|
534
|
+
if resp is None:
|
|
535
|
+
upstream[3] = False
|
|
536
|
+
_send_simple(tls, 502, b"Upstream closed connection")
|
|
537
|
+
break
|
|
538
|
+
|
|
539
|
+
if status == 101: # WebSocket / protocol switch
|
|
540
|
+
self._relay_upgrade(tls, creader, usock, ureader)
|
|
541
|
+
break
|
|
542
|
+
|
|
543
|
+
close_delimited = _transfer_body(
|
|
544
|
+
ureader, tls, rparsed, is_request=False, method=method, status=status)
|
|
545
|
+
|
|
546
|
+
if _conn_close(rparsed) or close_delimited:
|
|
547
|
+
upstream[3] = False
|
|
548
|
+
if _conn_close(parsed) or close_delimited:
|
|
549
|
+
break
|
|
550
|
+
except (ssl.SSLError, OSError, ConnectionError, ValueError):
|
|
551
|
+
pass
|
|
552
|
+
finally:
|
|
553
|
+
if upstream is not None:
|
|
554
|
+
_safe_close(upstream[0])
|
|
555
|
+
_safe_close(tls)
|
|
556
|
+
|
|
557
|
+
def serve(self, ssl_ctx):
|
|
558
|
+
self._ssl_ctx = ssl_ctx
|
|
559
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
560
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
561
|
+
srv.bind((self.bind, self.listen_port))
|
|
562
|
+
srv.listen(128)
|
|
563
|
+
srv.settimeout(1)
|
|
564
|
+
self._server = srv
|
|
565
|
+
while self.running:
|
|
566
|
+
try:
|
|
567
|
+
conn, addr = srv.accept()
|
|
568
|
+
except socket.timeout:
|
|
569
|
+
continue
|
|
570
|
+
except OSError:
|
|
571
|
+
break
|
|
572
|
+
conn.settimeout(self.timeout)
|
|
573
|
+
threading.Thread(target=self._handle, args=(conn, addr), daemon=True).start()
|
|
574
|
+
_safe_close(srv)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ── CLI command ──────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
EPILOG = Text.from_markup(
|
|
580
|
+
"[bold]Examples[/bold]\n\n"
|
|
581
|
+
" [cyan]evo localproxy edunext.ptudev.net --install-ca[/cyan]\n"
|
|
582
|
+
" [cyan]evo localproxy edunext.ptudev.net api.ptudev.net[/cyan]\n"
|
|
583
|
+
" [cyan]evo localproxy edunext.ptudev.net -p 8443[/cyan] (if 443 is busy)\n"
|
|
584
|
+
" [cyan]evo localproxy edunext.ptudev.net --no-hosts[/cyan]"
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def run_localproxy(domains, port, bind, no_hosts, cert_dir,
|
|
589
|
+
install_ca, insecure_upstream, upstream_port):
|
|
590
|
+
norm = []
|
|
591
|
+
for d in domains:
|
|
592
|
+
d = re.sub(r"^https?://", "", d.strip().lower()).split("/")[0].split(":")[0]
|
|
593
|
+
if d.endswith(".local"):
|
|
594
|
+
d = d[: -len(".local")]
|
|
595
|
+
if d and d not in norm:
|
|
596
|
+
norm.append(d)
|
|
597
|
+
if not norm:
|
|
598
|
+
error("No valid domains given.")
|
|
599
|
+
return
|
|
600
|
+
local_hosts = [f"{d}.local" for d in norm]
|
|
601
|
+
|
|
602
|
+
cdir = _cert_dir(cert_dir)
|
|
603
|
+
ca_key, ca_crt, ca_crt_path = _load_or_create_ca(cdir)
|
|
604
|
+
chain_path, key_path = _make_leaf(cdir, ca_key, ca_crt, local_hosts)
|
|
605
|
+
ssl_ctx = _server_ctx(chain_path, key_path)
|
|
606
|
+
|
|
607
|
+
if install_ca:
|
|
608
|
+
_install_ca(ca_crt_path)
|
|
609
|
+
if not no_hosts:
|
|
610
|
+
_setup_hosts(local_hosts)
|
|
611
|
+
|
|
612
|
+
def _url(host, p):
|
|
613
|
+
return f"https://{host}" if p == 443 else f"https://{host}:{p}"
|
|
614
|
+
|
|
615
|
+
table = Table(title="Local HTTPS Reverse Proxy", title_style="accent")
|
|
616
|
+
table.add_column("Browse (local)", style="cyan")
|
|
617
|
+
table.add_column("", style="white")
|
|
618
|
+
table.add_column("Forwards to", style="green")
|
|
619
|
+
for d in norm:
|
|
620
|
+
table.add_row(_url(f"{d}.local", port), "->", _url(d, upstream_port))
|
|
621
|
+
console.print(table)
|
|
622
|
+
console.print(f"[cmd]CA cert: {ca_crt_path}[/cmd]")
|
|
623
|
+
if not install_ca:
|
|
624
|
+
console.print("[cmd]Trust the CA once so browsers don't warn:[/cmd]")
|
|
625
|
+
if os.name == "nt":
|
|
626
|
+
console.print(f'[cmd] certutil -addstore -user -f Root "{ca_crt_path}" '
|
|
627
|
+
"(or re-run with --install-ca)[/cmd]")
|
|
628
|
+
else:
|
|
629
|
+
console.print(f"[cmd] import {ca_crt_path} into your trust store[/cmd]")
|
|
630
|
+
info(f"Listening on {bind}:{port} — Ctrl+C to stop.")
|
|
631
|
+
|
|
632
|
+
proxy = LocalReverseProxy(norm, listen_port=port, upstream_port=upstream_port,
|
|
633
|
+
bind=bind, insecure_upstream=insecure_upstream)
|
|
634
|
+
|
|
635
|
+
def _shutdown(_sig, _frame):
|
|
636
|
+
proxy.running = False
|
|
637
|
+
try:
|
|
638
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
639
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
640
|
+
except (ValueError, AttributeError):
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
proxy.serve(ssl_ctx)
|
|
645
|
+
except OSError as exc:
|
|
646
|
+
error(f"Cannot bind {bind}:{port} — {exc}")
|
|
647
|
+
if port == 443:
|
|
648
|
+
warning("Port 443 is likely in use. Try `-p 8443` and browse "
|
|
649
|
+
"https://{domain}.local:8443")
|
|
650
|
+
except KeyboardInterrupt:
|
|
651
|
+
pass
|
|
652
|
+
finally:
|
|
653
|
+
proxy.running = False
|
|
654
|
+
if not no_hosts:
|
|
655
|
+
_cleanup_hosts()
|
|
656
|
+
success("Proxy stopped.")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@click.command("localproxy", epilog=EPILOG)
|
|
660
|
+
@click.argument("domains", nargs=-1, required=True)
|
|
661
|
+
@click.option("-p", "--port", type=int, default=443, show_default=True, help="Local HTTPS listen port.")
|
|
662
|
+
@click.option("-u", "--upstream-port", type=int, default=443, show_default=True,
|
|
663
|
+
help="Upstream HTTPS port to forward to.")
|
|
664
|
+
@click.option("-b", "--bind", default="127.0.0.1", show_default=True, help="Bind address.")
|
|
665
|
+
@click.option("--no-hosts", is_flag=True, help="Don't modify the hosts file.")
|
|
666
|
+
@click.option("--cert-dir", type=click.Path(), default=None, help="Where to store the local CA / leaf certs.")
|
|
667
|
+
@click.option("--install-ca", is_flag=True, help="Install the local CA into the OS trust store.")
|
|
668
|
+
@click.option("--insecure-upstream", is_flag=True, help="Skip upstream TLS certificate verification.")
|
|
669
|
+
def localproxy(domains, port, upstream_port, bind, no_hosts, cert_dir, install_ca, insecure_upstream):
|
|
670
|
+
"""HTTPS reverse proxy: `https://{domain}.local` -> `https://{domain}`.
|
|
671
|
+
|
|
672
|
+
Maps a `.local` alias of a real site to the real site over TLS so you can
|
|
673
|
+
browse / test it under a local name. Host/Origin/Referer are rewritten
|
|
674
|
+
outbound; Location/Set-Cookie/CORS rewritten inbound so logins and redirects
|
|
675
|
+
stay inside the alias. A local CA issues the `.local` cert (trust it once
|
|
676
|
+
with `--install-ca`). Editing the hosts file needs an elevated shell.
|
|
677
|
+
"""
|
|
678
|
+
step("evo localproxy")
|
|
679
|
+
run_localproxy(domains, port, bind, no_hosts, cert_dir, install_ca, insecure_upstream, upstream_port)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import json as jsonlib
|
|
2
|
+
import socket
|
|
3
|
+
import ssl
|
|
4
|
+
import struct
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
import rich_click as click
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from evo_cli.console import console, error, info, step, success, warning
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOSTS = ["google.com", "facebook.com", "github.com"]
|
|
17
|
+
PORT = 443
|
|
18
|
+
SPEED_BYTES = 25_000_000
|
|
19
|
+
SPEED_URL = "https://speed.cloudflare.com/__down?bytes={n}"
|
|
20
|
+
|
|
21
|
+
# Thresholds used to turn raw numbers into a plain-language verdict.
|
|
22
|
+
DNS_SLOW_MS = 100
|
|
23
|
+
LATENCY_HIGH_MS = 150
|
|
24
|
+
JITTER_HIGH_MS = 80
|
|
25
|
+
|
|
26
|
+
EPILOG = Text.from_markup(
|
|
27
|
+
"[bold]Examples[/bold]\n\n"
|
|
28
|
+
" [cyan]evo netcheck[/cyan] check google, facebook, github\n"
|
|
29
|
+
" [cyan]evo netcheck example.com 1.1.1.1[/cyan] check your own hosts\n"
|
|
30
|
+
" [cyan]evo netcheck --speed[/cyan] also run a download throughput test\n"
|
|
31
|
+
" [cyan]evo netcheck -c 10[/cyan] take 10 latency samples per host\n"
|
|
32
|
+
" [cyan]evo netcheck --json[/cyan] machine-readable output\n\n"
|
|
33
|
+
"[dim]Splits every connection into DNS / TCP / TLS / TTFB so you can see which\n"
|
|
34
|
+
"phase is slow, measures latency + jitter + packet loss, and flags the classic\n"
|
|
35
|
+
"'broken IPv6' case where DNS hands out AAAA records the host cannot reach.[/dim]"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ms(start):
|
|
40
|
+
return (time.perf_counter() - start) * 1000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve(host, family):
|
|
44
|
+
"""Return resolved addresses for ``family`` (raises socket.gaierror on failure)."""
|
|
45
|
+
return socket.getaddrinfo(host, PORT, family, socket.SOCK_STREAM)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- Raw AAAA lookup ---------------------------------------------------------
|
|
49
|
+
# socket.getaddrinfo(AF_INET6) is filtered by the OS when the host has no IPv6
|
|
50
|
+
# source address (Windows returns "getaddrinfo failed"), which would hide the
|
|
51
|
+
# very "broken IPv6" case we want to catch. So query DNS for AAAA records
|
|
52
|
+
# directly over UDP - independent of local IPv6 connectivity, no dependencies.
|
|
53
|
+
PUBLIC_RESOLVERS = ["1.1.1.1", "8.8.8.8"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dns_resolvers():
|
|
57
|
+
"""Local nameservers first (Linux/mac resolv.conf), then public fallbacks."""
|
|
58
|
+
found = []
|
|
59
|
+
try:
|
|
60
|
+
with open("/etc/resolv.conf") as handle:
|
|
61
|
+
for line in handle:
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if line.startswith("nameserver"):
|
|
64
|
+
parts = line.split()
|
|
65
|
+
if len(parts) > 1:
|
|
66
|
+
found.append(parts[1])
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
for r in PUBLIC_RESOLVERS:
|
|
70
|
+
if r not in found:
|
|
71
|
+
found.append(r)
|
|
72
|
+
return found
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _encode_qname(host):
|
|
76
|
+
out = bytearray()
|
|
77
|
+
for label in host.rstrip(".").split("."):
|
|
78
|
+
raw = label.encode("idna") if any(ord(c) > 127 for c in label) else label.encode()
|
|
79
|
+
out.append(len(raw))
|
|
80
|
+
out.extend(raw)
|
|
81
|
+
out.append(0)
|
|
82
|
+
return bytes(out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _skip_name(data, off):
|
|
86
|
+
"""Advance past a (possibly compressed) DNS name and return the new offset."""
|
|
87
|
+
while off < len(data):
|
|
88
|
+
length = data[off]
|
|
89
|
+
if length == 0:
|
|
90
|
+
return off + 1
|
|
91
|
+
if length & 0xC0 == 0xC0: # compression pointer (2 bytes)
|
|
92
|
+
return off + 2
|
|
93
|
+
off += 1 + length
|
|
94
|
+
return off
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dns_query_aaaa(host, resolvers, timeout=2.0):
|
|
98
|
+
"""Return AAAA addresses from DNS, [] if none published, or None if DNS unreachable."""
|
|
99
|
+
header = struct.pack(">HHHHHH", 0xABCD, 0x0100, 1, 0, 0, 0)
|
|
100
|
+
packet = header + _encode_qname(host) + struct.pack(">HH", 28, 1) # QTYPE=AAAA, QCLASS=IN
|
|
101
|
+
for resolver in resolvers:
|
|
102
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
103
|
+
sock.settimeout(timeout)
|
|
104
|
+
try:
|
|
105
|
+
sock.sendto(packet, (resolver, 53))
|
|
106
|
+
data, _ = sock.recvfrom(4096)
|
|
107
|
+
except OSError:
|
|
108
|
+
continue
|
|
109
|
+
finally:
|
|
110
|
+
sock.close()
|
|
111
|
+
ancount = struct.unpack(">H", data[6:8])[0]
|
|
112
|
+
off = _skip_name(data, 12) + 4 # skip question name + qtype/qclass
|
|
113
|
+
addrs = []
|
|
114
|
+
for _ in range(ancount):
|
|
115
|
+
off = _skip_name(data, off)
|
|
116
|
+
if off + 10 > len(data):
|
|
117
|
+
break
|
|
118
|
+
rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[off : off + 10])
|
|
119
|
+
off += 10
|
|
120
|
+
rdata = data[off : off + rdlength]
|
|
121
|
+
off += rdlength
|
|
122
|
+
if rtype == 28 and rdlength == 16:
|
|
123
|
+
addrs.append(socket.inet_ntop(socket.AF_INET6, rdata))
|
|
124
|
+
return addrs
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def probe(host, timeout):
|
|
129
|
+
"""Time the DNS / TCP / TLS / TTFB phases of one HTTPS connection."""
|
|
130
|
+
res = {
|
|
131
|
+
"host": host,
|
|
132
|
+
"ip": None,
|
|
133
|
+
"dns_ms": None,
|
|
134
|
+
"tcp_ms": None,
|
|
135
|
+
"tls_ms": None,
|
|
136
|
+
"ttfb_ms": None,
|
|
137
|
+
"total_ms": None,
|
|
138
|
+
"aaaa": [],
|
|
139
|
+
"has_aaaa": False,
|
|
140
|
+
"error": None,
|
|
141
|
+
}
|
|
142
|
+
started = time.perf_counter()
|
|
143
|
+
try:
|
|
144
|
+
t = time.perf_counter()
|
|
145
|
+
infos = resolve(host, socket.AF_INET)
|
|
146
|
+
res["dns_ms"] = _ms(t)
|
|
147
|
+
res["ip"] = infos[0][4][0]
|
|
148
|
+
|
|
149
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
150
|
+
sock.settimeout(timeout)
|
|
151
|
+
t = time.perf_counter()
|
|
152
|
+
sock.connect((res["ip"], PORT))
|
|
153
|
+
res["tcp_ms"] = _ms(t)
|
|
154
|
+
|
|
155
|
+
ctx = ssl.create_default_context()
|
|
156
|
+
t = time.perf_counter()
|
|
157
|
+
ssock = ctx.wrap_socket(sock, server_hostname=host)
|
|
158
|
+
res["tls_ms"] = _ms(t)
|
|
159
|
+
|
|
160
|
+
request = (
|
|
161
|
+
f"HEAD / HTTP/1.1\r\nHost: {host}\r\n"
|
|
162
|
+
"User-Agent: evo-cli/netcheck\r\nAccept: */*\r\nConnection: close\r\n\r\n"
|
|
163
|
+
)
|
|
164
|
+
t = time.perf_counter()
|
|
165
|
+
ssock.sendall(request.encode())
|
|
166
|
+
ssock.recv(1)
|
|
167
|
+
res["ttfb_ms"] = _ms(t)
|
|
168
|
+
ssock.close()
|
|
169
|
+
res["total_ms"] = _ms(started)
|
|
170
|
+
except (socket.gaierror, socket.timeout, OSError, ssl.SSLError) as exc:
|
|
171
|
+
res["error"] = str(exc)
|
|
172
|
+
return res
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def latency_samples(ip, count, timeout):
|
|
176
|
+
"""Measure TCP-connect RTT to ``ip`` ``count`` times; return (rtts_ms, failures)."""
|
|
177
|
+
rtts = []
|
|
178
|
+
fails = 0
|
|
179
|
+
for _ in range(count):
|
|
180
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
181
|
+
sock.settimeout(timeout)
|
|
182
|
+
try:
|
|
183
|
+
t = time.perf_counter()
|
|
184
|
+
sock.connect((ip, PORT))
|
|
185
|
+
rtts.append(_ms(t))
|
|
186
|
+
except OSError:
|
|
187
|
+
fails += 1
|
|
188
|
+
finally:
|
|
189
|
+
sock.close()
|
|
190
|
+
return rtts, fails
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def measure_host(host, count, timeout, resolvers):
|
|
194
|
+
row = probe(host, timeout)
|
|
195
|
+
aaaa = dns_query_aaaa(host, resolvers)
|
|
196
|
+
row["aaaa"] = aaaa or []
|
|
197
|
+
row["has_aaaa"] = bool(aaaa)
|
|
198
|
+
row.update({"avg_ms": None, "min_ms": None, "max_ms": None, "jitter_ms": None, "loss": None})
|
|
199
|
+
if row["ip"] and count > 0:
|
|
200
|
+
rtts, fails = latency_samples(row["ip"], count, timeout)
|
|
201
|
+
total = count
|
|
202
|
+
row["loss"] = (fails / total) * 100 if total else 0.0
|
|
203
|
+
if rtts:
|
|
204
|
+
row["avg_ms"] = sum(rtts) / len(rtts)
|
|
205
|
+
row["min_ms"] = min(rtts)
|
|
206
|
+
row["max_ms"] = max(rtts)
|
|
207
|
+
row["jitter_ms"] = row["max_ms"] - row["min_ms"]
|
|
208
|
+
return row
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def check_ipv6(rows, timeout):
|
|
212
|
+
"""Detect broken IPv6: AAAA records exist in DNS but the host cannot reach them."""
|
|
213
|
+
for row in rows:
|
|
214
|
+
addrs = row.get("aaaa") or []
|
|
215
|
+
if not addrs:
|
|
216
|
+
continue
|
|
217
|
+
ip = addrs[0]
|
|
218
|
+
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
|
219
|
+
sock.settimeout(timeout)
|
|
220
|
+
try:
|
|
221
|
+
t = time.perf_counter()
|
|
222
|
+
sock.connect((ip, PORT))
|
|
223
|
+
return {"status": "healthy", "host": row["host"], "ip": ip, "rtt_ms": _ms(t)}
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
return {"status": "broken", "host": row["host"], "ip": ip, "error": str(exc)}
|
|
226
|
+
finally:
|
|
227
|
+
sock.close()
|
|
228
|
+
return {"status": "none"}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def speed_test(timeout):
|
|
232
|
+
url = SPEED_URL.format(n=SPEED_BYTES)
|
|
233
|
+
req = urllib.request.Request(url, headers={"User-Agent": "evo-cli/netcheck"})
|
|
234
|
+
total = 0
|
|
235
|
+
started = time.perf_counter()
|
|
236
|
+
try:
|
|
237
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
238
|
+
while True:
|
|
239
|
+
chunk = resp.read(65536)
|
|
240
|
+
if not chunk:
|
|
241
|
+
break
|
|
242
|
+
total += len(chunk)
|
|
243
|
+
except (urllib.error.URLError, socket.timeout, OSError) as exc:
|
|
244
|
+
return {"error": str(exc)}
|
|
245
|
+
elapsed = time.perf_counter() - started
|
|
246
|
+
mbps = (total * 8) / elapsed / 1e6 if elapsed else 0.0
|
|
247
|
+
return {"bytes": total, "seconds": elapsed, "mbps": mbps}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fmt(value, suffix="", missing="-"):
|
|
251
|
+
if value is None:
|
|
252
|
+
return missing
|
|
253
|
+
return f"{value:.0f}{suffix}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def render_table(rows):
|
|
257
|
+
table = Table(show_header=True, header_style="accent", expand=False)
|
|
258
|
+
table.add_column("Host", style="info", no_wrap=True)
|
|
259
|
+
table.add_column("IP", style="dim", no_wrap=True, min_width=15)
|
|
260
|
+
table.add_column("DNS", justify="right")
|
|
261
|
+
table.add_column("TCP", justify="right")
|
|
262
|
+
table.add_column("TLS", justify="right")
|
|
263
|
+
table.add_column("TTFB", justify="right")
|
|
264
|
+
table.add_column("Latency avg/jit", justify="right")
|
|
265
|
+
table.add_column("Loss", justify="right")
|
|
266
|
+
table.add_column("v6", justify="center")
|
|
267
|
+
|
|
268
|
+
for r in rows:
|
|
269
|
+
if r["error"]:
|
|
270
|
+
table.add_row(r["host"], "[error]unreachable[/error]", "-", "-", "-", "-", "-", "-", "-")
|
|
271
|
+
continue
|
|
272
|
+
latency = f"{fmt(r['avg_ms'])}/{fmt(r['jitter_ms'])}ms" if r["avg_ms"] is not None else "-"
|
|
273
|
+
loss = r["loss"]
|
|
274
|
+
loss_text = "-" if loss is None else (f"[error]{loss:.0f}%[/error]" if loss > 0 else "0%")
|
|
275
|
+
v6 = "[success]yes[/success]" if r["has_aaaa"] else "[dim]no[/dim]"
|
|
276
|
+
table.add_row(
|
|
277
|
+
r["host"],
|
|
278
|
+
r["ip"] or "-",
|
|
279
|
+
fmt(r["dns_ms"], "ms"),
|
|
280
|
+
fmt(r["tcp_ms"], "ms"),
|
|
281
|
+
fmt(r["tls_ms"], "ms"),
|
|
282
|
+
fmt(r["ttfb_ms"], "ms"),
|
|
283
|
+
latency,
|
|
284
|
+
loss_text,
|
|
285
|
+
v6,
|
|
286
|
+
)
|
|
287
|
+
console.print(table)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def build_notes(rows, ipv6):
|
|
291
|
+
notes = []
|
|
292
|
+
for r in rows:
|
|
293
|
+
if r["error"]:
|
|
294
|
+
notes.append(("error", f"{r['host']}: cannot connect - {r['error']}"))
|
|
295
|
+
continue
|
|
296
|
+
if r["dns_ms"] and r["dns_ms"] > DNS_SLOW_MS:
|
|
297
|
+
notes.append(("warning", f"{r['host']}: slow DNS ({r['dns_ms']:.0f}ms)"))
|
|
298
|
+
if r["avg_ms"] and r["avg_ms"] > LATENCY_HIGH_MS:
|
|
299
|
+
notes.append(("warning", f"{r['host']}: high latency ({r['avg_ms']:.0f}ms avg)"))
|
|
300
|
+
if r["loss"]:
|
|
301
|
+
notes.append(("warning", f"{r['host']}: {r['loss']:.0f}% packet loss"))
|
|
302
|
+
if r["jitter_ms"] and r["jitter_ms"] > JITTER_HIGH_MS:
|
|
303
|
+
notes.append(("warning", f"{r['host']}: high jitter ({r['jitter_ms']:.0f}ms)"))
|
|
304
|
+
if ipv6["status"] == "broken":
|
|
305
|
+
notes.append(
|
|
306
|
+
(
|
|
307
|
+
"warning",
|
|
308
|
+
"broken IPv6: DNS returns AAAA records but the host can't reach IPv6 "
|
|
309
|
+
f"({ipv6['host']} {ipv6['ip']}). This can cause lag on dual-stack sites; "
|
|
310
|
+
"prefer IPv4 (Windows: set Tcpip6 DisabledComponents = 0x20, then reboot).",
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return notes
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def run(hosts, count, timeout, want_speed, as_json):
|
|
317
|
+
resolvers = dns_resolvers()
|
|
318
|
+
rows = []
|
|
319
|
+
for host in hosts:
|
|
320
|
+
with console.status(f"[info]probing {host}...[/info]", spinner="dots"):
|
|
321
|
+
rows.append(measure_host(host, count, timeout, resolvers))
|
|
322
|
+
|
|
323
|
+
with console.status("[info]checking IPv6 reachability...[/info]", spinner="dots"):
|
|
324
|
+
ipv6 = check_ipv6(rows, timeout)
|
|
325
|
+
|
|
326
|
+
speed = None
|
|
327
|
+
if want_speed:
|
|
328
|
+
with console.status("[info]running throughput test (25MB)...[/info]", spinner="dots"):
|
|
329
|
+
speed = speed_test(max(timeout, 60))
|
|
330
|
+
|
|
331
|
+
if as_json:
|
|
332
|
+
payload = {"hosts": rows, "ipv6": ipv6, "speed": speed}
|
|
333
|
+
console.print_json(jsonlib.dumps(payload, ensure_ascii=False))
|
|
334
|
+
return payload
|
|
335
|
+
|
|
336
|
+
render_table(rows)
|
|
337
|
+
|
|
338
|
+
if ipv6["status"] == "healthy":
|
|
339
|
+
info(f"IPv6: reachable via [accent]{ipv6['host']}[/accent] ({fmt(ipv6['rtt_ms'], 'ms')})")
|
|
340
|
+
elif ipv6["status"] == "broken":
|
|
341
|
+
warning("IPv6: AAAA records resolve but cannot connect (broken / half-configured)")
|
|
342
|
+
else:
|
|
343
|
+
info("IPv6: no AAAA records served - IPv4-only, nothing to worry about")
|
|
344
|
+
|
|
345
|
+
if speed is not None:
|
|
346
|
+
if speed.get("error"):
|
|
347
|
+
warning(f"Speed test failed: {speed['error']}")
|
|
348
|
+
else:
|
|
349
|
+
mb = speed["bytes"] / 1e6
|
|
350
|
+
success(
|
|
351
|
+
f"Throughput: [accent]{speed['mbps']:.0f} Mbps[/accent] "
|
|
352
|
+
f"({mb:.0f}MB in {speed['seconds']:.1f}s)"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
notes = build_notes(rows, ipv6)
|
|
356
|
+
step("Verdict")
|
|
357
|
+
if not notes:
|
|
358
|
+
success("Everything looks healthy - DNS, latency, loss and routing are all fine.")
|
|
359
|
+
else:
|
|
360
|
+
for level, message in notes:
|
|
361
|
+
{"warning": warning, "error": error}.get(level, info)(message)
|
|
362
|
+
|
|
363
|
+
return {"hosts": rows, "ipv6": ipv6, "speed": speed}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@click.command("netcheck", epilog=EPILOG)
|
|
367
|
+
@click.argument("hosts", nargs=-1)
|
|
368
|
+
@click.option("-c", "--count", default=4, show_default=True, help="Latency samples (TCP RTT) per host.")
|
|
369
|
+
@click.option("-t", "--timeout", default=5.0, show_default=True, help="Per-connection timeout (seconds).")
|
|
370
|
+
@click.option("-s", "--speed", "want_speed", is_flag=True, help="Also run a 25MB download throughput test.")
|
|
371
|
+
@click.option("--json", "as_json", is_flag=True, help="Print result as JSON.")
|
|
372
|
+
def netcheck(hosts, count, timeout, want_speed, as_json):
|
|
373
|
+
"""Diagnose why network access feels slow.
|
|
374
|
+
|
|
375
|
+
For each `HOST` (default: google.com, facebook.com, github.com) this splits a
|
|
376
|
+
real HTTPS connection into its **DNS**, **TCP**, **TLS** and **TTFB** phases so
|
|
377
|
+
you can see exactly where the time goes, then takes several TCP round-trips to
|
|
378
|
+
report **latency**, **jitter** and **packet loss**.
|
|
379
|
+
|
|
380
|
+
It also flags the classic *broken IPv6* trap - where DNS hands out AAAA
|
|
381
|
+
records the machine cannot actually reach, making dual-stack sites (Google,
|
|
382
|
+
Facebook) lag while a plain `curl` looks fine - and, with `--speed`, measures
|
|
383
|
+
real download throughput.
|
|
384
|
+
|
|
385
|
+
Pure Python (sockets), so it needs no admin rights and works the same on
|
|
386
|
+
Windows and Linux.
|
|
387
|
+
"""
|
|
388
|
+
step("evo netcheck")
|
|
389
|
+
targets = list(hosts) if hosts else DEFAULT_HOSTS
|
|
390
|
+
try:
|
|
391
|
+
run(targets, count, timeout, want_speed, as_json)
|
|
392
|
+
except click.ClickException:
|
|
393
|
+
raise
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
error(str(exc))
|
|
396
|
+
sys.exit(1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Requires-Python: >=3.9
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
+
Requires-Dist: cryptography>=41.0
|
|
17
18
|
Requires-Dist: paramiko>=2.7.0
|
|
18
19
|
Requires-Dist: rich>=13.0
|
|
19
20
|
Requires-Dist: rich-click>=1.8
|
|
@@ -20,7 +20,9 @@ evo_cli/commands/__init__.py
|
|
|
20
20
|
evo_cli/commands/cloudflare.py
|
|
21
21
|
evo_cli/commands/fix_claude.py
|
|
22
22
|
evo_cli/commands/gdrive.py
|
|
23
|
+
evo_cli/commands/localproxy.py
|
|
23
24
|
evo_cli/commands/miniconda.py
|
|
25
|
+
evo_cli/commands/netcheck.py
|
|
24
26
|
evo_cli/commands/site2s.py
|
|
25
27
|
evo_cli/commands/ssh.py
|
|
26
28
|
tests/__init__.py
|
evo_cli-0.3.1/evo_cli/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.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
|