|
| 1 | +import asyncio |
| 2 | +import json |
| 3 | +import sys |
| 4 | + |
| 5 | +import rich_click as click |
| 6 | + |
| 7 | +import flyte.remote as remote |
| 8 | + |
| 9 | +from . import _common as common |
| 10 | + |
| 11 | +# Headers that must not be forwarded verbatim across a proxy hop. |
| 12 | +_HOP_BY_HOP = { |
| 13 | + "connection", |
| 14 | + "keep-alive", |
| 15 | + "proxy-authenticate", |
| 16 | + "proxy-authorization", |
| 17 | + "te", |
| 18 | + "trailer", |
| 19 | + "transfer-encoding", |
| 20 | + "upgrade", |
| 21 | + "host", |
| 22 | + "content-length", |
| 23 | +} |
| 24 | + |
| 25 | + |
| 26 | +@click.group(name="proxy") |
| 27 | +def proxy(): |
| 28 | + """Proxy a local port into a Flyte App through the authenticated app edge.""" |
| 29 | + |
| 30 | + |
| 31 | +@proxy.command(cls=common.CommandBase) |
| 32 | +@click.argument("name", type=str, required=False) |
| 33 | +@click.option("--url", "url", type=str, default=None, help="Proxy this app URL directly; skip name resolution.") |
| 34 | +@click.option("--port", type=int, default=8600, help="Local port to listen on (0 = pick a free port).") |
| 35 | +@click.option("--address", type=str, default="127.0.0.1", help="Local bind address; non-loopback triggers a warning.") |
| 36 | +@click.option( |
| 37 | + "--emit-mcp-config", is_flag=True, default=False, help="Print a generic HTTP-MCP config block for the local endpoint." |
| 38 | +) |
| 39 | +@click.option("-v", "--verbose", is_flag=True, default=False, help="Log each proxied request (never the token).") |
| 40 | +@click.pass_obj |
| 41 | +def app( |
| 42 | + cfg: common.CLIConfig, |
| 43 | + name: str | None = None, |
| 44 | + project: str | None = None, |
| 45 | + domain: str | None = None, |
| 46 | + url: str | None = None, |
| 47 | + port: int = 8600, |
| 48 | + address: str = "127.0.0.1", |
| 49 | + emit_mcp_config: bool = False, |
| 50 | + verbose: bool = False, |
| 51 | +): |
| 52 | + """ |
| 53 | + Open an authenticated localhost proxy into a no-auth Flyte App. |
| 54 | +
|
| 55 | + Reuses the same Union auth (with auto-refresh) the CLI uses, injecting a fresh bearer on every |
| 56 | + request, so a local HTTP client — a Grafana/Prometheus MCP, curl, a browser — reaches an |
| 57 | + edge-gated app with no token handling. Think: kubectl port-forward for Flyte Apps. |
| 58 | +
|
| 59 | + Foreground; Ctrl-C to stop. |
| 60 | + """ |
| 61 | + cfg.init(project=project, domain=domain) |
| 62 | + |
| 63 | + if url: |
| 64 | + target = url |
| 65 | + elif name: |
| 66 | + target = remote.App.get(name=name).endpoint |
| 67 | + else: |
| 68 | + raise click.UsageError("Provide an app NAME or --url.") |
| 69 | + target = target.rstrip("/") |
| 70 | + |
| 71 | + label = name or target |
| 72 | + try: |
| 73 | + asyncio.run(_serve(cfg, target, label, address, port, emit_mcp_config, verbose)) |
| 74 | + except KeyboardInterrupt: |
| 75 | + pass |
| 76 | + |
| 77 | + |
| 78 | +def _build_authenticator(cfg: common.CLIConfig): |
| 79 | + from flyte.remote._client.auth._authenticators.factory import get_async_authenticator |
| 80 | + from flyte.remote._client.auth._client_config import RemoteClientConfigStore |
| 81 | + from flyte.remote._client.auth._session import normalize_rpc_endpoint |
| 82 | + |
| 83 | + plat = cfg.config.platform |
| 84 | + insecure = getattr(plat, "insecure", False) |
| 85 | + # Config endpoint is gRPC-style (bare host); the OIDC-metadata client needs an http(s) base. |
| 86 | + endpoint = normalize_rpc_endpoint(plat.endpoint, insecure=insecure) |
| 87 | + auth_type = getattr(plat, "auth_mode", None) or "Pkce" |
| 88 | + return get_async_authenticator( |
| 89 | + endpoint=endpoint, |
| 90 | + cfg_store=RemoteClientConfigStore(endpoint), |
| 91 | + auth_type=auth_type, |
| 92 | + insecure_skip_verify=getattr(plat, "insecure_skip_verify", False), |
| 93 | + ca_cert_file_path=getattr(plat, "ca_cert_file_path", None), |
| 94 | + ) |
| 95 | + |
| 96 | + |
| 97 | +def _filter_request_headers(headers) -> dict: |
| 98 | + return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP and k.lower() != "authorization"} |
| 99 | + |
| 100 | + |
| 101 | +def _filter_response_headers(headers) -> dict: |
| 102 | + return {k: v for k, v in headers.items() if k.lower() not in _HOP_BY_HOP} |
| 103 | + |
| 104 | + |
| 105 | +async def _serve(cfg, target, label, address, port, emit_mcp_config, verbose): |
| 106 | + from aiohttp import ClientSession, ClientTimeout, web |
| 107 | + |
| 108 | + authenticator = _build_authenticator(cfg) |
| 109 | + # Prime credentials (load from keyring / run the auth flow) before we start serving. |
| 110 | + if authenticator.get_credentials() is None: |
| 111 | + await authenticator.refresh_credentials() |
| 112 | + |
| 113 | + # auto_decompress=False -> stream upstream bytes verbatim (Content-Encoding preserved). |
| 114 | + # total=None -> allow long-lived SSE streams (MCP streamable-HTTP, Grafana Live). |
| 115 | + upstream = ClientSession(timeout=ClientTimeout(total=None), auto_decompress=False) |
| 116 | + |
| 117 | + async def handle(request: "web.Request"): |
| 118 | + body = await request.read() |
| 119 | + fwd = _filter_request_headers(request.headers) |
| 120 | + up_url = target + request.raw_path |
| 121 | + |
| 122 | + async def send(refresh: bool): |
| 123 | + if refresh: |
| 124 | + await authenticator.refresh_credentials() |
| 125 | + ah = await authenticator.get_auth_headers() |
| 126 | + hdrs = dict(fwd) |
| 127 | + if ah: |
| 128 | + hdrs.update(ah.headers) |
| 129 | + return await upstream.request( |
| 130 | + request.method, up_url, headers=hdrs, data=body or None, allow_redirects=False |
| 131 | + ) |
| 132 | + |
| 133 | + resp = await send(refresh=False) |
| 134 | + # Stale token: the edge 401s or bounces to /login. Refresh once and retry. |
| 135 | + bounced = resp.status in (302, 307) and "/login" in resp.headers.get("Location", "") |
| 136 | + if resp.status in (401, 403) or bounced: |
| 137 | + resp.release() |
| 138 | + resp = await send(refresh=True) |
| 139 | + |
| 140 | + out = web.StreamResponse(status=resp.status, headers=_filter_response_headers(resp.headers)) |
| 141 | + await out.prepare(request) |
| 142 | + async for chunk in resp.content.iter_any(): |
| 143 | + await out.write(chunk) |
| 144 | + await out.write_eof() |
| 145 | + resp.release() |
| 146 | + if verbose: |
| 147 | + click.echo(f"{request.method} {request.path} -> {resp.status}", err=True) |
| 148 | + return out |
| 149 | + |
| 150 | + server = web.Application() |
| 151 | + server.router.add_route("*", "/{tail:.*}", handle) |
| 152 | + runner = web.AppRunner(server) |
| 153 | + await runner.setup() |
| 154 | + site = web.TCPSite(runner, address, port) |
| 155 | + await site.start() |
| 156 | + |
| 157 | + actual = port |
| 158 | + if port == 0: |
| 159 | + actual = runner.addresses[0][1] |
| 160 | + local = f"http://{address}:{actual}" |
| 161 | + |
| 162 | + if address not in ("127.0.0.1", "localhost", "::1"): |
| 163 | + click.secho( |
| 164 | + f"WARNING: binding {address} exposes your Union identity to anything that can reach it; prefer 127.0.0.1.", |
| 165 | + fg="yellow", |
| 166 | + err=True, |
| 167 | + ) |
| 168 | + identity = _identity(authenticator) |
| 169 | + click.echo(f"Proxying {target} -> {local}", err=True) |
| 170 | + click.echo(f" authenticating as: {identity}", err=True) |
| 171 | + click.echo(" Ctrl-C to stop.", err=True) |
| 172 | + if emit_mcp_config: |
| 173 | + _emit_mcp_config(label, local) |
| 174 | + |
| 175 | + try: |
| 176 | + while True: |
| 177 | + await asyncio.sleep(3600) |
| 178 | + finally: |
| 179 | + await upstream.close() |
| 180 | + await runner.cleanup() |
| 181 | + |
| 182 | + |
| 183 | +def _identity(authenticator) -> str: |
| 184 | + creds = authenticator.get_credentials() |
| 185 | + if not creds or not creds.access_token: |
| 186 | + return "<unknown>" |
| 187 | + # Best-effort: decode the JWT payload's sub/email without verifying (display only). |
| 188 | + try: |
| 189 | + import base64 |
| 190 | + |
| 191 | + payload = creds.access_token.split(".")[1] |
| 192 | + payload += "=" * (-len(payload) % 4) |
| 193 | + claims = json.loads(base64.urlsafe_b64decode(payload)) |
| 194 | + return claims.get("email") or claims.get("sub") or "<token>" |
| 195 | + except Exception: |
| 196 | + return "<token>" |
| 197 | + |
| 198 | + |
| 199 | +def _emit_mcp_config(name: str, local: str): |
| 200 | + block = {"mcpServers": {name: {"type": "http", "url": local}}} |
| 201 | + click.echo("", err=True) |
| 202 | + click.echo("# MCP client config (generic HTTP transport) — point your client at the local proxy:", err=True) |
| 203 | + sys.stdout.write(json.dumps(block, indent=2) + "\n") |
| 204 | + sys.stdout.flush() |
0 commit comments