Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1029 lines
49 KiB
Bash
1029 lines
49 KiB
Bash
#!/bin/sh
|
||
#
|
||
# Measures install.sh against the installer contract
|
||
# (docs/2026-09-07-installer-contract.md), one case per clause it can decide.
|
||
#
|
||
# sh dev/installer-probe.sh # DEBUG=1 to see what a failing case printed
|
||
# IMAGE=<other> sh dev/installer-probe.sh # to measure against another build
|
||
# INSTALLER=<file> sh dev/installer-probe.sh # to measure another install.sh,
|
||
# # e.g. the last release's, and see
|
||
# # a new case fail on it
|
||
#
|
||
# Every case runs in a throwaway container off the official Technitium image, so
|
||
# nothing on this machine is touched and every run starts from the same stock
|
||
# console.
|
||
#
|
||
# The two mode B cases are not skipped by decree. The probe asks the image
|
||
# whether it honours DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH (the same
|
||
# measurement the installer itself has to make) and runs them when it does.
|
||
# Point IMAGE at such a build and they run with no edit here.
|
||
#
|
||
# Exit code is the number of cases that failed, which is what makes this a gate
|
||
# and not a report.
|
||
|
||
set -u
|
||
|
||
IMAGE="${IMAGE:-technitium/dns-server:latest}"
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
INSTALLER="${INSTALLER:-$ROOT/install.sh}"
|
||
WWW=/opt/technitium/dns/www
|
||
|
||
[ -f "$ROOT/dist/index.html" ] || { echo "build first: npm run build"; exit 2; }
|
||
command -v docker >/dev/null 2>&1 || { echo "docker is needed"; exit 2; }
|
||
|
||
WORK="$(mktemp -d)"
|
||
# Some cases leave containers, a compose project and an image behind if they are
|
||
# interrupted, and write as root into folders of $WORK that the user running
|
||
# this cannot remove. All of it goes on the way out.
|
||
cleanup() {
|
||
docker compose -p "installer-probe-$$" -f "$WORK/compose.yaml" down -v >/dev/null 2>&1 || true
|
||
docker rm -f "installer-probe-$$" "installer-probe-$$-rw" "installer-probe-$$-root" \
|
||
"installer-probe-$$-pipe" "installer-probe-$$-other" "installer-probe-$$-vol" >/dev/null 2>&1 || true
|
||
docker volume rm "installer-probe-$$-www" >/dev/null 2>&1 || true
|
||
docker rmi "technitium-console-init:probe-$$" "technitium-console-init:probe-$$-badsum" \
|
||
"technitium-console-init:probe-$$-nosum" >/dev/null 2>&1 || true
|
||
docker run --rm -v "$WORK":/w busybox:stable sh -c 'rm -rf /w/* /w/.[!.]*' >/dev/null 2>&1 || true
|
||
rm -rf "$WORK"
|
||
}
|
||
# A signal stops the run: cleaning up and carrying on would run the remaining
|
||
# cases against a $WORK that is gone. The exit is what runs cleanup.
|
||
trap cleanup EXIT
|
||
trap 'exit 130' INT
|
||
trap 'exit 143' TERM
|
||
tar -czf "$WORK/console.tar.gz" -C "$ROOT/dist" .
|
||
cp "$INSTALLER" "$WORK/install.sh"
|
||
|
||
# A file only this console ships. Proving it is *served* is the only honest way
|
||
# to say "the console is installed": a marker the installer wrote itself only
|
||
# proves the installer ran.
|
||
ASSET="$(cd "$ROOT/dist" && ls assets/*.js | head -1)"
|
||
|
||
PASS=0; FAIL=0; SKIP=0
|
||
say() { printf ' %-6s %-4s %s\n' "$1" "$2" "$3"; }
|
||
verdict() { # id, exit code of the case, text
|
||
case "$2" in
|
||
0) PASS=$((PASS+1)); say "PASS" "$1" "$3" ;;
|
||
3) SKIP=$((SKIP+1)); say "SKIP" "$1" "$3" ;;
|
||
*) FAIL=$((FAIL+1)); say "FAIL" "$1" "$3"
|
||
[ "${DEBUG:-}" = "1" ] && sed 's/^/ | /' "$WORK/out" ;;
|
||
esac
|
||
}
|
||
|
||
# Runs a case body as root inside a fresh container. The body gets $ASSET and
|
||
# the web root, and says "met" or "not met" with its exit code.
|
||
case_run() {
|
||
cat > "$WORK/case.sh"
|
||
docker run --rm --entrypoint sh -e "ASSET=$ASSET" -e "WWW=$WWW" \
|
||
-v "$WORK":/w:ro "$IMAGE" /w/case.sh >"$WORK/out" 2>&1
|
||
}
|
||
|
||
printf '\n installer contract, measured against %s\n\n' "$IMAGE"
|
||
|
||
# --------------------------------------------------------------- C1 · fresh
|
||
case_run <<'EOF'
|
||
set -e
|
||
printf '[{"name":"mine"}]\n' > "$WWW/json/quick-block-lists-custom.json"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f "$WWW/$ASSET" ]
|
||
[ -f "$WWW/json/quick-block-lists-custom.json" ]
|
||
EOF
|
||
verdict "C1" $? "fresh install over the stock console, custom list kept"
|
||
|
||
# ------------------------------------------------------- C2 · re-run is a no-op
|
||
case_run <<'EOF'
|
||
set -e
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f "$WWW/$ASSET" ]
|
||
# the backup must still be the stock console and not a copy of ours
|
||
[ ! -f "$WWW.original/$ASSET" ]
|
||
[ -f "$WWW.original/js/main.js" ]
|
||
EOF
|
||
verdict "C2" $? "installing twice leaves the same state and keeps the real backup"
|
||
|
||
# ------------------------------------------------ C3 · after a server update
|
||
case_run <<'EOF'
|
||
set -e
|
||
tar -czf /stock.tar.gz -C "$WWW" .
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
tar -xzf /stock.tar.gz -C "$WWW" # what upstream's own installer does on update
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f "$WWW/$ASSET" ]
|
||
[ ! -f "$WWW/js/main.js" ] # no leftovers of the console it replaced
|
||
EOF
|
||
verdict "C3" $? "reinstalling after a server update leaves only this console"
|
||
|
||
# ------------------------------------------- C4 · open set of custom lists
|
||
case_run <<'EOF'
|
||
set -e
|
||
printf '[]\n' > "$WWW/json/some-future-list-custom.json"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f "$WWW/json/some-future-list-custom.json" ]
|
||
EOF
|
||
verdict "C4" $? "any *-custom.json is kept, not just the three known names"
|
||
|
||
# ------------------------------- C5 · a list written while ours is installed
|
||
case_run <<'EOF'
|
||
set -e
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
# The console has been in place for months and the administrator writes a list
|
||
# now. It lives in the served folder, which is the one the uninstall replaces.
|
||
printf '[{"name":"written later"}]\n' > "$WWW/json/quick-forwarders-list-custom.json"
|
||
sh /w/install.sh --uninstall --yes >/dev/null 2>&1
|
||
[ -f "$WWW/json/quick-forwarders-list-custom.json" ]
|
||
EOF
|
||
verdict "C5" $? "a custom list written after the install survives the uninstall"
|
||
|
||
# ------------------------------------------------------------ C6 · uninstall
|
||
case_run <<'EOF'
|
||
set -e
|
||
printf '[{"name":"mine"}]\n' > "$WWW/json/quick-block-lists-custom.json"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
sh /w/install.sh --uninstall --yes >/dev/null 2>&1
|
||
[ -f "$WWW/js/main.js" ]
|
||
[ ! -f "$WWW/$ASSET" ]
|
||
[ -f "$WWW/json/quick-block-lists-custom.json" ]
|
||
EOF
|
||
verdict "C6" $? "uninstall puts the stock console back and keeps the custom lists"
|
||
|
||
# ------------------------------------------------- C7 · interrupted install
|
||
case_run <<'EOF'
|
||
# The shape of a run that was killed between wiping the web root and writing the
|
||
# new files: the backup is there, the web root is not. Nobody can promise this
|
||
# never happens (A2); what has to hold is that the next run repairs it.
|
||
set -e
|
||
cp -a "$WWW" "$WWW.original"
|
||
rm -rf "$WWW"; mkdir -p "$WWW"
|
||
sh /w/install.sh --uninstall --yes >/dev/null 2>&1 || true
|
||
[ -f "$WWW/js/main.js" ]
|
||
EOF
|
||
verdict "C7" $? "the next run repairs what an interrupted one left behind"
|
||
|
||
# --------------------------------------------------- C8 · nothing but console
|
||
case_run <<'EOF'
|
||
set -e
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
# Bookkeeping does not belong in a folder the web service hands to anyone who
|
||
# can reach the login page.
|
||
tar -tzf /w/console.tar.gz | sed -e 's|^\./||' -e 's|/.*||' | grep . | sort -u > /expected
|
||
ls -A "$WWW" | sort -u > /actual
|
||
comm -13 /expected /actual > /extra
|
||
[ ! -s /extra ]
|
||
EOF
|
||
verdict "C8" $? "the web root holds the console and nothing else"
|
||
|
||
# ------------------------------------------------------ C9 · bind-mounted root
|
||
mkdir -p "$WORK/mnt"; cp -a "$ROOT/dist/." "$WORK/mnt/"
|
||
cat > "$WORK/case9.sh" <<'EOF'
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
EOF
|
||
docker run --rm --entrypoint sh -v "$WORK":/w -v "$WORK/mnt":$WWW "$IMAGE" /w/case9.sh >/dev/null 2>&1
|
||
verdict "C9" $? "a web root that is a mount point can be installed into"
|
||
|
||
# ------------------------------------------------------ C10 · --dir, new folder
|
||
case_run <<'EOF'
|
||
sh /w/install.sh --dir /opt/technitium-console --from /w/console.tar.gz --yes >/dev/null 2>&1 || exit 1
|
||
[ -f "/opt/technitium-console/$ASSET" ]
|
||
EOF
|
||
verdict "C10" $? "--dir creates a folder that does not exist yet"
|
||
|
||
# ------------------------------------------------------------ the server, live
|
||
#
|
||
# Some clauses can only be decided with the server running: whether it needs a
|
||
# restart, and whether it honours the variable.
|
||
NAME="installer-probe-$$"
|
||
PORT=""
|
||
start_server() { # extra docker arguments, e.g. the variable and its folder
|
||
docker rm -f "$NAME" >/dev/null 2>&1
|
||
docker run -d --name "$NAME" -P -e DNS_SERVER_ADMIN_PASSWORD=probe \
|
||
-v "$WORK":/w:ro "$@" "$IMAGE" >/dev/null 2>&1
|
||
PORT="$(docker port "$NAME" 5380/tcp 2>/dev/null | head -1 | sed 's/.*://')"
|
||
# Any answer will do, including a 404: with the variable honoured the served
|
||
# folder is not a console and "/" has nothing to give.
|
||
i=0
|
||
while [ "$i" -lt 40 ] && ! curl -s -o /dev/null "http://127.0.0.1:$PORT/" ; do
|
||
i=$((i+1)); sleep 1
|
||
done
|
||
}
|
||
stop_server() { docker rm -f "$NAME" >/dev/null 2>&1; }
|
||
in_server() { docker exec "$NAME" sh -c "$1" >/dev/null 2>&1; }
|
||
|
||
# ------------------------------------------------- C11 · no restart required
|
||
start_server
|
||
docker exec "$NAME" sh /w/install.sh --from /w/console.tar.gz --yes > "$WORK/out" 2>&1
|
||
c11=0
|
||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/$ASSET" || c11=1
|
||
grep -q 'goes the next time the container is recreated' "$WORK/out" || c11=1 # its own layer: said so
|
||
verdict "C11" $c11 "served with no restart, and an install into a container's own files is called out"
|
||
stop_server
|
||
|
||
# ------------------------------------------ C12/C13 · the environment variable
|
||
#
|
||
# Whether these two run is not a decision, it is a measurement, the same one W3
|
||
# asks the installer to make. Start the image with the variable pointing at a
|
||
# folder that already holds a file of ours, and ask the web service for it. The
|
||
# folder has to exist *before* the server starts, or a server that does honour
|
||
# the variable falls back to the default and says so in its log.
|
||
mkdir -p "$WORK/side"
|
||
printf 'capability probe\n' > "$WORK/side/capability-probe.txt"
|
||
|
||
start_server -e DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/side -v "$WORK/side":/side
|
||
CAPABLE=no
|
||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/capability-probe.txt" && CAPABLE=yes
|
||
VERSION="$(docker exec "$NAME" sh -c 'grep -ho "DNS Server (v[0-9.]*)" /var/log/technitium/dns/*.log | tail -1' 2>/dev/null | tr -d '()' | sed 's/DNS Server //')"
|
||
[ -n "$VERSION" ] || VERSION="this image"
|
||
|
||
if [ "$CAPABLE" = "yes" ]; then
|
||
# Mode B, against a server that serves the folder the variable names.
|
||
#
|
||
# The capability file goes first: a folder holding a file that is not a
|
||
# console is exactly what the installer must refuse to sweep (W6), and an
|
||
# administrator's empty folder is what this case is about.
|
||
rm -f "$WORK/side/capability-probe.txt"
|
||
#
|
||
# The list has to exist in the stock web root BEFORE the install, because half
|
||
# of F3 is that the install carries it across. The other half is the way back.
|
||
in_server "printf '[{\"name\":\"before\"}]\n' > $WWW/json/quick-block-lists-custom.json"
|
||
in_server "sh /w/install.sh --from /w/console.tar.gz --yes"
|
||
|
||
c12=0
|
||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/$ASSET" || c12=1
|
||
in_server "[ -f $WWW/js/main.js ] && [ ! -f $WWW/$ASSET ]" || c12=1
|
||
verdict "C12" $c12 "installs where the variable points and leaves www alone ($VERSION)"
|
||
|
||
c13=0
|
||
in_server "[ -f /side/json/quick-block-lists-custom.json ]" || c13=1 # carried across
|
||
# And a list written afterwards, in the folder that is actually being served:
|
||
# it exists nowhere else, so only the uninstall can bring it home.
|
||
in_server "printf '[{\"name\":\"after\"}]\n' > /side/json/quick-forwarders-list-custom.json"
|
||
# The folder is a bind mount here, as in the README's Docker layout: the
|
||
# uninstall has to finish on it, not stop at the mount point (on 2026-09-30
|
||
# it did, and this case did not look at the exit code).
|
||
in_server "sh /w/install.sh --uninstall --yes" || c13=1
|
||
in_server '[ -z "$(ls -A /side)" ]' || c13=1
|
||
in_server "[ -f $WWW/json/quick-forwarders-list-custom.json ]" || c13=1
|
||
in_server "[ -f $WWW/json/quick-block-lists-custom.json ]" || c13=1
|
||
verdict "C13" $c13 "the custom lists travel into the console's folder and back, and the uninstall finishes on a mount point"
|
||
|
||
else
|
||
verdict "C12" 3 "installs where the variable points and leaves www alone ($VERSION does not honour it)"
|
||
verdict "C13" 3 "the custom lists travel into the console's folder and back ($VERSION does not honour it)"
|
||
fi
|
||
stop_server
|
||
|
||
# ---------------------------------------------- C14 · W1, asked not assumed
|
||
case_run <<'EOF'
|
||
set -e
|
||
# A server that is NOT where the two known paths say. The only way to find its
|
||
# web root is to ask the process that is running.
|
||
cp -a /opt/technitium/dns /opt/elsewhere
|
||
mv /opt/technitium/dns /opt/technitium/dns.moved
|
||
/usr/bin/dotnet /opt/elsewhere/DnsServerApp.dll /etc/dns >/dev/null 2>&1 &
|
||
i=0; while [ $i -lt 20 ] && ! grep -qa DnsServerApp /proc/[0-9]*/cmdline 2>/dev/null; do i=$((i+1)); sleep 1; done
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f "/opt/elsewhere/www/$ASSET" ]
|
||
[ ! -f "/opt/technitium/dns.moved/www/$ASSET" ]
|
||
EOF
|
||
verdict "C14" $? "the web root comes from the running server, not from a guess"
|
||
|
||
# ------------------------------------- C15 · W3, a variable nobody honours
|
||
if [ "$CAPABLE" = "yes" ]; then
|
||
verdict "C15" 3 "does not install where the variable points when the server ignores it (this one does not ignore it)"
|
||
else
|
||
start_server -e "DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/side" -v "$WORK/side":/side
|
||
in_server "sh /w/install.sh --from /w/console.tar.gz --yes"
|
||
c15=0
|
||
in_server "[ -f $WWW/$ASSET ]" || c15=1 # went to the folder actually served
|
||
in_server "[ ! -f /side/$ASSET ]" || c15=1 # not to the one nobody reads
|
||
in_server "! ls /side/technitium-console-probe-* >/dev/null 2>&1" || c15=1 # and took its probe with it
|
||
verdict "C15" $c15 "refuses the folder the variable names when the server ignores it"
|
||
stop_server
|
||
fi
|
||
|
||
# ------------------------------------------ C16 · F4, the server's own data
|
||
case_run <<'EOF'
|
||
set -e
|
||
# Let the server run once so /etc/dns is a real configuration and not an empty
|
||
# folder, then stop it so nothing moves under the comparison.
|
||
/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns >/dev/null 2>&1 &
|
||
srv=$!
|
||
i=0; while [ $i -lt 30 ] && [ ! -f /etc/dns/dns.config ]; do i=$((i+1)); sleep 1; done
|
||
sleep 2; kill "$srv" 2>/dev/null || true; sleep 2
|
||
[ -f /etc/dns/dns.config ] # or the comparison below proves nothing
|
||
|
||
snapshot() { find /etc/dns /var/log/technitium /etc/systemd -type f -exec md5sum {} + 2>/dev/null | sort; md5sum /etc/resolv.conf 2>/dev/null || true; }
|
||
snapshot > /before
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
sh /w/install.sh --uninstall --yes >/dev/null 2>&1
|
||
snapshot > /after
|
||
diff /before /after
|
||
EOF
|
||
verdict "C16" $? "config, zones and resolv.conf come out byte for byte the same"
|
||
|
||
# -------------------------- C17 · A1 and A2, a failure at the publication point
|
||
case_run <<'EOF'
|
||
set -e
|
||
# Make every copy of a page fail, which is the worst moment there is: the assets
|
||
# of the new console are already in, the pages are not.
|
||
mkdir -p /usr/local/bin
|
||
cat > /usr/local/bin/cp <<'FAKE'
|
||
#!/bin/sh
|
||
for a in "$@"; do case "$a" in */index.html) exit 1 ;; esac; done
|
||
exec /bin/cp "$@"
|
||
FAKE
|
||
chmod +x /usr/local/bin/cp
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1 && exit 1 # it must fail
|
||
|
||
# It failed where it had to fail: the new assets are already in, which is what
|
||
# makes the window safe, and no page has been swapped yet.
|
||
[ -f "$WWW/$ASSET" ]
|
||
[ -f "$WWW/index.html" ]
|
||
grep -q 'js/main.js' "$WWW/index.html"
|
||
[ -f "$WWW/js/main.js" ]
|
||
[ -d "$WWW/css" ]
|
||
|
||
rm -f /usr/local/bin/cp
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1 # and the next run finishes the job
|
||
[ -f "$WWW/$ASSET" ]
|
||
[ ! -f "$WWW/js/main.js" ]
|
||
|
||
echo "step: a TERM in the middle of the sweep, as docker stop sends one"
|
||
sh /w/install.sh --uninstall --yes >/dev/null 2>&1 # the stock console back, so there is something to sweep
|
||
[ -f "$WWW/js/main.js" ]
|
||
# The first removal of a file of the console being replaced sends the run a TERM.
|
||
cat > /usr/local/bin/rm <<'FAKE'
|
||
#!/bin/sh
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
"$WWW"/*.tc-new) ;;
|
||
"$WWW"/*) [ -e /termed ] || { : > /termed; kill -TERM "$PPID"; } ;;
|
||
esac
|
||
done
|
||
exec /bin/rm "$@"
|
||
FAKE
|
||
chmod +x /usr/local/bin/rm
|
||
sh /w/install.sh --from /w/console.tar.gz --yes && exit 1 # it stops,
|
||
[ -e /termed ] # in the sweep,
|
||
[ -f "$WWW/$ASSET" ] # with nothing of the new console swept
|
||
grep -q '<div id="root"></div>' "$WWW/index.html"
|
||
grep -qx 'phase=done' /var/lib/technitium-console/install.state && exit 1 # and not recorded as finished
|
||
/bin/rm -f /usr/local/bin/rm
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1 # the next run repairs it (A2)
|
||
[ -f "$WWW/$ASSET" ]
|
||
[ ! -f "$WWW/js/main.js" ]
|
||
grep -qx 'phase=done' /var/lib/technitium-console/install.state
|
||
EOF
|
||
verdict "C17" $? "a failure mid-publication leaves a whole console, and the next run finishes"
|
||
|
||
# ------------------------------------------- C18 · A5, state out of the way
|
||
case_run <<'EOF'
|
||
set -e
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
[ -f /var/lib/technitium-console/install.state ]
|
||
grep -q '^webroot=' /var/lib/technitium-console/install.state
|
||
grep -q '^backup=' /var/lib/technitium-console/install.state
|
||
grep -q '^mode=' /var/lib/technitium-console/install.state
|
||
! ls -A "$WWW" | grep -q '^\.'
|
||
EOF
|
||
verdict "C18" $? "the bookkeeping lives outside the folder the server serves"
|
||
|
||
# ------------------------------ C19 · A7, a backup from another server version
|
||
#
|
||
# Needs the server running, because the version comes from its startup log.
|
||
start_server
|
||
in_server "sh /w/install.sh --from /w/console.tar.gz --yes"
|
||
c19=0
|
||
in_server "sed -i 's/^server_version=.*/server_version=v0.0.0.0/' /var/lib/technitium-console/install.state" || c19=1
|
||
in_server "sh /w/install.sh --uninstall --yes" && c19=1 # --yes must NOT be enough
|
||
in_server "[ -f $WWW/$ASSET ]" || c19=1 # and nothing may have moved
|
||
in_server "sh /w/install.sh --uninstall --yes --restore-mismatched-backup" || c19=1
|
||
in_server "[ -f $WWW/js/main.js ]" || c19=1
|
||
verdict "C19" $c19 "a backup from another version needs its own flag, and --yes is not it"
|
||
stop_server
|
||
|
||
# ------------------------------------------ C20 · U2, the hybrid gets named
|
||
case_run <<'EOF'
|
||
set -e
|
||
tar -czf /stock.tar.gz -C "$WWW" .
|
||
sh /w/install.sh --from /w/console.tar.gz --yes >/dev/null 2>&1
|
||
tar -xzf /stock.tar.gz -C "$WWW"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes 2>&1 | grep -q 'server was updated'
|
||
EOF
|
||
verdict "C20" $? "an update that put the stock console back is named, not silently undone"
|
||
|
||
# --------------------------------- C21 · S1 and S3, the service and the browser
|
||
case_run <<'EOF'
|
||
set -e
|
||
mkdir -p /usr/local/bin
|
||
printf '#!/bin/sh\ntouch /restarted\n' > /usr/local/bin/systemctl
|
||
printf '#!/bin/sh\ntouch /restarted\n' > /usr/local/bin/rc-service
|
||
chmod +x /usr/local/bin/systemctl /usr/local/bin/rc-service
|
||
sh /w/install.sh --from /w/console.tar.gz --yes > /out 2>&1
|
||
[ ! -f /restarted ]
|
||
! grep -qi 'F5' /out
|
||
EOF
|
||
verdict "C21" $? "no restart of the DNS service, and no cache advice that is not true"
|
||
|
||
# ---------------------------- C22 · W6, a folder that is not a console is refused
|
||
#
|
||
# Publishing sweeps whatever the release does not ship, as root. Pointed at the
|
||
# server's own folder by mistake, that is the binaries and nothing left to run.
|
||
# The "step:" lines are what DEBUG=1 shows, to say which half failed.
|
||
case_run <<'EOF'
|
||
set -e
|
||
fingerprint() { find "$1" -type f -exec md5sum {} + | sort; }
|
||
fingerprint /opt/technitium/dns > /before
|
||
|
||
echo "step: --dir at the server's own folder, no TTY and no --yes"
|
||
sh /w/install.sh --dir /opt/technitium/dns --from /w/console.tar.gz </dev/null && exit 1
|
||
echo "step: the same with --yes"
|
||
sh /w/install.sh --dir /opt/technitium/dns --from /w/console.tar.gz --yes && exit 1
|
||
fingerprint /opt/technitium/dns > /after
|
||
diff /before /after
|
||
[ ! -e /opt/technitium/dns.original ]
|
||
|
||
echo "step: a folder of somebody else's files"
|
||
mkdir -p /srv/data; printf 'keep\n' > /srv/data/notes.txt
|
||
sh /w/install.sh --dir /srv/data --from /w/console.tar.gz --yes && exit 1
|
||
[ -f /srv/data/notes.txt ]
|
||
[ ! -f "/srv/data/$ASSET" ]
|
||
|
||
echo "step: --dir / is the root folder, not a way of leaving --dir out"
|
||
sh /w/install.sh --dir / --from /w/console.tar.gz --yes && exit 1
|
||
[ ! -f "$WWW/$ASSET" ]
|
||
[ ! -e "$WWW.original" ]
|
||
|
||
echo "step: and the refused runs left no state folder behind"
|
||
[ ! -e /var/lib/technitium-console ]
|
||
|
||
echo "step: an empty folder is still what --dir is for"
|
||
mkdir -p /srv/empty
|
||
sh /w/install.sh --dir /srv/empty --from /w/console.tar.gz --yes
|
||
[ -f "/srv/empty/$ASSET" ]
|
||
EOF
|
||
verdict "C22" $? "refuses a folder that is neither empty nor a console, and changes nothing"
|
||
|
||
# ------------------------- C23 · W7, the server is identified, not the first match
|
||
case_run <<'EOF'
|
||
set -e
|
||
mkdir -p /victim; printf 'precious\n' > /victim/data.txt
|
||
# Something that looks like the server to /proc: its last argument ends in
|
||
# DnsServerApp.dll. Nothing else about it is real, which is the point.
|
||
look_alike() { sh -c 'sleep 300; :' "$1" & sleep 1; }
|
||
|
||
echo "step: an unprivileged account's look-alike, www pointing elsewhere"
|
||
mkdir -p /tmp/evil; touch /tmp/evil/DnsServerApp.dll; ln -s /victim /tmp/evil/www
|
||
chown -hR nobody /tmp/evil
|
||
setpriv --reuid=65534 --regid=65534 --clear-groups sh -c 'sleep 300; :' /tmp/evil/DnsServerApp.dll &
|
||
evil=$!; sleep 1
|
||
sh /w/install.sh --from /w/console.tar.gz --yes > /out 2>&1 # so the known folder
|
||
grep -q 'Not trusting process' /out # ignored, and said so
|
||
[ -f /victim/data.txt ]
|
||
[ -f "$WWW/$ASSET" ]
|
||
kill "$evil"
|
||
sh /w/install.sh --uninstall --yes
|
||
|
||
echo "step: a root process whose www is a symbolic link"
|
||
mkdir -p /opt/linked; touch /opt/linked/DnsServerApp.dll; ln -s /victim /opt/linked/www
|
||
look_alike /opt/linked/DnsServerApp.dll; linked=$!
|
||
sh /w/install.sh --from /w/console.tar.gz --yes && exit 1
|
||
[ -f /victim/data.txt ]
|
||
[ ! -f "/victim/$ASSET" ]
|
||
kill "$linked"
|
||
|
||
echo "step: two servers, and no silent choice between them"
|
||
mkdir -p /opt/second; cp -a "$WWW" /opt/second/www; touch /opt/second/DnsServerApp.dll
|
||
look_alike /opt/technitium/dns/DnsServerApp.dll; one=$!
|
||
look_alike /opt/second/DnsServerApp.dll; two=$!
|
||
sh /w/install.sh --from /w/console.tar.gz --yes > /out 2>&1 && exit 1
|
||
grep -q -- '--dir' /out
|
||
[ ! -f "$WWW/$ASSET" ]
|
||
[ ! -f "/opt/second/www/$ASSET" ]
|
||
kill "$one" "$two"
|
||
|
||
echo "step: --dir through a link is resolved once, and used resolved"
|
||
ln -s "$WWW" /opt/console-link
|
||
sh /w/install.sh --dir /opt/console-link --from /w/console.tar.gz --yes
|
||
[ -f "$WWW/$ASSET" ]
|
||
grep -qx "webroot=$WWW" /var/lib/technitium-console/install.state
|
||
[ -d "$WWW.original" ]
|
||
[ ! -e /opt/console-link.original ]
|
||
[ -L /opt/console-link ]
|
||
EOF
|
||
verdict "C23" $? "a look-alike process, a linked www or two servers are not taken on trust"
|
||
|
||
# -------------------------------- C24 · A8, uninstall acts on what it recorded
|
||
#
|
||
# Mode B, with a server started by hand so it can be restarted without the
|
||
# variable in the same container. This is the audit's case: the variable is removed,
|
||
# the server restarted, and only then is --uninstall run. That needs a server
|
||
# that honours the variable (CAPABLE). The last step does not: a mode B record
|
||
# is also what --into-volume writes, on any server, so it runs on every image.
|
||
# up() waits for the web port to listen (5380 is 1504 in /proc/net), because an
|
||
# image before 15.5 has no curl, wget or busybox to ask it with.
|
||
{
|
||
cat <<'EOF'
|
||
set -e
|
||
up() { i=0; while [ $i -lt 60 ] && ! grep -qi ':1504 [0-9a-f:]* 0A' /proc/net/tcp /proc/net/tcp6 2>/dev/null; do i=$((i+1)); sleep 1; done; }
|
||
EOF
|
||
if [ "$CAPABLE" = "yes" ]; then
|
||
cat <<'EOF'
|
||
mkdir -p /side
|
||
DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/side /usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns >/dev/null 2>&1 &
|
||
srv=$!; up
|
||
echo "step: install into the variable's folder"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes
|
||
[ -f "/side/$ASSET" ]
|
||
grep -qx 'mode=side-by-side' /var/lib/technitium-console/install.state
|
||
kill "$srv"; wait "$srv" || true
|
||
|
||
/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns >/dev/null 2>&1 &
|
||
srv=$!; up
|
||
echo "step: uninstall after the variable is gone"
|
||
sh /w/install.sh --uninstall --yes
|
||
[ ! -e /side ] # the folder it installed into went
|
||
[ -f "$WWW/js/main.js" ] # and the server's own did not
|
||
[ -f "$WWW/index.html" ]
|
||
EOF
|
||
fi
|
||
cat <<'EOF'
|
||
echo "step: a recorded folder that no longer looks like this console is left alone"
|
||
mkdir -p /srv/precious /var/lib/technitium-console
|
||
printf 'keep\n' > /srv/precious/data.txt
|
||
printf 'mode=side-by-side\nwebroot=/srv/precious\n' > /var/lib/technitium-console/install.state
|
||
sh /w/install.sh --uninstall --yes && exit 1
|
||
[ -f /srv/precious/data.txt ]
|
||
[ -f "$WWW/js/main.js" ]
|
||
EOF
|
||
} | case_run
|
||
c24=$?
|
||
if [ "$CAPABLE" = "yes" ]; then
|
||
verdict "C24" $c24 "--uninstall removes the folder it recorded, and only if it is still this console"
|
||
else
|
||
verdict "C24" $c24 "--uninstall leaves a recorded folder that is no longer this console ($VERSION does not honour the variable: that step only)"
|
||
fi
|
||
|
||
# ------------------------------------- C25 · I1, the download is the release
|
||
#
|
||
# A curl of our own plays GitHub: the tarball, and whatever checksum /hash says.
|
||
case_run <<'EOF'
|
||
set -e
|
||
mkdir -p /usr/local/bin
|
||
cat > /usr/local/bin/curl <<'FAKE'
|
||
#!/bin/sh
|
||
out=""; url=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in -o) out="$2"; shift ;; -*) ;; *) url="$1" ;; esac
|
||
shift
|
||
done
|
||
case "$url" in
|
||
*/technitium-console.tar.gz) cp /w/console.tar.gz "$out" ;;
|
||
*/technitium-console.tar.gz.sha256) [ -f /hash ] || exit 22; cp /hash "$out" ;;
|
||
esac
|
||
FAKE
|
||
chmod +x /usr/local/bin/curl
|
||
|
||
echo "step: a checksum that does not match"
|
||
printf '%064d technitium-console.tar.gz\n' 0 > /hash
|
||
sh /w/install.sh --yes && exit 1
|
||
[ -f "$WWW/js/main.js" ]
|
||
[ ! -f "$WWW/$ASSET" ]
|
||
|
||
echo "step: no checksum at all"
|
||
rm -f /hash
|
||
sh /w/install.sh --yes && exit 1
|
||
[ ! -f "$WWW/$ASSET" ]
|
||
|
||
echo "step: the checksum the release publishes"
|
||
printf '%s technitium-console.tar.gz\n' "$(sha256sum /w/console.tar.gz | cut -d' ' -f1)" > /hash
|
||
sh /w/install.sh --yes
|
||
[ -f "$WWW/$ASSET" ]
|
||
|
||
echo "step: --from says it did not verify"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes 2>&1 | grep -q 'not verified'
|
||
EOF
|
||
verdict "C25" $? "a download that does not match the release's .sha256 is refused"
|
||
|
||
# ---------------------- C31 · managed systemd folder, one restart and rollback
|
||
case_run <<'EOF'
|
||
set -e
|
||
mkdir -p /etc/systemd/system /usr/local/bin /run
|
||
cat > /etc/systemd/system/dns.service <<'UNIT'
|
||
[Service]
|
||
ExecStart=/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns
|
||
UNIT
|
||
cat > /usr/local/bin/systemctl <<'FAKE'
|
||
#!/bin/sh
|
||
[ "$1" = daemon-reload ] && exit 0
|
||
[ "$1" = restart ] || exit 1
|
||
count=0; [ ! -f /run/restarts ] || count="$(cat /run/restarts)"
|
||
printf '%s\n' "$((count + 1))" > /run/restarts
|
||
if [ -f /run/dns.pid ]; then
|
||
kill "$(cat /run/dns.pid)" 2>/dev/null || true
|
||
i=0; while [ "$i" -lt 30 ] && kill -0 "$(cat /run/dns.pid)" 2>/dev/null; do i=$((i+1)); sleep 1; done
|
||
fi
|
||
if [ -f /etc/systemd/system/dns.service.d/technitium-console.conf ]; then
|
||
DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console /usr/bin/dotnet \
|
||
/opt/technitium/dns/DnsServerApp.dll /etc/dns >/tmp/dns.log 2>&1 &
|
||
else
|
||
/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns >/tmp/dns.log 2>&1 &
|
||
fi
|
||
printf '%s\n' "$!" > /run/dns.pid
|
||
exit 0
|
||
FAKE
|
||
chmod +x /usr/local/bin/systemctl
|
||
|
||
/usr/bin/dotnet /opt/technitium/dns/DnsServerApp.dll /etc/dns >/tmp/dns.log 2>&1 &
|
||
printf '%s\n' "$!" > /run/dns.pid
|
||
i=0; while [ "$i" -lt 60 ] && ! curl -sf -o /dev/null http://127.0.0.1:5380/; do i=$((i+1)); sleep 1; done
|
||
grep -q 'DNS Server (v15\.[5-9]' /var/log/technitium/dns/*.log
|
||
printf '[{"name":"before"}]\n' > "$WWW/json/quick-block-lists-custom.json"
|
||
|
||
echo "step: first install configures systemd and restarts once"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes
|
||
[ "$(cat /run/restarts)" = 1 ]
|
||
curl -sf -o /dev/null "http://127.0.0.1:5380/$ASSET"
|
||
grep -qx 'managed_unit=dns.service' /var/lib/technitium-console/install.state
|
||
grep -qx 'managed_dropin=/etc/systemd/system/dns.service.d/technitium-console.conf' /var/lib/technitium-console/install.state
|
||
grep -qx 'Environment=DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console' \
|
||
/etc/systemd/system/dns.service.d/technitium-console.conf
|
||
[ -f /opt/technitium-console/json/quick-block-lists-custom.json ]
|
||
[ -f "$WWW/js/main.js" ] && [ ! -f "$WWW/$ASSET" ]
|
||
|
||
echo "step: update publishes in place without another restart"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes
|
||
[ "$(cat /run/restarts)" = 1 ]
|
||
|
||
echo "step: uninstall switches to stock before removing the folder"
|
||
printf '[{"name":"after"}]\n' > /opt/technitium-console/json/quick-forwarders-list-custom.json
|
||
sh /w/install.sh --uninstall --yes
|
||
[ "$(cat /run/restarts)" = 2 ]
|
||
[ ! -e /etc/systemd/system/dns.service.d/technitium-console.conf ]
|
||
[ ! -e /opt/technitium-console ]
|
||
curl -sf -o /dev/null http://127.0.0.1:5380/js/main.js
|
||
[ -f "$WWW/json/quick-block-lists-custom.json" ]
|
||
[ -f "$WWW/json/quick-forwarders-list-custom.json" ]
|
||
|
||
echo "step: a changed drop-in belongs to the administrator and is not removed"
|
||
sh /w/install.sh --from /w/console.tar.gz --yes
|
||
[ "$(cat /run/restarts)" = 3 ]
|
||
printf '\n# changed locally\n' >> /etc/systemd/system/dns.service.d/technitium-console.conf
|
||
sh /w/install.sh --uninstall --yes && exit 1
|
||
[ "$(cat /run/restarts)" = 3 ]
|
||
[ -f /etc/systemd/system/dns.service.d/technitium-console.conf ]
|
||
[ -f "/opt/technitium-console/$ASSET" ]
|
||
EOF
|
||
verdict "C31" $? "systemd gets a managed folder, one restart, clean uninstall and ownership checks"
|
||
|
||
# ------------------------------------------------- Docker: the init image (D1–D4)
|
||
#
|
||
# The layout README.md gives Docker users (the contract's §3, "Docker", D1 to D4):
|
||
# an init container off our image copies the console into a volume, and the
|
||
# official server mounts it read-only with the variable pointing at it. The
|
||
# image is built here from dist/ with the same three files CI puts in it from a
|
||
# release, so what is measured is this checkout's Dockerfile and install.sh.
|
||
#
|
||
# Every case needs a server that honours the variable, which was measured above
|
||
# (CAPABLE). C30 also needs docker:cli and the Docker socket.
|
||
INIT_IMAGE="technitium-console-init:probe-$$"
|
||
PROJECT="installer-probe-$$"
|
||
dc() { docker compose -p "$PROJECT" -f "$WORK/compose.yaml" "$@"; }
|
||
|
||
build_init() { # [marker]: the image, with probe-marker.txt in its tarball if given
|
||
rm -rf "$WORK/img"; mkdir -p "$WORK/img/dist"
|
||
cp -a "$ROOT/dist/." "$WORK/img/dist/"
|
||
[ -z "${1:-}" ] || printf '%s\n' "$1" > "$WORK/img/dist/probe-marker.txt"
|
||
tar -czf "$WORK/img/technitium-console.tar.gz" -C "$WORK/img/dist" .
|
||
rm -rf "$WORK/img/dist"
|
||
(cd "$WORK/img" && sha256sum technitium-console.tar.gz > technitium-console.tar.gz.sha256)
|
||
cp "$INSTALLER" "$WORK/img/install.sh"
|
||
cp "$ROOT/docker/Dockerfile" "$WORK/img/Dockerfile"
|
||
docker build -q -t "$INIT_IMAGE" "$WORK/img" >/dev/null 2>&1
|
||
}
|
||
|
||
compose_file() { # where the console lives: the volume, or a folder on this host
|
||
# [the init's /target, when it is not the same, a mistake C29 makes on purpose]
|
||
cat > "$WORK/compose.yaml" <<EOF
|
||
# The block in README.md, "Docker": they change together. Only the two image
|
||
# names, a published port and the admin password are the probe's own.
|
||
services:
|
||
dns-server:
|
||
image: $IMAGE
|
||
ports:
|
||
- "127.0.0.1::5380"
|
||
environment:
|
||
- DNS_SERVER_ADMIN_PASSWORD=probe
|
||
- DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console
|
||
volumes:
|
||
- config:/etc/dns
|
||
- $1:/opt/technitium-console:ro
|
||
|
||
technitium-console:
|
||
image: $INIT_IMAGE
|
||
volumes:
|
||
- ${2:-$1}:/target
|
||
restart: "no"
|
||
|
||
volumes:
|
||
config:
|
||
technitium-console:
|
||
EOF
|
||
}
|
||
|
||
base() { printf 'http://127.0.0.1:%s' "$(dc port dns-server 5380 2>/dev/null | sed 's/.*://')"; }
|
||
served() { # path: the init and the server start side by side, so it waits
|
||
i=0
|
||
while [ "$i" -lt 60 ]; do
|
||
curl -sf -o /dev/null "$(base)/$1" && return 0
|
||
i=$((i+1)); sleep 1
|
||
done
|
||
return 1
|
||
}
|
||
init_exit() { docker wait "$(dc ps -a -q technitium-console)" 2>/dev/null; }
|
||
started_at() { docker inspect -f '{{.State.StartedAt}}' "$(dc ps -q dns-server)" 2>/dev/null; }
|
||
in_volume() { docker run --rm -v "${PROJECT}_technitium-console:/v" busybox:stable sh -c "$1"; }
|
||
in_config() { docker run --rm -v "${PROJECT}_config:/v" busybox:stable sh -c "$1"; }
|
||
section() { # container: its part of what the Docker branch printed
|
||
awk -v n="$1" '/^ container /{ on = ($2 == n) } on' "$WORK/out"
|
||
}
|
||
host() { # install.sh as `curl … | sudo sh` runs it on a Docker host: from stdin, $0 is "sh"
|
||
docker run --rm -i -v /var/run/docker.sock:/var/run/docker.sock docker:cli sh -s -- "$@" < "$INSTALLER"
|
||
}
|
||
if [ "$CAPABLE" != "yes" ]; then
|
||
for c in C26 C27 C28 C29 C30; do
|
||
verdict "$c" 3 "the Docker layout ($VERSION does not honour the variable)"
|
||
done
|
||
else
|
||
|
||
# ----------------------------------------- C26 · D4, the image fills the volume
|
||
build_init
|
||
compose_file technitium-console
|
||
c26=0
|
||
dc up -d >"$WORK/out" 2>&1 || c26=1
|
||
[ "$(init_exit)" = "0" ] || c26=1
|
||
served "$ASSET" || c26=1
|
||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$(base)/js/main.js")" = "404" ] || c26=1 # not the stock one behind it
|
||
want="$(cut -d' ' -f1 < "$WORK/img/technitium-console.tar.gz.sha256")"
|
||
got="$(docker run --rm --entrypoint sha256sum "$INIT_IMAGE" /usr/share/technitium-console/technitium-console.tar.gz | cut -d' ' -f1)"
|
||
[ -n "$got" ] && [ "$got" = "$want" ] || c26=1 # the bytes it was built with
|
||
tar -tzf "$WORK/img/technitium-console.tar.gz" | sed -e 's|^\./||' -e 's|/.*||' | grep . | sort -u > "$WORK/expected"
|
||
in_volume 'ls -A /v' | grep -vx '\.technitium-console' | sort -u > "$WORK/actual"
|
||
[ -z "$(comm -13 "$WORK/expected" "$WORK/actual")" ] || c26=1 # A6: nothing but the console…
|
||
in_volume '[ -f /v/.technitium-console ] && [ ! -L /v/.technitium-console ]' || c26=1 # …and the marker (A6's one exception)
|
||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$(base)/.technitium-console")" != "200" ] || c26=1 # which is never served
|
||
dc logs technitium-console > "$WORK/out" 2>&1
|
||
grep -q 'Checksum matches' "$WORK/out" || c26=1
|
||
! grep -q 'sudo' "$WORK/out" || c26=1 # no host advice from inside the image
|
||
verdict "C26" $c26 "the init image fills the volume from the release's own bytes, and the server serves it"
|
||
|
||
# ------------------------------------------ C27 · D2, an update restarts nothing
|
||
c27=0
|
||
before="$(started_at)"
|
||
build_init "c27" # a new release, as far as the volume can tell
|
||
dc up -d technitium-console >"$WORK/out" 2>&1 || c27=1 # what the README says updating is
|
||
[ "$(init_exit)" = "0" ] || c27=1
|
||
served probe-marker.txt || c27=1
|
||
[ "$(curl -s "$(base)/probe-marker.txt")" = "c27" ] || c27=1
|
||
[ "$(started_at)" = "$before" ] || c27=1 # the server was never restarted
|
||
dc up -d >>"$WORK/out" 2>&1 || c27=1 # nor by bringing the whole file up again
|
||
[ "$(started_at)" = "$before" ] || c27=1
|
||
! dc logs technitium-console 2>&1 | grep -qi 'backed up' || c27=1 # a second pass is not a first install
|
||
verdict "C27" $c27 "updating the console restarts nothing and is served at once"
|
||
|
||
# --------------------------------------------- C28 · D3, the lists stay, and W6
|
||
c28=0
|
||
in_volume 'printf "[{\"name\":\"mine\"}]\n" > /v/json/quick-block-lists-custom.json' || c28=1
|
||
build_init "c28"
|
||
dc up -d technitium-console >"$WORK/out" 2>&1 || c28=1
|
||
[ "$(init_exit)" = "0" ] || c28=1
|
||
served probe-marker.txt || c28=1
|
||
[ "$(curl -s "$(base)/probe-marker.txt")" = "c28" ] || c28=1
|
||
[ "$(curl -s "$(base)/json/quick-block-lists-custom.json")" = '[{"name":"mine"}]' ] || c28=1
|
||
dc up -d --force-recreate dns-server >>"$WORK/out" 2>&1 || c28=1
|
||
served "$ASSET" || c28=1
|
||
[ "$(curl -s "$(base)/json/quick-block-lists-custom.json")" = '[{"name":"mine"}]' ] || c28=1
|
||
dc down -v >/dev/null 2>&1
|
||
# A folder on the host, holding only a list written by hand before the first run.
|
||
mkdir -p "$WORK/bind/json"
|
||
printf '[{"name":"by hand"}]\n' > "$WORK/bind/json/quick-forwarders-list-custom.json"
|
||
compose_file "$WORK/bind"
|
||
dc up -d >>"$WORK/out" 2>&1 || c28=1
|
||
[ "$(init_exit)" = "0" ] || c28=1
|
||
served "$ASSET" || c28=1
|
||
[ "$(cat "$WORK/bind/json/quick-forwarders-list-custom.json")" = '[{"name":"by hand"}]' ] || c28=1
|
||
[ "$(curl -s "$(base)/json/quick-forwarders-list-custom.json")" = '[{"name":"by hand"}]' ] || c28=1
|
||
# The bind folder, through an update and a new server container.
|
||
build_init "c28b"
|
||
dc up -d technitium-console >>"$WORK/out" 2>&1 || c28=1
|
||
[ "$(init_exit)" = "0" ] || c28=1
|
||
[ "$(curl -s "$(base)/probe-marker.txt")" = "c28b" ] || c28=1
|
||
[ "$(cat "$WORK/bind/json/quick-forwarders-list-custom.json")" = '[{"name":"by hand"}]' ] || c28=1
|
||
dc up -d --force-recreate dns-server >>"$WORK/out" 2>&1 || c28=1
|
||
served "$ASSET" || c28=1
|
||
[ "$(curl -s "$(base)/probe-marker.txt")" = "c28b" ] || c28=1
|
||
[ "$(curl -s "$(base)/json/quick-forwarders-list-custom.json")" = '[{"name":"by hand"}]' ] || c28=1
|
||
dc down -v >/dev/null 2>&1
|
||
verdict "C28" $c28 "custom lists, in the volume or in a host folder, survive updates and a new server container"
|
||
|
||
# --------------------------------- C29 · D1, a failing init does not stop the DNS
|
||
c29=0
|
||
echo "step: a volume holding somebody else's files" > "$WORK/log29"
|
||
compose_file technitium-console
|
||
dc create >"$WORK/out" 2>&1 || c29=1 # the volume exists; nothing runs yet
|
||
in_volume 'printf "keep\n" > /v/notes.txt' || c29=1 # somebody else's files: W6 has to stop the init
|
||
dc up -d >>"$WORK/out" 2>&1 || c29=1
|
||
[ "$(init_exit)" = "1" ] || c29=1
|
||
dc logs technitium-console 2>&1 | grep -q 'not a Technitium console' || c29=1 # …and stopped it for that reason
|
||
[ "$(docker inspect -f '{{.State.Running}}' "$(dc ps -q dns-server)")" = "true" ] || c29=1
|
||
i=0; while [ "$i" -lt 60 ] && ! curl -s -o /dev/null "$(base)/"; do i=$((i+1)); sleep 1; done
|
||
curl -s -o /dev/null "$(base)/" || c29=1 # and it answers
|
||
[ "$(in_volume 'cat /v/notes.txt')" = "keep" ] || c29=1
|
||
in_volume "[ ! -e /v/$ASSET ] && [ ! -e /v/.technitium-console ]" || c29=1 # nothing written, not even the marker
|
||
dc down -v >/dev/null 2>&1
|
||
|
||
echo "step: no volume at /target: c29=$c29" >> "$WORK/log29"
|
||
docker run --rm "$INIT_IMAGE" >"$WORK/novol" 2>&1 && c29=1 # no volume at /target: refused,
|
||
grep -q 'not a mounted volume' "$WORK/novol" || c29=1 # and not copied into nothing
|
||
|
||
echo "step: a bad or a missing checksum: c29=$c29" >> "$WORK/log29"
|
||
# The same image with its .sha256 altered, and with it gone: entrypoint and all.
|
||
printf '%064d technitium-console.tar.gz\n' 0 > "$WORK/img/bad.sha256"
|
||
printf 'FROM %s\nCOPY bad.sha256 /usr/share/technitium-console/technitium-console.tar.gz.sha256\n' "$INIT_IMAGE" > "$WORK/img/Dockerfile.badsum"
|
||
printf 'FROM %s\nRUN rm /usr/share/technitium-console/technitium-console.tar.gz.sha256\n' "$INIT_IMAGE" > "$WORK/img/Dockerfile.nosum"
|
||
for v in badsum nosum; do
|
||
docker build -q -f "$WORK/img/Dockerfile.$v" -t "$INIT_IMAGE-$v" "$WORK/img" >/dev/null 2>&1 || c29=1
|
||
done
|
||
dc create >>"$WORK/out" 2>&1 || c29=1 # a fresh, empty volume
|
||
for v in badsum nosum; do
|
||
docker run --rm -v "${PROJECT}_technitium-console:/target" "$INIT_IMAGE-$v" >>"$WORK/out" 2>&1 && c29=1
|
||
[ -z "$(in_volume 'ls -A /v')" ] || c29=1 # nothing written, not even the marker
|
||
done
|
||
grep -q 'does not match the release' "$WORK/out" || c29=1
|
||
grep -q 'no checksum next to' "$WORK/out" || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
|
||
echo "step: an interrupted first copy, then the next run: c29=$c29" >> "$WORK/log29"
|
||
dc create >>"$WORK/out" 2>&1 || c29=1
|
||
# What a first copy stopped halfway leaves: the marker, some assets, no page.
|
||
docker run --rm --entrypoint sh -v "${PROJECT}_technitium-console:/target" "$INIT_IMAGE" -c \
|
||
'mkdir /tmp/x && tar -xzf /usr/share/technitium-console/technitium-console.tar.gz -C /tmp/x &&
|
||
cp -a /tmp/x/assets /target/ && printf "technitium-console\n" > /target/.technitium-console' >>"$WORK/out" 2>&1 || c29=1
|
||
in_volume "[ -f /v/$ASSET ] && [ ! -e /v/index.html ]" || c29=1
|
||
dc up -d >>"$WORK/out" 2>&1 || c29=1 # the next up finishes it
|
||
[ "$(init_exit)" = "0" ] || c29=1
|
||
served "$ASSET" || c29=1
|
||
served index.html || c29=1
|
||
in_volume '[ -f /v/.technitium-console ]' || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
|
||
echo "step: a host folder with custom lists and another file: c29=$c29" >> "$WORK/log29"
|
||
mkdir -p "$WORK/mixed/json"
|
||
printf '[{"name":"mine"}]\n' > "$WORK/mixed/json/quick-block-lists-custom.json"
|
||
printf 'keep\n' > "$WORK/mixed/notes.txt"
|
||
compose_file "$WORK/mixed"
|
||
dc up -d >>"$WORK/out" 2>&1 || c29=1
|
||
[ "$(init_exit)" = "1" ] || c29=1
|
||
dc logs technitium-console 2>&1 | grep -q 'not a Technitium console' || c29=1
|
||
[ "$(docker inspect -f '{{.State.Running}}' "$(dc ps -q dns-server)")" = "true" ] || c29=1
|
||
[ "$(cat "$WORK/mixed/notes.txt")" = "keep" ] || c29=1
|
||
[ "$(cat "$WORK/mixed/json/quick-block-lists-custom.json")" = '[{"name":"mine"}]' ] || c29=1
|
||
[ ! -e "$WORK/mixed/$ASSET" ] && [ ! -e "$WORK/mixed/.technitium-console" ] || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
echo "step: the volumes swapped, the init's /target on the server's config: c29=$c29" >> "$WORK/log29"
|
||
compose_file technitium-console config
|
||
dc up -d dns-server >>"$WORK/out" 2>&1 || c29=1 # the server's data first, as on a live host
|
||
i=0; while [ "$i" -lt 60 ] && ! in_config '[ -f /v/dns.config ]'; do i=$((i+1)); sleep 1; done
|
||
dc stop dns-server >>"$WORK/out" 2>&1 || c29=1 # so nothing moves under the comparison
|
||
in_config 'find /v -type f -exec md5sum {} + | sort' > "$WORK/conf.before"
|
||
grep -q '/v/dns.config$' "$WORK/conf.before" || c29=1 # or the comparison proves nothing
|
||
dc up -d technitium-console >>"$WORK/out" 2>&1 || c29=1
|
||
[ "$(init_exit)" = "1" ] || c29=1
|
||
dc logs technitium-console 2>&1 | grep -q "DNS server's configuration" || c29=1
|
||
in_config 'find /v -type f -exec md5sum {} + | sort' > "$WORK/conf.after"
|
||
cmp -s "$WORK/conf.before" "$WORK/conf.after" || c29=1
|
||
in_config "[ ! -e /v/.technitium-console ] && [ ! -e /v/$ASSET ]" || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
|
||
echo "step: the volumes swapped on a first up, init and server at once, three times: c29=$c29" >> "$WORK/log29"
|
||
# The init may get there before the server has written anything, and then the
|
||
# folder is empty and it installs. What must hold is that it never removes what
|
||
# the server writes meanwhile: its sweep only touches what was there before.
|
||
n=0
|
||
while [ "$n" -lt 3 ]; do
|
||
n=$((n+1))
|
||
compose_file technitium-console config
|
||
dc up -d >>"$WORK/out" 2>&1 || c29=1
|
||
init_exit >/dev/null # whichever way it went
|
||
first_boot='[ -f /v/dns.config ] && [ -f /v/auth.config ] && [ -f /v/webservice.config ] && [ -f /v/scopes/Default.scope ] && [ -d /v/blocklists ]'
|
||
i=0
|
||
while [ "$i" -lt 60 ] && ! in_config "$first_boot"; do i=$((i+1)); sleep 1; done
|
||
in_config "$first_boot" || {
|
||
c29=1; echo " run $n: a first-boot file of the server is missing: $(in_config 'ls -A /v /v/scopes' | tr '\n' ' ')" >> "$WORK/log29"
|
||
}
|
||
[ "$(docker inspect -f '{{.State.Running}}' "$(dc ps -q dns-server)")" = "true" ] || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
done
|
||
|
||
echo "step: a folder holding the marker and a dns.config: c29=$c29" >> "$WORK/log29"
|
||
mkdir -p "$WORK/confmark"
|
||
printf 'technitium-console\n' > "$WORK/confmark/.technitium-console"
|
||
printf '{}\n' > "$WORK/confmark/dns.config"
|
||
compose_file "$WORK/confmark"
|
||
dc up -d >>"$WORK/out" 2>&1 || c29=1
|
||
[ "$(init_exit)" = "1" ] || c29=1
|
||
dc logs technitium-console 2>&1 | grep -q "DNS server's configuration" || c29=1
|
||
[ "$(cat "$WORK/confmark/dns.config")" = '{}' ] || c29=1
|
||
[ ! -e "$WORK/confmark/$ASSET" ] || c29=1
|
||
dc down -v >/dev/null 2>&1
|
||
echo "step: done: c29=$c29" >> "$WORK/log29"
|
||
cat "$WORK/log29" >> "$WORK/out"
|
||
verdict "C29" $c29 "an init that fails never keeps the DNS server from starting"
|
||
|
||
# ------------------------------- C30 · the Docker host gets a way in and a way out
|
||
c30=0
|
||
echo "step: a server with nothing set up, started with docker run" > "$WORK/log30"
|
||
start_server
|
||
host > "$WORK/out" 2>&1 || c30=1
|
||
section "$NAME" | grep -q 'ghcr.io/bygarcia/technitium-console' || c30=1
|
||
section "$NAME" | grep -q -- '-e DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console' || c30=1
|
||
! grep -q 'sudo sh --dir' "$WORK/out" || c30=1
|
||
! section "$NAME" | grep -q '/opt/technitium/dns/www:ro' || c30=1
|
||
host --uninstall > "$WORK/out" 2>&1 || c30=1
|
||
section "$NAME" | grep -q 'Nothing to remove' || c30=1
|
||
stop_server
|
||
echo "step: the README's compose layout, set up: c30=$c30" >> "$WORK/log30"
|
||
build_init
|
||
compose_file technitium-console
|
||
dc up -d >/dev/null 2>&1; init_exit >/dev/null
|
||
dns="$(docker inspect -f '{{.Name}}' "$(dc ps -q dns-server)" | sed 's|^/||')"
|
||
host > "$WORK/out" 2>&1 || c30=1
|
||
section "$dns" | grep -q 'already set up' || c30=1
|
||
dcp="docker compose -p $PROJECT -f $WORK/compose.yaml" # the project, named: runnable from anywhere
|
||
section "$dns" | grep -qF "$dcp pull technitium-console && $dcp up -d technitium-console" || c30=1
|
||
upd="$(section "$dns" | sed -n 's/.*&& \(docker compose .* up -d technitium-console\)$/\1/p')"
|
||
before="$(started_at)"
|
||
(cd / && sh -c "$upd") >/dev/null 2>&1 || c30=1 # run as printed, from another folder
|
||
[ "$(init_exit)" = "0" ] || c30=1
|
||
[ "$(started_at)" = "$before" ] || c30=1
|
||
host --uninstall > "$WORK/out" 2>&1 || c30=1
|
||
section "$dns" | grep -qF "$dcp up -d --remove-orphans" || c30=1
|
||
section "$dns" | grep -q "docker volume rm ${PROJECT}_technitium-console" || c30=1
|
||
dc down -v >/dev/null 2>&1
|
||
echo "step: a host folder over www, installed with --dir (the README before this): c30=$c30" >> "$WORK/log30"
|
||
mkdir -p "$WORK/legacy"
|
||
start_server -v "$WORK/legacy:$WWW:ro"
|
||
# A second server on the same folder, read-write: its own line, and one removal.
|
||
docker rm -f "$NAME-rw" >/dev/null 2>&1
|
||
docker run -d --name "$NAME-rw" -v "$WORK/legacy:$WWW" "$IMAGE" >/dev/null 2>&1 || c30=1
|
||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v "$WORK/legacy:$WORK/legacy" \
|
||
-v "$WORK/console.tar.gz:/console.tar.gz:ro" -v "$INSTALLER:/install.sh:ro" docker:cli \
|
||
sh -c "sh /install.sh --dir $WORK/legacy --from /console.tar.gz --yes && sh /install.sh --uninstall --dir $WORK/legacy" \
|
||
> "$WORK/out" 2>&1 || c30=1
|
||
section "$NAME" | grep -q "$WORK/legacy:$WWW:ro" || c30=1 # the mount to take out, named
|
||
section "$NAME-rw" | grep -qF -- "-v $WORK/legacy:$WWW (" || c30=1 # as it is mounted, read-write
|
||
! section "$NAME-rw" | grep -q "$WWW:ro" || c30=1
|
||
[ "$(grep -c "sudo rm -rf $WORK/legacy" "$WORK/out")" = "1" ] || c30=1 # removed once, after both
|
||
[ -f "$WORK/legacy/$ASSET" ] || c30=1 # and the folder left whole until then
|
||
docker rm -f "$NAME-rw" >/dev/null 2>&1
|
||
stop_server
|
||
echo "step: a bind of /, a source with a |, a folder that is not the console: c30=$c30" >> "$WORK/log30"
|
||
mkdir -p "$WORK/odd|dir" "$WORK/other"
|
||
printf 'keep\n' > "$WORK/other/notes.txt"
|
||
# Stand-ins, not servers: a command that names DnsServerApp.dll is what makes a
|
||
# container a Technitium one to the installer, and a sleep cannot crash. Nothing
|
||
# serves this machine's / over a web port.
|
||
stand_in() { # name, docker run arguments
|
||
sn="$1"; shift
|
||
docker run -d --name "$sn" "$@" busybox:stable sh -c 'exec sleep 600' /opt/technitium/dns/DnsServerApp.dll >/dev/null 2>&1
|
||
}
|
||
stand_in "$NAME-root" -v "/:$WWW:ro" || c30=1
|
||
stand_in "$NAME-pipe" -v "$WORK/odd|dir:$WWW:ro" || c30=1
|
||
stand_in "$NAME-other" -v "$WORK/other:$WWW:ro" || c30=1
|
||
stand_in "$NAME-vol" -v "$NAME-www:$WWW" || c30=1 # a named volume over www
|
||
# The folders where the script can see them, as on the host itself.
|
||
docker run --rm -i -v /var/run/docker.sock:/var/run/docker.sock -v "$WORK:$WORK" docker:cli \
|
||
sh -s -- --uninstall < "$INSTALLER" > "$WORK/out" 2>&1 || c30=1
|
||
for c in root pipe other; do
|
||
section "$NAME-$c" > "$WORK/sec"
|
||
! grep -q 'rm -rf' "$WORK/sec" || c30=1 # nothing to paste that could remove the wrong thing
|
||
grep -q 'remove it yourself' "$WORK/sec" || c30=1
|
||
done
|
||
section "$NAME-pipe" | grep -qF "$WORK/odd|dir" || c30=1 # named whole, not cut at the |
|
||
section "$NAME-root" | grep -q 'the folder mounted there' || c30=1
|
||
! section "$NAME-root" | grep -q '//json' || c30=1
|
||
section "$NAME-vol" | grep -qF "docker volume rm $NAME-www" || c30=1 # a volume is removed as one
|
||
! section "$NAME-vol" | grep -q 'rm -rf' || c30=1
|
||
host > "$WORK/out" 2>&1 || c30=1
|
||
section "$NAME-root" | grep -q 'cannot be named safely' || c30=1 # said, not merely left out:
|
||
! section "$NAME-root" | grep -q -- '--dir' || c30=1 # no update aimed at /
|
||
docker rm -f "$NAME-root" "$NAME-pipe" "$NAME-other" "$NAME-vol" >/dev/null 2>&1
|
||
docker volume rm "$NAME-www" >/dev/null 2>&1
|
||
echo "step: done: c30=$c30" >> "$WORK/log30"
|
||
cat "$WORK/log30" >> "$WORK/out"
|
||
verdict "C30" $c30 "on a Docker host it prints the steps in and out, with real names and no \$0"
|
||
|
||
fi
|
||
|
||
printf '\n %d met · %d not met · %d not applicable to this image\n\n' "$PASS" "$FAIL" "$SKIP"
|
||
exit "$FAIL"
|