From 5b0739a397258559b01fe6134b0167d8a0c52922 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Tue, 11 Aug 2026 10:29:18 +0800 Subject: [PATCH] git proxy run: auto-forward Parallels host IPs to loopback proxy. Detect Shared/Host-Only addresses from ifconfig and socat-forward them so Windows guests can reach FastGitHub without a hardcoded 10.211.55.2. --- mytoolkit/commands/git.py | 134 +++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/mytoolkit/commands/git.py b/mytoolkit/commands/git.py index 8a8bb6d..ea91560 100644 --- a/mytoolkit/commands/git.py +++ b/mytoolkit/commands/git.py @@ -1,14 +1,23 @@ """Git utilities including proxy management.""" +from __future__ import annotations + +import os +import re import shutil import signal import subprocess import sys +import time +from urllib.parse import urlparse import click from mytoolkit.config import config +# Parallels Desktop Shared / Host-Only nets on macOS: host is typically *.*.*.2 +_PARALLELS_NET_RE = re.compile(r"inet (10\.(?:211\.55|37\.129)\.\d+)\b") + def _get_proxies() -> dict[str, str]: """Get git proxies from config. @@ -50,6 +59,109 @@ def _unset_git_proxy(key: str) -> None: ) +def _proxy_loopback_port(proxy_url: str) -> int | None: + """Return listen port if proxy_url is a loopback HTTP proxy.""" + try: + parsed = urlparse(proxy_url) + except ValueError: + return None + if parsed.scheme not in {"http", "https"}: + return None + host = (parsed.hostname or "").lower() + if host not in {"127.0.0.1", "localhost", "::1"}: + return None + if parsed.port: + return int(parsed.port) + return 443 if parsed.scheme == "https" else 80 + + +def _parallels_host_ips() -> list[str]: + """Mac IPs that Parallels guests use to reach this host (auto from ifconfig).""" + if sys.platform != "darwin": + return [] + override = os.environ.get("XIAOHE_CI_PARALLELS_HOST_IP", "").strip() + if override: + return [override] + try: + out = subprocess.check_output( + ["ifconfig"], text=True, stderr=subprocess.DEVNULL + ) + except (OSError, subprocess.CalledProcessError): + return [] + ips: list[str] = [] + for match in _PARALLELS_NET_RE.finditer(out): + ip = match.group(1) + if ip not in ips: + ips.append(ip) + return ips + + +def _port_listening_on(host: str, port: int) -> bool: + try: + out = subprocess.check_output( + ["lsof", f"-iTCP@{host}:{port}", "-sTCP:LISTEN", "-n", "-P"], + text=True, + stderr=subprocess.DEVNULL, + ) + return bool(out.strip()) + except (OSError, subprocess.CalledProcessError): + return False + + +def _start_parallels_forwards(port: int) -> list[subprocess.Popen]: + """Forward Parallels host IPs:port → 127.0.0.1:port via socat (Mac only).""" + ips = _parallels_host_ips() + if not ips: + return [] + socat = shutil.which("socat") + if not socat: + click.secho( + "WARN: socat missing — Parallels VMs cannot reach loopback proxy " + f"on :{port}. brew install socat", + fg="yellow", + err=True, + ) + return [] + procs: list[subprocess.Popen] = [] + for ip in ips: + if _port_listening_on(ip, port): + click.echo(f"Parallels forward already up: {ip}:{port}") + continue + proc = subprocess.Popen( + [ + socat, + f"TCP-LISTEN:{port},bind={ip},fork,reuseaddr", + f"TCP:127.0.0.1:{port}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # Brief wait so bind failures surface. + time.sleep(0.3) + if proc.poll() is not None: + click.secho( + f"WARN: socat failed for {ip}:{port} (exit {proc.returncode})", + fg="yellow", + err=True, + ) + continue + procs.append(proc) + click.secho(f"Parallels forward {ip}:{port} → 127.0.0.1:{port}", fg="green") + return procs + + +def _stop_procs(procs: list[subprocess.Popen]) -> None: + for proc in procs: + if proc.poll() is not None: + continue + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + @click.group(name="git") def git_cmd() -> None: """Git utilities.""" @@ -114,6 +226,12 @@ def proxy_status() -> None: click.echo(f" http.proxy: {http_proxy}") click.echo(f" https.proxy: {https_proxy}") + ips = _parallels_host_ips() + if ips: + click.secho("\nParallels host IPs (auto):", fg="cyan") + for ip in ips: + click.echo(f" {ip}") + click.secho("\nAvailable proxies:", fg="cyan") for proxy_name, url in proxies.items(): marker = "*" if proxy_name == current_name else " " @@ -137,6 +255,9 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: If no COMMAND is given, tries to run a program with the same name as the proxy. If that program is not found, sets the proxy and waits for Ctrl-C. + + For loopback proxies (e.g. fastgithub on 127.0.0.1), also opens socat + forwards on auto-detected Parallels host IPs so Windows/Linux VMs can connect. """ proxies = _get_proxies() if name not in proxies: @@ -157,13 +278,22 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: ) click.secho(f"Git proxy set to {name}: {proxy_url}", fg="green") + forward_procs: list[subprocess.Popen] = [] + port = _proxy_loopback_port(proxy_url) + if port is not None: + forward_procs = _start_parallels_forwards(port) + # Determine what to run + already_up = port is not None and _port_listening_on("127.0.0.1", port) cmd_to_run = list(command) if command else None if cmd_to_run is None: - if shutil.which(name): + if already_up: + click.secho(f"{name} already listening on :{port}", fg="cyan") + elif shutil.which(name): cmd_to_run = [name] def cleanup(): + _stop_procs(forward_procs) if old_http == "(not set)": _unset_git_proxy("http.proxy") else: @@ -204,7 +334,7 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None: sys.exit(0) else: click.secho( - f"Proxy active. Press Ctrl-C to stop and auto-unset proxy", fg="cyan" + "Proxy active. Press Ctrl-C to stop and auto-unset proxy", fg="cyan" ) try: signal.pause()