first commit
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -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
|
||||||
189
README.md
Normal file
189
README.md
Normal file
@@ -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 任务、故障排查和安全最佳实践
|
||||||
82
SKILL.md
Normal file
82
SKILL.md
Normal file
@@ -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.
|
||||||
3
agents/openai.yaml
Normal file
3
agents/openai.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Linux SSH Operator"
|
||||||
|
short_description: "Operate Linux servers over SSH safely"
|
||||||
103
references/ssh-playbook.md
Normal file
103
references/ssh-playbook.md
Normal file
@@ -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).
|
||||||
|
|
||||||
763
scripts/ssh_copy.sh
Normal file
763
scripts/ssh_copy.sh
Normal file
@@ -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
|
||||||
176
scripts/ssh_run.sh
Normal file
176
scripts/ssh_run.sh
Normal file
@@ -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[@]}"
|
||||||
Reference in New Issue
Block a user