Skip to content

Proxy

The local proxy injects authentication so services do not each implement Google/IAP logic. It runs inside the supervisor.

There is no implicit listen port. proxy.listen.port is required when proxy.enabled is true — devctl config validate and any load fail with proxy.listen.port is required when proxy.enabled is true (exit 2). devctl setup writes 127.0.0.1:8080 as a starter; the demo uses 127.0.0.1:18080. Binding to 0.0.0.0 or :: is rejected.

devctl proxy start, TUI n on the proxy screen, and MCP start_proxy still refuse a 0 port even when the proxy is not enabled: same wording, CLI exit 7.

bash
devctl proxy start
devctl proxy status
devctl proxy stop

The TUI proxy tab (p) shows status, routes, and a live log of recent requests. n starts, x stops. SA email is shown when a route uses one.

Each request gets X-Devctl-Request-ID — propagated from the caller if it sent one, generated otherwise — and it's echoed back on the response so a caller can find its own request in the log below. Proxy logs never include Authorization headers. Bodies are streamed.

WebSocket upgrades use the same route matching, identity injection, middleware, request logging, and statistics as ordinary HTTP traffic (HMR and other upgraded connections behind a route). Active upgraded sockets are closed during proxy shutdown so devctl down cannot hang.

If proxy.enabled is true, devctl start also starts the proxy.

Routes

yaml
proxy:
  enabled: true
  listen:
    host: 127.0.0.1
    port: 8080
  routes:
    - name: invoices-api
      match:
        host: invoices-api.local
        path: ""
      upstream:
        url: http://127.0.0.1:18000
      auth:
        type: none          # none | iap | service_account
        identity: user      # or { type: service_account, service_account: email }
        # IAP only: audience is required. Optional client_id + client_secret
        # mint the ID token with that OAuth client instead of ADC's default.
        # client_secret may be a literal or ${NAME} / ${env.NAME}.

Match is host + optional path prefix.

Custom OAuth client credentials (separate from ADC)

A route can mint IAP tokens with a custom OAuth client via auth.client_id / auth.client_secret. By default the refresh token comes from gcloud ADC (~/.config/gcloud/application_default_credentials.json), which only works when ADC was itself logged in with that same client — otherwise Google rejects the mint as unauthorized_client ("client mismatch").

To use a custom client without clobbering ADC (which GCS/Firestore and other Google SDKs depend on), point the route — or the whole proxy — at a separate gcloud authorized_user credentials file:

yaml
proxy:
  credentials: ~/.devctl/iap-credentials.json   # default for every IAP route
  routes:
    - name: orchestrator-api
      auth:
        type: iap
        audience: 507686272917-0dpd...
        client_id: 507686272917-4j6f...
        # client_secret optional here — the file can supply it
        credentials: ~/.devctl/iap-credentials.json   # per-route override
        identity: { type: user }

The file is a standard gcloud authorized_user JSON (the same shape as ADC):

json
{ "type": "authorized_user", "client_id": "…", "client_secret": "…", "refresh_token": "…" }

Generate it with a scoped gcloud auth application-default login written to a custom path (not the default ADC location). Notes:

  • The file's refresh_token must have been issued by the same client_id as the route — a mismatched client_id in the file is rejected.
  • auth.credentials wins over proxy.credentials; the path may start with ~, be absolute, or be relative to the repository root.
  • The client_secret comes from the route when set, otherwise from the file.
  • ADC is never read for a file-backed route, so gcloud's default client stays usable for GCS/Firestore and everything else.
  • The file is read locally at mint time; its contents are never logged. It holds a long-lived refresh token and client secret — keep it private (chmod 600).

Extra token headers

Some IAP-protected upstreams want the minted token under an additional header, not just Authorization: Bearer …. auth.headers injects extra request headers on a token-minting route; ${token} in a value is replaced with the same token used for the bearer:

yaml
      auth:
        type: iap
        audience: 507686272917-0dpd...
        headers:
          identity-token: "${token}"      # same token, second header
          x-forwarded-client: gateway     # a plain literal is passed through

Applied only on iap / service_account routes (there is no token on a none route). This lets the proxy fully satisfy an upstream's auth expectations without changing the upstream or the calling service.

Response headers and CORS

route.response_headers adds headers to every response on the route, overriding whatever the upstream sent — most often CORS headers for a browser that loads a micro-frontend, Module Federation remote, or iframe from another origin and then calls back through the proxy:

yaml
      response_headers:
        Access-Control-Allow-Origin: "*"
        Access-Control-Allow-Methods: "GET, POST, PUT, DELETE, OPTIONS"
        Access-Control-Allow-Headers: "Authorization, Content-Type, X-Devctl-Request-ID"

A CORS preflight (an OPTIONS carrying Access-Control-Request-Method) is answered directly with these headers and a 204 — it is not forwarded, since the upstream may not handle OPTIONS and IAP would reject an unauthenticated preflight. Any other OPTIONS is proxied normally, and the headers are still applied. This makes the proxy the single entry point for both CORS and auth, instead of a separate CORS sidecar that can't inject IAP tokens.

Per-service routes

Optional proxy on a service is one route fragment or a list. At load they append to the same global proxy.routes list with stable names (<service> or <service>-<n>). Duplicate names fail validation. Runtime stays one listener.

yaml
services:
  api:
    command: python main.py
    proxy:
      - match:
          path: /api
        upstream:
          url: http://127.0.0.1:8000

