mirror of
https://github.com/ar0x4/azproxy/
synced 2026-06-28 15:09:58 +00:00
527 lines
19 KiB
Python
527 lines
19 KiB
Python
"""
|
|
Local proxy that routes traffic through rotating Azure Function endpoints.
|
|
|
|
Plain HTTP requests (absolute-URI) are rewritten and forwarded directly. HTTPS
|
|
arrives as CONNECT, which we can't rewrite inside the tunnel — so we terminate
|
|
TLS locally with a leaf cert signed by a local CA (~/.azprox/ca), read the
|
|
decrypted request, and forward it the same way. `exec_with_proxy` points the
|
|
child's CA-bundle env vars at that CA so the interception is transparent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import ssl
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from rich.console import Console
|
|
|
|
from azprox.proxy.interceptor import parse_proxy_request_line, rewrite_request
|
|
from azprox.proxy.rotator import EndpointRotator
|
|
|
|
console = Console()
|
|
err_console = Console(stderr=True)
|
|
logger = logging.getLogger("azprox.proxy")
|
|
|
|
CONFIG_DIR = Path.home() / ".azprox"
|
|
CA_DIR = CONFIG_DIR / "ca"
|
|
|
|
# Hop-by-hop headers (RFC 7230) plus framing headers we recompute.
|
|
_HOP_BY_HOP = {
|
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
"te", "trailers", "transfer-encoding", "upgrade",
|
|
"content-encoding", "content-length",
|
|
}
|
|
|
|
|
|
def _ci_get(headers: dict[str, str], name: str) -> Optional[str]:
|
|
name = name.lower()
|
|
for k, v in headers.items():
|
|
if k.lower() == name:
|
|
return v
|
|
return None
|
|
|
|
|
|
class CAStore:
|
|
"""Local CA + per-host leaf certs for HTTPS interception."""
|
|
|
|
def __init__(self, directory: Path = CA_DIR):
|
|
self.dir = directory
|
|
self.leaf_dir = directory / "leaf"
|
|
self.ca_cert_path = directory / "ca-cert.pem"
|
|
self.ca_key_path = directory / "ca-key.pem"
|
|
self._ca_cert = None
|
|
self._ca_key = None
|
|
self._contexts: dict[str, ssl.SSLContext] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def ensure_ca(self) -> None:
|
|
self.dir.mkdir(parents=True, exist_ok=True)
|
|
self.leaf_dir.mkdir(parents=True, exist_ok=True)
|
|
if self.ca_cert_path.exists() and self.ca_key_path.exists():
|
|
self._load_ca()
|
|
else:
|
|
self._generate_ca()
|
|
|
|
def _generate_ca(self) -> None:
|
|
import datetime as dt
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "AzProx Local CA")])
|
|
now = dt.datetime.now(dt.timezone.utc)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(name).issuer_name(name)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - dt.timedelta(days=1))
|
|
.not_valid_after(now + dt.timedelta(days=3650))
|
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
|
.add_extension(x509.KeyUsage(
|
|
digital_signature=True, key_cert_sign=True, crl_sign=True,
|
|
content_commitment=False, key_encipherment=False, data_encipherment=False,
|
|
key_agreement=False, encipher_only=False, decipher_only=False,
|
|
), critical=True)
|
|
.sign(key, hashes.SHA256())
|
|
)
|
|
self.ca_key_path.write_bytes(key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
))
|
|
self.ca_cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
self.ca_key_path.chmod(0o600)
|
|
self._ca_cert, self._ca_key = cert, key
|
|
|
|
def _load_ca(self) -> None:
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
self._ca_cert = x509.load_pem_x509_certificate(self.ca_cert_path.read_bytes())
|
|
self._ca_key = serialization.load_pem_private_key(self.ca_key_path.read_bytes(), password=None)
|
|
|
|
def context_for(self, host: str) -> ssl.SSLContext:
|
|
with self._lock:
|
|
if host not in self._contexts:
|
|
cert_path, key_path = self._leaf_for(host)
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path))
|
|
self._contexts[host] = ctx
|
|
return self._contexts[host]
|
|
|
|
def _leaf_for(self, host: str) -> tuple[Path, Path]:
|
|
import datetime as dt
|
|
import ipaddress
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
safe = host.replace(":", "_")
|
|
cert_path = self.leaf_dir / f"{safe}-cert.pem"
|
|
key_path = self.leaf_dir / f"{safe}-key.pem"
|
|
if cert_path.exists() and key_path.exists():
|
|
return cert_path, key_path
|
|
|
|
try:
|
|
san = x509.IPAddress(ipaddress.ip_address(host))
|
|
except ValueError:
|
|
san = x509.DNSName(host)
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
now = dt.datetime.now(dt.timezone.utc)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)]))
|
|
.issuer_name(self._ca_cert.subject)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - dt.timedelta(days=1))
|
|
.not_valid_after(now + dt.timedelta(days=825))
|
|
.add_extension(x509.SubjectAlternativeName([san]), critical=False)
|
|
.sign(self._ca_key, hashes.SHA256())
|
|
)
|
|
key_path.write_bytes(key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
))
|
|
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
return cert_path, key_path
|
|
|
|
|
|
class ProxyServer:
|
|
def __init__(
|
|
self,
|
|
rotator: EndpointRotator,
|
|
auth_key: str,
|
|
host: str = "127.0.0.1",
|
|
port: int = 8080,
|
|
verbose: bool = False,
|
|
ca: Optional[CAStore] = None,
|
|
):
|
|
self.rotator = rotator
|
|
self.auth_key = auth_key
|
|
self.host = host
|
|
self.port = port
|
|
self.verbose = verbose
|
|
self.ca = ca or CAStore()
|
|
self.actual_port = port
|
|
self._server: Optional[asyncio.Server] = None
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
self._stop_event: Optional[asyncio.Event] = None
|
|
|
|
@property
|
|
def proxy_url(self) -> str:
|
|
return f"http://{self.host}:{self.actual_port}"
|
|
|
|
async def start(self) -> None:
|
|
self.ca.ensure_ca()
|
|
self._client = httpx.AsyncClient(timeout=60.0, verify=True, follow_redirects=False)
|
|
self._stop_event = asyncio.Event()
|
|
self._server = await asyncio.start_server(self._handle_client, self.host, self.port)
|
|
self.actual_port = self._server.sockets[0].getsockname()[1]
|
|
|
|
async def serve_forever(self) -> None:
|
|
assert self._server is not None
|
|
async with self._server:
|
|
await self._server.serve_forever()
|
|
|
|
async def stop(self) -> None:
|
|
if self._server:
|
|
self._server.close()
|
|
try:
|
|
await self._server.wait_closed()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
if self._client:
|
|
await self._client.aclose()
|
|
|
|
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
try:
|
|
req = await self._read_request(reader)
|
|
if req is None:
|
|
return
|
|
method, target, _version, headers, body = req
|
|
|
|
if method.upper() == "CONNECT":
|
|
await self._handle_connect(target, reader, writer)
|
|
return
|
|
|
|
await self._handle_http_proxy(method, target, headers, body, writer)
|
|
while not writer.is_closing():
|
|
nxt = await self._read_request(reader)
|
|
if nxt is None:
|
|
break
|
|
m, t, _v, h, b = nxt
|
|
if m.upper() == "CONNECT":
|
|
break
|
|
await self._handle_http_proxy(m, t, h, b, writer)
|
|
except (ConnectionError, asyncio.IncompleteReadError):
|
|
pass
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("client handler error: %s", exc)
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
async def _read_request(self, reader: asyncio.StreamReader):
|
|
line = b""
|
|
while line in (b"", b"\r\n", b"\n"):
|
|
try:
|
|
line = await reader.readuntil(b"\r\n")
|
|
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ConnectionError):
|
|
return None
|
|
if not line:
|
|
return None
|
|
|
|
method, target, version = parse_proxy_request_line(line.decode("latin-1"))
|
|
headers = await self._read_headers(reader)
|
|
body = await self._read_body(reader, headers)
|
|
return method, target, version, headers, body
|
|
|
|
async def _read_headers(self, reader: asyncio.StreamReader) -> dict[str, str]:
|
|
headers: dict[str, str] = {}
|
|
while True:
|
|
line = await reader.readuntil(b"\r\n")
|
|
if line in (b"\r\n", b"\n", b""):
|
|
break
|
|
decoded = line.decode("latin-1").rstrip("\r\n")
|
|
if ":" in decoded:
|
|
k, _, v = decoded.partition(":")
|
|
headers[k.strip()] = v.strip()
|
|
return headers
|
|
|
|
async def _read_body(self, reader: asyncio.StreamReader, headers: dict[str, str]) -> bytes:
|
|
if "chunked" in (_ci_get(headers, "transfer-encoding") or "").lower():
|
|
return await self._read_chunked(reader)
|
|
cl = _ci_get(headers, "content-length")
|
|
if cl:
|
|
try:
|
|
n = int(cl)
|
|
except ValueError:
|
|
return b""
|
|
if n > 0:
|
|
return await reader.readexactly(n)
|
|
return b""
|
|
|
|
async def _read_chunked(self, reader: asyncio.StreamReader) -> bytes:
|
|
out = bytearray()
|
|
while True:
|
|
size_line = await reader.readuntil(b"\r\n")
|
|
try:
|
|
size = int(size_line.strip().split(b";")[0], 16)
|
|
except ValueError:
|
|
break
|
|
if size == 0:
|
|
await reader.readuntil(b"\r\n")
|
|
break
|
|
out += await reader.readexactly(size)
|
|
await reader.readexactly(2)
|
|
return bytes(out)
|
|
|
|
async def _handle_connect(self, authority: str, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
host, _, port_s = authority.partition(":")
|
|
port = int(port_s) if port_s else 443
|
|
|
|
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
await writer.drain()
|
|
|
|
try:
|
|
treader, twriter = await _upgrade_to_tls(reader, writer, self.ca.context_for(host))
|
|
except (ssl.SSLError, ConnectionError, asyncio.IncompleteReadError) as exc:
|
|
logger.warning("TLS upgrade failed for %s: %s", host, exc)
|
|
return
|
|
|
|
authority_host = host if port == 443 else f"{host}:{port}"
|
|
try:
|
|
while True:
|
|
req = await self._read_request(treader)
|
|
if req is None:
|
|
break
|
|
method, path, _v, headers, body = req
|
|
if method.upper() == "CONNECT":
|
|
break
|
|
await self._handle_http_proxy(method, f"https://{authority_host}{path}", headers, body, twriter)
|
|
if (_ci_get(headers, "connection") or "").lower() == "close":
|
|
break
|
|
except (ConnectionError, asyncio.IncompleteReadError):
|
|
pass
|
|
finally:
|
|
try:
|
|
twriter.close()
|
|
await twriter.wait_closed()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
async def _handle_http_proxy(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body: bytes,
|
|
writer: asyncio.StreamWriter,
|
|
) -> None:
|
|
assert self._client is not None
|
|
endpoint = await self.rotator.next()
|
|
rewritten = rewrite_request(method, url, headers, body, endpoint, self.auth_key)
|
|
|
|
try:
|
|
resp = await self._client.request(
|
|
rewritten.method, rewritten.url,
|
|
headers=rewritten.headers, content=rewritten.body or None,
|
|
)
|
|
self.rotator.report_success(endpoint)
|
|
except httpx.HTTPError as exc:
|
|
self.rotator.report_error(endpoint)
|
|
await self._write_error(writer, 502, f"Bad Gateway: {type(exc).__name__}")
|
|
return
|
|
|
|
if self.verbose:
|
|
err_console.print(f"[dim]{method} {url} via {endpoint.split('//')[1].split('.')[0]} → {resp.status_code}[/dim]")
|
|
await self._write_response(writer, resp)
|
|
|
|
async def _write_response(self, writer: asyncio.StreamWriter, resp: httpx.Response) -> None:
|
|
body = resp.content
|
|
out = bytearray(f"HTTP/1.1 {resp.status_code} {resp.reason_phrase or ''}\r\n".encode("latin-1"))
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in _HOP_BY_HOP:
|
|
out += f"{k}: {v}\r\n".encode("latin-1")
|
|
out += f"Content-Length: {len(body)}\r\n".encode("latin-1")
|
|
out += b"Connection: keep-alive\r\n\r\n" + body
|
|
writer.write(out)
|
|
await writer.drain()
|
|
|
|
async def _write_error(self, writer: asyncio.StreamWriter, code: int, message: str) -> None:
|
|
body = message.encode("latin-1", "replace")
|
|
out = (
|
|
f"HTTP/1.1 {code} {message}\r\nContent-Length: {len(body)}\r\nConnection: keep-alive\r\n\r\n"
|
|
).encode("latin-1") + body
|
|
try:
|
|
writer.write(out)
|
|
await writer.drain()
|
|
except (ConnectionError, RuntimeError):
|
|
pass
|
|
|
|
|
|
async def _upgrade_to_tls(
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
ctx: ssl.SSLContext,
|
|
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
loop = asyncio.get_event_loop()
|
|
transport = writer.transport
|
|
protocol = transport.get_protocol()
|
|
new_transport = await loop.start_tls(transport, protocol, ctx, server_side=True)
|
|
# Re-point the existing protocol/writer at the new TLS transport.
|
|
protocol._transport = new_transport # type: ignore[attr-defined]
|
|
writer._transport = new_transport # type: ignore[attr-defined]
|
|
return reader, writer
|
|
|
|
|
|
def run_proxy_server(
|
|
endpoints: list[str],
|
|
auth_key: str,
|
|
strategy: str = "round-robin",
|
|
host: str = "127.0.0.1",
|
|
port: int = 8080,
|
|
verbose: bool = False,
|
|
) -> None:
|
|
logging.getLogger("asyncio").setLevel(logging.ERROR)
|
|
ca = CAStore()
|
|
ca.ensure_ca()
|
|
rotator = EndpointRotator(endpoints, strategy=strategy, auth_key=auth_key)
|
|
server = ProxyServer(rotator, auth_key, host, port, verbose, ca)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
async def _run() -> None:
|
|
await server.start()
|
|
console.print("\n[green]AzProx proxy started[/green]")
|
|
console.print(f" HTTP proxy: {server.proxy_url}")
|
|
console.print(f" Endpoints: {len(endpoints)} active")
|
|
console.print(f" Strategy: {strategy}")
|
|
console.print(f" CA bundle: {ca.ca_cert_path}")
|
|
console.print("\n Usage:")
|
|
console.print(f" export HTTP_PROXY={server.proxy_url}")
|
|
console.print(f" export HTTPS_PROXY={server.proxy_url}")
|
|
console.print(f" export CURL_CA_BUNDLE={ca.ca_cert_path}")
|
|
console.print(" curl http://ifconfig.me\n")
|
|
console.print(" Press Ctrl+C to stop\n")
|
|
await server.serve_forever()
|
|
|
|
try:
|
|
loop.run_until_complete(_run())
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]Shutting down proxy...[/yellow]")
|
|
finally:
|
|
loop.run_until_complete(server.stop())
|
|
loop.close()
|
|
|
|
|
|
def start_proxy_background(
|
|
endpoints: list[str],
|
|
auth_key: str,
|
|
strategy: str = "round-robin",
|
|
host: str = "127.0.0.1",
|
|
port: int = 0,
|
|
verbose: bool = False,
|
|
ca: Optional[CAStore] = None,
|
|
) -> tuple[threading.Thread, "ProxyServer"]:
|
|
rotator = EndpointRotator(endpoints, strategy=strategy, auth_key=auth_key)
|
|
server = ProxyServer(rotator, auth_key, host, port, verbose, ca)
|
|
ready = threading.Event()
|
|
|
|
def _run() -> None:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
server._loop = loop # type: ignore[attr-defined]
|
|
|
|
async def _boot() -> None:
|
|
await server.start()
|
|
ready.set()
|
|
assert server._stop_event is not None
|
|
await server._stop_event.wait()
|
|
await server.stop()
|
|
|
|
try:
|
|
loop.run_until_complete(_boot())
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("proxy loop error: %s", exc)
|
|
ready.set()
|
|
finally:
|
|
loop.close()
|
|
|
|
thread = threading.Thread(target=_run, daemon=True, name="azprox-proxy")
|
|
thread.start()
|
|
if not ready.wait(timeout=15):
|
|
raise RuntimeError("Proxy server failed to start within 15s")
|
|
return thread, server
|
|
|
|
|
|
def stop_background(thread: threading.Thread, server: "ProxyServer") -> None:
|
|
loop = getattr(server, "_loop", None)
|
|
if loop and server._stop_event is not None:
|
|
loop.call_soon_threadsafe(server._stop_event.set)
|
|
thread.join(timeout=5)
|
|
|
|
|
|
def exec_with_proxy(
|
|
endpoints: list[str],
|
|
auth_key: str,
|
|
command: list[str],
|
|
strategy: str = "random",
|
|
verbose: bool = False,
|
|
) -> int:
|
|
# Default to random: most invocations issue one request, and round-robin
|
|
# would always start at index 0 (same endpoint every time).
|
|
logging.getLogger("asyncio").setLevel(logging.ERROR)
|
|
ca = CAStore()
|
|
ca.ensure_ca()
|
|
|
|
thread, server = start_proxy_background(endpoints, auth_key, strategy, verbose=verbose, ca=ca)
|
|
proxy = server.proxy_url
|
|
ca_path = str(ca.ca_cert_path)
|
|
|
|
env = os.environ.copy()
|
|
env.update({
|
|
"HTTP_PROXY": proxy, "HTTPS_PROXY": proxy,
|
|
"http_proxy": proxy, "https_proxy": proxy,
|
|
"ALL_PROXY": proxy, "all_proxy": proxy,
|
|
"NO_PROXY": "localhost,127.0.0.1,::1",
|
|
"no_proxy": "localhost,127.0.0.1,::1",
|
|
# Trust the local MITM CA so HTTPS interception is transparent.
|
|
"CURL_CA_BUNDLE": ca_path,
|
|
"REQUESTS_CA_BUNDLE": ca_path,
|
|
"SSL_CERT_FILE": ca_path,
|
|
"NODE_EXTRA_CA_CERTS": ca_path,
|
|
"GIT_SSL_CAINFO": ca_path,
|
|
})
|
|
|
|
err_console.print(f"[dim]azprox: proxying via {len(endpoints)} endpoint(s) on {proxy} ({strategy})[/dim]")
|
|
|
|
try:
|
|
return subprocess.run(command, env=env).returncode
|
|
except FileNotFoundError:
|
|
err_console.print(f"[red]command not found:[/red] {command[0]}")
|
|
return 127
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
finally:
|
|
stop_background(thread, server)
|