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 | 简体中文

Install

Download the binary for your platform from the latest release, 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.

Connect, run one command, and return that command's exit status.

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

# 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.

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

# 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

./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

./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
S
Description
No description provided
Readme
72 KiB
2026-10-19 10:24:13 +08:00
Languages
Go 66.4%
Shell 33.6%