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,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])
|
||||
}
|
||||
Reference in New Issue
Block a user