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:
Florent Benoit 2024-07-30 11:36:50 +02:00 committed by Florent BENOIT
parent 75e0b95f62
commit f7cb5a3450
8 changed files with 732 additions and 3 deletions

View file

@ -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",

View file

@ -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> {

View 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();
});

View 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;
}
}

View 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();
});

View 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;
}
}

View file

@ -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',
},

View file

@ -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"