#!/bin/bash
#
# Stable root-installed launcher for the pull updater.
#
# The launcher is intentionally small. The full updater remains in the release
# checkout so a successful signed-tag checkout also updates the code used on
# the next timer tick. Before executing that code, this launcher proves that:
#
# * fleet/updater.sh is the exact blob recorded at HEAD; and
# * HEAD is the commit named by a trusted, signed, annotated release tag.
#
# This prevents a dirty checkout or an unsigned branch from replacing the
# root-executed updater merely because /usr/local/bin is a stable entry point.
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
HOME=/root
export HOME
unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH NODE_OPTIONS NODE_PATH \
PYTHONPATH PYTHONHOME PYTHONSTARTUP SSH_ASKPASS SSH_AUTH_SOCK 2>/dev/null || true
set -euo pipefail
: "${HOME:=/root}"
unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CONFIG GIT_CONFIG_COUNT
unset GIT_CONFIG_GLOBAL GIT_CONFIG_PARAMETERS GIT_CONFIG_SYSTEM GIT_DIR GIT_EXEC_PATH
unset GIT_INDEX_FILE GIT_NAMESPACE GIT_OBJECT_DIRECTORY GIT_PREFIX GIT_PROXY_COMMAND
unset GIT_QUARANTINE_PATH GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_SSH GIT_SSH_COMMAND
unset GIT_SSL_CAINFO GIT_SSL_CAPATH GIT_SSL_NO_VERIFY GIT_WORK_TREE
export GIT_NO_REPLACE_OBJECTS=1
export GIT_GRAFT_FILE=/dev/null/hiverelay-disabled
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=core.hooksPath
export GIT_CONFIG_VALUE_0=/dev/null
export GIT_CONFIG_KEY_1=core.fsmonitor
export GIT_CONFIG_VALUE_1=false
CONF="${HIVERELAY_UPDATER_CONF:-/etc/hiverelay-updater.conf}"
ALLOWED_SIGNERS="${HIVERELAY_ALLOWED_SIGNERS:-/etc/hiverelay/allowed-signers}"
UPDATER_ENV_TRUST_FILE="${HIVERELAY_UPDATER_ENV_TRUST_FILE:-/etc/hiverelay/hiverelay-updater.env}"
log() { echo "[updater-launcher $(date -u +%FT%TZ)] $*"; }
die() { log "ERR $*" >&2; exit 1; }
stat_record() {
stat -c '%u|%a|%h|%s|%d|%i|%Y|%Z' "$1" 2>/dev/null || \
stat -f '%u|%Lp|%l|%z|%d|%i|%m|%c' "$1" 2>/dev/null
}
safe_parent() {
local path="$1" label="$2" parent record uid mode rest mode_value
# Preserve the lexical chain so an intermediate symlink cannot disappear
# into an otherwise trusted physical target before it is inspected.
parent="$(dirname "$path")"
while :; do
[ -d "$parent" ] && [ ! -L "$parent" ] || die "$label parent is not a regular directory: $parent"
record="$(stat_record "$parent")" || die "cannot inspect $label parent: $parent"
IFS='|' read -r uid mode rest <<< "$record"
[ "$uid" = "$EUID" ] || [ "$uid" = 0 ] || die "$label parent must be owned by root or the effective user: $parent"
[[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "$label parent mode is malformed: $parent"
mode_value=$((8#$mode))
if (( (mode_value & 8#022) != 0 )); then
[ "$uid" = 0 ] && (( (mode_value & 8#1000) != 0 )) || \
die "$label parent must not be group/world writable: $parent"
fi
[ "$parent" = / ] && break
parent="$(dirname "$parent")"
done
}
require_physical_canonical_path() {
local path="$1" label="$2" physical_parent leaf canonical
physical_parent="$(cd -P "$(dirname "$path")" 2>/dev/null && pwd -P)" || \
die "$label parent cannot be resolved"
leaf="$(basename "$path")"
if [ "$physical_parent" = / ]; then canonical="/$leaf"; else canonical="$physical_parent/$leaf"; fi
[ "$path" = "$canonical" ] || \
die "$label path must be canonical and contain no symlink ancestors"
}
canonical_absolute_path() {
local value="$1"
[ -z "$value" ] && return 0
[[ "$value" = /* ]] && [ "${#value}" -le 4096 ] || return 1
[[ "$value" != *'//' ]] && [[ "$value" != *'/./'* ]] && [[ "$value" != *'/../'* ]] &&
[[ "$value" != */. ]] && [[ "$value" != */.. ]] &&
! printf '%s' "$value" | LC_ALL=C grep -q '[[:cntrl:]]'
}
validate_updater_env_value() {
local key="$1" value="$2" port
case "$key" in
HIVERELAY_SERVICE)
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.@-]{0,126}([.]service)?$ ]] || return 1
;;
HIVERELAY_API)
if [[ "$value" =~ ^http://(127[.]0[.]0[.]1|localhost):([0-9]{1,5})$ ]]; then
port="${BASH_REMATCH[2]}"
elif [[ "$value" =~ ^http://\[::1\]:([0-9]{1,5})$ ]]; then
port="${BASH_REMATCH[1]}"
else
return 1
fi
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1
;;
HIVERELAY_CONTROL_BRANCH)
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$ ]] &&
[[ "$value" != -* ]] && [[ "$value" != *..* ]] || return 1
;;
HIVERELAY_HEALTH_TIMEOUT|HIVERELAY_PUBLIC_GATEWAY_PROBE_TIMEOUT)
[[ "$value" =~ ^[1-9][0-9]{0,2}$ ]] && [ "$value" -le 300 ] || return 1
;;
HIVERELAY_REQUIRE_SIGNED_TAGS|HIVERELAY_PUBLIC_GATEWAY_PROBE_PUBLIC_SUFFIX_READY)
[ "$value" = 0 ] || [ "$value" = 1 ] || return 1
;;
HIVERELAY_CONTROL_STATE|HIVERELAY_ENV_FILE|HIVERELAY_PUBLIC_GATEWAY_PROBE_CONFIG|\
HIVERELAY_PUBLIC_GATEWAY_PROBE_NGINX_CONFIG|HIVERELAY_PUBLIC_GATEWAY_PROBE_NGINX_BINARY|\
HIVERELAY_PUBLIC_GATEWAY_PROBE_CA|HIVERELAY_PUBLIC_GATEWAY_PROBE_EVIDENCE|\
HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE|HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE_KEY|\
HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE_ROOT|HIVERELAY_PUBLIC_GATEWAY_OPS_SS_BINARY|\
HIVERELAY_PUBLIC_GATEWAY_OPS_EVIDENCE|HIVERELAY_PUBLIC_GATEWAY_QUARANTINE_COMMAND|\
HIVERELAY_PUBLIC_GATEWAY_QUARANTINE_BACKUP)
canonical_absolute_path "$value" || return 1
;;
esac
}
trusted_file_record() {
local path="$1" label="$2" exact_mode="${3:-}" max_size="${4:-0}"
local owner_policy="${5:-euid}"
local min_size="${6:-1}"
local require_single_link="${7:-1}"
local record uid mode nlink size rest mode_value
[[ "$path" = /* ]] || die "$label path must be absolute"
[ "${#path}" -le 4096 ] || die "$label path is too long"
require_physical_canonical_path "$path" "$label"
[ -f "$path" ] && [ ! -L "$path" ] || die "$label must be a regular non-symlink file"
safe_parent "$path" "$label"
record="$(stat_record "$path")" || die "cannot inspect $label"
IFS='|' read -r uid mode nlink size rest <<< "$record"
if [ "$owner_policy" = root-ok ]; then
[ "$uid" = "$EUID" ] || [ "$uid" = 0 ] || die "$label must be owned by root or the effective user"
else
[ "$uid" = "$EUID" ] || die "$label must be owned by the effective user"
fi
[ "$require_single_link" = 0 ] || [ "$nlink" = 1 ] || die "$label must have exactly one link"
[[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "$label mode is malformed"
mode_value=$((8#$mode))
(( (mode_value & 8#022) == 0 )) || die "$label must not be group/world writable"
[ -z "$exact_mode" ] || [ "$mode" = "$exact_mode" ] || die "$label must have mode $exact_mode"
if [ "$max_size" -gt 0 ]; then
[[ "$size" =~ ^[0-9]+$ ]] && [ "$size" -ge "$min_size" ] && [ "$size" -le "$max_size" ] || \
die "$label size must be from $min_size to $max_size bytes"
fi
printf '%s\n' "$record"
}
trusted_directory() {
local path="$1" label="$2" record uid mode rest mode_value
[[ "$path" = /* ]] || die "$label path must be absolute"
require_physical_canonical_path "$path" "$label"
[ -d "$path" ] && [ ! -L "$path" ] || die "$label must be a regular directory"
safe_parent "$path" "$label"
record="$(stat_record "$path")" || die "cannot inspect $label"
IFS='|' read -r uid mode rest <<< "$record"
[ "$uid" = "$EUID" ] || [ "$uid" = 0 ] || die "$label must be owned by root or the effective user"
mode_value=$((8#$mode))
(( (mode_value & 8#022) == 0 )) || die "$label must not be group/world writable"
}
sha256_file() {
if [ -x /usr/bin/shasum ]; then /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'
elif [ -x /usr/bin/sha256sum ]; then /usr/bin/sha256sum "$1" | /usr/bin/awk '{print $1}'
else die 'trusted SHA-256 utility is unavailable'
fi
}
read_config_value() {
local wanted="$1"
awk -v wanted="$wanted" '
/^[[:space:]]*($|#)/ { next }
{
line = $0
key = line
sub(/[[:space:]]*=.*/, "", key)
sub(/^[[:space:]]*/, "", key)
sub(/[[:space:]]*$/, "", key)
if (key != wanted) next
sub(/^[^=]*=[[:space:]]*/, "", line)
values[++count] = line
}
END {
if (count > 1) exit 2
if (count == 0) exit 1
print values[1]
}
' "$CONF"
}
strip_optional_quotes() {
local value="$1"
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value#\"}"
value="${value%\"}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value#\'}"
value="${value%\'}"
fi
printf '%s\n' "$value"
}
trusted_file_record "$CONF" 'updater config' '' 262144 >/dev/null
UPDATER_ENV_RECORD="$(trusted_file_record "$UPDATER_ENV_TRUST_FILE" 'updater EnvironmentFile' 600 262144 euid 0)"
UPDATER_ENV_SHA256="$(sha256_file "$UPDATER_ENV_TRUST_FILE")"
UPDATER_ENV_KEYS_SEEN=$'\n'
while IFS= read -r env_line || [ -n "$env_line" ]; do
[[ "$env_line" =~ ^[[:space:]]*($|#) ]] && continue
[[ "$env_line" == *=* ]] || die 'updater EnvironmentFile contains a malformed line'
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="$(printf '%s' "$env_key" | awk '{$1=$1; print}')"
env_value="$(printf '%s' "$env_value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ "$env_key" =~ ^HIVERELAY_[A-Z0-9_]+$ ]] || die 'updater EnvironmentFile contains an invalid key'
case "$UPDATER_ENV_KEYS_SEEN" in
*$'\n'"$env_key"$'\n'*) die "updater EnvironmentFile repeats $env_key" ;;
esac
case "$env_key" in
HIVERELAY_SERVICE|HIVERELAY_API|\
HIVERELAY_CONTROL_BRANCH|HIVERELAY_CONTROL_STATE|HIVERELAY_ENV_FILE|HIVERELAY_HEALTH_TIMEOUT|\
HIVERELAY_REQUIRE_SIGNED_TAGS|HIVERELAY_PUBLIC_GATEWAY_PROBE_CONFIG|\
HIVERELAY_PUBLIC_GATEWAY_PROBE_NGINX_CONFIG|HIVERELAY_PUBLIC_GATEWAY_PROBE_NGINX_BINARY|\
HIVERELAY_PUBLIC_GATEWAY_PROBE_CA|HIVERELAY_PUBLIC_GATEWAY_PROBE_EVIDENCE|\
HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE|HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE_KEY|\
HIVERELAY_PUBLIC_GATEWAY_OPS_CERTIFICATE_ROOT|HIVERELAY_PUBLIC_GATEWAY_OPS_SS_BINARY|\
HIVERELAY_PUBLIC_GATEWAY_OPS_EVIDENCE|HIVERELAY_PUBLIC_GATEWAY_PROBE_PUBLIC_SUFFIX_READY|\
HIVERELAY_PUBLIC_GATEWAY_PROBE_TIMEOUT|HIVERELAY_PUBLIC_GATEWAY_QUARANTINE_COMMAND|\
HIVERELAY_PUBLIC_GATEWAY_QUARANTINE_BACKUP) ;;
*) die "updater EnvironmentFile key $env_key is not allowed" ;;
esac
if [ "${#env_value}" -gt 4096 ] || printf '%s' "$env_value" | LC_ALL=C grep -q '[[:cntrl:]]'; then
die "updater EnvironmentFile value for $env_key is unsafe"
fi
env_value="$(strip_optional_quotes "$env_value")"
validate_updater_env_value "$env_key" "$env_value" || \
die "updater EnvironmentFile value for $env_key is not canonical or in range"
UPDATER_ENV_KEYS_SEEN="${UPDATER_ENV_KEYS_SEEN}${env_key}"$'\n'
export "$env_key=$env_value"
done < "$UPDATER_ENV_TRUST_FILE"
[ "$(trusted_file_record "$UPDATER_ENV_TRUST_FILE" 'updater EnvironmentFile' 600 262144 euid 0)" = "$UPDATER_ENV_RECORD" ] || \
die 'updater EnvironmentFile metadata changed while parsing'
[ "$(sha256_file "$UPDATER_ENV_TRUST_FILE")" = "$UPDATER_ENV_SHA256" ] || \
die 'updater EnvironmentFile content changed while parsing'
REPO_DIR="${HIVERELAY_REPO_DIR:-}"
if [ -z "$REPO_DIR" ]; then
configured_repo=""
if configured_repo="$(read_config_value REPO_DIR)"; then
REPO_DIR="$(strip_optional_quotes "$configured_repo")"
else
status=$?
[ "$status" -ne 2 ] || die "$CONF contains duplicate REPO_DIR entries"
fi
fi
REPO_DIR="${REPO_DIR:-$HOME/hiverelay}"
[[ "$REPO_DIR" = /* ]] || die "repository path must be absolute"
[ "${#REPO_DIR}" -le 4096 ] || die "repository path is too long"
if printf '%s' "$REPO_DIR" | LC_ALL=C grep -q '[[:space:][:cntrl:]]'; then
die "repository path must not contain whitespace or control characters"
fi
if printf '%s' "$ALLOWED_SIGNERS" | LC_ALL=C grep -q '[[:cntrl:]]'; then
die "allowed-signers path contains control characters"
fi
[[ "$ALLOWED_SIGNERS" = /* ]] || die "allowed-signers path must be absolute"
UPDATER="$REPO_DIR/fleet/updater.sh"
[ -d "$REPO_DIR/.git" ] || [ -f "$REPO_DIR/.git" ] || die "repository not found at $REPO_DIR"
trusted_directory "$REPO_DIR" 'repository'
[ -f "$UPDATER" ] && [ ! -L "$UPDATER" ] || die "trusted updater must be a regular non-symlink file"
UPDATER_RECORD="$(trusted_file_record "$UPDATER" 'trusted updater' '' 1048576 root-ok)"
ALLOWED_SIGNERS_RECORD="$(trusted_file_record "$ALLOWED_SIGNERS" 'allowed-signers file' '' 262144)"
ALLOWED_SIGNERS_SHA256="$(sha256_file "$ALLOWED_SIGNERS")"
ALLOWED_SIGNERS_SNAPSHOT="$(mktemp /tmp/hiverelay-allowed-signers.XXXXXX)" || die 'cannot allocate allowed-signers snapshot'
cleanup_snapshot() { rm -f "$ALLOWED_SIGNERS_SNAPSHOT"; }
trap cleanup_snapshot EXIT HUP INT TERM
/bin/cat "$ALLOWED_SIGNERS" > "$ALLOWED_SIGNERS_SNAPSHOT"
chmod 0400 "$ALLOWED_SIGNERS_SNAPSHOT"
[ "$(sha256_file "$ALLOWED_SIGNERS_SNAPSHOT")" = "$ALLOWED_SIGNERS_SHA256" ] || die 'allowed-signers changed while snapshotting'
GIT_BIN="${HIVERELAY_GIT_BIN:-/usr/bin/git}"
[[ "$GIT_BIN" = /* ]] && [ -x "$GIT_BIN" ] && [ -f "$GIT_BIN" ] && [ ! -L "$GIT_BIN" ] || \
die "trusted Git executable is unavailable at $GIT_BIN"
trusted_file_record "$GIT_BIN" 'trusted Git executable' '' 268435456 root-ok 1 0 >/dev/null
HEAD_SHA="$($GIT_BIN -C "$REPO_DIR" rev-parse --verify 'HEAD^{commit}' 2>/dev/null)" || \
die "cannot resolve repository HEAD"
[[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || die "repository HEAD is malformed"
EXPECTED_UPDATER_BLOB="$($GIT_BIN -C "$REPO_DIR" rev-parse --verify 'HEAD:fleet/updater.sh' 2>/dev/null)" || \
die "HEAD does not contain fleet/updater.sh"
ACTUAL_UPDATER_BLOB="$($GIT_BIN -C "$REPO_DIR" hash-object --no-filters "$UPDATER" 2>/dev/null)" || \
die "cannot hash fleet/updater.sh"
[ "$ACTUAL_UPDATER_BLOB" = "$EXPECTED_UPDATER_BLOB" ] || \
die "fleet/updater.sh differs from the signed checkout"
TRUSTED_TAG=""
while IFS= read -r tag; do
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || continue
[ "$($GIT_BIN -C "$REPO_DIR" cat-file -t "refs/tags/$tag" 2>/dev/null || true)" = tag ] || continue
tag_commit="$($GIT_BIN -C "$REPO_DIR" rev-parse --verify "refs/tags/$tag^{commit}" 2>/dev/null || true)"
[ "$tag_commit" = "$HEAD_SHA" ] || continue
verify_output=""
if verify_output="$($GIT_BIN -C "$REPO_DIR" \
-c gpg.format=ssh \
-c "gpg.ssh.allowedSignersFile=$ALLOWED_SIGNERS_SNAPSHOT" \
-c gpg.ssh.program=/usr/bin/ssh-keygen \
verify-tag --raw "$tag" 2>&1)" &&
{ printf '%s' "$verify_output" | grep -Eq 'GOODSIG|TRUST_(FULLY|ULTIMATE)' ||
printf '%s' "$verify_output" | grep -qi 'Good.*signature'; }; then
TRUSTED_TAG="$tag"
break
fi
done < <($GIT_BIN -C "$REPO_DIR" tag --points-at "$HEAD_SHA")
[ "$(trusted_file_record "$ALLOWED_SIGNERS" 'allowed-signers file' '' 262144)" = "$ALLOWED_SIGNERS_RECORD" ] || \
die 'allowed-signers metadata changed during release verification'
[ "$(sha256_file "$ALLOWED_SIGNERS")" = "$ALLOWED_SIGNERS_SHA256" ] || \
die 'allowed-signers content changed during release verification'
[ -n "$TRUSTED_TAG" ] || die "HEAD $HEAD_SHA is not an allowed-signer-verified release tag"
[ "$(trusted_file_record "$UPDATER" 'trusted updater' '' 1048576 root-ok)" = "$UPDATER_RECORD" ] || \
die 'trusted updater metadata changed during release verification'
FINAL_UPDATER_BLOB="$($GIT_BIN -C "$REPO_DIR" hash-object --no-filters "$UPDATER" 2>/dev/null)" || \
die "cannot re-hash fleet/updater.sh"
[ "$FINAL_UPDATER_BLOB" = "$EXPECTED_UPDATER_BLOB" ] || \
die "fleet/updater.sh changed during release verification"
export HIVERELAY_REPO_DIR="$REPO_DIR"
log "executing updater from trusted $TRUSTED_TAG ($HEAD_SHA)"
cleanup_snapshot
trap - EXIT HUP INT TERM
exec /bin/bash --noprofile --norc "$UPDATER" "$@"