commit 4365e80075d2d678bc0437309c542adae07e00d7 Author: liyp Date: Mon Oct 19 10:22:53 2026 +0800 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) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6d2a74b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must keep LF even when checked out on Windows. +*.sh text eol=lf +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fcf0ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gocache/ +.gocache-*/ +dist/ +archive/ +.smoketest/ +.smoketest-goscp/ +smoketest/*.out +smoketest/*.err diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ab4f35 --- /dev/null +++ b/README.md @@ -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 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..30301aa --- /dev/null +++ b/build.sh @@ -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() { # + 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 ) diff --git a/cmd/golink/main.go b/cmd/golink/main.go new file mode 100644 index 0000000..b534242 --- /dev/null +++ b/cmd/golink/main.go @@ -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 (or set GOLINK_PASSWORD) or -i ") + } + + 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, +} diff --git a/cmd/goscp/main.go b/cmd/goscp/main.go new file mode 100644 index 0000000..670d305 --- /dev/null +++ b/cmd/goscp/main.go @@ -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 (or set GOSCP_PASSWORD) or -i ") + } + + 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]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..99a4cee --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd8f3eb --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/cli/args.go b/internal/cli/args.go new file mode 100644 index 0000000..131eb98 --- /dev/null +++ b/internal/cli/args.go @@ -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' +} diff --git a/internal/scp/upload.go b/internal/scp/upload.go new file mode 100644 index 0000000..1e02991 --- /dev/null +++ b/internal/scp/upload.go @@ -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]) +} diff --git a/internal/sshx/dial.go b/internal/sshx/dial.go new file mode 100644 index 0000000..fd716f9 --- /dev/null +++ b/internal/sshx/dial.go @@ -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 (or set GOLINK_PASSWORD) or -i ") + } + 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) + } +} diff --git a/smoketest/run-goscp.sh b/smoketest/run-goscp.sh new file mode 100755 index 0000000..96c3306 --- /dev/null +++ b/smoketest/run-goscp.sh @@ -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 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 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 ] diff --git a/smoketest/run.sh b/smoketest/run.sh new file mode 100755 index 0000000..552743d --- /dev/null +++ b/smoketest/run.sh @@ -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() { # [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" "$@" + 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() { # + 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" "$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 ] diff --git a/smoketest/sshd/main.go b/smoketest/sshd/main.go new file mode 100644 index 0000000..f2c15df --- /dev/null +++ b/smoketest/sshd/main.go @@ -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) +}