. "$BST_LIB" # BST-TIMEOUT: 600 # dnsmasq 2.90 -- Simon Kelley's lightweight DNS forwarder, DHCP/DHCPv6 server # and TFTP server (meta-networking recipes-support/dnsmasq). # # IT INSTALLS TO /usr/bin, NOT /usr/sbin, AND THAT IS THE RECIPE'S DOING. # Upstream's Makefile sets BINDIR = $(PREFIX)/sbin; the OE recipe overrides it # with BINDIR=${D}${bindir} on the do_install make line, so the binary lands at # /usr/bin/dnsmasq. Worth stating because it is the exact opposite of the # surprise 88-fping documents (a package whose upstream sbin_PROGRAMS survives), # and a suite written from either habit alone would `require` the wrong path. # # THE SERVER IS ALWAYS RUN -d (--no-daemon), AND THAT IS A PLATFORM FACT RATHER # THAN A CONVENIENCE. src/dnsmasq.c wraps its whole credential block in # `if (!option_bool(OPT_DEBUG) && getuid() == 0)`, so -d is the only mode that # issues no credential syscall at all. Every other mode reaches # setgroups(2)/setgid(2) unconditionally: that block runs whenever `gp` is # non-NULL, and gp defaults to getgrnam(CHGRP) -- CHGRP is "dip", which this # image's base-passwd /etc/group does define (gid 30). No option clears it: # --group=root is still a real setgid(0), and --user=root only skips the LATER # setuid, which is guarded by `ent_pw->pw_uid != 0`. dnsmasq/daemonise scores # that wall rather than avoiding it. # # THE CRITERION IS ASSERTED ON THE WIRE, NOT THROUGH getaddrinfo(3). A query # built here byte by byte and sent with socat is a statement about dnsmasq and # nothing else; routing it through the C library would make the case depend on # an NSS path that is independently broken on this image (see # dnsmasq/resolve-via-getaddrinfo). The two C-library paths that DO work, # gethostbyname(3) and getnameinfo(3), each get a case of their own -- so the # suite says exactly which resolver entry points reach this server. # # OFFLINE BY CONSTRUCTION. no-resolv + no-hosts means there is no upstream # server and no /etc/hosts behind any answer, so a reply can only have come from # this dnsmasq's own configuration -- which the log-provenance case then proves # directly. Port 53 on 127.0.0.1: we are root, the privileged bind is free, and # it is the port a real dnsmasq uses. require /usr/bin/dnsmasq dnsmasq/present DM=/usr/bin/dnsmasq D=${TMPDIR:-/tmp}/bst-dnsmasq.$$ rm -rf "$D"; mkdir -p "$D" || { bst_notok dnsmasq/workdir "cannot create $D"; exit 0; } NAME=bst-dnsmasq.test ADDR=10.11.12.13 # 0a0b0c0d on the wire WILD=10.11.12.14 # 0a0b0c0e on the wire # NOTHING ON THIS IMAGE CAN BE KILLED (brintos/linux#21: kill(2) reports success # and delivers nothing, SIGKILL included), so `timeout` cannot stop a command # that has blocked -- a `timeout 15 nc -u ...` measured here ran for the suite's # entire 600 s bound. Two rules follow and both are load-bearing: never wait(1) # on a child that may not exit, and reach for socat rather than netcat, whose # UDP client blocks on a read with no bound of its own. socat's -T ends the # relay from the inside, which is why it is the transport below. db() { _t=$1; shift; out=$(timeout "$_t" "$@" 2>&1); rc=$?; } # has ... : true iff EVERY needle is a substring. has() { _h=$1; shift for _n in "$@"; do case "$_h" in *"$_n"*) ;; *) return 1 ;; esac; done return 0 } # wire : send one DNS datagram to 127.0.0.1:53 and set $rhex to the # reply as a flat hex string ("" if nothing came back). wire() { rm -f "$D/r.bin" timeout 20 socat -t 2 -T 2 STDIO UDP4:127.0.0.1:53 < "$1" > "$D/r.bin" 2>"$D/r.err" rhex=$(od -An -tx1 "$D/r.bin" 2>/dev/null | tr -d ' \n') } # ---------------------------------------------------------------- inventory -- check_eq dnsmasq/inventory "yes" "$([ -x "$DM" ] && echo yes)" # The bare name a user types must be this binary; `command -v` reads root's # actual PATH, so this also catches an image on which /usr/bin lost its place. check_eq dnsmasq/on-path "$DM" "$(command -v dnsmasq 2>/dev/null)" # PACKAGECONFIG carries dhcp and dhcp6, and the recipe installs the three # contrib/lease-tools helpers only under those arms. They are the visible half # of a configure decision that is otherwise only a -D flag, so a flipped arm # shows up here rather than as a silent change in what the package contains. for t in dhcp_release dhcp_lease_time dhcp_release6; do check_eq "dnsmasq/lease-tool-$t" "/usr/bin/$t" "$(command -v "$t" 2>/dev/null)" done # CONFFILES:${PN} and the update-rc.d inherit. The init script is installed and # registered but nothing starts it -- this image's inittab runs only the tmpfs # mount and run-postinsts, never an rc runlevel -- which is what lets # dnsmasq/test-started-nothing below be a statement about --test. check dnsmasq/conffile-shipped test -f /etc/dnsmasq.conf check dnsmasq/initscript-shipped test -x /etc/init.d/dnsmasq # The recipe's do_install writes ${systemd_unitdir}/system/dnsmasq.service # UNCONDITIONALLY; what removes it again is systemd.bbclass's # rm_systemd_unitdir, which fires because DISTRO_FEATURES has no systemd. If # that stopped firing the file would be installed-but-unpackaged and the build # would fail QA -- so this is the on-image half of a build-side invariant. check_eq dnsmasq/no-systemd-unitdir "" "$(ls -d /lib/systemd 2>/dev/null)" # ----------------------------------------------------------- the binary runs -- db 30 "$DM" --version check_eq dnsmasq/version-rc "0" "$rc" case "$out" in *"Dnsmasq version 2.90"*) bst_ok dnsmasq/version-is-2.90 ;; *) bst_notok dnsmasq/version-is-2.90 "want 'Dnsmasq version 2.90', got '$(_bst_1line "$out")'" ;; esac # --version also prints compile_opts (src/config.h), which is generated from the # preprocessor state of the build that produced THIS binary -- so it reads the # actual PACKAGECONFIG outcome rather than a claim about it. The recipe default # is "auth dhcp dhcp6 dumpfile inotify ipset loop script tftp"; the negatives # are asserted alongside the positives because a package gaining DNSSEC # (nettle), DBus or nftset would be a new target dependency arriving unannounced. if has "$out" "DHCP " "DHCPv6 " "TFTP " "auth " "ipset " "loop-detect " "inotify " "dumpfile" \ "no-DBus" "no-UBus" "no-Lua" "no-DNSSEC" "no-nftset" "no-conntrack"; then bst_ok dnsmasq/compile-options else bst_notok dnsmasq/compile-options "PACKAGECONFIG arms not as expected -- $(_bst_1line "$out")" fi # --help is answered inside read_opts, before any socket or credential work, so # a red here separates "the binary is broken" from every runtime case below. db 30 "$DM" --help check_eq dnsmasq/help-rc "0" "$rc" case "$out" in *"Usage: dnsmasq [options]"*) bst_ok dnsmasq/help-text ;; *) bst_notok dnsmasq/help-text "usage text not as expected -- $(_bst_1line "$out")" ;; esac # --- THE TICKET'S NAMED CRITERION, first half: --test ------------------------ # "dnsmasq --test validates a configuration file and answers with no daemon # started at all." The answer goes to STDERR, not stdout: option.c's testmode # arm is fprintf(stderr, "dnsmasq: %s.\n", _("syntax check OK")) then exit(0). # It fires at the very end of read_opts -- after every option is parsed and # cross-validated -- and before main() opens a socket, forks, or touches a # credential. cat > "$D/good.conf" < "$D/bad.conf" db 30 "$DM" --test -C "$D/bad.conf" if [ "$rc" -eq 124 ]; then bst_notok dnsmasq/test-rejects-bad-conf "hung on a bad config -- $(_bst_1line "$out")" elif [ "$rc" -eq 0 ]; then bst_notok dnsmasq/test-rejects-bad-conf "accepted 999.999.999.999 as a listen-address -- $(_bst_1line "$out")" elif [ "$rc" -eq 1 ] && has "$out" "at line 2 of"; then bst_ok dnsmasq/test-rejects-bad-conf else bst_notok dnsmasq/test-rejects-bad-conf "want the documented exit 1 naming line 2, got rc=$rc -- $(_bst_1line "$out")" fi # The config the PACKAGE ships is itself parseable on this image -- 11 kB of # mostly-commented defaults that nothing else in the pipeline ever reads (the # init script would, if this image ran rc scripts). A red here would mean # /etc/dnsmasq.conf names something this build cannot do. db 30 "$DM" --test -C /etc/dnsmasq.conf check_eq dnsmasq/test-shipped-conf "0" "$rc" # --- THE TICKET'S NAMED CRITERION, second half: serve and resolve ------------ # Readiness comes from dnsmasq's own "started, version" line, not a fixed sleep: # it is logged after the listening sockets are bound, so no query can race the # bind. NOT wrapped in `timeout` -- see the kill(2) note at the top; a wrapper # would only add a process that equally cannot be stopped. "$DM" -d -C "$D/good.conf" > "$D/dnsmasq.log" 2>&1 & dpid=$! i=0; up=0 while [ $i -lt 40 ]; do if grep -q "started, version" "$D/dnsmasq.log" 2>/dev/null; then up=1; break; fi kill -0 "$dpid" 2>/dev/null || break i=$((i+1)); sleep 1 done if [ "$up" -eq 1 ]; then bst_ok dnsmasq/starts else bst_notok dnsmasq/starts "no 'started, version' after ${i}s -- $(_bst_1line "$(cat "$D/dnsmasq.log" 2>/dev/null)")"; fi # With no --server and no /etc/resolv.conf to read, dnsmasq has nowhere to # forward and says so. The line is the proof that every answer below is LOCAL. if grep -q "no upstream servers configured" "$D/dnsmasq.log" 2>/dev/null; then bst_ok dnsmasq/offline-no-upstream else bst_notok dnsmasq/offline-no-upstream "expected the 'no upstream servers configured' warning -- $(_bst_1line "$(cat "$D/dnsmasq.log" 2>/dev/null)")" fi # bind-interfaces + listen-address means one socket on 127.0.0.1 rather than a # wildcard bind filtered by IP_PKTINFO -- which is also the arm that skips the # IP_PKTINFO setsockopt entirely (src/network.c guards it with !OPT_NOWILD). # Read back from the kernel: ss is iproute2's in /sbin, netstat net-tools' in # /bin, and either answers. LST="" if command -v ss >/dev/null 2>&1; then LST=$(timeout 20 ss -lun 2>/dev/null) elif command -v netstat >/dev/null 2>&1; then LST=$(timeout 20 netstat -lun 2>/dev/null); fi if [ -z "$LST" ]; then bst_notok dnsmasq/listens-loopback-only "neither ss nor netstat listed any UDP listener" elif has "$LST" "127.0.0.1:53"; then case "$LST" in *"0.0.0.0:53"*) bst_notok dnsmasq/listens-loopback-only "bound the wildcard address as well as loopback -- $(_bst_1line "$LST")" ;; *) bst_ok dnsmasq/listens-loopback-only ;; esac else bst_notok dnsmasq/listens-loopback-only "no 127.0.0.1:53 UDP listener -- $(_bst_1line "$LST")" fi # THE CRITERION, on the wire. A hand-built query: id 0x1234, RD set, QDCOUNT 1, # QNAME 0b"bst-dnsmasq" 04"test" 00, QTYPE A, QCLASS IN. Octal escapes, not # \xNN, because \x is a bashism the target's printf need not honour. if ! command -v socat >/dev/null 2>&1; then bst_notok dnsmasq/resolve-on-the-wire "socat is not on this image, so the criterion could not be put on the wire" else printf '\022\064\001\000\000\001\000\000\000\000\000\000\013bst-dnsmasq\004test\000\000\001\000\001' > "$D/q-a.bin" wire "$D/q-a.bin" # 0a0b0c0d is $ADDR as A-record RDATA; ANCOUNT is bytes 6..7 and must not be # zero, so a reply that merely echoes the question cannot pass. if [ -z "$rhex" ]; then bst_notok dnsmasq/resolve-on-the-wire "no reply from 127.0.0.1:53 -- $(_bst_1line "$(cat "$D/r.err" 2>/dev/null)")" elif has "$rhex" "0a0b0c0d" && ! has "$rhex" "12348580000000000"; then bst_ok dnsmasq/resolve-on-the-wire else bst_notok dnsmasq/resolve-on-the-wire "want the A record 0a0b0c0d for $NAME, got $(_bst_1line "$rhex")" fi # --address=/wild.test/ is a DOMAIN match rather than a host entry: every name # under it answers, including one written down nowhere. Different lookup path # from the host-record hash, so it gets its own query. QNAME 01"x" 04"wild" # 04"test" 00. printf '\022\065\001\000\000\001\000\000\000\000\000\000\001x\004wild\004test\000\000\001\000\001' > "$D/q-w.bin" wire "$D/q-w.bin" if has "$rhex" "0a0b0c0e"; then bst_ok dnsmasq/wildcard-on-the-wire else bst_notok dnsmasq/wildcard-on-the-wire "want the A record 0a0b0c0e under wild.test, got $(_bst_1line "$rhex")"; fi # The complement, and what keeps every green above meaningful: a name this # server was never told about must not come back as a configured address. The # RCODE is deliberately NOT asserted -- with no upstream, what dnsmasq says # about an unknown name is its own business, and a silent drop is not a WRONG # answer. Only handing out $ADDR/$WILD would be, and that is what is scored. printf '\022\066\001\000\000\001\000\000\000\000\000\000\004nope\004test\000\000\001\000\001' > "$D/q-n.bin" wire "$D/q-n.bin" if has "$rhex" "0a0b0c0d" || has "$rhex" "0a0b0c0e"; then bst_notok dnsmasq/unknown-name-not-answered "an unconfigured name got a configured address -- $(_bst_1line "$rhex")" else bst_ok dnsmasq/unknown-name-not-answered fi fi # PROVENANCE, and the reason --log-queries is in the config. An answer alone # cannot say WHO answered; dnsmasq's own log can. The literal is "config", not # "reply": src/cache.c's log_query picks the source word from the answer flags, # and F_CONFIG (a --host-record hit) prints "config" while "reply" is reserved # for something an upstream server sent. Asserting "reply" would have been a red # that reads like a lost packet. if has "$(cat "$D/dnsmasq.log" 2>/dev/null)" "query[A] $NAME from 127.0.0.1" "config $NAME is $ADDR"; then bst_ok dnsmasq/answered-from-config else bst_notok dnsmasq/answered-from-config "log carries no query/config pair for $NAME -- $(_bst_1line "$(grep -i "$NAME" "$D/dnsmasq.log" 2>/dev/null)")" fi # --- the three C-library resolver entry points, which do not agree here ------ # gethostbyname(3) WORKS. wget is built --disable-ipv6, and lookup_host()'s # #ifndef ENABLE_IPV6 branch resolves a NAME with gethostbyname rather than # getaddrinfo -- so this is the one ordinary client on the image that reaches a # DNS server through libc. The connect must fail (nothing listens on # 10.11.12.13:80, and the guest has only lo), so rc 4 with the resolved address # echoed is the pass: it proves resolution happened and nothing beyond it. db 40 wget --spider --tries=1 --timeout=3 "http://$NAME/" if has "$out" "Resolving $NAME... $ADDR"; then bst_ok dnsmasq/resolve-via-gethostbyname else bst_notok dnsmasq/resolve-via-gethostbyname "gethostbyname did not resolve $NAME to $ADDR, rc=$rc -- $(_bst_1line "$out")"; fi # getnameinfo(3) WORKS TOO, and --host-record adds the PTR alongside the A, so # the reverse direction is part of the same one-line entry. busybox's nslookup # runs getnameinfo on every address it prints, so a numeric argument exercises # it. db 40 nslookup "$ADDR" if [ "$rc" -eq 124 ]; then bst_notok dnsmasq/reverse-ptr "nslookup hung -- $(_bst_1line "$out")" elif has "$out" "$NAME"; then bst_ok dnsmasq/reverse-ptr else bst_notok dnsmasq/reverse-ptr "want the PTR $NAME for $ADDR, rc=$rc -- $(_bst_1line "$out")" fi # getaddrinfo(3) DOES NOT, AND THAT IS THIS PORT'S FINDING. Same process image, # same name, same server that the two calls above just reached: getaddrinfo # returns EAI_AGAIN and dnsmasq's log shows NO query arrived -- the request # never leaves libc. It is not the absence of /etc/resolv.conf (writing one # changes nothing) and not getaddrinfo generally (it resolves /etc/hosts names # through the `files` module fine). The consequence for a user is that dnsmasq # serves this machine correctly while most of the machine cannot ask it. db 40 nslookup "$NAME" if [ "$rc" -eq 0 ] && has "$out" "$ADDR"; then bst_ok dnsmasq/resolve-via-getaddrinfo elif has "$out" "can't resolve"; then bst_notok dnsmasq/resolve-via-getaddrinfo "linux-in-the-browser#96 (getaddrinfo's dns leg sends no query): the same name resolves via gethostbyname and getnameinfo above -- $(_bst_1line "$out")" else bst_notok dnsmasq/resolve-via-getaddrinfo "rc=$rc -- $(_bst_1line "$out")" fi # --- stopping it, and the daemonising path ----------------------------------- # SIGTERM is dnsmasq's documented shutdown. It does not arrive: this kernel # delivers no signal at all, SIGKILL included. Asserted rather than skipped # because "the server cannot be stopped" is a real property of running dnsmasq # on this image, and the case flips green by itself when #21 closes. NEVER # wait(1) on $dpid afterwards -- it would block until the suite bound. kill -TERM "$dpid" 2>/dev/null i=0 while [ $i -lt 5 ] && kill -0 "$dpid" 2>/dev/null; do i=$((i+1)); sleep 1; done if kill -0 "$dpid" 2>/dev/null; then kill -KILL "$dpid" 2>/dev/null sleep 1 if kill -0 "$dpid" 2>/dev/null; then bst_notok dnsmasq/sigterm-stops "brintos/linux#21 (signal delivery does not work at all): dnsmasq survived SIGTERM for ${i}s and then SIGKILL, so no signal reached it" else bst_notok dnsmasq/sigterm-stops "ignored SIGTERM for ${i}s but died on SIGKILL -- that is dnsmasq's handler, not the kernel" fi else bst_ok dnsmasq/sigterm-stops fi # THE OTHER EXPECTED RED, and the reason every case above passes -d. Same # config, no -d, and a pid-file under /tmp so the probe cannot stop EARLIER for # a reason of our own making: dnsmasq writes RUNFILE (/run/dnsmasq.pid) after # forking and before the credential block, and a read-only /run would be # EVENT_PIDFILE/EC_FILE -- our wall, not the kernel's, and one that would hide # the finding this case exists for. # # CORROBORATION, GATHERED BUT NOT SCORED. busybox su reaches the same gap # through a different libc entry point (initgroups(3) in # libbb/change_identity.c, which dies "can't set groups"). Running it costs # nothing and puts independent evidence in the red's own detail, rather than # spending a second case on one cause. db 30 su -s /bin/sh -c 'exit 0' nobody < /dev/null case "$rc:$out" in 0:*) su_note="; busybox su changed identity fine" ;; *"can't set groups"*) su_note="; busybox su initgroups ENOSYS too" ;; *) su_note="" ;; esac sed -e 's,^log-facility=-$,log-facility='"$D"'/daemon.log,' "$D/good.conf" > "$D/daemon.conf" printf 'pid-file=%s/dnsmasq.pid\n' "$D" >> "$D/daemon.conf" db 60 "$DM" -C "$D/daemon.conf" drc=$rc; dout="$out$(cat "$D/daemon.log" 2>/dev/null)" # Scored three ways. `ok` if it daemonises and answers -- the whole point, and # what a fixed kernel gives with no edit here. An ATTRIBUTED red when it died at # a credential site with ENOSYS, the shape that gap predicts (fatal_event -> # "failed to change group-id to ", EC_MISC=5). A plain red otherwise, so # a genuine dnsmasq regression is never absorbed by an attribution written for # the kernel. if [ "$drc" -eq 0 ] && [ -s "$D/dnsmasq.pid" ]; then printf '\022\067\001\000\000\001\000\000\000\000\000\000\013bst-dnsmasq\004test\000\000\001\000\001' > "$D/q-d.bin" wire "$D/q-d.bin" if has "$rhex" "0a0b0c0d"; then bst_ok dnsmasq/daemonise else bst_notok dnsmasq/daemonise "daemonised but did not answer -- $(_bst_1line "$rhex")"; fi elif [ "$drc" -eq 124 ]; then bst_notok dnsmasq/daemonise "hung instead of forking or failing -- $(_bst_1line "$dout")" elif bst_execfail "$dout"; then bst_unrun dnsmasq/daemonise "$dout" elif has "$dout" "Function not implemented" && \ { has "$dout" "failed to change group-id" || has "$dout" "failed to change user-id" || has "$dout" "setting capabilities failed"; }; then bst_notok dnsmasq/daemonise "brintos/linux#19 (no credential setters wired): dnsmasq's non-debug path always changes group-id (gp defaults to CHGRP \"dip\", defined in /etc/group), so it cannot start at all -- rc=$drc $(_bst_1line "$dout")$su_note" else bst_notok dnsmasq/daemonise "rc=$drc -- $(_bst_1line "$dout")" fi # --- argument handling ------------------------------------------------------- # An unknown option must be refused rather than ignored, and refused before any # socket work: read_opts runs to completion before main() does anything else. db 20 "$DM" --no-such-option if [ "$rc" -eq 124 ]; then bst_notok dnsmasq/rejects-unknown-option "hung on a bad option -- $(_bst_1line "$out")" elif [ "$rc" -eq 0 ]; then bst_notok dnsmasq/rejects-unknown-option "accepted an unknown option -- $(_bst_1line "$out")" elif has "$out" "bad command line options"; then bst_ok dnsmasq/rejects-unknown-option else bst_notok dnsmasq/rejects-unknown-option "rejected (rc=$rc) but not with the expected diagnostic -- $(_bst_1line "$out")" fi rm -rf "$D"