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:
liyp
2026-10-19 10:22:53 +08:00
commit 4365e80075
14 changed files with 1958 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
#
# End-to-end smoke test for goscp against the bundled miniature sshd.
#
# ./smoketest/run-goscp.sh
#
# The test server runs commands through /bin/sh, so the real scp binary on this
# machine plays the remote side of the protocol. Everything stays local; the
# uploaded trees are checked directly on disk.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
work="$root/.smoketest-goscp"
export GOCACHE="${GOCACHE:-$root/.gocache}"
USER_NAME=tester
PASS_WORD=s3cr3t
HOST=127.0.0.1
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
command -v scp >/dev/null 2>&1 || { echo "scp binary not found; cannot run this test" >&2; exit 1; }
rm -rf "$work"
mkdir -p "$work/bin" "$work/src/sub" "$work/server-cwd"
printf 'building\n'
go build -o "$work/bin/goscp" ./cmd/goscp || exit 1
go build -o "$work/bin/sshd" ./smoketest/sshd || exit 1
printf 'hello\n' >"$work/src/a.txt"
: >"$work/src/empty.bin"
printf 'companion data\n' >"$work/src/notes.md"
printf 'space content\n' >"$work/src/sub/name with spaces.txt"
head -c 1048576 /dev/urandom >"$work/src/sub/big.bin"
printf '#!/bin/sh\necho hi\n' >"$work/src/run.sh"
BIG_SUM="$(sha256sum "$work/src/sub/big.bin" | cut -d' ' -f1)"
sshd_pids=()
cleanup() {
local p
for p in ${sshd_pids[@]+"${sshd_pids[@]}"}; do kill "$p" 2>/dev/null; done
wait 2>/dev/null
}
trap cleanup EXIT
# The server runs with cwd $work/server-cwd so that "default destination"
# (the remote home, ".")
( cd "$work/server-cwd" && exec "$work/bin/sshd" -addr 127.0.0.1:0 -user "$USER_NAME" -pass "$PASS_WORD" \
-hostkey "$work/hostkey" ) >"$work/sshd.out" 2>"$work/sshd.err" &
sshd_pids+=($!)
for i in $(seq 1 100); do
grep -q '^HOSTKEY_SHA256=' "$work/sshd.out" 2>/dev/null && break
sleep 0.05
done
adr="$(sed -n 's/^ADDR=//p' "$work/sshd.out" | head -1)"
PORT="${adr##*:}"
if [ -z "$PORT" ]; then
echo "sshd did not start: $(cat "$work/sshd.err")" >&2
exit 1
fi
TARGET="$USER_NAME@$HOST"
G=( "$TARGET" -p "$PASS_WORD" -port "$PORT" )
gscp() { timeout 60 "$work/bin/goscp" "$@" </dev/null; }
printf '\n== single file, default destination ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" >/dev/null 2>"$work/err.txt"
rc=$?
if [ "$rc" -eq 0 ] && [ "$(cat "$work/server-cwd/a.txt" 2>/dev/null)" = "hello" ]; then
ok "file lands in the remote home"
else
bad "file lands in the remote home" "exit=$rc stderr=$(head -1 "$work/err.txt")"
fi
gscp "${G[@]}" -files "$work/src/empty.bin" -dest "$work/dest1" >/dev/null 2>&1
if [ -f "$work/dest1/empty.bin" ] && [ ! -s "$work/dest1/empty.bin" ]; then
ok "zero byte file"
else
bad "zero byte file" "missing or non-empty"
fi
printf '\n== directory tree ==\n'
gscp "${G[@]}" -files "$work/src" -dest "$work/dest2" >/dev/null 2>"$work/err.txt"
rc=$?
if [ "$rc" -eq 0 ] && [ -f "$work/dest2/src/sub/big.bin" ] && [ -f "$work/dest2/src/sub/name with spaces.txt" ]; then
ok "nested tree with spaces in names"
else
bad "nested tree with spaces in names" "exit=$rc stderr=$(head -1 "$work/err.txt")"
fi
got="$(sha256sum "$work/dest2/src/sub/big.bin" 2>/dev/null | cut -d' ' -f1)"
[ "$got" = "$BIG_SUM" ] && ok "1 MiB binary is byte identical" || bad "1 MiB binary is byte identical" "sha256 mismatch"
if [ -d "$work/dest2/src/sub" ] && [ "$(stat -c %a "$work/dest2/src/sub")" = "755" ]; then
ok "directories are 0755"
else
bad "directories are 0755" "mode=$(stat -c %a "$work/dest2/src/sub" 2>/dev/null)"
fi
mode="$(stat -c %a "$work/dest2/src/a.txt" 2>/dev/null)"
[ "$mode" = "644" ] && ok "plain files are 0644 by default" || bad "plain files are 0644 by default" "mode=$mode"
printf '\n== file modes ==\n'
chmod 755 "$work/src/run.sh"
gscp "${G[@]}" -files "$work/src/run.sh" -dest "$work/dest3" >/dev/null 2>&1
mode="$(stat -c %a "$work/dest3/run.sh" 2>/dev/null)"
[ "$mode" = "755" ] && ok "local exec bit is preserved" || bad "local exec bit is preserved" "mode=$mode"
printf '\n== multiple files and extra positionals ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" -files "$work/src/notes.md" -dest "$work/dest4" >/dev/null 2>&1
if [ -f "$work/dest4/a.txt" ] && [ -f "$work/dest4/notes.md" ]; then
ok "two -files flags"
else
bad "two -files flags" "missing output"
fi
# one -files followed by several bare paths: everything after the target is
# treated as a path, so both spellings below are equivalent
gscp "${G[@]}" -files "$work/src/a.txt" "$work/src/notes.md" "$work/src/empty.bin" -dest "$work/dest4b" >/dev/null 2>&1
if [ -f "$work/dest4b/a.txt" ] && [ -f "$work/dest4b/notes.md" ] && [ -f "$work/dest4b/empty.bin" ]; then
ok "-files with several bare paths"
else
bad "-files with several bare paths" "missing output"
fi
gscp "${G[@]}" -dest "$work/dest5" "$work/src/a.txt" "$work/src/notes.md" >/dev/null 2>&1
if [ -f "$work/dest5/a.txt" ] && [ -f "$work/dest5/notes.md" ]; then
ok "trailing positional paths"
else
bad "trailing positional paths" "missing output"
fi
printf '\n== destination handling ==\n'
gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/deep/nested/dir" >/dev/null 2>&1
[ -f "$work/deep/nested/dir/a.txt" ] && ok "missing destination directories are created" || bad "missing destination directories are created" "not created"
gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/rel" >/dev/null 2>&1
[ -f "$work/rel/a.txt" ] && ok "absolute -dest" || bad "absolute -dest" "missing"
printf '\n== output and exit codes ==\n'
out="$(gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/dest6" -v 2>&1 >/dev/null)"
if printf '%s' "$out" | grep -q 'a.txt'; then
ok "-v lists the uploaded entry"
else
bad "-v lists the uploaded entry" "stderr: $(printf '%s' "$out" | head -2 | tr '\n' '|')"
fi
out="$(gscp "${G[@]}" -files "$work/src/a.txt" -dest "$work/dest7" -q 2>&1 >/dev/null)"
[ -z "$out" ] && ok "-q keeps stderr empty" || bad "-q keeps stderr empty" "stderr: $out"
gscp "${G[@]}" -files "$work/does-not-exist" -dest "$work/dest8" >/dev/null 2>"$work/err.txt"
rc=$?
[ "$rc" -eq 1 ] && grep -q 'does-not-exist' "$work/err.txt" && ok "missing local path -> exit 1" \
|| bad "missing local path -> exit 1" "exit=$rc"
gscp "${G[@]}" -dest "$work/dest9" >/dev/null 2>&1
[ $? -eq 2 ] && ok "no -files -> exit 2" || bad "no -files -> exit 2"
gscp "$TARGET" -p wrong -port "$PORT" -files "$work/src/a.txt" >/dev/null 2>&1
[ $? -eq 255 ] && ok "wrong password -> exit 255" || bad "wrong password -> exit 255"
gscp "$TARGET" -port "$PORT" -files "$work/src/a.txt" >/dev/null 2>&1
[ $? -eq 2 ] && ok "no credentials -> exit 2" || bad "no credentials -> exit 2"
printf '\n== password from environment ==\n'
GOSCP_PASSWORD="$PASS_WORD" timeout 60 "$work/bin/goscp" "$TARGET" -port "$PORT" -files "$work/src/a.txt" -dest "$work/dest10" </dev/null >/dev/null 2>&1
[ -f "$work/dest10/a.txt" ] && ok "GOSCP_PASSWORD works" || bad "GOSCP_PASSWORD works" "missing output"
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
#
# End-to-end smoke test for golink against the bundled miniature sshd.
#
# ./smoketest/run.sh
#
# Builds golink and the test server into .smoketest/bin, starts the server on a
# free port and checks stdout, stderr and exit status for every case. Each case
# runs with stdin closed and a hard timeout, which also proves golink never waits
# for interactive input. Exits non-zero if any case fails.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
work="$root/.smoketest"
export GOCACHE="${GOCACHE:-$root/.gocache}"
USER_NAME=tester
PASS_WORD=s3cr3t
pass=0
fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
rm -rf "$work"
mkdir -p "$work/bin"
printf 'building\n'
go build -o "$work/bin/golink" ./cmd/golink || exit 1
go build -o "$work/bin/sshd" ./smoketest/sshd || exit 1
sshd_pids=()
cleanup() {
local p
for p in ${sshd_pids[@]+"${sshd_pids[@]}"}; do kill "$p" 2>/dev/null; done
wait 2>/dev/null
}
trap cleanup EXIT
start_sshd() { # <name> <auth-mode> [extra sshd flags...]
local name="$1" mode="$2" i
shift 2
"$work/bin/sshd" -addr 127.0.0.1:0 -auth "$mode" -user "$USER_NAME" -pass "$PASS_WORD" \
-hostkey "$work/hostkey" -knownhosts-out "$work/known_hosts_$name" "$@" \
>"$work/$name.out" 2>"$work/$name.err" &
sshd_pids+=($!)
for i in $(seq 1 100); do
grep -q '^HOSTKEY_SHA256=' "$work/$name.out" 2>/dev/null && break
sleep 0.05
done
ADDR="$(sed -n 's/^ADDR=//p' "$work/$name.out" | head -1)"
PORT="${ADDR##*:}"
FINGERPRINT="$(sed -n 's/^HOSTKEY_SHA256=//p' "$work/$name.out" | head -1)"
if [ -z "$PORT" ]; then
echo "sshd ($name) did not start: $(cat "$work/$name.err")" >&2
exit 1
fi
}
# gl runs golink with stdin closed and a watchdog, so a hang shows up as a
# failure instead of a stuck test run.
gl() { timeout 30 "$work/bin/golink" "$@" </dev/null; }
expect_status() { # <name> <want-status> <args...>
local name="$1" want="$2"; shift 2
local status
gl "$@" >/dev/null 2>"$work/last.err"
status=$?
if [ "$status" -ne "$want" ]; then
bad "$name" "exit=$status want=$want; stderr: $(head -1 "$work/last.err")"
else
ok "$name"
fi
}
expect_output() { # <name> <want-status> <want-stdout> <args...>
local name="$1" want="$2" wantout="$3"; shift 3
local status out
out="$(gl "$@" 2>"$work/last.err")"
status=$?
if [ "$status" -ne "$want" ]; then
bad "$name" "exit=$status want=$want; stderr: $(head -1 "$work/last.err")"
elif [ "$out" != "$wantout" ]; then
bad "$name" "stdout=$(printf '%q' "$out") want=$(printf '%q' "$wantout")"
else
ok "$name"
fi
}
start_sshd main both
HOST=127.0.0.1
TARGET="$USER_NAME@$HOST"
G=( "$TARGET" -p "$PASS_WORD" -port "$PORT" )
printf '\n== basic execution ==\n'
expect_output "echo" 0 "hello from golink" "${G[@]}" -command "echo hello from golink"
expect_output "flags in any order" 0 "any-order" -command "echo any-order" -p "$PASS_WORD" "$TARGET" -port "$PORT"
expect_output "host:port inline" 0 "inline" "$TARGET:$PORT" -p "$PASS_WORD" -command "echo inline"
expect_output "-l instead of user@host" 0 "via-l" -l "$USER_NAME" "$HOST" -port "$PORT" -p "$PASS_WORD" -command "echo via-l"
expect_output "trailing args become the command" 0 "a b c" "$TARGET" -p "$PASS_WORD" -port "$PORT" echo a b c
expect_output "shell pipeline remotely" 0 "2" "${G[@]}" -command "printf 'x\ny\n' | wc -l"
expect_output "-pw plink-style alias" 0 "pw-ok" "$TARGET" -pw "$PASS_WORD" -port "$PORT" -command "echo pw-ok"
printf '\n== exit status passthrough ==\n'
expect_status "exit 0" 0 "${G[@]}" -command "true"
expect_status "exit 7" 7 "${G[@]}" -command "exit 7"
expect_status "exit 42" 42 "${G[@]}" -command "exit 42"
expect_status "unknown command -> 127" 127 "${G[@]}" -command "definitely-not-a-real-command-xyz"
expect_status "killed by SIGTERM -> 143" 143 "${G[@]}" -command 'kill -TERM $$'
expect_status "killed by SIGKILL -> 137" 137 "${G[@]}" -command 'kill -KILL $$'
printf '\n== stdout / stderr separation ==\n'
out="$(gl "${G[@]}" -command "echo to-stdout; echo to-stderr 1>&2" 2>"$work/last.err")"
if [ "$out" = "to-stdout" ] && grep -qx 'to-stderr' "$work/last.err"; then
ok "remote stderr stays on stderr"
else
bad "remote stderr stays on stderr" "stdout=$(printf '%q' "$out") stderr=$(cat "$work/last.err")"
fi
printf '\n== stdin piping ==\n'
if printf 'piped-data\n' | timeout 30 "$work/bin/golink" "${G[@]}" -command "cat" >"$work/stdin.out" 2>/dev/null; then
if [ "$(cat "$work/stdin.out")" = "piped-data" ]; then
ok "stdin piped into remote cat"
else
bad "stdin piped into remote cat" "got $(printf '%q' "$(cat "$work/stdin.out")")"
fi
else
bad "stdin piped into remote cat" "golink failed"
fi
head -c 200000 /dev/urandom >"$work/blob"
got="$(timeout 30 "$work/bin/golink" "${G[@]}" -command "wc -c" <"$work/blob" 2>/dev/null | tr -d ' ')"
[ "$got" = "200000" ] && ok "200000 byte stdin round trip" || bad "200000 byte stdin round trip" "got $got"
printf '\n== larger output ==\n'
lines="$(gl "${G[@]}" -command "seq 1 20000" 2>/dev/null | wc -l)"
[ "$lines" = "20000" ] && ok "20000 lines streamed back" || bad "20000 lines streamed back" "got $lines"
printf '\n== host key policy ==\n'
expect_status "-hostkey correct fingerprint" 0 "${G[@]}" -hostkey "$FINGERPRINT" -command "true"
expect_status "-hostkey wrong fingerprint -> 255" 255 "${G[@]}" -hostkey "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" -command true
expect_output "-knownhosts file" 0 "known-ok" "${G[@]}" -knownhosts "$work/known_hosts_main" -command "echo known-ok"
: >"$work/empty_known_hosts"
expect_status "-knownhosts unknown host -> 255" 255 "${G[@]}" -knownhosts "$work/empty_known_hosts" -command true
printf '\n== failures are fast, never interactive ==\n'
expect_status "wrong password -> 255" 255 "$TARGET" -p wrong -port "$PORT" -command true
expect_status "wrong user -> 255" 255 "nosuchuser@$HOST" -p "$PASS_WORD" -port "$PORT" -command true
expect_status "no credentials -> 2" 2 "$TARGET" -port "$PORT" -command true
expect_status "missing user -> 2" 2 "$HOST" -p "$PASS_WORD" -port "$PORT" -command true
expect_status "missing command -> 2" 2 "${G[@]}"
expect_status "connection refused -> 255" 255 "$TARGET" -p "$PASS_WORD" -port 1 -timeout 3s -command true
expect_status "-h exits 0" 0 -h
expect_status "-version exits 0" 0 -version
printf '\n== verbose output does not pollute stdout ==\n'
out="$(gl "${G[@]}" -v -command "echo v-ok" 2>"$work/last.err")"
if [ "$out" = "v-ok" ] && grep -q 'host key SHA256:' "$work/last.err"; then
ok "-v diagnostics go to stderr"
else
bad "-v diagnostics go to stderr" "stdout=$(printf '%q' "$out") stderr=$(head -3 "$work/last.err" | tr '\n' '|')"
fi
printf '\n== GOLINK_PASSWORD ==\n'
out="$(GOLINK_PASSWORD="$PASS_WORD" timeout 30 "$work/bin/golink" "$TARGET" -port "$PORT" -command "echo from-env" </dev/null 2>"$work/last.err")"
if [ "$out" = "from-env" ]; then
ok "GOLINK_PASSWORD supplies the password"
else
bad "GOLINK_PASSWORD supplies the password" "stdout=$(printf '%q' "$out") stderr=$(head -1 "$work/last.err")"
fi
printf '\n== keyboard-interactive only server ==\n'
start_sshd kbd keyboard-interactive
expect_output "keyboard-interactive login" 0 "kbd-ok" "$USER_NAME@$HOST" -p "$PASS_WORD" -port "$PORT" -command "echo kbd-ok"
expect_status "keyboard-interactive wrong password -> 255" 255 "$USER_NAME@$HOST" -p nope -port "$PORT" -command true
printf '\n== public key authentication ==\n'
if command -v ssh-keygen >/dev/null 2>&1; then
ssh-keygen -q -t ed25519 -N "" -C golink-smoketest -f "$work/id_ed25519" >/dev/null 2>&1
ssh-keygen -q -t ed25519 -N "" -C golink-other -f "$work/id_other" >/dev/null 2>&1
ssh-keygen -q -t ed25519 -N "hunter2" -C golink-locked -f "$work/id_locked" >/dev/null 2>&1
start_sshd key publickey -authorized-key "$work/id_ed25519.pub"
expect_output "-i private key" 0 "key-ok" "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -command "echo key-ok"
expect_status "unauthorized key -> 255" 255 "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_other" -command true
gl "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_locked" -command true >/dev/null 2>"$work/last.err"
lock_status=$?
if [ "$lock_status" -eq 255 ] && grep -qi 'passphrase' "$work/last.err"; then
ok "encrypted key fails with a clear message"
else
bad "encrypted key fails with a clear message" "exit=$lock_status stderr: $(head -1 "$work/last.err")"
fi
# -auth publickey server: only the exact key is accepted, and a key-only
# client must fail against a password-only server instead of prompting
start_sshd pwdonly password
expect_status "key rejected by password-only server -> 255" 255 "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -command true
expect_output "key and password together" 0 "mix-ok" "$USER_NAME@$HOST" -port "$PORT" -i "$work/id_ed25519" -p "$PASS_WORD" -command "echo mix-ok"
else
printf ' skip ssh-keygen not available, public key cases skipped\n'
fi
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+253
View File
@@ -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)
}