Files
haproxy-mcp/haproxy_mcp/ssh_ops.py
kappa 12fd3b5e8f Store SQLite DB on remote host via SCP for persistence
Instead of syncing JSON files back, the SQLite DB itself is now
the persistent store on the remote HAProxy host:
- Startup: download remote DB via SCP (skip migration if exists)
- After writes: upload local DB via SCP (WAL checkpoint first)
- JSON sync removed (sync_servers_json, sync_certs_json deleted)

New functions:
- ssh_ops: remote_download_file(), remote_upload_file() via SCP
- db: sync_db_to_remote(), _try_download_remote_db()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 11:46:36 +09:00

207 lines
6.0 KiB
Python

"""SSH remote execution for HAProxy MCP Server.
When REMOTE_MODE is enabled (SSH_HOST is set), file I/O and subprocess
commands are executed on the remote HAProxy host via SSH.
"""
import subprocess
from .config import (
SSH_HOST,
SSH_USER,
SSH_KEY,
SSH_PORT,
REMOTE_MODE,
SUBPROCESS_TIMEOUT,
logger,
)
def _ssh_base_cmd() -> list[str]:
"""Build base SSH command with options."""
cmd = [
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-p", str(SSH_PORT),
]
if SSH_KEY:
cmd.extend(["-i", SSH_KEY])
cmd.append(f"{SSH_USER}@{SSH_HOST}")
return cmd
def remote_exec(command: str, timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess:
"""Execute a command on the remote host via SSH.
The command is passed as a single SSH argument so the remote shell
interprets it as-is (compatible with fish, bash, zsh).
Args:
command: Shell command to execute remotely
timeout: Command timeout in seconds
Returns:
CompletedProcess with stdout/stderr
Raises:
subprocess.TimeoutExpired: If command times out
OSError: If SSH command fails to execute
"""
ssh_cmd = _ssh_base_cmd() + [command]
logger.debug("SSH exec: %s", command)
return subprocess.run(
ssh_cmd,
capture_output=True,
text=True,
timeout=timeout,
)
def remote_read_file(path: str) -> str:
"""Read a file from the remote host.
Args:
path: Absolute file path on remote host
Returns:
File contents as string
Raises:
FileNotFoundError: If file doesn't exist on remote
IOError: If read fails
"""
result = remote_exec(f"cat {path}")
if result.returncode != 0:
stderr = result.stderr.strip()
if "No such file" in stderr:
raise FileNotFoundError(f"Remote file not found: {path}")
raise IOError(f"Failed to read remote file {path}: {stderr}")
return result.stdout
def remote_write_file(path: str, content: str) -> None:
"""Write content to a file on the remote host atomically.
Uses bash explicitly for bash-specific syntax ($(mktemp), $tmpf).
The entire script is passed as a single argument to 'bash -c'.
Args:
path: Absolute file path on remote host
content: Content to write
Raises:
IOError: If write fails
"""
ssh_cmd = _ssh_base_cmd()
# Atomic write via bash (single-quoted to survive remote shell interpretation)
remote_script = f"tmpf=$(mktemp {path}.tmp.XXXXXX) && cat >\"$tmpf\" && mv \"$tmpf\" {path}"
ssh_cmd.append(f"bash -c '{remote_script}'")
logger.debug("SSH write: %s (%d bytes)", path, len(content))
result = subprocess.run(
ssh_cmd,
input=content,
capture_output=True,
text=True,
timeout=SUBPROCESS_TIMEOUT,
)
if result.returncode != 0:
raise IOError(f"Failed to write remote file {path}: {result.stderr.strip()}")
def remote_file_exists(path: str) -> bool:
"""Check if a file exists on the remote host.
Args:
path: Absolute file path on remote host
Returns:
True if file exists
"""
result = remote_exec(f"test -f {path} && echo yes || echo no")
return result.stdout.strip() == "yes"
def _scp_base_cmd() -> list[str]:
"""Build base SCP command with options."""
cmd = [
"scp",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-P", str(SSH_PORT),
]
if SSH_KEY:
cmd.extend(["-i", SSH_KEY])
return cmd
def remote_download_file(remote_path: str, local_path: str) -> bool:
"""Download a binary file from the remote host via SCP.
Args:
remote_path: Absolute file path on remote host
local_path: Absolute local file path to write to
Returns:
True if downloaded successfully, False if file doesn't exist
"""
cmd = _scp_base_cmd() + [f"{SSH_USER}@{SSH_HOST}:{remote_path}", local_path]
logger.debug("SCP download: %s -> %s", remote_path, local_path)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT)
if result.returncode != 0:
logger.debug("SCP download failed: %s", result.stderr.strip())
return False
return True
def remote_upload_file(local_path: str, remote_path: str) -> None:
"""Upload a binary file to the remote host via SCP.
Args:
local_path: Absolute local file path to upload
remote_path: Absolute file path on remote host
Raises:
IOError: If upload fails
"""
cmd = _scp_base_cmd() + [local_path, f"{SSH_USER}@{SSH_HOST}:{remote_path}"]
logger.debug("SCP upload: %s -> %s", local_path, remote_path)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT)
if result.returncode != 0:
raise IOError(f"SCP upload failed: {result.stderr.strip()}")
def run_command(args: list[str], timeout: int = SUBPROCESS_TIMEOUT) -> subprocess.CompletedProcess:
"""Execute a command locally or remotely based on REMOTE_MODE.
Args:
args: Command and arguments as list
timeout: Command timeout in seconds
Returns:
CompletedProcess with stdout/stderr
"""
if REMOTE_MODE:
# Join args into a shell command for SSH
# Quote arguments that contain spaces
quoted = []
for a in args:
if " " in a or "'" in a or '"' in a:
quoted.append(f"'{a}'")
else:
quoted.append(a)
return remote_exec(" ".join(quoted), timeout=timeout)
else:
return subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
)