| using System; |
| using System.Collections.Generic; |
| using System.Drawing; |
| using System.IO; |
| using System.Linq; |
| using System.Net; |
| using System.Net.Sockets; |
| using System.Text; |
| using System.Text.RegularExpressions; |
| using System.Threading; |
| using System.Threading.Tasks; |
| using System.Windows.Forms; |
| using Renci.SshNet; |
|
|
| namespace SshBridge |
| { |
| public class Program |
| { |
| [STAThread] |
| public static void Main(string[] args) |
| { |
| Application.EnableVisualStyles(); |
| Application.SetCompatibleTextRenderingDefault(false); |
| Application.Run(new SshBridgeForm()); |
| } |
| } |
|
|
| public class SshBridgeForm : Form |
| { |
| private RichTextBox _outputBox = null!; |
| private TextBox _hostBox = null!; |
| private TextBox _portBox = null!; |
| private TextBox _userBox = null!; |
| private TextBox _passwordBox = null!; |
| private Button _connectButton = null!; |
| private Button _disconnectButton = null!; |
| private Label _statusLabel = null!; |
| private Panel _loginPanel = null!; |
| private Panel _sessionPanel = null!; |
|
|
| private Thread? _serverThread; |
| private CancellationTokenSource? _cts; |
| private SshClient? _sshClient; |
| private ShellStream? _shellStream; |
| private bool _isConnected; |
| private int _commandCount; |
| private string _currentHost = ""; |
| private string _currentUser = ""; |
| private string _promptPattern = ""; |
| private readonly object _shellLock = new object(); |
| |
| |
| private bool _stayOnTop; |
| private bool _penLifted; |
| private bool _allowSudo; |
| private bool _isRunning; |
| private string _storedPassword = ""; |
| private Button _stayOnTopButton = null!; |
| private Button _penButton = null!; |
| private Button _sudoButton = null!; |
| private System.Windows.Forms.Timer _runningTimer = null!; |
| private int _runningDots; |
|
|
| private const int PORT = 52718; |
| private const int MAX_OUTPUT_BYTES = 500 * 1024; |
| private const int MAX_OUTPUT_LINES = 150; |
| private const int DEFAULT_TIMEOUT_MS = 30000; |
| |
| private int _nextCommandTimeoutMs = DEFAULT_TIMEOUT_MS; |
| private Dictionary<string, int> _spawnedProcesses = new(); |
|
|
| public SshBridgeForm() |
| { |
| InitializeComponents(); |
| StartTcpServer(); |
| } |
|
|
| private void InitializeComponents() |
| { |
| this.Text = "SSH Bridge for Claude"; |
| this.Size = new Size(700, 500); |
| this.MinimumSize = new Size(500, 400); |
| this.StartPosition = FormStartPosition.CenterScreen; |
| this.FormBorderStyle = FormBorderStyle.Sizable; |
|
|
| |
| _loginPanel = new Panel |
| { |
| Dock = DockStyle.Fill, |
| Padding = new Padding(20), |
| }; |
|
|
| var loginLayout = new TableLayoutPanel |
| { |
| Dock = DockStyle.Fill, |
| ColumnCount = 2, |
| RowCount = 6, |
| Padding = new Padding(50, 30, 50, 30), |
| }; |
| loginLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 100)); |
| loginLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); |
| loginLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); |
|
|
| loginLayout.Controls.Add(new Label { Text = "Host:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 0); |
| _hostBox = new TextBox { Dock = DockStyle.Fill, Text = "" }; |
| loginLayout.Controls.Add(_hostBox, 1, 0); |
|
|
| loginLayout.Controls.Add(new Label { Text = "Port:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 1); |
| _portBox = new TextBox { Dock = DockStyle.Fill, Text = "22", Width = 80 }; |
| loginLayout.Controls.Add(_portBox, 1, 1); |
|
|
| loginLayout.Controls.Add(new Label { Text = "User:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 2); |
| _userBox = new TextBox { Dock = DockStyle.Fill, Text = "" }; |
| loginLayout.Controls.Add(_userBox, 1, 2); |
|
|
| loginLayout.Controls.Add(new Label { Text = "Password:", Anchor = AnchorStyles.Left, AutoSize = true }, 0, 3); |
| _passwordBox = new TextBox { Dock = DockStyle.Fill, UseSystemPasswordChar = true }; |
| _passwordBox.KeyPress += (s, e) => { if (e.KeyChar == (char)Keys.Enter) Connect(); }; |
| loginLayout.Controls.Add(_passwordBox, 1, 3); |
|
|
| _connectButton = new Button { Text = "Connect", Width = 100, Height = 35 }; |
| _connectButton.Click += (s, e) => Connect(); |
| var buttonPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight }; |
| buttonPanel.Controls.Add(_connectButton); |
| loginLayout.Controls.Add(buttonPanel, 1, 4); |
|
|
| _loginPanel.Controls.Add(loginLayout); |
|
|
| |
| _sessionPanel = new Panel |
| { |
| Dock = DockStyle.Fill, |
| Visible = false, |
| }; |
|
|
| var topBar = new Panel |
| { |
| Dock = DockStyle.Top, |
| Height = 40, |
| BackColor = Color.FromArgb(45, 45, 48), |
| Padding = new Padding(10, 5, 10, 5), |
| }; |
|
|
| _statusLabel = new Label |
| { |
| Text = "Disconnected", |
| ForeColor = Color.White, |
| AutoSize = true, |
| Location = new Point(10, 10), |
| Font = new Font("Segoe UI", 10), |
| }; |
| topBar.Controls.Add(_statusLabel); |
|
|
| _disconnectButton = new Button |
| { |
| Text = "Disconnect", |
| ForeColor = Color.White, |
| BackColor = Color.FromArgb(180, 50, 50), |
| FlatStyle = FlatStyle.Flat, |
| Width = 90, |
| Height = 28, |
| Anchor = AnchorStyles.Right, |
| }; |
| _disconnectButton.Click += (s, e) => Disconnect(); |
| topBar.Controls.Add(_disconnectButton); |
|
|
| _penButton = new Button |
| { |
| Text = "✏️ Lift Pen", |
| ForeColor = Color.White, |
| BackColor = Color.FromArgb(70, 130, 180), |
| FlatStyle = FlatStyle.Flat, |
| Width = 100, |
| Height = 28, |
| }; |
| _penButton.Click += (s, e) => TogglePen(); |
| topBar.Controls.Add(_penButton); |
|
|
| _stayOnTopButton = new Button |
| { |
| Text = "📌 Pin", |
| ForeColor = Color.White, |
| BackColor = Color.FromArgb(80, 80, 85), |
| FlatStyle = FlatStyle.Flat, |
| Width = 70, |
| Height = 28, |
| }; |
| _stayOnTopButton.Click += (s, e) => ToggleStayOnTop(); |
| topBar.Controls.Add(_stayOnTopButton); |
|
|
| _sudoButton = new Button |
| { |
| Text = "🔓 Sudo", |
| ForeColor = Color.White, |
| BackColor = Color.FromArgb(80, 80, 85), |
| FlatStyle = FlatStyle.Flat, |
| Width = 80, |
| Height = 28, |
| }; |
| _sudoButton.Click += (s, e) => ToggleSudo(); |
| topBar.Controls.Add(_sudoButton); |
|
|
| |
| topBar.Resize += (s, e) => RepositionTopBarButtons(); |
| this.Load += (s, e) => RepositionTopBarButtons(); |
|
|
| _outputBox = new RichTextBox |
| { |
| Dock = DockStyle.Fill, |
| ReadOnly = true, |
| BackColor = Color.FromArgb(30, 30, 30), |
| ForeColor = Color.FromArgb(220, 220, 220), |
| Font = new Font("Consolas", 10), |
| WordWrap = false, |
| }; |
|
|
| |
| var contextMenu = new ContextMenuStrip(); |
| var copyItem = new ToolStripMenuItem("Copy", null, (s, e) => |
| { |
| if (_outputBox.SelectionLength > 0) |
| Clipboard.SetText(_outputBox.SelectedText); |
| }); |
| var copyAllItem = new ToolStripMenuItem("Copy All", null, (s, e) => |
| { |
| if (!string.IsNullOrEmpty(_outputBox.Text)) |
| Clipboard.SetText(_outputBox.Text); |
| }); |
| var clearItem = new ToolStripMenuItem("Clear", null, (s, e) => |
| { |
| _outputBox.Clear(); |
| }); |
| contextMenu.Items.Add(copyItem); |
| contextMenu.Items.Add(copyAllItem); |
| contextMenu.Items.AddRange(new ToolStripItem[] { new ToolStripSeparator(), clearItem }); |
| _outputBox.ContextMenuStrip = contextMenu; |
|
|
| _sessionPanel.Controls.Add(_outputBox); |
| _sessionPanel.Controls.Add(topBar); |
|
|
| this.Controls.Add(_loginPanel); |
| this.Controls.Add(_sessionPanel); |
|
|
| |
| _runningTimer = new System.Windows.Forms.Timer { Interval = 400 }; |
| _runningTimer.Tick += (s, e) => |
| { |
| if (_isRunning) |
| { |
| _runningDots = (_runningDots + 1) % 4; |
| var dots = new string('.', _runningDots); |
| _statusLabel.Text = $"⚡ Running{dots}"; |
| _statusLabel.ForeColor = Color.Yellow; |
| } |
| }; |
| } |
|
|
| private void Connect() |
| { |
| string host = _hostBox.Text.Trim(); |
| string user = _userBox.Text.Trim(); |
| string password = _passwordBox.Text; |
| int port = 22; |
| int.TryParse(_portBox.Text.Trim(), out port); |
| if (port <= 0 || port > 65535) port = 22; |
|
|
| if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(password)) |
| { |
| MessageBox.Show("Please fill in all fields.", "Connection Error", MessageBoxButtons.OK, MessageBoxIcon.Warning); |
| return; |
| } |
|
|
| _connectButton.Enabled = false; |
| _connectButton.Text = "Connecting..."; |
|
|
| Task.Run(() => |
| { |
| try |
| { |
| _sshClient = new SshClient(host, port, user, password); |
| _sshClient.ConnectionInfo.Timeout = TimeSpan.FromSeconds(10); |
| _sshClient.Connect(); |
|
|
| if (_sshClient.IsConnected) |
| { |
| _currentHost = host; |
| _currentUser = user; |
| _commandCount = 0; |
|
|
| |
| _shellStream = _sshClient.CreateShellStream("xterm", 200, 50, 800, 600, 65536); |
| |
| |
| Thread.Sleep(500); |
| var initial = ReadAvailable(); |
| _promptPattern = DetectPrompt(initial, user); |
|
|
| this.Invoke(() => |
| { |
| _storedPassword = _passwordBox.Text; |
| _passwordBox.Clear(); |
| OnConnected(); |
| if (!string.IsNullOrEmpty(initial)) |
| { |
| var cleaned = StripAnsiCodes(initial).Trim(); |
| if (!string.IsNullOrEmpty(cleaned)) |
| { |
| AppendOutput(cleaned, Color.Gray); |
| } |
| } |
| }); |
| } |
| else |
| { |
| throw new Exception("Connection failed"); |
| } |
| } |
| catch (Exception ex) |
| { |
| this.Invoke(() => |
| { |
| MessageBox.Show($"Connection failed:\n{ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); |
| _connectButton.Enabled = true; |
| _connectButton.Text = "Connect"; |
| }); |
| } |
| }); |
| } |
|
|
| private void OnConnected() |
| { |
| _isConnected = true; |
| _loginPanel.Visible = false; |
| _sessionPanel.Visible = true; |
| _statusLabel.Text = $"Connected to {_currentUser}@{_currentHost}"; |
| _outputBox.Clear(); |
| AppendOutput($"=== Connected to {_currentUser}@{_currentHost} ===", Color.LimeGreen); |
| } |
|
|
| private void Disconnect() |
| { |
| _isConnected = false; |
| _penLifted = false; |
| _allowSudo = false; |
| _storedPassword = ""; |
|
|
| try |
| { |
| _shellStream?.Dispose(); |
| _sshClient?.Disconnect(); |
| _sshClient?.Dispose(); |
| } |
| catch { } |
|
|
| _shellStream = null; |
| _sshClient = null; |
| _currentHost = ""; |
| _currentUser = ""; |
| _promptPattern = ""; |
|
|
| _loginPanel.Visible = true; |
| _sessionPanel.Visible = false; |
| _connectButton.Enabled = true; |
| _connectButton.Text = "Connect"; |
| _statusLabel.Text = "Disconnected"; |
| UpdatePenButton(); |
| UpdateSudoButton(); |
| } |
|
|
| private void RepositionTopBarButtons() |
| { |
| int rightEdge = _sessionPanel.Width - 10; |
| _disconnectButton.Location = new Point(rightEdge - _disconnectButton.Width, 6); |
| _penButton.Location = new Point(_disconnectButton.Left - _penButton.Width - 5, 6); |
| _stayOnTopButton.Location = new Point(_penButton.Left - _stayOnTopButton.Width - 5, 6); |
| _sudoButton.Location = new Point(_stayOnTopButton.Left - _sudoButton.Width - 5, 6); |
| } |
|
|
| private void ToggleStayOnTop() |
| { |
| _stayOnTop = !_stayOnTop; |
| this.TopMost = _stayOnTop; |
| _stayOnTopButton.BackColor = _stayOnTop |
| ? Color.FromArgb(60, 140, 60) |
| : Color.FromArgb(80, 80, 85); |
| _stayOnTopButton.Text = _stayOnTop ? "📌 Pinned" : "📌 Pin"; |
| } |
|
|
| private void TogglePen() |
| { |
| _penLifted = !_penLifted; |
| UpdatePenButton(); |
| |
| if (_penLifted) |
| { |
| AppendOutput("=== PEN LIFTED - Claude paused ===", Color.Orange); |
| } |
| else |
| { |
| AppendOutput("=== PEN DOWN - Claude resumed ===", Color.LimeGreen); |
| } |
| } |
|
|
| private void UpdatePenButton() |
| { |
| _penButton.BackColor = _penLifted |
| ? Color.FromArgb(200, 120, 50) |
| : Color.FromArgb(70, 130, 180); |
| _penButton.Text = _penLifted ? "✏️ Pen Up!" : "✏️ Lift Pen"; |
| } |
|
|
| private void ToggleSudo() |
| { |
| if (string.IsNullOrEmpty(_storedPassword)) |
| { |
| MessageBox.Show("No password stored. Reconnect to enable sudo.", "Sudo", MessageBoxButtons.OK, MessageBoxIcon.Warning); |
| return; |
| } |
| |
| _allowSudo = !_allowSudo; |
| UpdateSudoButton(); |
| |
| if (_allowSudo) |
| { |
| AppendOutput("=== SUDO ENABLED - Password will auto-send ===", Color.FromArgb(180, 100, 255)); |
| } |
| else |
| { |
| AppendOutput("=== SUDO DISABLED ===", Color.Gray); |
| } |
| } |
|
|
| private void UpdateSudoButton() |
| { |
| _sudoButton.BackColor = _allowSudo |
| ? Color.FromArgb(140, 80, 200) |
| : Color.FromArgb(80, 80, 85); |
| _sudoButton.Text = _allowSudo ? "🔐 Sudo On" : "🔓 Sudo"; |
| } |
|
|
| private void AppendOutput(string? text, Color? color = null) |
| { |
| if (string.IsNullOrEmpty(text)) return; |
| |
| if (_outputBox.InvokeRequired) |
| { |
| _outputBox.Invoke(() => AppendOutput(text, color)); |
| return; |
| } |
|
|
| |
| var normalized = text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"); |
| |
| _outputBox.SelectionStart = _outputBox.TextLength; |
| _outputBox.SelectionLength = 0; |
| _outputBox.SelectionColor = color ?? Color.FromArgb(220, 220, 220); |
| _outputBox.AppendText(normalized + "\r\n"); |
| _outputBox.SelectionColor = _outputBox.ForeColor; |
| _outputBox.ScrollToCaret(); |
| } |
|
|
| public string ExecuteCommand(string command) |
| { |
| if (!_isConnected || _sshClient == null || !_sshClient.IsConnected || _shellStream == null) |
| { |
| return "ERROR: Not connected. Open SSH Bridge and connect first."; |
| } |
|
|
| lock (_shellLock) |
| { |
| try |
| { |
| _commandCount++; |
| SetRunning(true); |
| |
| |
| ReadAvailable(); |
| |
| |
| if (IsBlockedCommand(command, out string blockReason)) |
| { |
| AppendOutput($"> {command}", Color.Gray); |
| AppendOutput($"[BLOCKED - Interactive command]\n{blockReason}", Color.Red); |
| return $"BLOCKED: Interactive command not supported.\n{blockReason}"; |
| } |
| |
| |
| if (command.TrimStart().StartsWith("sudo") && !_allowSudo) |
| { |
| AppendOutput($"> {command}", Color.White); |
| AppendOutput("[SUDO BLOCKED - Command not sent. Enable sudo button to allow.]", Color.Red); |
| return "SUDO_BLOCKED: Sudo commands are disabled. Click the Sudo button to enable."; |
| } |
| |
| |
| _shellStream.WriteLine(command); |
| |
| |
| var output = WaitForPrompt(_nextCommandTimeoutMs); |
| _nextCommandTimeoutMs = DEFAULT_TIMEOUT_MS; |
| |
| |
| if (command.TrimStart().StartsWith("sudo") && IsSudoPrompt(output)) |
| { |
| AppendOutput("[sudo password auto-sent]", Color.FromArgb(180, 100, 255)); |
| _shellStream.WriteLine(_storedPassword); |
| var sudoOutput = WaitForPrompt(30000); |
| output += sudoOutput; |
| } |
|
|
| |
| |
| output = CleanOutput(output, command); |
| |
| if (string.IsNullOrEmpty(output)) |
| { |
| output = "(no output)"; |
| } |
|
|
| |
| var lines = output.Split('\n'); |
| string returnOutput; |
| if (lines.Length > MAX_OUTPUT_LINES) |
| { |
| var tailLines = lines.Skip(lines.Length - MAX_OUTPUT_LINES).ToArray(); |
| returnOutput = $"[... {lines.Length - MAX_OUTPUT_LINES} lines truncated ...]\n" + string.Join("\n", tailLines); |
| } |
| else |
| { |
| returnOutput = output; |
| } |
| |
| |
| if (lines.Length > MAX_OUTPUT_LINES) |
| { |
| AppendOutput($"[Returned last {MAX_OUTPUT_LINES} of {lines.Length} lines to Claude]", Color.Gray); |
| } |
| |
| |
| if (Encoding.UTF8.GetByteCount(returnOutput) > MAX_OUTPUT_BYTES) |
| { |
| returnOutput = TruncateToBytes(returnOutput, MAX_OUTPUT_BYTES); |
| returnOutput += $"\n\n[OUTPUT TRUNCATED - exceeded 500KB limit]"; |
| } |
| |
| return returnOutput; |
| } |
| catch (Exception ex) |
| { |
| var error = $"ERROR: {ex.Message}"; |
| AppendOutput(error, Color.Red); |
| return error; |
| } |
| finally |
| { |
| SetRunning(false); |
| } |
| } |
| } |
|
|
| private void SetRunning(bool running) |
| { |
| _isRunning = running; |
| if (this.InvokeRequired) |
| { |
| this.Invoke(() => SetRunning(running)); |
| return; |
| } |
| |
| if (running) |
| { |
| _runningDots = 0; |
| _runningTimer.Start(); |
| } |
| else |
| { |
| _runningTimer.Stop(); |
| _statusLabel.Text = $"Connected to {_currentUser}@{_currentHost}"; |
| _statusLabel.ForeColor = Color.White; |
| } |
| } |
|
|
| private static string TruncateToBytes(string text, int maxBytes) |
| { |
| if (string.IsNullOrEmpty(text)) return text; |
| var bytes = Encoding.UTF8.GetBytes(text); |
| if (bytes.Length <= maxBytes) return text; |
| |
| |
| int cutPoint = maxBytes; |
| while (cutPoint > 0 && (bytes[cutPoint] & 0xC0) == 0x80) |
| cutPoint--; |
| |
| return Encoding.UTF8.GetString(bytes, 0, cutPoint); |
| } |
|
|
| private bool IsProcessRunning(int pid) |
| { |
| if (pid <= 0) return false; |
| try |
| { |
| var proc = System.Diagnostics.Process.GetProcessById(pid); |
| return !proc.HasExited; |
| } |
| catch |
| { |
| return false; |
| } |
| } |
|
|
| private void SendAbort() |
| { |
| if (_shellStream != null && _isConnected) |
| { |
| try |
| { |
| _shellStream.Write("\x03"); |
| Thread.Sleep(100); |
| _shellStream.Write("\x03"); |
| AppendOutput("[ABORT SIGNAL SENT - Ctrl+C]", Color.Orange); |
| } |
| catch { } |
| } |
| } |
|
|
| private string ReadAvailable() |
| { |
| if (_shellStream == null) return ""; |
| |
| var sb = new StringBuilder(); |
| while (_shellStream.DataAvailable) |
| { |
| var buffer = new byte[4096]; |
| var read = _shellStream.Read(buffer, 0, buffer.Length); |
| if (read > 0) |
| { |
| sb.Append(Encoding.UTF8.GetString(buffer, 0, read)); |
| } |
| } |
| return sb.ToString(); |
| } |
|
|
| private string WaitForPrompt(int timeoutMs) |
| { |
| if (_shellStream == null) return ""; |
| |
| var sb = new StringBuilder(); |
| var sw = System.Diagnostics.Stopwatch.StartNew(); |
| var lastDataTime = sw.ElapsedMilliseconds; |
| |
| |
| const int minWaitMs = 300; |
| const int quietTimeMs = 150; |
| |
| while (sw.ElapsedMilliseconds < timeoutMs) |
| { |
| |
| if (_penLifted) |
| { |
| try { _shellStream.Write("\x03"); } catch { } |
| Thread.Sleep(200); |
| sb.Append(ReadAvailable()); |
| sb.Append("\n[ABORTED BY USER - Pen lifted]"); |
| AppendOutput("[Command aborted - Pen lifted]", Color.Orange); |
| return sb.ToString(); |
| } |
| |
| if (_shellStream.DataAvailable) |
| { |
| var buffer = new byte[4096]; |
| var read = _shellStream.Read(buffer, 0, buffer.Length); |
| if (read > 0) |
| { |
| var chunk = Encoding.UTF8.GetString(buffer, 0, read); |
| sb.Append(chunk); |
| lastDataTime = sw.ElapsedMilliseconds; |
| |
| |
| var cleaned = StripAnsiCodes(chunk); |
| if (!string.IsNullOrEmpty(cleaned)) |
| { |
| AppendOutput(cleaned, Color.FromArgb(180, 180, 180)); |
| } |
| } |
| } |
| else |
| { |
| |
| if (sw.ElapsedMilliseconds > minWaitMs && |
| sw.ElapsedMilliseconds - lastDataTime > quietTimeMs) |
| { |
| var current = sb.ToString(); |
| if (LooksLikePrompt(current)) |
| { |
| break; |
| } |
| } |
| Thread.Sleep(5); |
| } |
| } |
| |
| return sb.ToString(); |
| } |
|
|
| private bool LooksLikePrompt(string text) |
| { |
| if (string.IsNullOrEmpty(text)) return false; |
| |
| |
| var normalized = text.Replace("\r\n", "\n").Replace("\r", "\n"); |
| var lines = normalized.Split('\n'); |
| |
| |
| string lastLine = ""; |
| for (int i = lines.Length - 1; i >= 0; i--) |
| { |
| var trimmed = lines[i].Trim(); |
| if (!string.IsNullOrEmpty(trimmed)) |
| { |
| lastLine = trimmed; |
| break; |
| } |
| } |
| |
| if (string.IsNullOrEmpty(lastLine)) return false; |
| |
| |
| if (!string.IsNullOrEmpty(_promptPattern) && lastLine.Contains(_promptPattern)) return true; |
| |
| |
| if ((lastLine.EndsWith("$") || lastLine.EndsWith("#")) && lastLine.Contains("@")) return true; |
| |
| |
| if (lastLine.EndsWith(">") && (lastLine.Contains(":\\") || lastLine.Contains(":/"))) return true; |
| |
| |
| if (lastLine.EndsWith("PS>") || lastLine.EndsWith("PS >")) return true; |
| |
| return false; |
| } |
|
|
| private bool IsSudoPrompt(string text) |
| { |
| if (string.IsNullOrEmpty(text)) return false; |
| |
| |
| var lines = text.Split('\n'); |
| var lastLine = lines[lines.Length - 1].Trim().ToLowerInvariant(); |
| |
| |
| if (lastLine.EndsWith("password:")) return true; |
| if (lastLine.Contains("[sudo]") && lastLine.Contains("password")) return true; |
| if (Regex.IsMatch(lastLine, @"password for \w+:")) return true; |
| |
| return false; |
| } |
|
|
| private bool IsBlockedCommand(string command, out string reason) |
| { |
| var trimmed = command.Trim(); |
| var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| var firstWord = parts.FirstOrDefault()?.ToLowerInvariant() ?? ""; |
| var fullLower = trimmed.ToLowerInvariant(); |
| |
| reason = ""; |
| |
| switch (firstWord) |
| { |
| |
| case "nano": |
| case "vim": |
| case "vi": |
| case "nvim": |
| case "emacs": |
| case "pico": |
| case "joe": |
| case "mcedit": |
| reason = "Interactive editors not supported. Use:\n" + |
| "• echo \"content\" > file.txt (create/overwrite)\n" + |
| "• echo \"more\" >> file.txt (append)\n" + |
| "• cat << 'EOF' > file.txt (multi-line)\n" + |
| "• sed -i 's/old/new/g' file.txt (find/replace)"; |
| return true; |
| |
| |
| case "less": |
| case "more": |
| reason = "Use 'cat', 'head -n 100', or 'tail -n 100' instead."; |
| return true; |
| |
| |
| case "top": |
| if (!fullLower.Contains("-b")) |
| { |
| reason = "Use 'top -b -n 1' for batch mode, or 'ps aux' instead."; |
| return true; |
| } |
| break; |
| case "htop": |
| case "btop": |
| case "atop": |
| case "nmon": |
| case "glances": |
| reason = "Use 'ps aux', 'free -h', 'df -h', or 'top -b -n 1' instead."; |
| return true; |
| |
| |
| case "mysql": |
| if (!fullLower.Contains("-e")) |
| { |
| reason = "Use 'mysql -e \"SELECT...\"' for non-interactive query."; |
| return true; |
| } |
| break; |
| case "psql": |
| if (!fullLower.Contains("-c")) |
| { |
| reason = "Use 'psql -c \"SELECT...\"' for non-interactive query."; |
| return true; |
| } |
| break; |
| case "mongo": |
| case "mongosh": |
| if (!fullLower.Contains("--eval")) |
| { |
| reason = "Use 'mongosh --eval \"db.collection.find()\"' for non-interactive."; |
| return true; |
| } |
| break; |
| case "redis-cli": |
| if (parts.Length == 1) |
| { |
| reason = "Add command: 'redis-cli GET key' or 'redis-cli INFO'."; |
| return true; |
| } |
| break; |
| case "sqlite3": |
| if (!fullLower.Contains("-cmd") && !trimmed.Contains("\"")) |
| { |
| reason = "Use 'sqlite3 db.sqlite \"SELECT...\"' for non-interactive."; |
| return true; |
| } |
| break; |
| |
| |
| case "tmux": |
| case "screen": |
| case "byobu": |
| reason = "Terminal multiplexers not supported in this shell."; |
| return true; |
| |
| |
| case "mc": |
| case "ranger": |
| case "nnn": |
| reason = "Use 'ls -la', 'find', or 'tree' instead."; |
| return true; |
| |
| |
| case "bash": |
| case "zsh": |
| case "fish": |
| case "sh": |
| case "csh": |
| case "tcsh": |
| if (!fullLower.Contains("-c")) |
| { |
| reason = "Nested shells not supported. Use 'bash -c \"command\"' for one-offs."; |
| return true; |
| } |
| break; |
| |
| |
| case "ssh": |
| case "telnet": |
| reason = "Nested SSH not supported. Disconnect and connect to the other server."; |
| return true; |
| |
| |
| case "man": |
| case "info": |
| reason = "Use 'command --help' or search online."; |
| return true; |
| |
| |
| case "ftp": |
| case "sftp": |
| reason = "Interactive FTP not supported. Use 'scp' or 'curl' instead."; |
| return true; |
| } |
| |
| return false; |
| } |
|
|
| private string DetectPrompt(string initialOutput, string user) |
| { |
| if (string.IsNullOrEmpty(initialOutput)) return ">"; |
| |
| var lines = initialOutput.Split('\n'); |
| var lastLine = lines[^1].Trim(); |
| |
| |
| if (lastLine.EndsWith(">") || lastLine.EndsWith("$") || lastLine.EndsWith("#")) |
| { |
| return lastLine; |
| } |
| |
| return ">"; |
| } |
|
|
| private string CleanOutput(string output, string command) |
| { |
| if (string.IsNullOrEmpty(output)) return ""; |
| |
| |
| var cleaned = StripAnsiCodes(output); |
| |
| cleaned = cleaned.Replace("\r\n", "\n").Replace("\r", "\n"); |
| return cleaned.Trim(); |
| } |
|
|
| private static string StripAnsiCodes(string text) |
| { |
| if (string.IsNullOrEmpty(text)) return text; |
| |
| |
| |
| |
| |
| |
| var result = text; |
| |
| |
| result = Regex.Replace(result, @"\x1B\[[0-9;?]*[A-Za-z]", ""); |
| result = Regex.Replace(result, @"\x1B\[[0-9;?]*[ -/]*[@-~]", ""); |
| |
| |
| result = Regex.Replace(result, @"\x1B\][^\x07\x1B]*(\x07|\x1B\\)?", ""); |
| |
| |
| result = Regex.Replace(result, @"\x1B[()][AB012]", ""); |
| result = Regex.Replace(result, @"\x1B[@-_]", ""); |
| |
| |
| result = Regex.Replace(result, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", ""); |
| |
| return result; |
| } |
|
|
| private void StartTcpServer() |
| { |
| _cts = new CancellationTokenSource(); |
| _serverThread = new Thread(() => TcpServerLoop(_cts.Token)) |
| { |
| IsBackground = true, |
| Name = "SshBridge TCP Server" |
| }; |
| _serverThread.Start(); |
| } |
|
|
| private void TcpServerLoop(CancellationToken ct) |
| { |
| var listener = new TcpListener(IPAddress.Loopback, PORT); |
| listener.Start(); |
|
|
| while (!ct.IsCancellationRequested) |
| { |
| try |
| { |
| if (!listener.Pending()) |
| { |
| Thread.Sleep(50); |
| continue; |
| } |
|
|
| using var client = listener.AcceptTcpClient(); |
| using var stream = client.GetStream(); |
| using var reader = new StreamReader(stream, Encoding.UTF8); |
| using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; |
|
|
| string? command = reader.ReadLine(); |
| if (!string.IsNullOrEmpty(command)) |
| { |
| string response; |
| if (command == "__STATUS__") |
| { |
| response = _isConnected ? $"CONNECTED:{_currentUser}@{_currentHost}" : "DISCONNECTED"; |
| } |
| else if (command == "__PEN_STATUS__") |
| { |
| response = _penLifted ? "PEN_LIFTED" : "PEN_DOWN"; |
| } |
| else if (command == "__PEN_DOWN__") |
| { |
| if (_penLifted) |
| { |
| this.Invoke(() => TogglePen()); |
| response = "PEN_LOWERED"; |
| } |
| else |
| { |
| response = "PEN_ALREADY_DOWN"; |
| } |
| } |
| else if (command == "__ABORT__") |
| { |
| SendAbort(); |
| response = _isRunning ? "ABORT_SENT" : "NO_COMMAND_RUNNING"; |
| } |
| else if (command == "__IS_RUNNING__") |
| { |
| response = _isRunning ? "RUNNING" : "IDLE"; |
| } |
| else if (command.StartsWith("__TIMEOUT__:")) |
| { |
| var secStr = command.Substring(12); |
| if (int.TryParse(secStr, out int seconds) && seconds > 0 && seconds <= 3600) |
| { |
| _nextCommandTimeoutMs = seconds * 1000; |
| response = $"TIMEOUT_SET:{seconds}s"; |
| AppendOutput($"[Timeout set to {seconds}s for next command]", Color.Cyan); |
| } |
| else |
| { |
| response = "ERROR: Invalid timeout (1-3600 seconds)"; |
| } |
| } |
| else if (command.StartsWith("__KILL_PORT__:")) |
| { |
| var portStr = command.Substring(14); |
| if (int.TryParse(portStr, out int portNum) && portNum > 0 && portNum <= 65535) |
| { |
| |
| var killCmd = $"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr :{portNum} ^| findstr LISTENING') do @taskkill /PID %a /F 2>nul & echo Killed PID %a"; |
| response = ExecuteCommand(killCmd); |
| } |
| else |
| { |
| response = "ERROR: Invalid port number"; |
| } |
| } |
| else if (command.StartsWith("__WRITE_FILE__:")) |
| { |
| |
| |
| |
| var rest = command.Substring(15); |
| var pipeIdx = rest.IndexOf('|'); |
| if (pipeIdx > 0) |
| { |
| var path = rest.Substring(0, pipeIdx); |
| var content = rest.Substring(pipeIdx + 1) |
| .Replace("<<CRLF>>", "\r\n") |
| .Replace("<<LF>>", "\r\n") |
| .Replace("<<CR>>", "\r"); |
| try |
| { |
| |
| var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(content)); |
| var psCmd = $"[System.IO.File]::WriteAllText('{path}', [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{base64}')))"; |
| ExecuteCommand($"powershell -Command \"{psCmd}\""); |
| response = $"WRITTEN:{path} ({content.Length} chars)"; |
| AppendOutput($"[Wrote {content.Length} chars to {path}]", Color.Green); |
| } |
| catch (Exception ex) |
| { |
| response = $"ERROR: {ex.Message}"; |
| } |
| } |
| else |
| { |
| response = "ERROR: Use __WRITE_FILE__:path|content (use <<LF>> for newlines)"; |
| } |
| } |
| else if (command.StartsWith("__APPEND_FILE__:")) |
| { |
| |
| var rest = command.Substring(16); |
| var pipeIdx = rest.IndexOf('|'); |
| if (pipeIdx > 0) |
| { |
| var path = rest.Substring(0, pipeIdx); |
| var content = rest.Substring(pipeIdx + 1) |
| .Replace("<<CRLF>>", "\r\n") |
| .Replace("<<LF>>", "\r\n") |
| .Replace("<<CR>>", "\r"); |
| try |
| { |
| |
| var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(content)); |
| var psCmd = $"[System.IO.File]::AppendAllText('{path}', [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{base64}')))"; |
| ExecuteCommand($"powershell -Command \"{psCmd}\""); |
| response = $"APPENDED:{path} ({content.Length} chars)"; |
| AppendOutput($"[Appended {content.Length} chars to {path}]", Color.Green); |
| } |
| catch (Exception ex) |
| { |
| response = $"ERROR: {ex.Message}"; |
| } |
| } |
| else |
| { |
| response = "ERROR: Use __APPEND_FILE__:path|content (use <<LF>> for newlines)"; |
| } |
| } |
| else if (command.StartsWith("__SPAWN__:")) |
| { |
| |
| var rest = command.Substring(10); |
| var colonIdx = rest.IndexOf(':'); |
| if (colonIdx > 0) |
| { |
| var name = rest.Substring(0, colonIdx); |
| var spawnCmd = rest.Substring(colonIdx + 1); |
| |
| var psCmd = $"$p = Start-Process -FilePath cmd -ArgumentList '/c {spawnCmd.Replace("'", "''")}' -PassThru -WindowStyle Hidden; $p.Id"; |
| var pidResult = ExecuteCommand($"powershell -Command \"{psCmd}\""); |
| if (int.TryParse(pidResult.Trim().Split('\n').Last().Trim(), out int pid)) |
| { |
| _spawnedProcesses[name] = pid; |
| response = $"SPAWNED:{name}:PID={pid}"; |
| } |
| else |
| { |
| response = $"SPAWN_STARTED:{name} (PID unknown)"; |
| } |
| } |
| else |
| { |
| response = "ERROR: Use __SPAWN__:name:command"; |
| } |
| } |
| else if (command == "__LIST_SPAWNED__") |
| { |
| if (_spawnedProcesses.Count == 0) |
| { |
| response = "NO_SPAWNED_PROCESSES"; |
| } |
| else |
| { |
| var sb = new StringBuilder(); |
| foreach (var kvp in _spawnedProcesses) |
| { |
| |
| var checkCmd = $"tasklist /FI \"PID eq {kvp.Value}\" /NH 2>nul | findstr {kvp.Value}"; |
| var checkResult = ExecuteCommand(checkCmd); |
| var status = checkResult.Contains(kvp.Value.ToString()) ? "RUNNING" : "STOPPED"; |
| sb.AppendLine($"{kvp.Key}: PID={kvp.Value} ({status})"); |
| } |
| response = sb.ToString().TrimEnd(); |
| } |
| } |
| else if (command == "__TAIL__") |
| { |
| |
| string text = ""; |
| this.Invoke(() => text = _outputBox.Text); |
| var lines = text.Split('\n'); |
| var tail = lines.Skip(Math.Max(0, lines.Length - 50)).ToArray(); |
| response = string.Join("\n", tail); |
| } |
| else if (command.StartsWith("__KILL_SPAWNED__:")) |
| { |
| var name = command.Substring(17); |
| if (_spawnedProcesses.TryGetValue(name, out int pid)) |
| { |
| ExecuteCommand($"taskkill /PID {pid} /F /T 2>nul"); |
| _spawnedProcesses.Remove(name); |
| response = $"KILLED:{name}:PID={pid}"; |
| } |
| else |
| { |
| response = $"ERROR: No spawned process named '{name}'"; |
| } |
| } |
| else if (command.StartsWith("__PREFILL__:")) |
| { |
| |
| |
| var parts = command.Substring(12).Split(':', 4); |
| if (parts.Length >= 3) |
| { |
| this.Invoke(() => |
| { |
| _hostBox.Text = parts[0]; |
| _portBox.Text = parts[1]; |
| _userBox.Text = parts[2]; |
| if (parts.Length >= 4 && !string.IsNullOrEmpty(parts[3])) |
| { |
| _passwordBox.Text = parts[3]; |
| } |
| _passwordBox.Focus(); |
| this.Activate(); |
| this.BringToFront(); |
| }); |
| response = "PREFILLED"; |
| } |
| else |
| { |
| response = "ERROR: Invalid prefill format. Use __PREFILL__:host:port:user[:password]"; |
| } |
| } |
| else if (command == "__CONNECT__") |
| { |
| |
| this.Invoke(() => Connect()); |
| response = "CONNECTING"; |
| } |
| else if (_penLifted) |
| { |
| |
| AppendOutput($"> {command}", Color.Gray); |
| AppendOutput("[BLOCKED - Pen lifted by user]", Color.Orange); |
| response = "PEN_LIFTED: User has paused command execution. Use SshPenDown to resume, or wait for user to click 'Lift Pen' button again."; |
| } |
| else |
| { |
| response = ExecuteCommand(command); |
| } |
| |
| response = response.Replace("\r\n", "<<CRLF>>").Replace("\n", "<<LF>>").Replace("\r", "<<CR>>"); |
| writer.WriteLine(response); |
| } |
| } |
| catch (Exception) |
| { |
| |
| } |
| } |
|
|
| listener.Stop(); |
| } |
|
|
| protected override void OnFormClosing(FormClosingEventArgs e) |
| { |
| _cts?.Cancel(); |
| Disconnect(); |
| base.OnFormClosing(e); |
| } |
| } |
| } |
|
|