diff --git a/.env.example b/.env.example index 53b35e4..565ef02 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,12 @@ VITE_OBP_API_HOST=http://127.0.0.1:8080 VITE_OBP_API_VERSION=v5.1.0 +### OBP gRPC endpoint (used by the gRPC services browser) ### +### Defaults to grpc. if not set — port 443 with TLS for an https +### base URL, port 50051 without TLS for http (no grpc. prefix for localhost/IPs). +### VITE_OBP_GRPC_TLS=true|false overrides the port-based TLS default. +# VITE_OBP_GRPC_HOST=localhost:50051 + ### API Explorer Host ### VITE_OBP_API_EXPLORER_HOST=http://localhost:5173 @@ -39,9 +45,6 @@ VITE_OBP_LOGOUT_MODE=public VITE_OBP_OIDC_CLIENT_ID=your-obp-oidc-client-id VITE_OBP_OIDC_CLIENT_SECRET=your-obp-oidc-client-secret -### OBP Consumer Key (for API calls) ### -VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id - ### Keycloak Provider (Optional) ### # VITE_KEYCLOAK_CLIENT_ID=your-keycloak-client-id # VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret @@ -84,3 +87,7 @@ VITE_CHATBOT_ENABLED=false ### Resource Docs Version ### VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv7.0.0 + +### /status page: when set, the consumer_id shown under each OAuth2 provider links to the +### consumer's page on the API Manager, e.g. VITE_API_MANAGER_URL=http://localhost:3003 +# VITE_API_MANAGER_URL= diff --git a/README.md b/README.md index d338906..923fba9 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,20 @@ server { } ``` +## gRPC connection (gRPC services browser) + +The gRPC services page connects to OBP-API over gRPC: + +- `VITE_OBP_GRPC_HOST` — explicit gRPC target as `host:port`; always wins when set. + When unset, the target is derived from `VITE_OBP_API_HOST`: + - `https://api.example.com` → `grpc.api.example.com:443` (TLS ingress convention) + - `http://api.example.com` → `grpc.api.example.com:50051` + - `http://localhost:8080` or an IP → `localhost:50051` / `:50051` (no `grpc.` prefix) +- `VITE_OBP_GRPC_TLS` — `true` or `false`; forces TLS channel credentials on or off. + When unset, TLS is inferred from the port of the resolved host: `:443` → TLS on, + any other port → TLS off. Set this only for setups where the port is not a + reliable signal (e.g. TLS on a non-443 port). + Note: if you have issues with session stickyness / login issues, enable #DEBUG=express-session in your .env and if you see messages like these in the log, diff --git a/server/routes/grpc.ts b/server/routes/grpc.ts index c5b2043..9994210 100644 --- a/server/routes/grpc.ts +++ b/server/routes/grpc.ts @@ -29,10 +29,13 @@ import { Router } from 'express' import type { Request, Response } from 'express' import { credentials } from '@grpc/grpc-js' import { Client as ReflectionClient } from 'grpc-reflection-js' +import { resolveGrpcTarget } from '../utils/grpcHost.js' const router = Router() -const GRPC_HOST = process.env.VITE_OBP_GRPC_HOST || 'localhost:50051' +const GRPC_TARGET = resolveGrpcTarget(process.env) +const GRPC_HOST = GRPC_TARGET.host +const GRPC_CREDENTIALS = GRPC_TARGET.tls ? credentials.createSsl() : credentials.createInsecure() const REFLECTION_SERVICE_NAMES = new Set([ 'grpc.reflection.v1.ServerReflection', @@ -80,7 +83,7 @@ router.get('/grpc/services', async (_req: Request, res: Response) => { let client: ReflectionClient | null = null try { console.log(`gRPC: Reflecting against ${GRPC_HOST}`) - client = new ReflectionClient(GRPC_HOST, credentials.createInsecure()) + client = new ReflectionClient(GRPC_HOST, GRPC_CREDENTIALS) const serviceNames = await client.listServices() const services: ServiceInfo[] = [] diff --git a/server/routes/status.ts b/server/routes/status.ts index 9ed4739..bc55e35 100644 --- a/server/routes/status.ts +++ b/server/routes/status.ts @@ -37,7 +37,8 @@ import { RESOURCE_DOCS_API_VERSION, MESSAGE_DOCS_API_VERSION, API_VERSIONS_LIST_API_VERSION, - V5_1_0 + V5_1_0, + SSE_PROBE_SPACING_MS } from '../../src/shared-constants.js' const router = Router() @@ -160,6 +161,26 @@ router.get('/health', (req: Request, res: Response) => { }) }) +/** + * GET /status/stream + * SSE transport probe for the status page: emits two spaced events so the + * browser can tell real streaming from a proxy-buffered response. Uses the + * same headers as the real Opey SSE stream and no proxy opt-outs + * (e.g. X-Accel-Buffering), so it experiences the same proxy behavior. + * Carries no data, so no auth. + */ +router.get('/status/stream', (req: Request, res: Response) => { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.write(':ok\n\ndata: {"seq":1}\n\n') + const timer = setTimeout(() => { + res.write('data: {"seq":2}\n\n') + res.end() + }, SSE_PROBE_SPACING_MS) + req.on('close', () => clearTimeout(timer)) +}) + /** * GET /status * Get application status and health checks @@ -261,7 +282,6 @@ router.get('/status/providers', (req: Request, res: Response) => { // Get env configuration (masked) const envConfig = { obpOidc: { - consumerId: process.env.VITE_OBP_CONSUMER_KEY || 'not configured', clientId: maskCredential(process.env.VITE_OBP_OIDC_CLIENT_ID) }, keycloak: { diff --git a/server/services/OIDCServiceHealth.ts b/server/services/OIDCServiceHealth.ts index b733974..20e0da0 100644 --- a/server/services/OIDCServiceHealth.ts +++ b/server/services/OIDCServiceHealth.ts @@ -27,6 +27,7 @@ import { Container } from 'typedi' import { OAuth2ProviderManager } from './OAuth2ProviderManager.js' +import OBPClientService from './OBPClientService.js' /** * Deep per-provider OIDC health checks for the /status page. @@ -48,6 +49,18 @@ interface TokenTestOutcome { message: string responseTimeMs: number ranAt: number + /** The issued token, kept server-side so the consumer identity can be read with it. */ + accessToken?: string + /** Which OBP Consumer the client is, read once per token from GET /obp/v7.0.0/consumers/current/identity. */ + consumer?: ConsumerIdentity +} + +/** The calling Consumer as OBP reports it: id and name only. */ +interface ConsumerIdentity { + consumer_id?: string + consumer_name?: string + /** Set when the OBP-API has no identity endpoint yet, or refused the token. */ + note?: string } const FETCH_TIMEOUT_MS = 5000 @@ -92,6 +105,39 @@ async function fetchJson(url: string): Promise { } } +/** + * Which OBP Consumer a token belongs to. GET /obp/v7.0.0/consumers/current/identity needs no + * role and returns only consumer_id and consumer_name. Answers are attached to the cached + * token test, so this runs at most once per token. + */ +async function readConsumerIdentity(accessToken: string): Promise { + const baseUri = Container.get(OBPClientService).getOBPClientConfig().baseUri.replace(/\/$/, '') + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS) + try { + const response = await fetch(`${baseUri}/obp/v7.0.0/consumers/current/identity`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, + signal: controller.signal + }) + const body = (await response.json().catch(() => ({}))) as { + consumer_id?: string + consumer_name?: string + message?: string + } + if (response.ok && body.consumer_id) { + return { consumer_id: body.consumer_id, consumer_name: body.consumer_name ?? '' } + } + if (response.status === 404) { + return { note: 'not available: this OBP-API has no GET /obp/v7.0.0/consumers/current/identity' } + } + return { note: `OBP did not identify the application (${response.status}): ${body.message ?? response.statusText}` } + } catch (err) { + return { note: err instanceof Error ? err.message : String(err) } + } finally { + clearTimeout(timeoutId) + } +} + async function runTokenTest( provider: string, tokenEndpoint: string, @@ -125,7 +171,14 @@ async function runTokenTest( const responseTimeMs = Math.round(performance.now() - start) if (response.ok) { - outcome = { ok: true, message: 'token issued', responseTimeMs, ranAt: Date.now() } + let accessToken: string | undefined + try { + accessToken = ((await response.json()) as { access_token?: string }).access_token + } catch { + // Token body not JSON: the test still passed, only the identity lookup is skipped + } + outcome = { ok: true, message: 'token issued', responseTimeMs, ranAt: Date.now(), accessToken } + outcome.consumer = accessToken ? await readConsumerIdentity(accessToken) : undefined } else { let message = `${response.status} ${response.statusText}` try { @@ -215,6 +268,16 @@ async function checkProvider( const outcome = await runTokenTest(name, discovery.token_endpoint, clientId, clientSecret) details.token_test = outcome.ok ? 'ok' : 'failed' details.token_test_ms = outcome.responseTimeMs + if (outcome.consumer?.consumer_id) { + details.consumer_id = outcome.consumer.consumer_id + details.consumer_name = outcome.consumer.consumer_name ?? '' + const managerUrl = process.env.VITE_API_MANAGER_URL?.replace(/\/$/, '') + if (managerUrl) { + details.consumer_id_url = `${managerUrl}/consumers/${encodeURIComponent(outcome.consumer.consumer_id)}` + } + } else if (outcome.consumer?.note) { + details.consumer = outcome.consumer.note + } if (!outcome.ok) { // Non-strict: surfaced in details but does not flip the provider // unhealthy — the client may be authorization_code-only. diff --git a/server/test/grpcHost.test.ts b/server/test/grpcHost.test.ts new file mode 100644 index 0000000..502b2b4 --- /dev/null +++ b/server/test/grpcHost.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { resolveGrpcTarget, defaultGrpcHost } from '../utils/grpcHost' + +describe('resolveGrpcTarget', () => { + it('prefers VITE_OBP_GRPC_HOST when set', () => { + expect( + resolveGrpcTarget({ + VITE_OBP_GRPC_HOST: 'grpc.example.com:9999', + VITE_OBP_API_HOST: 'https://api.example.com' + }) + ).toEqual({ host: 'grpc.example.com:9999', tls: false }) + }) + + it('derives grpc.:443 with TLS from an https VITE_OBP_API_HOST', () => { + expect(resolveGrpcTarget({ VITE_OBP_API_HOST: 'https://api.example.com' })).toEqual({ + host: 'grpc.api.example.com:443', + tls: true + }) + }) + + it('derives grpc.:50051 without TLS from an http VITE_OBP_API_HOST', () => { + expect(resolveGrpcTarget({ VITE_OBP_API_HOST: 'http://obp.internal:8080' })).toEqual({ + host: 'grpc.obp.internal:50051', + tls: false + }) + }) + + it('turns on TLS for an explicit host on port 443', () => { + expect(resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:443' }).tls).toBe(true) + }) + + it('lets VITE_OBP_GRPC_TLS override the port-based default in both directions', () => { + expect( + resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:443', VITE_OBP_GRPC_TLS: 'false' }) + .tls + ).toBe(false) + expect( + resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:50051', VITE_OBP_GRPC_TLS: 'true' }) + .tls + ).toBe(true) + }) + + it('falls back to localhost:50051 without TLS when nothing is set', () => { + expect(resolveGrpcTarget({})).toEqual({ host: 'localhost:50051', tls: false }) + }) +}) + +describe('defaultGrpcHost', () => { + it('uses port 443 for https base URLs and 50051 for http ones', () => { + expect(defaultGrpcHost('https://api.example.com')).toBe('grpc.api.example.com:443') + expect(defaultGrpcHost('http://obp.internal:8080')).toBe('grpc.obp.internal:50051') + }) + + it('does not prefix grpc. onto localhost or IP literals', () => { + expect(defaultGrpcHost('http://localhost:8080')).toBe('localhost:50051') + expect(defaultGrpcHost('http://obp.localhost:8080')).toBe('obp.localhost:50051') + expect(defaultGrpcHost('http://127.0.0.1:8080')).toBe('127.0.0.1:50051') + expect(defaultGrpcHost('http://[::1]:8080')).toBe('[::1]:50051') + }) + + it('falls back to localhost when the base URL is unset or unparseable', () => { + expect(defaultGrpcHost(undefined)).toBe('localhost:50051') + expect(defaultGrpcHost('')).toBe('localhost:50051') + expect(defaultGrpcHost('not a url')).toBe('localhost:50051') + }) +}) diff --git a/server/utils/grpcHost.ts b/server/utils/grpcHost.ts new file mode 100644 index 0000000..f43fa88 --- /dev/null +++ b/server/utils/grpcHost.ts @@ -0,0 +1,56 @@ +// OBP-API deployments conventionally expose gRPC on a `grpc.` subdomain of the +// REST host. Behind a public (https) deployment that subdomain serves gRPC +// through the ingress on port 443 with TLS — a raw high port is typically not +// reachable there — so when VITE_OBP_GRPC_HOST is unset the default is +// grpc.:443 with TLS for https deployments, and +// port 50051 without TLS for http (dev) ones. localhost and IP literals get no +// `grpc.` prefix (there is no subdomain to resolve there). + +export const DEFAULT_GRPC_PORT = 50051 +export const DEFAULT_GRPC_TLS_PORT = 443 + +export interface GrpcTarget { + /** gRPC target as host:port (no scheme). */ + host: string + /** Whether to dial with TLS channel credentials. */ + tls: boolean +} + +/** + * The gRPC target to connect to, resolved from an env-like record + * (pass `process.env`): VITE_OBP_GRPC_HOST when set, otherwise derived from + * VITE_OBP_API_HOST. TLS follows VITE_OBP_GRPC_TLS ("true"/"false") when set, + * otherwise the port: 443 means TLS. + */ +export function resolveGrpcTarget(env: Record): GrpcTarget { + const host = env.VITE_OBP_GRPC_HOST || defaultGrpcHost(env.VITE_OBP_API_HOST) + const tls = + env.VITE_OBP_GRPC_TLS !== undefined + ? env.VITE_OBP_GRPC_TLS === 'true' + : host.endsWith(`:${DEFAULT_GRPC_TLS_PORT}`) + return { host, tls } +} + +export function defaultGrpcHost(obpApiHost: string | undefined | null): string { + if (obpApiHost) { + try { + const url = new URL(obpApiHost) + if (grpcSubdomainApplies(url.hostname)) { + const port = url.protocol === 'https:' ? DEFAULT_GRPC_TLS_PORT : DEFAULT_GRPC_PORT + return `grpc.${url.hostname}:${port}` + } + return `${url.hostname}:${DEFAULT_GRPC_PORT}` + } catch { + // unparseable base URL — fall through to localhost + } + } + return `localhost:${DEFAULT_GRPC_PORT}` +} + +function grpcSubdomainApplies(hostname: string): boolean { + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return false + } + // IPv4 literal; IPv6 literals contain ':' (URL.hostname keeps their brackets) + return !/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && !hostname.includes(':') +} diff --git a/src/obp/sseProbe.ts b/src/obp/sseProbe.ts new file mode 100644 index 0000000..cdbe498 --- /dev/null +++ b/src/obp/sseProbe.ts @@ -0,0 +1,100 @@ +// Browser-side judge for the SSE transport probe served at SSE_PROBE_PATH. +// +// The Opey chat streams over SSE from the browser to the Express server; a +// reverse proxy that buffers responses breaks that silently while every +// request/response check stays green. The server emits two events a fixed +// interval apart; if they arrive together instead of spaced, something between +// the browser and the server is buffering the stream. + +import { + SSE_PROBE_PATH, + SSE_PROBE_EVENT_COUNT, + SSE_PROBE_SPACING_MS +} from '../shared-constants.js' + +export interface SseProbeResult { + ok: boolean + /** ms from request start until the first event arrived */ + timeToFirstEventMs?: number + /** ms between arrival of the first and last event — near zero means buffered */ + eventSpreadMs?: number + buffered?: boolean + error?: string +} + +export async function runSseProbe( + options: { + timeoutMs?: number + spacingMs?: number + path?: string + fetchFn?: typeof fetch + } = {} +): Promise { + const { + timeoutMs = 5000, + spacingMs = SSE_PROBE_SPACING_MS, + path = SSE_PROBE_PATH, + fetchFn = fetch + } = options + + const start = performance.now() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort('timeout'), timeoutMs) + try { + const res = await fetchFn(path, { + signal: controller.signal, + headers: { accept: 'text/event-stream' } + }) + if (!res.ok) { + return { ok: false, error: `Unexpected status code: ${res.status}` } + } + if (!res.body) { + return { ok: false, error: 'Response has no body stream' } + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + const eventArrivals: number[] = [] + let pending = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + pending += decoder.decode(value, { stream: true }) + const blocks = pending.split('\n\n') + pending = blocks.pop() ?? '' + const now = performance.now() + for (const block of blocks) { + if (block.split('\n').some((line) => line.startsWith('data:'))) { + eventArrivals.push(now) + } + } + } + + if (eventArrivals.length < SSE_PROBE_EVENT_COUNT) { + return { + ok: false, + error: `Stream ended after ${eventArrivals.length} of ${SSE_PROBE_EVENT_COUNT} events` + } + } + + const timeToFirstEventMs = Math.round(eventArrivals[0] - start) + const eventSpreadMs = Math.round(eventArrivals[eventArrivals.length - 1] - eventArrivals[0]) + // The server spaced the events spacingMs apart; arriving in less than half + // that means they were held back and delivered together. + const buffered = eventSpreadMs < spacingMs / 2 + return { + ok: !buffered, + timeToFirstEventMs, + eventSpreadMs, + buffered, + error: buffered + ? `Events arrived ${eventSpreadMs}ms apart though the server spaced them ${spacingMs}ms apart — a proxy between the browser and the server is buffering SSE responses, which breaks live streaming` + : undefined + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: msg === 'timeout' ? 'Request timeout' : msg } + } finally { + clearTimeout(timer) + } +} diff --git a/src/shared-constants.ts b/src/shared-constants.ts index 261038b..4dd974b 100644 --- a/src/shared-constants.ts +++ b/src/shared-constants.ts @@ -37,3 +37,13 @@ export const GLOSSARY_API_VERSION = 'v5.1.0' */ export const V5_1_0 = 'v5.1.0' export const V6_0_0 = 'v6.0.0' + +/** + * Browser → Node SSE transport probe (see /api/status/stream and the status + * page's browser-side check). The server emits SSE_PROBE_EVENT_COUNT events + * SSE_PROBE_SPACING_MS apart; the browser judges the transport buffered when + * they arrive closer together than half that spacing. + */ +export const SSE_PROBE_PATH = '/api/status/stream' +export const SSE_PROBE_EVENT_COUNT = 2 +export const SSE_PROBE_SPACING_MS = 700 diff --git a/src/test/sseProbe.test.ts b/src/test/sseProbe.test.ts new file mode 100644 index 0000000..1680cc3 --- /dev/null +++ b/src/test/sseProbe.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { runSseProbe } from '../obp/sseProbe' + +// A genuinely streamed response: two events spacingMs apart. +function streamedResponse(spacingMs: number): Response { + const encoder = new TextEncoder() + let timer: ReturnType | undefined + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(':ok\n\ndata: {"seq":1}\n\n')) + timer = setTimeout(() => { + controller.enqueue(encoder.encode('data: {"seq":2}\n\n')) + controller.close() + }, spacingMs) + }, + cancel() { + clearTimeout(timer) + } + }) + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +// The same events delivered in a single chunk — what a buffering proxy produces. +function bufferedResponse(): Response { + return new Response(':ok\n\ndata: {"seq":1}\n\ndata: {"seq":2}\n\n', { + headers: { 'Content-Type': 'text/event-stream' } + }) +} + +describe('runSseProbe', () => { + it('reports healthy for a genuinely streamed response', async () => { + const result = await runSseProbe({ + spacingMs: 200, + fetchFn: async () => streamedResponse(200) + }) + expect(result.ok).toBe(true) + expect(result.buffered).toBe(false) + expect(result.eventSpreadMs).toBeGreaterThanOrEqual(100) + }) + + it('detects a buffered stream', async () => { + const result = await runSseProbe({ fetchFn: async () => bufferedResponse() }) + expect(result.ok).toBe(false) + expect(result.buffered).toBe(true) + expect(result.error).toContain('buffering') + }) + + it('reports an HTTP error status', async () => { + const result = await runSseProbe({ + fetchFn: async () => new Response('nope', { status: 502 }) + }) + expect(result.ok).toBe(false) + expect(result.error).toContain('502') + }) + + it('reports a truncated stream', async () => { + const result = await runSseProbe({ + fetchFn: async () => + new Response('data: {"seq":1}\n\n', { + headers: { 'Content-Type': 'text/event-stream' } + }) + }) + expect(result.ok).toBe(false) + expect(result.error).toContain('1 of 2 events') + }) + + it('reports a network failure', async () => { + const result = await runSseProbe({ + fetchFn: async () => { + throw new Error('Failed to fetch') + } + }) + expect(result.ok).toBe(false) + expect(result.error).toBe('Failed to fetch') + }) +}) diff --git a/src/views/APIServerStatusView.vue b/src/views/APIServerStatusView.vue index ae05b7f..a3f01c5 100644 --- a/src/views/APIServerStatusView.vue +++ b/src/views/APIServerStatusView.vue @@ -29,6 +29,7 @@ import { ref, computed, onBeforeMount } from 'vue' import { SuccessFilled, RemoveFilled, WarningFilled } from '@element-plus/icons-vue' import { serverStatus } from './../obp' +import { runSseProbe, type SseProbeResult } from './../obp/sseProbe' interface OIDCProviderHealth { name: string @@ -38,7 +39,13 @@ interface OIDCProviderHealth { } const status = ref({}) +// Browser → server SSE transport check: verifies probe events arrive spaced, +// not buffered by a proxy — the hop the server-side checks cannot see. +const sseProbe = ref(null) onBeforeMount(async () => { + runSseProbe().then((result) => { + sseProbe.value = result + }) status.value = await serverStatus() }) @@ -90,6 +97,27 @@ const oauthProviders = computed(() => status.value.oauthPr +
+
+ sseStreaming (browser) +        + + +
+
{{ sseProbe.error }}
+
+ first event: {{ sseProbe.timeToFirstEventMs }}ms, spread: {{ sseProbe.eventSpreadMs }}ms +
+
+
OAuth2 / OIDC Providers
(() => status.value.oauthPr
{{ provider.error }}
-
- token test: {{ provider.details.token_test }} -
+
@@ -184,4 +228,8 @@ span { text-align: center; margin: 2px 0 8px; } +.provider-link { + color: inherit; + text-decoration: underline; +} diff --git a/src/views/GrpcServicesView.vue b/src/views/GrpcServicesView.vue index f0968a0..8479a56 100644 --- a/src/views/GrpcServicesView.vue +++ b/src/views/GrpcServicesView.vue @@ -134,8 +134,10 @@ onMounted(load) Could not reach gRPC server.
{{ errorMessage }}
- Check that a gRPC server is running at {{ host || 'localhost:50051' }} with - reflection enabled. Override with the VITE_OBP_GRPC_HOST environment variable. + Check that a gRPC server with reflection enabled is running at + + . + Override with the VITE_OBP_GRPC_HOST environment variable.