From a6742336bce54343d5602d7ca3d08ea416ea02ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=8C=E5=B0=8F=E8=8A=BD?= Date: Mon, 23 Mar 2026 17:27:15 +0800 Subject: [PATCH] first commit --- .claude/settings.local.json | 14 + .gitignore | 22 ++ README.md | 189 +++++++++ SKILL.md | 82 ++++ agents/openai.yaml | 3 + references/ssh-playbook.md | 103 +++++ scripts/ssh_copy.sh | 763 ++++++++++++++++++++++++++++++++++++ scripts/ssh_run.sh | 176 +++++++++ 8 files changed, 1352 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 agents/openai.yaml create mode 100644 references/ssh-playbook.md create mode 100644 scripts/ssh_copy.sh create mode 100644 scripts/ssh_run.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8bc1e6b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(bash -n /mnt/d/SmyProjects/Skills/linux-ssh-operator/scripts/ssh_copy.sh && echo \"Syntax OK\")", + "Bash(/mnt/d/SmyProjects/Skills/linux-ssh-operator/scripts/ssh_copy.sh --help)", + "Bash(./scripts/ssh_copy.sh --dry-run push my-server ./file.txt /tmp/)", + "Bash(./scripts/ssh_copy.sh --method rsync -r --dry-run push my-server ./dir /tmp/)", + "Bash(./scripts/ssh_copy.sh --tar --dry-run push my-server ./dir /tmp/)", + "Bash(./scripts/ssh_copy.sh --tar --dry-run push my-server ./ssh_copy.sh /tmp/)", + "Bash(./scripts/ssh_copy.sh --method rsync -r --exclude '.git' --exclude 'node_modules' --delete --dry-run push my-server /tmp/test_transfer_dir /tmp/)", + "Bash(bash -n /mnt/d/SmyProjects/Skills/linux-ssh-operator/scripts/ssh_run.sh && bash -n /mnt/d/SmyProjects/Skills/linux-ssh-operator/scripts/ssh_copy.sh && echo \"Both scripts syntax OK\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1efe8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Node / React +node_modules/ +build/ +coverage/ + +# Go +*.exe +*.test +*.out +*.dll +*.so +*.dylib + +# Data +data/data.json + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..5deb627 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# Linux SSH Operator + +通过 SSH 连接并操作 Linux 服务器的 Claude Code 技能——执行远程命令、查看日志、管理 systemd 服务、传输文件。 + +## 触发场景 + +当你说这些时会激活此技能: +- `ssh` / `scp` / `rsync` +- 远程服务器 IP 和端口 +- `systemctl` / `journalctl` +- 部署到服务器 +- 在服务器上运行命令 +- 向/从远程服务器拷贝文件 + +## 快速上手 + +### 1. 配置 SSH 密钥(推荐) + +```bash +# 生成 ed25519 密钥 +ssh-keygen -t ed25519 -C "codex" -f ~/.ssh/id_ed25519 + +# 将公钥复制到服务器 +ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 22 USER@SERVER_IP + +# 可选:在 ~/.ssh/config 中添加主机别名 +Host my-server + HostName SERVER_IP + Port 22 + User USER + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +``` + +### 2. 执行远程命令 + +```bash +# 直接执行 +ssh my-server uname -a + +# 使用 sudo(需要 TTY) +ssh -tt my-server sudo systemctl restart nginx + +# 通过包装脚本执行(统一选项) +~/.claude/skills/linux-ssh-operator/scripts/ssh_run.sh my-server -- uname -a +~/.claude/skills/linux-ssh-operator/scripts/ssh_run.sh --tty --sudo my-server -- systemctl restart nginx +``` + +### 3. 传输文件 + +```bash +# 上传(push) +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh push my-server ./local.txt /tmp/local.txt + +# 下载(pull) +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh pull my-server /var/log/syslog ./syslog + +# 强制 rsync 方式同步目录 +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh --method rsync -r push my-server ./dir /tmp/dir + +# 强制 tar 打包传输(大量小文件) +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh --tar push my-server ./many-small-files/ /tmp/ + +# Rsync 排除文件并同步 +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh --method rsync -r \ + --exclude '.git' --exclude 'node_modules' --exclude '*.log' \ + --delete push my-server ./project/ /tmp/project/ + +# 显示传输进度和统计 +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh --method rsync -r \ + --progress --stats push my-server ./dir /tmp/ + +# 预演模式(不执行,只显示命令) +~/.claude/skills/linux-ssh-operator/scripts/ssh_copy.sh --method tar \ + --dry-run push my-server ./dir /tmp/ +``` + +## 脚本说明 + +### ssh_run.sh + +统一选项的远程命令执行脚本。 + +```bash +ssh_run.sh [选项] 主机 -- 命令 [参数...] + +选项: + -u, --user 用户名 指定 SSH 用户 + -p, --port 端口 SSH 端口(默认 22) + -i, --key 路径 私钥文件路径 + -t, --tty 强制分配 pseudo-tty + --sudo 命令前加 sudo + --sudo-non-interactive 使用 sudo -n(密码需要时失败) + --connect-timeout 秒 连接超时(默认 10 秒) + --dry-run 仅打印 ssh 命令,不执行 + -h, --help 显示帮助 + +环境变量默认值: + REMOTE_USER, REMOTE_PORT, REMOTE_KEY, REMOTE_CONNECT_TIMEOUT +``` + +### ssh_copy.sh + +统一选项的文件传输脚本(支持 scp/rsync/sftp,自动选择最优方式)。 + +```bash +ssh_copy.sh [选项] push 主机 本地路径 远程路径 +ssh_copy.sh [选项] pull 主机 远程路径 本地路径 + +选项: + -u, --user 用户名 指定 SSH 用户 + -p, --port 端口 SSH 端口(默认 22) + -i, --key 路径 私钥文件路径 + --connect-timeout 秒 连接超时(默认 10 秒) + -r, --recursive 递归拷贝目录 + --accept-new 设置 StrictHostKeyChecking=accept-new + + # 传输方式 + -m, --method {auto,scp,rsync,sftp} + 传输方式(默认 auto) + --tar 强制 tar+scp 打包传输 + --tar-format {tar,tar.gz,tar.xz} + tar 格式(默认 tar.gz) + --tar-threshold N 触发打包的文件数量阈值(默认 20) + + # 压缩 + --compress {auto,yes,no} + 启用压缩(默认 auto) + --compress-level N 压缩级别 1-9(默认 6) + + # Rsync 专用 + --exclude 模式 排除匹配(可多次指定) + --delete 同步后删除目标多余文件 + --whole-file 强制全量传输 + + # 输出 + --progress 显示传输进度 + --stats 显示传输统计 + --dry-run 仅打印命令,不执行 + -h, --help 显示帮助 + +环境变量默认值: + REMOTE_USER, REMOTE_PORT, REMOTE_KEY, REMOTE_CONNECT_TIMEOUT + +自动选择规则: + - 单文件 → scp + - 目录同步 → rsync -a + - 大量小文件 (>20个) → tar.gz + scp + - 大文件 (>100MB) → rsync -z + - 需要排除文件 → rsync --exclude + +注意:`--tar` 与 `--method rsync/scp/sftp` 不要混用。 + +## 常用操作 + +| 任务 | 命令 | +|------|------| +| 磁盘使用 | `df -h`, `du -sh /路径/* \| sort -h` | +| 内存/进程 | `free -h`, `ps aux --sort=-%mem \| head` | +| 查看日志 | `journalctl -u 服务名 -n 200 --no-pager` | +| 服务状态 | `systemctl status 服务名 --no-pager` | +| 重启服务 | `systemctl restart 服务名`(需要 sudo + tty) | +| 网络状态 | `ss -lntp`, `ip a`, `ip r` | + +## 安全规范 + +- **禁止** 在文件或聊天记录中存储密码 +- **避免** `StrictHostKeyChecking=no`——优先验证 host key,或仅对临时主机使用 `accept-new` +- **破坏性命令**(rm、shutdown、防火墙变更)需要用户明确确认,并先展示完整命令 +- **先读后写**——先做只读检查,再执行变更,最后验证 + +## 项目结构 + +``` +linux-ssh-operator/ +├── SKILL.md # 技能定义和触发条件 +├── README.md # 本文件 +├── agents/ +│ └── openai.yaml # Agent 接口配置 +├── references/ +│ └── ssh-playbook.md # SSH 操作参考文档 +└── scripts/ + ├── ssh_run.sh # 远程命令执行包装脚本 + └── ssh_copy.sh # 文件传输包装脚本(scp/rsync/sftp) +``` + +## 参考资料 + +- [SSH 操作参考](references/ssh-playbook.md) — 常用 SSH 任务、故障排查和安全最佳实践 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..71d79ab --- /dev/null +++ b/SKILL.md @@ -0,0 +1,82 @@ +--- +name: linux-ssh-operator +description: 通过 SSH 连接并操作 Linux 服务器:执行远程命令、查看日志、管理 systemd 服务、传输文件(scp/rsync/tar/sftp)、排障。用户提到 ssh/scp/rsync、远程服务器 IP:端口、systemctl/journalctl、部署到服务器、在服务器上运行命令、远程拷贝文件 等场景时使用。 +--- + +# Linux SSH Operator + +## Goal + +Use SSH for safe, repeatable Linux server operations. + +## Fast Decision + +1. Remote command or service check -> `scripts/ssh_run.sh` +2. Single file copy -> `scripts/ssh_copy.sh --method scp` +3. Directory sync or exclusions -> `scripts/ssh_copy.sh --method rsync -r` +4. Many small files -> `scripts/ssh_copy.sh --method tar` +5. If sudo may prompt -> add `--tty --sudo` + +Prefer explicit method selection when the shape is already known. It is faster and avoids bad auto guesses. + +## Before Acting + +1. Confirm `host`, `port`, `user`, and auth method. +2. Prefer SSH keys and `~/.ssh/config` aliases. +3. Start with read-only checks, then change, then verify. +4. For first-connect automation, prefer `--accept-new` only when appropriate. +5. On flaky links, set `--connect-timeout` so failed attempts return fast. + +## Command Runs + +Use `ssh_run.sh` for non-interactive commands: + +```bash +ssh_run.sh my-server -- uname -a +ssh_run.sh --tty --sudo my-server -- systemctl restart nginx +ssh_run.sh --sudo-non-interactive my-server -- systemctl status nginx --no-pager +``` + +Notes: + +- `--sudo` is for commands that may prompt. +- `--sudo-non-interactive` is only for passwordless sudo paths. + +## File Transfer + +Use `ssh_copy.sh` for transfers: + +```bash +ssh_copy.sh --method scp push my-server ./local.txt /tmp/local.txt +ssh_copy.sh --method rsync -r push my-server ./dir /tmp/dir +ssh_copy.sh --method tar push my-server ./many-small-files/ /tmp/ +``` + +Rules: + +- `--tar` is a packaging mode, not something to mix with `--method rsync/scp/sftp`. +- Use `--exclude` only with `rsync`. +- Use `--delete` only when you really want destination cleanup. + +## Common Ops + +- Disk: `df -h`, `du -sh /path/* | sort -h` +- Memory/CPU: `free -h`, `top`, `ps aux --sort=-%mem | head` +- Logs: `journalctl -u SERVICE -n 200 --no-pager` +- Services: `systemctl status|restart|stop SERVICE` +- Networking: `ss -lntp`, `ip a`, `ip r` + +## Safety + +- Never store passwords in repo files or chat logs. +- Avoid `StrictHostKeyChecking=no`. +- For destructive commands, ask for explicit confirmation and show the exact command first. + +## References + +- SSH security + troubleshooting: `references/ssh-playbook.md` + +## Scripts + +- `scripts/ssh_run.sh`: remote command execution with consistent options. +- `scripts/ssh_copy.sh`: file transfer via scp/rsync/tar/sftp with consistent options. diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..06b94ff --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Linux SSH Operator" + short_description: "Operate Linux servers over SSH safely" diff --git a/references/ssh-playbook.md b/references/ssh-playbook.md new file mode 100644 index 0000000..702e0d3 --- /dev/null +++ b/references/ssh-playbook.md @@ -0,0 +1,103 @@ +# SSH playbook (Linux server ops) + +## Defaults and conventions + +- Prefer SSH keys (ed25519) and `~/.ssh/config` aliases for repeatable runs. +- Avoid putting passwords in files, prompts, or chat logs. If password auth is required, use an interactive terminal/TTY. +- Start with read-only inspection, then apply changes, then verify. + +Recommended env vars for wrappers: + +- `REMOTE_USER`: default SSH user +- `REMOTE_PORT`: default SSH port (usually 22) +- `REMOTE_KEY`: path to identity file (private key) +- `REMOTE_CONNECT_TIMEOUT`: connect timeout seconds + +## SSH key setup (recommended) + +Generate a new key: + +```bash +ssh-keygen -t ed25519 -C "codex" -f ~/.ssh/id_ed25519 +``` + +Copy the public key to the server: + +```bash +ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 22 USER@SERVER_IP +``` + +Add a host alias: + +```sshconfig +Host my-server + HostName SERVER_IP + Port 22 + User USER + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +``` + +## Common tasks + +### Connectivity and OS info + +```bash +ssh my-server "whoami && hostname && uname -a" +ssh my-server "cat /etc/os-release" +``` + +### Disk and memory + +```bash +ssh my-server "df -h" +ssh my-server "free -h" +ssh my-server "du -sh /var/log/* | sort -h | tail" +``` + +### Processes and ports + +```bash +ssh my-server "ps aux --sort=-%mem | head" +ssh my-server "ss -lntp" +``` + +### Logs (systemd) + +```bash +ssh my-server "journalctl -u SERVICE -n 200 --no-pager" +ssh my-server "journalctl -u SERVICE -f --no-pager" +``` + +### Services (systemd) + +Status: + +```bash +ssh my-server "systemctl status SERVICE --no-pager" +``` + +Restart (often needs sudo and TTY): + +```bash +ssh -tt my-server "sudo systemctl restart SERVICE" +``` + +Non-interactive sudo (fails if a password prompt would be required): + +```bash +ssh my-server "sudo -n systemctl restart SERVICE" +``` + +## Safer host key handling + +- Prefer verifying the host key fingerprint out-of-band on first connect. +- If you must automate first-connect for ephemeral hosts, use `StrictHostKeyChecking=accept-new` (OpenSSH 7.6+). +- If you see a "host key changed" warning, treat it as a potential security incident until you confirm the change is expected. + +## Troubleshooting quick hits + +- `Permission denied (publickey)`: wrong user, wrong key, server missing your public key, or `sshd` settings. +- `Connection timed out`: routing/firewall/security group, wrong port, server down. +- `No route to host`: network path missing (VPN, subnet, ACL). + diff --git a/scripts/ssh_copy.sh b/scripts/ssh_copy.sh new file mode 100644 index 0000000..6b88565 --- /dev/null +++ b/scripts/ssh_copy.sh @@ -0,0 +1,763 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Copy files via scp/rsync/sftp with automatic optimization. + +Usage: + ssh_copy.sh [options] push HOST LOCAL_PATH REMOTE_PATH + ssh_copy.sh [options] pull HOST REMOTE_PATH LOCAL_PATH + +Options: + -u, --user USER Override SSH user (or set REMOTE_USER) + -p, --port PORT SSH port (default: REMOTE_PORT or 22) + -i, --key PATH Identity file (default: REMOTE_KEY) + --connect-timeout SEC Connect timeout (default: REMOTE_CONNECT_TIMEOUT or 10) + -r, --recursive Copy directories recursively + --accept-new Set StrictHostKeyChecking=accept-new + + # Transfer method + -m, --method {auto,scp,rsync,sftp} + Transfer method (default: auto) + --tar Force tar+scp packaging (exclusive with --method) + --tar-format {tar,tar.gz,tar.xz} + Tar format (default: tar.gz) + --tar-threshold N File count threshold to trigger tar (default: 20) + + # Compression + --compress {auto,yes,no} + Enable compression (default: auto) + --compress-level N Compression level 1-9 (default: 6) + + # Rsync options + --exclude PATTERN Exclude pattern (can be repeated) + --delete Delete extraneous files in destination + --whole-file Force full file transfer (no delta) + + # Output + --progress Show transfer progress + --stats Show transfer statistics + --dry-run Print the command that would run + -h, --help Show help + +Environment defaults: + REMOTE_USER, REMOTE_PORT, REMOTE_KEY, REMOTE_CONNECT_TIMEOUT + +Method selection guide: + - Single file: scp (default) + - Directory sync: rsync -a (default) + - Many small files (>20): tar.gz + scp (default) + - Large file (>100MB): rsync -z (default) + - Exclude patterns: rsync --exclude (only option) + +Examples: + # Standard push/pull (auto-selects best method) + ssh_copy.sh push my-server ./file.txt /tmp/ + ssh_copy.sh pull my-server /var/log/syslog ./syslog + + # Force specific method + ssh_copy.sh --method rsync -r push my-server ./dir /tmp/ + ssh_copy.sh --method sftp push my-server ./file.txt /tmp/ + + # Force tar packaging + ssh_copy.sh --tar push my-server ./many-files/ /tmp/ + + # Rsync with exclusions + ssh_copy.sh --method rsync -r --exclude '.git' --exclude 'node_modules' \ + --delete push my-server ./project/ /tmp/project/ + + # Dry run + ssh_copy.sh --method tar --dry-run push my-server ./dir /tmp/ +USAGE +} + +fail() { + echo "Error: $*" >&2 + exit 2 +} + +require_arg() { + local value="${1:-}" + local opt="${2:-option}" + [[ -n "$value" ]] || fail "$opt requires a value" + printf '%s' "$value" +} + +quote_shell() { + local value="$1" + printf "'%s'" "${value//\'/\'\\\'\'}" +} + +remote_path_expr() { + local value="$1" + if [[ "$value" == "~" ]]; then + printf '%s' '$HOME' + elif [[ "$value" == "~/"* ]]; then + printf '%s' "\$HOME/$(quote_shell "${value#\~/}")" + else + quote_shell "$value" + fi +} + +# Default values +port="${REMOTE_PORT:-22}" +user="${REMOTE_USER:-}" +key="${REMOTE_KEY:-}" +connect_timeout="${REMOTE_CONNECT_TIMEOUT:-10}" + +# Method selection +method="auto" +recursive=false +accept_new=false +dry_run=false + +# Tar options +force_tar=false +tar_format="tar.gz" +tar_threshold=20 + +# Compression +compress="auto" +compress_level=6 + +# Rsync options +declare -a exclude_patterns=() +delete_mode=false +whole_file=false + +# Output options +show_progress=false +show_stats=false + +# Transfer source stats +source_kind="" +source_count=0 +source_size=0 + +# Parse options +while [[ $# -gt 0 ]]; do + case "$1" in + -u|--user) + user="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -p|--port) + port="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -i|--key) + key="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --connect-timeout) + connect_timeout="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -r|--recursive) + recursive=true + shift + ;; + --accept-new) + accept_new=true + shift + ;; + -m|--method) + method="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --tar) + force_tar=true + shift + ;; + --tar-format) + tar_format="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --tar-threshold) + tar_threshold="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --compress) + compress="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --compress-level) + compress_level="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --exclude) + exclude_patterns+=("$(require_arg "${2:-}" "$1")") + shift 2 + ;; + --delete) + delete_mode=true + shift + ;; + --whole-file) + whole_file=true + shift + ;; + --progress) + show_progress=true + shift + ;; + --stats) + show_stats=true + shift + ;; + --dry-run) + dry_run=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + *) + break + ;; + esac +done + +if [[ $# -lt 4 ]]; then + usage >&2 + exit 2 +fi + +direction="$1" +shift + +case "$direction" in + push|pull) + ;; + *) + fail "Invalid direction: $direction (expected push or pull)" + ;; +esac + +host="$1" +shift + +if [[ "$method" != "auto" && "$method" != "scp" && "$method" != "rsync" && "$method" != "sftp" && "$method" != "tar" ]]; then + fail "Invalid method: $method" +fi + +case "$compress" in + auto|yes|no) + ;; + *) + fail "Invalid compress mode: $compress" + ;; +esac + +case "$tar_format" in + tar|tar.gz|tgz|tar.xz) + ;; + *) + fail "Invalid tar format: $tar_format" + ;; +esac + +if ! [[ "$tar_threshold" =~ ^[0-9]+$ ]]; then + fail "Invalid tar threshold: $tar_threshold" +fi + +if ! [[ "$compress_level" =~ ^[0-9]+$ ]] || [[ "$compress_level" -lt 1 || "$compress_level" -gt 9 ]]; then + fail "Invalid compress level: $compress_level" +fi + +if ! [[ "$connect_timeout" =~ ^[0-9]+$ ]]; then + fail "Invalid connect timeout: $connect_timeout" +fi + +if $force_tar && [[ "$method" != "auto" && "$method" != "tar" ]]; then + fail "--tar cannot be combined with --method $method" +fi + +if $force_tar; then + method="tar" +fi + +# Build destination host string +dest_host="$host" +if [[ -n "$user" ]]; then + host_no_user="${host#*@}" + dest_host="${user}@${host_no_user}" +fi + +# Build common SSH options +ssh_opts=( + -p "$port" + -o "ConnectTimeout=${connect_timeout}" + -o "ServerAliveInterval=30" + -o "ServerAliveCountMax=3" +) + +if [[ -n "$key" ]]; then + ssh_opts+=(-i "$key" -o "IdentitiesOnly=yes") +fi + +if $accept_new; then + ssh_opts+=(-o "StrictHostKeyChecking=accept-new") +fi + +# Build scp options +scp_opts=(-P "$port" -p) +if [[ -n "$key" ]]; then + scp_opts+=(-i "$key" -o "IdentitiesOnly=yes") +fi +if $recursive; then + scp_opts+=(-r) +fi +if $accept_new; then + scp_opts+=(-o "StrictHostKeyChecking=accept-new") +fi +if $show_progress; then + scp_opts+=(-v) +fi + +# Get file count for a path +get_file_count() { + local path="$1" + if [[ -f "$path" ]]; then + echo 1 + elif [[ -d "$path" ]]; then + if $recursive; then + find "$path" -type f 2>/dev/null | wc -l + else + find "$path" -maxdepth 1 -type f 2>/dev/null | wc -l + fi + else + echo 0 + fi +} + +# Get total size in bytes +get_total_size() { + local path="$1" + if [[ -f "$path" ]]; then + stat -c%s "$path" 2>/dev/null || echo 0 + elif [[ -d "$path" ]]; then + if $recursive; then + du -sb "$path" 2>/dev/null | cut -f1 || echo 0 + else + find "$path" -maxdepth 1 -type f -exec stat -c%s {} + 2>/dev/null | awk '{s+=$1} END {print s+0}' + fi + else + echo 0 + fi +} + +probe_local_source_stats() { + local path="$1" + + if [[ -f "$path" ]]; then + source_kind="file" + source_count=1 + source_size=$(stat -c%s "$path" 2>/dev/null || echo 0) + return + fi + + if [[ -d "$path" ]]; then + source_kind="dir" + source_count=$(find "$path" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d '[:space:]') + source_size=$(du -sb "$path" 2>/dev/null | cut -f1 || echo 0) + return + fi + + fail "Local path not found: $path" +} + +probe_remote_source_stats() { + local path="$1" + local quoted_path + quoted_path=$(remote_path_expr "$path") + local remote_cmd + remote_cmd="if [ -f $quoted_path ]; then printf 'file 1 %s\n' \"\$(stat -c%s -- $quoted_path 2>/dev/null || echo 0)\"; elif [ -d $quoted_path ]; then count=\$(find $quoted_path -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d '[:space:]'); size=\$(du -sb $quoted_path 2>/dev/null | cut -f1 || echo 0); printf 'dir %s %s\n' \"\$count\" \"\$size\"; else printf 'missing 0 0\n'; fi" + + read -r source_kind source_count source_size < <( + ssh "${ssh_opts[@]}" "$dest_host" "$remote_cmd" + ) + + if [[ "$source_kind" == "missing" ]]; then + fail "Remote path not found: $path" + fi +} + +# Determine if tar should be used +should_use_tar() { + if $force_tar; then + return 0 + fi + + if [[ "$source_kind" != "dir" ]]; then + return 1 + fi + + if [[ "$source_count" -gt "$tar_threshold" ]]; then + return 0 + fi + + return 1 +} + +# Auto-detect best transfer method +auto_detect_method() { + if should_use_tar; then + echo "tar" + return + fi + + # If rsync options specified, use rsync + if [[ ${#exclude_patterns[@]} -gt 0 ]] || $delete_mode || $whole_file; then + echo "rsync" + return + fi + + if [[ "$source_kind" == "unknown" ]]; then + if $recursive || [[ "$source_path" == */ ]]; then + echo "rsync" + else + echo "scp" + fi + return + fi + + if [[ "$source_kind" == "file" ]]; then + if [[ "$source_size" -gt 104857600 ]]; then + echo "rsync" + else + echo "scp" + fi + return + fi + + # Directory sync defaults to rsync + if [[ "$source_kind" == "dir" ]]; then + echo "rsync" + return + fi + + fail "Unable to determine source type for auto transfer" +} + +# Determine compression flag +get_compress_flag() { + case "$compress" in + yes) + echo true + ;; + no) + echo false + ;; + auto) + # Auto compress for large files (>100MB) + if [[ "$source_size" -gt 104857600 ]]; then + echo true + else + echo false + fi + ;; + esac +} + +# Get tar extension based on format +get_tar_extension() { + case "$tar_format" in + tar.gz|tgz) + echo "tar.gz" + ;; + tar.xz) + echo "tar.xz" + ;; + tar) + echo "tar" + ;; + *) + echo "tar.gz" + ;; + esac +} + +# Get tar compression flags +get_tar_compress_flags() { + case "$tar_format" in + tar.gz|tgz) + echo "-z" + ;; + tar.xz) + echo "-J" + ;; + tar) + echo "" + ;; + *) + echo "-z" + ;; + esac +} + +# Build rsync options +build_rsync_opts() { + local rsync_opts=("-a" "-v") + + if $show_progress; then + rsync_opts+=("--progress") + fi + + if $show_stats; then + rsync_opts+=("--stats") + fi + + if [[ ${#exclude_patterns[@]} -gt 0 ]]; then + for pattern in "${exclude_patterns[@]}"; do + rsync_opts+=("--exclude=$pattern") + done + fi + + if $delete_mode; then + rsync_opts+=("--delete") + fi + + if $whole_file; then + rsync_opts+=("--whole-file") + fi + + # Compression for large files + local use_compress + use_compress=$(get_compress_flag) + if $use_compress; then + rsync_opts+=("-z" "--compress-level=$compress_level") + fi + + echo "${rsync_opts[@]}" +} + +# Build SSH command for rsync +build_rsync_ssh_cmd() { + local ssh_cmd=("ssh") + ssh_cmd+=(-p "$port") + ssh_cmd+=(-o "ConnectTimeout=${connect_timeout}") + ssh_cmd+=(-o "ServerAliveInterval=30") + ssh_cmd+=(-o "ServerAliveCountMax=3") + if [[ -n "$key" ]]; then + ssh_cmd+=(-i "$key" -o "IdentitiesOnly=yes") + fi + if $accept_new; then + ssh_cmd+=(-o "StrictHostKeyChecking=accept-new") + fi + echo "${ssh_cmd[@]}" +} + +# Push with tar+scp +do_push_with_tar() { + local local_path="$1" + local remote_path="$2" + local ext + ext=$(get_tar_extension) + local local_tarball + local_tarball="$(mktemp "${TMPDIR:-/tmp}/_transfer_XXXXXX.${ext}")" + local remote_tarball="/tmp/$(basename "$local_tarball")" + local tar_compress + tar_compress=$(get_tar_compress_flags) + + if $dry_run; then + echo "tar ${tar_compress} -cf '$local_tarball' -C '$(dirname "$local_path")' '$(basename "$local_path")'" + echo "scp ${scp_opts[*]} '$local_tarball' '${dest_host}:$remote_tarball'" + echo "ssh ${ssh_opts[*]} '$dest_host' \"mkdir -p $(remote_path_expr "$remote_path") && tar ${tar_compress} -xf $(quote_shell "$remote_tarball") -C $(remote_path_expr "$remote_path") && rm -f $(quote_shell "$remote_tarball")\"" + echo "rm -f '$local_tarball'" + return + fi + + # Create tarball + tar ${tar_compress} -cf "$local_tarball" -C "$(dirname "$local_path")" "$(basename "$local_path")" + + # Transfer via scp + scp "${scp_opts[@]}" "$local_tarball" "${dest_host}:${remote_tarball}" + + # Remote: extract and cleanup + ssh "${ssh_opts[@]}" "$dest_host" "mkdir -p $(remote_path_expr "$remote_path") && tar ${tar_compress} -xf $(quote_shell "$remote_tarball") -C $(remote_path_expr "$remote_path") && rm -f $(quote_shell "$remote_tarball")" + + # Local cleanup + rm -f "$local_tarball" +} + +# Pull with tar+scp +do_pull_with_tar() { + local remote_path="$1" + local local_path="$2" + local ext + ext=$(get_tar_extension) + local local_tarball + local_tarball="$(mktemp "${TMPDIR:-/tmp}/_transfer_XXXXXX.${ext}")" + local remote_tarball="/tmp/$(basename "$local_tarball")" + local tar_compress + tar_compress=$(get_tar_compress_flags) + local remote_dir + remote_dir=$(dirname "$remote_path") + local remote_base + remote_base=$(basename "$remote_path") + + if $dry_run; then + echo "ssh ${ssh_opts[*]} '$dest_host' \"cd $(remote_path_expr "$remote_dir") && tar ${tar_compress} -cf $(quote_shell "$remote_tarball") $(quote_shell "$remote_base")\"" + echo "scp ${scp_opts[*]} '${dest_host}:$remote_tarball' '$local_tarball'" + echo "mkdir -p '$local_path' && tar ${tar_compress} -xf '$local_tarball' -C '$local_path'" + echo "rm -f '$local_tarball'" + echo "ssh ${ssh_opts[*]} '$dest_host' \"rm -f $(quote_shell "$remote_tarball")\"" + return + fi + + # Remote: create tarball + ssh "${ssh_opts[@]}" "$dest_host" "cd $(remote_path_expr "$remote_dir") && tar ${tar_compress} -cf $(quote_shell "$remote_tarball") $(quote_shell "$remote_base")" + + # Download tarball + scp "${scp_opts[@]}" "${dest_host}:${remote_tarball}" "$local_tarball" + + # Local: extract and cleanup + mkdir -p "$local_path" + tar ${tar_compress} -xf "$local_tarball" -C "$local_path" + rm -f "$local_tarball" + + # Remote cleanup + ssh "${ssh_opts[@]}" "$dest_host" "rm -f $(quote_shell "$remote_tarball")" +} + +# Standard scp transfer +do_scp() { + local local_path="$1" + local remote_path="$2" + + if $dry_run; then + if [[ "$direction" == "push" ]]; then + echo "scp ${scp_opts[*]} '$local_path' '${dest_host}:$remote_path'" + else + echo "scp ${scp_opts[*]} '${dest_host}:$local_path' '$remote_path'" + fi + return + fi + + case "$direction" in + push) + scp "${scp_opts[@]}" "$local_path" "${dest_host}:${remote_path}" + ;; + pull) + scp "${scp_opts[@]}" "${dest_host}:${local_path}" "$remote_path" + ;; + esac +} + +# Rsync transfer +do_rsync() { + local local_path="$1" + local remote_path="$2" + local rsync_opts + rsync_opts=$(build_rsync_opts) + local rsync_ssh + rsync_ssh=$(build_rsync_ssh_cmd) + + if $dry_run; then + if [[ "$direction" == "push" ]]; then + echo "rsync ${rsync_opts} -e '${rsync_ssh}' '$local_path' '${dest_host}:$remote_path'" + else + echo "rsync ${rsync_opts} -e '${rsync_ssh}' '${dest_host}:$local_path' '$remote_path'" + fi + return + fi + + case "$direction" in + push) + rsync ${rsync_opts} -e "${rsync_ssh}" "$local_path" "${dest_host}:${remote_path}" + ;; + pull) + rsync ${rsync_opts} -e "${rsync_ssh}" "${dest_host}:${local_path}" "$remote_path" + ;; + esac +} + +# SFTP transfer (interactive/ scripted) +do_sftp() { + local local_path="$1" + local remote_path="$2" + + if $dry_run; then + if [[ "$direction" == "push" ]]; then + echo "sftp ${ssh_opts[*]} '${dest_host}' <<< 'put $local_path $remote_path'" + else + echo "sftp ${ssh_opts[*]} '${dest_host}' <<< 'get $local_path $remote_path'" + fi + return + fi + + case "$direction" in + push) + sftp "${ssh_opts[@]}" "$dest_host" <<< "put '$local_path' '$remote_path'" + ;; + pull) + sftp "${ssh_opts[@]}" "$dest_host" <<< "get '$local_path' '$remote_path'" + ;; + esac +} + +# Main logic +local_path="$1" +remote_path="$2" +source_path="$local_path" + +need_source_stats=false +if [[ "$method" == "auto" ]]; then + need_source_stats=true +elif [[ "$method" == "rsync" && "$compress" == "auto" ]]; then + need_source_stats=true +fi + +if $need_source_stats; then + if $dry_run && [[ "$direction" == "pull" ]]; then + source_kind="unknown" + source_count=0 + source_size=0 + elif [[ "$direction" == "push" ]]; then + probe_local_source_stats "$local_path" + else + probe_remote_source_stats "$local_path" + fi +fi + +if [[ "$method" == "auto" ]]; then + method=$(auto_detect_method) +fi + +# Validate method +case "$method" in + scp|rsync|sftp|tar) + ;; + *) + echo "Invalid method: $method" >&2 + exit 2 + ;; +esac + +# Execute transfer +case "$method" in + scp) + do_scp "$local_path" "$remote_path" + ;; + rsync) + do_rsync "$local_path" "$remote_path" + ;; + sftp) + do_sftp "$local_path" "$remote_path" + ;; + tar) + if [[ "$direction" == "push" ]]; then + do_push_with_tar "$local_path" "$remote_path" + else + do_pull_with_tar "$local_path" "$remote_path" + fi + ;; +esac diff --git a/scripts/ssh_run.sh b/scripts/ssh_run.sh new file mode 100644 index 0000000..1cfb510 --- /dev/null +++ b/scripts/ssh_run.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Run a remote command over SSH with consistent, script-friendly options. + +Usage: + ssh_run.sh [options] HOST -- COMMAND [ARG...] + ssh_run.sh [options] HOST # interactive shell + +Options: + -u, --user USER Override SSH user (or set REMOTE_USER) + -p, --port PORT SSH port (default: REMOTE_PORT or 22) + -i, --key PATH Identity file (default: REMOTE_KEY) + -t, --tty Force pseudo-tty allocation (useful for sudo prompts) + --accept-new Set StrictHostKeyChecking=accept-new + --sudo Prefix command with sudo -- + --sudo-non-interactive Prefix command with sudo -n -- (fails if password needed) + --connect-timeout SEC Connect timeout (default: REMOTE_CONNECT_TIMEOUT or 10) + --dry-run Print the ssh command that would run + -h, --help Show help + +Environment defaults: + REMOTE_USER, REMOTE_PORT, REMOTE_KEY, REMOTE_CONNECT_TIMEOUT + +Examples: + ssh_run.sh --user ubuntu 10.0.0.1 -- uname -a + ssh_run.sh --tty --sudo my-server -- systemctl restart nginx +USAGE +} + +fail() { + echo "Error: $*" >&2 + exit 2 +} + +require_arg() { + local value="${1:-}" + local opt="${2:-option}" + [[ -n "$value" ]] || fail "$opt requires a value" + printf '%s' "$value" +} + +port="${REMOTE_PORT:-22}" +user="${REMOTE_USER:-}" +key="${REMOTE_KEY:-}" +connect_timeout="${REMOTE_CONNECT_TIMEOUT:-10}" + +tty=false +accept_new=false +sudo_mode="" +dry_run=false + +while [[ $# -gt 0 ]]; do + case "$1" in + -u|--user) + user="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -p|--port) + port="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -i|--key) + key="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + -t|--tty) + tty=true + shift + ;; + --accept-new) + accept_new=true + shift + ;; + --sudo) + sudo_mode="sudo" + shift + ;; + --sudo-non-interactive) + sudo_mode="sudo-n" + shift + ;; + --connect-timeout) + connect_timeout="$(require_arg "${2:-}" "$1")" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + *) + break + ;; + esac +done + +if ! [[ "$port" =~ ^[0-9]+$ ]]; then + fail "Invalid port: $port" +fi + +if ! [[ "$connect_timeout" =~ ^[0-9]+$ ]]; then + fail "Invalid connect timeout: $connect_timeout" +fi + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 2 +fi + +host="$1" +shift + +dest="$host" +if [[ -n "$user" ]]; then + host_no_user="${host#*@}" + dest="${user}@${host_no_user}" +fi + +ssh_opts=( + -p "$port" + -o "ConnectTimeout=${connect_timeout}" + -o "ServerAliveInterval=30" + -o "ServerAliveCountMax=3" +) + +if [[ -n "$key" ]]; then + ssh_opts+=(-i "$key" -o "IdentitiesOnly=yes") +fi + +if $accept_new; then + ssh_opts+=(-o "StrictHostKeyChecking=accept-new") +fi + +if $tty; then + ssh_opts+=(-tt) +fi + +cmd=("$@") +if [[ ${#cmd[@]} -gt 0 && "${cmd[0]}" == "--" ]]; then + cmd=("${cmd[@]:1}") +fi +if [[ -n "$sudo_mode" && ${#cmd[@]} -gt 0 ]]; then + if [[ "$sudo_mode" == "sudo-n" ]]; then + cmd=("sudo" "-n" "--" "${cmd[@]}") + else + cmd=("sudo" "--" "${cmd[@]}") + fi +fi + +full_cmd=(ssh "${ssh_opts[@]}" "$dest") +if [[ ${#cmd[@]} -gt 0 ]]; then + full_cmd+=("${cmd[@]}") +fi + +if $dry_run; then + printf '%q ' "${full_cmd[@]}" + printf '\n' + exit 0 +fi + +"${full_cmd[@]}"