v1.8.65: Linux→Windows auto-sanitize commands for Windows SSH servers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-25 08:27:57 -05:00
parent 327647f77e
commit b2c6f8fdd3
6 changed files with 283 additions and 5 deletions

View File

@@ -246,7 +246,10 @@ class SSHClientWrapper:
sftp.put(local_path, remote_path, callback=progress_cb)
else:
sftp.put(local_path, remote_path)
try:
sftp.chmod(remote_path, 0o664)
except OSError:
pass # Windows OpenSSH doesn't support chmod
sftp.close()
finally:
client.close()

View File

@@ -149,9 +149,13 @@ def run_command(server: dict, command: str, use_sudo: bool = True) -> tuple:
client = get_client(server)
try:
user = server.get("user", "root")
need_sudo = use_sudo and user != "root"
is_win = _is_windows_server(server)
need_sudo = not is_win and use_sudo and user != "root"
if need_sudo:
if is_win:
# Windows SSH: translate Linux commands to Windows equivalents
full_cmd = _sanitize_windows_command(command)
elif need_sudo:
# Use sudo -S to read password from stdin
# -p '' suppresses the password prompt text
full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}"
@@ -184,10 +188,278 @@ def _shell_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
def _is_windows_server(server: dict) -> bool:
"""Detect if server is Windows by alias, notes, or type."""
stype = server.get("type", "ssh")
if stype in ("winrm", "rdp"):
return True
text = (server.get("alias", "") + " " + server.get("notes", "")).lower()
return "windows" in text or "win " in text
# ── Windows command translation ──────────────────────
# Map of Linux commands → Windows/PowerShell equivalents
# Each entry: (regex_pattern, replacement_callable)
# Patterns use ^\s* anchoring — matches only at command start, no partial-word risk
_WIN_CMD_MAP = [
# --- Skip / warn ---
(r'^\s*chmod\b.*', lambda m: 'echo [SKIP] chmod not supported on Windows'),
(r'^\s*chown\b.*', lambda m: 'echo [SKIP] chown not supported on Windows'),
# --- Environment ---
(r'^\s*export\s+(\w+)=(.*)', lambda m: f'set {m.group(1)}={m.group(2)}'),
(r'^\s*env\s*$', lambda m: 'set'),
(r'^\s*printenv\s*$', lambda m: 'set'),
(r'^\s*printenv\s+(\w+)', lambda m: f'echo %{m.group(1)}%'),
# --- File operations ---
(r'^\s*ls\s+-la\s+(.*)', lambda m: f'dir /a "{m.group(1).strip()}"'),
(r'^\s*ls\s+-lah?\s+(.*)', lambda m: f'dir /a "{m.group(1).strip()}"'),
(r'^\s*ls\s+-[a-zA-Z]*R[a-zA-Z]*\s*(.*)', lambda m: f'dir /s /a "{m.group(1).strip()}"' if m.group(1).strip() else 'dir /s /a'),
(r'^\s*ls\s+-[a-zA-Z]+\s+(.*)', lambda m: f'dir "{m.group(1).strip()}"'),
(r'^\s*ls\s+-[a-zA-Z]+\s*$', lambda m: 'dir'),
(r'^\s*ls\s+(.*)', lambda m: f'dir "{m.group(1).strip()}"'),
(r'^\s*ls\s*$', lambda m: 'dir'),
(r'^\s*cat\b\s+(.*)', lambda m: f'type {m.group(1).strip()}'),
(r'^\s*cp\s+-r\s+(.*?)\s+(.*)', lambda m: f'xcopy /E /I "{m.group(1).strip()}" "{m.group(2).strip()}"'),
(r'^\s*cp\s+(.*?)\s+(.*)', lambda m: f'copy "{m.group(1).strip()}" "{m.group(2).strip()}"'),
(r'^\s*mv\s+(.*?)\s+(.*)', lambda m: f'move "{m.group(1).strip()}" "{m.group(2).strip()}"'),
(r'^\s*rm\s+-rf?\s+(.*)', lambda m: f'rmdir /S /Q "{m.group(1).strip()}"'),
(r'^\s*rm\s+-f\s+(.*)', lambda m: f'del /F /Q "{m.group(1).strip()}"'),
(r'^\s*rm\s+(.*)', lambda m: f'del /Q "{m.group(1).strip()}"'),
(r'^\s*mkdir\s+-p\s+(.*)', lambda m: f'mkdir "{m.group(1).strip()}"'),
(r'^\s*touch\s+(.*)', lambda m: f'type nul > "{m.group(1).strip()}"'),
(r'^\s*pwd\s*$', lambda m: 'cd'),
# --- Text processing (PowerShell) ---
(r'^\s*grep\s+-r\s+"?([^"]*)"?\s+(.*)', lambda m: f'powershell -Command "Get-ChildItem -Recurse {m.group(2).strip()} | Select-String -Pattern \'{m.group(1)}\'"'),
(r'^\s*grep\s+-i\s+"?([^"]*)"?\s+(.*)', lambda m: f'findstr /I "{m.group(1)}" {m.group(2).strip()}'),
(r'^\s*grep\s+"?([^"]*)"?\s+(.*)', lambda m: f'findstr "{m.group(1)}" {m.group(2).strip()}'),
(r'^\s*head\s+-n?\s*(\d+)\s+(.*)', lambda m: f'powershell -Command "Get-Content \'{m.group(2).strip()}\' | Select-Object -First {m.group(1)}"'),
(r'^\s*head\s+(.*)', lambda m: f'powershell -Command "Get-Content \'{m.group(1).strip()}\' | Select-Object -First 10"'),
(r'^\s*tail\s+-n?\s*(\d+)\s+(.*)', lambda m: f'powershell -Command "Get-Content \'{m.group(2).strip()}\' -Tail {m.group(1)}"'),
(r'^\s*tail\s+-f\s+(.*)', lambda m: f'powershell -Command "Get-Content \'{m.group(1).strip()}\' -Wait -Tail 20"'),
(r'^\s*tail\s+(.*)', lambda m: f'powershell -Command "Get-Content \'{m.group(1).strip()}\' -Tail 10"'),
(r'^\s*wc\s+-l\s+(.*)', lambda m: f'powershell -Command "(Get-Content \'{m.group(1).strip()}\' | Measure-Object -Line).Lines"'),
# --- Search ---
(r'^\s*find\s+(.*?)\s+-name\s+"?([^"]*)"?', lambda m: f'powershell -Command "Get-ChildItem -Path \'{m.group(1).strip()}\' -Recurse -Filter \'{m.group(2)}\'"'),
(r'^\s*which\s+(.*)', lambda m: f'where.exe {m.group(1).strip()}'),
(r'^\s*whereis\s+(.*)', lambda m: f'where.exe {m.group(1).strip()}'),
# --- Process / system ---
(r'^\s*ps\s+aux\s*$', lambda m: 'tasklist'),
(r'^\s*ps\s+-ef\s*$', lambda m: 'tasklist'),
(r'^\s*ps\s*$', lambda m: 'tasklist'),
(r'^\s*kill\s+-9\s+(\d+)', lambda m: f'taskkill /F /PID {m.group(1)}'),
(r'^\s*kill\s+(\d+)', lambda m: f'taskkill /PID {m.group(1)}'),
(r'^\s*top\s*$', lambda m: 'powershell -Command "Get-Process | Sort-Object CPU -Descending | Select-Object -First 20 Name, Id, CPU, @{N=\'Mem(MB)\';E={[math]::Round($_.WS/1MB,1)}}"'),
(r'^\s*df\s*', lambda m: 'powershell -Command "Get-PSDrive -PSProvider FileSystem | Select-Object Name, @{N=\'Used(GB)\';E={[math]::Round($_.Used/1GB,1)}}, @{N=\'Free(GB)\';E={[math]::Round($_.Free/1GB,1)}}"'),
(r'^\s*free\b.*', lambda m: 'powershell -Command "$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{TotalMB=[math]::Round($os.TotalVisibleMemorySize/1024); FreeMB=[math]::Round($os.FreePhysicalMemory/1024); UsedMB=[math]::Round(($os.TotalVisibleMemorySize-$os.FreePhysicalMemory)/1024)} | Format-List"'),
(r'^\s*du\s+-sh?\s+(.*)', lambda m: f'powershell -Command "(Get-ChildItem -Recurse \'{m.group(1).strip()}\' | Measure-Object -Property Length -Sum).Sum / 1MB | ForEach-Object {{Write-Host ([math]::Round($_,1)) \'MB\'}}"'),
(r'^\s*uname\b.*', lambda m: 'powershell -Command "[System.Environment]::OSVersion | Format-List"'),
(r'^\s*uptime\s*$', lambda m: 'powershell -Command "$b=(Get-CimInstance Win32_OperatingSystem).LastBootUpTime; $u=(New-TimeSpan $b (Get-Date)); Write-Host \"up $($u.Days)d $($u.Hours)h $($u.Minutes)m, since $b\""'),
(r'^\s*whoami\s*$', lambda m: 'whoami'),
(r'^\s*hostname\s*$', lambda m: 'hostname'),
# --- Service management ---
(r'^\s*systemctl\s+status\s+(.*)', lambda m: f'powershell -Command "Get-Service \'{m.group(1).strip()}\' | Format-List"'),
(r'^\s*systemctl\s+start\s+(.*)', lambda m: f'powershell -Command "Start-Service \'{m.group(1).strip()}\'"'),
(r'^\s*systemctl\s+stop\s+(.*)', lambda m: f'powershell -Command "Stop-Service \'{m.group(1).strip()}\'"'),
(r'^\s*systemctl\s+restart\s+(.*)', lambda m: f'powershell -Command "Restart-Service \'{m.group(1).strip()}\'"'),
(r'^\s*systemctl\s+list-units\b.*', lambda m: 'powershell -Command "Get-Service | Format-Table Name, Status, DisplayName -AutoSize"'),
# --- Networking ---
(r'^\s*ifconfig\s*$', lambda m: 'ipconfig'),
(r'^\s*ip\s+addr\b.*', lambda m: 'ipconfig /all'),
(r'^\s*ip\s+route\b.*', lambda m: 'route print'),
(r'^\s*netstat\b(.*)', lambda m: f'netstat{m.group(1)}'),
(r'^\s*ss\s+-tulnp\s*$', lambda m: 'netstat -ano'),
(r'^\s*curl\s+(.*)', lambda m: f'powershell -Command "Invoke-WebRequest -Uri \'{m.group(1).strip()}\' -UseBasicParsing | Select-Object StatusCode, Content"'),
(r'^\s*wget\s+(.*)', lambda m: f'powershell -Command "Invoke-WebRequest -Uri \'{m.group(1).strip()}\' -OutFile ([System.IO.Path]::GetFileName(\'{m.group(1).strip()}\'))"'),
]
def _strip_sudo(command: str) -> str:
"""Strip sudo and its flags from a command. Handles -u USER, -S, -p '', etc."""
# sudo flags that consume the next argument
_SUDO_ARG_FLAGS = {'-u', '-g', '-C', '-p', '-r', '-t', '-D'}
parts = command.strip().split()
if not parts or parts[0] != 'sudo':
return command
i = 1
while i < len(parts):
if parts[i].startswith('-'):
flag = parts[i]
i += 1
if flag in _SUDO_ARG_FLAGS and i < len(parts):
i += 1 # skip argument
else:
break
return ' '.join(parts[i:]) if i < len(parts) else ''
def _sanitize_windows_command(command: str) -> str:
"""Translate Linux commands to Windows/PowerShell equivalents for Windows SSH servers.
Features:
- Translates common Linux commands (ls, cat, grep, ps, df, etc.)
- Wraps pipe chains in PowerShell when needed
- Forces UTF-8 via chcp 65001
- Passes through native Windows/PowerShell commands unchanged
- Auto-removes sudo, skips chmod/chown with warning
"""
import re
# 0. Empty / whitespace guard
if not command or not command.strip():
return command
# 1. Strip sudo early (before any other logic)
if re.match(r'^\s*sudo\b', command):
command = _strip_sudo(command)
if not command:
return command
# 2. Handle && chains — split, translate each, rejoin (before passthrough!)
if ' && ' in command:
parts = command.split(' && ')
translated = [_translate_single_command(p.strip()) for p in parts]
joined = ' && '.join(translated)
return f"chcp 65001 >nul && {joined}"
# 3. Handle pipes (before passthrough!)
if '|' in command:
return _translate_piped_command(command)
# 4. Passthrough — already Windows/PowerShell native (single commands only)
stripped = command.strip()
passthrough_prefixes = ('powershell ', 'powershell.exe ', 'pwsh ', 'pwsh.exe ',
'cmd /c ', 'cmd.exe /c ', 'chcp ', 'dir ', 'type ',
'copy ', 'move ', 'del ', 'mkdir ', 'rmdir ',
'tasklist', 'taskkill', 'ipconfig', 'netstat',
'systeminfo', 'where.exe', 'findstr ', 'echo ',
'set ', 'reg ', 'sc ', 'wmic ',
'Get-', 'Set-', 'New-', 'Remove-', 'Start-', 'Stop-',
'Restart-', 'Invoke-', 'Select-', 'Write-', 'Out-',
'Format-', 'Test-', 'Import-', 'Export-')
stripped_lower = stripped.lower()
for prefix in passthrough_prefixes:
if stripped_lower.startswith(prefix.lower()):
return f"chcp 65001 >nul && {command}"
# 5. Single command translation
translated = _translate_single_command(command)
return f"chcp 65001 >nul && {translated}"
def _translate_single_command(command: str) -> str:
"""Translate a single (non-piped) Linux command to Windows equivalent."""
import re
for pattern, replacement in _WIN_CMD_MAP:
match = re.match(pattern, command.strip(), re.IGNORECASE)
if match:
try:
return replacement(match)
except Exception:
continue
return command # No match — pass through as-is
def _translate_piped_command(command: str) -> str:
"""Translate a piped Linux command chain to PowerShell."""
import re
# If it already contains PowerShell cmdlets, pass through
if re.search(r'Get-|Set-|Select-|Where-|ForEach-|Measure-', command):
return f"chcp 65001 >nul && {command}"
# Split by pipe, translate first command, check if we need PowerShell wrapping
parts = [p.strip() for p in command.split('|')]
# Try translating the first segment
first_translated = _translate_single_command(parts[0])
# If the pipe chain mixes cmd and grep/awk/sed — wrap entire thing in PowerShell
needs_ps = any(re.match(r'\s*(grep|awk|sed|sort|uniq|wc|head|tail|cut|tr)\b', p, re.IGNORECASE)
for p in parts[1:])
if needs_ps:
# Build a PowerShell pipeline
ps_parts = [_linux_to_ps_pipe_segment(p.strip()) for p in parts]
ps_cmd = ' | '.join(ps_parts)
return f'chcp 65001 >nul && powershell -Command "{ps_cmd}"'
# Otherwise just join translated parts
translated_parts = [first_translated] + [_translate_single_command(p) for p in parts[1:]]
return f"chcp 65001 >nul && {' | '.join(translated_parts)}"
def _linux_to_ps_pipe_segment(segment: str) -> str:
"""Convert a single pipe segment from Linux to PowerShell equivalent."""
import re
s = segment.strip()
# cat → Get-Content
m = re.match(r'cat\b\s+(.*)', s, re.IGNORECASE)
if m:
return f"Get-Content '{m.group(1).strip()}'"
# ps aux/ps -ef → Get-Process
if re.match(r'ps\s+(aux|-ef)\s*$', s, re.IGNORECASE):
return 'Get-Process'
# grep → Select-String
m = re.match(r'grep\s+(-i\s+)?"?([^"]*)"?', s, re.IGNORECASE)
if m:
return f"Select-String -Pattern '{m.group(2)}'"
# sort
if re.match(r'sort\s*$', s, re.IGNORECASE):
return 'Sort-Object'
# sort -r / sort -n
if re.match(r'sort\s+-r', s, re.IGNORECASE):
return 'Sort-Object -Descending'
if re.match(r'sort\s+-n', s, re.IGNORECASE):
return 'Sort-Object {[int]$_}'
# uniq
if re.match(r'uniq\s*$', s, re.IGNORECASE):
return 'Get-Unique'
# wc -l
if re.match(r'wc\s+-l', s, re.IGNORECASE):
return 'Measure-Object -Line'
# head -n N
m = re.match(r'head\s+(-n\s*)?(\d+)', s, re.IGNORECASE)
if m:
return f'Select-Object -First {m.group(2)}'
if re.match(r'head\s*$', s, re.IGNORECASE):
return 'Select-Object -First 10'
# tail -n N
m = re.match(r'tail\s+(-n\s*)?(\d+)', s, re.IGNORECASE)
if m:
return f'Select-Object -Last {m.group(2)}'
if re.match(r'tail\s*$', s, re.IGNORECASE):
return 'Select-Object -Last 10'
# awk — basic field extraction
m = re.match(r"awk\s+['\"]?\{print\s+\$(\d+)\}['\"]?", s, re.IGNORECASE)
if m:
idx = int(m.group(1)) - 1
return f"ForEach-Object {{ ($_ -split '\\s+')[{idx}] }}"
# cut -d'X' -f N
m = re.match(r"cut\s+-d['\"]?(.)['\"]?\s+-f(\d+)", s, re.IGNORECASE)
if m:
idx = int(m.group(2)) - 1
return f"ForEach-Object {{ ($_ -split '{m.group(1)}')[{idx}] }}"
# sed — basic s/old/new/
m = re.match(r"sed\s+['\"]?s/([^/]*)/([^/]*)/?[g]?['\"]?", s, re.IGNORECASE)
if m:
return f"ForEach-Object {{ $_ -replace '{m.group(1)}', '{m.group(2)}' }}"
# tr -d 'X'
m = re.match(r"tr\s+-d\s+['\"]?(.+?)['\"]?\s*$", s, re.IGNORECASE)
if m:
return f"ForEach-Object {{ $_ -replace '[{m.group(1)}]', '' }}"
# Fallback — return as-is
return s
# ── File transfer ─────────────────────────────────────
def _normalize_remote_path(remote_path: str) -> str:
"""Normalize remote path by detecting and fixing MSYS path conversions."""
# Strip leading // that user adds to prevent Git Bash MSYS path conversion
# //C:/Temp → /C:/Temp
if remote_path.startswith("//") and len(remote_path) > 2 and remote_path[2] != "/":
remote_path = remote_path[1:]
# If the path looks like a Windows path that was converted by MSYS, fix it back
if ':' in remote_path and ('Program Files/Git' in remote_path or (len(remote_path) > 3 and remote_path[1] == ':' and remote_path[2] == '/')):
# Convert C:/Program Files/Git/tmp/file.txt back to /tmp/file.txt
@@ -251,7 +523,10 @@ def upload_file(server: dict, local_path: str, remote_path: str):
t0 = time.time()
sftp.put(local_path, normalized_remote_path, callback=_progress_cb(file_size))
elapsed = time.time() - t0
try:
sftp.chmod(normalized_remote_path, 0o664)
except OSError:
pass # Windows OpenSSH doesn't support chmod
sftp.close()
info = f"{_fmt_size(file_size)}, {elapsed:.1f}s"

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.63"
__version__ = "1.8.65"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"