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.
Files changed (33) hide show
  1. {evo_cli-0.3.1 → evo_cli-0.4.0}/PKG-INFO +2 -1
  2. evo_cli-0.4.0/evo_cli/VERSION +1 -0
  3. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/cli.py +4 -0
  4. evo_cli-0.4.0/evo_cli/commands/localproxy.py +679 -0
  5. evo_cli-0.4.0/evo_cli/commands/netcheck.py +396 -0
  6. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/PKG-INFO +2 -1
  7. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/SOURCES.txt +2 -0
  8. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/requires.txt +1 -0
  9. {evo_cli-0.3.1 → evo_cli-0.4.0}/pyproject.toml +1 -0
  10. evo_cli-0.3.1/evo_cli/VERSION +0 -1
  11. {evo_cli-0.3.1 → evo_cli-0.4.0}/Containerfile +0 -0
  12. {evo_cli-0.3.1 → evo_cli-0.4.0}/HISTORY.md +0 -0
  13. {evo_cli-0.3.1 → evo_cli-0.4.0}/LICENSE +0 -0
  14. {evo_cli-0.3.1 → evo_cli-0.4.0}/MANIFEST.in +0 -0
  15. {evo_cli-0.3.1 → evo_cli-0.4.0}/README.md +0 -0
  16. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/base.py +0 -0
  19. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/fix_claude.py +0 -0
  22. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/gdrive.py +0 -0
  23. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/miniconda.py +0 -0
  24. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/site2s.py +0 -0
  25. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/commands/ssh.py +0 -0
  26. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli/console.py +0 -0
  27. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  28. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/entry_points.txt +0 -0
  29. {evo_cli-0.3.1 → evo_cli-0.4.0}/evo_cli.egg-info/top_level.txt +0 -0
  30. {evo_cli-0.3.1 → evo_cli-0.4.0}/setup.cfg +0 -0
  31. {evo_cli-0.3.1 → evo_cli-0.4.0}/tests/__init__.py +0 -0
  32. {evo_cli-0.3.1 → evo_cli-0.4.0}/tests/test_cli.py +0 -0
  33. {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.1
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.1
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
@@ -1,3 +1,4 @@
1
+ cryptography>=41.0
1
2
  paramiko>=2.7.0
2
3
  rich>=13.0
3
4
  rich-click>=1.8
@@ -18,6 +18,7 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3",
19
19
  ]
20
20
  dependencies = [
21
+ "cryptography>=41.0",
21
22
  "paramiko>=2.7.0",
22
23
  "rich>=13.0",
23
24
  "rich-click>=1.8",
@@ -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