diff --git a/README.md b/README.md index 2ab4f35..656fca4 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,234 @@ -# SSH_TOOLS +# sshtools -Go 实现的 SSH 命令行工具集,对标 PuTTY 的 plink / pscp:单个静态二进制,纯 Go,不依赖本机 -OpenSSH,也不需要安装任何运行时。 +Non-interactive SSH command-line tools for scripts: **golink** replaces PuTTY's +`plink`, **goscp** replaces the upload half of `pscp`. -当前包含 **golink**(对应 plink)和 **goscp**(对应 pscp 的上传方向)。 +Plain Go, `CGO_ENABLED=0`, one static binary per platform. The SSH stack and all +cryptography (RSA, ECDSA, Ed25519, AES-GCM, ChaCha20-Poly1305, SHA-2) are Go's +own implementation compiled into the binary -- nothing links against the system +OpenSSL and nothing has to be installed alongside it. ---- +**English** | [简体中文](README.zh-CN.md) + +## Install + +Download the binary for your platform from the +[latest release](../../releases/latest), then `chmod +x` it. + +| Platform | golink | goscp | +| --- | --- | --- | +| Windows x86-64 | `golink_windows_amd64.exe` | `goscp_windows_amd64.exe` | +| Windows ARM64 | `golink_windows_arm64.exe` | `goscp_windows_arm64.exe` | +| Linux x86-64 | `golink_linux_amd64` | `goscp_linux_amd64` | +| Linux ARM64 | `golink_linux_arm64` | `goscp_linux_arm64` | +| macOS Intel | `golink_darwin_amd64` | `goscp_darwin_amd64` | +| macOS Apple Silicon | `golink_darwin_arm64` | `goscp_darwin_arm64` | + +SHA256SUMS is attached to every release. + +On macOS a downloaded binary carries the quarantine attribute; clear it once with +`xattr -d com.apple.quarantine ./golink`. + +## The contract both tools share + +- they never prompt: not for a password, not for a host key, not for anything; +- they never allocate a terminal or open an interactive shell; +- stdout carries only the remote program's stdout, diagnostics go to stderr; +- exit codes are meaningful (see each tool below); +- every credential is supplied up front, so they are safe inside cron jobs and CI. ## golink -非交互 SSH 命令执行器:连上去、跑一条命令、把远端退出码原样带回来。 - -**从不提示密码、从不询问主机密钥、从不分配终端**,所以可以直接放进脚本和定时任务。 - -### 你要的那行,原样可用 - -``` -golink user@host -p "password" -port 22 -command "命令" -``` - -Go 标准库的 flag 遇到第一个位置参数就停止解析,所以 golink 在解析前把参数重排了一遍: -位置参数在前、flag 在后、或者混着写,都能正确识别。 - -### 示例 +Connect, run one command, and return that command's exit status. ```bash -# 最普通的一条 +golink user@host -p "password" -port 22 -command "command" +``` + +Go's `flag` package stops parsing at the first positional argument, so golink +reorders `argv` before parsing: the target may come first, last, or in between, +and flags may be written in any order. + +### Examples + +```bash +# the common case golink root@10.0.0.5 -p 'secret' -port 22 -command "uptime && df -h" -# 密码走环境变量,不出现在 ps 和 shell history 里 +# password from the environment, so it stays out of ps and shell history GOLINK_PASSWORD='secret' golink deploy@web1 -command "systemctl restart nginx" -# 远端退出码原样返回,脚本里可以直接判断 -if golink ci@build1 -p "$PASS" -command "make test"; then echo 通过; else echo "失败: $?"; fi +# the remote exit status is passed through, so this works +if golink ci@build1 -p "$PASS" -command "make test"; then echo passed; else echo "failed: $?"; fi -# 本地 stdin 直接喂给远端命令 +# feed local stdin to the remote command tar czf - ./src | golink root@backup -p "$PASS" -command "cat > /srv/src.tgz" -# 把远端输出接进本地管道 +# pipe remote output into a local filter golink db@10.0.0.9 -p "$PASS" -command "cat /var/log/app.log" | grep ERROR -# 密钥登录(OpenSSH 格式私钥) +# key auth (OpenSSH private key, no passphrase) golink ops@bastion -i ~/.ssh/id_ed25519 -command hostname -# 校验主机指纹,避免中间人 +# pin the host key fingerprint golink ops@10.0.0.7 -p "$PASS" -hostkey "$(ssh-keyscan -p 22 10.0.0.7 2>/dev/null | ssh-keygen -lf - | awk '{print $2}')" -command true -# 命令也可以当位置参数跟在后面(plink 风格) +# the command may also be trailing positional arguments, plink style golink ops@web1 -p "$PASS" systemctl status nginx ``` -### 参数 +### Flags -| 参数 | 默认 | 说明 | +| Flag | Default | Meaning | | --- | --- | --- | -| `-p` / `-password` / `-pw` | 空 | SSH 密码;也可以用环境变量 `GOLINK_PASSWORD` | -| `-port` / `-P` | 22 | SSH 端口;也可以写在目标里 `host:2222` | -| `-l` | 空 | 登录用户,等价于 `user@host` 里的 user | -| `-command` | 空 | 远端执行的命令;后面的位置参数会自动拼上去 | -| `-i` | 空 | OpenSSH 格式私钥文件(不支持带口令的私钥,见下) | -| `-knownhosts` | 空 | known_hosts 文件;未知或变更的主机密钥直接失败,不会提示 | -| `-hostkey` | 空 | 要求的主机指纹,`SHA256:...` 或 MD5 均可 | -| `-timeout` | 15s | 只覆盖「建连 + 握手 + 认证」,命令跑多久都不受它限制 | -| `-v` | 关 | 把连接、主机指纹、认证过程打到 stderr,不污染 stdout | -| `-version` / `-h` | | 版本 / 帮助 | +| `-p` / `-password` / `-pw` | empty | SSH password; `GOLINK_PASSWORD` works too | +| `-port` / `-P` | 22 | SSH port; `host:2222` in the target also works | +| `-l` | empty | login user, alternative to `user@host` | +| `-command` | empty | command to run; trailing positional arguments are appended | +| `-i` | empty | OpenSSH private key file (no passphrase, see below) | +| `-knownhosts` | empty | known_hosts file; unknown or changed keys fail, never prompt | +| `-hostkey` | empty | required host key fingerprint, `SHA256:...` or MD5 | +| `-timeout` | 15s | covers connect, handshake and authentication only, never the command | +| `-v` | off | connection diagnostics on stderr | +| `-version` / `-h` | | version / help | -目标写法支持 `user@host`、`user@host:2222`、`[::1]:2222`。 +Targets accept `user@host`, `user@host:2222` and `[::1]:2222`. -### 退出码 +### Exit status -| 退出码 | 含义 | +| Code | Meaning | | --- | --- | -| 0-255 | 远端命令的退出码原样返回(被信号杀死时按 shell 惯例返回 128+信号,如 SIGTERM → 143) | -| 2 | 用法错误:参数写错、没写用户、没写命令、没给任何凭据 | -| 255 | 连接、主机密钥或认证失败(和 OpenSSH 一致) | - -stdout 只放远端命令的标准输出,远端 stderr 走本地 stderr,`-v` 的诊断信息也走 stderr, -所以 `golink ... | ...` 和 `golink ... > out.txt` 都是安全的。 - -### 多平台二进制 - -| 文件 | 平台 | -| --- | --- | -| `dist/golink_linux_amd64` | Linux x86-64 | -| `dist/golink_linux_arm64` | Linux ARM64 | -| `dist/golink_darwin_amd64` | macOS Intel | -| `dist/golink_darwin_arm64` | macOS Apple Silicon | -| `dist/golink_windows_amd64.exe` | Windows x86-64 | -| `dist/golink_windows_arm64.exe` | Windows ARM64 | - -都是 `CGO_ENABLED=0` 的静态二进制,`chmod +x` 就能用;`dist/SHA256SUMS` 里有校验和。 - -### 重新编译 - -```bash -./build.sh # 六个平台全部编译到 dist/ -./build.sh local # 只编当前平台,产出 dist/golink -VERSION=1.2.3 ./build.sh -``` - -唯一的依赖是 `golang.org/x/crypto`(本地 module cache 里已经有 v0.41.0),所以**离线也能编译**。 -注意 build.sh 默认把 `GOCACHE` 指到仓库内的 `.gocache/`:某些受限环境里 `~/.cache/go-build` -是只读的,不换目录 go 会以非零码退出。 - -### 自测 - -```bash -./smoketest/run.sh -``` - -38 项端到端检查,全部在本地跑,不需要外网。它会编译一个自带的迷你 sshd -(`smoketest/sshd`,支持 password / keyboard-interactive / publickey 三种认证),然后逐个 -验证:参数顺序、stdin 管道、20 万字节回传、退出码透传(含 143/137)、stdout/stderr 分离、 -主机指纹校验、known_hosts、错误密码快速失败、加密私钥报错、键盘交互登录等。 -每个用例都关掉 stdin 并加了超时——只要哪里会等待交互输入,测试就会挂掉。 - -### Windows 提示 - -- cmd 里中文乱码:先 `chcp 65001`;PowerShell 7 一般没问题。 -- 退出码在 cmd 里看 `%ERRORLEVEL%`,PowerShell 里看 `$LASTEXITCODE`。 -- 首次运行可能被 SmartScreen / 杀软拦,需要放行。 - -### 主机密钥策略(重要) - -默认**不校验**主机密钥(`InsecureIgnoreHostKey`),因为脚本无法回答「是否信任这台主机」的 -交互提示——这跟 plink `-batch` 的取舍是一样的,代价是理论上可被中间人攻击。要防这一手: - -- `-hostkey SHA256:xxxx`:写死指纹,不匹配就拒绝; -- `-knownhosts ~/.ssh/known_hosts`:未知或变更直接失败,绝不提示。 - -### 安全提示 - -密码写在命令行会出现在 `ps` 和 shell history 里(plink 的 `-pw` 同样如此)。写脚本时更推荐 -`GOLINK_PASSWORD` 环境变量或 `-i` 密钥。带口令的私钥不支持:golink 不会交互式提问,遇到会 -直接报错并提示先用 `ssh-keygen -p` 去掉口令。 - -### 目录结构 - -``` -cmd/golink/ 命令行入口:参数、退出码映射 -internal/cli/ 参数重排(让 flag 可以出现在任意位置) -internal/sshx/ 建连、认证、主机密钥策略(后续工具复用) -smoketest/sshd/ 测试用迷你 SSH 服务端 -smoketest/run.sh 端到端测试 -build.sh 交叉编译脚本 -dist/ 产物 -``` - ---- +| 0-255 | the remote command's status, passed through (a killed command reports 128+signal, so SIGTERM gives 143) | +| 2 | usage error: bad flag, missing user, missing command, no credentials | +| 255 | connection, host key or authentication failure (same convention as OpenSSH) | ## goscp -把本地文件或目录传到远端,走 SCP 协议(远端跑 `scp -t`),不依赖 SFTP 子系统。 +Copy local files and directories to a remote host with the SCP protocol. The +remote side runs `scp -t`, so no SFTP subsystem is required. ```bash goscp [flags] [user@]host[:port] -files "dir or file" [-files ...] [-dest /remote/dir] ``` -### 示例 +One flag handles both files and directories: `-files` inspects the path and +either sends a single entry or walks the tree. There is no `-r` switch and no +separate mode. + +### Examples ```bash -# 传一个文件到远端家目录 -goscp jarue@172.19.4.240 -p jarue -port 22 -files "build/output.bin" +# one file, into the remote user's home directory +goscp user@host -p "$PASS" -port 22 -files "build/output.bin" -# 传整个目录到指定位置(远端目录不存在会自动建) -goscp jarue@172.19.4.240 -p jarue -files "build" -dest /home/jarue/app +# a whole directory, into a specific place (created when missing) +goscp user@host -p "$PASS" -files "build" -dest /home/user/app -# 多个文件 +# several files, either repeated or trailing goscp user@host -p "$PASS" -files a.txt -files b.txt -dest /srv/in +goscp user@host -p "$PASS" -files a.txt b.txt -dest /srv/in -# 密码走环境变量 +# password from the environment GOSCP_PASSWORD='secret' goscp user@host -files ./dist/ ``` -### 参数 +### Flags -| 参数 | 默认 | 说明 | +| Flag | Default | Meaning | | --- | --- | --- | -| `-files` | 空 | 要上传的本地文件**或目录**,同一套参数自动识别;可重复,尾随位置参数也算 | -| `-dest` | 远端家目录 | 远端目标目录,不存在会自动 `mkdir -p` | -| `-p` / `-password` / `-pw` | 空 | SSH 密码;也可用 `GOSCP_PASSWORD` / `GOLINK_PASSWORD` | -| `-port` / `-P` | 22 | SSH 端口 | -| `-l` | 空 | 登录用户 | -| `-i` | 空 | OpenSSH 格式私钥 | -| `-knownhosts` | 空 | known_hosts 文件;未知或变更直接失败 | -| `-hostkey` | 空 | 要求的主机指纹 | -| `-timeout` | 15s | 建连 + 认证超时,不含传输 | -| `-v` | 关 | 逐条列出上传的文件和大小 | -| `-q` | 关 | 不打印最后那行汇总 | +| `-files` | empty | local file **or** directory, detected automatically; repeatable, trailing arguments count too | +| `-dest` | remote home | remote target directory, created with `mkdir -p` when missing | +| `-p` / `-password` / `-pw` | empty | SSH password; `GOSCP_PASSWORD` or `GOLINK_PASSWORD` | +| `-port` / `-P` | 22 | SSH port | +| `-l` | empty | login user | +| `-i` | empty | OpenSSH private key | +| `-knownhosts` / `-hostkey` | empty | host key policy | +| `-timeout` | 15s | connect and authentication, not the transfer | +| `-v` | off | list every entry as it is sent | +| `-q` | off | no summary line on stderr | -### 退出码 +### Exit status -| 退出码 | 含义 | +| Code | Meaning | | --- | --- | -| 0 | 全部成功 | -| 1 | 传输失败(本地路径不存在、远端 scp 报错、权限不足) | -| 2 | 用法错误(参数、目标、没给文件、没给凭据) | -| 255 | 连接、主机密钥或认证失败 | +| 0 | everything was copied | +| 1 | transfer failed: missing local path, remote scp error, permission denied | +| 2 | usage error | +| 255 | connection, host key or authentication failure | -### 行为说明 +### Behaviour -- **文件和目录同一条命令**:`-files` 给什么就传什么,目录自动递归,不需要 `-r` 之类的开关 -- 目录保留相对结构:`-files build -dest /srv` → `/srv/build/...` -- 权限:普通文件 0644,本地本身有执行位的保持 0755,目录 0755 -- 符号链接:命令行上直接点名的会跟随;目录内部遇到的跳过并在 stderr 提示 -- 文件名含空格没问题(SCP 头按行解析);含换行的不支持 -- 传输是流式的,1 MiB 文件实测逐字节一致 +- directories keep their name and relative structure: `-files build -dest /srv` gives `/srv/build/...` +- permissions: regular files 0644 (0755 when the local file is executable), directories 0755 +- symlinks named on the command line are followed; symlinks met inside a tree are skipped with a warning +- file names may contain spaces; newlines are rejected +- the transfer is streamed and verified byte for byte in the test suite -### 还没做 +## Host key policy -- 下载方向(远端 → 本地) -- 断点续传、并行传输、进速显示 -- SFTP 后端(需要额外依赖,目前零依赖) -- 跳板机(`-J`)、端口转发、交互式 shell / pty +Host keys are **not** verified by default. A script cannot answer "do you trust +this host", so the tools behave like `plink -batch`: they accept the key and +continue. The trade-off is a theoretical man-in-the-middle. + +Pin it when it matters: + +- `-hostkey SHA256:...` -- fail unless the fingerprint matches; +- `-knownhosts ~/.ssh/known_hosts` -- unknown or changed keys fail, never prompt. + +## Security notes + +A password on the command line is visible in `ps` and the shell history, exactly +like `plink -pw`. Prefer `GOLINK_PASSWORD` / `GOSCP_PASSWORD` or `-i`. + +Passphrase-protected keys are rejected with an explanatory error: these tools +never prompt, so decrypt the key once with `ssh-keygen -p` instead. + +## Building + +```bash +./build.sh # every program for every platform, into dist/ +./build.sh local # only the host platform +VERSION=1.2.3 ./build.sh +``` + +The only dependency is `golang.org/x/crypto`, so an offline build works as long +as the module cache is populated. `build.sh` points `GOCACHE` at the in-repo +`.gocache/` directory, because some sandboxes mount `~/.cache/go-build` read-only. + +Pushing a `v*` tag makes GitHub Actions run both test suites, cross-compile +everything and attach the binaries to the GitHub release. Gitea also discovers +`.github/workflows`, so the jobs carry an explicit +`if: github.server_url == 'https://github.com'` guard and stay idle there. + +## Testing + +```bash +./smoketest/run.sh # golink, 38 checks +./smoketest/run-goscp.sh # goscp, 19 checks +``` + +Both suites are fully local: they build a miniature sshd (`smoketest/sshd`, +supporting password, keyboard-interactive and public-key auth) and drive it over +loopback. Every case runs with stdin closed and a watchdog, so any attempt to +wait for interactive input shows up as a failure rather than a hang. + +## Layout + +``` +cmd/golink/ golink entry point: flags, exit status mapping +cmd/goscp/ goscp entry point +internal/cli/ argv reordering so flags work anywhere +internal/sshx/ dial, authentication, host key policy +internal/scp/ SCP wire protocol, client side +smoketest/sshd/ miniature SSH server used by the tests +smoketest/run.sh golink end-to-end suite +smoketest/run-goscp.sh +build.sh cross-compilation for six targets +``` + +## Not implemented + +- download direction for goscp (remote to local) +- resume, parallel transfers, progress +- an SFTP backend (would add a dependency; SCP keeps the binary self-contained) +- jump hosts (`-J`), port forwarding, interactive shells / pty diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..96dc462 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,227 @@ +# sshtools + +面向脚本的非交互 SSH 命令行工具:**golink** 对应 PuTTY 的 `plink`,**goscp** 对应 `pscp` 的上传方向。 + +纯 Go、`CGO_ENABLED=0`,每个平台一个静态二进制。SSH 协议栈和全部密码学(RSA、ECDSA、Ed25519、 +AES-GCM、ChaCha20-Poly1305、SHA-2)都是 Go 自己的实现,编译进二进制里 —— 不链接系统 OpenSSL, +也不需要额外安装任何东西。 + +[English](README.md) | **简体中文** + +## 安装 + +从 [最新发布](../../releases/latest) 下载对应平台的二进制,`chmod +x` 即可。 + +| 平台 | golink | goscp | +| --- | --- | --- | +| Windows x86-64 | `golink_windows_amd64.exe` | `goscp_windows_amd64.exe` | +| Windows ARM64 | `golink_windows_arm64.exe` | `goscp_windows_arm64.exe` | +| Linux x86-64 | `golink_linux_amd64` | `goscp_linux_amd64` | +| Linux ARM64 | `golink_linux_arm64` | `goscp_linux_arm64` | +| macOS Intel | `golink_darwin_amd64` | `goscp_darwin_amd64` | +| macOS Apple Silicon | `golink_darwin_arm64` | `goscp_darwin_arm64` | + +每次发布都附带 `SHA256SUMS`。 + +macOS 上从网上下载的二进制带 quarantine 属性,执行一次 +`xattr -d com.apple.quarantine ./golink` 即可。 + +## 两个工具共同遵守的约定 + +- 从不提示:不问密码、不问主机密钥、不问任何东西; +- 从不分配终端,也不打开交互式 shell; +- stdout 只放远端程序的标准输出,诊断信息一律走 stderr; +- 退出码有明确含义(见下文); +- 所有凭据都要提前给,所以可以放心放进 cron 和 CI。 + +## golink + +连上去、跑一条命令、把远端退出码原样带回来。 + +```bash +golink user@host -p "password" -port 22 -command "command" +``` + +Go 标准库的 `flag` 遇到第一个位置参数就停止解析,所以 golink 在解析前把 `argv` 重排了一遍: +目标在最前、最后、或者中间都行,flag 的顺序也无所谓。 + +### 示例 + +```bash +# 最普通的一条 +golink root@10.0.0.5 -p 'secret' -port 22 -command "uptime && df -h" + +# 密码走环境变量,不出现在 ps 和 shell history 里 +GOLINK_PASSWORD='secret' golink deploy@web1 -command "systemctl restart nginx" + +# 远端退出码原样返回,脚本里可以直接判断 +if golink ci@build1 -p "$PASS" -command "make test"; then echo 通过; else echo "失败: $?"; fi + +# 本地 stdin 直接喂给远端命令 +tar czf - ./src | golink root@backup -p "$PASS" -command "cat > /srv/src.tgz" + +# 把远端输出接进本地管道 +golink db@10.0.0.9 -p "$PASS" -command "cat /var/log/app.log" | grep ERROR + +# 密钥登录(OpenSSH 格式私钥) +golink ops@bastion -i ~/.ssh/id_ed25519 -command hostname + +# 写死主机指纹,避免中间人 +golink ops@10.0.0.7 -p "$PASS" -hostkey "$(ssh-keyscan -p 22 10.0.0.7 2>/dev/null | ssh-keygen -lf - | awk '{print $2}')" -command true + +# 命令也可以当位置参数跟在后面(plink 风格) +golink ops@web1 -p "$PASS" systemctl status nginx +``` + +### 参数 + +| 参数 | 默认 | 说明 | +| --- | --- | --- | +| `-p` / `-password` / `-pw` | 空 | SSH 密码;也可以用环境变量 `GOLINK_PASSWORD` | +| `-port` / `-P` | 22 | SSH 端口;也可以写在目标里 `host:2222` | +| `-l` | 空 | 登录用户,等价于 `user@host` 里的 user | +| `-command` | 空 | 远端执行的命令;后面的位置参数会自动拼上去 | +| `-i` | 空 | OpenSSH 格式私钥文件(不支持带口令的私钥,见下) | +| `-knownhosts` | 空 | known_hosts 文件;未知或变更的主机密钥直接失败,不会提示 | +| `-hostkey` | 空 | 要求的主机指纹,`SHA256:...` 或 MD5 均可 | +| `-timeout` | 15s | 只覆盖「建连 + 握手 + 认证」,命令跑多久都不受它限制 | +| `-v` | 关 | 把连接、主机指纹、认证过程打到 stderr,不污染 stdout | +| `-version` / `-h` | | 版本 / 帮助 | + +目标写法支持 `user@host`、`user@host:2222`、`[::1]:2222`。 + +### 退出码 + +| 退出码 | 含义 | +| --- | --- | +| 0-255 | 远端命令的退出码原样返回(被信号杀死时按 shell 惯例返回 128+信号,如 SIGTERM → 143) | +| 2 | 用法错误:参数写错、没写用户、没写命令、没给任何凭据 | +| 255 | 连接、主机密钥或认证失败(和 OpenSSH 一致) | + +stdout 只放远端命令的标准输出,远端 stderr 走本地 stderr,`-v` 的诊断信息也走 stderr, +所以 `golink ... | ...` 和 `golink ... > out.txt` 都是安全的。 + +## goscp + +把本地文件或目录传到远端,走 SCP 协议(远端跑 `scp -t`),不依赖 SFTP 子系统。 + +```bash +goscp [flags] [user@]host[:port] -files "dir or file" [-files ...] [-dest /remote/dir] +``` + +一个 `-files` 同时管文件和目录:它看路径本身,是文件就发一个条目,是目录就递归整棵树。 +没有 `-r` 开关,也没有第二种模式。 + +### 示例 + +```bash +# 传一个文件到远端家目录 +goscp user@host -p "$PASS" -port 22 -files "build/output.bin" + +# 传整个目录到指定位置(远端目录不存在会自动建) +goscp user@host -p "$PASS" -files "build" -dest /home/user/app + +# 多个文件:重复写,或者跟在后头 +goscp user@host -p "$PASS" -files a.txt -files b.txt -dest /srv/in +goscp user@host -p "$PASS" -files a.txt b.txt -dest /srv/in + +# 密码走环境变量 +GOSCP_PASSWORD='secret' goscp user@host -files ./dist/ +``` + +### 参数 + +| 参数 | 默认 | 说明 | +| --- | --- | --- | +| `-files` | 空 | 要上传的本地文件**或目录**,自动识别;可重复,尾随位置参数也算 | +| `-dest` | 远端家目录 | 远端目标目录,不存在会自动 `mkdir -p` | +| `-p` / `-password` / `-pw` | 空 | SSH 密码;也可用 `GOSCP_PASSWORD` / `GOLINK_PASSWORD` | +| `-port` / `-P` | 22 | SSH 端口 | +| `-l` | 空 | 登录用户 | +| `-i` | 空 | OpenSSH 格式私钥 | +| `-knownhosts` / `-hostkey` | 空 | 主机密钥策略 | +| `-timeout` | 15s | 建连 + 认证超时,不含传输 | +| `-v` | 关 | 逐条列出上传的文件和大小 | +| `-q` | 关 | 不打印最后那行汇总 | + +### 退出码 + +| 退出码 | 含义 | +| --- | --- | +| 0 | 全部成功 | +| 1 | 传输失败(本地路径不存在、远端 scp 报错、权限不足) | +| 2 | 用法错误(参数、目标、没给文件、没给凭据) | +| 255 | 连接、主机密钥或认证失败 | + +### 行为说明 + +- 目录保留名字和相对结构:`-files build -dest /srv` → `/srv/build/...` +- 权限:普通文件 0644(本地有执行位则 0755),目录 0755 +- 符号链接:命令行上直接点名的会跟随;目录内部遇到的跳过并在 stderr 提示 +- 文件名含空格没问题;含换行的不支持 +- 传输是流式的,测试里逐字节校验过 + +## 主机密钥策略(重要) + +默认**不校验**主机密钥。脚本无法回答「是否信任这台主机」,所以行为等同 `plink -batch`: +接受并继续,代价是理论上可被中间人攻击。 + +在意的话就钉死: + +- `-hostkey SHA256:...` —— 指纹不符直接失败; +- `-knownhosts ~/.ssh/known_hosts` —— 未知或变更直接失败,绝不提示。 + +## 安全提示 + +密码写在命令行会出现在 `ps` 和 shell history 里,和 `plink -pw` 一样。优先用 +`GOLINK_PASSWORD` / `GOSCP_PASSWORD` 或 `-i` 密钥。 + +带口令的私钥会被明确拒绝并给出提示:这两个工具从不交互提问,请先用 `ssh-keygen -p` +去掉口令。 + +## 编译 + +```bash +./build.sh # 所有程序 × 所有平台,产物在 dist/ +./build.sh local # 只编当前平台 +VERSION=1.2.3 ./build.sh +``` + +唯一的依赖是 `golang.org/x/crypto`,只要 module cache 有货,离线也能编。 +`build.sh` 把 `GOCACHE` 指到仓库内的 `.gocache/`,因为有些沙箱里 `~/.cache/go-build` 是只读的。 + +推一个 `v*` tag 会触发 GitHub Actions:先跑两套测试,再交叉编译并把二进制挂到 GitHub Release。 +Gitea 也会识别 `.github/workflows`,所以 job 里带了 +`if: github.server_url == 'https://github.com'` 的判断,在 Gitea 上保持空转。 + +## 自测 + +```bash +./smoketest/run.sh # golink,38 项 +./smoketest/run-goscp.sh # goscp,19 项 +``` + +两套都在本地跑:编一个迷你 sshd(`smoketest/sshd`,支持 password / keyboard-interactive / +publickey 三种认证),通过回环驱动。每个用例都关掉 stdin 并加了超时,任何等待交互输入的行为 +都会表现为失败而不是挂起。 + +## 目录结构 + +``` +cmd/golink/ 命令行入口:参数、退出码映射 +cmd/goscp/ 命令行入口 +internal/cli/ 参数重排,让 flag 可以出现在任意位置 +internal/sshx/ 建连、认证、主机密钥策略 +internal/scp/ SCP 协议客户端 +smoketest/sshd/ 测试用迷你 SSH 服务端 +smoketest/run.sh golink 端到端测试 +smoketest/run-goscp.sh +build.sh 六个目标的交叉编译 +``` + +## 还没做 + +- goscp 的下载方向(远端 → 本地) +- 断点续传、并行传输、进度显示 +- SFTP 后端(会引入依赖,目前 SCP 让二进制保持自包含) +- 跳板机(`-J`)、端口转发、交互式 shell / pty