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:
Executable
+178
@@ -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 ]
|
||||
Executable
+203
@@ -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 ]
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user