diff --git a/.env.example b/.env.example index 09cc7f4e..d0adc374 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,8 @@ DATABASE_PASSWORD=change-me # STACKABLE_COCKPIT_TRINO_AUTH_USERNAME=service-account # STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD=secret # STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION=false # Disable impersonation: run all queries as the auth user (requires basic auth). Default: true -# STACKABLE_COCKPIT_TRINO_TLS_INSECURE=false # Set to "true" to skip TLS certificate verification +# STACKABLE_COCKPIT_TRINO_TLS_INSECURE=false # Set to "true" to skip TLS certificate verification. Requires STACKABLE_COCKPIT_TRINO_URL +# STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE=false # Set to "true" to let users skip TLS certificate verification for connections entered in the UI. Requires STACKABLE_COCKPIT_TRINO_URL to be unset # STACKABLE_COCKPIT_QUERY_TTL=1800 # Seconds before completed, unaccessed tab queries are evicted (default: 1800) # Feature flags diff --git a/deploy/helm/cockpit/README.md b/deploy/helm/cockpit/README.md index 96c53297..2b8dd4e3 100644 --- a/deploy/helm/cockpit/README.md +++ b/deploy/helm/cockpit/README.md @@ -168,6 +168,7 @@ At startup, the cockpit fetches the OIDC discovery document once and logs the ca ### Trino Connection Parameters Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app connection form is hidden. +Otherwise users enter their own connection, and `trino.userConnection.tls.allowInsecure` lets them skip TLS certificate verification. | Parameter | Description | Default | | --- | --- | --- | @@ -179,7 +180,8 @@ Optional pre-configured Trino endpoint. When `trino.url` is set, the in-app conn | `trino.auth.username` | Username for basic auth. | `""` | | `trino.auth.password.secretKeyRef.name` | Name of the Secret holding the Trino password. Required when `type=basic`. | `""` | | `trino.auth.password.secretKeyRef.key` | Key inside the Secret. | `trino-auth-password` | -| `trino.tls.insecure` | Skip TLS certificate verification. Logs a warning in production; for a private CA, use `tls.caCerts` or `tls.secretClasses` instead. | `false` | +| `trino.tls.insecure` | Skip TLS certificate verification for `trino.url`. Requires `trino.url`. Logs a warning in production; for a private CA, use `tls.caCerts` or `tls.secretClasses` instead. | `false` | +| `trino.userConnection.tls.allowInsecure` | Let users skip TLS certificate verification for the connection they enter in the UI. Requires an empty `trino.url`. | `false` | | `trino.queryTtl` | Query result TTL in seconds. | `1800` (application default) | ### Security Parameters diff --git a/deploy/helm/cockpit/templates/deployment.yaml b/deploy/helm/cockpit/templates/deployment.yaml index 07628199..0f3ec828 100644 --- a/deploy/helm/cockpit/templates/deployment.yaml +++ b/deploy/helm/cockpit/templates/deployment.yaml @@ -276,9 +276,19 @@ spec: - name: STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION value: {{ .Values.trino.userImpersonation.enabled | quote }} {{- if .Values.trino.tls.insecure }} + {{- if not .Values.trino.url }} + {{- fail "trino.tls.insecure only applies to the pre-configured connection and requires trino.url" }} + {{- end }} - name: STACKABLE_COCKPIT_TRINO_TLS_INSECURE value: "true" {{- end }} + {{- if .Values.trino.userConnection.tls.allowInsecure }} + {{- if .Values.trino.url }} + {{- fail "trino.userConnection.tls.allowInsecure only applies to connections users enter and requires an empty trino.url" }} + {{- end }} + - name: STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE + value: "true" + {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/deploy/helm/cockpit/values.yaml b/deploy/helm/cockpit/values.yaml index db5398e5..26186c3c 100644 --- a/deploy/helm/cockpit/values.yaml +++ b/deploy/helm/cockpit/values.yaml @@ -311,8 +311,14 @@ trino: name: "" key: trino-auth-password tls: - # Skip TLS certificate verification. A private CA goes into tls.caCerts or - # tls.secretClasses instead. + # Skip TLS certificate verification for the connection to trino.url. Requires trino.url. + # A private CA goes into tls.caCerts or tls.secretClasses instead. insecure: false + # Connections users enter in the UI. Only used when trino.url is empty. + userConnection: + tls: + # Let users skip TLS certificate verification for their connection. Requires an + # empty trino.url. + allowInsecure: false # Query result TTL in seconds. Default: 1800 (application default). # queryTtl: 1800 diff --git a/docs/modules/cockpit/pages/modules/trino.adoc b/docs/modules/cockpit/pages/modules/trino.adoc index d1e728c5..40b6b72c 100644 --- a/docs/modules/cockpit/pages/modules/trino.adoc +++ b/docs/modules/cockpit/pages/modules/trino.adoc @@ -31,6 +31,7 @@ For a Trino cluster with a private CA, see xref:installation/helm.adoc#_ca_certi Without `trino.url`, each user enters a connection in the UI. These connections are kept in memory and are lost when Cockpit restarts. +With `trino.userConnection.tls.allowInsecure: true`, users can skip certificate verification for their connection. == User identity diff --git a/docs/modules/cockpit/pages/reference/environment-variables.adoc b/docs/modules/cockpit/pages/reference/environment-variables.adoc index d6a9a365..d0d447cf 100644 --- a/docs/modules/cockpit/pages/reference/environment-variables.adoc +++ b/docs/modules/cockpit/pages/reference/environment-variables.adoc @@ -140,7 +140,11 @@ Cockpit does not start if a value is invalid, and the error names every invalid |`STACKABLE_COCKPIT_TRINO_TLS_INSECURE` |`false` -|Skip TLS certificate verification. +|Skip TLS certificate verification for `STACKABLE_COCKPIT_TRINO_URL`. Requires `STACKABLE_COCKPIT_TRINO_URL`. + +|`STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE` +|`false` +|Let users skip TLS certificate verification for the connection they enter in the UI. Requires `STACKABLE_COCKPIT_TRINO_URL` to be unset. |`STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION` |`true` diff --git a/e2e/trino/connection-form.spec.ts b/e2e/trino/connection-form.spec.ts index cb447aab..d0598ff2 100644 --- a/e2e/trino/connection-form.spec.ts +++ b/e2e/trino/connection-form.spec.ts @@ -37,7 +37,8 @@ function interceptSaveSuccess(page: import('@playwright/test').Page) { connectionUrl: MOCK_TRINO_URL, authType: 'none', authUsername: '', - authPassword: '' + authPassword: '', + tlsInsecure: false }, message: { type: 'success' } }; @@ -50,24 +51,27 @@ function interceptSaveSuccess(page: import('@playwright/test').Page) { } /** - * Intercept SvelteKit responses to override the trinoConfigured boolean. + * Intercept SvelteKit responses to override boolean page data fields such as + * trinoConfigured. * * SvelteKit embeds page data in two formats: * - SSR HTML uses devalue.uneval with unquoted keys: `trinoConfigured:true` * - __data.json uses devalue.stringify with indexed arrays where booleans * are stored at positions referenced by the shape object at index 0 */ -function interceptTrinoConfigured(page: import('@playwright/test').Page, value: boolean) { +function interceptPageData( + page: import('@playwright/test').Page, + overrides: Record +) { page.route('**/trino', async (route, request) => { if (request.resourceType() !== 'document') return route.continue(); const response = await route.fetch(); let html = await response.text(); - // devalue.uneval uses literal booleans; minified builds may use !0 / !1 - html = html.replaceAll(`trinoConfigured:${!value}`, `trinoConfigured:${value}`); - html = html.replaceAll( - `trinoConfigured:${!value ? '!0' : '!1'}`, - `trinoConfigured:${value ? '!0' : '!1'}` - ); + for (const [key, value] of Object.entries(overrides)) { + // devalue.uneval uses literal booleans; minified builds may use !0 / !1 + html = html.replaceAll(`${key}:${!value}`, `${key}:${value}`); + html = html.replaceAll(`${key}:${!value ? '!0' : '!1'}`, `${key}:${value ? '!0' : '!1'}`); + } await route.fulfill({ response, body: html }); }); @@ -77,8 +81,9 @@ function interceptTrinoConfigured(page: import('@playwright/test').Page, value: for (const node of json.nodes ?? []) { if (node?.type === 'data' && Array.isArray(node.data)) { const shape = node.data[0]; - if (typeof shape === 'object' && shape !== null && 'trinoConfigured' in shape) { - node.data[shape.trinoConfigured as number] = value; + if (typeof shape !== 'object' || shape === null) continue; + for (const [key, value] of Object.entries(overrides)) { + if (key in shape) node.data[shape[key] as number] = value; } } } @@ -86,6 +91,51 @@ function interceptTrinoConfigured(page: import('@playwright/test').Page, value: }); } +/** Capture the fields posted by the next `?/save` request and abort it. */ +function captureSave(page: import('@playwright/test').Page): Promise { + return new Promise((resolve) => { + page.route( + (url) => url.pathname === '/trino' && url.search === '?/save', + async (route, request) => { + resolve(new URLSearchParams(request.postData() ?? '')); + await route.abort(); + } + ); + }); +} + +// --------------------------------------------------------------------------- +// 0. Per-user connections that may skip TLS verification +// --------------------------------------------------------------------------- +test.describe('Connection form (insecure TLS allowed)', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.removeItem('trino_url'); + localStorage.removeItem('trino_tls_insecure'); + }); + interceptPageData(page, { trinoConfigured: false, allowInsecureTls: true }); + }); + + test('skipping TLS verification is posted, persisted and shown in the summary', async ({ + page + }) => { + await page.goto('/trino'); + await waitForHydration(page); + + await page.getByLabel('URL').fill(MOCK_TRINO_URL); + await page.getByLabel('Skip TLS certificate verification').check(); + await expect(page.getByText('TLS unverified')).toBeVisible(); + + const posted = captureSave(page); + await page.getByRole('button', { name: 'Save' }).click(); + + expect((await posted).get('tlsInsecure')).toBe('on'); + expect(await page.evaluate(() => localStorage.getItem('trino_tls_insecure'))).toBe('true'); + }); +}); + // --------------------------------------------------------------------------- // 1. When Trino IS env-configured, the form must be hidden // --------------------------------------------------------------------------- @@ -116,7 +166,7 @@ test.describe('Connection form (manual mode)', () => { localStorage.removeItem('trino_password'); }); - interceptTrinoConfigured(page, false); + interceptPageData(page, { trinoConfigured: false }); }); test('connection form is visible', async ({ page }) => { @@ -171,6 +221,14 @@ test.describe('Connection form (manual mode)', () => { await expect(page.getByLabel('Password')).not.toBeVisible(); }); + test('TLS verification option is hidden unless allowed', async ({ page }) => { + await page.goto('/trino'); + await waitForHydration(page); + + await expect(page.getByLabel('URL')).toBeVisible(); + await expect(page.getByLabel('Skip TLS certificate verification')).not.toBeVisible(); + }); + test('submitting without a URL shows a validation error', async ({ page }) => { await page.goto('/trino'); await waitForHydration(page); diff --git a/messages/de.json b/messages/de.json index c8b51b1e..c745f6c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -99,6 +99,10 @@ "trino_connection_test_failed": "Verbindung zu Trino fehlgeschlagen – bitte URL und Zugangsdaten prüfen.", "trino_connection_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.", "trino_connection_env_managed": "Die Verbindung wird über Umgebungsvariablen verwaltet.", + "trino_connection_tls_insecure": "TLS-Zertifikatsprüfung überspringen", + "trino_connection_tls_insecure_hint": "Unsicher: Das Zertifikat des Trino-Servers wird nicht geprüft. Nur für Testumgebungen verwenden.", + "trino_connection_tls_insecure_not_allowed": "Das Überspringen der TLS-Zertifikatsprüfung ist auf diesem Server nicht erlaubt.", + "trino_connection_tls_unverified": "TLS ungeprüft", "trino_user_missing": "Ihrem Konto ist kein Trino-Benutzername zugeordnet. Melden Sie sich ab und erneut an; besteht das Problem weiterhin, wenden Sie sich an Ihre Administration.", "trino_cancel_query": "Abbrechen", "trino_state_submitting": "Wird eingereicht", diff --git a/messages/en.json b/messages/en.json index 8d29f704..63b7f6b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -99,6 +99,10 @@ "trino_connection_test_failed": "Could not connect to Trino — check the URL and credentials.", "trino_connection_save_failed": "Could not save the connection. Please try again.", "trino_connection_env_managed": "The connection is managed via environment variables.", + "trino_connection_tls_insecure": "Skip TLS certificate verification", + "trino_connection_tls_insecure_hint": "Insecure: the Trino server certificate is not checked. Only use this for test setups.", + "trino_connection_tls_insecure_not_allowed": "Skipping TLS certificate verification is not allowed on this server.", + "trino_connection_tls_unverified": "TLS unverified", "trino_user_missing": "Your account has no Trino username. Sign out and sign in again; if the problem persists, contact your administrator.", "trino_cancel_query": "Cancel", "trino_state_submitting": "Submitting", diff --git a/src/lib/server/trino/config.test.ts b/src/lib/server/trino/config.test.ts index 08d45f7c..1f65c54d 100644 --- a/src/lib/server/trino/config.test.ts +++ b/src/lib/server/trino/config.test.ts @@ -15,6 +15,7 @@ describe('parseTrinoConfig', () => { authUsername: undefined, authPassword: undefined, tlsInsecure: false, + userConnectionAllowInsecureTls: false, userImpersonation: true, queryTtlSeconds: 1800 }); @@ -42,4 +43,34 @@ describe('parseTrinoConfig', () => { ); expect(parseTrinoConfig(basic).authType).toBe('basic'); }); + + it('rejects an invalid STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE', () => { + expect(() => + parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'yes' }) + ).toThrow('STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE:'); + }); + + it('applies each TLS setting to its own kind of connection', () => { + expect( + parseTrinoConfig({ + STACKABLE_COCKPIT_TRINO_URL: TRINO_URL, + STACKABLE_COCKPIT_TRINO_TLS_INSECURE: 'true' + }) + ).toMatchObject({ tlsInsecure: true, userConnectionAllowInsecureTls: false }); + expect( + parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'true' }) + ).toMatchObject({ tlsInsecure: false, userConnectionAllowInsecureTls: true }); + }); + + it('rejects a TLS setting that has no effect', () => { + expect(() => parseTrinoConfig({ STACKABLE_COCKPIT_TRINO_TLS_INSECURE: 'true' })).toThrow( + /STACKABLE_COCKPIT_TRINO_TLS_INSECURE: only applies to the pre-configured connection/ + ); + expect(() => + parseTrinoConfig({ + STACKABLE_COCKPIT_TRINO_URL: TRINO_URL, + STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: 'true' + }) + ).toThrow(/STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: only applies to/); + }); }); diff --git a/src/lib/server/trino/config.ts b/src/lib/server/trino/config.ts index 8b8da79e..5c250031 100644 --- a/src/lib/server/trino/config.ts +++ b/src/lib/server/trino/config.ts @@ -13,6 +13,7 @@ const schema = z STACKABLE_COCKPIT_TRINO_AUTH_USERNAME: envString, STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD: envString, STACKABLE_COCKPIT_TRINO_TLS_INSECURE: envBoolean(false), + STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE: envBoolean(false), STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION: envBoolean(true), STACKABLE_COCKPIT_QUERY_TTL: envInteger(1800) }) @@ -27,6 +28,26 @@ const schema = z message: 'is "basic" but STACKABLE_COCKPIT_TRINO_AUTH_USERNAME or STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD is missing' } + ) + // Each TLS setting applies to one kind of connection only; reject a setting that would + // silently have no effect. + .refine( + (config) => !config.STACKABLE_COCKPIT_TRINO_TLS_INSECURE || config.STACKABLE_COCKPIT_TRINO_URL, + { + path: ['STACKABLE_COCKPIT_TRINO_TLS_INSECURE'], + message: + 'only applies to the pre-configured connection, but STACKABLE_COCKPIT_TRINO_URL is not set' + } + ) + .refine( + (config) => + !config.STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE || + !config.STACKABLE_COCKPIT_TRINO_URL, + { + path: ['STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE'], + message: + 'only applies to connections users enter, but STACKABLE_COCKPIT_TRINO_URL is set and hides the connection form' + } ); export function parseTrinoConfig(env: Record) { @@ -38,6 +59,8 @@ export function parseTrinoConfig(env: Record) { authUsername: config.STACKABLE_COCKPIT_TRINO_AUTH_USERNAME, authPassword: config.STACKABLE_COCKPIT_TRINO_AUTH_PASSWORD, tlsInsecure: config.STACKABLE_COCKPIT_TRINO_TLS_INSECURE, + userConnectionAllowInsecureTls: + config.STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE, userImpersonation: config.STACKABLE_COCKPIT_TRINO_USER_IMPERSONATION, queryTtlSeconds: config.STACKABLE_COCKPIT_QUERY_TTL }; @@ -45,5 +68,7 @@ export function parseTrinoConfig(env: Record) { /** Trino settings from the environment. The connection fields are only used * when `STACKABLE_COCKPIT_TRINO_URL` is set; otherwise users configure their - * own connection. Impersonation also applies to those per-user connections. */ + * own connection. Impersonation also applies to those per-user connections. + * `tlsInsecure` skips certificate verification for the pre-configured connection; + * `userConnectionAllowInsecureTls` lets users skip it for their own. */ export const trinoConfig = parseTrinoConfig(env); diff --git a/src/lib/server/trino/user-clients.test.ts b/src/lib/server/trino/user-clients.test.ts index a48b00e3..06484320 100644 --- a/src/lib/server/trino/user-clients.test.ts +++ b/src/lib/server/trino/user-clients.test.ts @@ -1,28 +1,43 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { TrinoClientMock } = vi.hoisted(() => ({ +const { TrinoClientMock, AgentMock, trinoConfigMock, logMock } = vi.hoisted(() => ({ TrinoClientMock: vi.fn(function (this: Record, options: unknown) { this.options = options; - }) + }), + AgentMock: vi.fn(function (this: Record, options: unknown) { + this.options = options; + }), + trinoConfigMock: { userConnectionAllowInsecureTls: false }, + logMock: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() } })); vi.mock('$lib/server/logging', () => ({ - logger: { child: () => ({ info: vi.fn(), warn: vi.fn(), debug: vi.fn() }) } + logger: { child: () => logMock } })); vi.mock('./client.js', () => ({ TrinoClient: TrinoClientMock, buildBasicAuthHeader: (user: string, pass: string) => `Basic ${user}:${pass}`, trinoUserImpersonation: true })); +vi.mock('./config.js', () => ({ trinoConfig: trinoConfigMock })); +vi.mock('undici', () => ({ Agent: AgentMock })); type UserClients = typeof import('./user-clients.js'); let uc: UserClients; -const BASE = { url: 'http://trino:8080', authType: 'none', username: '', password: '' } as const; +const BASE = { + url: 'http://trino:8080', + authType: 'none', + username: '', + password: '', + tlsInsecure: false +} as const; +const BASIC = { ...BASE, authType: 'basic', username: 'alice', password: 'secret' } as const; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); + trinoConfigMock.userConnectionAllowInsecureTls = false; uc = await import('./user-clients.js'); }); @@ -33,6 +48,7 @@ describe('buildUserTrinoClient', () => { expect(TrinoClientMock).toHaveBeenCalledWith({ serverUrl: 'http://trino:8080', authorization: undefined, + dispatcher: undefined, impersonate: true }); }); @@ -56,6 +72,25 @@ describe('buildUserTrinoClient', () => { ); }); + it('rejects skipping TLS verification unless allowed', () => { + expect(() => uc.buildUserTrinoClient({ ...BASE, tlsInsecure: true })).toThrow(/not allowed/); + expect(TrinoClientMock).not.toHaveBeenCalled(); + }); + + it('skips TLS verification when allowed and requested', () => { + trinoConfigMock.userConnectionAllowInsecureTls = true; + uc.buildUserTrinoClient({ ...BASE, tlsInsecure: true }); + + uc.buildUserTrinoClient({ ...BASIC, tlsInsecure: true }); + + expect(AgentMock).toHaveBeenCalledTimes(1); + expect(AgentMock).toHaveBeenCalledWith({ connect: { rejectUnauthorized: false } }); + const dispatchers = TrinoClientMock.mock.calls.map( + ([options]) => (options as { dispatcher?: unknown }).dispatcher + ); + expect(dispatchers).toEqual([AgentMock.mock.instances[0], AgentMock.mock.instances[0]]); + }); + it('does not store the client', () => { uc.buildUserTrinoClient(BASE); @@ -84,4 +119,16 @@ describe('per-user connections', () => { expect(uc.getUserTrinoClient('u1')).toBe(TrinoClientMock.mock.instances[1]); expect(uc.getUserTrinoUrl('u1')).toBe('http://other:8443'); }); + + it('logs a warning when the connection skips TLS verification', () => { + trinoConfigMock.userConnectionAllowInsecureTls = true; + uc.createUserTrinoClient('u1', BASE); + expect(logMock.warn).not.toHaveBeenCalled(); + + uc.createUserTrinoClient('u1', { ...BASE, tlsInsecure: true }); + expect(logMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ user_id: 'u1', tls_insecure: true }), + expect.stringContaining('without TLS certificate verification') + ); + }); }); diff --git a/src/lib/server/trino/user-clients.ts b/src/lib/server/trino/user-clients.ts index 90a538f5..8dc51015 100644 --- a/src/lib/server/trino/user-clients.ts +++ b/src/lib/server/trino/user-clients.ts @@ -1,5 +1,7 @@ +import { Agent } from 'undici'; import { logger } from '$lib/server/logging'; import { TrinoClient, buildBasicAuthHeader, trinoUserImpersonation } from './client.js'; +import { trinoConfig } from './config.js'; const log = logger.child({ module: 'trino-user-clients' }); @@ -8,6 +10,8 @@ export interface UserConnectionConfig { authType: 'none' | 'basic'; username: string; password: string; + /** Skip TLS certificate verification. Only allowed with `STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE`. */ + tlsInsecure: boolean; } interface UserEntry { @@ -17,8 +21,23 @@ interface UserEntry { const userClients = new Map(); +// Shared by all per-user connections that skip verification, so they reuse one set of +// connection pools. Verifying connections use the default dispatcher, which trusts the +// private CAs from STACKABLE_COCKPIT_TLS_CA_CERTS via the process-wide trust store. +let insecureDispatcher: Agent | undefined; + +function dispatcherFor(tlsInsecure: boolean): Agent | undefined { + if (!tlsInsecure) return undefined; + insecureDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecureDispatcher; +} + /** Build a per-user Trino client from a connection config without storing it. */ export function buildUserTrinoClient(config: UserConnectionConfig): TrinoClient { + if (config.tlsInsecure && !trinoConfig.userConnectionAllowInsecureTls) { + throw new Error('Skipping TLS certificate verification is not allowed'); + } + const authorization = config.authType === 'basic' && config.username && config.password ? buildBasicAuthHeader(config.username, config.password) @@ -27,6 +46,7 @@ export function buildUserTrinoClient(config: UserConnectionConfig): TrinoClient return new TrinoClient({ serverUrl: config.url, authorization, + dispatcher: dispatcherFor(config.tlsInsecure), impersonate: trinoUserImpersonation }); } @@ -35,7 +55,16 @@ export function buildUserTrinoClient(config: UserConnectionConfig): TrinoClient export function createUserTrinoClient(userId: string, config: UserConnectionConfig): void { const client = buildUserTrinoClient(config); userClients.set(userId, { client, url: config.url }); - log.info({ user_id: userId, trino_url: config.url }, 'user connection created'); + const context = { + user_id: userId, + trino_url: config.url, + tls_insecure: config.tlsInsecure + }; + if (config.tlsInsecure) { + log.warn(context, 'user connection created without TLS certificate verification'); + } else { + log.info(context, 'user connection created'); + } } /** Returns the per-user TrinoClient, or null if none has been configured. */ diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts index a9649095..ef78197f 100644 --- a/src/routes/(app)/trino/+page.server.ts +++ b/src/routes/(app)/trino/+page.server.ts @@ -1,5 +1,5 @@ import { fail } from '@sveltejs/kit'; -import { superValidate, message, type Infer, type InferIn } from 'sveltekit-superforms'; +import { superValidate, message, setError, type Infer, type InferIn } from 'sveltekit-superforms'; import { zod4 as zod } from 'sveltekit-superforms/adapters'; import { findTrinoUser, getUserId } from '$lib/server/auth-utils.js'; import { getAllQuerySummaries, resetTabQueries } from '$lib/server/trino/queries.js'; @@ -9,6 +9,7 @@ import { CONN_TEST_TIMEOUT_MS } from '$lib/server/trino/client.js'; import { completionEnabled } from '$lib/server/feature-flags.js'; +import { trinoConfig } from '$lib/server/trino/config.js'; import { buildUserTrinoClient, createUserTrinoClient, @@ -29,7 +30,15 @@ export const load: PageServerLoad = async ({ locals }) => { const userId = getUserId(locals); const activeQueries = getAllQuerySummaries(userId); const userClientExists = getUserTrinoClient(userId) !== null; - return { connectionForm, activeQueries, trinoConfigured, userClientExists, completionEnabled }; + return { + connectionForm, + activeQueries, + trinoConfigured, + userClientExists, + completionEnabled, + // STACKABLE_COCKPIT_TRINO_USER_CONNECTION_TLS_ALLOW_INSECURE lets users skip verification. + allowInsecureTls: !trinoConfigured && trinoConfig.userConnectionAllowInsecureTls + }; }; export const actions: Actions = { @@ -55,6 +64,11 @@ export const actions: Actions = { return fail(400, { form }); } + if (form.data.tlsInsecure && !trinoConfig.userConnectionAllowInsecureTls) { + log.info('connection save rejected, skipping TLS verification is not allowed'); + return setError(form, 'tlsInsecure', m.trino_connection_tls_insecure_not_allowed()); + } + const userId = getUserId(locals); const user = findTrinoUser(locals); if (!user) { @@ -64,12 +78,13 @@ export const actions: Actions = { { status: 500 } ); } - const { connectionUrl, authType, authUsername, authPassword } = form.data; + const { connectionUrl, authType, authUsername, authPassword, tlsInsecure } = form.data; const config = { url: connectionUrl, authType, username: authUsername, - password: authPassword + password: authPassword, + tlsInsecure }; // Verify connectivity before storing the client or cancelling running queries. diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index e5214181..3c4f29ef 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -142,6 +142,8 @@ $connectionForm.connectionUrl = getStoredValue('trino_url', ''); $connectionForm.authType = getStoredValue('trino_auth_type', 'none') as 'none' | 'basic'; $connectionForm.authUsername = getStoredValue('trino_username', ''); + $connectionForm.tlsInsecure = + data.allowInsecureTls && getStoredValue('trino_tls_insecure', 'false') === 'true'; } defaultCatalog = getStoredValue('trino_default_catalog', ''); defaultSchema = getStoredValue('trino_default_schema', ''); @@ -183,6 +185,7 @@ localStorage.setItem('trino_url', $connectionForm.connectionUrl); localStorage.setItem('trino_auth_type', $connectionForm.authType); localStorage.setItem('trino_username', $connectionForm.authUsername); + localStorage.setItem('trino_tls_insecure', String($connectionForm.tlsInsecure)); }); // Persist settings. @@ -228,7 +231,9 @@ const url = $connectionForm.connectionUrl; const host = url ? url.replace(/^https?:\/\//, '').replace(/\/$/, '') : '-'; const auth = $connectionForm.authType === 'basic' ? m.trino_auth_basic() : m.trino_auth_none(); - return `${host} \u00b7 ${auth}`; + const parts = [host, auth]; + if ($connectionForm.tlsInsecure) parts.push(m.trino_connection_tls_unverified()); + return parts.join(' \u00b7 '); }); const stateLabel = $derived.by(() => { @@ -572,6 +577,30 @@ {/if} + {#if data.allowInsecureTls} +
+
+ +

+ {m.trino_connection_tls_insecure_hint()} +

+ {#if $connectionErrors.tlsInsecure} +

{$connectionErrors.tlsInsecure}

+ {/if} +
+ +
+ {/if} +