. "$BST_LIB" # BST-TIMEOUT: 300 # vsftpd 3.0.5 (meta-networking recipes-daemons/vsftpd) -- the FTP DAEMON, and # the third FTP server on this image after proftpd and pure-ftpd. # # THIS IS THE FIRST ftpd ON THIS IMAGE THAT CARRIES A WHOLE TRANSFER, and the # reason is a property of vsftpd's own default security model rather than of # anything this port switched off. Five facts shape the file. # # 1. THE SESSION IS DRIVEN UNDER inetd, WHICH IS vsftpd's OWN NON-STANDALONE # ARM. With listen=NO, main() falls straight past vsf_standalone_main() and # do_sanity_checks() requires fd 0 to be a socket -- i.e. the super-server has # already accepted the connection -- so the whole session runs in the process # inetd exec'd, with no fork of its own. # # 2. run_as_launching_user=YES IS WHAT AVOIDS BOTH WALLS THE OTHER TWO SERVERS # HIT, AND IT IS UPSTREAM'S OPTION, NOT A TEST FIXTURE. main.c sets # tunable_one_process_model from it, so vsf_one_process_start() runs prelogin, # login and postlogin in this same process; and one_process_start() then takes # the arm that calls str_chdir() and NOTHING else -- the branch holding # vsf_secutil_change_credentials(), the only function there that would set a # uid, gid or supplementary set, is on the other side of the if. So: # * brintos/linux#19 (setuid/setgid/setgroups are ENOSYS) is never reached, # which is why this port carries no credential patch where pure-ftpd # needed one; and # * brintos/hwjs-cc#80 (a forked child cannot move bytes on the socket it # inherited) is never reached either, because nothing forks. # Both are asserted below rather than left as claims: the session cases are # the positive half, and vsftpd/standalone-serves drives the forking arm on # purpose so #80 is measured on a third package. # # 3. THE GREETING IS ALSO THIS PORT'S rlimit WITNESS. main() -> limits_init() is # one setrlimit(RLIMIT_AS) whose only failure arm is die("setrlimit"), and # arch/wasm32 wires no resource-limit syscall at all (brintos/linux#20), so an # UNPATCHED vsftpd exits before writing its 220 -- in every process model, on # every invocation. Patch 0001 accepts ENOSYS at that one site. There is no # separate case for it because there is nothing separate to measure: a red on # vsftpd/greeting IS that patch failing, and the failure text says so. # # 4. THE SERVER IS ANONYMOUS-ONLY AND READ-ONLY, which is upstream's default and # the ticket's own criterion. That gives the suite its two negative controls # for free, and both are load-bearing: vsftpd/local-refused says a REAL # account of this image is turned away (without it, vsftpd/pass would pass # equally against a build that had stopped checking anything), and # vsftpd/stor-refused says write_enable=NO is honoured. # # 5. NOTHING HERE WAITS ON A SIGNAL. Signal delivery is unreliable # (brintos/linux#21), so every bound is an iteration count or a tcl [after], # and the client reads every reply non-blocking through the event loop. # # The expected code for every step was read off a DRY RUN against a native build # of this identical release (3.0.5, non-PAM, same configuration), not predicted: # that is where 530-then-503 for the refused local account, 550 for the refused # STOR, and the 8400-byte fixture length come from. # # The ticket offered tnftp or socat as the driver. Neither is used: tnftp is # BRINTOS-678, landing in this same round, and the ticket itself asks that the # suite not depend on it; socat cannot open FTP's second connection from a shell # script without the suite losing the ability to tell a bad control reply from a # data channel that never opened. data/vsftpd-client.tcl does both and bounds # every read, which is the same choice 89-pure-ftpd.test made for the same # reason. # BRINTOS-1149: brintos_local_verify.py loses a suite's FIRST BST result line # before the host parser sees it, and counts the dropped line as a pass. So the # first line out of this file asserts something already true. It hides nothing -- # the harness still reports the deficit and every real case below is still # counted. Same idiom, and the same reason, as 82-double-conversion and # 56-snappy. Remove this case when 1149 is fixed. bst_ok vsftpd/suite-canary require /usr/sbin/vsftpd vsftpd/present VS=/usr/sbin/vsftpd INETD=/usr/bin/inetd.inetutils CLIENT="$BST_DATA/vsftpd-client.tcl" PORT=8941 SPORT=8942 USER_=anonymous PW=bst@brintos.test D=/tmp/.bst-vsftpd rm -rf "$D"; mkdir -p "$D/root" || { bst_notok vsftpd/workdir "cannot create $D"; exit 0; } # ~8 KB of fixed text, so a short transfer is visible while staying affordable. # Byte-identical to the fixture the native dry run used, which is where R11-size # and vsftpd/retr-bytes get their 8400. awk 'BEGIN{for(n=1;n<=140;n++) printf "%04d the quick brown fox jumps over the lazy dog 0123456789\n", n}' > "$D/root/payload" chmod 644 "$D/root/payload" chmod 755 "$D" "$D/root" # --- identity ----------------------------------------------------------------- check vsftpd/binary test -x "$VS" check vsftpd/stock-conf test -f /etc/vsftpd.conf check vsftpd/init-script test -x /etc/init.d/vsftpd # The recipe's USERADD_PARAM creates the `ftp' account, and it is not cosmetic: # session_init() looks tunable_ftp_username up with getpwnam(3) and die2()s if it # is not there, so an image whose useradd did not take has a vsftpd that cannot # start at all. Asserted before the server is run, so that failure is reported as # what it is rather than as a dead session. if id ftp >/dev/null 2>&1; then bst_ok vsftpd/ftp-account else bst_notok vsftpd/ftp-account "the 'ftp' account the recipe's USERADD_PARAM creates does not resolve, and session_init() die2()s without it"; fi # `vsftpd -v' WRITES NOTHING on this release -- measured on a native build, not # assumed -- so the version is read from the banner instead, at vsftpd/version # below. This case pins the silence, so a future release that starts answering # -v is noticed rather than silently leaving vsftpd/version as the only witness. out=$("$VS" -v 2>&1) if [ -z "$out" ]; then bst_ok vsftpd/dash-v-silent else bst_notok vsftpd/dash-v-silent "vsftpd -v now writes something ('$(_bst_1line "$out")'); read the version from here instead of from the banner"; fi # --- the configuration both arms are driven with ------------------------------ # Written out rather than passed as -o flags so the two servers below are # demonstrably the same server but for listen/listen_port, which is what makes # vsftpd/standalone-serves a measurement of the fork and not of the config. cat > "$D/common.conf" <> "$D/inetd.conf" cp "$D/common.conf" "$D/alone.conf" printf 'listen=YES\nlisten_address=127.0.0.1\nlisten_port=%s\npasv_min_port=30400\npasv_max_port=30420\n' "$SPORT" >> "$D/alone.conf" net_skip() { for c in inetd-registered connect greeting version syst-preauth user pass \ local-refused cwd pwd type list list-final list-names size \ retr-open retr-final retr-bytes retr-bytewise stor-refused nak quit \ standalone-startup standalone-serves; do bst_skip "vsftpd/$c" "$1" done } if ! command -v tclsh >/dev/null 2>&1; then net_skip "tclsh not installed; the FTP data connection needs a real client" elif [ ! -x "$INETD" ]; then net_skip "inetutils' inetd is not installed, and fact 1 says the session is driven under it" else # --- fact 2's second half: the forking arm, asserted as far as it gets ------ "$VS" "$D/alone.conf" > "$D/alone.log" 2>&1 & alone_pid=$! # The loop costs one greeting bound at most: a daemon that is not listening # yet refuses the connect immediately, and the first connect that SUCCEEDS # ends the loop whether or not a banner follows -- which is the point, because # under hwjs-cc#80 the banner may never come. i=0 while [ $i -lt 20 ]; do i=$((i+1)) tclsh "$CLIENT" "$SPORT" x x x x x x greet > "$D/probe.out" 2>&1 grep -q '^R0-connect 1' "$D/probe.out" 2>/dev/null && break sleep 1 done if grep -q '^R0-connect 1' "$D/probe.out" 2>/dev/null; then bst_ok vsftpd/standalone-startup else bst_notok vsftpd/standalone-startup "the standalone daemon did not accept on $SPORT after $i polls. If it never started, brintos/linux#20 is the first thing to read -- limits_init()'s setrlimit(RLIMIT_AS) is ENOSYS here and patch 0001 is what lets it past: $(_bst_1line "$(cat "$D/alone.log" 2>/dev/null)")" fi # WHETHER THE FORKED CHILD CAN TALK IS THE INTERESTING HALF. proftpd's could # not and pure-ftpd's could not (brintos/hwjs-cc#80): each logged a complete # session open, wrote its banner, and the client received nothing. vsftpd's # standalone arm forks the same way, so this is the third witness either way # -- a red repeats #80 on a third package, a green says #80 is narrower than # two packages' evidence suggested and the inetd arm below was not necessary. if grep -q '^R1-greeting 220' "$D/probe.out" 2>/dev/null; then bst_ok vsftpd/standalone-serves else bst_notok vsftpd/standalone-serves "brintos/hwjs-cc#80: the standalone daemon accepts and forks, and the client gets no banner from the child. Under inetd the same binary serves the whole session; see the cases below. client=[$(_bst_1line "$(cat "$D/probe.out" 2>/dev/null)")] daemon=[$(tail -3 "$D/alone.log" 2>/dev/null | tr '\n' '|')]" fi kill "$alone_pid" 2>/dev/null # --- the session, under inetd ----------------------------------------------- printf '127.0.0.1:%s stream tcp nowait root %s vsftpd %s\n' \ "$PORT" "$VS" "$D/inetd.conf" > "$D/inetd.tab" "$INETD" -d "$D/inetd.tab" > "$D/inetd.log" 2>&1 & inetd_pid=$! i=0 while [ $i -lt 40 ]; do grep -q 'registered ' "$D/inetd.log" 2>/dev/null && break i=$((i+1)); sleep 1 done if grep -q 'registered ' "$D/inetd.log" 2>/dev/null; then bst_ok vsftpd/inetd-registered else bst_notok vsftpd/inetd-registered "inetd bound no service after $i polls: $(_bst_1line "$(cat "$D/inetd.log" 2>/dev/null)")" fi OUT=$(tclsh "$CLIENT" "$PORT" "$USER_" "$PW" "$D/root" \ payload "$D/got" uploaded 2>&1) val() { printf '%s\n' "$OUT" | awk -v k="$1" '$1==k{print $2; exit}'; } want() { # want _v=$(val "$2") if [ "$_v" = "$3" ]; then bst_ok "vsftpd/$1" elif [ -z "$_v" ]; then bst_notok "vsftpd/$1" "client printed no $2 line: $(_bst_1line "$OUT")" else bst_notok "vsftpd/$1" "$2=$_v want $3: $(_bst_1line "$OUT")"; fi } want connect R0-connect 1 # A red here is where brintos/linux#20 lands: an unpatched vsftpd dies in # limits_init() on an ENOSYS setrlimit before it writes a single byte, so the # connection is accepted by inetd and then closed with no greeting at all. want greeting R1-greeting 220 # The build's own account of itself, and the only one it gives: see # vsftpd/dash-v-silent above. banner=$(printf '%s\n' "$OUT" | sed -n 's/^R1-text //p' | head -1) case "$banner" in *"vsFTPd 3.0.5"*) bst_ok vsftpd/version ;; *) bst_notok vsftpd/version "the greeting does not name vsFTPd 3.0.5, so this is a different build than the port measured: '$(_bst_1line "$banner")'" ;; esac # vsftpd requires a login even for SYST -- measured, not assumed. It is here as # a cheap witness that the COMMAND PARSER is running, rather than that a banner # was written by a process which then stopped: a 530 is a considered answer, # and silence or a dropped connection is not. want syst-preauth R1b-syst 530 want user R2-user 331 want pass R3-pass 230 # Fact 4's first negative control, and it reads the USER reply rather than the # PASS reply: vsftpd answers `530 This FTP server is anonymous only.' to USER # root outright, and the following PASS is then a bad-sequence 503 that says # nothing about authentication. want local-refused R4-local-user 530 want cwd R5-cwd 250 want pwd R6-pwd "$D/root" want type R7-type 200 want list-final R8-final 226 want list-names R8-names 1 want retr-open R9-open 150 want retr-final R9-final 226 # Fact 4's second negative control. want stor-refused R10-stor 550 want nak R12-nak 550 want quit R13-quit 221 # A listing that transferred nothing is not a listing. n=$(val R8-list) if [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then bst_ok vsftpd/list else bst_notok vsftpd/list "LIST moved ${n:-no} bytes"; fi want_sz=$(wc -c < "$D/root/payload" | tr -d ' ') # SIZE is the server's own statement of the length, read over the CONTROL # channel before the transfer, so it is independent evidence from the byte # count the data channel produced below. if [ "$(val R11-size)" = "$want_sz" ]; then bst_ok vsftpd/size else bst_notok vsftpd/size "SIZE said $(val R11-size), want $want_sz"; fi # THE TICKET'S NAMED CRITERION, in two cases deliberately. A transfer that # "succeeds" having moved the wrong bytes is the failure a length check cannot # see, so the length and the content are separate findings. got_sz=$(val R9-retr) if [ "$got_sz" = "$want_sz" ]; then bst_ok vsftpd/retr-bytes else bst_notok vsftpd/retr-bytes "RETR moved ${got_sz:-no} bytes of $want_sz"; fi if cmp -s "$D/root/payload" "$D/got" 2>/dev/null; then bst_ok vsftpd/retr-bytewise else bst_notok vsftpd/retr-bytewise "the retrieved copy differs from the source ($(wc -c < "$D/got" 2>/dev/null | tr -d ' ') bytes on disk)"; fi # write_enable=NO is asserted twice, and the second half is the one that # matters: a 550 on the control channel is worth little if the file landed # anyway. if [ ! -e "$D/root/uploaded" ]; then bst_ok vsftpd/stor-landed-nothing else bst_notok vsftpd/stor-landed-nothing "STOR was refused on the control channel and the file appeared anyway ($(wc -c < "$D/root/uploaded" 2>/dev/null | tr -d ' ') bytes), so write_enable=NO is not being honoured"; fi kill "$inetd_pid" 2>/dev/null fi # --- what this image does not carry ------------------------------------------- bst_skip vsftpd/man-pages "vsftpd.8 and vsftpd.conf.5 are packaged into vsftpd-doc, and this image installs no -doc packages" rm -rf "$D"