Upload 5 files
Browse files- README.md +130 -3
- ShellMcp.cs +604 -0
- ShellMcp.csproj +23 -0
- SshBridge.cs +414 -0
- SshBridge.csproj +23 -0
README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shell MCP Server
|
| 2 |
+
|
| 3 |
+
Terminal access for Claude with two security modes, plus SSH bridge for remote servers.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Local shell** with safe/dangerous command separation
|
| 8 |
+
- **SSH Bridge** - GUI app for secure remote server access
|
| 9 |
+
- **Full visibility** - see every command Claude runs
|
| 10 |
+
- **Instant disconnect** - revoke access anytime
|
| 11 |
+
|
| 12 |
+
## Components
|
| 13 |
+
|
| 14 |
+
### 1. Shell MCP (`shell-mcp.dll`)
|
| 15 |
+
Local Windows terminal access with configurable command allowlists.
|
| 16 |
+
|
| 17 |
+
### 2. SSH Bridge (`ssh-bridge.exe`)
|
| 18 |
+
WinForms app that:
|
| 19 |
+
- You authenticate with password (never stored on disk)
|
| 20 |
+
- Claude sends commands through it
|
| 21 |
+
- You see all commands and output in real-time
|
| 22 |
+
- Click Disconnect to revoke access instantly
|
| 23 |
+
|
| 24 |
+
## Installation
|
| 25 |
+
|
| 26 |
+
### Prerequisites
|
| 27 |
+
- .NET 8.0 SDK
|
| 28 |
+
- Windows 10/11
|
| 29 |
+
|
| 30 |
+
### Build
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
git clone https://github.com/FreeOnlineUser/shell-mcp.git
|
| 34 |
+
cd shell-mcp
|
| 35 |
+
dotnet restore
|
| 36 |
+
dotnet build ShellMcp.csproj
|
| 37 |
+
dotnet build SshBridge.csproj
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### Configure Claude Desktop
|
| 41 |
+
|
| 42 |
+
Edit `%APPDATA%\Claude\claude_desktop_config.json`:
|
| 43 |
+
|
| 44 |
+
```json
|
| 45 |
+
{
|
| 46 |
+
"mcpServers": {
|
| 47 |
+
"shell_safe": {
|
| 48 |
+
"command": "dotnet",
|
| 49 |
+
"args": ["C:\\path\\to\\shell-mcp.dll"],
|
| 50 |
+
"env": {
|
| 51 |
+
"SHELL_MCP_MODE": "safe",
|
| 52 |
+
"SHELL_MCP_START_DIR": "C:\\your\\workspace"
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
"shell_dangerous": {
|
| 56 |
+
"command": "dotnet",
|
| 57 |
+
"args": ["C:\\path\\to\\shell-mcp.dll"],
|
| 58 |
+
"env": {
|
| 59 |
+
"SHELL_MCP_MODE": "dangerous",
|
| 60 |
+
"SHELL_MCP_START_DIR": "C:\\your\\workspace"
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
**Approval settings:**
|
| 68 |
+
- `shell_safe` → "Allow always"
|
| 69 |
+
- `shell_dangerous` → "Allow once" (asks every time)
|
| 70 |
+
|
| 71 |
+
## Tools
|
| 72 |
+
|
| 73 |
+
### Local Shell
|
| 74 |
+
| Tool | Description |
|
| 75 |
+
|------|-------------|
|
| 76 |
+
| `Shell` | Execute a local command |
|
| 77 |
+
| `Pwd` | Get current working directory |
|
| 78 |
+
| `ShellInfo` | Show mode and allowed commands |
|
| 79 |
+
| `ShellBatch` | Run multiple commands in sequence |
|
| 80 |
+
|
| 81 |
+
### SSH (requires SSH Bridge running)
|
| 82 |
+
| Tool | Description |
|
| 83 |
+
|------|-------------|
|
| 84 |
+
| `SshCommand` | Execute command on remote server |
|
| 85 |
+
| `SshStatus` | Check if SSH Bridge is connected |
|
| 86 |
+
|
| 87 |
+
## SSH Bridge Usage
|
| 88 |
+
|
| 89 |
+
1. Run `ssh-bridge.exe`
|
| 90 |
+
2. Enter host, username, and password
|
| 91 |
+
3. Click **Connect**
|
| 92 |
+
4. Window shows all commands Claude runs and their output
|
| 93 |
+
5. Click **Disconnect** anytime to revoke access
|
| 94 |
+
|
| 95 |
+
Password is held in memory only while connected - never written to disk.
|
| 96 |
+
|
| 97 |
+
## Security Model
|
| 98 |
+
|
| 99 |
+
### shell_safe (approve once)
|
| 100 |
+
Read-only and build commands:
|
| 101 |
+
- `dir`, `ls`, `type`, `cat`, `pwd`, `cd`
|
| 102 |
+
- `git status`, `git log`, `git diff`, `git branch`
|
| 103 |
+
- `dotnet build`, `dotnet run`, `dotnet test`
|
| 104 |
+
- `npm install`, `npm run`, `npm test`
|
| 105 |
+
|
| 106 |
+
### shell_dangerous (approve each time)
|
| 107 |
+
Modifying commands:
|
| 108 |
+
- `del`, `rm`, `rmdir`, `move`, `copy`, `mkdir`
|
| 109 |
+
- `git push`, `git commit`, `git reset`
|
| 110 |
+
- `taskkill`
|
| 111 |
+
|
| 112 |
+
### Always blocked
|
| 113 |
+
- `format`, `diskpart`, `regedit`
|
| 114 |
+
- `net user`, `net localgroup`
|
| 115 |
+
- `rm -rf /`, `del /s /q c:\`
|
| 116 |
+
|
| 117 |
+
### SSH Bridge
|
| 118 |
+
- You authenticate manually each session
|
| 119 |
+
- You see every command in real-time
|
| 120 |
+
- Disconnect button = instant revoke
|
| 121 |
+
- No password persistence
|
| 122 |
+
|
| 123 |
+
## Dependencies
|
| 124 |
+
|
| 125 |
+
- [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) - MCP SDK for .NET
|
| 126 |
+
- [SSH.NET](https://www.nuget.org/packages/SSH.NET) - SSH client library
|
| 127 |
+
|
| 128 |
+
## License
|
| 129 |
+
|
| 130 |
+
MIT
|
ShellMcp.cs
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System;
|
| 2 |
+
using System.Collections.Generic;
|
| 3 |
+
using System.ComponentModel;
|
| 4 |
+
using System.Diagnostics;
|
| 5 |
+
using System.IO;
|
| 6 |
+
using System.IO.Pipes;
|
| 7 |
+
using System.Linq;
|
| 8 |
+
using System.Text;
|
| 9 |
+
using System.Text.Json;
|
| 10 |
+
using System.Threading;
|
| 11 |
+
using System.Threading.Tasks;
|
| 12 |
+
using Microsoft.Extensions.DependencyInjection;
|
| 13 |
+
using Microsoft.Extensions.Hosting;
|
| 14 |
+
using Microsoft.Extensions.Logging;
|
| 15 |
+
using ModelContextProtocol.Server;
|
| 16 |
+
|
| 17 |
+
namespace ShellMcp
|
| 18 |
+
{
|
| 19 |
+
public class Program
|
| 20 |
+
{
|
| 21 |
+
public static async Task Main(string[] args)
|
| 22 |
+
{
|
| 23 |
+
var mode = Environment.GetEnvironmentVariable("SHELL_MCP_MODE") ?? "safe";
|
| 24 |
+
ShellExecutor.Initialize(mode);
|
| 25 |
+
|
| 26 |
+
var builder = Host.CreateApplicationBuilder(args);
|
| 27 |
+
|
| 28 |
+
builder.Logging.ClearProviders();
|
| 29 |
+
builder.Logging.AddConsole(options =>
|
| 30 |
+
{
|
| 31 |
+
options.LogToStandardErrorThreshold = LogLevel.Trace;
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
builder.Services
|
| 35 |
+
.AddMcpServer()
|
| 36 |
+
.WithStdioServerTransport()
|
| 37 |
+
.WithToolsFromAssembly();
|
| 38 |
+
|
| 39 |
+
await builder.Build().RunAsync();
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// ===============================================
|
| 44 |
+
// SHELL EXECUTOR
|
| 45 |
+
// ===============================================
|
| 46 |
+
|
| 47 |
+
public static class ShellExecutor
|
| 48 |
+
{
|
| 49 |
+
private static string _currentDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
| 50 |
+
private static string _mode = "safe";
|
| 51 |
+
private static int _defaultTimeout = 30;
|
| 52 |
+
|
| 53 |
+
private static readonly HashSet<string> SafeCommands = new(StringComparer.OrdinalIgnoreCase)
|
| 54 |
+
{
|
| 55 |
+
"dir", "ls", "pwd", "cd", "tree",
|
| 56 |
+
"type", "cat", "head", "tail", "more", "less", "find", "findstr", "grep", "where", "which",
|
| 57 |
+
"echo", "date", "time", "whoami", "hostname", "ver",
|
| 58 |
+
"git status", "git log", "git diff", "git branch", "git remote", "git fetch", "git show", "git ls-files", "git stash list",
|
| 59 |
+
"dotnet build", "dotnet run", "dotnet test", "dotnet restore", "dotnet clean", "dotnet --version", "dotnet --list-sdks",
|
| 60 |
+
"npm install", "npm run", "npm test", "npm list", "npm --version", "npm ci", "npm audit",
|
| 61 |
+
"node --version", "yarn --version", "yarn install", "yarn build", "yarn test",
|
| 62 |
+
"cls", "clear", "help", "man",
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
private static readonly HashSet<string> DangerousCommands = new(StringComparer.OrdinalIgnoreCase)
|
| 66 |
+
{
|
| 67 |
+
"del", "rm", "rmdir", "rd", "erase",
|
| 68 |
+
"move", "mv", "rename", "ren",
|
| 69 |
+
"copy", "cp", "xcopy", "robocopy",
|
| 70 |
+
"mkdir", "md",
|
| 71 |
+
"git push", "git pull", "git merge", "git rebase", "git reset", "git clean", "git checkout", "git commit", "git add", "git rm", "git stash",
|
| 72 |
+
"taskkill", "kill", "shutdown", "restart",
|
| 73 |
+
"npm install -g", "npm uninstall", "dotnet tool install",
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
private static readonly HashSet<string> BlockedCommands = new(StringComparer.OrdinalIgnoreCase)
|
| 77 |
+
{
|
| 78 |
+
"format", "diskpart", "reg", "regedit", "net user", "net localgroup",
|
| 79 |
+
"powershell -enc", "cmd /c", "rm -rf /", "del /s /q c:\\",
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
public static void Initialize(string mode)
|
| 83 |
+
{
|
| 84 |
+
_mode = mode.ToLowerInvariant();
|
| 85 |
+
var startDir = Environment.GetEnvironmentVariable("SHELL_MCP_START_DIR");
|
| 86 |
+
if (!string.IsNullOrEmpty(startDir) && Directory.Exists(startDir))
|
| 87 |
+
_currentDirectory = startDir;
|
| 88 |
+
|
| 89 |
+
// Set SSH Bridge path from environment
|
| 90 |
+
var bridgePath = Environment.GetEnvironmentVariable("SSH_BRIDGE_PATH");
|
| 91 |
+
if (!string.IsNullOrEmpty(bridgePath))
|
| 92 |
+
SshBridgeClient.SetBridgePath(bridgePath);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
public static string Mode => _mode;
|
| 96 |
+
public static string CurrentDirectory => _currentDirectory;
|
| 97 |
+
|
| 98 |
+
public static bool IsCommandAllowed(string command, out string reason)
|
| 99 |
+
{
|
| 100 |
+
reason = "";
|
| 101 |
+
string cmdLower = command.ToLowerInvariant().Trim();
|
| 102 |
+
string firstWord = cmdLower.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "";
|
| 103 |
+
|
| 104 |
+
foreach (var blocked in BlockedCommands)
|
| 105 |
+
{
|
| 106 |
+
if (cmdLower.Contains(blocked.ToLowerInvariant()))
|
| 107 |
+
{
|
| 108 |
+
reason = $"Command '{blocked}' is blocked for safety";
|
| 109 |
+
return false;
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (_mode == "safe")
|
| 114 |
+
{
|
| 115 |
+
bool isSafe = SafeCommands.Any(safe =>
|
| 116 |
+
cmdLower.StartsWith(safe.ToLowerInvariant()) ||
|
| 117 |
+
firstWord == safe.ToLowerInvariant().Split(' ')[0]);
|
| 118 |
+
|
| 119 |
+
if (!isSafe)
|
| 120 |
+
{
|
| 121 |
+
reason = $"Command '{firstWord}' is not in the safe list. Use shell_dangerous for this operation.";
|
| 122 |
+
return false;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
else if (_mode == "dangerous")
|
| 126 |
+
{
|
| 127 |
+
bool isAllowed = SafeCommands.Any(safe =>
|
| 128 |
+
cmdLower.StartsWith(safe.ToLowerInvariant()) ||
|
| 129 |
+
firstWord == safe.ToLowerInvariant().Split(' ')[0]) ||
|
| 130 |
+
DangerousCommands.Any(dangerous =>
|
| 131 |
+
cmdLower.StartsWith(dangerous.ToLowerInvariant()) ||
|
| 132 |
+
firstWord == dangerous.ToLowerInvariant().Split(' ')[0]);
|
| 133 |
+
|
| 134 |
+
if (!isAllowed)
|
| 135 |
+
{
|
| 136 |
+
reason = $"Command '{firstWord}' is not recognized.";
|
| 137 |
+
return false;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return true;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
public static CommandResult Execute(string command, int? timeoutSeconds = null)
|
| 145 |
+
{
|
| 146 |
+
var result = new CommandResult { Command = command };
|
| 147 |
+
int timeout = timeoutSeconds ?? _defaultTimeout;
|
| 148 |
+
|
| 149 |
+
try
|
| 150 |
+
{
|
| 151 |
+
if (command.Trim().StartsWith("cd ", StringComparison.OrdinalIgnoreCase) ||
|
| 152 |
+
command.Trim().Equals("cd", StringComparison.OrdinalIgnoreCase))
|
| 153 |
+
{
|
| 154 |
+
return HandleCd(command);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
if (!IsCommandAllowed(command, out string reason))
|
| 158 |
+
{
|
| 159 |
+
result.Success = false;
|
| 160 |
+
result.Stderr = reason;
|
| 161 |
+
result.ExitCode = -1;
|
| 162 |
+
return result;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
var psi = new ProcessStartInfo
|
| 166 |
+
{
|
| 167 |
+
FileName = "cmd.exe",
|
| 168 |
+
Arguments = $"/c {command}",
|
| 169 |
+
WorkingDirectory = _currentDirectory,
|
| 170 |
+
UseShellExecute = false,
|
| 171 |
+
RedirectStandardOutput = true,
|
| 172 |
+
RedirectStandardError = true,
|
| 173 |
+
CreateNoWindow = true,
|
| 174 |
+
StandardOutputEncoding = Encoding.UTF8,
|
| 175 |
+
StandardErrorEncoding = Encoding.UTF8,
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
using var process = new Process { StartInfo = psi };
|
| 179 |
+
var stdout = new StringBuilder();
|
| 180 |
+
var stderr = new StringBuilder();
|
| 181 |
+
|
| 182 |
+
process.OutputDataReceived += (s, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
| 183 |
+
process.ErrorDataReceived += (s, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
| 184 |
+
|
| 185 |
+
var sw = Stopwatch.StartNew();
|
| 186 |
+
process.Start();
|
| 187 |
+
process.BeginOutputReadLine();
|
| 188 |
+
process.BeginErrorReadLine();
|
| 189 |
+
|
| 190 |
+
bool completed = process.WaitForExit(timeout * 1000);
|
| 191 |
+
sw.Stop();
|
| 192 |
+
|
| 193 |
+
if (!completed)
|
| 194 |
+
{
|
| 195 |
+
try { process.Kill(entireProcessTree: true); } catch { }
|
| 196 |
+
result.Success = false;
|
| 197 |
+
result.Stderr = $"Command timed out after {timeout} seconds";
|
| 198 |
+
result.ExitCode = -1;
|
| 199 |
+
result.TimedOut = true;
|
| 200 |
+
}
|
| 201 |
+
else
|
| 202 |
+
{
|
| 203 |
+
result.Success = process.ExitCode == 0;
|
| 204 |
+
result.ExitCode = process.ExitCode;
|
| 205 |
+
result.Stdout = stdout.ToString().TrimEnd();
|
| 206 |
+
result.Stderr = stderr.ToString().TrimEnd();
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
result.ExecutionTimeMs = sw.ElapsedMilliseconds;
|
| 210 |
+
result.WorkingDirectory = _currentDirectory;
|
| 211 |
+
}
|
| 212 |
+
catch (Exception ex)
|
| 213 |
+
{
|
| 214 |
+
result.Success = false;
|
| 215 |
+
result.Stderr = $"Execution error: {ex.Message}";
|
| 216 |
+
result.ExitCode = -1;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return result;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
private static CommandResult HandleCd(string command)
|
| 223 |
+
{
|
| 224 |
+
var result = new CommandResult { Command = command };
|
| 225 |
+
var parts = command.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
| 226 |
+
|
| 227 |
+
if (parts.Length == 1)
|
| 228 |
+
{
|
| 229 |
+
result.Success = true;
|
| 230 |
+
result.Stdout = _currentDirectory;
|
| 231 |
+
result.WorkingDirectory = _currentDirectory;
|
| 232 |
+
return result;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
string targetPath = parts[1].Trim().Trim('"');
|
| 236 |
+
string newPath;
|
| 237 |
+
|
| 238 |
+
if (Path.IsPathRooted(targetPath))
|
| 239 |
+
newPath = targetPath;
|
| 240 |
+
else if (targetPath == "..")
|
| 241 |
+
newPath = Path.GetDirectoryName(_currentDirectory) ?? _currentDirectory;
|
| 242 |
+
else if (targetPath == "~")
|
| 243 |
+
newPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
| 244 |
+
else
|
| 245 |
+
newPath = Path.Combine(_currentDirectory, targetPath);
|
| 246 |
+
|
| 247 |
+
newPath = Path.GetFullPath(newPath);
|
| 248 |
+
|
| 249 |
+
if (Directory.Exists(newPath))
|
| 250 |
+
{
|
| 251 |
+
_currentDirectory = newPath;
|
| 252 |
+
result.Success = true;
|
| 253 |
+
result.Stdout = _currentDirectory;
|
| 254 |
+
result.WorkingDirectory = _currentDirectory;
|
| 255 |
+
}
|
| 256 |
+
else
|
| 257 |
+
{
|
| 258 |
+
result.Success = false;
|
| 259 |
+
result.Stderr = $"Directory not found: {newPath}";
|
| 260 |
+
result.ExitCode = 1;
|
| 261 |
+
result.WorkingDirectory = _currentDirectory;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return result;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
public static string GetAllowedCommands()
|
| 268 |
+
{
|
| 269 |
+
var sb = new StringBuilder();
|
| 270 |
+
sb.AppendLine($"Mode: {_mode}");
|
| 271 |
+
sb.AppendLine();
|
| 272 |
+
sb.AppendLine("Safe commands (always allowed):");
|
| 273 |
+
foreach (var cmd in SafeCommands.OrderBy(c => c))
|
| 274 |
+
sb.AppendLine($" - {cmd}");
|
| 275 |
+
|
| 276 |
+
if (_mode == "dangerous")
|
| 277 |
+
{
|
| 278 |
+
sb.AppendLine();
|
| 279 |
+
sb.AppendLine("Dangerous commands (available in this mode):");
|
| 280 |
+
foreach (var cmd in DangerousCommands.OrderBy(c => c))
|
| 281 |
+
sb.AppendLine($" - {cmd}");
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
sb.AppendLine();
|
| 285 |
+
sb.AppendLine("Blocked commands (never allowed):");
|
| 286 |
+
foreach (var cmd in BlockedCommands.OrderBy(c => c))
|
| 287 |
+
sb.AppendLine($" - {cmd}");
|
| 288 |
+
|
| 289 |
+
return sb.ToString();
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
public class CommandResult
|
| 294 |
+
{
|
| 295 |
+
public string Command { get; set; } = "";
|
| 296 |
+
public bool Success { get; set; }
|
| 297 |
+
public string Stdout { get; set; } = "";
|
| 298 |
+
public string Stderr { get; set; } = "";
|
| 299 |
+
public int ExitCode { get; set; }
|
| 300 |
+
public long ExecutionTimeMs { get; set; }
|
| 301 |
+
public string WorkingDirectory { get; set; } = "";
|
| 302 |
+
public bool TimedOut { get; set; }
|
| 303 |
+
|
| 304 |
+
public override string ToString()
|
| 305 |
+
{
|
| 306 |
+
var sb = new StringBuilder();
|
| 307 |
+
if (!string.IsNullOrEmpty(Stdout))
|
| 308 |
+
sb.AppendLine(Stdout);
|
| 309 |
+
if (!string.IsNullOrEmpty(Stderr))
|
| 310 |
+
{
|
| 311 |
+
if (sb.Length > 0) sb.AppendLine();
|
| 312 |
+
sb.AppendLine($"[stderr] {Stderr}");
|
| 313 |
+
}
|
| 314 |
+
sb.AppendLine();
|
| 315 |
+
sb.AppendLine($"[exit: {ExitCode}] [time: {ExecutionTimeMs}ms] [cwd: {WorkingDirectory}]");
|
| 316 |
+
if (TimedOut)
|
| 317 |
+
sb.AppendLine("[TIMED OUT]");
|
| 318 |
+
return sb.ToString();
|
| 319 |
+
}
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// ===============================================
|
| 323 |
+
// SSH BRIDGE CLIENT
|
| 324 |
+
// ===============================================
|
| 325 |
+
|
| 326 |
+
public static class SshBridgeClient
|
| 327 |
+
{
|
| 328 |
+
private const int PORT = 52718;
|
| 329 |
+
|
| 330 |
+
private static string? _bridgePath;
|
| 331 |
+
|
| 332 |
+
public static void SetBridgePath(string path)
|
| 333 |
+
{
|
| 334 |
+
_bridgePath = path;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
public static string GetStatus()
|
| 338 |
+
{
|
| 339 |
+
try
|
| 340 |
+
{
|
| 341 |
+
return SendCommand("__STATUS__");
|
| 342 |
+
}
|
| 343 |
+
catch
|
| 344 |
+
{
|
| 345 |
+
return "DISCONNECTED";
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
public static bool LaunchBridge()
|
| 350 |
+
{
|
| 351 |
+
if (string.IsNullOrEmpty(_bridgePath) || !System.IO.File.Exists(_bridgePath))
|
| 352 |
+
return false;
|
| 353 |
+
|
| 354 |
+
try
|
| 355 |
+
{
|
| 356 |
+
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
| 357 |
+
{
|
| 358 |
+
FileName = _bridgePath,
|
| 359 |
+
UseShellExecute = true
|
| 360 |
+
});
|
| 361 |
+
return true;
|
| 362 |
+
}
|
| 363 |
+
catch
|
| 364 |
+
{
|
| 365 |
+
return false;
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
public static bool IsBridgeRunning()
|
| 370 |
+
{
|
| 371 |
+
try
|
| 372 |
+
{
|
| 373 |
+
using var client = new System.Net.Sockets.TcpClient();
|
| 374 |
+
client.Connect("127.0.0.1", PORT);
|
| 375 |
+
return true;
|
| 376 |
+
}
|
| 377 |
+
catch
|
| 378 |
+
{
|
| 379 |
+
return false;
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
public static string SendCommand(string command)
|
| 384 |
+
{
|
| 385 |
+
using var client = new System.Net.Sockets.TcpClient();
|
| 386 |
+
client.Connect("127.0.0.1", PORT);
|
| 387 |
+
|
| 388 |
+
using var stream = client.GetStream();
|
| 389 |
+
using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
|
| 390 |
+
using var reader = new StreamReader(stream, Encoding.UTF8);
|
| 391 |
+
|
| 392 |
+
writer.WriteLine(command);
|
| 393 |
+
var response = reader.ReadLine() ?? "No response";
|
| 394 |
+
// Decode newlines
|
| 395 |
+
return response.Replace("<<CRLF>>", "\r\n").Replace("<<LF>>", "\n");
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
public static string Prefill(string host, int port, string user, string? password = null)
|
| 399 |
+
{
|
| 400 |
+
var cmd = $"__PREFILL__:{host}:{port}:{user}";
|
| 401 |
+
if (!string.IsNullOrEmpty(password))
|
| 402 |
+
cmd += $":{password}";
|
| 403 |
+
return SendCommand(cmd);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
public static string TriggerConnect()
|
| 407 |
+
{
|
| 408 |
+
return SendCommand("__CONNECT__");
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// ===============================================
|
| 413 |
+
// MCP TOOLS
|
| 414 |
+
// ===============================================
|
| 415 |
+
|
| 416 |
+
[McpServerToolType]
|
| 417 |
+
public static class ShellTools
|
| 418 |
+
{
|
| 419 |
+
[McpServerTool, Description("Execute a shell command. Working directory persists across calls. Use 'cd' to navigate. Check 'shell_info' for allowed commands.")]
|
| 420 |
+
public static string Shell(
|
| 421 |
+
[Description("Command to execute")] string command,
|
| 422 |
+
[Description("Timeout in seconds (default: 30)")] int? timeout = null)
|
| 423 |
+
{
|
| 424 |
+
if (string.IsNullOrWhiteSpace(command))
|
| 425 |
+
return "Error: No command provided";
|
| 426 |
+
|
| 427 |
+
var result = ShellExecutor.Execute(command, timeout);
|
| 428 |
+
return result.Success ? result.ToString() : $"❌ Command failed:\n{result}";
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
[McpServerTool, Description("Get current working directory")]
|
| 432 |
+
public static string Pwd()
|
| 433 |
+
{
|
| 434 |
+
return ShellExecutor.CurrentDirectory;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
[McpServerTool, Description("Show shell mode and list of allowed commands")]
|
| 438 |
+
public static string ShellInfo()
|
| 439 |
+
{
|
| 440 |
+
var sb = new StringBuilder();
|
| 441 |
+
sb.AppendLine($"Shell MCP - {ShellExecutor.Mode.ToUpperInvariant()} mode");
|
| 442 |
+
sb.AppendLine($"Current directory: {ShellExecutor.CurrentDirectory}");
|
| 443 |
+
sb.AppendLine();
|
| 444 |
+
sb.AppendLine(ShellExecutor.GetAllowedCommands());
|
| 445 |
+
return sb.ToString();
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
[McpServerTool, Description("Execute multiple commands in sequence. Stops on first failure unless continue_on_error is true.")]
|
| 449 |
+
public static string ShellBatch(
|
| 450 |
+
[Description("JSON array of commands: [\"cmd1\", \"cmd2\", ...]")] string commands_json,
|
| 451 |
+
[Description("Continue executing even if a command fails")] bool continue_on_error = false,
|
| 452 |
+
[Description("Timeout per command in seconds")] int? timeout = null)
|
| 453 |
+
{
|
| 454 |
+
try
|
| 455 |
+
{
|
| 456 |
+
var commands = JsonSerializer.Deserialize<List<string>>(commands_json);
|
| 457 |
+
if (commands == null || commands.Count == 0)
|
| 458 |
+
return "Error: No commands provided";
|
| 459 |
+
|
| 460 |
+
var sb = new StringBuilder();
|
| 461 |
+
int succeeded = 0, failed = 0;
|
| 462 |
+
|
| 463 |
+
foreach (var cmd in commands)
|
| 464 |
+
{
|
| 465 |
+
sb.AppendLine($"$ {cmd}");
|
| 466 |
+
var result = ShellExecutor.Execute(cmd, timeout);
|
| 467 |
+
sb.AppendLine(result.ToString());
|
| 468 |
+
|
| 469 |
+
if (result.Success)
|
| 470 |
+
succeeded++;
|
| 471 |
+
else
|
| 472 |
+
{
|
| 473 |
+
failed++;
|
| 474 |
+
if (!continue_on_error)
|
| 475 |
+
{
|
| 476 |
+
sb.AppendLine($"❌ Batch stopped. {succeeded} succeeded, {failed} failed, {commands.Count - succeeded - failed} skipped.");
|
| 477 |
+
return sb.ToString();
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
sb.AppendLine($"✅ Batch complete: {succeeded} succeeded, {failed} failed");
|
| 483 |
+
return sb.ToString();
|
| 484 |
+
}
|
| 485 |
+
catch (Exception ex)
|
| 486 |
+
{
|
| 487 |
+
return $"Error parsing commands: {ex.Message}";
|
| 488 |
+
}
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
[McpServerTool, Description("Execute a command on a remote server via SSH Bridge. Requires SSH Bridge app to be running and connected.")]
|
| 492 |
+
public static string SshCommand(
|
| 493 |
+
[Description("Command to execute on the remote server")] string command)
|
| 494 |
+
{
|
| 495 |
+
if (string.IsNullOrWhiteSpace(command))
|
| 496 |
+
return "Error: No command provided";
|
| 497 |
+
|
| 498 |
+
try
|
| 499 |
+
{
|
| 500 |
+
// Check if bridge is running, launch if not
|
| 501 |
+
if (!SshBridgeClient.IsBridgeRunning())
|
| 502 |
+
{
|
| 503 |
+
if (SshBridgeClient.LaunchBridge())
|
| 504 |
+
{
|
| 505 |
+
return "🚀 SSH Bridge launched!\n\nPlease:\n1. Enter host, user, and password in the window that just opened\n2. Click Connect\n3. Try this command again";
|
| 506 |
+
}
|
| 507 |
+
else
|
| 508 |
+
{
|
| 509 |
+
return "❌ SSH Bridge not running and could not auto-launch.\n\nPlease run ssh-bridge.exe manually, or set SSH_BRIDGE_PATH in your Claude config.";
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
var status = SshBridgeClient.GetStatus();
|
| 514 |
+
if (status == "DISCONNECTED")
|
| 515 |
+
{
|
| 516 |
+
return "⚠️ SSH Bridge is open but not connected.\n\nPlease:\n1. Enter host, user, and password\n2. Click Connect\n3. Try this command again";
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
var result = SshBridgeClient.SendCommand(command);
|
| 520 |
+
return $"📡 {status}\n\n{result}";
|
| 521 |
+
}
|
| 522 |
+
catch (Exception ex)
|
| 523 |
+
{
|
| 524 |
+
return $"❌ SSH error: {ex.Message}";
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
[McpServerTool, Description("Check if SSH Bridge is connected")]
|
| 529 |
+
public static string SshStatus()
|
| 530 |
+
{
|
| 531 |
+
try
|
| 532 |
+
{
|
| 533 |
+
if (!SshBridgeClient.IsBridgeRunning())
|
| 534 |
+
{
|
| 535 |
+
return "❌ SSH Bridge not running";
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
var status = SshBridgeClient.GetStatus();
|
| 539 |
+
if (status.StartsWith("CONNECTED:"))
|
| 540 |
+
{
|
| 541 |
+
return $"✅ {status.Replace("CONNECTED:", "Connected to ")}";
|
| 542 |
+
}
|
| 543 |
+
return "⚠️ SSH Bridge open but not connected";
|
| 544 |
+
}
|
| 545 |
+
catch
|
| 546 |
+
{
|
| 547 |
+
return "❌ SSH Bridge not running";
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
[McpServerTool, Description("Pre-fill SSH Bridge connection details. Launches SSH Bridge if not running, fills in host/port/user, optionally password. User must click Connect or you can call with auto_connect=true.")]
|
| 552 |
+
public static string SshPrefill(
|
| 553 |
+
[Description("SSH host/IP address")] string host,
|
| 554 |
+
[Description("SSH username")] string user,
|
| 555 |
+
[Description("SSH port (default: 22)")] int port = 22,
|
| 556 |
+
[Description("SSH password (optional - user can enter manually for security)")] string? password = null,
|
| 557 |
+
[Description("Automatically click Connect after prefilling")] bool auto_connect = false)
|
| 558 |
+
{
|
| 559 |
+
try
|
| 560 |
+
{
|
| 561 |
+
// Launch bridge if not running
|
| 562 |
+
if (!SshBridgeClient.IsBridgeRunning())
|
| 563 |
+
{
|
| 564 |
+
if (!SshBridgeClient.LaunchBridge())
|
| 565 |
+
{
|
| 566 |
+
return "❌ Could not launch SSH Bridge. Set SSH_BRIDGE_PATH in your Claude config.";
|
| 567 |
+
}
|
| 568 |
+
// Wait for it to start
|
| 569 |
+
Thread.Sleep(1000);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// Prefill the fields
|
| 573 |
+
var result = SshBridgeClient.Prefill(host, port, user, password);
|
| 574 |
+
if (result != "PREFILLED")
|
| 575 |
+
{
|
| 576 |
+
return $"❌ Prefill failed: {result}";
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
if (auto_connect && !string.IsNullOrEmpty(password))
|
| 580 |
+
{
|
| 581 |
+
Thread.Sleep(200);
|
| 582 |
+
SshBridgeClient.TriggerConnect();
|
| 583 |
+
Thread.Sleep(2000); // Wait for connection
|
| 584 |
+
var status = SshBridgeClient.GetStatus();
|
| 585 |
+
if (status.StartsWith("CONNECTED:"))
|
| 586 |
+
{
|
| 587 |
+
return $"✅ Connected to {user}@{host}:{port}";
|
| 588 |
+
}
|
| 589 |
+
return $"⚠️ Connection initiated. Status: {status}";
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
if (string.IsNullOrEmpty(password))
|
| 593 |
+
{
|
| 594 |
+
return $"📝 SSH Bridge prefilled with {user}@{host}:{port}\n\nPlease enter password and click Connect.";
|
| 595 |
+
}
|
| 596 |
+
return $"📝 SSH Bridge prefilled with {user}@{host}:{port}\n\nClick Connect when ready.";
|
| 597 |
+
}
|
| 598 |
+
catch (Exception ex)
|
| 599 |
+
{
|
| 600 |
+
return $"❌ Error: {ex.Message}";
|
| 601 |
+
}
|
| 602 |
+
}
|
| 603 |
+
}
|
| 604 |
+
}
|
ShellMcp.csproj
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
|
| 3 |
+
<PropertyGroup>
|
| 4 |
+
<OutputType>Exe</OutputType>
|
| 5 |
+
<TargetFramework>net8.0</TargetFramework>
|
| 6 |
+
<ImplicitUsings>disable</ImplicitUsings>
|
| 7 |
+
<Nullable>enable</Nullable>
|
| 8 |
+
<AssemblyName>shell-mcp</AssemblyName>
|
| 9 |
+
<RootNamespace>ShellMcp</RootNamespace>
|
| 10 |
+
</PropertyGroup>
|
| 11 |
+
|
| 12 |
+
<!-- Only compile ShellMcp.cs -->
|
| 13 |
+
<ItemGroup>
|
| 14 |
+
<Compile Remove="*.cs" />
|
| 15 |
+
<Compile Include="ShellMcp.cs" />
|
| 16 |
+
</ItemGroup>
|
| 17 |
+
|
| 18 |
+
<ItemGroup>
|
| 19 |
+
<PackageReference Include="ModelContextProtocol" Version="0.1.0-preview.10" />
|
| 20 |
+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
| 21 |
+
</ItemGroup>
|
| 22 |
+
|
| 23 |
+
</Project>
|
SshBridge.cs
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System;
|
| 2 |
+
using System.Drawing;
|
| 3 |
+
using System.IO;
|
| 4 |
+
using System.Net;
|
| 5 |
+
using System.Net.Sockets;
|
| 6 |
+
using System.Text;
|
| 7 |
+
using System.Threading;
|
| 8 |
+
using System.Threading.Tasks;
|
| 9 |
+
using System.Windows.Forms;
|
| 10 |
+
using Renci.SshNet;
|
| 11 |
+
|
| 12 |
+
namespace SshBridge
|
| 13 |
+
{
|
| 14 |
+
public class Program
|
| 15 |
+
{
|
| 16 |
+
[STAThread]
|
| 17 |
+
public static void Main(string[] args)
|
| 18 |
+
{
|
| 19 |
+
Application.EnableVisualStyles();
|
| 20 |
+
Application.SetCompatibleTextRenderingDefault(false);
|
| 21 |
+
Application.Run(new SshBridgeForm());
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
public class SshBridgeForm : Form
|
| 26 |
+
{
|
| 27 |
+
private RichTextBox _outputBox = null!;
|
| 28 |
+
private TextBox _hostBox = null!;
|
| 29 |
+
private TextBox _portBox = null!;
|
| 30 |
+
private TextBox _userBox = null!;
|
| 31 |
+
private TextBox _passwordBox = null!;
|
| 32 |
+
private Button _connectButton = null!;
|
| 33 |
+
private Button _disconnectButton = null!;
|
| 34 |
+
private Label _statusLabel = null!;
|
| 35 |
+
private Panel _loginPanel = null!;
|
| 36 |
+
private Panel _sessionPanel = null!;
|
| 37 |
+
|
| 38 |
+
private Thread? _serverThread;
|
| 39 |
+
private CancellationTokenSource? _cts;
|
| 40 |
+
private SshClient? _sshClient;
|
| 41 |
+
private bool _isConnected;
|
| 42 |
+
private int _commandCount;
|
| 43 |
+
private string _currentHost = "";
|
| 44 |
+
private string _currentUser = "";
|
| 45 |
+
|
| 46 |
+
private const int PORT = 52718;
|
| 47 |
+
|
| 48 |
+
public SshBridgeForm()
|
| 49 |
+
{
|
| 50 |
+
InitializeComponents();
|
| 51 |
+
StartTcpServer();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
private void InitializeComponents()
|
| 55 |
+
{
|
| 56 |
+
this.Text = "SSH Bridge for Claude";
|
| 57 |
+
this.Size = new Size(700, 500);
|
| 58 |
+
this.MinimumSize = new Size(500, 400);
|
| 59 |
+
this.StartPosition = FormStartPosition.CenterScreen;
|
| 60 |
+
this.FormBorderStyle = FormBorderStyle.Sizable;
|
| 61 |
+
|
| 62 |
+
// Login Panel
|
| 63 |
+
_loginPanel = new Panel
|
| 64 |
+
{
|
| 65 |
+
Dock = DockStyle.Fill,
|
| 66 |
+
Padding = new Padding(20),
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
var loginLayout = new TableLayoutPanel
|
| 70 |
+
{
|
| 71 |
+
Dock = DockStyle.Fill,
|
| 72 |
+
ColumnCount = 2,
|
| 73 |
+
RowCount = 6,
|
| 74 |
+
Padding = new Padding(50, 30, 50, 30),
|
| 75 |
+
};
|
| 76 |
+
loginLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 100));
|
| 77 |
+
loginLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
| 78 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
|
| 79 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
|
| 80 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
|
| 81 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40));
|
| 82 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50));
|
| 83 |
+
loginLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
| 84 |
+
|
| 85 |
+
loginLayout.Controls.Add(new Label { Text = "Host:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 0);
|
| 86 |
+
_hostBox = new TextBox { Dock = DockStyle.Fill, Text = "" };
|
| 87 |
+
loginLayout.Controls.Add(_hostBox, 1, 0);
|
| 88 |
+
|
| 89 |
+
loginLayout.Controls.Add(new Label { Text = "Port:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 1);
|
| 90 |
+
_portBox = new TextBox { Dock = DockStyle.Fill, Text = "22", Width = 80 };
|
| 91 |
+
loginLayout.Controls.Add(_portBox, 1, 1);
|
| 92 |
+
|
| 93 |
+
loginLayout.Controls.Add(new Label { Text = "User:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 2);
|
| 94 |
+
_userBox = new TextBox { Dock = DockStyle.Fill, Text = "" };
|
| 95 |
+
loginLayout.Controls.Add(_userBox, 1, 2);
|
| 96 |
+
|
| 97 |
+
loginLayout.Controls.Add(new Label { Text = "Password:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 3);
|
| 98 |
+
_passwordBox = new TextBox { Dock = DockStyle.Fill, UseSystemPasswordChar = true };
|
| 99 |
+
_passwordBox.KeyPress += (s, e) => { if (e.KeyChar == (char)Keys.Enter) Connect(); };
|
| 100 |
+
loginLayout.Controls.Add(_passwordBox, 1, 3);
|
| 101 |
+
|
| 102 |
+
_connectButton = new Button { Text = "Connect", Width = 100, Height = 35 };
|
| 103 |
+
_connectButton.Click += (s, e) => Connect();
|
| 104 |
+
var buttonPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight };
|
| 105 |
+
buttonPanel.Controls.Add(_connectButton);
|
| 106 |
+
loginLayout.Controls.Add(buttonPanel, 1, 4);
|
| 107 |
+
|
| 108 |
+
_loginPanel.Controls.Add(loginLayout);
|
| 109 |
+
|
| 110 |
+
// Session Panel
|
| 111 |
+
_sessionPanel = new Panel
|
| 112 |
+
{
|
| 113 |
+
Dock = DockStyle.Fill,
|
| 114 |
+
Visible = false,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
var topBar = new Panel
|
| 118 |
+
{
|
| 119 |
+
Dock = DockStyle.Top,
|
| 120 |
+
Height = 40,
|
| 121 |
+
BackColor = Color.FromArgb(45, 45, 48),
|
| 122 |
+
Padding = new Padding(10, 5, 10, 5),
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
_statusLabel = new Label
|
| 126 |
+
{
|
| 127 |
+
Text = "Disconnected",
|
| 128 |
+
ForeColor = Color.White,
|
| 129 |
+
AutoSize = true,
|
| 130 |
+
Location = new Point(10, 10),
|
| 131 |
+
Font = new Font("Segoe UI", 10),
|
| 132 |
+
};
|
| 133 |
+
topBar.Controls.Add(_statusLabel);
|
| 134 |
+
|
| 135 |
+
_disconnectButton = new Button
|
| 136 |
+
{
|
| 137 |
+
Text = "Disconnect",
|
| 138 |
+
ForeColor = Color.White,
|
| 139 |
+
BackColor = Color.FromArgb(180, 50, 50),
|
| 140 |
+
FlatStyle = FlatStyle.Flat,
|
| 141 |
+
Width = 90,
|
| 142 |
+
Height = 28,
|
| 143 |
+
Anchor = AnchorStyles.Right,
|
| 144 |
+
};
|
| 145 |
+
_disconnectButton.Location = new Point(topBar.Width - _disconnectButton.Width - 10, 6);
|
| 146 |
+
_disconnectButton.Click += (s, e) => Disconnect();
|
| 147 |
+
topBar.Controls.Add(_disconnectButton);
|
| 148 |
+
topBar.Resize += (s, e) => _disconnectButton.Location = new Point(topBar.Width - _disconnectButton.Width - 10, 6);
|
| 149 |
+
|
| 150 |
+
_outputBox = new RichTextBox
|
| 151 |
+
{
|
| 152 |
+
Dock = DockStyle.Fill,
|
| 153 |
+
ReadOnly = true,
|
| 154 |
+
BackColor = Color.FromArgb(30, 30, 30),
|
| 155 |
+
ForeColor = Color.FromArgb(220, 220, 220),
|
| 156 |
+
Font = new Font("Consolas", 10),
|
| 157 |
+
WordWrap = false,
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
_sessionPanel.Controls.Add(_outputBox);
|
| 161 |
+
_sessionPanel.Controls.Add(topBar);
|
| 162 |
+
|
| 163 |
+
this.Controls.Add(_loginPanel);
|
| 164 |
+
this.Controls.Add(_sessionPanel);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
private void Connect()
|
| 168 |
+
{
|
| 169 |
+
string host = _hostBox.Text.Trim();
|
| 170 |
+
string user = _userBox.Text.Trim();
|
| 171 |
+
string password = _passwordBox.Text;
|
| 172 |
+
int port = 22;
|
| 173 |
+
int.TryParse(_portBox.Text.Trim(), out port);
|
| 174 |
+
if (port <= 0 || port > 65535) port = 22;
|
| 175 |
+
|
| 176 |
+
if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(password))
|
| 177 |
+
{
|
| 178 |
+
MessageBox.Show("Please fill in all fields.", "Connection Error", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
_connectButton.Enabled = false;
|
| 183 |
+
_connectButton.Text = "Connecting...";
|
| 184 |
+
|
| 185 |
+
Task.Run(() =>
|
| 186 |
+
{
|
| 187 |
+
try
|
| 188 |
+
{
|
| 189 |
+
_sshClient = new SshClient(host, port, user, password);
|
| 190 |
+
_sshClient.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10);
|
| 191 |
+
_sshClient.Connect();
|
| 192 |
+
|
| 193 |
+
if (_sshClient.IsConnected)
|
| 194 |
+
{
|
| 195 |
+
_currentHost = host;
|
| 196 |
+
_currentUser = user;
|
| 197 |
+
_commandCount = 0;
|
| 198 |
+
|
| 199 |
+
this.Invoke(() =>
|
| 200 |
+
{
|
| 201 |
+
_passwordBox.Clear();
|
| 202 |
+
OnConnected();
|
| 203 |
+
});
|
| 204 |
+
}
|
| 205 |
+
else
|
| 206 |
+
{
|
| 207 |
+
throw new Exception("Connection failed");
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
catch (Exception ex)
|
| 211 |
+
{
|
| 212 |
+
this.Invoke(() =>
|
| 213 |
+
{
|
| 214 |
+
MessageBox.Show($"Connection failed:\n{ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
| 215 |
+
_connectButton.Enabled = true;
|
| 216 |
+
_connectButton.Text = "Connect";
|
| 217 |
+
});
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
private void OnConnected()
|
| 223 |
+
{
|
| 224 |
+
_isConnected = true;
|
| 225 |
+
_loginPanel.Visible = false;
|
| 226 |
+
_sessionPanel.Visible = true;
|
| 227 |
+
_statusLabel.Text = $"Connected to {_currentUser}@{_currentHost}";
|
| 228 |
+
_outputBox.Clear();
|
| 229 |
+
AppendOutput($"=== Connected to {_currentUser}@{_currentHost} ===", Color.LimeGreen);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
private void Disconnect()
|
| 233 |
+
{
|
| 234 |
+
_isConnected = false;
|
| 235 |
+
|
| 236 |
+
try
|
| 237 |
+
{
|
| 238 |
+
_sshClient?.Disconnect();
|
| 239 |
+
_sshClient?.Dispose();
|
| 240 |
+
}
|
| 241 |
+
catch { }
|
| 242 |
+
|
| 243 |
+
_sshClient = null;
|
| 244 |
+
_currentHost = "";
|
| 245 |
+
_currentUser = "";
|
| 246 |
+
|
| 247 |
+
_loginPanel.Visible = true;
|
| 248 |
+
_sessionPanel.Visible = false;
|
| 249 |
+
_connectButton.Enabled = true;
|
| 250 |
+
_connectButton.Text = "Connect";
|
| 251 |
+
_statusLabel.Text = "Disconnected";
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
private void AppendOutput(string? text, Color? color = null)
|
| 255 |
+
{
|
| 256 |
+
if (string.IsNullOrEmpty(text)) return;
|
| 257 |
+
|
| 258 |
+
if (_outputBox.InvokeRequired)
|
| 259 |
+
{
|
| 260 |
+
_outputBox.Invoke(() => AppendOutput(text, color));
|
| 261 |
+
return;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Normalize line endings to Windows style
|
| 265 |
+
var normalized = text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n");
|
| 266 |
+
|
| 267 |
+
_outputBox.SelectionStart = _outputBox.TextLength;
|
| 268 |
+
_outputBox.SelectionLength = 0;
|
| 269 |
+
_outputBox.SelectionColor = color ?? Color.FromArgb(220, 220, 220);
|
| 270 |
+
_outputBox.AppendText(normalized + "\r\n");
|
| 271 |
+
_outputBox.SelectionColor = _outputBox.ForeColor;
|
| 272 |
+
_outputBox.ScrollToCaret();
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
public string ExecuteCommand(string command)
|
| 276 |
+
{
|
| 277 |
+
if (!_isConnected || _sshClient == null || !_sshClient.IsConnected)
|
| 278 |
+
{
|
| 279 |
+
return "ERROR: Not connected. Open SSH Bridge and connect first.";
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
try
|
| 283 |
+
{
|
| 284 |
+
_commandCount++;
|
| 285 |
+
this.Invoke(() => _statusLabel.Text = $"Connected to {_currentUser}@{_currentHost} ({_commandCount} commands)");
|
| 286 |
+
|
| 287 |
+
AppendOutput($"> {command}", Color.Cyan);
|
| 288 |
+
|
| 289 |
+
using var cmd = _sshClient.CreateCommand(command);
|
| 290 |
+
cmd.CommandTimeout = TimeSpan.FromSeconds(30);
|
| 291 |
+
var result = cmd.Execute();
|
| 292 |
+
var error = cmd.Error;
|
| 293 |
+
|
| 294 |
+
var output = result.Trim();
|
| 295 |
+
if (!string.IsNullOrEmpty(error))
|
| 296 |
+
{
|
| 297 |
+
output += "\n[stderr] " + error.Trim();
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
if (string.IsNullOrEmpty(output))
|
| 301 |
+
{
|
| 302 |
+
output = "(no output)";
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
AppendOutput(output);
|
| 306 |
+
|
| 307 |
+
return output;
|
| 308 |
+
}
|
| 309 |
+
catch (Exception ex)
|
| 310 |
+
{
|
| 311 |
+
var error = $"ERROR: {ex.Message}";
|
| 312 |
+
AppendOutput(error, Color.Red);
|
| 313 |
+
return error;
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
private void StartTcpServer()
|
| 318 |
+
{
|
| 319 |
+
_cts = new CancellationTokenSource();
|
| 320 |
+
_serverThread = new Thread(() => TcpServerLoop(_cts.Token))
|
| 321 |
+
{
|
| 322 |
+
IsBackground = true,
|
| 323 |
+
Name = "SshBridge TCP Server"
|
| 324 |
+
};
|
| 325 |
+
_serverThread.Start();
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
private void TcpServerLoop(CancellationToken ct)
|
| 329 |
+
{
|
| 330 |
+
var listener = new TcpListener(IPAddress.Loopback, PORT);
|
| 331 |
+
listener.Start();
|
| 332 |
+
|
| 333 |
+
while (!ct.IsCancellationRequested)
|
| 334 |
+
{
|
| 335 |
+
try
|
| 336 |
+
{
|
| 337 |
+
if (!listener.Pending())
|
| 338 |
+
{
|
| 339 |
+
Thread.Sleep(50);
|
| 340 |
+
continue;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
using var client = listener.AcceptTcpClient();
|
| 344 |
+
using var stream = client.GetStream();
|
| 345 |
+
using var reader = new StreamReader(stream, Encoding.UTF8);
|
| 346 |
+
using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
|
| 347 |
+
|
| 348 |
+
string? command = reader.ReadLine();
|
| 349 |
+
if (!string.IsNullOrEmpty(command))
|
| 350 |
+
{
|
| 351 |
+
string response;
|
| 352 |
+
if (command == "__STATUS__")
|
| 353 |
+
{
|
| 354 |
+
response = _isConnected ? $"CONNECTED:{_currentUser}@{_currentHost}" : "DISCONNECTED";
|
| 355 |
+
}
|
| 356 |
+
else if (command.StartsWith("__PREFILL__:"))
|
| 357 |
+
{
|
| 358 |
+
// Format: __PREFILL__:host:port:user:password
|
| 359 |
+
// Password is optional - if not provided, user must enter it
|
| 360 |
+
var parts = command.Substring(12).Split(':', 4);
|
| 361 |
+
if (parts.Length >= 3)
|
| 362 |
+
{
|
| 363 |
+
this.Invoke(() =>
|
| 364 |
+
{
|
| 365 |
+
_hostBox.Text = parts[0];
|
| 366 |
+
_portBox.Text = parts[1];
|
| 367 |
+
_userBox.Text = parts[2];
|
| 368 |
+
if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]))
|
| 369 |
+
{
|
| 370 |
+
_passwordBox.Text = parts[3];
|
| 371 |
+
}
|
| 372 |
+
_passwordBox.Focus();
|
| 373 |
+
this.Activate();
|
| 374 |
+
this.BringToFront();
|
| 375 |
+
});
|
| 376 |
+
response = "PREFILLED";
|
| 377 |
+
}
|
| 378 |
+
else
|
| 379 |
+
{
|
| 380 |
+
response = "ERROR: Invalid prefill format. Use __PREFILL__:host:port:user[:password]";
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
else if (command == "__CONNECT__")
|
| 384 |
+
{
|
| 385 |
+
// Trigger connect if fields are filled
|
| 386 |
+
this.Invoke(() => Connect());
|
| 387 |
+
response = "CONNECTING";
|
| 388 |
+
}
|
| 389 |
+
else
|
| 390 |
+
{
|
| 391 |
+
response = ExecuteCommand(command);
|
| 392 |
+
}
|
| 393 |
+
// Encode newlines so they survive the single-line protocol
|
| 394 |
+
response = response.Replace("\r\n", "<<CRLF>>").Replace("\n", "<<LF>>");
|
| 395 |
+
writer.WriteLine(response);
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
catch (Exception)
|
| 399 |
+
{
|
| 400 |
+
// Ignore errors
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
listener.Stop();
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
protected override void OnFormClosing(FormClosingEventArgs e)
|
| 408 |
+
{
|
| 409 |
+
_cts?.Cancel();
|
| 410 |
+
Disconnect();
|
| 411 |
+
base.OnFormClosing(e);
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
}
|
SshBridge.csproj
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
|
| 3 |
+
<PropertyGroup>
|
| 4 |
+
<OutputType>WinExe</OutputType>
|
| 5 |
+
<TargetFramework>net8.0-windows</TargetFramework>
|
| 6 |
+
<UseWindowsForms>true</UseWindowsForms>
|
| 7 |
+
<ImplicitUsings>disable</ImplicitUsings>
|
| 8 |
+
<Nullable>enable</Nullable>
|
| 9 |
+
<AssemblyName>ssh-bridge</AssemblyName>
|
| 10 |
+
<RootNamespace>SshBridge</RootNamespace>
|
| 11 |
+
</PropertyGroup>
|
| 12 |
+
|
| 13 |
+
<!-- Only compile SshBridge.cs -->
|
| 14 |
+
<ItemGroup>
|
| 15 |
+
<Compile Remove="*.cs" />
|
| 16 |
+
<Compile Include="SshBridge.cs" />
|
| 17 |
+
</ItemGroup>
|
| 18 |
+
|
| 19 |
+
<ItemGroup>
|
| 20 |
+
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
| 21 |
+
</ItemGroup>
|
| 22 |
+
|
| 23 |
+
</Project>
|