# sshtools Non-interactive SSH command-line tools for scripts: **golink** replaces PuTTY's `plink`, **goscp** replaces the upload half of `pscp`. Plain Go, `CGO_ENABLED=0`, one static binary per platform. The SSH stack and all cryptography (RSA, ECDSA, Ed25519, AES-GCM, ChaCha20-Poly1305, SHA-2) are Go's own implementation compiled into the binary -- nothing links against the system OpenSSL and nothing has to be installed alongside it. **English** | [简体中文](README.zh-CN.md) ## Install Download the binary for your platform from the [latest release](../../releases/latest), then `chmod +x` it. | Platform | golink | goscp | | --- | --- | --- | | Windows x86-64 | `golink_windows_amd64.exe` | `goscp_windows_amd64.exe` | | Windows ARM64 | `golink_windows_arm64.exe` | `goscp_windows_arm64.exe` | | Linux x86-64 | `golink_linux_amd64` | `goscp_linux_amd64` | | Linux ARM64 | `golink_linux_arm64` | `goscp_linux_arm64` | | macOS Intel | `golink_darwin_amd64` | `goscp_darwin_amd64` | | macOS Apple Silicon | `golink_darwin_arm64` | `goscp_darwin_arm64` | SHA256SUMS is attached to every release. On macOS a downloaded binary carries the quarantine attribute; clear it once with `xattr -d com.apple.quarantine ./golink`. ## The contract both tools share - they never prompt: not for a password, not for a host key, not for anything; - they never allocate a terminal or open an interactive shell; - stdout carries only the remote program's stdout, diagnostics go to stderr; - exit codes are meaningful (see each tool below); - every credential is supplied up front, so they are safe inside cron jobs and CI. ## golink Connect, run one command, and return that command's exit status. ```bash golink user@host -p "password" -port 22 -command "command" ``` Go's `flag` package stops parsing at the first positional argument, so golink reorders `argv` before parsing: the target may come first, last, or in between, and flags may be written in any order. ### Examples ```bash # the common case golink root@10.0.0.5 -p 'secret' -port 22 -command "uptime && df -h" # password from the environment, so it stays out of ps and shell history GOLINK_PASSWORD='secret' golink deploy@web1 -command "systemctl restart nginx" # the remote exit status is passed through, so this works if golink ci@build1 -p "$PASS" -command "make test"; then echo passed; else echo "failed: $?"; fi # feed local stdin to the remote command tar czf - ./src | golink root@backup -p "$PASS" -command "cat > /srv/src.tgz" # pipe remote output into a local filter golink db@10.0.0.9 -p "$PASS" -command "cat /var/log/app.log" | grep ERROR # key auth (OpenSSH private key, no passphrase) golink ops@bastion -i ~/.ssh/id_ed25519 -command hostname # pin the host key fingerprint golink ops@10.0.0.7 -p "$PASS" -hostkey "$(ssh-keyscan -p 22 10.0.0.7 2>/dev/null | ssh-keygen -lf - | awk '{print $2}')" -command true # the command may also be trailing positional arguments, plink style golink ops@web1 -p "$PASS" systemctl status nginx ``` ### Flags | Flag | Default | Meaning | | --- | --- | --- | | `-p` / `-password` / `-pw` | empty | SSH password; `GOLINK_PASSWORD` works too | | `-port` / `-P` | 22 | SSH port; `host:2222` in the target also works | | `-l` | empty | login user, alternative to `user@host` | | `-command` | empty | command to run; trailing positional arguments are appended | | `-i` | empty | OpenSSH private key file (no passphrase, see below) | | `-knownhosts` | empty | known_hosts file; unknown or changed keys fail, never prompt | | `-hostkey` | empty | required host key fingerprint, `SHA256:...` or MD5 | | `-timeout` | 15s | covers connect, handshake and authentication only, never the command | | `-v` | off | connection diagnostics on stderr | | `-version` / `-h` | | version / help | Targets accept `user@host`, `user@host:2222` and `[::1]:2222`. ### Exit status | Code | Meaning | | --- | --- | | 0-255 | the remote command's status, passed through (a killed command reports 128+signal, so SIGTERM gives 143) | | 2 | usage error: bad flag, missing user, missing command, no credentials | | 255 | connection, host key or authentication failure (same convention as OpenSSH) | ## goscp Copy local files and directories to a remote host with the SCP protocol. The remote side runs `scp -t`, so no SFTP subsystem is required. ```bash goscp [flags] [user@]host[:port] -files "dir or file" [-files ...] [-dest /remote/dir] ``` One flag handles both files and directories: `-files` inspects the path and either sends a single entry or walks the tree. There is no `-r` switch and no separate mode. ### Examples ```bash # one file, into the remote user's home directory goscp user@host -p "$PASS" -port 22 -files "build/output.bin" # a whole directory, into a specific place (created when missing) goscp user@host -p "$PASS" -files "build" -dest /home/user/app # several files, either repeated or trailing goscp user@host -p "$PASS" -files a.txt -files b.txt -dest /srv/in goscp user@host -p "$PASS" -files a.txt b.txt -dest /srv/in # password from the environment GOSCP_PASSWORD='secret' goscp user@host -files ./dist/ ``` ### Flags | Flag | Default | Meaning | | --- | --- | --- | | `-files` | empty | local file **or** directory, detected automatically; repeatable, trailing arguments count too | | `-dest` | remote home | remote target directory, created with `mkdir -p` when missing | | `-p` / `-password` / `-pw` | empty | SSH password; `GOSCP_PASSWORD` or `GOLINK_PASSWORD` | | `-port` / `-P` | 22 | SSH port | | `-l` | empty | login user | | `-i` | empty | OpenSSH private key | | `-knownhosts` / `-hostkey` | empty | host key policy | | `-timeout` | 15s | connect and authentication, not the transfer | | `-v` | off | list every entry as it is sent | | `-q` | off | no summary line on stderr | ### Exit status | Code | Meaning | | --- | --- | | 0 | everything was copied | | 1 | transfer failed: missing local path, remote scp error, permission denied | | 2 | usage error | | 255 | connection, host key or authentication failure | ### Behaviour - directories keep their name and relative structure: `-files build -dest /srv` gives `/srv/build/...` - permissions: regular files 0644 (0755 when the local file is executable), directories 0755 - symlinks named on the command line are followed; symlinks met inside a tree are skipped with a warning - file names may contain spaces; newlines are rejected - the transfer is streamed and verified byte for byte in the test suite ## Host key policy Host keys are **not** verified by default. A script cannot answer "do you trust this host", so the tools behave like `plink -batch`: they accept the key and continue. The trade-off is a theoretical man-in-the-middle. Pin it when it matters: - `-hostkey SHA256:...` -- fail unless the fingerprint matches; - `-knownhosts ~/.ssh/known_hosts` -- unknown or changed keys fail, never prompt. ## Security notes A password on the command line is visible in `ps` and the shell history, exactly like `plink -pw`. Prefer `GOLINK_PASSWORD` / `GOSCP_PASSWORD` or `-i`. Passphrase-protected keys are rejected with an explanatory error: these tools never prompt, so decrypt the key once with `ssh-keygen -p` instead. ## Building ```bash ./build.sh # every program for every platform, into dist/ ./build.sh local # only the host platform VERSION=1.2.3 ./build.sh ``` The only dependency is `golang.org/x/crypto`, so an offline build works as long as the module cache is populated. `build.sh` points `GOCACHE` at the in-repo `.gocache/` directory, because some sandboxes mount `~/.cache/go-build` read-only. Pushing a `v*` tag makes GitHub Actions run both test suites, cross-compile everything and attach the binaries to the GitHub release. Gitea also discovers `.github/workflows`, so the jobs carry an explicit `if: github.server_url == 'https://github.com'` guard and stay idle there. ## Testing ```bash ./smoketest/run.sh # golink, 38 checks ./smoketest/run-goscp.sh # goscp, 19 checks ``` Both suites are fully local: they build a miniature sshd (`smoketest/sshd`, supporting password, keyboard-interactive and public-key auth) and drive it over loopback. Every case runs with stdin closed and a watchdog, so any attempt to wait for interactive input shows up as a failure rather than a hang. ## Layout ``` cmd/golink/ golink entry point: flags, exit status mapping cmd/goscp/ goscp entry point internal/cli/ argv reordering so flags work anywhere internal/sshx/ dial, authentication, host key policy internal/scp/ SCP wire protocol, client side smoketest/sshd/ miniature SSH server used by the tests smoketest/run.sh golink end-to-end suite smoketest/run-goscp.sh build.sh cross-compilation for six targets ``` ## Not implemented - download direction for goscp (remote to local) - resume, parallel transfers, progress - an SFTP backend (would add a dependency; SCP keeps the binary self-contained) - jump hosts (`-J`), port forwarding, interactive shells / pty