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.
This commit is contained in:
Zhengshou Lai
2026-08-11 10:29:33 +08:00
parent e772c5b896
commit 5b0739a397
+132 -2
View File
@@ -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()