. "$BST_LIB" # BST-TIMEOUT: 600 # Kept at 600 deliberately. The batch-1214 image reported # elapsed=600s bound=600s twice (2026-08-25, 2026-08-26) -- # but that is not this suite's cost. Sixteen cases scored # green and the criterion below then hung forever on a # `timeout' that bounds nothing here (brintos/linux#21; see # the watchdog there, which no longer depends on being # reaped). No run has yet reported an honest elapsed= for # this file, so the bound stays until one does -- a bound is # a ceiling, not a cost. The NATIVE x86-64 stunnel 5.72 # control (hz1, 2026-08-24) was 25s end-to-end, which is a # floor and not the target's number: process staging on the # guest is far slower than native. # # stunnel 5.72 (meta-networking recipes-support/stunnel) on wasm32-hwjs -- the # TLS wrapper that puts SSL around an arbitrary TCP service. # # WHAT THIS SUITE IS FOR. stunnel is not a library and not a filter: it is a # server that has to BIND, ACCEPT, COMPLETE A TLS HANDSHAKE and then relay bytes # in both directions. "The files are present" says nothing about any of that, and # every one of those four steps is a real question on this target rather than a # formality -- which is why the criterion this port is judged on is a full round # trip on loopback rather than a version string. # # The shape is three processes: socat runs a plain TCP echo on 22461, stunnel # wraps TLS around it on 22460, and `openssl s_client' is the client. All three # are already on this image, so the suite adds no dependency; what it does add is # the ONE THING none of them can supply, a certificate -- staged out of $BST_DATA # rather than generated, because RSA keygen on this target is neither fast nor # free and a suite that spent its bound making a key would be testing openssl. # # TWO THINGS HERE WERE MEASURED RATHER THAN ASSUMED: # # * THE THREADING MODEL IS ASSERTED ON THE TARGET, and it is the assertion this # port most needs. stunnel's configure takes FORK only as AX_PTHREAD's # FAILURE branch, and that macro fails here for a reason unrelated to threads # (brintos/glibc#5, __sigsetjmp). So the recipe pins --with-threads=fork and # stunnel/threading-fork reads `Threading:FORK' back out of the shipped # binary. A build that lost the flag would still start, still bind and still # accept -- and fail every connection, because PTHREAD mode spawns a thread # per accepted connection and pthread_create does not work here # (brintos/linux-in-the-browser#85). That regression is invisible to every # other case in this file except by the round trip going red with no cause. # # * `stunnel -version' EXITS 2, BY DESIGN (src/options.c:371 returns 2 for # -version, -help, -sockets and -options alike). So its output is captured # ONCE into $ver and the four configuration cases read that string, rather # than each running check_out -- which would fail on the exit status alone, # four times, for a binary that did exactly what upstream intended. require stunnel stunnel/present D=${TMPDIR:-/tmp}/bst-stunnel.$$ rm -rf "$D"; mkdir -p "$D" || { bst_notok stunnel/workdir "cannot create $D"; exit 0; } # Bound every server. Same reasoning as 88-socat-net.test: if neither busybox nor # coreutils supplies timeout(1), say so once and let the cases run under the # suite's own bound rather than skipping the ticket's whole criterion. # The servers outlive the client budget ON PURPOSE. TS is 180 s against a # handshake budget of at most TRIES x TC = 100 s: a server that died at 60 s # would take the later cases down with it and report the port as broken when # what expired was the harness. # # WHAT THESE BOUNDS ARE, AND WHAT THEY ARE NOT. timeout(1) bounds nothing on this # arm: it signals, and signal delivery does not work here at all -- `timeout 3 # sleep 30' exits 0 after 30 s (brintos/linux#21). So every command wrapped below # has to end BY ITSELF, and each of them does: stunnel exits on a config it # rejects, and `openssl s_client' without -quiet honours the `Q' this file feeds # it. The wrappers are kept because they cost nothing, are real on a native # control run, and become real again the day #21 closes. The ONE command that # could not end by itself is the echo criterion -- -quiet implies -ign_eof -- and # it no longer uses one; see the watchdog there. TS=""; TC=""; TB="" if command -v timeout >/dev/null 2>&1; then TS="timeout 180"; TC="timeout 10"; TB="timeout 15" else bst_notok stunnel/timeout-available "no timeout(1) on this image; server cases run unbounded under the suite bound alone" fi # A TCP listener has no filesystem artifact to wait on, so readiness is # established by retrying the CLIENT. The budget is deliberately small and the # per-attempt bound short -- 10 x 10 s is the whole of what a dead listener may # cost this file, and the echo case below reuses the handshake's success rather # than retrying again. TRIES=10 # gap -- a red that names its own cause where this platform has # one. An exec the kernel could not stage is not a stunnel defect and must not be # counted as one; anything else falls through to a plain red on purpose, because # that is what a regression in stunnel itself should look like and it must not be # absorbed by an attribution written for a different cause. gap() { if bst_execfail "$2"; then bst_unrun "$1" "$2"; else bst_notok "$1" "$(_bst_1line "$2")"; fi } # ---------------------------------------------------------------- the binary -- ver=$(stunnel -version 2>&1) case "$ver" in *"stunnel 5.72"*) bst_ok stunnel/version ;; *) gap stunnel/version "$ver" ;; esac case "$ver" in *Threading:FORK*) bst_ok stunnel/threading-fork ;; *Threading:PTHREAD*) bst_notok stunnel/threading-fork "built PTHREAD: the recipe's --with-threads=fork was lost. pthread_create does not work on this arm (brintos/linux-in-the-browser#85) and PTHREAD mode spawns one thread per accepted connection, so this binary accepts and then fails every connection" ;; *Threading:UCONTEXT*) bst_notok stunnel/threading-fork "built UCONTEXT: neither the recipe nor upstream's autodetect can select this, so the configure line was overridden by something unknown" ;; *) gap stunnel/threading-fork "$ver" ;; esac # libwrap is upstream's unconditional PACKAGECONFIG default and tcp-wrappers is # on this image static-only. Asserted rather than assumed: a PACKAGECONFIG that # quietly stopped resolving would drop the access-control layer with no other # symptom. case "$ver" in *Auth:LIBWRAP*) bst_ok stunnel/auth-libwrap ;; *) gap stunnel/auth-libwrap "$ver" ;; esac # The TLS feature line, read out of the same capture. ENGINE's ABSENCE is the # assertion: this image's openssl is a no-engine build, so if stunnel ever # reports it, the image has grown a dlopen path this kernel cannot serve. SNI's # presence is the opposite polarity and is the control -- it says the line was # actually produced and parsed, so "no ENGINE" means what it says rather than # "no line at all". tlsline=$(printf '%s\n' "$ver" | sed -n 's/.*\(TLS:[A-Za-z0-9,]*\).*/\1/p' | head -1) case "$tlsline" in "") bst_notok stunnel/tls-no-engine "stunnel -version printed no TLS: feature line at all" ;; *ENGINE*) bst_notok stunnel/tls-no-engine "$tlsline names ENGINE against a no-engine openssl" ;; *) bst_ok stunnel/tls-no-engine ;; esac case "$tlsline" in *SNI*) bst_ok stunnel/tls-sni ;; *) bst_notok stunnel/tls-sni "no SNI in '$tlsline'; OPENSSL_NO_TLSEXT would mean a crippled TLS build" ;; esac # ------------------------------------------------ the refusal half of the ask -- # The ticket's second criterion: a malformed config is REFUSED with a non-zero # exit, not started. stunnel reads the file itself, so this is not a shell # redirect and needs no staging. if [ -f "$BST_DATA/stunnel-bad.conf" ]; then bst_ok stunnel/fixture-bad-conf-shipped else bst_notok stunnel/fixture-bad-conf-shipped "$BST_DATA/stunnel-bad.conf is not on the image" fi badout=$($TB stunnel "$BST_DATA/stunnel-bad.conf" 2>&1); badrc=$? if [ "$badrc" -eq 0 ]; then bst_notok stunnel/bad-config-refused "exited 0 on a config carrying an unknown service option; it must refuse, not start" elif bst_execfail "$badout"; then bst_unrun stunnel/bad-config-refused "$badout" else bst_ok stunnel/bad-config-refused fi # A refusal that does not say what it refused is barely a refusal -- and an exit # status alone would also be satisfied by a crash, which is the outcome this case # separates out. case "$badout" in *"not valid here"*|*thisIsNotAnOption*) bst_ok stunnel/bad-config-names-option ;; *) bst_notok stunnel/bad-config-names-option "refused (rc=$badrc) but named neither the option nor a reason: $(_bst_1line "$badout")" ;; esac # ----------------------------------------------------- the round trip proper -- # Assert test -f on the SHIPPED payload, never test -x and never a path the suite # could have produced: a fixture check that passes against a hand-placed copy and # fails against every image that built the package inverts what it is for. if [ -f "$BST_DATA/stunnel-test.pem" ] && [ -f "$BST_DATA/stunnel-test.conf" ]; then bst_ok stunnel/fixture-shipped else bst_notok stunnel/fixture-shipped "stunnel-test.pem or stunnel-test.conf missing from $BST_DATA" fi cp "$BST_DATA/stunnel-test.pem" "$D/cert.pem" 2>/dev/null if [ -s "$D/cert.pem" ]; then bst_ok stunnel/stage-cert else bst_notok stunnel/stage-cert "cannot stage the certificate into $D"; fi # The rootfs is read-only at browser runtime, so the bundled config carries # @-placeholders and the writable paths are substituted in here. sed reads the # fixture as an ARGUMENT -- not a top-level stdin redirect. sed -e "s|@LOG@|$D/stunnel.log|" \ -e "s|@CERT@|$D/cert.pem|" \ -e "s|@ACCEPT@|22460|" \ -e "s|@CONNECT@|22461|" \ "$BST_DATA/stunnel-test.conf" > "$D/stunnel.conf" 2>"$D/sed.err" # Match the placeholder TOKENS, not a bare `@'. The fixture's own header # explains the mechanism and therefore contains the word "@-placeholders"; a # bare `@' test reds on that comment every run, which is a suite bug that looks # exactly like a substitution failure. if [ -s "$D/stunnel.conf" ] && ! grep -qE '@(LOG|CERT|ACCEPT|CONNECT)@' "$D/stunnel.conf"; then bst_ok stunnel/config-substituted else bst_notok stunnel/config-substituted "an @-placeholder survived substitution, or the config is empty: $(grep -oE '@(LOG|CERT|ACCEPT|CONNECT)@' "$D/stunnel.conf" 2>/dev/null | tr '\n' ' ')$(_bst_1line "$(cat "$D/sed.err" 2>/dev/null)")" fi # socat and the openssl CLI are both on this image; their ABSENCE is a finding # about the image rather than about stunnel, so it is reported as a red naming # that -- not as a skip, which would read as coverage. have_peers=1 command -v socat >/dev/null 2>&1 || { have_peers=0; bst_notok stunnel/peer-socat "socat is not on this image; the round trip cannot be driven"; } command -v openssl >/dev/null 2>&1 || { have_peers=0; bst_notok stunnel/peer-openssl "the openssl CLI is not on this image; the round trip cannot be driven"; } # 48000 bytes of fixed text: enough to span several TLS records, so a relay that # truncates at a record boundary is visible. ONE awk process, and LC_ALL=C, for # the reasons 88-socat-net.test records. No line begins with Q or R -- `openssl # s_client' reads those as its own quit/renegotiate commands, and a payload that # tripped one would fail for a reason that is not stunnel's. LC_ALL=C awk 'BEGIN{for(n=1;n<=800;n++) printf "%04d the quick brown fox jumps over the lazy dog 0123456789\n", n}' > "$D/payload" if [ "$have_peers" -eq 1 ]; then $TS socat TCP4-LISTEN:22461,bind=127.0.0.1,reuseaddr,fork EXEC:cat 2>"$D/socat.err" & bpid=$! $TS stunnel "$D/stunnel.conf" >"$D/stunnel.out" 2>&1 & spid=$! # The handshake probe doubles as the readiness wait for BOTH servers. `Q' on # stdin is s_client's own clean-quit command, so this closes the session # rather than being killed by the timeout. i=0; hrc=1 while [ $i -lt $TRIES ]; do if printf 'Q\n' | $TC openssl s_client -connect 127.0.0.1:22460 > "$D/hs.out" 2>&1; then hrc=0; break; fi i=$((i+1)); sleep 1 done slog=$(cat "$D/stunnel.log" "$D/stunnel.out" 2>/dev/null) case "$slog" in *"Configuration successful"*) bst_ok stunnel/config-accepted ;; *) gap stunnel/config-accepted "stunnel never reported a successful configuration -- $(_bst_1line "$slog")" ;; esac if [ "$hrc" -ne 0 ]; then hs="" gap stunnel/tls-handshake "no handshake completed on 127.0.0.1:22460 in $TRIES tries -- $(_bst_1line "$(cat "$D/hs.out" 2>/dev/null)") -- stunnel: $(_bst_1line "$slog")" else hs=$(cat "$D/hs.out" 2>/dev/null) bst_ok stunnel/tls-handshake fi # The negotiated protocol and cipher. openssl 3.5.7 and stunnel both default to # TLS 1.3 here, whose whole cipher list is three AEAD suites -- so this is # pinned to the SET rather than to one name: which of the three is chosen is # openssl's business and not a property of this port, while a TLS 1.2 suite # appearing here would mean the 1.3 handshake had silently fallen back. # s_client reports the negotiated version TWICE, and only one of the two is # dependable here. The `SSL-Session:' block carrying `Protocol : TLSv1.3' is # NOT printed when the session ends on the `Q' command, which is how this # probe closes -- measured against a native x86-64 stunnel 5.72 control, where # the block was absent and the case red with an empty value. The `New, , # Cipher is ' line is always printed on a new session, so that is the # primary form; the block is accepted as an alternative rather than dropped, # because a probe that ever does print it should not go red for succeeding. case "$hs" in "") bst_notok stunnel/tls-protocol "no handshake output to read a protocol out of" ;; *"New, TLSv1.3,"*|*"Protocol : TLSv1.3"*|*"Protocol : TLSv1.3"*) bst_ok stunnel/tls-protocol ;; *) bst_notok stunnel/tls-protocol "not TLSv1.3: $(printf '%s\n' "$hs" | sed -n -e 's/^New, /negotiated /p' -e 's/.*Protocol *: */protocol /p' | head -1)" ;; esac case "$hs" in "") bst_notok stunnel/tls-cipher "no handshake output to read a cipher out of" ;; *TLS_AES_256_GCM_SHA384*|*TLS_AES_128_GCM_SHA256*|*TLS_CHACHA20_POLY1305_SHA256*) bst_ok stunnel/tls-cipher ;; *) bst_notok stunnel/tls-cipher "no TLS 1.3 AEAD suite negotiated: $(printf '%s\n' "$hs" | sed -n 's/.*Cipher *: *//p' | head -1)" ;; esac # The peer certificate is the fixture's, which is what ties the handshake to # the BUNDLED cert rather than to some default stunnel found elsewhere. case "$hs" in *"CN = localhost"*|*"CN=localhost"*) bst_ok stunnel/tls-served-fixture-cert ;; "") bst_notok stunnel/tls-served-fixture-cert "no handshake output to read a subject out of" ;; *) bst_notok stunnel/tls-served-fixture-cert "the peer certificate is not the shipped fixture: $(printf '%s\n' "$hs" | sed -n 's/^subject=//p' | head -1)" ;; esac # THE CRITERION. One attempt only: the handshake above already established that # both servers are up, so a retry loop here would only multiply a real failure # by TRIES. # # -quiet keeps s_client's session banner off stdout, so stdout is the echoed # bytes and nothing else; its verify chatter goes to stderr. It also implies # -ign_eof, which means s_client does NOT close when the payload ends -- it # sits on the connection until something reaps it. -no_ign_eof is the obvious # alternative and is NOT taken: it shuts the connection down on stdin EOF, # which can cut off echo bytes still in flight and would turn a working relay # into a short read. # # SO THE REAP IS THIS CASE'S OWN PROBLEM, AND `timeout' CANNOT BE IT. This is # measured, not supposed: on two independent boots of the batch-1214 image # (2026-08-25 and 2026-08-26) all sixteen cases above scored green and this one # then sat in `cat payload | timeout 25 openssl s_client -quiet' until the # suite's own 600 s bound cut the file off -- reporting stunnel/suite-timeout # in place of the very criterion this suite exists for, both times, identically. # timeout(1) signals, and signal delivery does not work on this arm at all # (brintos/linux#21). # # The shape that does work is the runner's own watchdog, as 88-sshpass.test # applies it to the same defect: run in the background, poll a completion # sentinel against a monotonic clock, kill -9 on overrun. An explicit SIGKILL # does reap a syscall-blocked process here even where delivery to a handler # does not. # # THE SENTINEL IS THE BYTE COUNT rather than an exit status, and that is only # sound because s_client's echo path is raw_write_stdout() -- a bare write(2) # on fileno(stdout) (apps/lib/apps.c), not stdio. No userspace buffer holds the # tail back, so $D/echo.out reaches exactly $want the instant the last byte is # relayed and a kill -9 after that loses nothing. Were it buffered, a # 48000-byte payload would stall four short of a 4 kB block and never arrive. # It also makes the case END WHEN THE BYTES DO instead of always costing its # whole bound, which is where the old shape spent most of the native control's # 25 s. # # The rc is NOT the verdict -- the client is killed and so it never has one -- # the BYTES are. # # BRINTOS-WORKAROUND: brintos/linux#21 revisit -- this watchdog exists because # `timeout N cmd' bounds nothing here. When #21 closes it collapses back to one # `timeout 25 ...' line. Revisit rather than remove: ending on the byte count # is both faster and a sharper statement of the criterion than ending on a # reap, whether or not #21 is fixed. want=$(wc -c < "$D/payload") : > "$D/echo.out" ( cat "$D/payload" | openssl s_client -connect 127.0.0.1:22460 -quiet echo $? > "$D/.erc" ) > "$D/echo.out" 2>"$D/echo.err" & epid=$! # EB is the real bound and is read from /proc/uptime with builtins only, so # polling it costs no process. ETICKS is a backstop for a clock that is missing # or standing still, and IT IS CHECKED UNCONDITIONALLY -- 88-sshpass.test's # first shape consulted its ceiling only when the clock read FAILED, so a clock # that read fine and never advanced left the loop with no exit at all # (batch-664). It is set well above EB/3 on purpose: `sleep' does not actually # sleep on this runtime, so a tick is really the cost of its two spawns, and a # ceiling sized as if sleep worked would cut a healthy relay off early. EB=90; ETICKS=200 e0=""; et=0; egot=0 while [ "$et" -lt "$ETICKS" ]; do egot=$(wc -c < "$D/echo.out" 2>/dev/null) || egot=0 [ "${egot:-0}" -ge "$want" ] && break # The client exited on its own, so the count just read is FINAL and sitting # out the rest of EB would only delay a red -- a refused connection is the # ordinary way here. Tested with `[ -e ]' on a sentinel the subshell writes, # not with `kill -0 "$epid"': nothing reaps that subshell (`wait' never # returns on this arm, brintos/linux#74), so it lingers as a zombie and # kill -0 keeps saying "alive" long after it is not. [ -e "$D/.erc" ] && break if read -r _eup _erest < /proc/uptime 2>/dev/null; then _eup=${_eup%%.*} if [ -z "$e0" ]; then e0=$_eup elif [ $((_eup - e0)) -ge "$EB" ]; then break fi fi sleep 3 et=$((et + 1)) done # Reap before scoring. The client is still sitting on the connection BY DESIGN # (-ign_eof), so nothing here is a failure signal; it is just tidy-up, and it # matters because the next thing this file does is tear the servers down. # Swept by this suite's own port, so it can reach nothing but its own client. kill -9 "$epid" 2>/dev/null if command -v pgrep >/dev/null 2>&1; then for _ek in $(pgrep -f "s_client -connect 127.0.0.1:22460" 2>/dev/null); do kill -9 "$_ek" 2>/dev/null done fi if [ ! -s "$D/echo.out" ]; then gap stunnel/tls-echo-byte-exact "nothing came back through the tunnel in ${EB}s / $et polls -- $(_bst_1line "$(cat "$D/echo.err" 2>/dev/null)") -- socat: $(_bst_1line "$(cat "$D/socat.err" 2>/dev/null)")" elif cmp -s "$D/payload" "$D/echo.out"; then bst_ok stunnel/tls-echo-byte-exact else gap stunnel/tls-echo-byte-exact "$want bytes in, $(wc -c < "$D/echo.out") out after ${EB}s / $et polls -- $(_bst_1line "$(cat "$D/echo.err" 2>/dev/null)")" fi # kill -9, not kill: SIGTERM never arrives (brintos/linux#21), so a plain kill # leaves stunnel and socat holding 22460 and 22461 for the rest of the boot and # the next run of this suite scores a bind failure it did not cause. The sweep # is the same point one level down -- $spid and $bpid are the `timeout' # wrappers, and SIGKILLing those orphans the servers underneath them. kill -9 $spid $bpid 2>/dev/null if command -v pgrep >/dev/null 2>&1; then for _ek in $(pgrep -f "$D/stunnel.conf" 2>/dev/null) \ $(pgrep -f "TCP4-LISTEN:22461" 2>/dev/null); do kill -9 "$_ek" 2>/dev/null done fi fi rm -rf "$D"