From 82b5f37336fc0edf7b1dede109537a54367a99f6 Mon Sep 17 00:00:00 2001 From: Jeff MAURY Date: Tue, 10 Sep 2024 12:09:03 +0200 Subject: [PATCH] fix: configure certificates for secure proxy (#8704) Fixes #7683 Signed-off-by: Jeff MAURY --- .../main/src/plugin/extension-loader.spec.ts | 4 ++++ packages/main/src/plugin/extension-loader.ts | 4 +++- .../extensions-catalog/extensions-catalog.ts | 4 ++++ packages/main/src/plugin/index.ts | 7 +++--- .../main/src/plugin/proxy-resolver.spec.ts | 21 +++++++++------- packages/main/src/plugin/proxy-resolver.ts | 24 ++++++++++--------- packages/main/src/plugin/proxy.spec.ts | 9 +++++-- packages/main/src/plugin/proxy.ts | 12 ++++++++-- 8 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index cbd366c70..bf12c0306 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -26,6 +26,7 @@ import type * as containerDesktopAPI from '@podman-desktop/api'; import { app } from 'electron'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { Certificates } from '/@/plugin/certificates.js'; import type { ContributionManager } from '/@/plugin/contribution-manager.js'; import type { KubeGeneratorRegistry } from '/@/plugin/kubernetes/kube-generator-registry.js'; import { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; @@ -241,6 +242,8 @@ const dialogRegistry: DialogRegistry = { saveDialog: saveDialogMock, } as unknown as DialogRegistry; +const certificates: Certificates = {} as unknown as Certificates; + vi.mock('electron', () => { return { app: { @@ -292,6 +295,7 @@ beforeAll(() => { colorRegistry, dialogRegistry, safeStorageRegistry, + certificates, ); }); diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index 6dc6ecc21..9cd5958cd 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -41,6 +41,7 @@ import type { ApiSenderType } from './api.js'; import type { PodInfo } from './api/pod-info.js'; import type { AuthenticationImpl } from './authentication.js'; import { CancellationTokenSource } from './cancellation-token.js'; +import type { Certificates } from './certificates.js'; import type { CliToolRegistry } from './cli-tool-registry.js'; import type { CommandRegistry } from './command-registry.js'; import type { ConfigurationRegistry, IConfigurationNode } from './configuration-registry.js'; @@ -192,6 +193,7 @@ export class ExtensionLoader { private colorRegistry: ColorRegistry, private dialogRegistry: DialogRegistry, private safeStorageRegistry: SafeStorageRegistry, + private certificates: Certificates, ) { this.pluginsDirectory = directories.getPluginsDirectory(); this.pluginsScanDirectory = directories.getPluginsScanDirectory(); @@ -289,7 +291,7 @@ export class ExtensionLoader { fs.mkdirSync(this.pluginsScanDirectory, { recursive: true }); } - this.moduleLoader.addOverride(createHttpPatchedModules(this.proxy)); // add patched http and https + this.moduleLoader.addOverride(createHttpPatchedModules(this.proxy, this.certificates)); // add patched http and https this.moduleLoader.addOverride({ '@podman-desktop/api': ext => ext.api }); // add podman desktop API this.moduleLoader.overrideRequire(); diff --git a/packages/main/src/plugin/extensions-catalog/extensions-catalog.ts b/packages/main/src/plugin/extensions-catalog/extensions-catalog.ts index 12a6257f8..0ade221fa 100644 --- a/packages/main/src/plugin/extensions-catalog/extensions-catalog.ts +++ b/packages/main/src/plugin/extensions-catalog/extensions-catalog.ts @@ -225,6 +225,10 @@ export class ExtensionsCatalog { maxFreeSockets: 256, scheduling: 'lifo', proxy: httpsProxyUrl, + ca: this.certificates.getAllCertificates(), + proxyRequestOptions: { + ca: this.certificates.getAllCertificates(), + }, }); } catch (error) { throw new Error(`Failed to create https proxy agent from ${httpsProxyUrl}: ${error}`); diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 6fa66db4a..49455f48b 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -456,7 +456,9 @@ export class PluginSystem { const colorRegistry = new ColorRegistry(apiSender, configurationRegistry); colorRegistry.init(); - const proxy = new Proxy(configurationRegistry); + const certificates = new Certificates(); + await certificates.init(); + const proxy = new Proxy(configurationRegistry, certificates); await proxy.init(); const telemetry = new Telemetry(configurationRegistry); @@ -471,8 +473,6 @@ export class PluginSystem { const notificationRegistry = new NotificationRegistry(apiSender, taskManager); const menuRegistry = new MenuRegistry(commandRegistry); const kubeGeneratorRegistry = new KubeGeneratorRegistry(); - const certificates = new Certificates(); - await certificates.init(); const imageRegistry = new ImageRegistry(apiSender, telemetry, certificates, proxy); const viewRegistry = new ViewRegistry(); const context = new Context(apiSender); @@ -670,6 +670,7 @@ export class PluginSystem { colorRegistry, dialogRegistry, safeStorageRegistry, + certificates, ); await this.extensionLoader.init(); diff --git a/packages/main/src/plugin/proxy-resolver.spec.ts b/packages/main/src/plugin/proxy-resolver.spec.ts index 9b4ff3fd7..7a4d84aa3 100644 --- a/packages/main/src/plugin/proxy-resolver.spec.ts +++ b/packages/main/src/plugin/proxy-resolver.spec.ts @@ -27,6 +27,7 @@ import type { HttpsProxyAgentOptions } from 'hpagent'; import { HttpsProxyAgent } from 'hpagent'; import { beforeEach, expect, test, vi } from 'vitest'; +import type { Certificates } from './certificates.js'; import type { Proxy } from './proxy.js'; import * as ProxyResolver from './proxy-resolver.js'; @@ -83,40 +84,44 @@ const Http = 'http'; const HttpProxyUrl = `${Http}://proxy.url`; const HttpsProxyUrl = 'https://proxy.url'; +const certificates: Certificates = { + getAllCertificates: vi.fn(), +} as unknown as Certificates; + beforeEach(() => { vi.clearAllMocks(); }); test('getOptions return options w/o agent if proxy not enabled', () => { const proxy = createProxy(false); - const options = ProxyResolver.getOptions(proxy, false); + const options = ProxyResolver.getOptions(proxy, false, certificates); expect(options.agent).toBeUndefined(); }); test('getOptions return options w/ agent for https proxy', () => { const proxy = createProxy(true, HttpsProxyUrl, HttpProxyUrl); - const options = ProxyResolver.getOptions(proxy, true); + const options = ProxyResolver.getOptions(proxy, true, certificates); expect(options.agent).not.toBeUndefined(); expect((options.agent as any).https).toBeTruthy(); }); test('getOptions return options w/ https.Agent for https proxy', () => { const proxy = createProxy(true, undefined, HttpProxyUrl); - const options = ProxyResolver.getOptions(proxy, false); + const options = ProxyResolver.getOptions(proxy, false, certificates); expect(options.agent).not.toBeUndefined(); expect((options.agent as any).https).toBeFalsy(); }); test('patched http get calls original with the original parameters when proxy is not enabled', () => { const proxy = createProxy(false, HttpsProxyUrl, HttpProxyUrl); - const patched = ProxyResolver.createHttpPatchedModules(proxy); + const patched = ProxyResolver.createHttpPatchedModules(proxy, certificates); patched.http.get(`${Http}://site.url`); expect(get).toBeCalledWith(`${Http}://site.url`); }); test('patched http get calls original method with the original parameters when proxy is enabled and socketPath is requested', () => { const proxy = createProxy(true, HttpsProxyUrl, HttpProxyUrl); - const patched = ProxyResolver.createHttpPatchedModules(proxy); + const patched = ProxyResolver.createHttpPatchedModules(proxy, certificates); const socketOptions = { socketPath: '/var/socket/path' }; patched.http.get(socketOptions); expect(get).toBeCalledWith(socketOptions, undefined); @@ -124,7 +129,7 @@ test('patched http get calls original method with the original parameters when p test('patched http get when called with url and callback calls original with options and callback', () => { const proxy = createProxy(true, HttpsProxyUrl, HttpProxyUrl); - const patched = ProxyResolver.createHttpPatchedModules(proxy); + const patched = ProxyResolver.createHttpPatchedModules(proxy, certificates); const colon = ':'; const url = `https://[fe80${colon}${colon}1802${colon}20ff${colon}fe8d${colon}d4ce]`; const callback = vi.fn(); @@ -145,7 +150,7 @@ test('patched http get when called with url and callback calls original with opt test('patched http get translates username@password in url to auth option', () => { const proxy = createProxy(true, HttpsProxyUrl, HttpProxyUrl); - const patched = ProxyResolver.createHttpPatchedModules(proxy); + const patched = ProxyResolver.createHttpPatchedModules(proxy, certificates); const url = 'https://usr:pass@rest.url'; const callback = vi.fn(); patched.http.get(url, callback); @@ -166,7 +171,7 @@ test('patched http get translates username@password in url to auth option', () = test('patched http get works when url passed as protocol and hostname in options', () => { const proxy = createProxy(true, HttpsProxyUrl, HttpProxyUrl); - const patched = ProxyResolver.createHttpPatchedModules(proxy); + const patched = ProxyResolver.createHttpPatchedModules(proxy, certificates); const callback = vi.fn(); const options = { hostname: 'rest.url', diff --git a/packages/main/src/plugin/proxy-resolver.ts b/packages/main/src/plugin/proxy-resolver.ts index b1229bdfb..163a6e949 100644 --- a/packages/main/src/plugin/proxy-resolver.ts +++ b/packages/main/src/plugin/proxy-resolver.ts @@ -23,6 +23,7 @@ import * as nodeurl from 'node:url'; import type { HttpProxyAgentOptions, HttpsProxyAgentOptions } from 'hpagent'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import type { Certificates } from './certificates.js'; import type { Proxy } from './proxy.js'; // Agents usage table @@ -39,7 +40,7 @@ import type { Proxy } from './proxy.js'; // ------------------------------------ // Source - https://github.com/delvedor/hpagent/tree/main#usage -function createProxyAgent(secure: boolean, proxyUrl: string): http.Agent | https.Agent { +function createProxyAgent(secure: boolean, proxyUrl: string, certificates: Certificates): http.Agent | https.Agent { const options = { keepAlive: true, keepAliveMsecs: 1000, @@ -47,6 +48,7 @@ function createProxyAgent(secure: boolean, proxyUrl: string): http.Agent | https maxFreeSockets: 256, scheduling: 'lifo', proxy: proxyUrl, + ca: certificates.getAllCertificates(), }; return secure ? new HttpsProxyAgent(options as HttpsProxyAgentOptions) @@ -62,24 +64,24 @@ export function getProxyUrl(proxy: Proxy, secure: boolean): string | undefined { type ProxyOptions = { agent?: http.Agent | https.Agent }; -export function getOptions(proxy: Proxy, secure: boolean): ProxyOptions { +export function getOptions(proxy: Proxy, secure: boolean, certificates: Certificates): ProxyOptions { const options: ProxyOptions = {}; const proxyUrl = getProxyUrl(proxy, secure); if (proxyUrl) { - options.agent = createProxyAgent(secure, proxyUrl); + options.agent = createProxyAgent(secure, proxyUrl, certificates); } return options; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createHttpPatch(originals: typeof http | typeof https, proxy: Proxy): any { +export function createHttpPatch(originals: typeof http | typeof https, proxy: Proxy, certificates: Certificates): any { return { - get: patch(originals.get), - request: patch(originals.request), + get: patch(originals.get, certificates), + request: patch(originals.request, certificates), }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - function patch(original: typeof http.get): any { + function patch(original: typeof http.get, certificates: Certificates): any { function patched( url?: string | nodeurl.URL | null, options?: http.RequestOptions | null, @@ -127,7 +129,7 @@ export function createHttpPatch(originals: typeof http | typeof https, proxy: Pr const host = options.hostname ?? options.host; const isLocalhost = !host || host === 'localhost' || host === '127.0.0.1'; if (!isLocalhost) { - options = { ...options, ...getOptions(proxy, options.protocol === 'https:') }; + options = { ...options, ...getOptions(proxy, options.protocol === 'https:', certificates) }; } return original(options, callback); @@ -139,10 +141,10 @@ export function createHttpPatch(originals: typeof http | typeof https, proxy: Pr } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function createHttpPatchedModules(proxy: Proxy): any { +export function createHttpPatchedModules(proxy: Proxy, certificates: Certificates): any { const res = { - http: { ...http, ...createHttpPatch(http, proxy) }, - https: { ...https, ...createHttpPatch(https, proxy) }, + http: { ...http, ...createHttpPatch(http, proxy, certificates) }, + https: { ...https, ...createHttpPatch(https, proxy, certificates) }, }; return { ...res, 'node:https': res.https, 'node:http': res.http }; } diff --git a/packages/main/src/plugin/proxy.spec.ts b/packages/main/src/plugin/proxy.spec.ts index 15395368a..d5b0af923 100644 --- a/packages/main/src/plugin/proxy.spec.ts +++ b/packages/main/src/plugin/proxy.spec.ts @@ -22,11 +22,16 @@ import type { AddressInfo } from 'node:net'; import { createProxy, type ProxyServer } from 'proxy'; import { describe, expect, test, vi } from 'vitest'; +import type { Certificates } from '/@/plugin/certificates.js'; import type { ConfigurationRegistry } from '/@/plugin/configuration-registry.js'; import { ensureURL, Proxy } from '/@/plugin/proxy.js'; const URL = 'https://podman-desktop.io'; +const certificates: Certificates = { + getAllCertificates: vi.fn(), +} as unknown as Certificates; + function getConfigurationRegistry( enabled: boolean, http: string | undefined, @@ -64,7 +69,7 @@ async function buildProxy(): Promise { test('fetch without proxy', async () => { const configurationRegistry = getConfigurationRegistry(false, undefined, undefined, undefined); - const proxy = new Proxy(configurationRegistry); + const proxy = new Proxy(configurationRegistry, certificates); await proxy.init(); await fetch(URL); }); @@ -73,7 +78,7 @@ test('fetch with http proxy', async () => { const proxyServer = await buildProxy(); const address = proxyServer.address() as AddressInfo; const configurationRegistry = getConfigurationRegistry(true, `127.0.0.1:${address.port}`, undefined, undefined); - const proxy = new Proxy(configurationRegistry); + const proxy = new Proxy(configurationRegistry, certificates); await proxy.init(); let connectDone = false; proxyServer.on('connect', () => (connectDone = true)); diff --git a/packages/main/src/plugin/proxy.ts b/packages/main/src/plugin/proxy.ts index 1d841ce0b..5ee8a72da 100644 --- a/packages/main/src/plugin/proxy.ts +++ b/packages/main/src/plugin/proxy.ts @@ -19,6 +19,8 @@ import type { Event, ProxySettings } from '@podman-desktop/api'; import { ProxyAgent } from 'undici'; +import type { Certificates } from '/@/plugin/certificates.js'; + import type { ConfigurationRegistry, IConfigurationNode } from './configuration-registry.js'; import { Emitter } from './events/emitter.js'; import { getProxyUrl } from './proxy-resolver.js'; @@ -61,7 +63,10 @@ export class Proxy { private readonly _onDidStateChange = new Emitter(); public readonly onDidStateChange: Event = this._onDidStateChange.event; - constructor(private configurationRegistry: ConfigurationRegistry) {} + constructor( + private configurationRegistry: ConfigurationRegistry, + private certificates: Certificates, + ) {} async init(): Promise { const proxyConfigurationNode: IConfigurationNode = { @@ -171,7 +176,10 @@ export class Proxy { globalThis.fetch = function (url: any, opts?: any): Promise { const proxyurl = getProxyUrl(_me, asURL(url).protocol === 'https'); if (proxyurl) { - opts = { ...opts, dispatcher: new ProxyAgent(proxyurl) }; + opts = { + ...opts, + dispatcher: new ProxyAgent({ uri: proxyurl, proxyTls: { ca: _me.certificates.getAllCertificates() } }), + }; } return original(url, opts);