From f7cb5a3450ee98705fb59790f3eb4fea4e377796 Mon Sep 17 00:00:00 2001 From: Florent Benoit Date: Tue, 30 Jul 2024 11:36:50 +0200 Subject: [PATCH] feat: add support for podman remote host Can be turned on by a property, disabled by default fixes https://github.com/containers/podman-desktop/issues/279 Signed-off-by: Florent Benoit --- extensions/podman/package.json | 9 +- extensions/podman/src/extension.ts | 4 + .../src/podman-remote-connections.spec.ts | 168 +++++++++++++ .../podman/src/podman-remote-connections.ts | 225 ++++++++++++++++++ .../src/podman-remote-ssh-tunnel.spec.ts | 138 +++++++++++ .../podman/src/podman-remote-ssh-tunnel.ts | 180 ++++++++++++++ extensions/podman/vite.config.js | 2 +- yarn.lock | 9 +- 8 files changed, 732 insertions(+), 3 deletions(-) create mode 100644 extensions/podman/src/podman-remote-connections.spec.ts create mode 100644 extensions/podman/src/podman-remote-connections.ts create mode 100644 extensions/podman/src/podman-remote-ssh-tunnel.spec.ts create mode 100644 extensions/podman/src/podman-remote-ssh-tunnel.ts diff --git a/extensions/podman/package.json b/extensions/podman/package.json index 4234a599035..084e660db5d 100644 --- a/extensions/podman/package.json +++ b/extensions/podman/package.json @@ -51,6 +51,11 @@ "default": "", "description": "Custom path to Podman binary (Default is blank)" }, + "podman.system.connections.remote": { + "type": "boolean", + "default": false, + "description": "Load remote system connections (ssh)" + }, "podman.machine.cpus": { "type": "number", "format": "cpu", @@ -380,9 +385,11 @@ "@ltd/j-toml": "^1.38.0", "@podman-desktop/api": "^0.0.1", "compare-versions": "^6.1.1", - "ps-list": "^8.1.1" + "ps-list": "^8.1.1", + "ssh2": "^1.15.0" }, "devDependencies": { + "@types/ssh2": "^1.15.0", "adm-zip": "^0.5.14", "hasha": "^6.0.0", "mkdirp": "^3.0.1", diff --git a/extensions/podman/src/extension.ts b/extensions/podman/src/extension.ts index a26e4506324..2a267ebe2a2 100644 --- a/extensions/podman/src/extension.ts +++ b/extensions/podman/src/extension.ts @@ -36,6 +36,7 @@ import { getPodmanCli, getPodmanInstallation } from './podman-cli'; import { PodmanConfiguration } from './podman-configuration'; import { PodmanInfoHelper } from './podman-info-helper'; import { PodmanInstall } from './podman-install'; +import { PodmanRemoteConnections } from './podman-remote-connections'; import { QemuHelper } from './qemu-helper'; import { RegistrySetup } from './registry-setup'; import { @@ -1631,6 +1632,9 @@ export async function activate(extensionContext: extensionApi.ExtensionContext): await registrySetup.setup(); await calcPodmanMachineSetting(podmanConfiguration); + + const podmanRemoteConnections = new PodmanRemoteConnections(extensionContext, provider); + podmanRemoteConnections.start(); } export async function calcPodmanMachineSetting(podmanConfiguration: PodmanConfiguration): Promise { diff --git a/extensions/podman/src/podman-remote-connections.spec.ts b/extensions/podman/src/podman-remote-connections.spec.ts new file mode 100644 index 00000000000..c99b1d761ff --- /dev/null +++ b/extensions/podman/src/podman-remote-connections.spec.ts @@ -0,0 +1,168 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as fs from 'node:fs'; + +import * as extensionApi from '@podman-desktop/api'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { PodmanRemoteConnections } from './podman-remote-connections'; +import type { PodmanRemoteSshTunnel } from './podman-remote-ssh-tunnel'; + +vi.mock('node:fs'); + +// mock the API +vi.mock('@podman-desktop/api', async () => { + return { + env: { + isWindows: true, + }, + configuration: { + onDidChangeConfiguration: vi.fn(), + getConfiguration: vi.fn(), + }, + process: { + exec: vi.fn(), + }, + }; +}); + +const extensionContext = {} as extensionApi.ExtensionContext; + +const provider = {} as extensionApi.Provider; + +beforeEach(() => { + vi.resetAllMocks(); +}); +class TestPodmanRemoteConnections extends PodmanRemoteConnections { + createTunnel( + host: string, + port: number, + username: string, + privateKey: string, + remotePath: string, + localPath: string, + ): PodmanRemoteSshTunnel { + return super.createTunnel(host, port, username, privateKey, remotePath, localPath); + } + + async refreshRemoteConnections(): Promise { + return super.refreshRemoteConnections(); + } +} + +test('should do nothing if the configuration is disabled', async () => { + vi.mocked(extensionApi.configuration.getConfiguration).mockReturnValue({ + get: () => false, + } as unknown as extensionApi.Configuration); + const podmanRemoteConnections = new TestPodmanRemoteConnections(extensionContext, provider); + + // spy createTunnel method + const spyCreateTunnel = vi.spyOn(podmanRemoteConnections, 'createTunnel'); + + // spy refreshRemoteConnections method + const spyRefreshRemoteConnections = vi.spyOn(podmanRemoteConnections, 'refreshRemoteConnections'); + + // start + podmanRemoteConnections.start(); + + // wait for the getConfiguration being called + await vi.waitFor(() => expect(extensionApi.configuration.getConfiguration).toHaveBeenCalled()); + + // no connection should be created + expect(spyCreateTunnel).not.toHaveBeenCalled(); + expect(spyRefreshRemoteConnections).not.toHaveBeenCalled(); +}); + +test('should check connections if configuration is enabled', async () => { + vi.mocked(extensionApi.configuration.getConfiguration).mockReturnValue({ + get: () => true, + } as unknown as extensionApi.Configuration); + const podmanRemoteConnections = new TestPodmanRemoteConnections(extensionContext, provider); + + // mock exec method for listing podman system connections + vi.mocked(extensionApi.process.exec).mockResolvedValue({ + stdout: JSON.stringify([]), + } as unknown as extensionApi.RunResult); + + // spy createTunnel method + const spyCreateTunnel = vi.spyOn(podmanRemoteConnections, 'createTunnel'); + + // spy refreshRemoteConnections method + const spyRefreshRemoteConnections = vi.spyOn(podmanRemoteConnections, 'refreshRemoteConnections'); + + // start + podmanRemoteConnections.start(); + + // wait for the getConfiguration being called + await vi.waitFor(() => expect(extensionApi.configuration.getConfiguration).toHaveBeenCalled()); + + // no connection should be created + expect(spyCreateTunnel).not.toHaveBeenCalled(); + expect(spyRefreshRemoteConnections).toHaveBeenCalled(); +}); + +test('should check connections if configuration is enabled and a system connection', async () => { + vi.mocked(extensionApi.configuration.getConfiguration).mockReturnValue({ + get: () => true, + } as unknown as extensionApi.Configuration); + const podmanRemoteConnections = new TestPodmanRemoteConnections(extensionContext, provider); + + // mock readFileSync + vi.spyOn(fs, 'readFileSync').mockReturnValue('file'); + + // mock exec method for listing podman system connections + + // one machine and one remote connection + + vi.mocked(extensionApi.process.exec).mockResolvedValue({ + stdout: JSON.stringify([ + { + IsMachine: true, + URI: 'ssh://dummy@127.0.0.1:1234/run/podman/podman.sock', + Identity: '/tmp/fakepath', + Name: 'Machine1', + }, + + { + IsMachine: false, + URI: 'ssh://dummy@127.0.0.1:1234/run/podman/podman.sock', + Identity: '/tmp/fakepath', + Name: 'RemoteSystemConnection1', + }, + ]), + } as unknown as extensionApi.RunResult); + + // spy createTunnel method + const spyCreateTunnel = vi.spyOn(podmanRemoteConnections, 'createTunnel'); + + // spy refreshRemoteConnections method + const spyRefreshRemoteConnections = vi.spyOn(podmanRemoteConnections, 'refreshRemoteConnections'); + + // start + podmanRemoteConnections.start(); + + // wait for the getConfiguration being called + await vi.waitFor(() => expect(extensionApi.configuration.getConfiguration).toHaveBeenCalled()); + + // no connection should be created + expect(spyCreateTunnel).not.toHaveBeenCalled(); + expect(spyRefreshRemoteConnections).toHaveBeenCalled(); +}); diff --git a/extensions/podman/src/podman-remote-connections.ts b/extensions/podman/src/podman-remote-connections.ts new file mode 100644 index 00000000000..251e0f7ff5e --- /dev/null +++ b/extensions/podman/src/podman-remote-connections.ts @@ -0,0 +1,225 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import * as extensionApi from '@podman-desktop/api'; + +import { getPodmanCli } from './podman-cli'; +import { PodmanRemoteSshTunnel } from './podman-remote-ssh-tunnel'; + +interface ConnectionListFormatJson { + Name: string; + IsMachine?: boolean; + URI: string; + Identity: string; +} + +interface RemoteSystemConnection { + name: string; + sshTunnel: PodmanRemoteSshTunnel; + connectionDisposable: extensionApi.Disposable; +} + +const CONFIGURATION_REMOTE_ENABLED_KEY = 'system.connections.remote'; + +const PODMAN_CONFIGURATION_REMOTE_ENABLED_KEY = `podman.${CONFIGURATION_REMOTE_ENABLED_KEY}`; + +export class PodmanRemoteConnections { + #extensionContext: extensionApi.ExtensionContext; + + #provider: extensionApi.Provider; + + #currentConnections: Map = new Map(); + + #stopMonitoring = false; + + #timeout: NodeJS.Timeout | undefined; + + constructor(extensionContext: extensionApi.ExtensionContext, provider: extensionApi.Provider) { + this.#extensionContext = extensionContext; + this.#provider = provider; + } + + // get the list of all connections and filter out the local ones (Machines have a flag IsMachine set to true) + async grabRemoteConnections(): Promise { + const command = await extensionApi.process.exec(getPodmanCli(), [ + 'system', + 'connection', + 'list', + '--format', + 'json', + ]); + + const response = command.stdout; + + // parse JSON + const connections: ConnectionListFormatJson[] = JSON.parse(response); + + // filter out all machines (that are local) + return connections + .filter(connection => connection.IsMachine !== true) + .filter(connection => connection.URI.startsWith('ssh:')); + } + + start(): void { + // need to watch the configuration to enable again the monitoring + extensionApi.configuration.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration(PODMAN_CONFIGURATION_REMOTE_ENABLED_KEY)) { + await this.monitorRemoteConnections(); + } + }); + + this.monitorRemoteConnections().catch((error: unknown) => { + console.error('Error starting remote podman system connections', error); + }); + } + + protected async monitorRemoteConnections(): Promise { + // check the configuration to see if we need to monitor the remote connections + const checkForRemote = + extensionApi.configuration.getConfiguration('podman').get(CONFIGURATION_REMOTE_ENABLED_KEY) ?? false; + + if (!checkForRemote) { + this.#stopMonitoring = true; + this.cleanupConnections(Array.from(this.#currentConnections.values())); + return; + } else { + this.#stopMonitoring = false; + } + + // call us again + if (!this.#stopMonitoring) { + try { + await this.refreshRemoteConnections(); + } catch (error) { + // ignore the update of remote connections + } + // wait 5s before checking again + // and remove any other existing timeout + if (this.#timeout) { + clearTimeout(this.#timeout); + } + this.#timeout = setTimeout(() => { + this.monitorRemoteConnections().catch((error: unknown) => { + console.error('Error monitoring remote podman system connections', error); + }); + }, 5000); + } + } + + protected async refreshRemoteConnections(): Promise { + const cliRemoteConnections = await this.grabRemoteConnections(); + + // compare to the previous list of connections + const toAdd = cliRemoteConnections.filter(connection => !this.#currentConnections.has(connection.Name)); + const toRemove = Array.from(this.#currentConnections.values()).filter( + connection => !cliRemoteConnections.some(c => c.Name === connection.name), + ); + + // for each new connection, create the tunnel and register the connection + + // for each new connection, setup a tunnel and add the provider + for (const connection of toAdd) { + // extract values from the configuration it looks like + /*{ + "Name": "fedora40", + "URI": "ssh://username@1.2.3.4:22/run/user/1000/podman/podman.sock", + "Identity": "/Users/username/.ssh/id_1234", + "Default": false, + "ReadWrite": true + },*/ + + const uri = new URL(connection.URI); + const host = uri.hostname; + const port = parseInt(uri.port); + const username = uri.username; + const privateKeyFile = connection.Identity; + + // read the content of the private key + const privateKey = readFileSync(privateKeyFile, 'utf8'); + const remotePath = uri.pathname; + + let localPath; + // on Windows, use npipe + if (extensionApi.env.isWindows) { + localPath = `\\\\.\\pipe\\podman-remote-${connection.Name}`; + } else { + // on mac and Linux use socket file + localPath = join(tmpdir(), `podman-remote-${connection.Name}.sock`); + } + + const sshTunnel = this.createTunnel(host, port, username, privateKey, remotePath, localPath); + + // connect the tunnel + sshTunnel.connect(); + + //delay before registering the socket + await new Promise(resolve => setTimeout(resolve, 1000)); + + // register the socket + const connectionDisposable = this.#provider.registerContainerProviderConnection({ + name: connection.Name, + status: () => sshTunnel.status(), + type: 'podman', + endpoint: { + socketPath: localPath, + }, + }); + this.#extensionContext.subscriptions.push(connectionDisposable); + const remoteConnection: RemoteSystemConnection = { + name: connection.Name, + sshTunnel, + connectionDisposable, + }; + this.#currentConnections.set(connection.Name, remoteConnection); + } + + // for each connection to remove, close the tunnel and remove the provider + this.cleanupConnections(toRemove); + } + + cleanupConnections(connections: RemoteSystemConnection[]): void { + for (const connection of connections) { + const remoteConnection = this.#currentConnections.get(connection.name); + if (remoteConnection) { + remoteConnection.sshTunnel.disconnect(); + this.#currentConnections.delete(connection.name); + // unregister the connection + remoteConnection.connectionDisposable.dispose(); + } + } + } + + protected createTunnel( + host: string, + port: number, + username: string, + privateKey: string, + remotePath: string, + localPath: string, + ): PodmanRemoteSshTunnel { + return new PodmanRemoteSshTunnel(host, port, username, privateKey, remotePath, localPath); + } + + stop(): void { + this.#stopMonitoring = true; + } +} diff --git a/extensions/podman/src/podman-remote-ssh-tunnel.spec.ts b/extensions/podman/src/podman-remote-ssh-tunnel.spec.ts new file mode 100644 index 00000000000..cb8bd012488 --- /dev/null +++ b/extensions/podman/src/podman-remote-ssh-tunnel.spec.ts @@ -0,0 +1,138 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { rm } from 'node:fs/promises'; +import { type AddressInfo, createConnection, createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Server } from 'ssh2'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { PodmanRemoteSshTunnel } from './podman-remote-ssh-tunnel'; + +// mock the API +vi.mock('@podman-desktop/api', async () => { + return { + process: { + exec: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +class TestPodmanRemoteSshTunnel extends PodmanRemoteSshTunnel { + isListening(): boolean { + return super.isListening(); + } +} + +// this is a dummy key for testing +// no leakeage there +const DUMMY_KEY = ` +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA64q3c1wk0Gwey/p+UnWB3gbX2j6EQBp7rVfIBuUbXxQAAAJhWRLISVkSy +EgAAAAtzc2gtZWQyNTUxOQAAACA64q3c1wk0Gwey/p+UnWB3gbX2j6EQBp7rVfIBuUbXxQ +AAAECMAo9dAwYs3Z1Jwn+fVhF/bG6FBMn2QnEWWqdEmAKM+TrirdzXCTQbB7L+n5SdYHeB +tfaPoRAGnutV8gG5RtfFAAAAFGJlbm9pdGZARmxvcmVudHMtTUJQAQ== +-----END OPENSSH PRIVATE KEY----- +`; // notsecret +test('should be able to connect', async () => { + let sshPort = 0; + let connected = false; + let authenticated = false; + + // create ssh server + const sshServer = new Server( + { + hostKeys: [DUMMY_KEY], + }, + client => { + connected = true; + + client + .on('authentication', ctx => { + ctx.accept(); + }) + .on('ready', () => { + authenticated = true; + }); + }, + ).listen(0, '127.0.0.1', () => { + const address: AddressInfo = sshServer.address() as AddressInfo; + sshPort = address?.port; + }); + + // wait that the server is listening + await vi.waitFor(() => expect(sshPort).toBeGreaterThan(0)); + + // create a npipe/socket server + // on windows it's an npipe, on macOS a socket file + let socketOrNpipePathLocal: string; + let socketOrNpipePathRemote: string; + if (process.platform === 'win32') { + socketOrNpipePathLocal = '\\\\.\\pipe\\test-local'; + socketOrNpipePathRemote = '\\\\.\\pipe\\test-remote'; + } else { + socketOrNpipePathLocal = join(tmpdir(), 'test-local.sock'); + socketOrNpipePathRemote = join(tmpdir(), 'test-remote.sock'); + } + + // delete file if exists + await rm(socketOrNpipePathLocal, { force: true }); + await rm(socketOrNpipePathRemote, { force: true }); + + let listenReady = false; + + // start a remote server + const npipeServer = createServer(_socket => {}).listen(socketOrNpipePathRemote, () => { + listenReady = true; + }); + + await vi.waitFor(() => expect(listenReady).toBeTruthy()); + + const podmanRemoteSshTunnel = new TestPodmanRemoteSshTunnel( + 'localhost', + sshPort, + 'foo', + '', + socketOrNpipePathRemote, + socketOrNpipePathLocal, + ); + + podmanRemoteSshTunnel.connect(); + + // wait authenticated and connected + await vi.waitFor(() => expect(connected && authenticated && podmanRemoteSshTunnel.isListening()).toBeTruthy()); + + let connectedToLocal = false; + // send a request to the tunnel using the socket path + const client = createConnection({ path: socketOrNpipePathLocal }, () => { + connectedToLocal = true; + }); + + await vi.waitFor(() => expect(connectedToLocal).toBeTruthy()); + + client.end(); + npipeServer.close(); +}); diff --git a/extensions/podman/src/podman-remote-ssh-tunnel.ts b/extensions/podman/src/podman-remote-ssh-tunnel.ts new file mode 100644 index 00000000000..0a235cb08cf --- /dev/null +++ b/extensions/podman/src/podman-remote-ssh-tunnel.ts @@ -0,0 +1,180 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import * as net from 'node:net'; + +import type { ProviderConnectionStatus } from '@podman-desktop/api'; +import type { ConnectConfig } from 'ssh2'; +import { Client } from 'ssh2'; + +export class PodmanRemoteSshTunnel { + #sshConfig: ConnectConfig; + + #client: Client | undefined; + + // local server for listening on the local file socket + #server: net.Server | undefined; + + #status: ProviderConnectionStatus = 'unknown'; + + #reconnect: boolean = false; + + #reconnectTimeout: NodeJS.Timeout | undefined; + + #resolveConnected: (value: boolean) => void = () => {}; + #connected: Promise; + + #listening: boolean = false; + + constructor( + private host: string, + private port: number, + private username: string, + private privateKey: string, + private remotePath: string, + private localPath: string, + ) { + this.#sshConfig = { + host: this.host, + port: this.port, + username: this.username, + privateKey: this.privateKey, + }; + this.#connected = new Promise((resolve, _reject) => { + this.#resolveConnected = resolve; + }); + } + + dispose(): void { + this.disconnect(); + } + + status(): ProviderConnectionStatus { + return this.#status; + } + + connect(): void { + this.#reconnect = true; + this.#listening = false; + this.#client = new Client(); + this.#connected = new Promise((resolve, _reject) => { + this.#resolveConnected = resolve; + }); + + this.#client + .on('ready', () => { + this.#status = 'started'; + + this.#resolveConnected(true); + + // Create a local server to listen on the local file socket + this.#server = net.createServer(localSocket => { + // Create a connection to the remote socket via SSH + this.#client?.openssh_forwardOutStreamLocal(this.remotePath, (err, remoteSocket) => { + if (err) { + localSocket.end(); + return; + } + + // Forward data from local to remote + localSocket.on('data', data => { + remoteSocket.write(data); + }); + + // Forward data from remote to local + remoteSocket.on('data', (data: string | Uint8Array) => { + localSocket.write(data); + }); + + // Handle local socket close + localSocket.on('close', () => { + remoteSocket.end(); + }); + + // Handle remote socket close + remoteSocket.on('close', () => { + localSocket.end(); + }); + + // Handle local socket error + localSocket.on('error', err => { + console.error('Podman ssh tunnel local socket error using configuration', this.#sshConfig, err); + remoteSocket.end(); + }); + + // Handle remote socket error + remoteSocket.on('error', (err: unknown) => { + console.error('Podman ssh tunnel remote socket error using configuration', this.#sshConfig, err); + localSocket.end(); + }); + }); + }); + + // Listen on the local file socket + this.#server.listen(this.localPath, () => { + this.#listening = true; + }); + + // Handle server error + this.#server.on('error', err => { + console.error('Server error:', err); + }); + }) + .connect(this.#sshConfig); + + this.#client.on('error', err => { + console.error('SSH connection error:', err); + this.#status = 'unknown'; + this.handleReconnect(); + }); + + this.#client.on('end', () => { + this.#status = 'stopped'; + this.handleReconnect(); + }); + + this.#client.on('close', () => { + this.#status = 'stopped'; + this.handleReconnect(); + }); + } + + handleReconnect(): void { + // need to reconnect if no timeout is set for now + if (this.#reconnect && !this.#reconnectTimeout) { + this.#reconnectTimeout = setTimeout(() => { + this.#reconnectTimeout = undefined; + this.connect(); + }, 30000); + } + } + + disconnect(): void { + // Set the reconnect flag to false to prevent reconnecting + this.#reconnect = false; + this.#client?.end(); + this.#server?.close(); + } + + isConnected(): Promise { + return this.#connected; + } + + protected isListening(): boolean { + return this.#listening; + } +} diff --git a/extensions/podman/vite.config.js b/extensions/podman/vite.config.js index 5078ba7fc5d..d24970ba182 100644 --- a/extensions/podman/vite.config.js +++ b/extensions/podman/vite.config.js @@ -45,7 +45,7 @@ const config = { formats: ['cjs'], }, rollupOptions: { - external: ['@podman-desktop/api', ...builtinModules.flatMap(p => [p, `node:${p}`])], + external: ['@podman-desktop/api', 'ssh2', ...builtinModules.flatMap(p => [p, `node:${p}`])], output: { entryFileNames: '[name].cjs', }, diff --git a/yarn.lock b/yarn.lock index 4d2d0f6fdd3..a2469688f22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6381,7 +6381,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^17.0.5", "@types/node@^18.0.0", "@types/node@^20", "@types/node@^20.1.1", "@types/node@^20.9.0": +"@types/node@*", "@types/node@^17.0.5", "@types/node@^18.0.0", "@types/node@^18.11.18", "@types/node@^20", "@types/node@^20.1.1", "@types/node@^20.9.0": version "20.14.13" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.13.tgz#bf4fe8959ae1c43bc284de78bd6c01730933736b" integrity sha512-+bHoGiZb8UiQ0+WEtmph2IWQCjIqg8MDZMAV+ppRRhUZnquF5mQkP/9vpSwJClEiSM/C7fZZExPzfU0vJTyp8w== @@ -6613,6 +6613,13 @@ "@types/node" "*" "@types/ssh2-streams" "*" +"@types/ssh2@^1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.0.tgz#ef698a2fe05696d898e0f9398384ad370cd35317" + integrity sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww== + dependencies: + "@types/node" "^18.11.18" + "@types/stream-chain@*": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/stream-chain/-/stream-chain-2.0.1.tgz#4d3cc47a32609878bc188de0bae420bcfd3bf1f5"