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:
@@ -0,0 +1,3 @@
|
||||
# Shell scripts must keep LF even when checked out on Windows.
|
||||
*.sh text eol=lf
|
||||
* text=auto
|
||||
@@ -0,0 +1,8 @@
|
||||
.gocache/
|
||||
.gocache-*/
|
||||
dist/
|
||||
archive/
|
||||
.smoketest/
|
||||
.smoketest-goscp/
|
||||
smoketest/*.out
|
||||
smoketest/*.err
|
||||
@@ -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
|
||||
@@ -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 )
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+178
@@ -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 ]
|
||||
Executable
+203
@@ -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 ]
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user