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.""" """Git utilities including proxy management."""
from __future__ import annotations
import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
import time
from urllib.parse import urlparse
import click import click
from mytoolkit.config import config 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]: def _get_proxies() -> dict[str, str]:
"""Get git proxies from config. """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") @click.group(name="git")
def git_cmd() -> None: def git_cmd() -> None:
"""Git utilities.""" """Git utilities."""
@@ -114,6 +226,12 @@ def proxy_status() -> None:
click.echo(f" http.proxy: {http_proxy}") click.echo(f" http.proxy: {http_proxy}")
click.echo(f" https.proxy: {https_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") click.secho("\nAvailable proxies:", fg="cyan")
for proxy_name, url in proxies.items(): for proxy_name, url in proxies.items():
marker = "*" if proxy_name == current_name else " " 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 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. 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() proxies = _get_proxies()
if name not in 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") 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 # 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 cmd_to_run = list(command) if command else None
if cmd_to_run is 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] cmd_to_run = [name]
def cleanup(): def cleanup():
_stop_procs(forward_procs)
if old_http == "(not set)": if old_http == "(not set)":
_unset_git_proxy("http.proxy") _unset_git_proxy("http.proxy")
else: else:
@@ -204,7 +334,7 @@ def proxy_run(name: str, command: tuple[str, ...]) -> None:
sys.exit(0) sys.exit(0)
else: else:
click.secho( 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: try:
signal.pause() signal.pause()