mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-05-23 01:38:48 +00:00
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 <fbenoit@redhat.com>
This commit is contained in:
parent
75e0b95f62
commit
f7cb5a3450
8 changed files with 732 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
168
extensions/podman/src/podman-remote-connections.spec.ts
Normal file
168
extensions/podman/src/podman-remote-connections.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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();
|
||||
});
|
||||
225
extensions/podman/src/podman-remote-connections.ts
Normal file
225
extensions/podman/src/podman-remote-connections.ts
Normal file
|
|
@ -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<string, RemoteSystemConnection> = 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<ConnectionListFormatJson[]> {
|
||||
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<void> {
|
||||
// check the configuration to see if we need to monitor the remote connections
|
||||
const checkForRemote =
|
||||
extensionApi.configuration.getConfiguration('podman').get<boolean>(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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
138
extensions/podman/src/podman-remote-ssh-tunnel.spec.ts
Normal file
138
extensions/podman/src/podman-remote-ssh-tunnel.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
180
extensions/podman/src/podman-remote-ssh-tunnel.ts
Normal file
180
extensions/podman/src/podman-remote-ssh-tunnel.ts
Normal file
|
|
@ -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<boolean>;
|
||||
|
||||
#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<boolean>((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<boolean>((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<boolean> {
|
||||
return this.#connected;
|
||||
}
|
||||
|
||||
protected isListening(): boolean {
|
||||
return this.#listening;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue