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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ SSH_SERVER_[NAME]_DEFAULT_DIR=/path # Optional default working directory
SSH_SERVER_[NAME]_SUDO_PASSWORD=pass # Optional for automated sudo
SSH_SERVER_[NAME]_PLATFORM=windows # Optional: "linux" (default) or "windows"
SSH_SERVER_[NAME]_PROXYJUMP=bastion # Optional: name of another server to use as jump host
SSH_SERVER_[NAME]_PROXYCOMMAND=command # Optional: custom proxy command (ncat, ssh -W, etc.)
```

### TOML Format
Expand All @@ -171,6 +172,7 @@ default_dir = "/path" # Optional default working directory
sudo_password = "pass" # Optional for automated sudo
platform = "windows" # Optional: "linux" (default) or "windows"
proxy_jump = "bastion" # Optional: name of another server to use as jump host
proxy_command = "command" # Optional: custom proxy command (ncat, ssh -W, etc.)
```

## Key Implementation Details
Expand All @@ -185,6 +187,8 @@ proxy_jump = "bastion" # Optional: name of another server to

5. **Environment Loading**: Uses dotenv to load configuration from `.env` file in project root

6. **Proxy Command Support**: Custom proxy commands (SOCKS5, ssh -W, etc.) are executed locally to establish connections, with proper error handling and timeout management (src/index.js:389-432)

## Security Considerations

- Never commit `.env` files (included in .gitignore)
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ This release adds **12 new MCP tools** transforming SSH Manager into a comprehen
- **🔗 Multiple SSH Connections** - Manage unlimited SSH servers from a single interface
- **🔐 Secure Authentication** - Support for password, SSH key, and ssh-agent authentication (including passphrase-protected keys)
- **🔀 ProxyJump / Bastion Host** - Connect to servers behind jump hosts with chained multi-hop support
- **🔌 ProxyCommand / Custom Proxy** - Connect through SOCKS5 proxies or custom proxy commands (ncat, ssh -W, etc.)
- **📁 File Operations** - Upload and download files between local and remote systems
- **⚡ Command Execution** - Run commands on remote servers with working directory support
- **📂 Default Directories** - Set default working directories per server for convenience
Expand Down Expand Up @@ -568,6 +569,7 @@ SSH_SERVER_[NAME]_DEFAULT_DIR=/path/to/dir # Optional, default working director
SSH_SERVER_[NAME]_DESCRIPTION=Description # Optional
SSH_SERVER_[NAME]_PLATFORM=windows # Optional: "linux" (default) or "windows"
SSH_SERVER_[NAME]_PROXYJUMP=bastion # Optional: name of another server to use as jump host
SSH_SERVER_[NAME]_PROXYCOMMAND=command # Optional: custom proxy command (ncat, ssh -W, etc.)

# Example: Linux server
SSH_SERVER_PRODUCTION_HOST=prod.example.com
Expand Down Expand Up @@ -731,6 +733,37 @@ proxy_jump = "bastion"

**Chained jumps** are supported: if `bastion` itself has a `proxy_jump`, the chain is followed recursively. Circular references are detected and rejected.

### ProxyCommand / Custom Proxy

Connect through SOCKS5 proxies or custom proxy commands. The proxy command executes locally and forwards traffic to the remote host.

```env
# SOCKS5 proxy via ncat
SSH_SERVER_SOCKS_HOST=target.example.com
SSH_SERVER_SOCKS_USER=admin
SSH_SERVER_SOCKS_PROXYCOMMAND="ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p"

# Windows SSH proxy command
SSH_SERVER_WINPROXY_HOST=internal.example.com
SSH_SERVER_WINPROXY_USER=admin
SSH_SERVER_WINPROXY_PROXYCOMMAND="C:\Windows\System32\OpenSSH\ssh.exe -W %h:%p user@jump-host"
```

Or in TOML:
```toml
[ssh_servers.socks]
host = "target.example.com"
user = "admin"
proxy_command = "ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p"

[ssh_servers.winproxy]
host = "internal.example.com"
user = "admin"
proxy_command = "C:\\Windows\\System32\\OpenSSH\\ssh.exe -W %h:%p user@jump-host"
```

The proxy command must be a valid command that reads from stdin and writes to stdout, accepting `%h` and `%p` placeholders for host and port.

### Documentation
- [DEPLOYMENT_GUIDE.md](docs/DEPLOYMENT_GUIDE.md) - Deployment strategies and permission handling
- [ALIASES_AND_HOOKS.md](docs/ALIASES_AND_HOOKS.md) - Command aliases and automation hooks
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcp-ssh-manager",
"version": "3.2.2",
"version": "3.3.0",
"description": "MCP SSH Manager: Model Context Protocol server for SSH remote server management. Control SSH connections from Claude Code and OpenAI Codex - execute commands, transfer files, database operations, backups, health monitoring, and DevOps automation. NEW: Tool activation system reduces context usage by 92%",
"main": "src/index.js",
"bin": {
Expand Down
4 changes: 4 additions & 0 deletions src/config-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class ConfigLoader {
description: serverConfig.description,
platform: serverConfig.platform ? serverConfig.platform.toLowerCase() : undefined,
proxyJump: serverConfig.proxy_jump,
proxyCommand: serverConfig.proxy_command || serverConfig.proxycommand,
source: 'toml'
});
}
Expand Down Expand Up @@ -143,6 +144,7 @@ export class ConfigLoader {
description: env[`SSH_SERVER_${match[1]}_DESCRIPTION`],
platform: (env[`SSH_SERVER_${match[1]}_PLATFORM`] || '').toLowerCase() || undefined,
proxyJump: env[`SSH_SERVER_${match[1]}_PROXYJUMP`],
proxyCommand: env[`SSH_SERVER_${match[1]}_PROXYCOMMAND`],
source: 'env'
};

Expand Down Expand Up @@ -196,6 +198,7 @@ export class ConfigLoader {
if (server.description) serverConfig.description = server.description;
if (server.platform) serverConfig.platform = server.platform;
if (server.proxyJump) serverConfig.proxy_jump = server.proxyJump;
if (server.proxyCommand) serverConfig.proxy_command = server.proxyCommand;

config.ssh_servers[name] = serverConfig;
}
Expand Down Expand Up @@ -225,6 +228,7 @@ export class ConfigLoader {
if (server.description) lines.push(`SSH_SERVER_${upperName}_DESCRIPTION="${server.description}"`);
if (server.platform) lines.push(`SSH_SERVER_${upperName}_PLATFORM=${server.platform}`);
if (server.proxyJump) lines.push(`SSH_SERVER_${upperName}_PROXYJUMP=${server.proxyJump}`);
if (server.proxyCommand) lines.push(`SSH_SERVER_${upperName}_PROXYCOMMAND=${server.proxyCommand}`);
lines.push('');
}

Expand Down
61 changes: 60 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,56 @@ function cleanupOldConnections() {
}
}

// Create a socket from a proxy command (e.g., "ncat --proxy 127.0.0.1:1080 --proxy-type socks5 %h %p")
// The command is executed through the system shell, matching OpenSSH ProxyCommand semantics,
// so quoted arguments and shell metacharacters work as users expect.
async function createProxyCommandSocket(proxyCommand, host, port) {
const { spawn } = await import('child_process');
const { Duplex } = await import('stream');

const cmd = proxyCommand.replace(/%h/g, host).replace(/%p/g, port.toString());

return new Promise((resolve, reject) => {
const child = spawn(cmd, {
shell: true,
stdio: ['pipe', 'pipe', 'pipe']
});

const socket = Duplex.from({
readable: child.stdout,
writable: child.stdin,
allowHalfOpen: false
});

// Forward proxy stderr to the MCP server's stderr for debugging
child.stderr.on('data', (chunk) => {
process.stderr.write(`[proxy-command] ${chunk}`);
});

let settled = false;
const settle = (fn, arg) => {
if (settled) return;
settled = true;
fn(arg);
};

socket.on('close', () => {
if (!child.killed) child.kill();
});

child.on('error', (err) => settle(reject, err));
child.on('spawn', () => settle(resolve, socket));
child.on('exit', (code, signal) => {
// Only surface unexpected exits — a kill() after a successful connection is normal.
if (!settled && code !== 0) {
settle(reject, new Error(`Proxy command exited with code ${code}${signal ? ` (${signal})` : ''}`));
} else if (settled && code !== 0 && !signal && !socket.destroyed) {
socket.destroy(new Error(`Proxy command exited with code ${code}`));
}
});
});
}

// Get or create SSH connection with reconnection support
async function getConnection(serverName) {
const servers = loadServerConfig();
Expand Down Expand Up @@ -465,6 +515,14 @@ async function getConnection(serverName) {
await ssh.connect({ sock: stream });
jumpDependencies.set(normalizedName, jumpServerName);
ssh.jumpConnection = jumpSSH;
} else if (serverConfig.proxyCommand) {
// Create socket via proxy command (e.g., SOCKS5 proxy)
const socket = await createProxyCommandSocket(
serverConfig.proxyCommand,
serverConfig.host,
serverConfig.port || 22
);
await ssh.connect({ sock: socket });
} else {
await ssh.connect();
}
Expand All @@ -479,7 +537,8 @@ async function getConnection(serverName) {
host: serverConfig.host,
port: serverConfig.port,
method: serverConfig.password ? 'password' : 'key',
proxyJump: serverConfig.proxyJump || null
proxyJump: serverConfig.proxyJump || null,
proxyCommand: serverConfig.proxyCommand ? '<set>' : null
});

// Execute post-connect hook
Expand Down