sshtools: 非交互 SSH 工具集(golink / goscp)

golink: 一条命令在远端执行,退出码原样透传,从不提示、不分配终端。
goscp:  用 SCP 协议上传,-files 一个参数同时接受文件和目录。

- 纯 Go,唯一依赖 golang.org/x/crypto,可离线交叉编译到六个桌面平台
- internal/sshx 统一建连、认证(password + keyboard-interactive)与主机密钥策略
- smoketest/ 自带迷你 sshd,57 项端到端测试(golink 38 + goscp 19)
This commit is contained in:
liyp
2026-10-19 10:22:53 +08:00
commit 4365e80075
14 changed files with 1958 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Shell scripts must keep LF even when checked out on Windows.
*.sh text eol=lf
* text=auto
+8
View File
@@ -0,0 +1,8 @@
.gocache/
.gocache-*/
dist/
archive/
.smoketest/
.smoketest-goscp/
smoketest/*.out
smoketest/*.err
+215
View File
@@ -0,0 +1,215 @@
# SSH_TOOLS
Go 实现的 SSH 命令行工具集,对标 PuTTY 的 plink / pscp:单个静态二进制,纯 Go,不依赖本机
OpenSSH,也不需要安装任何运行时。
当前包含 **golink**(对应 plink)和 **goscp**(对应 pscp 的上传方向)。
---
## golink
非交互 SSH 命令执行器:连上去、跑一条命令、把远端退出码原样带回来。
**从不提示密码、从不询问主机密钥、从不分配终端**,所以可以直接放进脚本和定时任务。
### 你要的那行,原样可用
```
golink user@host -p "password" -port 22 -command "命令"
```
Go 标准库的 flag 遇到第一个位置参数就停止解析,所以 golink 在解析前把参数重排了一遍:
位置参数在前、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` 都是安全的。
### 多平台二进制
| 文件 | 平台 |
| --- | --- |
| `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/ 产物
```
---
## goscp
把本地文件或目录传到远端,走 SCP 协议(远端跑 `scp -t`),不依赖 SFTP 子系统。
```bash
goscp [flags] [user@]host[:port] -files "dir or file" [-files ...] [-dest /remote/dir]
```
### 示例
```bash
# 传一个文件到远端家目录
goscp jarue@172.19.4.240 -p jarue -port 22 -files "build/output.bin"
# 传整个目录到指定位置(远端目录不存在会自动建)
goscp jarue@172.19.4.240 -p jarue -files "build" -dest /home/jarue/app
# 多个文件
goscp user@host -p "$PASS" -files a.txt -files 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` | 空 | known_hosts 文件;未知或变更直接失败 |
| `-hostkey` | 空 | 要求的主机指纹 |
| `-timeout` | 15s | 建连 + 认证超时,不含传输 |
| `-v` | 关 | 逐条列出上传的文件和大小 |
| `-q` | 关 | 不打印最后那行汇总 |
### 退出码
| 退出码 | 含义 |
| --- | --- |
| 0 | 全部成功 |
| 1 | 传输失败(本地路径不存在、远端 scp 报错、权限不足) |
| 2 | 用法错误(参数、目标、没给文件、没给凭据) |
| 255 | 连接、主机密钥或认证失败 |
### 行为说明
- **文件和目录同一条命令**:`-files` 给什么就传什么,目录自动递归,不需要 `-r` 之类的开关
- 目录保留相对结构:`-files build -dest /srv` → `/srv/build/...`
- 权限:普通文件 0644,本地本身有执行位的保持 0755,目录 0755
- 符号链接:命令行上直接点名的会跟随;目录内部遇到的跳过并在 stderr 提示
- 文件名含空格没问题(SCP 头按行解析);含换行的不支持
- 传输是流式的,1 MiB 文件实测逐字节一致
### 还没做
- 下载方向(远端 → 本地)
- 断点续传、并行传输、进速显示
- SFTP 后端(需要额外依赖,目前零依赖)
- 跳板机(`-J`)、端口转发、交互式 shell / pty
Executable
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# Cross-compile the sshtools binaries for the three desktop platforms.
#
# ./build.sh cross-compile every program and target
# ./build.sh local only the host binaries, at dist/golink and dist/goscp
#
# Everything is pure Go (CGO disabled), so no cross toolchain is needed and the
# build works offline: the only dependency, golang.org/x/crypto, is already in
# the local module cache.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
version="${VERSION:-1.0.0}"
export CGO_ENABLED=0
export GOCACHE="${GOCACHE:-$PWD/.gocache}"
export GOFLAGS="${GOFLAGS:--mod=mod}"
ldflags="-s -w -X main.version=$version"
programs="golink goscp"
targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64"
mkdir -p dist
build() { # <program> <goos> <goarch> <output>
GOOS="$2" GOARCH="$3" go build -trimpath -ldflags "$ldflags" -o "$4" "./cmd/$1"
printf ' %-32s %7s %s\n' "$4" "$(du -h "$4" | cut -f1)" "$(file -b "$4" 2>/dev/null | cut -c1-58)"
}
hostos="$(go env GOOS)"
hostarch="$(go env GOARCH)"
if [ "${1:-all}" = local ]; then
for program in $programs; do
ext=""
[ "$hostos" = windows ] && ext=".exe"
build "$program" "$hostos" "$hostarch" "dist/${program}${ext}"
done
exit 0
fi
echo "sshtools $version"
for program in $programs; do
for target in $targets; do
os="${target%%/*}"
arch="${target##*/}"
ext=""
[ "$os" = windows ] && ext=".exe"
build "$program" "$os" "$arch" "dist/${program}_${os}_${arch}${ext}"
done
done
# convenience copies for the machine running this script
ext=""
[ "$hostos" = windows ] && ext=".exe"
for program in $programs; do
native="dist/${program}_${hostos}_${hostarch}${ext}"
if [ -f "$native" ]; then
cp "$native" "dist/${program}${ext}"
fi
done
echo
( cd dist && sha256sum golink_* goscp_* > SHA256SUMS && cat SHA256SUMS )
+221
View File
@@ -0,0 +1,221 @@
// Command golink runs a single command on a remote host over SSH and exits with
// that command's exit status.
//
// It is deliberately non-interactive: it never prompts for a password, never
// asks to confirm a host key and never allocates a terminal, so it can be
// dropped straight into shell scripts and scheduled jobs.
//
// golink [flags] [user@]host[:port] [-command "command"] [args...]
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"golang.org/x/crypto/ssh"
"sshtools/internal/cli"
"sshtools/internal/sshx"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
const usage = "" +
"golink - non-interactive SSH command runner (sshtools)\n" +
"\n" +
"usage:\n" +
" golink [flags] [user@]host[:port] [-command \"command\"] [args...]\n" +
"\n" +
"examples:\n" +
" golink root@10.0.0.5 -p 'secret' -port 22 -command \"uptime && df -h\"\n" +
" golink deploy@web1 -p \"$PASS\" -command \"systemctl restart nginx\"\n" +
" echo data | golink user@host -p \"$PASS\" -command \"cat > /tmp/x\"\n" +
" golink user@host -i ~/.ssh/id_ed25519 -command hostname\n" +
"\n" +
"flags:\n" +
" -p, -password string SSH password, or set GOLINK_PASSWORD\n" +
" (-pw is accepted as a plink-style alias)\n" +
" -port, -P int SSH port (default 22)\n" +
" -l string login user, alternative to user@host\n" +
" -command string command to run remotely; extra positional args\n" +
" are appended to it\n" +
" -i string private key file (OpenSSH format)\n" +
" -knownhosts string known_hosts file: unknown or changed keys fail\n" +
" -hostkey string require this host key fingerprint (SHA256:...)\n" +
" -timeout duration connect and authenticate timeout (default 15s)\n" +
" -v verbose diagnostics on stderr\n" +
" -version print version and exit\n" +
"\n" +
"exit status:\n" +
" the remote command's exit status is passed through unchanged\n" +
" 2 usage error (bad flags, missing target or user, no credentials)\n" +
" 255 connection, host key or authentication failure\n"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var (
password string
user string
port int
command string
keyFile string
knownHosts string
hostKey string
timeout time.Duration
verbose bool
showVer bool
)
fs := flag.NewFlagSet("golink", flag.ContinueOnError)
fs.SetOutput(io.Discard) // errors are reported below in our own format
fs.StringVar(&password, "p", "", "")
fs.StringVar(&password, "password", "", "")
fs.StringVar(&password, "pw", "", "")
fs.IntVar(&port, "port", sshx.DefaultPort, "")
fs.IntVar(&port, "P", sshx.DefaultPort, "")
fs.StringVar(&user, "l", "", "")
fs.StringVar(&command, "command", "", "")
fs.StringVar(&keyFile, "i", "", "")
fs.StringVar(&knownHosts, "knownhosts", "", "")
fs.StringVar(&hostKey, "hostkey", "", "")
fs.DurationVar(&timeout, "timeout", 15*time.Second, "")
fs.BoolVar(&verbose, "v", false, "")
fs.BoolVar(&showVer, "version", false, "")
valueFlags := map[string]bool{
"p": true, "password": true, "pw": true, "port": true, "P": true, "l": true,
"command": true, "i": true, "knownhosts": true, "hostkey": true,
"timeout": true,
}
flags, positional := cli.Split(args, valueFlags)
if err := fs.Parse(append(flags, positional...)); err != nil {
if errors.Is(err, flag.ErrHelp) {
fmt.Print(usage)
return 0
}
return usageError(err.Error())
}
rest := fs.Args()
if showVer {
fmt.Printf("golink %s\n", version)
return 0
}
if len(rest) == 0 {
return usageError("missing target: expected [user@]host")
}
target, err := sshx.ParseTarget(rest[0], user, port)
if err != nil {
return usageError(err.Error())
}
remoteCmd := command
if extra := rest[1:]; len(extra) > 0 {
if remoteCmd == "" {
remoteCmd = strings.Join(extra, " ")
} else {
remoteCmd += " " + strings.Join(extra, " ")
}
}
if strings.TrimSpace(remoteCmd) == "" {
return usageError("missing command: pass -command \"...\" (golink never opens an interactive shell)")
}
if password == "" {
password = os.Getenv("GOLINK_PASSWORD")
}
if password == "" && keyFile == "" {
return usageError("no credentials: pass -p <password> (or set GOLINK_PASSWORD) or -i <keyfile>")
}
logf := func(format string, args ...any) {}
if verbose {
logf = func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "golink: "+format+"\n", args...)
}
}
client, err := sshx.Dial(sshx.Config{
Target: target,
Password: password,
KeyFile: keyFile,
KnownHosts: knownHosts,
HostKey: hostKey,
Timeout: timeout,
Logf: logf,
})
if err != nil {
fmt.Fprintf(os.Stderr, "golink: %v\n", err)
return 255
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
fmt.Fprintf(os.Stderr, "golink: open session: %v\n", err)
return 255
}
defer session.Close()
session.Stdin = os.Stdin
session.Stdout = os.Stdout
session.Stderr = os.Stderr
logf("running %q on %s", remoteCmd, target.Addr())
return exitStatus(session.Run(remoteCmd))
}
func usageError(msg string) int {
fmt.Fprintf(os.Stderr, "golink: %s\ngolink: run 'golink -h' for usage\n", msg)
return 2
}
// exitStatus maps the result of running a remote command onto a process exit
// status: the remote status is passed through unchanged, a remote signal is
// reported as 128+N (the shell convention) and local failures use 255, the same
// convention as OpenSSH.
func exitStatus(err error) int {
if err == nil {
return 0
}
var exitErr *ssh.ExitError
if errors.As(err, &exitErr) {
if status := exitErr.ExitStatus(); status >= 0 {
return status
}
if name := exitErr.Signal(); name != "" {
if num, ok := signalNumbers[name]; ok {
return 128 + num
}
fmt.Fprintf(os.Stderr, "golink: remote command killed by signal %s\n", name)
return 255
}
return 255
}
var missing *ssh.ExitMissingError
if errors.As(err, &missing) {
fmt.Fprintf(os.Stderr, "golink: %v\n", err)
return 255
}
fmt.Fprintf(os.Stderr, "golink: %v\n", err)
return 255
}
var signalNumbers = map[string]int{
"HUP": 1, "INT": 2, "QUIT": 3, "ILL": 4, "TRAP": 5, "ABRT": 6, "BUS": 7,
"FPE": 8, "KILL": 9, "USR1": 10, "SEGV": 11, "USR2": 12, "PIPE": 13,
"ALRM": 14, "TERM": 15, "STKFLT": 16, "CHLD": 17, "CONT": 18, "STOP": 19,
"TSTP": 20, "TTIN": 21, "TTOU": 22, "URG": 23, "XCPU": 24, "XFSZ": 25,
"VTALRM": 26, "PROF": 27, "WINCH": 28, "IO": 29, "PWR": 30, "SYS": 31,
}
+225
View File
@@ -0,0 +1,225 @@
// Command goscp copies local files and directories to a remote host over SSH,
// speaking the SCP protocol.
//
// It is non-interactive like golink: credentials come from flags or the
// environment, host keys are never prompted for, and the exit status is
// meaningful in scripts.
//
// goscp [flags] [user@]host[:port] -files "dir or file" [-dest /remote/dir]
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
"sshtools/internal/cli"
"sshtools/internal/scp"
"sshtools/internal/sshx"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
const usage = "" +
"goscp - upload files to a remote host over SSH (sshtools)\n" +
"\n" +
"usage:\n" +
" goscp [flags] [user@]host[:port] -files \"dir or file\" [-files ...] [-dest /remote/dir]\n" +
"\n" +
"examples:\n" +
" goscp jarue@172.19.4.240 -p jarue -port 22 -files \"build/output.bin\"\n" +
" goscp jarue@172.19.4.240 -p jarue -port 22 -files \"build\"\n" +
" goscp jarue@172.19.4.240 -p jarue -files \"build\" -dest /home/jarue/app\n" +
" goscp jarue@10.0.0.5 -i ~/.ssh/id_ed25519 -files a.txt -files b.txt -dest /srv/in\n" +
" GOSCP_PASSWORD='secret' goscp user@host -files ./dist/jar\n" +
"\n" +
"flags:\n" +
" -files string local file or directory to upload; the same\n" +
" command handles both. Repeatable; extra positional\n" +
" arguments are also treated as paths\n" +
" -dest string remote target directory (default: remote home)\n" +
" -p, -password string SSH password, or set GOSCP_PASSWORD / GOLINK_PASSWORD\n" +
" (-pw is accepted as a plink-style alias)\n" +
" -port, -P int SSH port (default 22)\n" +
" -l string login user, alternative to user@host\n" +
" -i string private key file (OpenSSH format)\n" +
" -knownhosts string known_hosts file: unknown or changed keys fail\n" +
" -hostkey string require this host key fingerprint (SHA256:...)\n" +
" -timeout duration connect and authenticate timeout (default 15s)\n" +
" -v list every entry as it is sent\n" +
" -q no summary line on stderr\n" +
" -version print version and exit\n" +
"\n" +
"exit status:\n" +
" 0 everything was copied\n" +
" 1 transfer failed (missing local path, remote scp error)\n" +
" 2 usage error (bad flags, missing target or files, no credentials)\n" +
" 255 connection, host key or authentication failure\n"
func main() {
os.Exit(run(os.Args[1:]))
}
// stringList collects a repeatable flag.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ", ") }
func (s *stringList) Set(value string) error {
*s = append(*s, value)
return nil
}
func run(args []string) int {
var (
password string
user string
port int
dest string
files stringList
keyFile string
knownHosts string
hostKey string
timeout time.Duration
verbose bool
showVer bool
quiet bool
)
fs := flag.NewFlagSet("goscp", flag.ContinueOnError)
fs.SetOutput(io.Discard) // errors are reported below in our own format
fs.StringVar(&password, "p", "", "")
fs.StringVar(&password, "password", "", "")
fs.StringVar(&password, "pw", "", "")
fs.IntVar(&port, "port", sshx.DefaultPort, "")
fs.IntVar(&port, "P", sshx.DefaultPort, "")
fs.StringVar(&user, "l", "", "")
fs.Var(&files, "files", "")
fs.StringVar(&dest, "dest", ".", "")
fs.StringVar(&keyFile, "i", "", "")
fs.StringVar(&knownHosts, "knownhosts", "", "")
fs.StringVar(&hostKey, "hostkey", "", "")
fs.DurationVar(&timeout, "timeout", 15*time.Second, "")
fs.BoolVar(&verbose, "v", false, "")
fs.BoolVar(&showVer, "version", false, "")
fs.BoolVar(&quiet, "q", false, "")
valueFlags := map[string]bool{
"p": true, "password": true, "pw": true, "port": true, "P": true, "l": true,
"files": true, "dest": true, "i": true, "knownhosts": true, "hostkey": true,
"timeout": true,
}
flags, positional := cli.Split(args, valueFlags)
if err := fs.Parse(append(flags, positional...)); err != nil {
if errors.Is(err, flag.ErrHelp) {
fmt.Print(usage)
return 0
}
return usageError(err.Error())
}
rest := fs.Args()
if showVer {
fmt.Printf("goscp %s\n", version)
return 0
}
if len(rest) == 0 {
return usageError("missing target: expected [user@]host")
}
target, err := sshx.ParseTarget(rest[0], user, port)
if err != nil {
return usageError(err.Error())
}
paths := append([]string{}, files...)
paths = append(paths, rest[1:]...)
if len(paths) == 0 {
return usageError("nothing to upload: pass -files \"dir or file\"")
}
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
fmt.Fprintf(os.Stderr, "goscp: %v\n", err)
return 1
}
}
if password == "" {
password = os.Getenv("GOSCP_PASSWORD")
}
if password == "" {
password = os.Getenv("GOLINK_PASSWORD")
}
if password == "" && keyFile == "" {
return usageError("no credentials: pass -p <password> (or set GOSCP_PASSWORD) or -i <keyfile>")
}
logf := func(format string, args ...any) {}
if verbose {
logf = func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "goscp: "+format+"\n", args...)
}
}
client, err := sshx.Dial(sshx.Config{
Target: target,
Password: password,
KeyFile: keyFile,
KnownHosts: knownHosts,
HostKey: hostKey,
Timeout: timeout,
Logf: logf,
})
if err != nil {
fmt.Fprintf(os.Stderr, "goscp: %v\n", err)
return 255
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
fmt.Fprintf(os.Stderr, "goscp: open session: %v\n", err)
return 255
}
defer session.Close()
logf("uploading %d path(s) to %s:%s", len(paths), target.Addr(), dest)
stats, err := scp.Upload(session, scp.Options{
Dest: dest,
Paths: paths,
Logf: logf,
})
if err != nil {
fmt.Fprintf(os.Stderr, "goscp: %v\n", err)
return 1
}
if !quiet {
fmt.Fprintf(os.Stderr, "goscp: uploaded %d file(s), %d dir(s), %s to %s:%s\n",
stats.Files, stats.Dirs, humanBytes(stats.Bytes), target.Addr(), dest)
}
return 0
}
func usageError(msg string) int {
fmt.Fprintf(os.Stderr, "goscp: %s\ngoscp: run 'goscp -h' for usage\n", msg)
return 2
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+7
View File
@@ -0,0 +1,7 @@
module sshtools
go 1.24.4
require golang.org/x/crypto v0.41.0
require golang.org/x/sys v0.35.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
+53
View File
@@ -0,0 +1,53 @@
// Package cli holds small command-line helpers shared by the sshtools binaries.
package cli
import "strings"
// Split reorders a raw argument list so that Go's flag package can parse it
// even when flags follow positional arguments, as in:
//
// golink user@host -p secret -port 22 -command uptime
//
// flag.Parse stops at the first non-flag argument, so that raw order would
// otherwise leave every flag unset and silently do the wrong thing. Split walks
// the arguments once and collects flags (together with the value of any flag
// named in takesValue) into flags, and everything else into positional.
//
// The caller is expected to concatenate the results:
//
// flags, positional := cli.Split(os.Args[1:], valueFlags)
// fs.Parse(append(flags, positional...))
//
// A literal "--" ends flag processing; everything after it is positional.
func Split(args []string, takesValue map[string]bool) (flags, positional []string) {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
positional = append(positional, args[i+1:]...)
break
}
if !isFlag(arg) {
positional = append(positional, arg)
continue
}
flags = append(flags, arg)
name := strings.TrimLeft(arg, "-")
if strings.ContainsRune(name, '=') {
continue // -port=22 carries its value inline
}
if takesValue[name] && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
return flags, positional
}
// isFlag reports whether arg looks like a flag rather than a positional value.
// A bare "-" and negative numbers are treated as positional.
func isFlag(arg string) bool {
if len(arg) < 2 || arg[0] != '-' {
return false
}
return arg[1] < '0' || arg[1] > '9'
}
+262
View File
@@ -0,0 +1,262 @@
// Package scp implements the client half of the SCP wire protocol on top of an
// SSH session.
//
// It exists so the sshtools binaries stay dependency free: the protocol is
// small, and every normal OpenSSH server still ships the scp binary that the
// remote side of this protocol talks to.
package scp
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/ssh"
)
// Options describes one upload batch.
type Options struct {
// Dest is the remote directory that receives the paths. Empty means the
// remote user's home directory.
Dest string
// Paths are local files and directories, uploaded in order. Each one is
// handled according to what it is: a file is sent as one entry, a directory
// is walked recursively. Callers do not have to distinguish them.
Paths []string
// Logf reports each entry as it is sent. nil disables reporting.
Logf func(format string, args ...any)
}
// Stats summarises an upload.
type Stats struct {
Files int
Dirs int
Bytes int64
}
// Upload copies opts.Paths into opts.Dest on the remote host. The session must
// not have been started yet.
func Upload(sess *ssh.Session, opts Options) (Stats, error) {
var stats Stats
dest := opts.Dest
if dest == "" {
dest = "."
}
stdin, err := sess.StdinPipe()
if err != nil {
return stats, err
}
stdout, err := sess.StdoutPipe()
if err != nil {
return stats, err
}
sess.Stderr = os.Stderr
// -d tells scp the target is a directory, -r that directories may arrive;
// mkdir -p makes sure the target exists in the first place.
cmd := "mkdir -p " + shellQuote(dest) + " && scp -t -r -d " + shellQuote(dest)
if err := sess.Start(cmd); err != nil {
return stats, err
}
writer := bufio.NewWriter(stdin)
reader := bufio.NewReader(stdout)
if err := readAck(reader); err != nil {
stdin.Close()
return stats, err
}
for _, path := range opts.Paths {
if err := sendPath(writer, reader, path, opts, &stats); err != nil {
stdin.Close()
return stats, err
}
}
if err := writer.Flush(); err != nil {
stdin.Close()
return stats, err
}
if err := stdin.Close(); err != nil {
return stats, err
}
return stats, sess.Wait()
}
func sendPath(w *bufio.Writer, r *bufio.Reader, local string, opts Options, stats *Stats) error {
// Stat, not Lstat: a symlink named on the command line is uploaded as the
// file or directory it points at.
info, err := os.Stat(local)
if err != nil {
return err
}
switch {
case info.IsDir():
return sendDir(w, r, local, opts, stats)
case info.Mode().IsRegular():
return sendFile(w, r, local, info.Size(), opts, stats)
default:
return fmt.Errorf("%s: not a regular file or directory", local)
}
}
func sendFile(w *bufio.Writer, r *bufio.Reader, local string, size int64, opts Options, stats *Stats) error {
name := filepath.Base(local)
if err := checkName(name); err != nil {
return err
}
mode := fs.FileMode(0644)
if isExecutable(local) {
mode = 0755
}
f, err := os.Open(local)
if err != nil {
return err
}
defer f.Close()
if _, err := fmt.Fprintf(w, "C%04o %d %s\n", mode, size, name); err != nil {
return err
}
if err := w.Flush(); err != nil {
return err
}
if err := readAck(r); err != nil {
return fmt.Errorf("%s: %w", local, err)
}
if _, err := io.CopyN(w, f, size); err != nil {
return fmt.Errorf("%s: %w", local, err)
}
if err := w.WriteByte(0); err != nil {
return err
}
if err := w.Flush(); err != nil {
return err
}
if err := readAck(r); err != nil {
return fmt.Errorf("%s: %w", local, err)
}
stats.Files++
stats.Bytes += size
if opts.Logf != nil {
opts.Logf("%-40s %s", name, humanBytes(size))
}
return nil
}
func sendDir(w *bufio.Writer, r *bufio.Reader, local string, opts Options, stats *Stats) error {
name := filepath.Base(local)
if err := checkName(name); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "D%04o 0 %s\n", 0755, name); err != nil {
return err
}
if err := w.Flush(); err != nil {
return err
}
if err := readAck(r); err != nil {
return fmt.Errorf("%s: %w", local, err)
}
stats.Dirs++
if opts.Logf != nil {
opts.Logf("%-40s dir", name+"/")
}
entries, err := os.ReadDir(local)
if err != nil {
return err
}
for _, entry := range entries {
child := filepath.Join(local, entry.Name())
info, err := entry.Info()
if err != nil {
return err
}
switch {
case info.IsDir():
if err := sendDir(w, r, child, opts, stats); err != nil {
return err
}
case info.Mode().IsRegular():
if err := sendFile(w, r, child, info.Size(), opts, stats); err != nil {
return err
}
default:
fmt.Fprintf(os.Stderr, "goscp: skipping %s (not a regular file)\n", child)
}
}
if _, err := w.WriteString("E\n"); err != nil {
return err
}
if err := w.Flush(); err != nil {
return err
}
if err := readAck(r); err != nil {
return fmt.Errorf("%s: %w", local, err)
}
return nil
}
// readAck consumes the single-byte reply that follows every SCP message.
func readAck(r *bufio.Reader) error {
b, err := r.ReadByte()
if err != nil {
return fmt.Errorf("scp: %w", err)
}
switch b {
case 0:
return nil
case 1, 2:
msg, _ := r.ReadString('\n')
msg = strings.TrimSpace(msg)
if msg == "" {
msg = "unspecified error"
}
return errors.New("scp: " + msg)
default:
return fmt.Errorf("scp: unexpected reply %q", b)
}
}
func checkName(name string) error {
if name == "" || name == "." || name == ".." {
return fmt.Errorf("invalid file name %q", name)
}
if strings.ContainsAny(name, "\n\r/") {
return fmt.Errorf("unsupported file name %q", name)
}
return nil
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.Mode().Perm()&0111 != 0
}
// shellQuote wraps s for the remote shell, escaping embedded single quotes.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+259
View File
@@ -0,0 +1,259 @@
// Package sshx implements the connection, authentication and host key policy
// layer shared by the sshtools command-line clients.
//
// It is non-interactive by design: credentials are supplied up front and host
// keys are either accepted, pinned or verified against a known_hosts file.
// Nothing here ever prompts.
package sshx
import (
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// DefaultPort is used when neither the target nor the port flag sets one.
const DefaultPort = 22
// Target is a parsed [user@]host[:port] destination.
type Target struct {
User string
Host string
Port int
}
// Addr returns the host:port address to dial.
func (t Target) Addr() string {
return net.JoinHostPort(t.Host, strconv.Itoa(t.Port))
}
// ParseTarget parses raw as [user@]host[:port]. A non-empty user (from -l)
// overrides the user embedded in raw. A port embedded in raw overrides port.
func ParseTarget(raw, user string, port int) (Target, error) {
t := Target{Port: port}
if t.Port == 0 {
t.Port = DefaultPort
}
host := strings.TrimSpace(raw)
if host == "" {
return t, errors.New("missing target: expected [user@]host")
}
if i := strings.LastIndex(host, "@"); i >= 0 {
if user == "" {
user = host[:i]
}
host = host[i+1:]
}
if user == "" {
return t, fmt.Errorf("target %q has no user: use user@host or -l user", raw)
}
t.User = user
if strings.HasPrefix(host, "[") {
end := strings.Index(host, "]")
if end < 0 {
return t, fmt.Errorf("invalid target %q: missing ]", raw)
}
rest := host[end+1:]
host = host[1:end]
switch {
case rest == "":
case strings.HasPrefix(rest, ":"):
p, err := parsePort(rest[1:], raw)
if err != nil {
return t, err
}
t.Port = p
default:
return t, fmt.Errorf("invalid target %q: unexpected %q after ]", raw, rest)
}
} else if i := strings.LastIndex(host, ":"); i >= 0 {
if strings.Count(host, ":") > 1 {
return t, fmt.Errorf("invalid target %q: bracket IPv6 literals like [::1]:22", raw)
}
p, err := parsePort(host[i+1:], raw)
if err != nil {
return t, err
}
t.Port = p
host = host[:i]
}
if host == "" {
return t, fmt.Errorf("invalid target %q: missing host", raw)
}
if t.Port < 1 || t.Port > 65535 {
return t, fmt.Errorf("invalid port %d", t.Port)
}
t.Host = host
return t, nil
}
func parsePort(s, raw string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil || p < 1 || p > 65535 {
return 0, fmt.Errorf("invalid target %q: bad port %q", raw, s)
}
return p, nil
}
// Config describes a connection attempt. Zero values are usable: an empty
// KnownHosts and HostKey means host keys are accepted without verification.
type Config struct {
Target Target
Password string
KeyFile string
KnownHosts string
HostKey string
Timeout time.Duration
Logf func(format string, args ...any)
}
func (c Config) logf(format string, args ...any) {
if c.Logf != nil {
c.Logf(format, args...)
}
}
// Dial connects to the target, completes the SSH handshake and authenticates.
// The caller owns the returned client and must close it. Dial never prompts.
func Dial(cfg Config) (*ssh.Client, error) {
auth, err := authMethods(cfg)
if err != nil {
return nil, err
}
hostKey, err := hostKeyCallback(cfg)
if err != nil {
return nil, err
}
addr := cfg.Target.Addr()
clientConfig := &ssh.ClientConfig{
User: cfg.Target.User,
Auth: auth,
HostKeyCallback: hostKey,
Timeout: cfg.Timeout,
}
cfg.logf("connecting to %s", addr)
dialer := net.Dialer{Timeout: cfg.Timeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", addr, err)
}
if cfg.Timeout > 0 {
// Bound the handshake and authentication too, then clear the deadline:
// the remote command itself may legitimately run for hours.
_ = conn.SetDeadline(time.Now().Add(cfg.Timeout))
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, clientConfig)
if err != nil {
conn.Close()
return nil, fmt.Errorf("ssh handshake with %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Time{})
cfg.logf("authenticated as %s", cfg.Target.User)
return ssh.NewClient(sshConn, chans, reqs), nil
}
// authMethods builds the credential list. Public keys come first; the password
// is offered both as the "password" method and as keyboard-interactive, because
// plenty of servers (PAM setups in particular) only offer the latter.
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
if cfg.KeyFile != "" {
signer, err := loadSigner(cfg.KeyFile)
if err != nil {
return nil, err
}
methods = append(methods, ssh.PublicKeys(signer))
}
if cfg.Password != "" {
password := cfg.Password
methods = append(methods, ssh.Password(password))
methods = append(methods, ssh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range answers {
answers[i] = password
}
return answers, nil
}))
}
if len(methods) == 0 {
return nil, errors.New("no credentials: pass -p <password> (or set GOLINK_PASSWORD) or -i <keyfile>")
}
return methods, nil
}
func loadSigner(path string) (ssh.Signer, error) {
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key %s: %w", path, err)
}
signer, err := ssh.ParsePrivateKey(pemBytes)
if err != nil {
var missing *ssh.PassphraseMissingError
if errors.As(err, &missing) {
return nil, fmt.Errorf("key %s is passphrase protected: golink is non-interactive, decrypt it with 'ssh-keygen -p' or use -p", path)
}
return nil, fmt.Errorf("parse key %s: %w", path, err)
}
return signer, nil
}
// hostKeyCallback selects the host key policy: pinned fingerprint, known_hosts
// file, or accept anything (the default, since a prompt is not an option).
func hostKeyCallback(cfg Config) (ssh.HostKeyCallback, error) {
var cb ssh.HostKeyCallback
switch {
case cfg.HostKey != "":
cb = pinnedHostKey(cfg.HostKey)
case cfg.KnownHosts != "":
verify, err := knownhosts.New(cfg.KnownHosts)
if err != nil {
return nil, fmt.Errorf("load known_hosts %s: %w", cfg.KnownHosts, err)
}
cb = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := verify(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
if len(keyErr.Want) == 0 {
return fmt.Errorf("host %s is not in %s: add it with 'ssh-keyscan -p %d %s' or pass -hostkey", hostname, cfg.KnownHosts, cfg.Target.Port, cfg.Target.Host)
}
return fmt.Errorf("host key mismatch for %s against %s (possible MITM): %w", hostname, cfg.KnownHosts, err)
}
return err
}
default:
cb = ssh.InsecureIgnoreHostKey()
}
if cfg.Logf != nil {
inner := cb
cb = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
cfg.logf("host key %s (%s)", ssh.FingerprintSHA256(key), key.Type())
return inner(hostname, remote, key)
}
}
return cb, nil
}
func pinnedHostKey(want string) ssh.HostKeyCallback {
want = strings.TrimSpace(want)
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
sha256 := ssh.FingerprintSHA256(key)
if strings.EqualFold(want, sha256) ||
strings.EqualFold(want, strings.TrimPrefix(sha256, "SHA256:")) ||
strings.EqualFold(want, ssh.FingerprintLegacyMD5(key)) {
return nil
}
return fmt.Errorf("host key for %s does not match -hostkey:\n offered: %s\n wanted: %s", hostname, sha256, want)
}
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
#
# End-to-end smoke test for goscp against the bundled miniature sshd.
#
# ./smoketest/run-goscp.sh
#
# The test server runs commands through /bin/sh, so the real scp binary on this
# machine plays the remote side of the protocol. Everything stays local; the
# uploaded trees are checked directly on disk.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
work="$root/.smoketest-goscp"
export GOCACHE="${GOCACHE:-$root/.gocache}"
USER_NAME=tester
PASS_WORD=s3cr3t
HOST=127.0.0.1
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
command -v scp >/dev/null 2>&1 || { echo "scp binary not found; cannot run this test" >&2; exit 1; }
rm -rf "$work"
mkdir -p "$work/bin" "$work/src/sub" "$work/server-cwd"
printf 'building\n'
go build -o "$work/bin/goscp" ./cmd/goscp || exit 1
go build -o "$work/bin/sshd" ./smoketest/sshd || exit 1
printf 'hello\n' >"$work/src/a.txt"
: >"$work/src/empty.bin"
printf 'companion data\n' >"$work/src/notes.md"
printf 'space content\n' >"$work/src/sub/name with spaces.txt"
head -c 1048576 /dev/urandom >"$work/src/sub/big.bin"
printf '#!/bin/sh\necho hi\n' >"$work/src/run.sh"
BIG_SUM="$(sha256sum "$work/src/sub/big.bin" | cut -d' ' -f1)"
sshd_pids=()
cleanup() {
local p
for p in ${sshd_pids[@]+"${sshd_pids[@]}"}; do kill "$p" 2>/dev/null; done
wait 2>/dev/null
}
trap cleanup EXIT
# The server runs with cwd $work/server-cwd so that "default destination"
# (the remote home, ".")
( cd "$work/server-cwd" && exec "$work/bin/sshd" -addr 127.0.0.1:0 -user "$USER_NAME" -pass "$PASS_WORD" \
-hostkey "$work/hostkey" ) >"$work/sshd.out" 2>"$work/sshd.err" &
sshd_pids+=($!)
for i in $(seq 1 100); do
grep -q '^HOSTKEY_SHA256=' "$work/sshd.out" 2>/dev/null && break
sleep 0.05
done
adr="$(sed -n 's/^ADDR=//p' "$work/sshd.out" | head -1)"
PORT="${adr##*:}"
if [ -z "$PORT" ]; then
echo "sshd did not start: $(cat "$work/sshd.err")" >&2
exit 1
fi
TARGET="$USER_NAME@$HOST"
G=( "$TARGET" -p "$PASS_WORD" -port "$PORT" )
gscp() { timeout 60 "$work/bin/goscp" "$@" </dev/null; }
printf '\n== single file, default destination ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" >/dev/null 2>"$work/err.txt"
rc=$?
if [ "$rc" -eq 0 ] && [ "$(cat "$work/server-cwd/a.txt" 2>/dev/null)" = "hello" ]; then
ok "file lands in the remote home"
else
bad "file lands in the remote home" "exit=$rc stderr=$(head -1 "$work/err.txt")"
fi
gscp "${G[@]}" -files "$work/src/empty.bin" -dest "$work/dest1" >/dev/null 2>&1
if [ -f "$work/dest1/empty.bin" ] && [ ! -s "$work/dest1/empty.bin" ]; then
ok "zero byte file"
else
bad "zero byte file" "missing or non-empty"
fi
printf '\n== directory tree ==\n'
gscp "${G[@]}" -files "$work/src" -dest "$work/dest2" >/dev/null 2>"$work/err.txt"
rc=$?
if [ "$rc" -eq 0 ] && [ -f "$work/dest2/src/sub/big.bin" ] && [ -f "$work/dest2/src/sub/name with spaces.txt" ]; then
ok "nested tree with spaces in names"
else
bad "nested tree with spaces in names" "exit=$rc stderr=$(head -1 "$work/err.txt")"
fi
got="$(sha256sum "$work/dest2/src/sub/big.bin" 2>/dev/null | cut -d' ' -f1)"
[ "$got" = "$BIG_SUM" ] && ok "1 MiB binary is byte identical" || bad "1 MiB binary is byte identical" "sha256 mismatch"
if [ -d "$work/dest2/src/sub" ] && [ "$(stat -c %a "$work/dest2/src/sub")" = "755" ]; then
ok "directories are 0755"
else
bad "directories are 0755" "mode=$(stat -c %a "$work/dest2/src/sub" 2>/dev/null)"
fi
mode="$(stat -c %a "$work/dest2/src/a.txt" 2>/dev/null)"
[ "$mode" = "644" ] && ok "plain files are 0644 by default" || bad "plain files are 0644 by default" "mode=$mode"
printf '\n== file modes ==\n'
chmod 755 "$work/src/run.sh"
gscp "${G[@]}" -files "$work/src/run.sh" -dest "$work/dest3" >/dev/null 2>&1
mode="$(stat -c %a "$work/dest3/run.sh" 2>/dev/null)"
[ "$mode" = "755" ] && ok "local exec bit is preserved" || bad "local exec bit is preserved" "mode=$mode"
printf '\n== multiple files and extra positionals ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" -files "$work/src/notes.md" -dest "$work/dest4" >/dev/null 2>&1
if [ -f "$work/dest4/a.txt" ] && [ -f "$work/dest4/notes.md" ]; then
ok "two -files flags"
else
bad "two -files flags" "missing output"
fi
# one -files followed by several bare paths: everything after the target is
# treated as a path, so both spellings below are equivalent
gscp "${G[@]}" -files "$work/src/a.txt" "$work/src/notes.md" "$work/src/empty.bin" -dest "$work/dest4b" >/dev/null 2>&1
if [ -f "$work/dest4b/a.txt" ] && [ -f "$work/dest4b/notes.md" ] && [ -f "$work/dest4b/empty.bin" ]; then
ok "-files with several bare paths"
else
bad "-files with several bare paths" "missing output"
fi
gscp "${G[@]}" -dest "$work/dest5" "$work/src/a.txt" "$work/src/notes.md" >/dev/null 2>&1
if [ -f "$work/dest5/a.txt" ] && [ -f "$work/dest5/notes.md" ]; then
ok "trailing positional paths"
else
bad "trailing positional paths" "missing output"
fi
printf '\n== destination handling ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/deep/nested/dir" >/dev/null 2>&1
[ -f "$work/deep/nested/dir/a.txt" ] && ok "missing destination directories are created" || bad "missing destination directories are created" "not created"
gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/rel" >/dev/null 2>&1
[ -f "$work/rel/a.txt" ] && ok "absolute -dest" || bad "absolute -dest" "missing"
printf '\n== output and exit codes ==\n'
out="$(gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/dest6" -v 2>&1 >/dev/null)"
if printf '%s' "$out" | grep -q 'a.txt'; then
ok "-v lists the uploaded entry"
else
bad "-v lists the uploaded entry" "stderr: $(printf '%s' "$out" | head -2 | tr '\n' '|')"
fi
out="$(gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/dest7" -q 2>&1 >/dev/null)"
[ -z "$out" ] && ok "-q keeps stderr empty" || bad "-q keeps stderr empty" "stderr: $out"
gscp "${G[@]}" -files "$work/does-not-exist" -dest "$work/dest8" >/dev/null 2>"$work/err.txt"
rc=$?
[ "$rc" -eq 1 ] && grep -q 'does-not-exist' "$work/err.txt" && ok "missing local path -> exit 1" \
|| bad "missing local path -> exit 1" "exit=$rc"
gscp "${G[@]}" -dest "$work/dest9" >/dev/null 2>&1
[ $? -eq 2 ] && ok "no -files -> exit 2" || bad "no -files -> exit 2"
gscp "$TARGET" -p wrong -port "$PORT" -files "$work/src/a.txt" >/dev/null 2>&1
[ $? -eq 255 ] && ok "wrong password -> exit 255" || bad "wrong password -> exit 255"
gscp "$TARGET" -port "$PORT" -files "$work/src/a.txt" >/dev/null 2>&1
[ $? -eq 2 ] && ok "no credentials -> exit 2" || bad "no credentials -> exit 2"
printf '\n== password from environment ==\n'
GOSCP_PASSWORD="$PASS_WORD" timeout 60 "$work/bin/goscp" "$TARGET" -port "$PORT" -files "$work/src/a.txt" -dest "$work/dest10" </dev/null >/dev/null 2>&1
[ -f "$work/dest10/a.txt" ] && ok "GOSCP_PASSWORD works" || bad "GOSCP_PASSWORD works" "missing output"
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
#
# End-to-end smoke test for golink against the bundled miniature sshd.
#
# ./smoketest/run.sh
#
# Builds golink and the test server into .smoketest/bin, starts the server on a
# free port and checks stdout, stderr and exit status for every case. Each case
# runs with stdin closed and a hard timeout, which also proves golink never waits
# for interactive input. Exits non-zero if any case fails.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
work="$root/.smoketest"
export GOCACHE="${GOCACHE:-$root/.gocache}"
USER_NAME=tester
PASS_WORD=s3cr3t
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
rm -rf "$work"
mkdir -p "$work/bin"
printf 'building\n'
go build -o "$work/bin/golink" ./cmd/golink || exit 1
go build -o "$work/bin/sshd" ./smoketest/sshd || exit 1
sshd_pids=()
cleanup() {
local p
for p in ${sshd_pids[@]+"${sshd_pids[@]}"}; do kill "$p" 2>/dev/null; done
wait 2>/dev/null
}
trap cleanup EXIT
start_sshd() { # <name> <auth-mode> [extra sshd flags...]
local name="$1" mode="$2" i
shift 2
"$work/bin/sshd" -addr 127.0.0.1:0 -auth "$mode" -user "$USER_NAME" -pass "$PASS_WORD" \
-hostkey "$work/hostkey" -knownhosts-out "$work/known_hosts_$name" "$@" \
>"$work/$name.out" 2>"$work/$name.err" &
sshd_pids+=($!)
for i in $(seq 1 100); do
grep -q '^HOSTKEY_SHA256=' "$work/$name.out" 2>/dev/null && break
sleep 0.05
done
ADDR="$(sed -n 's/^ADDR=//p' "$work/$name.out" | head -1)"
PORT="${ADDR##*:}"
FINGERPRINT="$(sed -n 's/^HOSTKEY_SHA256=//p' "$work/$name.out" | head -1)"
if [ -z "$PORT" ]; then
echo "sshd ($name) did not start: $(cat "$work/$name.err")" >&2
exit 1
fi
}
# gl runs golink with stdin closed and a watchdog, so a hang shows up as a
# failure instead of a stuck test run.
gl() { timeout 30 "$work/bin/golink" "$@" </dev/null; }
expect_status() { # <name> <want-status> <args...>
local name="$1" want="$2"; shift 2
local status
gl "$@" >/dev/null 2>"$work/last.err"
status=$?
if [ "$status" -ne "$want" ]; then
bad "$name" "exit=$status want=$want; stderr: $(head -1 "$work/last.err")"
else
ok "$name"
fi
}
expect_output() { # <name> <want-status> <want-stdout> <args...>
local name="$1" want="$2" wantout="$3"; shift 3
local status out
out="$(gl "$@" 2>"$work/last.err")"
status=$?
if [ "$status" -ne "$want" ]; then
bad "$name" "exit=$status want=$want; stderr: $(head -1 "$work/last.err")"
elif [ "$out" != "$wantout" ]; then
bad "$name" "stdout=$(printf '%q' "$out") want=$(printf '%q' "$wantout")"
else
ok "$name"
fi
}
start_sshd main both
HOST=127.0.0.1
TARGET="$USER_NAME@$HOST"
G=( "$TARGET" -p "$PASS_WORD" -port "$PORT" )
printf '\n== basic execution ==\n'
expect_output "echo" 0 "hello from golink" "${G[@]}" -command "echo hello from golink"
expect_output "flags in any order" 0 "any-order" -command "echo any-order" -p "$PASS_WORD" "$TARGET" -port "$PORT"
expect_output "host:port inline" 0 "inline" "$TARGET:$PORT" -p "$PASS_WORD" -command "echo inline"
expect_output "-l instead of user@host" 0 "via-l" -l "$USER_NAME" "$HOST" -port "$PORT" -p "$PASS_WORD" -command "echo via-l"
expect_output "trailing args become the command" 0 "a b c" "$TARGET" -p "$PASS_WORD" -port "$PORT" echo a b c
expect_output "shell pipeline remotely" 0 "2" "${G[@]}" -command "printf 'x\ny\n' | wc -l"
expect_output "-pw plink-style alias" 0 "pw-ok" "$TARGET" -pw "$PASS_WORD" -port "$PORT" -command "echo pw-ok"
printf '\n== exit status passthrough ==\n'
expect_status "exit 0" 0 "${G[@]}" -command "true"
expect_status "exit 7" 7 "${G[@]}" -command "exit 7"
expect_status "exit 42" 42 "${G[@]}" -command "exit 42"
expect_status "unknown command -> 127" 127 "${G[@]}" -command "definitely-not-a-real-command-xyz"
expect_status "killed by SIGTERM -> 143" 143 "${G[@]}" -command 'kill -TERM $$'
expect_status "killed by SIGKILL -> 137" 137 "${G[@]}" -command 'kill -KILL $$'
printf '\n== stdout / stderr separation ==\n'
out="$(gl "${G[@]}" -command "echo to-stdout; echo to-stderr 1>&2" 2>"$work/last.err")"
if [ "$out" = "to-stdout" ] && grep -qx 'to-stderr' "$work/last.err"; then
ok "remote stderr stays on stderr"
else
bad "remote stderr stays on stderr" "stdout=$(printf '%q' "$out") stderr=$(cat "$work/last.err")"
fi
printf '\n== stdin piping ==\n'
if printf 'piped-data\n' | timeout 30 "$work/bin/golink" "${G[@]}" -command "cat" >"$work/stdin.out" 2>/dev/null; then
if [ "$(cat "$work/stdin.out")" = "piped-data" ]; then
ok "stdin piped into remote cat"
else
bad "stdin piped into remote cat" "got $(printf '%q' "$(cat "$work/stdin.out")")"
fi
else
bad "stdin piped into remote cat" "golink failed"
fi
head -c 200000 /dev/urandom >"$work/blob"
got="$(timeout 30 "$work/bin/golink" "${G[@]}" -command "wc -c" <"$work/blob" 2>/dev/null | tr -d ' ')"
[ "$got" = "200000" ] && ok "200000 byte stdin round trip" || bad "200000 byte stdin round trip" "got $got"
printf '\n== larger output ==\n'
lines="$(gl "${G[@]}" -command "seq 1 20000" 2>/dev/null | wc -l)"
[ "$lines" = "20000" ] && ok "20000 lines streamed back" || bad "20000 lines streamed back" "got $lines"
printf '\n== host key policy ==\n'
expect_status "-hostkey correct fingerprint" 0 "${G[@]}" -hostkey "$FINGERPRINT" -command "true"
expect_status "-hostkey wrong fingerprint -> 255" 255 "${G[@]}" -hostkey "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" -command true
expect_output "-knownhosts file" 0 "known-ok" "${G[@]}" -knownhosts "$work/known_hosts_main" -command "echo known-ok"
: >"$work/empty_known_hosts"
expect_status "-knownhosts unknown host -> 255" 255 "${G[@]}" -knownhosts "$work/empty_known_hosts" -command true
printf '\n== failures are fast, never interactive ==\n'
expect_status "wrong password -> 255" 255 "$TARGET" -p wrong -port "$PORT" -command true
expect_status "wrong user -> 255" 255 "nosuchuser@$HOST" -p "$PASS_WORD" -port "$PORT" -command true
expect_status "no credentials -> 2" 2 "$TARGET" -port "$PORT" -command true
expect_status "missing user -> 2" 2 "$HOST" -p "$PASS_WORD" -port "$PORT" -command true
expect_status "missing command -> 2" 2 "${G[@]}"
expect_status "connection refused -> 255" 255 "$TARGET" -p "$PASS_WORD" -port 1 -timeout 3s -command true
expect_status "-h exits 0" 0 -h
expect_status "-version exits 0" 0 -version
printf '\n== verbose output does not pollute stdout ==\n'
out="$(gl "${G[@]}" -v -command "echo v-ok" 2>"$work/last.err")"
if [ "$out" = "v-ok" ] && grep -q 'host key SHA256:' "$work/last.err"; then
ok "-v diagnostics go to stderr"
else
bad "-v diagnostics go to stderr" "stdout=$(printf '%q' "$out") stderr=$(head -3 "$work/last.err" | tr '\n' '|')"
fi
printf '\n== GOLINK_PASSWORD ==\n'
out="$(GOLINK_PASSWORD="$PASS_WORD" timeout 30 "$work/bin/golink" "$TARGET" -port "$PORT" -command "echo from-env" </dev/null 2>"$work/last.err")"
if [ "$out" = "from-env" ]; then
ok "GOLINK_PASSWORD supplies the password"
else
bad "GOLINK_PASSWORD supplies the password" "stdout=$(printf '%q' "$out") stderr=$(head -1 "$work/last.err")"
fi
printf '\n== keyboard-interactive only server ==\n'
start_sshd kbd keyboard-interactive
expect_output "keyboard-interactive login" 0 "kbd-ok" "$USER_NAME@$HOST" -p "$PASS_WORD" -port "$PORT" -command "echo kbd-ok"
expect_status "keyboard-interactive wrong password -> 255" 255 "$USER_NAME@$HOST" -p nope -port "$PORT" -command true
printf '\n== public key authentication ==\n'
if command -v ssh-keygen >/dev/null 2>&1; then
ssh-keygen -q -t ed25519 -N "" -C golink-smoketest -f "$work/id_ed25519" >/dev/null 2>&1
ssh-keygen -q -t ed25519 -N "" -C golink-other -f "$work/id_other" >/dev/null 2>&1
ssh-keygen -q -t ed25519 -N "hunter2" -C golink-locked -f "$work/id_locked" >/dev/null 2>&1
start_sshd key publickey -authorized-key "$work/id_ed25519.pub"
expect_output "-i private key" 0 "key-ok" "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -command "echo key-ok"
expect_status "unauthorized key -> 255" 255 "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_other" -command true
gl "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_locked" -command true >/dev/null 2>"$work/last.err"
lock_status=$?
if [ "$lock_status" -eq 255 ] && grep -qi 'passphrase' "$work/last.err"; then
ok "encrypted key fails with a clear message"
else
bad "encrypted key fails with a clear message" "exit=$lock_status stderr: $(head -1 "$work/last.err")"
fi
# -auth publickey server: only the exact key is accepted, and a key-only
# client must fail against a password-only server instead of prompting
start_sshd pwdonly password
expect_status "key rejected by password-only server -> 255" 255 "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -command true
expect_output "key and password together" 0 "mix-ok" "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -p "$PASS_WORD" -command "echo mix-ok"
else
printf ' skip ssh-keygen not available, public key cases skipped\n'
fi
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+253
View File
@@ -0,0 +1,253 @@
// Command sshd is a miniature SSH server used only to smoke-test the sshtools
// clients locally. It is not a real sshd: one hard-coded account, commands run
// through /bin/sh, no PTY and no port forwarding.
//
// It prints the address it listens on and the fingerprint of its host key on
// stdout, so a test script can drive it without hard-coded ports.
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"flag"
"fmt"
"net"
"os"
"os/exec"
"syscall"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
type exitStatusMsg struct{ Status uint32 }
type exitSignalMsg struct {
Signal string
CoreDumped bool
Errmsg string
Lang string
}
var signalNames = map[syscall.Signal]string{
syscall.SIGHUP: "HUP", syscall.SIGINT: "INT", syscall.SIGQUIT: "QUIT",
syscall.SIGILL: "ILL", syscall.SIGABRT: "ABRT", syscall.SIGFPE: "FPE",
syscall.SIGKILL: "KILL", syscall.SIGSEGV: "SEGV", syscall.SIGPIPE: "PIPE",
syscall.SIGALRM: "ALRM", syscall.SIGTERM: "TERM", syscall.SIGUSR1: "USR1",
syscall.SIGUSR2: "USR2",
}
func main() {
addr := flag.String("addr", "127.0.0.1:2222", "listen address")
user := flag.String("user", "tester", "accepted username")
pass := flag.String("pass", "s3cr3t", "accepted password")
auth := flag.String("auth", "both", "offered auth methods: both|password|keyboard-interactive|publickey")
authorizedKey := flag.String("authorized-key", "", "public key file to accept; enables public key auth")
hostKeyFile := flag.String("hostkey", "", "PEM file holding the host key (generated when missing)")
knownHostsOut := flag.String("knownhosts-out", "", "write a known_hosts line for this server here")
flag.Parse()
config := &ssh.ServerConfig{}
switch *auth {
case "both":
config.PasswordCallback = passwordCallback(*user, *pass)
config.KeyboardInteractiveCallback = kbdCallback(*user, *pass)
case "password":
config.PasswordCallback = passwordCallback(*user, *pass)
case "keyboard-interactive":
config.KeyboardInteractiveCallback = kbdCallback(*user, *pass)
case "publickey":
// installed below
default:
fatalf("unknown -auth %q", *auth)
}
if *authorizedKey != "" {
callback, err := publicKeyCallback(*user, *authorizedKey)
if err != nil {
fatalf("authorized key: %v", err)
}
config.PublicKeyCallback = callback
} else if *auth == "publickey" {
fatalf("-auth publickey needs -authorized-key")
}
signer, err := hostSigner(*hostKeyFile)
if err != nil {
fatalf("host key: %v", err)
}
config.AddHostKey(signer)
listener, err := net.Listen("tcp", *addr)
if err != nil {
fatalf("listen: %v", err)
}
if *knownHostsOut != "" {
line := knownhosts.Line([]string{knownhosts.Normalize(listener.Addr().String())}, signer.PublicKey())
if err := os.WriteFile(*knownHostsOut, []byte(line+"\n"), 0o600); err != nil {
fatalf("write known_hosts: %v", err)
}
}
// Printed last: their presence tells the test script that everything is ready.
fmt.Printf("ADDR=%s\n", listener.Addr().String())
fmt.Printf("HOSTKEY_SHA256=%s\n", ssh.FingerprintSHA256(signer.PublicKey()))
for {
conn, err := listener.Accept()
if err != nil {
return
}
go serve(conn, config)
}
}
func passwordCallback(user, pass string) func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) {
return func(c ssh.ConnMetadata, given []byte) (*ssh.Permissions, error) {
if c.User() == user && string(given) == pass {
return nil, nil
}
return nil, fmt.Errorf("password rejected for %q", c.User())
}
}
func kbdCallback(user, pass string) func(ssh.ConnMetadata, ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
return func(c ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
answers, err := challenge("", "", []string{"Password: "}, []bool{false})
if err != nil {
return nil, err
}
if c.User() == user && len(answers) == 1 && answers[0] == pass {
return nil, nil
}
return nil, fmt.Errorf("keyboard-interactive rejected for %q", c.User())
}
}
func publicKeyCallback(user, path string) (func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error), error) {
authorized, err := os.ReadFile(path)
if err != nil {
return nil, err
}
want, _, _, _, err := ssh.ParseAuthorizedKey(authorized)
if err != nil {
return nil, err
}
return func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if c.User() == user && bytes.Equal(key.Marshal(), want.Marshal()) {
return nil, nil
}
return nil, fmt.Errorf("public key rejected for %q", c.User())
}, nil
}
func hostSigner(path string) (ssh.Signer, error) {
if path != "" {
pemBytes, err := os.ReadFile(path)
switch {
case err == nil:
return ssh.ParsePrivateKey(pemBytes)
case !errors.Is(err, os.ErrNotExist):
return nil, err
}
}
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
return nil, err
}
if path != "" {
block, err := ssh.MarshalPrivateKey(priv, "sshtools smoketest host key")
if err != nil {
return nil, err
}
if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
return nil, err
}
}
return signer, nil
}
func serve(nConn net.Conn, config *ssh.ServerConfig) {
defer nConn.Close()
conn, chans, reqs, err := ssh.NewServerConn(nConn, config)
if err != nil {
fmt.Fprintf(os.Stderr, "sshd: handshake: %v\n", err)
return
}
defer conn.Close()
go ssh.DiscardRequests(reqs)
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType, "only session channels are supported")
continue
}
ch, requests, err := newChan.Accept()
if err != nil {
continue
}
go runSession(ch, requests)
}
}
func runSession(ch ssh.Channel, requests <-chan *ssh.Request) {
defer ch.Close()
for req := range requests {
if req.Type != "exec" {
req.Reply(false, nil)
continue
}
var payload struct{ Command string }
if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
req.Reply(false, nil)
return
}
req.Reply(true, nil)
execCommand(ch, payload.Command)
return
}
}
func execCommand(ch ssh.Channel, command string) {
cmd := exec.Command("/bin/sh", "-c", command)
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
err := cmd.Run()
if err == nil {
sendExitStatus(ch, 0)
return
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
name, ok := signalNames[ws.Signal()]
if !ok {
name = ws.Signal().String()
}
_, _ = ch.SendRequest("exit-signal", false, ssh.Marshal(exitSignalMsg{Signal: name}))
return
}
code := exitErr.ExitCode()
if code < 0 {
code = 1
}
sendExitStatus(ch, code)
return
}
fmt.Fprintf(ch.Stderr(), "sshd: %v\n", err)
sendExitStatus(ch, 127)
}
func sendExitStatus(ch ssh.Channel, status int) {
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(exitStatusMsg{Status: uint32(status)}))
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "sshd: "+format+"\n", args...)
os.Exit(1)
}