Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ apps/wolfsshd/wolfsshd
apps/wolfsshd/test/test_configuration
apps/wolfsshd/test/sshd_privdrop_preload.so
apps/wolfsshd/test/privdrop_client_*.log
apps/wolfsshd/test/sshd_fork_preload.so
apps/wolfsshd/test/fork_client_*.log
apps/wolfsshd/test/log.txt
apps/wolfsshd/test/sshd_config_*
apps/wolfsshd/test/authorized_keys_test
Expand Down
10 changes: 9 additions & 1 deletion apps/wolfsshd/test/run_all_sshd_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ extra_test_cases=(
"sshd_login_grace_test.sh"
"sshd_privdrop_fail_test.sh"
"sshd_chroot_fail_test.sh"
"sshd_fork_fail_test.sh"
"sshd_exit_status_test.sh"
"sshd_x509_test.sh"
"sshd_x509_upn_fail.sh"
"sshd_mldsa_composite_test.sh"
Expand Down Expand Up @@ -126,7 +128,10 @@ done
# +0 shared wolfSSHd (TEST_PORT) +3 host key ownership/symlink gate
# +1 StrictModes negative test +4 OpenSSH certificate test
# +2 AuthorizedUPNDomains negative +5 privilege-drop test
# +6 chroot skip-after-failure test
# +6 chroot skip-after-failure test +7 fork failure test
#
# That fills the PORT_BLOCK_SIZE (8) ports of a block; another private-daemon
# port needs a larger PORT_BLOCK_SIZE in port_lease.sh.
#
# The range deliberately starts above everything else in the repo that binds
# a port: CI steps bind 22222, 22225 and 22226, and scripts/fwd-bulk.test
Expand Down Expand Up @@ -164,6 +169,7 @@ HOSTKEY_PERM_PORT=$((PORT_BASE + 3))
export WOLFSSHD_TEST_PORT=$((PORT_BASE + 4))
export WOLFSSHD_PRIVDROP_PORT=$((PORT_BASE + 5))
export WOLFSSHD_CHROOT_PORT=$((PORT_BASE + 6))
export WOLFSSHD_FORK_PORT=$((PORT_BASE + 7))

# Registry of the daemons started during this run, appended to by
# start_wolfsshd in every test script that sources start_sshd.sh. The exit
Expand Down Expand Up @@ -764,6 +770,8 @@ local_sshd_tests=(
"sshd_login_grace_test.sh"
"sshd_privdrop_fail_test.sh"
"sshd_chroot_fail_test.sh"
"sshd_fork_fail_test.sh"
"sshd_exit_status_test.sh"
)
if [ "$USING_LOCAL_HOST" == 1 ]; then
for test in "${local_sshd_tests[@]}"; do
Expand Down
63 changes: 63 additions & 0 deletions apps/wolfsshd/test/sshd_exit_status_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/bin/bash

# Exit-status regression: the POSIX daemon used to end StartSSHD() with an
# unconditional "return 0", so a config check (-t) of a bad config, or any
# other startup failure, exited 0. Every case runs in -D -t mode, so nothing
# daemonizes or binds a port.

TESTDIR=`pwd`
ROOT="$TESTDIR/../../.."

SSHD_BIN="../wolfsshd"
if [ ! -x "$SSHD_BIN" ]; then
echo "SKIP: $SSHD_BIN not built"
exit 77
fi

cleanup() {
rm -f sshd_config_test_exit exit_status_hostkey.pem
return 0
}
trap cleanup EXIT

# The host key gate wants an owner-only file; the checked-in key is 0644.
cp "$ROOT/keys/server-key.pem" exit_status_hostkey.pem
chmod 600 exit_status_hostkey.pem

# $1: host key path
write_config() {
cat <<EOF > sshd_config_test_exit
Protocol 2
UsePrivilegeSeparation no
UseDNS no
HostKey $1
EOF
}

# $1: expected status, "zero" or "nonzero"; $2: description; rest: arguments
expect_status() {
local want="$1" what="$2" ret
shift 2
"$SSHD_BIN" -D -t -E /dev/null "$@" < /dev/null > /dev/null 2>&1
ret=$?
if [ "$want" = "zero" ] && [ "$ret" -ne 0 ]; then
echo "FAIL: $what exited $ret, expected 0"
exit 1
fi
if [ "$want" = "nonzero" ] && [ "$ret" -eq 0 ]; then
echo "FAIL: $what exited 0"
exit 1
fi
echo "ok: $what exited $ret"
}

write_config "$TESTDIR/exit_status_hostkey.pem"
expect_status zero "valid config" -f sshd_config_test_exit
expect_status nonzero "missing config file" -f "$TESTDIR/no_such_sshd_config"
expect_status nonzero "invalid -p" -f sshd_config_test_exit -p 70000

write_config "$TESTDIR/no_such_hostkey.pem"
expect_status nonzero "missing host key" -f sshd_config_test_exit

echo "PASS: wolfsshd exit status reflects startup failures"
exit 0
171 changes: 171 additions & 0 deletions apps/wolfsshd/test/sshd_fork_fail_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/bin/bash

# Accept-loop regression: a fork() that fails for one connection must drop that
# connection only. The daemon used to store the failure in its accept loop's
# control variable and shut down, so one EAGAIN at a process limit stopped it
# listening for good. The failure is injected against the stock wolfsshd with
# an LD_PRELOAD interposer (sshd_fork_preload.c) that fails the first
# connection's fork() once.

if [ -z "$1" ] || [ -z "$2" ]; then
echo "expecting host and the runner's shared port as arguments;"
echo "this test binds WOLFSSHD_FORK_PORT, or that port + 7"
echo "./sshd_fork_fail_test.sh 127.0.0.1 22300"
exit 1
fi

# Not PWD: that is bash's own variable and the shell rewrites it on every cd,
# so a saved copy is gone by the time it is read.
TESTDIR=`pwd`
USER=`whoami`
TEST_HOST="$1"

# Own daemon on a port leased by the runner, so the isolation holds against
# another run of the suite on the same host. Standalone, fall back to an
# offset from the port passed in.
TEST_PORT="${WOLFSSHD_FORK_PORT:-$(( $2 + 7 ))}"

SSHD_BIN="../wolfsshd"
if [ ! -x "$SSHD_BIN" ]; then
echo "SKIP: $SSHD_BIN not built"
exit 77
fi

# macOS strips DYLD_INSERT_LIBRARIES from the sudo-launched daemon, so Linux only.
if [ "`uname -s`" != "Linux" ]; then
echo "SKIP: fork fault injection needs Linux LD_PRELOAD"
exit 77
fi

# start_sshd.sh word-splits SSHD_ENV to build "sudo env NAME=VALUE ...", so a
# preload path holding whitespace would be torn in half.
case "$TESTDIR" in
*[[:space:]]*)
echo "SKIP: test directory path contains whitespace"
exit 77
;;
esac

