From 23899eda8573dd3d8fee3283575911dfce9fbf6c Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 11 Aug 2026 09:01:06 -0700 Subject: [PATCH] Build and test the wolfssh client app in CI - --enable-sshclient defaults to no, so the app was built only by the configs that use --enable-all, and never under the multi-compiler warning flags. Add it to the multi-compiler matrix. - Add scripts/sshclient.test, run by make check. It covers the client's sessions and the -E log file against the echoserver. - The script is not gated on BUILD_SSHCLIENT. It exits 77 when the client app or the echoserver isn't there, so every build runs it and the ones without the app report it as a skip. - Check the client and the echoserver by asking each for its usage message, not by looking for the file. Both are libtool wrapper scripts in the build tree, and a wrapper outlives a reconfigure that drops the program it wraps, then runs only far enough to say so. - The echoserver runs in echo mode and the client's stdin comes from a fifo written a piece at a time, so the session carries data and ends on its own. Each client run has a watchdog. - Rename sshd-test.yml's job to cover both apps. That workflow builds the client app along with wolfsshd. - Check that the command reaches the server, now that the client sends it rather than discarding it. - Make the SINGLE_THREADED guard a preprocessor #error. The runtime err_sys() only caught the misconfiguration in an autotools build that got as far as running; the #error catches it at compile time for the IDE and plain Makefile builds too. - Treat WS_WANT_READ and WS_WANT_WRITE out of wolfSSH_worker() as a clean shutdown. The socket is non-blocking, so the peer having nothing ready is not a session failure. --- .github/workflows/multi-compiler.yml | 2 +- .github/workflows/sshd-test.yml | 2 +- apps/wolfssh/wolfssh.c | 15 +- scripts/include.am | 4 + scripts/sshclient.test | 280 +++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 8 deletions(-) create mode 100755 scripts/sshclient.test diff --git a/.github/workflows/multi-compiler.yml b/.github/workflows/multi-compiler.yml index 2305c0c8..1ae8d4b8 100644 --- a/.github/workflows/multi-compiler.yml +++ b/.github/workflows/multi-compiler.yml @@ -88,7 +88,7 @@ jobs: CXX: ${{ matrix.cxx }} run: | ./autogen.sh - ./configure CFLAGS="-Wall -Wextra -Wpedantic" + ./configure --enable-sshclient CFLAGS="-Wall -Wextra -Wpedantic" make -j$(nproc) - name: Make dist diff --git a/.github/workflows/sshd-test.yml b/.github/workflows/sshd-test.yml index b98a5c00..c3e06d8e 100644 --- a/.github/workflows/sshd-test.yml +++ b/.github/workflows/sshd-test.yml @@ -72,7 +72,7 @@ jobs: os: [ ubuntu-latest ] wolfssl: ${{ fromJson(needs.create_matrix.outputs['versions']) }} mldsa: [ 'yes', 'no' ] - name: Build and test wolfsshd + name: Build and test the wolfsshd and wolfssh apps runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: diff --git a/apps/wolfssh/wolfssh.c b/apps/wolfssh/wolfssh.c index 730df2dc..9c943145 100644 --- a/apps/wolfssh/wolfssh.c +++ b/apps/wolfssh/wolfssh.c @@ -82,6 +82,10 @@ #include #endif +#ifdef SINGLE_THREADED + #error "Threading needed for terminal and command sessions." +#endif + int myoptind = 0; char* myoptarg = NULL; @@ -1050,10 +1054,6 @@ static THREAD_RETURN WOLFSSH_THREAD wolfSSH_Client(void* args) if (clientConfig.hostname == NULL) err_sys("client requires a hostname parameter."); -#ifdef SINGLE_THREADED - err_sys("Threading needed for terminal and command sessions\n"); -#endif - if (clientConfig.keyFile) { ret = ClientSetPrivateKey(clientConfig.keyFile); if (ret == 0) { @@ -1249,8 +1249,11 @@ static THREAD_RETURN WOLFSSH_THREAD wolfSSH_Client(void* args) else { ret = wolfSSH_worker(ssh, NULL); } - if (ret == WS_CHANNEL_CLOSED) { - /* Shutting down, channel closing isn't a fail. */ + if (ret == WS_CHANNEL_CLOSED || ret == WS_WANT_READ + || ret == WS_WANT_WRITE) { + /* Shutting down. The channel closing isn't a fail, and neither + * is the peer having nothing ready on this non-blocking socket; + * either way there is nothing left to wait for. */ ret = WS_SUCCESS; } else if (ret != WS_SUCCESS) { diff --git a/scripts/include.am b/scripts/include.am index 2f769362..4fbfe39a 100644 --- a/scripts/include.am +++ b/scripts/include.am @@ -11,5 +11,9 @@ if BUILD_SCP dist_noinst_SCRIPTS+= scripts/scp.test endif +# Not gated on BUILD_SSHCLIENT. The script skips itself when the client +# app wasn't built. +dist_noinst_SCRIPTS+= scripts/sshclient.test + dist_noinst_SCRIPTS+= scripts/external.test scripts/fwd.test EXTRA_DIST += scripts/fwd.test.expect diff --git a/scripts/sshclient.test b/scripts/sshclient.test new file mode 100755 index 00000000..58c02bd2 --- /dev/null +++ b/scripts/sshclient.test @@ -0,0 +1,280 @@ +#!/bin/sh + +# wolfssh client app test +# +# Runs the wolfssh client against the echoserver, covering the remote +# command session, the terminal session, and the -E log file option. + +no_pid=-1 +server_pid=$no_pid +client_pid=$no_pid +input_pid=$no_pid +killer_pid=$no_pid +work_dir=`pwd`/wolfssh_client_test$$ +ready_file=$work_dir/ready +input_file=$work_dir/input +client_out=$work_dir/client.out +port=0 +counter=0 +# Seconds to give the client before killing it. Nothing here takes more +# than a moment, the limit is only so a stuck session fails this test +# instead of hanging make check. +client_limit=60 + +[ ! -x ./apps/wolfssh/wolfssh ] \ + && echo "wolfssh client app doesn't exist, skipping" && exit 77 +./apps/wolfssh/wolfssh -h 2>&1 | grep -q "usage: " \ + || { echo "wolfssh client app doesn't run, skipping"; exit 77; } +[ ! -x ./examples/echoserver/echoserver ] \ + && echo "echoserver doesn't exist, skipping" && exit 77 +./examples/echoserver/echoserver '-?' 2>&1 | grep -q "^echoserver " \ + || { echo "echoserver doesn't run, skipping"; exit 77; } + +do_cleanup() { + echo "in cleanup" + + if [ $killer_pid != $no_pid ] + then + kill $killer_pid 2>/dev/null + killer_pid=$no_pid + fi + if [ $input_pid != $no_pid ] + then + kill $input_pid 2>/dev/null + input_pid=$no_pid + fi + if [ $client_pid != $no_pid ] + then + echo "killing client" + kill -9 $client_pid 2>/dev/null + client_pid=$no_pid + fi + if [ $server_pid != $no_pid ] + then + echo "killing server" + kill -9 $server_pid 2>/dev/null + server_pid=$no_pid + fi + rm -rf $work_dir +} + +do_trap() { + echo "got trap" + do_cleanup + exit 1 +} + +trap do_trap INT TERM + +# The echoserver is one shot, start a new one for each connection. It picks +# an ephemeral port and writes it to the ready file. +# +# -f keeps the server in echo mode. Without it a build with shell support +# tries to fork a login shell for the user, which fails since jill isn't a +# real account, and the session ends before anything crosses the channel. +start_server() { + # The -1 server exits after its connection, but a client run that failed + # before connecting leaves one listening. Reap it, server_pid is about + # to be overwritten. + if [ $server_pid != $no_pid ] + then + kill -9 $server_pid 2>/dev/null + wait $server_pid 2>/dev/null + server_pid=$no_pid + fi + + rm -f $ready_file + ./examples/echoserver/echoserver -1 -f -R $ready_file \ + > $work_dir/server.log 2>&1 & + server_pid=$! + + # A debug build starting up under a parallel make check needs more than + # the couple of seconds the other scripts allow. + counter=0 + while [ ! -s "$ready_file" ] && [ "$counter" -lt 100 ]; do + echo "waiting for ready file..." + sleep 0.1 + counter=$((counter + 1)) + done + + if [ ! -s "$ready_file" ]; then + echo -e "\n\nNO ready file ending test..." + do_cleanup + exit 1 + fi + + port=`cat $ready_file` + echo "server listening on port $port" +} + +fail() { + echo -e "\n\n$1" + do_cleanup + exit 1 +} + +# Wait for the client to write something to its output. The prompts are +# flushed as they are printed, so this tells us the client is about to read +# the answer. +wait_for_output() { + count=0 + while [ "$count" -lt 300 ]; do + grep -q "$1" $client_out 2>/dev/null && return 0 + sleep 0.1 + count=$((count + 1)) + done + return 1 +} + +# Run the client with its stdin coming from a fifo. +# +# The client answers its prompts with stdio, which buffers everything that +# is ready to be read, so anything written along with a prompt's answer is +# swallowed with it and never reaches the session. Writing each piece only +# once the client has asked for it keeps them in separate reads. The +# echoserver only ends the session when it receives a 0x03, so every +# session has to send one or both ends wait for the other forever. +# +# $1 - "confirm" when the client will ask about the unknown server key +# rest - client arguments +run_client() { + confirm=$1 + shift + + rm -f $input_file $client_out + touch $client_out + mkfifo $input_file || fail "couldn't create the input fifo" + + ( + # GetConfirmation() reads a single character. A newline here would + # be left behind for the password prompt to read as an empty + # password. + [ "$confirm" = "confirm" ] && printf 'Y' + + wait_for_output "Password:" || exit 1 + printf 'upthehill\n' + + # Let the client consume the password before sending the session + # data. A single read that catches both loses the data. + sleep 2 + + printf 'hello\003' + ) > $input_file 2>/dev/null & + input_pid=$! + + HOME=$work_dir ./apps/wolfssh/wolfssh "$@" \ + < $input_file > $client_out 2>&1 & + client_pid=$! + + # Poll rather than sleep through the whole limit. Killing a subshell + # that is waiting on a sleep leaves the sleep running. + ( + watched=0 + while kill -0 $client_pid 2>/dev/null; do + if [ $watched -ge $client_limit ]; then + kill -9 $client_pid 2>/dev/null + break + fi + sleep 1 + watched=$((watched + 1)) + done + ) 2>/dev/null & + killer_pid=$! + + wait $client_pid + client_status=$? + client_pid=$no_pid + + kill $killer_pid 2>/dev/null + killer_pid=$no_pid + kill $input_pid 2>/dev/null + input_pid=$no_pid + + cat $client_out + return $client_status +} + +mkdir -p $work_dir/.ssh + +# The known hosts check rejects a missing or empty file without asking, so +# seed the file with an entry for another host. The first connection then +# gets the "server is unknown" prompt and answers it. +echo "example.invalid ssh-rsa AAAA" > $work_dir/.ssh/known_hosts + +echo "Test learning the server's key" +start_server +run_client confirm -E $work_dir/learn.log -p $port jill@127.0.0.1 "echo one" +RESULT=$? + +if [ $RESULT -ne 0 ]; then + [ $RESULT -gt 128 ] && fail "the client had to be killed, session stuck" + fail "failed to connect" +fi + +grep -q "^127.0.0.1 " $work_dir/.ssh/known_hosts \ + || fail "server key not added to the known hosts" +grep -q "hello" $client_out \ + || fail "the echoserver's reply didn't make it back" + +# With the server's key known, the client only prompts for the password. +# The log file is empty unless the library has logging compiled in. +echo "Test a session given a command, with a log file" +start_server +run_client "" -E $work_dir/command.log -p $port jill@127.0.0.1 "echo two" +[ $? -ne 0 ] && fail "failed to open the session" + +grep -q "hello" $client_out \ + || fail "the echoserver's reply didn't make it back" + +if [ -s $work_dir/command.log ]; then + echo "checking the log file" + + # The log is redirected before wolfSSH_Init(), so the library's start up + # message is the first thing in the file. + head -n 1 $work_dir/command.log | grep -q "Entering wolfSSH_Init()" \ + || fail "log file is missing the wolfSSH_Init() message" + + # The log is closed after wolfSSH_Cleanup(), which logs as well. + grep -q "Leaving wolfSSH_Cleanup()" $work_dir/command.log \ + || fail "log file is missing the wolfSSH_Cleanup() message" + + # Given a command the client opens an exec channel to carry it, with no + # terminal request to discard it. + grep -q "type = exec" $work_dir/command.log \ + || fail "the client didn't open an exec channel for the command" + + # The command string itself is only logged by a debug build. + if grep -q " command = " $work_dir/command.log; then + grep -q "command = echo two" $work_dir/command.log \ + || fail "the client didn't send the command it was given" + fi +else + echo "empty log file, library built without logging" +fi + +echo "Test terminal session" +start_server +run_client "" -E $work_dir/terminal.log -p $port jill@127.0.0.1 +[ $? -ne 0 ] && fail "failed to open the terminal session" + +grep -q "hello" $client_out \ + || fail "the echoserver's reply didn't make it back" + +if [ -s $work_dir/terminal.log ]; then + grep -q "Leaving wolfSSH_Cleanup()" $work_dir/terminal.log \ + || fail "log file is missing the wolfSSH_Cleanup() message" + + # No command was given, the client asks for a terminal instead. + grep -q "type = exec" $work_dir/terminal.log \ + && fail "the client opened an exec channel it wasn't asked for" + grep -q " command = " $work_dir/terminal.log \ + && fail "the client sent a command it wasn't given" +fi + +echo "Test the usage message" +./apps/wolfssh/wolfssh -Z 2>&1 | grep -q "usage:" \ + || fail "no usage message for a bad option" + +do_cleanup +echo "wolfssh client tests passed" +exit 0