Expose — the proxy as a stable entry point

Instead of hand-writing a route, a service can be exposed through the proxy at a stable, logical address. The synthesized route addresses its target by service name, so the proxy resolves the service's current port at request time — a service that restarts on a different auto-assigned port is followed with no proxy reload and no consumer change.

yaml
proxy:
  enabled: true              # expose requires an enabled proxy
  listen: { host: 127.0.0.1, port: 8080 }
services:
  invoices-api:
    command: python main.py
    ports: { http: 18000 }
    expose: true             # → route "invoices-api", match host invoices-api.local

expose: true matches host <service>.local and forwards to the service's http port. Exposure is host-based: the request path is forwarded verbatim, so a path prefix belongs on a hand-written route, not here. Use the object form to override the host or port:

yaml
    expose:
      host: api.internal     # default: <service>.local
      port: grpc             # named port to forward to (default: http)

Set proxy.gateway: true to expose every HTTP-capable service (one with a port named http) at once — sugar over per-service expose. For selective exposure, leave gateway off and mark services individually.

A hand-written route or proxy: fragment of the same name always wins over a synthesized one, so you can override any auto route (for example to attach auth).

Auth is always none on synthesized routes. An internal service-to-service hop never silently acquires a service's identity token — injecting credentials stays an explicit choice you make with a hand-written route.

Referencing an exposed service — ${services.<name>.url}

${services.<name>.url} and ${services.<name>.host} give a service a stable logical address in another service's environment:

yaml
services:
  billing-console:
    environment:
      API_URL: ${services.invoices-api.url}
  • Direct (target not exposed): resolves to http://127.0.0.1:<port> — a startup snapshot, like ${services.<name>.port}.
  • Hub (target exposed and proxy enabled): resolves to the proxy entry address, e.g. http://invoices-api.local:8080. This is stable — the consumer keeps working even when the target moves to a new port.

Host-based addressing is for host clients: <service>.local must resolve to 127.0.0.1 on the machine that makes the request — add it to /etc/hosts or your resolver. A container's loopback is isolated from the host proxy, and the container schema has no host-network or extra-hosts mode, so do not point a container at <service>.local.

gRPC routes (Temporal, and other h2 clients behind IAP)

A grpc route is a dedicated loopback HTTP/2 (h2c) listener that forwards every gRPC stream to one upstream over HTTP/2 + TLS, injecting the route's IAP token per RPC. It's for clients that speak gRPC and can't go through the HTTP proxy — a Temporal worker, say — so they stay entirely token-free.

yaml
proxy:
  enabled: true
  listen: { host: 127.0.0.1, port: 8080 }     # the HTTP proxy (still required when enabled)
  routes:
    - name: temporal
      transport: grpc
      listen: { host: 127.0.0.1, port: 7233 }  # the local address the client dials
      upstream: { url: "https://temporal.internal.example.com:443" }
      auth:
        type: iap
        audience: 507686272917-0dpd...
        client_id: 507686272917-4j6f...
        credentials: ~/.devctl/iap-credentials.json

The client connects plaintext to the local port and does nothing else — no token, no refresh:

python
client = await Client.connect("127.0.0.1:7233", namespace="prod", tls=False)

devctl adds Authorization: Bearer <fresh id-token> (plus any auth.headers) to each RPC's HTTP/2 headers, mints and refreshes it with the same machinery as HTTP routes (audience / client_id / credentials), and relays the response and gRPC trailers. Because every RPC carries the current token, expiry is handled with no timer in the app.

Notes:

  • Each grpc route needs its own loopback listen.port, distinct from the HTTP proxy and every other grpc route, and an https:// upstream (the IAP leg is TLS).
  • Injection only happens on an iap / service_account route; a none grpc route is a plain forwarder.
  • This targets a self-hosted Temporal behind a GCP IAP HTTPS load balancer. Temporal Cloud (mTLS + API key) is not covered by this route type.

Token endpoint

Optional GET /token (proxy.token_endpoint) binds to loopback (never 0.0.0.0 or ::), requires X-Devctl-Internal-Token, and only accepts loopback peers.

json
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_at": "2026-08-30T00:05:00.000Z",
  "identity": "user"
}

Managed processes receive DEVCTL_TOKEN_URL (rewritten to the bound port after listen) and DEVCTL_INTERNAL_TOKEN, not raw tokens in the environment.

Live request log

The proxy keeps the last 100 requests in memory — method, path, matched route (blank for a request that matched no route, still logged as a 404), identity key used, status, duration, and request id — and reports a running total/error count alongside them. This is part of the regular status snapshot, so it updates the same way everything else in the TUI does: the moment a request refreshes a token or hits a route, the proxy tab reflects it without pressing r or restarting anything.

Paths are redacted the same way response header values already are, since a query string can carry secrets. Nothing here is persisted — it's an in-memory ring buffer, reset on daemon restart.

Tracing

Each proxied request (HTTP and gRPC) is also recorded as an OpenTelemetry span — method, route, status, duration, identity — and the proxy propagates a traceparent and X-Devctl-Request-ID to the upstream, so a service's own spans and logs share the request's trace. An incoming traceparent is honored; a bare request-id header is not adopted as the trace id. Open the trace from a log row in the TUI, devctl logs --trace <id>, or the MCP get_trace / trace_request tools. See Telemetry.

Request flow

A missing identity.type on an IAP route is a configuration error.

Released under the MIT License.