ROOT="$TESTDIR/../../.."
TEST_CLIENT="$ROOT/examples/client/client"
if [ ! -x "$TEST_CLIENT" ]; then
echo "SKIP: $TEST_CLIENT not built"
exit 77
fi

# Absolute: the example client calls ChangeToWolfSshRoot() before parsing
# anything, so a relative path would be resolved from the repository root.
PRIVATE_KEY="$ROOT/keys/hansel-key-ecc.der"
PUBLIC_KEY="$ROOT/keys/hansel-key-ecc.pub"

source ./start_sshd.sh

# Teardown on every exit path; log.txt and the client logs are kept for
# debugging like the other tests.
cleanup() {
stop_wolfsshd
rm -f sshd_config_test_fork "$PRELOAD_LIB"
return 0
}
trap cleanup EXIT

# Build the interposer next to this script; skip if no compiler. Relative "./"
# path so the LD_PRELOAD value has no space (see SSHD_ENV in start_sshd.sh).
PRELOAD_SRC="sshd_fork_preload.c"
PRELOAD_LIB="./sshd_fork_preload.so"
CC_BIN="${CC:-cc}"
if ! "$CC_BIN" -shared -fPIC -o "$PRELOAD_LIB" "$PRELOAD_SRC" -ldl 2>/dev/null; then
echo "SKIP: could not build $PRELOAD_LIB with $CC_BIN"
exit 77
fi

# The runner creates this in its setup step; a standalone run does not get that.
if [ ! -f authorized_keys_test ]; then
./create_authorized_test_file.sh || exit 1
fi

touch log.txt 2>/dev/null

cat <<EOF > sshd_config_test_fork
Port $TEST_PORT
Protocol 2
LoginGraceTime 600
PermitRootLogin yes
PasswordAuthentication yes
PermitEmptyPasswords no
UsePrivilegeSeparation no
UseDNS no
HostKey $ROOT/keys/server-key.pem
AuthorizedKeysFile $TESTDIR/authorized_keys_test
EOF

