Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 38 additions & 40 deletions lib/docker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import * as path from 'path';
import * as os from 'os';
import * as http from 'http';
import * as https from 'https';
import * as tls from 'tls';
import * as models from './models/index.js';
import { HTTPClient } from './http.js';
import { SocketReuseAgent } from './socket.js';
import { SocketAgent } from './socket.js';
import { Filter } from './filter.js';
import { SSH } from './ssh.js';
import { TLS } from './tls.js';
Expand Down Expand Up @@ -40,54 +41,42 @@ export class DockerClient {
certPath?: string,
): Promise<DockerClient> {
return new Promise((resolve, reject) => {
let socket: net.Socket;

if (dockerHost.startsWith('unix:')) {
// Unix socket connection
// Unix socket connection - use SocketAgent with socket creation function
const socketPath = dockerHost.substring(5); // Remove "unix:" prefix
socket = net.createConnection(socketPath);

socket.on('connect', () => {
// Increase max listeners since we'll be reusing this socket for multiple requests
socket.setMaxListeners(50);
const agent = new SocketReuseAgent(socket);
try {
const agent = new SocketAgent(() =>
net.createConnection(socketPath),
);
resolve(new DockerClient(agent));
});

socket.on('error', (error) => {
} catch (error) {
reject(
new Error(
`Failed to connect to Docker host ${dockerHost}: ${error.message}`,
`Failed to create Docker client for ${dockerHost}: ${error.message}`,
),
);
});
}
} else if (dockerHost.startsWith('tcp:')) {
// TCP connection - use HTTP/HTTPS agents
// TCP connection - use SocketAgent with TCP socket creation function
const tcpAddress = dockerHost.substring(6); // Remove "tcp://" prefix
const [host, portStr] = tcpAddress.split(':');
const port = parseInt(portStr) || (certPath ? 2376 : 2375); // Default ports: 2376 for TLS, 2375 for plain

try {
let agent: http.Agent;
let agent: SocketAgent;

if (certPath) {
// Use HTTPS agent with TLS certificates
// Use SocketAgent with TLS socket creation function
const tlsOptions = TLS.loadCertificates(certPath);
agent = new https.Agent({
host,
port,
...tlsOptions,
keepAlive: true,
keepAliveMsecs: 30000,
});
agent = new SocketAgent(() =>
tls.connect({ host, port, ...tlsOptions }),
);
} else {
// Use plain HTTP agent
agent = new http.Agent({
host,
port,
keepAlive: true,
keepAliveMsecs: 30000,
});
// Use SocketAgent with plain TCP socket creation function
agent = new SocketAgent(() =>
net.createConnection({ host, port }),
);
}

resolve(new DockerClient(agent));
Expand All @@ -99,15 +88,19 @@ export class DockerClient {
);
}
} else if (dockerHost.startsWith('ssh:')) {
// SSH connection
SSH.createConnection(dockerHost)
.then((sshSocket) => {
// Increase max listeners since we'll be reusing this socket for multiple requests
sshSocket.setMaxListeners(50);
const agent = new SocketReuseAgent(sshSocket);
resolve(new DockerClient(agent));
})
.catch(reject);
// SSH connection - use SocketAgent with SSH socket creation function
try {
const agent = new SocketAgent(
SSH.createSocketFactory(dockerHost),
);
resolve(new DockerClient(agent));
} catch (error) {
reject(
new Error(
`Failed to create SSH Docker client for ${dockerHost}: ${error.message}`,
),
);
}
} else {
reject(
new Error(
Expand Down Expand Up @@ -203,6 +196,11 @@ export class DockerClient {
* @returns Promise that resolves to a connected DockerClient instance
*/
static async fromDockerConfig(): Promise<DockerClient> {
// Check for DOCKER_HOST environment variable first - takes precedence over config
if (process.env.DOCKER_HOST) {
return DockerClient.fromDockerHost(process.env.DOCKER_HOST);
}

// Check for DOCKER_CONFIG environment variable, otherwise use default path
const configPath =
process.env.DOCKER_CONFIG ||
Expand Down
54 changes: 36 additions & 18 deletions lib/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,51 @@ import * as http from 'http';
import * as stream from 'stream';

/**
* Custom HTTP Agent that reuses an existing socket connection.
* This agent is designed to work with persistent socket connections
* like Unix domain sockets or long-lived TCP connections.
* HTTP Agent that creates socket connections using a provided factory function.
* This allows flexible socket creation strategies while supporting connection pooling.
*/
export class SocketReuseAgent extends http.Agent {
private socket: net.Socket;
export class SocketAgent extends http.Agent {
private socketFactory: () => net.Socket;

constructor(socket: net.Socket) {
constructor(createSocketFn: () => net.Socket) {
super({
keepAlive: true,
keepAliveMsecs: 0,
keepAliveMsecs: 30000,
maxSockets: Infinity,
maxFreeSockets: 1,
maxFreeSockets: 10,
maxTotalSockets: Infinity,
timeout: 120000,
scheduling: 'lifo',
});

this.socket = socket;
}
this.socketFactory = createSocketFn;

// Override createConnection to use our socket factory
this.createConnection = (
options: any,
callback?: (err: Error | null, socket?: stream.Duplex) => void,
): stream.Duplex => {
const socket = this.socketFactory();
socket.setNoDelay(true);
socket.setKeepAlive(true, 30000);
socket.setTimeout(0);

if (callback) {
const onConnect = () => {
socket.removeListener('error', onError);
callback(null, socket);
};

createConnection(options: any, callback?: any): stream.Duplex {
// Ensure our socket is properly configured for HTTP
this.socket.setNoDelay(true);
this.socket.setKeepAlive(true);
const onError = (error: Error) => {
socket.removeListener('connect', onConnect);
callback(error);
};

if (callback) {
process.nextTick(callback, null, this.socket);
}
socket.once('connect', onConnect);
socket.once('error', onError);
}

return this.socket;
return socket;
};
}
}
147 changes: 76 additions & 71 deletions lib/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,120 +9,125 @@ import { Client } from 'ssh2';
*/
export class SSH {
/**
* Create an SSH connection to a remote Docker daemon
* @param sshHost SSH host string (e.g., "ssh://user@host:22/var/run/docker.sock")
* @returns Promise that resolves to a connected socket through SSH tunnel
* Get SSH private key from common locations
* @returns SSH private key buffer or undefined
*/
static createConnection(sshHost: string): Promise<net.Socket> {
return new Promise((resolve, reject) => {
// Parse SSH URL: ssh://[user@]host[:port][/path/to/socket]
const sshUrl = sshHost.substring(6); // Remove "ssh://" prefix

let user = 'root'; // Default user
let host: string;
let port = 22; // Default SSH port
let socketPath = '/var/run/docker.sock'; // Default Docker socket path

// Parse user@host part
const atIndex = sshUrl.indexOf('@');
let hostPart: string;

if (atIndex !== -1) {
user = sshUrl.substring(0, atIndex);
hostPart = sshUrl.substring(atIndex + 1);
} else {
hostPart = sshUrl;
private static getPrivateKey(): Buffer | undefined {
const keyPaths = [
path.join(os.homedir(), '.ssh', 'id_rsa'),
path.join(os.homedir(), '.ssh', 'id_ed25519'),
path.join(os.homedir(), '.ssh', 'id_ecdsa'),
];

for (const keyPath of keyPaths) {
try {
if (fs.existsSync(keyPath)) {
return fs.readFileSync(keyPath);
}
} catch (err) {
// Continue to next key
}
}

// Parse host:port/path part
const slashIndex = hostPart.indexOf('/');
let hostPortPart: string;
return undefined;
}

if (slashIndex !== -1) {
hostPortPart = hostPart.substring(0, slashIndex);
socketPath = hostPart.substring(slashIndex);
} else {
hostPortPart = hostPart;
}
/**
* Create a socket factory function for SSH connections that can be used with SocketAgent
* @param sshHost SSH host string (e.g., "ssh://user@host:22/var/run/docker.sock")
* @returns Function that creates new SSH socket connections
*/
static createSocketFactory(sshHost: string): () => net.Socket {
// Parse SSH connection parameters once
const sshUrl = sshHost.substring(6); // Remove "ssh://" prefix

let user = 'root'; // Default user
let host: string;
let port = 22; // Default SSH port
let socketPath = '/var/run/docker.sock'; // Default Docker socket path

// Parse user@host part
const atIndex = sshUrl.indexOf('@');
let hostPart: string;

if (atIndex !== -1) {
user = sshUrl.substring(0, atIndex);
hostPart = sshUrl.substring(atIndex + 1);
} else {
hostPart = sshUrl;
}

// Parse host:port part
const colonIndex = hostPortPart.lastIndexOf(':');
if (colonIndex !== -1) {
host = hostPortPart.substring(0, colonIndex);
port = parseInt(hostPortPart.substring(colonIndex + 1)) || 22;
} else {
host = hostPortPart;
}
// Parse host:port/path part
const slashIndex = hostPart.indexOf('/');
let hostPortPart: string;

if (slashIndex !== -1) {
hostPortPart = hostPart.substring(0, slashIndex);
socketPath = hostPart.substring(slashIndex);
} else {
hostPortPart = hostPart;
}

// Parse host:port part
const colonIndex = hostPortPart.lastIndexOf(':');
if (colonIndex !== -1) {
host = hostPortPart.substring(0, colonIndex);
port = parseInt(hostPortPart.substring(colonIndex + 1)) || 22;
} else {
host = hostPortPart;
}

// Return factory function that creates new SSH connections
return () => {
const conn = new Client();
const sshStream = new net.Socket();

conn.on('ready', () => {
// Create a Unix socket connection through SSH
conn.openssh_forwardInStreamLocal(socketPath, (err, stream) => {
if (err) {
conn.end();
reject(
sshStream.emit(
'error',
new Error(
`Failed to create SSH tunnel to ${socketPath}: ${err.message}`,
),
);
return;
}

// Wrap the SSH stream as a net.Socket
const socket = stream as any as net.Socket;
// Pipe the SSH stream to our socket wrapper
stream.pipe(sshStream);
sshStream.pipe(stream);

// Handle SSH connection cleanup
socket.on('close', () => {
sshStream.on('close', () => {
conn.end();
});

resolve(socket);
sshStream.emit('connect');
});
});

conn.on('error', (err) => {
reject(
sshStream.emit(
'error',
new Error(
`SSH connection failed to ${user}@${host}:${port}: ${err.message}`,
),
);
});

// Connect using SSH key authentication (looks for default keys)
// TODO: Add support for password authentication and custom key paths
conn.connect({
host,
port,
username: user,
// Try common SSH key locations
privateKey: SSH.getPrivateKey(),
tryKeyboard: true,
});
});
}

/**
* Get SSH private key from common locations
* @returns SSH private key buffer or undefined
*/
private static getPrivateKey(): Buffer | undefined {
const keyPaths = [
path.join(os.homedir(), '.ssh', 'id_rsa'),
path.join(os.homedir(), '.ssh', 'id_ed25519'),
path.join(os.homedir(), '.ssh', 'id_ecdsa'),
];

for (const keyPath of keyPaths) {
try {
if (fs.existsSync(keyPath)) {
return fs.readFileSync(keyPath);
}
} catch (err) {
// Continue to next key
}
}

return undefined;
return sshStream;
};
}
}
Loading