diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 2bbf4be8..153ee961 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -53,6 +53,32 @@ Multiple contexts are supported. Override `current_context` for a single command Each context can have any combination of service blocks (`elasticsearch`, `kibana`, and `cloud`). Authentication supports `api_key` or `username` + `password`. +### Reaching Elasticsearch through Kibana + +Some deployments publish only Kibana and keep Elasticsearch unreachable from clients. Set `via: kibana` on the `elasticsearch` block and requests are forwarded by Kibana's Console proxy instead of being sent to Elasticsearch directly: + +```yaml +current_context: proxied + +contexts: + proxied: + kibana: + url: https://kibana.example.internal + auth: + api_key: your-kibana-api-key-here + elasticsearch: + via: kibana +``` + +Notes: + +- `via` and `url` are mutually exclusive, and a `via: kibana` block needs a `kibana` block in the same context to route through. +- The Kibana credentials are reused, so the `elasticsearch` block takes no `auth` of its own. +- Every `elastic es …` command works as usual, and Elasticsearch errors keep their original status code. +- `elastic status` marks the route, for example `green (5 nodes) via Kibana`. +- The Kibana API key must be allowed to use the Console proxy, and the deployment must have it enabled (`console.ui.enabled` is `true` by default). +- Extensions receive no `ELASTIC_ES_*` environment variables for such a context, since there is no Elasticsearch endpoint to pass on. + ## Authoring the config from the CLI Instead of hand-editing YAML, the `elastic config` command group creates and maintains contexts and stores secrets in the OS keychain when available (macOS Keychain, Linux libsecret, `pass`, Windows Credential Manager). The YAML then holds a resolver expression like `$(keychain:...)` rather than the raw secret. diff --git a/docs/cli/schema.json b/docs/cli/schema.json index cdd1ecc9..fa8286a2 100644 --- a/docs/cli/schema.json +++ b/docs/cli/schema.json @@ -69326,6 +69326,13 @@ "required": false, "summary": "Elasticsearch URL" }, + { + "role": "flag", + "name": "es-via", + "type": "string", + "required": false, + "summary": "Reach Elasticsearch through another service instead of a URL. Only \"kibana\" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is" + }, { "role": "flag", "name": "es-username", @@ -69423,6 +69430,13 @@ "required": false, "summary": "Elasticsearch URL" }, + { + "role": "flag", + "name": "es-via", + "type": "string", + "required": false, + "summary": "Reach Elasticsearch through another service instead of a URL. Only \"kibana\" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is" + }, { "role": "flag", "name": "es-username", diff --git a/src/config/commands.ts b/src/config/commands.ts index e67aeee4..83d89055 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -66,6 +66,7 @@ const SECRET_FIELDS: SecretField[] = [ const PLAIN_FIELDS: PlainField[] = [ { flag: 'es-url', path: ['elasticsearch', 'url'], description: 'Elasticsearch URL' }, + { flag: 'es-via', path: ['elasticsearch', 'via'], description: 'Reach Elasticsearch through another service instead of a URL. Only "kibana" is supported, and it reuses the Kibana credentials -- for deployments where Elasticsearch is unreachable but Kibana is' }, { flag: 'es-username', path: ['elasticsearch', 'auth', 'username'], description: 'Elasticsearch username (pair with --es-password)' }, { flag: 'kb-url', path: ['kibana', 'url'], description: 'Kibana URL' }, { flag: 'kb-username', path: ['kibana', 'auth', 'username'], description: 'Kibana username (pair with --kb-password)' }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 64419a25..b2a63d59 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -33,15 +33,45 @@ export const BasicAuthSchema = z.object({ /** Union of all supported auth variants -- type is inferred from whichever fields are present. */ export const AuthSchema = z.union([ApiKeyAuthSchema, BasicAuthSchema]) +/** Validates that a configured endpoint is an absolute http(s) URL. */ +const ServiceUrlSchema = z.string().url().refine( + (u) => u.startsWith('https://') || u.startsWith('http://'), + { message: 'URL must use http:// or https:// scheme' } +) + /** Endpoint URL and authentication credentials for a single service. */ export const ServiceBlockSchema = z.object({ - url: z.string().url().refine( - (u) => u.startsWith('https://') || u.startsWith('http://'), - { message: 'URL must use http:// or https:// scheme' } - ), + url: ServiceUrlSchema, auth: AuthSchema.optional() }) +/** + * The Elasticsearch service block. + * + * Unlike Kibana and Cloud, Elasticsearch may be addressed in two ways: + * - `url` (+ optional `auth`) — a direct connection, the default. + * - `via: kibana` — requests are forwarded by Kibana's Console proxy, reusing the + * context's `kibana` credentials. This is for deployments where Elasticsearch is + * not reachable from the client but Kibana is. + * + * The two are mutually exclusive: `via` means there is no ES endpoint to address + * directly, so accepting a `url` alongside it would silently ignore one of them. + */ +export const EsServiceBlockSchema = z + .object({ + url: ServiceUrlSchema.optional(), + auth: AuthSchema.optional(), + via: z.literal('kibana').optional(), + }) + .refine( + (es) => (es.via == null) !== (es.url == null), + { error: 'elasticsearch: set either "url" for a direct connection or "via: kibana", but not both' } + ) + .refine( + (es) => !(es.via != null && es.auth != null), + { error: 'elasticsearch: "via: kibana" reuses the kibana credentials; remove "auth"' } + ) + /** * Policy controlling which commands are permitted to run. * @@ -78,7 +108,7 @@ export const CommandPolicySchema = z */ export const ContextSchema = z .object({ - elasticsearch: ServiceBlockSchema.optional(), + elasticsearch: EsServiceBlockSchema.optional(), kibana: ServiceBlockSchema.optional(), cloud: ServiceBlockSchema.optional(), commands: CommandPolicySchema.optional(), @@ -87,6 +117,10 @@ export const ContextSchema = z (ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null, { error: 'at least one service block (elasticsearch, kibana, or cloud) is required' } ) + .refine( + (ctx) => ctx.elasticsearch?.via !== 'kibana' || ctx.kibana != null, + { error: 'elasticsearch: "via: kibana" requires a kibana block in the same context' } + ) /** * The root configuration file structure. diff --git a/src/config/types.ts b/src/config/types.ts index 88a3d32f..4a86095c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -7,6 +7,7 @@ import type { z } from 'zod' import type { AuthSchema, ServiceBlockSchema, + EsServiceBlockSchema, ContextSchema, ConfigFileSchema, CommandPolicySchema, @@ -31,6 +32,12 @@ export type Auth = z.infer /** Endpoint URL and authentication credentials for a single service. */ export type ServiceBlock = z.infer +/** The Elasticsearch service block: a direct connection, or routed through Kibana. */ +export type EsServiceBlock = z.infer + +/** An Elasticsearch block whose requests are forwarded by Kibana's Console proxy. */ +export interface EsViaKibanaBlock { via: 'kibana' } + /** A context value: optional service blocks with at least one present. */ export type Context = z.infer @@ -42,11 +49,29 @@ export type CommandPolicy = z.infer /** The active context after resolution — only its configured service blocks, no extras. */ export interface ResolvedContext { - elasticsearch?: ServiceBlock + elasticsearch?: EsServiceBlock kibana?: ServiceBlock cloud?: ServiceBlock } +/** + * Narrows an Elasticsearch block to the Kibana-routed variant. + * + * The schema guarantees `via` and `url` are mutually exclusive, so this doubles as + * a check that no direct endpoint is available. + */ +export function isEsViaKibana (block: EsServiceBlock): block is EsViaKibanaBlock { + return block.via === 'kibana' +} + +/** + * Narrows an Elasticsearch block to the direct-connection variant, whose `url` is + * guaranteed present by the schema. + */ +export function isEsDirect (block: EsServiceBlock): block is ServiceBlock { + return block.url != null +} + /** Typed configuration object passed to command handlers after loading and context resolution. */ export interface ResolvedConfig { context: ResolvedContext diff --git a/src/es/handler.ts b/src/es/handler.ts index 0c06da5d..5e3d9a51 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { EsClient } from '../lib/es-client.ts' +import type { EsTransport } from '../lib/es-client.ts' import type { EsApiDefinition } from './types.ts' import type { SchemaArgDefinition } from '../lib/schema-args.ts' import { buildRequestParams } from './request-builder.ts' @@ -16,8 +16,8 @@ import type { JsonValue, ParsedResult } from '../factory.ts' * Production code uses the defaults; tests supply stubs. */ export interface EsHandlerDeps { - /** returns the active EsClient instance, or throws `missing_config` */ - getEsClient: () => EsClient + /** returns the active EsTransport instance, or throws `missing_config` */ + getEsClient: () => EsTransport /** builds EsRequestParams from a definition, parsed CLI input, and schema args */ buildRequestParams: typeof buildRequestParams } diff --git a/src/es/helpers/bulk-ingest.ts b/src/es/helpers/bulk-ingest.ts index dc54a5d2..3e5b71f1 100644 --- a/src/es/helpers/bulk-ingest.ts +++ b/src/es/helpers/bulk-ingest.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { readFileSync } from 'node:fs' -import type { EsClient } from '../../lib/es-client.ts' +import type { EsTransport } from '../../lib/es-client.ts' import { defineCommand } from '../../factory.ts' import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts' import { getEsClient } from '../../lib/es-client.ts' @@ -23,7 +23,7 @@ import { /** Dependencies injectable for testing. */ export interface BulkIngestDeps { - getEsClient: () => EsClient + getEsClient: () => EsTransport } const defaultDeps: BulkIngestDeps = { getEsClient } @@ -139,7 +139,7 @@ function collectDocuments (opts: BulkIngestInput): { docs: unknown[], filesProce /** Sends a single bulk batch to Elasticsearch. Returns the count of errors. */ async function sendBatch ( - transport: EsClient, + transport: EsTransport, ndjsonBody: string, index: string ): Promise<{ errors: number, total: number }> { @@ -165,7 +165,7 @@ function createBulkIngestHandler (deps: BulkIngestDeps = defaultDeps) { return async (parsed: { input?: BulkIngestInput; options: Record }): Promise => { const opts = parsed.input! - let transport: EsClient + let transport: EsTransport try { transport = deps.getEsClient() } catch (err) { diff --git a/src/es/helpers/msearch.ts b/src/es/helpers/msearch.ts index 2802a42f..44b67cbd 100644 --- a/src/es/helpers/msearch.ts +++ b/src/es/helpers/msearch.ts @@ -4,7 +4,7 @@ */ import { z } from 'zod' -import type { EsClient } from '../../lib/es-client.ts' +import type { EsTransport } from '../../lib/es-client.ts' import { defineCommand } from '../../factory.ts' import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts' import { getEsClient } from '../../lib/es-client.ts' @@ -22,7 +22,7 @@ interface MsearchResponse { /** Dependencies injectable for testing. */ export interface MsearchDeps { - getEsClient: () => EsClient + getEsClient: () => EsTransport } const defaultDeps: MsearchDeps = { getEsClient } @@ -73,7 +73,7 @@ function createMsearchHandler (deps: MsearchDeps = defaultDeps) { return async (parsed: { input?: z.infer; options: Record }): Promise => { const { index, query_file, batch_size, concurrency } = parsed.input! - let transport: EsClient + let transport: EsTransport try { transport = deps.getEsClient() } catch (err) { diff --git a/src/es/helpers/scroll-search.ts b/src/es/helpers/scroll-search.ts index 5121258e..9711d096 100644 --- a/src/es/helpers/scroll-search.ts +++ b/src/es/helpers/scroll-search.ts @@ -4,7 +4,7 @@ */ import { z } from 'zod' -import type { EsClient } from '../../lib/es-client.ts' +import type { EsTransport } from '../../lib/es-client.ts' import { defineCommand } from '../../factory.ts' import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts' import { getEsClient } from '../../lib/es-client.ts' @@ -26,7 +26,7 @@ interface SearchResponse { /** Dependencies injectable for testing. */ export interface ScrollSearchDeps { - getEsClient: () => EsClient + getEsClient: () => EsTransport stdout: { write: (chunk: string) => boolean } stderr: { write: (chunk: string) => boolean } env?: NodeJS.ProcessEnv @@ -53,7 +53,7 @@ function createScrollSearchHandler (deps: ScrollSearchDeps = defaultDeps) { const { index, query, query_file, scroll, size, max_docs } = parsed.input! const maxDocs = max_docs ?? Infinity - let transport: EsClient + let transport: EsTransport try { transport = deps.getEsClient() } catch (err) { diff --git a/src/es/helpers/watch.ts b/src/es/helpers/watch.ts index 609a6b14..f34e18e8 100644 --- a/src/es/helpers/watch.ts +++ b/src/es/helpers/watch.ts @@ -4,7 +4,7 @@ */ import { z } from 'zod' -import type { EsClient } from '../../lib/es-client.ts' +import type { EsTransport } from '../../lib/es-client.ts' import { defineCommand } from '../../factory.ts' import type { OpaqueCommandHandle, JsonValue } from '../../factory.ts' import { getEsClient } from '../../lib/es-client.ts' @@ -25,7 +25,7 @@ interface SearchResponse { /** Dependencies injectable for testing. */ export interface WatchDeps { - getEsClient: () => EsClient + getEsClient: () => EsTransport stdout: { write: (chunk: string) => boolean } stderr: { write: (chunk: string) => boolean } sleep: (ms: number) => Promise @@ -118,7 +118,7 @@ function createWatchHandler (deps: WatchDeps = defaultDeps) { return async (parsed: { input?: z.infer; options: Record }): Promise => { const { index, query, query_file, sort_field, poll_interval, from, size, format } = parsed.input! - let transport: EsClient + let transport: EsTransport try { transport = deps.getEsClient() } catch (err) { diff --git a/src/extension/context.ts b/src/extension/context.ts index 06e2581a..bbd89d1e 100644 --- a/src/extension/context.ts +++ b/src/extension/context.ts @@ -35,6 +35,7 @@ */ import type { ResolvedConfig, ServiceBlock } from '../config/types.ts' +import { isEsViaKibana } from '../config/types.ts' type EnvMap = Record @@ -59,7 +60,11 @@ function serviceEnv (prefix: string, block: ServiceBlock): EnvMap { export function buildContextEnv (config: ResolvedConfig): EnvMap { const env: EnvMap = {} const { elasticsearch, kibana, cloud } = config.context - if (elasticsearch != null) Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch)) + // A `via: kibana` context has no Elasticsearch endpoint to hand to an extension: the + // Kibana variables below are the only usable credentials, so no ES_* vars are emitted. + if (elasticsearch != null && !isEsViaKibana(elasticsearch)) { + Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch as ServiceBlock)) + } if (kibana != null) Object.assign(env, serviceEnv('ELASTIC_KIBANA', kibana)) if (cloud != null) Object.assign(env, serviceEnv('ELASTIC_CLOUD', cloud)) return env diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 351645a7..d30c7d92 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -25,3 +25,24 @@ export function buildAuthHeader (auth: ApiKeyOrBasicAuth | undefined): string | const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64') return `Basic ${encoded}` } + +/** + * Narrows a config service block's `auth` field to {@link ApiKeyOrBasicAuth}. + * + * The resolved config types auth loosely, since a config file may supply either + * variant (or neither, when security is disabled). Every client needs the same + * narrowing before it can build a header. + * + * @returns the narrowed auth, or `undefined` when absent or incomplete + */ +export function narrowAuth (auth: unknown): ApiKeyOrBasicAuth | undefined { + if (auth == null || typeof auth !== 'object') return undefined + const record = auth as Record + if (typeof record['api_key'] === 'string') { + return { api_key: record['api_key'] } + } + if (typeof record['username'] === 'string' && typeof record['password'] === 'string') { + return { username: record['username'], password: record['password'] } + } + return undefined +} diff --git a/src/lib/es-client.ts b/src/lib/es-client.ts index fdb9b498..6f7f3fa3 100644 --- a/src/lib/es-client.ts +++ b/src/lib/es-client.ts @@ -4,39 +4,26 @@ */ import { getResolvedConfig } from '../config/store.ts' -import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { isEsViaKibana } from '../config/types.ts' +import { buildAuthHeader, narrowAuth, type ApiKeyOrBasicAuth } from './auth.ts' +import { EsConsoleProxyClient } from './es-console-proxy-client.ts' +import { + buildEsQueryString, + EsConnectionError, + EsResponseError, + type EsRequestParams, + type EsTransport, +} from './es-transport.ts' import { clientHeaders } from './meta.ts' -export interface EsRequestParams { - method: string - path: string - querystring?: Record - /** Object body → JSON-serialized; string body → sent as-is with application/json */ - body?: unknown - /** NDJSON body → sent as-is with application/x-ndjson; takes precedence over `body` */ - bulkBody?: string -} - -export class EsResponseError extends Error { - statusCode: number - body: unknown - - constructor (statusCode: number, body: unknown) { - const message = body != null && typeof body === 'object' && 'error' in body - ? JSON.stringify((body as Record).error) - : String(body) - super(message) - this.name = 'EsResponseError' - this.statusCode = statusCode - this.body = body - } -} - -export class EsConnectionError extends Error { - constructor (message: string) { - super(message) - this.name = 'EsConnectionError' - } +// The transport contract lives in `es-transport.ts` so that other transports can depend +// on it without importing this module. Re-exported here for existing consumers. +export { + buildEsQueryString, + EsConnectionError, + EsResponseError, + type EsRequestParams, + type EsTransport, } /** @@ -49,7 +36,7 @@ export class EsConnectionError extends Error { * All requests automatically include `x-elastic-client-meta` and `user-agent` * headers via `clientHeaders()`. */ -export class EsClient { +export class EsClient implements EsTransport { readonly baseUrl: string private readonly authHeader: string | undefined private _fetch: typeof fetch = globalThis.fetch @@ -68,13 +55,8 @@ export class EsClient { ): Promise { let url = `${this.baseUrl}${params.path}` - if (params.querystring != null && Object.keys(params.querystring).length > 0) { - const pieces = Object.entries(params.querystring) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) - .join('&') - if (pieces.length > 0) url += `?${pieces}` - } + const queryString = buildEsQueryString(params.querystring) + if (queryString.length > 0) url += `?${queryString}` const headers: Record = { ...clientHeaders(), @@ -143,15 +125,19 @@ export class EsClient { } } -let _client: EsClient | undefined +let _client: EsTransport | undefined /** - * Returns a lazily-created, cached `EsClient` configured from the + * Returns a lazily-created, cached Elasticsearch transport configured from the * resolved config context's `elasticsearch` service block. * - * @throws {Error} with code `missing_config` when no Elasticsearch service is configured + * Returns a direct {@link EsClient} for a block with a `url`, or a transport that + * forwards through Kibana when the block declares `via: kibana`. + * + * @throws {Error} with code `missing_config` when no Elasticsearch service is configured, + * or when `via: kibana` is set without a `kibana` block to route through */ -export function getEsClient (): EsClient { +export function getEsClient (): EsTransport { if (_client != null) return _client const config = getResolvedConfig() @@ -164,17 +150,26 @@ export function getEsClient (): EsClient { ) } - const { url, auth } = es - const authRecord = auth != null ? auth as Record : undefined + if (isEsViaKibana(es)) { + const kibana = config?.context.kibana + if (kibana == null) { + throw new Error( + 'missing_config: elasticsearch is configured with "via: kibana" but the active ' + + 'context has no kibana block. Add one, or replace "via" with an elasticsearch url.' + ) + } + _client = new EsConsoleProxyClient(kibana.url, narrowAuth(kibana.auth)) + return _client + } - let typedAuth: { api_key: string } | { username: string; password: string } | undefined - if (typeof authRecord?.['api_key'] === 'string') { - typedAuth = { api_key: authRecord['api_key'] as string } - } else if (typeof authRecord?.['username'] === 'string' && typeof authRecord?.['password'] === 'string') { - typedAuth = { username: authRecord['username'] as string, password: authRecord['password'] as string } + if (es.url == null) { + throw new Error( + 'missing_config: The elasticsearch block has no url. Set a url, or use ' + + '"via: kibana" to route requests through Kibana.' + ) } - _client = new EsClient(url, typedAuth) + _client = new EsClient(es.url, narrowAuth(es.auth)) return _client } diff --git a/src/lib/es-console-proxy-client.ts b/src/lib/es-console-proxy-client.ts new file mode 100644 index 00000000..944b43e8 --- /dev/null +++ b/src/lib/es-console-proxy-client.ts @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildAuthHeader, type ApiKeyOrBasicAuth } from './auth.ts' +import { + buildEsQueryString, + EsConnectionError, + EsResponseError, + type EsRequestParams, + type EsTransport, +} from './es-transport.ts' +import { isLoopbackUrl } from './is-loopback-host.ts' +import { clientHeaders } from './meta.ts' + +/** Kibana route that forwards a request to Elasticsearch on the caller's behalf. */ +export const CONSOLE_PROXY_PATH = '/api/console/proxy' + +/** + * Kibana reports the real Elasticsearch status here. The outer response is always 200 + * when the proxy itself succeeded, even if Elasticsearch answered 4xx or 5xx. + */ +export const PROXY_STATUS_HEADER = 'x-console-proxy-status-code' + +/** Kibana restricts this route to callers that identify as an internal origin. */ +export const INTERNAL_ORIGIN_HEADER = 'x-elastic-internal-origin' + +/** + * Headers Kibana requires on every Console proxy call, beyond authentication. + * + * Shared so that `elastic status` probes the proxy exactly the way requests travel. + * Without {@link INTERNAL_ORIGIN_HEADER}, Kibana answers HTTP 400 with a message that + * reads as though the route were disabled. + */ +export const CONSOLE_PROXY_HEADERS: Readonly> = Object.freeze({ + 'kbn-xsrf': 'true', + [INTERNAL_ORIGIN_HEADER]: 'Kibana', +}) + +/** + * Builds the Kibana Console proxy URL for an Elasticsearch request. + * + * `target` is the Elasticsearch path including any querystring, already + * percent-encoded per path parameter. Encoding it again here is deliberate: Kibana + * decodes the query parameter once, which restores exactly the encoded path + * Elasticsearch expects. For an index named `my index` that is + * `/my%20index/_search` → `path=%2Fmy%2520index%2F_search` → Kibana decodes → + * `/my%20index/_search` → Elasticsearch decodes → `my index`. + * + * Percent-encoding is used rather than `URLSearchParams`, which form-encodes a literal + * space as `+` — a percent-decoder would then read it as `+` instead of a space. + */ +export function consoleProxyUrl (kibanaUrl: string, target: string, method: string): string { + const base = kibanaUrl.replace(/\/+$/, '') + const path = encodeURIComponent(target) + const verb = encodeURIComponent(method.toUpperCase()) + return `${base}${CONSOLE_PROXY_PATH}?path=${path}&method=${verb}` +} + +/** + * Reads the Elasticsearch status code reported by the proxy, falling back to the outer + * HTTP status when the header is absent or unparseable. + */ +export function proxiedEsStatus (response: Response): number { + const reported = Number(response.headers.get(PROXY_STATUS_HEADER)) + return Number.isInteger(reported) && reported > 0 ? reported : response.status +} + +/** + * Kibana's reply when {@link INTERNAL_ORIGIN_HEADER} is missing. The wording suggests the + * route is disabled on the deployment, which is misleading, so it gets an explicit hint. + */ +const ROUTE_RESTRICTED = /not available with the current configuration/i + +/** + * Elasticsearch transport that forwards requests through Kibana's Console proxy. + * + * Intended for deployments where Elasticsearch is not reachable from the client but + * Kibana is — commonly on-prem installs that publish only Kibana. Kibana performs the + * Elasticsearch call and returns its response body verbatim, so this satisfies the same + * {@link EsTransport} contract as a direct connection and every `elastic es` command + * works unchanged. + * + * Authentication uses the context's Kibana credentials; there is no separate + * Elasticsearch endpoint to authenticate against. + */ +export class EsConsoleProxyClient implements EsTransport { + readonly kibanaUrl: string + private readonly authHeader: string | undefined + private _fetch: typeof fetch = globalThis.fetch + + constructor (kibanaUrl: string, auth?: ApiKeyOrBasicAuth) { + this.kibanaUrl = kibanaUrl.replace(/\/+$/, '') + this.authHeader = buildAuthHeader(auth) + if (this.kibanaUrl.startsWith('http://') && !isLoopbackUrl(this.kibanaUrl)) { + process.stderr.write('Warning: using plaintext HTTP. Credentials will be sent unencrypted.\n') + } + } + + async request( + params: EsRequestParams, + opts?: { headers?: Record } + ): Promise { + const url = this.buildProxyUrl(params) + + const headers: Record = { + ...clientHeaders(), + ...(this.authHeader != null && { 'Authorization': this.authHeader }), + 'Accept': 'application/json', + ...CONSOLE_PROXY_HEADERS, + } + + let body: string | undefined + if (params.bulkBody !== undefined) { + body = params.bulkBody + headers['Content-Type'] = 'application/x-ndjson' + } else if (typeof params.body === 'string') { + body = params.body + headers['Content-Type'] = 'application/json' + } else if (params.body !== undefined) { + body = JSON.stringify(params.body) + headers['Content-Type'] = 'application/json' + } + + if (opts?.headers != null) { + Object.assign(headers, opts.headers) + } + + let response: Response + try { + response = await this._fetch(url, { + // The Elasticsearch method travels in the `method` query parameter, so the + // request to Kibana is always a POST — including for ES GET-with-body searches. + method: 'POST', + headers, + ...(body !== undefined && { body }), + redirect: 'error', + }) + } catch (err) { + throw new EsConnectionError(err instanceof Error ? err.message : String(err)) + } + + // A non-2xx here means Kibana rejected the request, so the Elasticsearch call never + // happened. Report it as a connection failure rather than an Elasticsearch response. + if (!response.ok) { + throw new EsConnectionError(await this.describeProxyFailure(response)) + } + + const esStatus = proxiedEsStatus(response) + + if (params.method.toUpperCase() === 'HEAD') { + if (esStatus < 400) return true as T + if (esStatus === 404) return false as T + } + + const payload = await this.parseBody(response) + + if (esStatus >= 400) { + throw new EsResponseError(esStatus, payload) + } + + return payload as T + } + + /** Combines the Elasticsearch path and querystring into a single proxy target. */ + private buildProxyUrl (params: EsRequestParams): string { + const queryString = buildEsQueryString(params.querystring) + const target = queryString.length > 0 ? `${params.path}?${queryString}` : params.path + return consoleProxyUrl(this.kibanaUrl, target, params.method) + } + + /** Parses a proxied Elasticsearch response body, mirroring a direct connection. */ + private async parseBody (response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + const text = await response.text() + if (text.length === 0) return {} + if (contentType.includes('application/json') || contentType.includes('application/x-ndjson')) { + try { + return JSON.parse(text) + } catch { + // Kibana labelled it JSON but sent something else (e.g. an HTML error page from + // an intermediate proxy). Surface the payload instead of a parser error. + return text + } + } + return text + } + + /** Describes a Kibana-level failure, adding a hint for the misleading restricted-route reply. */ + private async describeProxyFailure (response: Response): Promise { + let detail: string + try { + detail = (await response.text()).trim() + } catch { + detail = '' + } + + const url = `${this.kibanaUrl}${CONSOLE_PROXY_PATH}` + let message = `Kibana rejected the Elasticsearch request (HTTP ${response.status}) at ${url}` + if (detail.length > 0) message += `: ${detail}` + + if (response.status === 400 && ROUTE_RESTRICTED.test(detail)) { + message += `\n\nHint: Kibana restricts ${CONSOLE_PROXY_PATH} to internal callers. ` + + `This CLI sends the required ${INTERNAL_ORIGIN_HEADER} header, so this usually means ` + + 'the deployment disables the Console proxy (console.ui.enabled: false) or an ' + + 'intermediate proxy strips the header.' + } + if (response.status === 401 || response.status === 403) { + message += '\n\nHint: these are the kibana credentials from your config; ' + + 'the API key must be allowed to use the Console proxy.' + } + + return message + } + + /** @internal test seam — replaces the fetch implementation for unit tests */ + _testSetFetch (fn: typeof fetch): void { + this._fetch = fn + } +} diff --git a/src/lib/es-transport.ts b/src/lib/es-transport.ts new file mode 100644 index 00000000..21fe2da4 --- /dev/null +++ b/src/lib/es-transport.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The Elasticsearch transport contract, shared by every implementation. + * + * Kept separate from the concrete clients so that a transport (e.g. the Kibana + * Console proxy) can depend on the contract without importing the direct client, + * which would introduce an import cycle. + */ + +export interface EsRequestParams { + method: string + path: string + querystring?: Record + /** Object body → JSON-serialized; string body → sent as-is with application/json */ + body?: unknown + /** NDJSON body → sent as-is with application/x-ndjson; takes precedence over `body` */ + bulkBody?: string +} + +/** + * The contract every Elasticsearch transport satisfies. + * + * Commands and helpers depend on this rather than on a concrete client, so requests + * can be sent directly or forwarded through Kibana without any change to callers. + */ +export interface EsTransport { + request( + params: EsRequestParams, + opts?: { headers?: Record } + ): Promise + /** @internal test seam — replaces the fetch implementation for unit tests */ + _testSetFetch (fn: typeof fetch): void +} + +/** An error response returned by Elasticsearch, carrying its status code and body. */ +export class EsResponseError extends Error { + statusCode: number + body: unknown + + constructor (statusCode: number, body: unknown) { + const message = body != null && typeof body === 'object' && 'error' in body + ? JSON.stringify((body as Record).error) + : String(body) + super(message) + this.name = 'EsResponseError' + this.statusCode = statusCode + this.body = body + } +} + +/** A failure to reach Elasticsearch at all — DNS, TLS, timeouts, or a rejecting proxy. */ +export class EsConnectionError extends Error { + constructor (message: string) { + super(message) + this.name = 'EsConnectionError' + } +} + +/** + * Serializes an Elasticsearch querystring, skipping `undefined` values. + * + * Shared so that transports carrying the querystring differently — such as folding it + * into a proxy's `path` parameter — encode it identically to a direct connection. + * + * @returns the encoded querystring without a leading `?`, or an empty string + */ +export function buildEsQueryString (querystring: Record | undefined): string { + if (querystring == null) return '' + return Object.entries(querystring) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') +} diff --git a/src/status/checks.ts b/src/status/checks.ts index 188a36a5..a6e10d47 100644 --- a/src/status/checks.ts +++ b/src/status/checks.ts @@ -12,8 +12,14 @@ * surface every service's state independently. */ -import type { ServiceBlock } from '../config/types.ts' -import { buildAuthHeader } from '../lib/auth.ts' +import type { EsServiceBlock, ServiceBlock } from '../config/types.ts' +import { isEsViaKibana } from '../config/types.ts' +import { buildAuthHeader, narrowAuth } from '../lib/auth.ts' +import { + CONSOLE_PROXY_HEADERS, + consoleProxyUrl, + proxiedEsStatus, +} from '../lib/es-console-proxy-client.ts' import { clientHeaders } from '../lib/meta.ts' /** Successful Elasticsearch probe. */ @@ -45,7 +51,15 @@ export interface CheckErr { error: string } -export type EsCheck = EsCheckOk | CheckErr +/** + * Marks how the Elasticsearch probe reached the cluster. Present only when the request + * was forwarded by Kibana, so a direct connection reports exactly as before. + */ +export interface EsRouting { + via?: 'kibana' +} + +export type EsCheck = (EsCheckOk | CheckErr) & EsRouting export type KbCheck = KbCheckOk | CheckErr export type CloudCheck = CloudCheckOk | CheckErr @@ -104,22 +118,79 @@ async function pingService ( * or response shape is invalid. */ export async function checkElasticsearch ( - block: ServiceBlock, + block: EsServiceBlock, fetchFn: typeof fetch = globalThis.fetch, + kibana?: ServiceBlock, ): Promise { - const result = await pingService(block.url, '/_cluster/health', block.auth, fetchFn) - if (!result.ok) return { ok: false, url: block.url, error: result.error } + if (isEsViaKibana(block)) { + if (kibana == null) { + return { ok: false, url: '', error: 'via: kibana requires a kibana block', via: 'kibana' } + } + const proxied = await pingViaConsoleProxy(kibana, fetchFn) + return { ...interpretHealth(proxied, kibana.url), via: 'kibana' } + } + // The schema guarantees a url when `via` is absent. + const url = block.url as string + const result = await pingService(url, '/_cluster/health', block.auth, fetchFn) + return interpretHealth(result, url) +} + +/** Extracts cluster status and node count from a `_cluster/health` probe result. */ +function interpretHealth ( + result: { ok: true, body: unknown } | { ok: false, error: string }, + url: string, +): EsCheckOk | CheckErr { + if (!result.ok) return { ok: false, url, error: result.error } const body = result.body if (body == null || typeof body !== 'object') { - return { ok: false, url: block.url, error: 'unexpected response' } + return { ok: false, url, error: 'unexpected response' } } const rec = body as Record const status = rec['status'] const nodes = rec['number_of_nodes'] if (typeof status !== 'string' || typeof nodes !== 'number') { - return { ok: false, url: block.url, error: 'unexpected response' } + return { ok: false, url, error: 'unexpected response' } + } + return { ok: true, url, status, nodes } +} + +/** + * Probes `GET /_cluster/health` through Kibana's Console proxy. + * + * Mirrors how requests travel for a `via: kibana` context: the outer call is a POST to + * Kibana and the real Elasticsearch status arrives in a response header. + */ +async function pingViaConsoleProxy ( + kibana: ServiceBlock, + fetchFn: typeof fetch, +): Promise<{ ok: true, body: unknown } | { ok: false, error: string }> { + const headers: Record = { + ...clientHeaders(), + 'Accept': 'application/json', + ...CONSOLE_PROXY_HEADERS, + } + const h = buildAuthHeader(narrowAuth(kibana.auth)) + if (h != null) headers['Authorization'] = h + + const url = consoleProxyUrl(kibana.url, '/_cluster/health', 'GET') + let response: Response + try { + response = await fetchFn(url, { method: 'POST', headers, redirect: 'error' }) + } catch (err) { + return { ok: false, error: classifyNetwork(err) } + } + if (!response.ok) return { ok: false, error: classifyHttp(response.status) } + + const esStatus = proxiedEsStatus(response) + if (esStatus >= 400) return { ok: false, error: classifyHttp(esStatus) } + + const text = await response.text() + if (text.length === 0) return { ok: true, body: {} } + try { + return { ok: true, body: JSON.parse(text) } + } catch { + return { ok: false, error: 'unexpected response' } } - return { ok: true, url: block.url, status, nodes } } /** diff --git a/src/status/format.ts b/src/status/format.ts index c268df1a..b7f6dd14 100644 --- a/src/status/format.ts +++ b/src/status/format.ts @@ -28,9 +28,12 @@ interface Row { } function esSummary (s: EsCheck): string { - if (!s.ok) return s.error + // Requests forwarded by Kibana are reported explicitly: the url column shows the + // Kibana endpoint, so without this the route would be indistinguishable from direct. + const route = s.via === 'kibana' ? ' via Kibana' : '' + if (!s.ok) return `${s.error}${route}` const noun = s.nodes === 1 ? 'node' : 'nodes' - return `${s.status} (${s.nodes} ${noun})` + return `${s.status} (${s.nodes} ${noun})${route}` } function kbSummary (s: KbCheck): string { diff --git a/src/status/register.ts b/src/status/register.ts index 4f05afc3..194408c6 100644 --- a/src/status/register.ts +++ b/src/status/register.ts @@ -58,7 +58,8 @@ export async function runStatusChecks ( const tasks: Array> = [] if (context.elasticsearch != null) { const block = context.elasticsearch - tasks.push(checkElasticsearch(block, fetchFn).then((r): ['elasticsearch', EsCheck] => ['elasticsearch', r])) + // The Kibana block is passed too: a `via: kibana` context is probed through it. + tasks.push(checkElasticsearch(block, fetchFn, context.kibana).then((r): ['elasticsearch', EsCheck] => ['elasticsearch', r])) } if (context.kibana != null) { const block = context.kibana diff --git a/test/config/commands.test.ts b/test/config/commands.test.ts index ed507bea..5e463d1c 100644 --- a/test/config/commands.test.ts +++ b/test/config/commands.test.ts @@ -6,7 +6,7 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' @@ -69,6 +69,37 @@ describe('elastic config (integration)', () => { } }) + it('context add --es-via kibana writes a proxied elasticsearch block', async () => { + const viaCfg = join(dir, 'via.yml') + const res = run([ + 'config', 'context', 'add', 'proxied', + '--config-file', viaCfg, + '--kb-url', 'https://kibana.example', + '--es-via', 'kibana', + ]) + assert.equal(res.exitCode, 0, res.stderr) + + const written = await readFile(viaCfg, 'utf-8') + // Isolate the elasticsearch block: the kibana block that follows has its own url. + const esBlock = written.match(/ {4}elasticsearch:\n((?: {6}.*\n)*)/)?.[1] ?? '' + assert.match(esBlock, /via: kibana/, written) + // `via` replaces the url; no Elasticsearch endpoint is written. + assert.ok(!esBlock.includes('url:'), written) + assert.match(written, / {4}kibana:\n {6}url: https:\/\/kibana\.example/, written) + }) + + it('context add --es-via kibana without a kibana block is rejected', () => { + const res = run([ + 'config', 'context', 'add', 'broken', + '--config-file', join(dir, 'broken.yml'), + '--es-via', 'kibana', + ]) + assert.notEqual(res.exitCode, 0) + const err = (res.json as { error?: { code?: string, message?: string } }).error + assert.equal(err?.code, 'invalid_context') + assert.match(err?.message ?? '', /requires a kibana block/) + }) + it('context add without any service flags errors with code=no_fields', () => { const res = run([ 'config', 'context', 'add', 'barebones', diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts index 7b34825e..45dd3e7c 100644 --- a/test/config/schema.test.ts +++ b/test/config/schema.test.ts @@ -5,7 +5,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { ApiKeyAuthSchema, BasicAuthSchema, AuthSchema, ServiceBlockSchema, ContextSchema, ConfigFileSchema, CommandPolicySchema } from '../../src/config/schema.ts' +import { ApiKeyAuthSchema, BasicAuthSchema, AuthSchema, ServiceBlockSchema, EsServiceBlockSchema, ContextSchema, ConfigFileSchema, CommandPolicySchema } from '../../src/config/schema.ts' const esBlock = { url: 'https://es.example.com:9200', auth: { api_key: 'key1' } } const kibanaBlock = { url: 'https://kibana.example.com:5601', auth: { username: 'u', password: 'p' } } @@ -192,6 +192,46 @@ describe('ServiceBlockSchema', () => { }) }) +describe('EsServiceBlockSchema', () => { + it('accepts a direct connection with url and auth', () => { + const result = EsServiceBlockSchema.safeParse({ + url: 'https://es.example.com:9200', + auth: { api_key: 'abc123' }, + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.url, 'https://es.example.com:9200') + }) + + it('accepts via: kibana without a url', () => { + const result = EsServiceBlockSchema.safeParse({ via: 'kibana' }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.via, 'kibana') + }) + + it('rejects a block with both via and url', () => { + // A url alongside via is ambiguous: one of the two would be silently ignored. + const result = EsServiceBlockSchema.safeParse({ via: 'kibana', url: 'https://es.example.com:9200' }) + assert.equal(result.success, false) + }) + + it('rejects a block with neither via nor url', () => { + assert.equal(EsServiceBlockSchema.safeParse({}).success, false) + }) + + it('rejects an unknown via target', () => { + assert.equal(EsServiceBlockSchema.safeParse({ via: 'logstash' }).success, false) + }) + + it('rejects auth alongside via, which reuses the kibana credentials', () => { + const result = EsServiceBlockSchema.safeParse({ via: 'kibana', auth: { api_key: 'abc' } }) + assert.equal(result.success, false) + }) + + it('still rejects a non-http url', () => { + assert.equal(EsServiceBlockSchema.safeParse({ url: 'ftp://es.example.com' }).success, false) + }) +}) + describe('ContextSchema', () => { it('accepts a context with only elasticsearch', () => { const result = ContextSchema.safeParse({ elasticsearch: esBlock }) @@ -208,6 +248,19 @@ describe('ContextSchema', () => { assert.equal(result.success, true) }) + it('accepts via: kibana when a kibana block is present', () => { + const result = ContextSchema.safeParse({ elasticsearch: { via: 'kibana' }, kibana: kibanaBlock }) + assert.equal(result.success, true) + }) + + it('rejects via: kibana without a kibana block to route through', () => { + const result = ContextSchema.safeParse({ elasticsearch: { via: 'kibana' } }) + assert.equal(result.success, false) + if (!result.success) { + assert.match(result.error.issues.map((i) => i.message).join('\n'), /requires a kibana block/) + } + }) + it('accepts a context with only cloud', () => { const result = ContextSchema.safeParse({ cloud: cloudBlock }) assert.equal(result.success, true) diff --git a/test/extension/context.test.ts b/test/extension/context.test.ts new file mode 100644 index 00000000..7383136f --- /dev/null +++ b/test/extension/context.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildContextEnv } from '../../src/extension/context.ts' +import type { ResolvedConfig } from '../../src/config/types.ts' + +describe('buildContextEnv', () => { + it('exports url and api key for each configured service', () => { + const env = buildContextEnv({ + context: { + elasticsearch: { url: 'http://localhost:9200', auth: { api_key: 'es-key' } }, + kibana: { url: 'http://localhost:5601', auth: { api_key: 'kb-key' } }, + cloud: { url: 'https://api.elastic-cloud.com', auth: { api_key: 'cloud-key' } }, + }, + } as ResolvedConfig) + + assert.equal(env['ELASTIC_ES_URL'], 'http://localhost:9200') + assert.equal(env['ELASTIC_ES_API_KEY'], 'es-key') + assert.equal(env['ELASTIC_KIBANA_URL'], 'http://localhost:5601') + assert.equal(env['ELASTIC_KIBANA_API_KEY'], 'kb-key') + assert.equal(env['ELASTIC_CLOUD_URL'], 'https://api.elastic-cloud.com') + assert.equal(env['ELASTIC_CLOUD_API_KEY'], 'cloud-key') + }) + + it('exports basic auth credentials', () => { + const env = buildContextEnv({ + context: { elasticsearch: { url: 'http://localhost:9200', auth: { username: 'u', password: 'p' } } }, + } as ResolvedConfig) + + assert.equal(env['ELASTIC_ES_USERNAME'], 'u') + assert.equal(env['ELASTIC_ES_PASSWORD'], 'p') + assert.equal(env['ELASTIC_ES_API_KEY'], undefined) + }) + + it('omits credentials when a service has no auth', () => { + const env = buildContextEnv({ + context: { kibana: { url: 'http://localhost:5601' } }, + } as ResolvedConfig) + + assert.deepEqual(Object.keys(env), ['ELASTIC_KIBANA_URL']) + }) + + it('omits ES variables for a via-kibana context', () => { + // There is no Elasticsearch endpoint to hand over: an extension has to go through + // Kibana, so only the Kibana variables are exported. + const env = buildContextEnv({ + context: { + elasticsearch: { via: 'kibana' }, + kibana: { url: 'https://kibana.example', auth: { api_key: 'kb-key' } }, + }, + } as ResolvedConfig) + + assert.equal(env['ELASTIC_ES_URL'], undefined) + assert.equal(env['ELASTIC_ES_API_KEY'], undefined) + assert.equal(env['ELASTIC_KIBANA_URL'], 'https://kibana.example') + assert.equal(env['ELASTIC_KIBANA_API_KEY'], 'kb-key') + }) + + it('returns an empty map for a context with no services', () => { + assert.deepEqual(buildContextEnv({ context: {} } as ResolvedConfig), {}) + }) +}) diff --git a/test/lib/es-client.test.ts b/test/lib/es-client.test.ts index 286d07a6..a29d1663 100644 --- a/test/lib/es-client.test.ts +++ b/test/lib/es-client.test.ts @@ -6,6 +6,7 @@ import { describe, it, afterEach } from 'node:test' import assert from 'node:assert/strict' import { EsClient, EsResponseError, EsConnectionError, getEsClient, _testResetEsClient } from '../../src/lib/es-client.ts' +import { EsConsoleProxyClient } from '../../src/lib/es-console-proxy-client.ts' import { setResolvedConfig } from '../../src/config/store.ts' import type { ResolvedConfig } from '../../src/config/types.ts' import { clientHeaders } from '../../src/lib/meta.ts' @@ -64,8 +65,48 @@ describe('getEsClient', () => { it('strips trailing slash from baseUrl', () => { setResolvedConfig(makeApiKeyConfig('http://localhost:9200/', 'key')) const client = getEsClient() + assert.ok(client instanceof EsClient) assert.equal(client.baseUrl, 'http://localhost:9200') }) + + it('returns a Console proxy transport for a via-kibana context', () => { + setResolvedConfig({ + context: { + elasticsearch: { via: 'kibana' }, + kibana: { url: 'https://kibana.example/', auth: { api_key: 'kb-key' } }, + }, + } as ResolvedConfig) + + const client = getEsClient() + assert.ok(client instanceof EsConsoleProxyClient) + // Requests are addressed to Kibana, since there is no Elasticsearch endpoint. + assert.equal(client.kibanaUrl, 'https://kibana.example') + }) + + it('routes via Kibana without credentials when the kibana block has no auth', () => { + setResolvedConfig({ + context: { elasticsearch: { via: 'kibana' }, kibana: { url: 'https://kibana.example' } }, + } as ResolvedConfig) + + assert.ok(getEsClient() instanceof EsConsoleProxyClient) + }) + + it('throws when via-kibana is set without a kibana block', () => { + setResolvedConfig({ context: { elasticsearch: { via: 'kibana' } } } as ResolvedConfig) + assert.throws(() => getEsClient(), /missing_config.*no kibana block/is) + }) + + it('throws when the elasticsearch block has neither url nor via', () => { + setResolvedConfig({ context: { elasticsearch: {} } } as ResolvedConfig) + assert.throws(() => getEsClient(), /missing_config.*no url/is) + }) + + it('caches the Console proxy transport too', () => { + setResolvedConfig({ + context: { elasticsearch: { via: 'kibana' }, kibana: { url: 'https://kibana.example' } }, + } as ResolvedConfig) + assert.strictEqual(getEsClient(), getEsClient()) + }) }) describe('EsClient.request', () => { diff --git a/test/lib/es-console-proxy-client.test.ts b/test/lib/es-console-proxy-client.test.ts new file mode 100644 index 00000000..ba95a2a3 --- /dev/null +++ b/test/lib/es-console-proxy-client.test.ts @@ -0,0 +1,349 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + CONSOLE_PROXY_PATH, + EsConsoleProxyClient, + consoleProxyUrl, + proxiedEsStatus, +} from '../../src/lib/es-console-proxy-client.ts' +import { EsConnectionError, EsResponseError } from '../../src/lib/es-transport.ts' +import { clientHeaders } from '../../src/lib/meta.ts' + +const KIBANA = 'https://kibana.example' + +type FetchCall = { url: string, init: RequestInit } + +/** Records every request and replies with the responder's result. */ +function recordingClient ( + responder: (url: string) => Response | Promise | Error, + auth?: { api_key: string } | { username: string, password: string }, +): { client: EsConsoleProxyClient, calls: FetchCall[] } { + const calls: FetchCall[] = [] + const client = new EsConsoleProxyClient(KIBANA, auth) + client._testSetFetch((async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: typeof url === 'string' ? url : url.toString(), init: init ?? {} }) + const r = await responder(typeof url === 'string' ? url : url.toString()) + if (r instanceof Error) throw r + return r + }) as unknown as typeof fetch) + return { client, calls } +} + +/** A proxied Elasticsearch reply: outer 200 plus the real ES status in the header. */ +function proxied (body: string, esStatus = 200, contentType = 'application/json'): Response { + return new Response(body, { + status: 200, + headers: { 'content-type': contentType, 'x-console-proxy-status-code': String(esStatus) }, + }) +} + +describe('consoleProxyUrl', () => { + it('encodes the Elasticsearch path into the path parameter', () => { + const url = consoleProxyUrl(KIBANA, '/_cluster/health', 'GET') + assert.equal(url, `${KIBANA}${CONSOLE_PROXY_PATH}?path=%2F_cluster%2Fhealth&method=GET`) + }) + + it('uppercases the method', () => { + assert.match(consoleProxyUrl(KIBANA, '/_search', 'post'), /&method=POST$/) + }) + + it('strips trailing slashes from the Kibana url', () => { + assert.match(consoleProxyUrl(`${KIBANA}///`, '/_search', 'GET'), new RegExp(`^${KIBANA}\\${CONSOLE_PROXY_PATH}`)) + }) + + it('re-encodes an already-encoded path so Kibana\'s single decode restores it', () => { + // An index named `my index` reaches us as `/my%20index/_search`; Kibana decodes the + // query parameter once, which must yield that exact string back. + const url = consoleProxyUrl(KIBANA, '/my%20index/_search', 'POST') + assert.match(url, /path=%2Fmy%2520index%2F_search/) + const decoded = new URL(url).searchParams.get('path') + assert.equal(decoded, '/my%20index/_search') + }) + + it('encodes a literal space as %20 rather than +', () => { + // URLSearchParams would form-encode this to `+`, which a percent-decoder reads as `+`. + const url = consoleProxyUrl(KIBANA, '/my index/_search', 'POST') + assert.match(url, /path=%2Fmy%20index%2F_search/) + assert.ok(!url.includes('+')) + }) + + it('encodes path traversal and fragment characters', () => { + const url = consoleProxyUrl(KIBANA, '/../_nodes?x=1#frag', 'GET') + assert.ok(!url.includes('#'), 'fragment must not terminate the url') + assert.match(url, /path=%2F\.\.%2F_nodes%3Fx%3D1%23frag/) + assert.equal(new URL(url).searchParams.get('path'), '/../_nodes?x=1#frag') + }) + + it('preserves wildcards, which Elasticsearch needs verbatim', () => { + assert.match(consoleProxyUrl(KIBANA, '/logs-*/_search', 'POST'), /path=%2Flogs-\*%2F_search/) + }) + + it('handles an empty path', () => { + assert.equal(consoleProxyUrl(KIBANA, '', 'GET'), `${KIBANA}${CONSOLE_PROXY_PATH}?path=&method=GET`) + }) +}) + +describe('proxiedEsStatus', () => { + it('reads the proxy status header', () => { + assert.equal(proxiedEsStatus(proxied('{}', 404)), 404) + }) + + it('falls back to the outer status when the header is absent', () => { + assert.equal(proxiedEsStatus(new Response('{}', { status: 200 })), 200) + }) + + it('falls back when the header is not a usable number', () => { + for (const value of ['', 'abc', '0', '-1', '2.5']) { + const response = new Response('{}', { + status: 201, + headers: { 'x-console-proxy-status-code': value }, + }) + assert.equal(proxiedEsStatus(response), 201, `header value ${JSON.stringify(value)}`) + } + }) +}) + +describe('EsConsoleProxyClient.request', () => { + it('posts to the Console proxy with the Elasticsearch method in the query', async () => { + const { client, calls } = recordingClient(() => proxied('{"ok":true}')) + await client.request({ method: 'GET', path: '/_cluster/health' }) + + assert.equal(calls.length, 1) + assert.equal(calls[0]!.url, `${KIBANA}${CONSOLE_PROXY_PATH}?path=%2F_cluster%2Fhealth&method=GET`) + // The outer request is always POST; the ES method travels as a query parameter. + assert.equal(calls[0]!.init.method, 'POST') + assert.equal(calls[0]!.init.redirect, 'error') + }) + + it('keeps GET as the Elasticsearch method even with a body', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'GET', path: '/_search', body: { size: 0 } }) + + assert.match(calls[0]!.url, /&method=GET$/) + assert.equal(calls[0]!.init.method, 'POST') + assert.equal(calls[0]!.init.body, '{"size":0}') + }) + + it('folds the Elasticsearch querystring into the path parameter', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ + method: 'POST', + path: '/logs-*/_search', + querystring: { size: 0, terminate_after: 1, ignored: undefined }, + }) + + const path = new URL(calls[0]!.url).searchParams.get('path') + assert.equal(path, '/logs-*/_search?size=0&terminate_after=1') + assert.ok(!path!.includes('ignored'), 'undefined querystring values are skipped') + }) + + it('sends the headers Kibana requires', async () => { + const { client, calls } = recordingClient(() => proxied('{}'), { api_key: 'secret-key' }) + await client.request({ method: 'GET', path: '/' }) + + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], 'ApiKey secret-key') + assert.equal(headers['kbn-xsrf'], 'true') + // Without this header Kibana rejects the route with a misleading 400. + assert.equal(headers['x-elastic-internal-origin'], 'Kibana') + assert.equal(headers['Accept'], 'application/json') + const meta = clientHeaders() + assert.equal(headers['x-elastic-client-meta'], meta['x-elastic-client-meta']) + assert.equal(headers['user-agent'], meta['user-agent']) + }) + + it('encodes basic auth', async () => { + const { client, calls } = recordingClient(() => proxied('{}'), { username: 'u', password: 'p' }) + await client.request({ method: 'GET', path: '/' }) + + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], `Basic ${Buffer.from('u:p').toString('base64')}`) + }) + + it('omits Authorization when no auth is configured', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'GET', path: '/' }) + + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], undefined) + }) + + it('serializes an object body as JSON', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'POST', path: '/_search', body: { query: { match_all: {} } } }) + + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Content-Type'], 'application/json') + assert.equal(calls[0]!.init.body, '{"query":{"match_all":{}}}') + }) + + it('passes a string body through unchanged', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'POST', path: '/_search', body: '{"raw":1}' }) + + assert.equal(calls[0]!.init.body, '{"raw":1}') + assert.equal((calls[0]!.init.headers as Record)['Content-Type'], 'application/json') + }) + + it('sends a bulk body as NDJSON, taking precedence over body', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + const ndjson = '{"index":{}}\n{"a":1}\n' + await client.request({ method: 'POST', path: '/_bulk', body: { ignored: true }, bulkBody: ndjson }) + + assert.equal(calls[0]!.init.body, ndjson) + assert.equal((calls[0]!.init.headers as Record)['Content-Type'], 'application/x-ndjson') + }) + + it('sends no body when none is given', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'GET', path: '/' }) + + assert.equal(calls[0]!.init.body, undefined) + assert.equal((calls[0]!.init.headers as Record)['Content-Type'], undefined) + }) + + it('lets caller headers override the defaults', async () => { + const { client, calls } = recordingClient(() => proxied('{}')) + await client.request({ method: 'GET', path: '/' }, { headers: { 'Accept': 'text/plain' } }) + + assert.equal((calls[0]!.init.headers as Record)['Accept'], 'text/plain') + }) + + it('returns the parsed Elasticsearch body', async () => { + const { client } = recordingClient(() => proxied('{"hits":{"total":{"value":7}}}')) + const result = await client.request<{ hits: { total: { value: number } } }>( + { method: 'POST', path: '/_search' } + ) + + assert.equal(result.hits.total.value, 7) + }) + + it('returns raw text for non-JSON responses, keeping cat APIs usable', async () => { + const { client } = recordingClient(() => proxied('green open logs-1\n', 200, 'text/plain')) + const result = await client.request({ method: 'GET', path: '/_cat/indices' }) + + assert.equal(result, 'green open logs-1\n') + }) + + it('returns an empty object for an empty body', async () => { + const { client } = recordingClient(() => proxied('')) + assert.deepEqual(await client.request({ method: 'GET', path: '/' }), {}) + }) + + it('returns the payload when Kibana mislabels a non-JSON body as JSON', async () => { + const { client } = recordingClient(() => proxied('gateway')) + assert.equal(await client.request({ method: 'GET', path: '/' }), 'gateway') + }) + + it('throws EsResponseError with the real Elasticsearch status from the header', async () => { + const body = { error: { type: 'index_not_found_exception' }, status: 404 } + // The outer response is 200: only the header carries the true status. + const { client } = recordingClient(() => proxied(JSON.stringify(body), 404)) + + await assert.rejects( + () => client.request({ method: 'POST', path: '/missing/_search' }), + (err: unknown) => { + assert.ok(err instanceof EsResponseError) + assert.equal(err.statusCode, 404) + assert.deepEqual(err.body, body) + return true + } + ) + }) + + it('reports HEAD as found or missing using the proxied status', async () => { + const { client: found } = recordingClient(() => proxied('', 200)) + assert.equal(await found.request({ method: 'HEAD', path: '/logs-1' }), true) + + const { client: missing } = recordingClient(() => proxied('', 404)) + assert.equal(await missing.request({ method: 'HEAD', path: '/nope' }), false) + }) + + it('throws for a HEAD failure that is not a 404', async () => { + const { client } = recordingClient(() => proxied('{"error":"boom"}', 500)) + await assert.rejects( + () => client.request({ method: 'HEAD', path: '/logs-1' }), + (err: unknown) => { + assert.ok(err instanceof EsResponseError) + assert.equal(err.statusCode, 500) + return true + } + ) + }) + + it('wraps a transport failure as EsConnectionError', async () => { + const { client } = recordingClient(() => new Error('getaddrinfo ENOTFOUND kibana.example')) + await assert.rejects( + () => client.request({ method: 'GET', path: '/' }), + (err: unknown) => { + assert.ok(err instanceof EsConnectionError) + assert.match(err.message, /ENOTFOUND/) + return true + } + ) + }) + + it('reports a Kibana rejection as a connection failure, not an ES response', async () => { + // Kibana refused the request, so the Elasticsearch call never happened. + const { client } = recordingClient(() => new Response('nope', { status: 502 })) + await assert.rejects( + () => client.request({ method: 'GET', path: '/' }), + (err: unknown) => { + assert.ok(err instanceof EsConnectionError) + assert.match(err.message, /Kibana rejected the Elasticsearch request \(HTTP 502\)/) + assert.match(err.message, /nope/) + return true + } + ) + }) + + it('hints at the Console proxy when Kibana reports the route as unavailable', async () => { + const body = JSON.stringify({ + statusCode: 400, + message: 'uri [/api/console/proxy] with method [post] exists but is not available with the current configuration', + }) + const { client } = recordingClient(() => new Response(body, { status: 400 })) + + await assert.rejects( + () => client.request({ method: 'GET', path: '/' }), + (err: unknown) => { + assert.ok(err instanceof EsConnectionError) + assert.match(err.message, /console\.ui\.enabled/) + return true + } + ) + }) + + it('hints at the kibana credentials on an auth failure', async () => { + for (const status of [401, 403]) { + const { client } = recordingClient(() => new Response('denied', { status })) + await assert.rejects( + () => client.request({ method: 'GET', path: '/' }), + (err: unknown) => { + assert.match((err as Error).message, /kibana credentials/) + return true + } + ) + } + }) + + it('warns once when Kibana is addressed over plaintext HTTP', () => { + const original = process.stderr.write.bind(process.stderr) + const written: string[] = [] + process.stderr.write = ((chunk: string) => { written.push(chunk); return true }) as typeof process.stderr.write + try { + new EsConsoleProxyClient('http://kibana.example') + new EsConsoleProxyClient('http://localhost:5601') + } finally { + process.stderr.write = original + } + + assert.equal(written.length, 1, 'loopback hosts must not warn') + assert.match(written[0]!, /plaintext HTTP/) + }) +}) diff --git a/test/status/checks.test.ts b/test/status/checks.test.ts index 11a541be..83d6aa51 100644 --- a/test/status/checks.test.ts +++ b/test/status/checks.test.ts @@ -47,6 +47,69 @@ describe('checkElasticsearch', () => { assert.equal(calls[0]!.init.method, 'GET') }) + it('probes through the Console proxy for a via-kibana block', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => + new Response(JSON.stringify({ status: 'green', number_of_nodes: 5 }), { + status: 200, + headers: { 'content-type': 'application/json', 'x-console-proxy-status-code': '200' }, + }) + ) + const result = await checkElasticsearch( + { via: 'kibana' }, + fetchFn, + { url: 'https://kibana.example', auth: { api_key: 'kb' } }, + ) + + // The url reported is Kibana's, and `via` records how the cluster was reached. + assert.deepEqual(result, { ok: true, url: 'https://kibana.example', status: 'green', nodes: 5, via: 'kibana' }) + assert.equal(calls.length, 1) + assert.equal(calls[0]!.url, 'https://kibana.example/api/console/proxy?path=%2F_cluster%2Fhealth&method=GET') + const headers = calls[0]!.init.headers as Record + assert.equal(headers['Authorization'], 'ApiKey kb') + assert.equal(headers['kbn-xsrf'], 'true') + assert.equal(headers['x-elastic-internal-origin'], 'Kibana') + assert.equal(calls[0]!.init.method, 'POST') + assert.equal(calls[0]!.init.redirect, 'error') + }) + + it('reports the Elasticsearch status when the proxy forwards a failure', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response('{}', { + status: 200, + headers: { 'x-console-proxy-status-code': '403' }, + }) + ) + const result = await checkElasticsearch({ via: 'kibana' }, fetchFn, { url: 'https://kibana.example' }) + + assert.deepEqual(result, { ok: false, url: 'https://kibana.example', error: 'auth failed (403)', via: 'kibana' }) + }) + + it('reports a network failure reaching Kibana', async () => { + const { fetch: fetchFn } = recordingFetch(() => new Error('ECONNREFUSED')) + const result = await checkElasticsearch({ via: 'kibana' }, fetchFn, { url: 'https://kibana.example' }) + + assert.equal(result.ok, false) + if (!result.ok) assert.match(result.error, /network error: ECONNREFUSED/) + }) + + it('reports a via-kibana block with no kibana block as a failure', async () => { + const { fetch: fetchFn, calls } = recordingFetch(() => new Response('{}', { status: 200 })) + const result = await checkElasticsearch({ via: 'kibana' }, fetchFn) + + assert.deepEqual(result, { ok: false, url: '', error: 'via: kibana requires a kibana block', via: 'kibana' }) + assert.equal(calls.length, 0, 'no request is attempted without a route') + }) + + it('reports an unexpected body from the proxy', async () => { + const { fetch: fetchFn } = recordingFetch(() => + new Response('not json', { status: 200, headers: { 'x-console-proxy-status-code': '200' } }) + ) + const result = await checkElasticsearch({ via: 'kibana' }, fetchFn, { url: 'https://kibana.example' }) + + assert.equal(result.ok, false) + if (!result.ok) assert.equal(result.error, 'unexpected response') + }) + it('strips trailing slashes from the URL', async () => { const { fetch: fetchFn, calls } = recordingFetch(() => new Response(JSON.stringify({ status: 'yellow', number_of_nodes: 1 }), { status: 200 }) diff --git a/test/status/format.test.ts b/test/status/format.test.ts index 5be31658..5c956dfd 100644 --- a/test/status/format.test.ts +++ b/test/status/format.test.ts @@ -43,6 +43,28 @@ describe('formatStatusText', () => { assert.ok(!out.includes('Cloud')) }) + it('marks an Elasticsearch cluster reached through Kibana', () => { + // The url column shows the Kibana endpoint, so the route needs saying explicitly. + const out = formatStatusText({ + context: 'proxied', + services: { + elasticsearch: { ok: true, url: 'https://kibana.example', status: 'green', nodes: 5, via: 'kibana' }, + kibana: { ok: true, url: 'https://kibana.example', status: 'available', version: '9.2.1' }, + }, + }) + assert.ok(out.includes('green (5 nodes) via Kibana'), `got ${out}`) + }) + + it('marks the route on a failed proxied check too', () => { + const out = formatStatusText({ + context: 'proxied', + services: { + elasticsearch: { ok: false, url: 'https://kibana.example', error: 'auth failed (403)', via: 'kibana' }, + }, + }) + assert.ok(out.includes('auth failed (403) via Kibana'), `got ${out}`) + }) + it('pluralises the node count correctly', () => { const one = formatStatusText({ context: 'c',