# Read through sudo: the daemon runs under sudo, so log.txt may be root-owned.
log_count() {
local n
n=`sudo grep -c "$1" log.txt 2>/dev/null`
echo "${n:-0}"
}

DEADLINE=30
TIMEOUT=""
if command -v timeout >/dev/null 2>&1; then
TIMEOUT="timeout $DEADLINE"
fi

# Runs one exec session; output in $CLIENT_LOG, exit status in $CLIENT_RET.
run_client() {
CLIENT_LOG="$TESTDIR/fork_client_$1.log"
$TIMEOUT "$TEST_CLIENT" -q -c "echo $TOKEN" -u "$USER" -i "$PRIVATE_KEY" \
-j "$PUBLIC_KEY" -h "$TEST_HOST" -p "$TEST_PORT" \
> "$CLIENT_LOG" 2>&1
CLIENT_RET=$?
}

client_said() {
echo " client said: `tail -n 3 "$CLIENT_LOG" | tr '\n' '|'`"
echo " full client output in $CLIENT_LOG"
}

# Echoed by the exec session. Present only when a shell actually ran.
TOKEN="wolfsshd_fork_ok_$$"

BEFORE_FORK_FAIL=`log_count "Issue spawning new process"`

SSHD_ENV="LD_PRELOAD=$PRELOAD_LIB WOLFSSHD_FAULT_FORK=1"
export SSHD_BIN SSHD_ENV
start_wolfsshd "sshd_config_test_fork"
if [ -z "$PID" ]; then
echo "FAIL: fork-fault daemon did not start"
exit 1
fi

# First connection: its fork() is failed, so the daemon drops it. The client's
# own result does not matter, only that the daemon reached the failure.
run_client "faulted"
AFTER_FORK_FAIL=`log_count "Issue spawning new process"`
if [ "$AFTER_FORK_FAIL" -le "$BEFORE_FORK_FAIL" ]; then
echo "FAIL: injected fork() failure never reached the daemon"
client_said
exit 1
fi

# The daemon must still be up. Give a daemon that is on its way out the time to
# finish exiting, so this does not pass on a race.
sleep 1
if ! wolfsshd_alive "$PID"; then
echo "FAIL: daemon exited after one connection's fork() failed"
exit 1
fi

# Second connection: the interposer forwards this fork(), so the session must
# run end to end.
run_client "after"
if [ "$CLIENT_RET" -ne 0 ] || ! grep -q "$TOKEN" "$CLIENT_LOG"; then
echo "FAIL: daemon did not serve a connection after a failed fork()"
client_said
exit 1
fi

rm -f "$TESTDIR"/fork_client_*.log
echo "PASS: daemon keeps accepting after a failed fork()"
exit 0
72 changes: 72 additions & 0 deletions apps/wolfsshd/test/sshd_fork_preload.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* sshd_fork_preload.c
*
* Copyright (C) 2014-2026 wolfSSL Inc.
*
* This file is part of wolfSSH.
*
* wolfSSH is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* wolfSSH is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with wolfSSH. If not, see <http://www.gnu.org/licenses/>.
*/

/* LD_PRELOAD interposer for sshd_fork_fail_test.sh only. */
/* When WOLFSSHD_FAULT_FORK is set, the first fork() after the first accept()
* returns EAGAIN, as it does at a process limit. Keyed on accept() so the two
* forks made while daemonizing are left alone. One shot: every later fork,
* including those in connection children, is forwarded. */

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <dlfcn.h>

static int wsshd_accepted = 0;
static int wsshd_failed = 0;

int accept(int fd, struct sockaddr* addr, socklen_t* addrLen)
{
int (*real)(int, struct sockaddr*, socklen_t*);
int ret;

real = (int (*)(int, struct sockaddr*, socklen_t*))
dlsym(RTLD_NEXT, "accept");
if (real == NULL) {
errno = ENOSYS;
return -1;
}
ret = real(fd, addr, addrLen);
if (ret >= 0) {
wsshd_accepted++;
}
return ret;
}

pid_t fork(void)
{
pid_t (*real)(void);

if (getenv("WOLFSSHD_FAULT_FORK") != NULL && wsshd_accepted == 1
&& !wsshd_failed) {
wsshd_failed = 1;
errno = EAGAIN;
return -1;
}
real = (pid_t (*)(void))dlsym(RTLD_NEXT, "fork");
if (real == NULL) {
errno = ENOSYS;
return -1;
}
return real();
}
Loading
Loading