Files
warmbly/scripts/check-join-script.sh
T

228 lines
12 KiB
Bash
Executable File

#!/bin/sh
# Everything CI should know about the fleet join script.
#
# The script is served verbatim from the backend at GET /join.sh and is what a
# stranger pipes into a root shell to add a machine, which makes it the
# highest-consequence file in the repo that is not Go. Nothing else covered it,
# and that is how a systemd unit that could never start, an env file with a
# stray JSON fragment in it, and a state directory the node could not write all
# reached the branch at once.
#
# What is checked:
# - POSIX parse under dash, which is /bin/sh on Debian and Ubuntu
# - shellcheck, in sh mode
# - --help exits 0 and says something
# - the generated systemd unit is ONE ExecStart line with the image as a
# systemd variable, not a command substitution systemd would never expand
# - the mount list always includes the agent directory
# - a relative BLOB_FS_ROOT is refused rather than mounted
# - the local override env file is passed AFTER node.env, so it wins, and is
# created without ever truncating one that is already there
# - --dry-run masks every credential, including the ones that hide inside a
# URL, and does it with an expression this sed actually supports
set -eu
SCRIPT="internal/api/handler/nodescript/join.sh"
fail() { printf 'check-join-script: %s\n' "$*" >&2; exit 1; }
ok() { printf ' ok %s\n' "$*"; }
[ -f "$SCRIPT" ] || fail "$SCRIPT not found (run from the repository root)"
# POSIX parse. sh -n under a non-POSIX shell proves nothing about dash, which
# is /bin/sh on Debian and Ubuntu, so dash is used when it is there.
parse_check() {
if command -v dash >/dev/null 2>&1; then
dash -n "$1" || fail "dash -n failed on $1"
else
sh -n "$1" || fail "sh -n failed on $1"
fi
}
parse_check "$SCRIPT"
# The checker too: its own shellcheck-disable directives are load-bearing.
parse_check "$0"
if command -v dash >/dev/null 2>&1; then
ok "dash -n (script and checker)"
else
printf ' -- dash not installed; parsed with sh -n instead\n'
fi
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -s sh "$SCRIPT" || fail "shellcheck failed on $SCRIPT"
shellcheck -s sh "$0" || fail "shellcheck failed on $0"
ok "shellcheck -s sh (script and checker)"
else
printf ' -- shellcheck not installed; skipped\n'
fi
out=$(sh "$SCRIPT" --help) || fail "--help exited non-zero"
printf '%s' "$out" | grep -q -- "--token" || fail "--help does not document --token"
ok "--help"
# Everything below asserts on what the script RENDERS, never on its source
# text. A previous version of this file checked a heredoc copied in here, which
# meant putting the original `$(cat ...)` bug back left it passing green.
# The one non-default environment the unit is rendered under. Named for what
# it is rather than dressed up as a list: sh has no clean way to iterate blocks
# that themselves contain newlines, so a third variant means adding it to the
# `for` below by hand.
FS_BLOB_ENV='BLOB_PROVIDER=fs
BLOB_FS_ROOT=/var/lib/warmbly/blobs'
# NODE_ENV is forced empty rather than inherited: this repo exports NODE_ENV in
# several trees, and an inherited value would render a unit this check did not
# choose, or fail validate_blob_root for an unrelated reason.
unit=$(NODE_ENV="" sh "$SCRIPT" --print-unit) || fail "--print-unit failed"
# shellcheck disable=SC2016 # the pattern is literal on purpose; it must not expand
printf '%s\n' "$unit" | grep -q 'ExecStart=.*\${WARMBLY_IMAGE_REF}$' \
|| fail "ExecStart must end with the systemd variable \${WARMBLY_IMAGE_REF}"
# shellcheck disable=SC2016 # literal on purpose
if printf '%s\n' "$unit" | grep -q 'ExecStart=.*\$('; then
fail "ExecStart contains a command substitution; systemd never expands one"
fi
[ "$(printf '%s\n' "$unit" | grep -c '^ExecStart=')" = "1" ] \
|| fail "ExecStart must be exactly one line"
printf '%s\n' "$unit" | grep -q '^EnvironmentFile=/var/lib/warmbly/image-ref$' \
|| fail "image-ref must stay in the root-owned state dir; the node must not be able to rewrite it"
printf '%s\n' "$unit" | grep -q 'ExecStart=.*-v /var/lib/warmbly/node:/var/lib/warmbly/node' \
|| fail "the agent directory must always be mounted, or auto-update stops silently"
ok "rendered unit (no blob mount)"
# node.local.env is the operator's half of the config and the only one a
# re-join does not rewrite. Docker applies --env-file in order, so it has to
# come AFTER node.env or an override silently loses to the generated value.
# Matched as one ordered pattern rather than two greps, which would pass with
# the files reversed.
printf '%s\n' "$unit" | grep -q 'ExecStart=.*--env-file /etc/warmbly/node.env .*--env-file /etc/warmbly/node.local.env' \
|| fail "node.local.env must be passed after node.env, or a local override loses to the generated value"
ok "local override env file is passed last"
# With local blobs the root has to be mounted too, and the line must still be
# one line: a multi-line mount list is how the continuation collapsed before.
unit=$(NODE_ENV="$FS_BLOB_ENV" sh "$SCRIPT" --print-unit) || fail "--print-unit with blobs failed"
printf '%s\n' "$unit" | grep -q 'ExecStart=.*-v /var/lib/warmbly/blobs:/var/lib/warmbly/blobs' \
|| fail "BLOB_FS_ROOT must be mounted (the fs alias counts as filesystem)"
[ "$(printf '%s\n' "$unit" | grep -c '^ExecStart=')" = "1" ] \
|| fail "ExecStart must stay one line when a blob mount is added"
ok "rendered unit (fs alias + blob mount)"
# A relative root is refused rather than rendered into a mount docker rejects.
if NODE_ENV="BLOB_PROVIDER=filesystem
BLOB_FS_ROOT=data/blobs" sh "$SCRIPT" --print-unit >/dev/null 2>&1; then
fail "a relative BLOB_FS_ROOT must be refused, not mounted"
fi
ok "relative BLOB_FS_ROOT refused"
# NO EnvironmentFile may point into the node-writable mount. Asserting only
# that the right one exists is not enough: an extra one under AGENT_DIR would
# let the container choose the image root's `docker run --network host` runs.
# Checked in every render, not just the default one. EnvironmentFile does not
# vary with NODE_ENV today, but the point of this assertion is what someone
# changes tomorrow, and "today it is redundant" is exactly the reasoning that
# already dropped this guard once. One extra subshell is a fair price.
for variant_env in "" "$FS_BLOB_ENV"; do
v_unit=$(NODE_ENV="$variant_env" sh "$SCRIPT" --print-unit) || fail "--print-unit failed"
if printf '%s\n' "$v_unit" | grep '^EnvironmentFile=' | grep -q '/var/lib/warmbly/node'; then
fail "an EnvironmentFile points into the node-writable mount; the node could choose the image root runs"
fi
done
ok "no EnvironmentFile is node-writable (every render)"
# --dry-run prints the config a node would receive, which now includes a
# database DSN. Run the real function against the shapes a node is actually
# sent, rather than reading the expression: the first version of it used a BRE
# alternation, which is a GNU extension, so on any other sed it matched nothing
# and printed every secret in clear while looking correct in review.
redact_fn=$(awk '/^redact\(\) \{/ { inside = 1 } inside { print } inside && $0 == "}" { exit }' "$SCRIPT")
[ -n "$redact_fn" ] || fail "function redact() not found in $SCRIPT"
redacted=$(printf '%s\n' "$redact_fn" > /tmp/warmbly-redact.$$ && \
printf '%s\n' \
'PRIMARY_DB=postgres://warmbly:dbsecret@db.example.com:5432/warmbly' \
'NATS_URL=tls://bussecret@bus.example.com:4222' \
'REDIS=rediss://:cachesecret@bus.example.com:6380' \
'INTERNAL_API_TOKEN=tokensecret' \
'NODE_BROKER_TOKEN=brokersecret' \
'CREDENTIALS_ENCRYPTION_KEY=keysecret' \
'BOX_GOOGLE_CLIENT_SECRET=oauthsecret' \
'ENCRYPTED_KEYS_BACKEND_URL=https://api.example.com' \
| sh -c ". /tmp/warmbly-redact.$$; redact")
rm -f "/tmp/warmbly-redact.$$"
for leaked in dbsecret bussecret cachesecret tokensecret brokersecret keysecret oauthsecret; do
if printf '%s\n' "$redacted" | grep -q "$leaked"; then
fail "--dry-run prints $leaked in clear; the redaction does not cover it on this sed"
fi
done
# The addresses are the reason --dry-run exists, so they have to survive.
printf '%s\n' "$redacted" | grep -q 'db.example.com:5432' \
|| fail "redaction ate the database host; only the credential should go"
printf '%s\n' "$redacted" | grep -q '^ENCRYPTED_KEYS_BACKEND_URL=https://api.example.com$' \
|| fail "redaction masked an address that carries no credential"
ok "--dry-run masks every credential and keeps the addresses"
# Two invariants that leave no trace in the rendered unit and so cannot be
# caught above: both were real defects, so they are asserted at their call
# sites. Comments are stripped and the call is matched in command position, so
# a commented-out call fails while reformatting does not.
# body_of prints one function's body. Tolerant about the definition's spacing,
# and loud when the function is not found: an empty body would otherwise fail
# every assertion below with a message about the wrong thing.
body_of() {
out=$(awk -v fn="$1" '
$0 ~ "^" fn "[ \t]*\\([ \t]*\\)[ \t]*{" { inside = 1 }
inside { print }
inside && $0 == "}" { exit }' "$SCRIPT")
[ -n "$out" ] || fail "function $1() not found in $SCRIPT"
printf '%s\n' "$out"
}
# Match the call lines themselves, not any line mentioning the word: "enrol"
# also appears inside the word "enrolment" in a comment.
# Captured before asserting: body_of fails when the function is missing, and a
# pipeline would run it in a subshell where that failure is lost and the
# misleading assertion message wins.
main_body=$(body_of main)
printf '%s\n' "$main_body" | awk '
$1 == "enrol" { e = NR }
$1 == "validate_blob_root" { v = NR }
$1 == "write_config" { w = NR }
END { exit !(e && v && w && e < v && v < w) }' \
|| fail "main must call validate_blob_root between enrol and write_config"
ok "config is validated before anything is written"
# Field match on the first word, which cannot be fooled by the name appearing
# inside a string or a comment. That does mean the call has to stay a
# standalone statement; join.sh says so at the call site. A looser regex was
# tried and was satisfied by `warn "... ensure_blob_root ..."`, which is a much
# worse failure than a reformat that reports itself clearly.
install_body=$(body_of install_units)
printf '%s\n' "$install_body" | awk '$1 == "ensure_blob_root" { found = 1 } END { exit !found }' \
|| fail "install_units must call ensure_blob_root as a standalone statement, or a filesystem-blob node restart-loops"
ok "blob root is prepared before the unit is installed"
# The unit names node.local.env, so a join that does not create it leaves the
# service unable to start at all: docker refuses a missing --env-file.
write_body=$(body_of write_config)
printf '%s\n' "$write_body" | awk '$1 == "ensure_local_env" { found = 1 } END { exit !found }' \
|| fail "write_config must call ensure_local_env as a standalone statement; the unit names the file and docker refuses a missing --env-file"
ok "local override env file is created on join"
# The whole point of the file is that a re-join keeps it. A creation path that
# can truncate would discard the credential an operator put there, which is
# both silent and unrecoverable.
# Matched on the shape of the invariant rather than one spelling of it: some
# existence test naming the file, before the line that writes it. `if [ -f ]`,
# `if ! test -f` and an AND-OR all satisfy this; only dropping the guard does
# not.
local_body=$(body_of ensure_local_env)
printf '%s\n' "$local_body" | awk '
/-f .*node\.local\.env/ { guard = NR }
/> *"?\$CONFIG_DIR\/node\.local\.env"?/ { write = NR }
END { exit !(guard && write && guard < write) }' \
|| fail "ensure_local_env must test for an existing file before writing one, or a re-join truncates the operator's file"
ok "an existing local override file is never truncated"
printf 'check-join-script: all checks passed\n'