fix(bootstrap): secrets.env、offsite.env、felis.toml 改为临时文件+fsync+rename 原子写入
This commit is contained in:
2 files changed
+104
-14
No files matched your search
+23
-12
@@ -705,6 +705,24 @@ secret_key_to_file() {
|
|||||||
mv -f -- "$tmp" "$path"
|
mv -f -- "$tmp" "$path"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# write_file_atomic path mode < content: path ends up holding all of content, or is
|
||||||
|
# left as it was. A crash or a full disk halfway through a plain `cat >` leaves a
|
||||||
|
# truncated file that the next run takes as the truth: a secrets.env cut short mints
|
||||||
|
# passwords the database does not know, an offsite.env cut short a key that cannot
|
||||||
|
# read the bucket. The temp file sits beside path, so mv is a rename on one
|
||||||
|
# filesystem; mktemp makes it 0600 before anything is in it, it is on the disk
|
||||||
|
# before the rename, and the directory is synced after it, so the new name survives
|
||||||
|
# a power cut too.
|
||||||
|
write_file_atomic() {
|
||||||
|
local path="$1" mode="$2" tmp
|
||||||
|
tmp="$(mktemp "${path}.XXXXXX")" || die "could not create a temporary file beside ${path}"
|
||||||
|
remember_temp "$tmp"
|
||||||
|
{ cat > "$tmp" && chmod "$mode" "$tmp" && sync -- "$tmp"; } \
|
||||||
|
|| die "could not write ${path}; it is left as it was"
|
||||||
|
mv -f -- "$tmp" "$path" || die "could not replace ${path}; it is left as it was"
|
||||||
|
sync -- "$(dirname -- "$path")" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# apply_setup_credential_secrets applies the Secrets behind `felis setup`'s email and
|
# apply_setup_credential_secrets applies the Secrets behind `felis setup`'s email and
|
||||||
# uploads-bucket screens from their host copies: felis-smtp in the control namespace and
|
# uploads-bucket screens from their host copies: felis-smtp in the control namespace and
|
||||||
# in the workload one (the reaper's warning mails read that copy), felis-uploads-s3 in the
|
# in the workload one (the reaper's warning mails read that copy), felis-uploads-s3 in the
|
||||||
@@ -4155,9 +4173,7 @@ load_or_make_secrets() {
|
|||||||
REGISTRY_PLATFORM_TOKEN="${REGISTRY_PLATFORM_TOKEN:-$(openssl rand -hex 32)}"
|
REGISTRY_PLATFORM_TOKEN="${REGISTRY_PLATFORM_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
REGISTRY_BUILD_TOKEN="${REGISTRY_BUILD_TOKEN:-$(openssl rand -hex 32)}"
|
REGISTRY_BUILD_TOKEN="${REGISTRY_BUILD_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
REGISTRY_PRUNE_TOKEN="${REGISTRY_PRUNE_TOKEN:-$(openssl rand -hex 32)}"
|
REGISTRY_PRUNE_TOKEN="${REGISTRY_PRUNE_TOKEN:-$(openssl rand -hex 32)}"
|
||||||
(
|
write_file_atomic "$SECRETS_ENV" 0600 <<EOF
|
||||||
umask 077
|
|
||||||
cat > "$SECRETS_ENV" <<EOF
|
|
||||||
DB_PASSWORD=${DB_PASSWORD}
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
SERVICE_TOKEN=${SERVICE_TOKEN}
|
SERVICE_TOKEN=${SERVICE_TOKEN}
|
||||||
LIMBO_TOKEN=${LIMBO_TOKEN}
|
LIMBO_TOKEN=${LIMBO_TOKEN}
|
||||||
@@ -4169,8 +4185,6 @@ REGISTRY_PLATFORM_TOKEN=${REGISTRY_PLATFORM_TOKEN}
|
|||||||
REGISTRY_BUILD_TOKEN=${REGISTRY_BUILD_TOKEN}
|
REGISTRY_BUILD_TOKEN=${REGISTRY_BUILD_TOKEN}
|
||||||
REGISTRY_PRUNE_TOKEN=${REGISTRY_PRUNE_TOKEN}
|
REGISTRY_PRUNE_TOKEN=${REGISTRY_PRUNE_TOKEN}
|
||||||
EOF
|
EOF
|
||||||
)
|
|
||||||
chmod 0600 "$SECRETS_ENV"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_panel_tls_cert() {
|
ensure_panel_tls_cert() {
|
||||||
@@ -4432,7 +4446,8 @@ offsite_enabled() {
|
|||||||
|
|
||||||
# write_felis_toml target host:port [namespace/deployment]: the deployment is the
|
# write_felis_toml target host:port [namespace/deployment]: the deployment is the
|
||||||
# database's pod, where `felis db` runs pg_dump, pg_restore and psql (the host has no
|
# database's pod, where `felis db` runs pg_dump, pg_restore and psql (the host has no
|
||||||
# PostgreSQL client); only the host copy names it.
|
# PostgreSQL client); only the host copy names it. The file is 0600: its url holds the
|
||||||
|
# database password.
|
||||||
write_felis_toml() {
|
write_felis_toml() {
|
||||||
local target="$1" db_addr="$2" deployment="${3:-}" deployment_line="" smtp_block auth_body auth_source_blocks registry_block archive_block offsite_section
|
local target="$1" db_addr="$2" deployment="${3:-}" deployment_line="" smtp_block auth_body auth_source_blocks registry_block archive_block offsite_section
|
||||||
if [ -n "$deployment" ]; then
|
if [ -n "$deployment" ]; then
|
||||||
@@ -4467,7 +4482,7 @@ deployment = \"${deployment}\""
|
|||||||
if [ -n "$offsite_section" ]; then
|
if [ -n "$offsite_section" ]; then
|
||||||
offsite_section="${offsite_section}"$'\n\n' # keep a blank line before the next section
|
offsite_section="${offsite_section}"$'\n\n' # keep a blank line before the next section
|
||||||
fi
|
fi
|
||||||
cat > "$target" <<EOF
|
write_file_atomic "$target" 0600 <<EOF
|
||||||
# Generated by deploy/bootstrap.sh; rerun the installer to regenerate. Hand edits are
|
# Generated by deploy/bootstrap.sh; rerun the installer to regenerate. Hand edits are
|
||||||
# overwritten, except [auth], [smtp], [[auth_source]], [offsite], and the operator-owned
|
# overwritten, except [auth], [smtp], [[auth_source]], [offsite], and the operator-owned
|
||||||
# [registry] / [archive] overrides, which carry forward. Move the install to another
|
# [registry] / [archive] overrides, which carry forward. Move the install to another
|
||||||
@@ -4993,9 +5008,7 @@ configure_offsite() {
|
|||||||
FELIS_OFFSITE_KEY="$(openssl rand -base64 32)"
|
FELIS_OFFSITE_KEY="$(openssl rand -base64 32)"
|
||||||
OFFSITE_KEY_NEW=1
|
OFFSITE_KEY_NEW=1
|
||||||
fi
|
fi
|
||||||
(
|
write_file_atomic "$OFFSITE_ENV" 0600 <<EOF
|
||||||
umask 077
|
|
||||||
cat > "$OFFSITE_ENV" <<EOF
|
|
||||||
# The off-site copy's secrets (felis offsite, docs/troubleshooting.md §16). Keep a copy of
|
# The off-site copy's secrets (felis offsite, docs/troubleshooting.md §16). Keep a copy of
|
||||||
# FELIS_OFFSITE_KEY somewhere other than this machine: without it the copies in the
|
# FELIS_OFFSITE_KEY somewhere other than this machine: without it the copies in the
|
||||||
# bucket cannot be read, and this file goes with the machine.
|
# bucket cannot be read, and this file goes with the machine.
|
||||||
@@ -5003,8 +5016,6 @@ FELIS_OFFSITE_ACCESS_KEY='${FELIS_OFFSITE_ACCESS_KEY}'
|
|||||||
FELIS_OFFSITE_SECRET_KEY='${FELIS_OFFSITE_SECRET_KEY}'
|
FELIS_OFFSITE_SECRET_KEY='${FELIS_OFFSITE_SECRET_KEY}'
|
||||||
FELIS_OFFSITE_KEY='${FELIS_OFFSITE_KEY}'
|
FELIS_OFFSITE_KEY='${FELIS_OFFSITE_KEY}'
|
||||||
EOF
|
EOF
|
||||||
)
|
|
||||||
chmod 0600 "$OFFSITE_ENV"
|
|
||||||
ok "off-site copy: secrets in ${OFFSITE_ENV}"
|
ok "off-site copy: secrets in ${OFFSITE_ENV}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1478,6 +1478,63 @@ else
|
|||||||
fi
|
fi
|
||||||
rm -f "$kubcalls"
|
rm -f "$kubcalls"
|
||||||
|
|
||||||
|
# --- write_file_atomic leaves the old file whole when a write fails ----------------------
|
||||||
|
# secrets.env, offsite.env and felis.host.toml were written with a plain `cat >`: a full
|
||||||
|
# disk or a crash halfway left a truncated file that the next run took as the truth.
|
||||||
|
|
||||||
|
wablock="$(awk '/^write_file_atomic\(\) \{/,/^}/' "$BS")"
|
||||||
|
[ -n "$wablock" ] || { echo "FAIL: no write_file_atomic found in $BS"; exit 1; }
|
||||||
|
[ "$(printf '%s\n' "$wablock" | wc -l)" -lt 20 ] \
|
||||||
|
|| { echo "FAIL: the extracted block is not write_file_atomic -- did its closing brace move?"; exit 1; }
|
||||||
|
wafile="$(mktemp)"
|
||||||
|
printf '%s\n' "$wablock" > "$wafile"
|
||||||
|
wadir="$(mktemp -d)"
|
||||||
|
run_atomic() { # script; under the installer's shell options, its temp files removed on exit as cleanup does
|
||||||
|
FNFILE="$wafile" bash -c '
|
||||||
|
set -Eeuo pipefail
|
||||||
|
die() { printf "DIE: %s\n" "$*"; exit 1; }
|
||||||
|
TEMP_PATHS=()
|
||||||
|
remember_temp() { TEMP_PATHS+=("$1"); }
|
||||||
|
trap '\''for p in "${TEMP_PATHS[@]-}"; do [ -z "$p" ] || rm -f -- "$p"; done'\'' EXIT
|
||||||
|
. "$FNFILE"
|
||||||
|
'"$1" 2>&1
|
||||||
|
}
|
||||||
|
mode_of() { ls -l "$1" | cut -c1-10; }
|
||||||
|
|
||||||
|
printf 'A=1\nB=2\n' > "$wadir/in.new"
|
||||||
|
printf 'DB_PASSWORD=new\nSESSION_SECRET=new\n' > "$wadir/in.replace"
|
||||||
|
out="$(run_atomic "umask 000; write_file_atomic '$wadir/new.env' 0600 < '$wadir/in.new'; echo done")"
|
||||||
|
expect "an atomic write finishes" "done" "$out"
|
||||||
|
expect "an atomic write holds all of its input" "$(printf 'A=1\nB=2')" "$(cat "$wadir/new.env")"
|
||||||
|
expect "an atomic write is private under a permissive umask" "-rw-------" "$(mode_of "$wadir/new.env")"
|
||||||
|
run_atomic "write_file_atomic '$wadir/new.env' 0640 < '$wadir/in.new'" >/dev/null
|
||||||
|
expect "an atomic write sets the mode it is given" "-rw-r-----" "$(mode_of "$wadir/new.env")"
|
||||||
|
|
||||||
|
printf 'DB_PASSWORD=old-and-whole\nSESSION_SECRET=kept\n' > "$wadir/old.env"
|
||||||
|
out="$(run_atomic "cat() { head -c 7; return 1; }; write_file_atomic '$wadir/old.env' 0600 < '$wadir/in.replace'; echo survived")"
|
||||||
|
expect "a write that fails halfway dies" "DIE: could not write $wadir/old.env; it is left as it was" "$out"
|
||||||
|
expect "a write that fails halfway leaves the old file whole" \
|
||||||
|
"$(printf 'DB_PASSWORD=old-and-whole\nSESSION_SECRET=kept')" "$(cat "$wadir/old.env")"
|
||||||
|
out="$(run_atomic "sync() { return 1; }; write_file_atomic '$wadir/old.env' 0600 < '$wadir/in.replace'")"
|
||||||
|
expect "content that did not reach the disk does not replace the old file" "DIE: could not write $wadir/old.env" "$out"
|
||||||
|
expect "the old file survives a failed sync" "DB_PASSWORD=old-and-whole" "$(cat "$wadir/old.env")"
|
||||||
|
left="$(cd "$wadir" && ls -a | grep '^old\.env\.' || true)"
|
||||||
|
if [ -z "$left" ]; then
|
||||||
|
echo "PASS a failed write leaves no temp file beside the old one"
|
||||||
|
else
|
||||||
|
echo "FAIL a failed write left $left beside old.env"; fails=$((fails + 1))
|
||||||
|
fi
|
||||||
|
rm -rf "$wadir" "$wafile"
|
||||||
|
|
||||||
|
# The installer never runs load_or_make_secrets alone (it would mint real secrets), so
|
||||||
|
# its writer is checked by what it calls.
|
||||||
|
lsblock="$(awk '/^load_or_make_secrets\(\) \{/,/^}/' "$BS")"
|
||||||
|
expect "secrets.env is written whole or not at all" 'write_file_atomic "$SECRETS_ENV" 0600 <<EOF' "$lsblock"
|
||||||
|
case "$lsblock" in
|
||||||
|
*'> "$SECRETS_ENV"'*) echo "FAIL load_or_make_secrets still writes secrets.env in place"; fails=$((fails + 1)) ;;
|
||||||
|
*) echo "PASS load_or_make_secrets writes secrets.env nowhere else" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# --- installer re-runs keep the operator's [registry] overrides --------------------------
|
# --- installer re-runs keep the operator's [registry] overrides --------------------------
|
||||||
# §15's upgrade path is re-running the installer, but the build-lane mirrors and the
|
# §15's upgrade path is re-running the installer, but the build-lane mirrors and the
|
||||||
# uploads backend live in [registry] as hand-written keys (docs/troubleshooting.md §8e or
|
# uploads backend live in [registry] as hand-written keys (docs/troubleshooting.md §8e or
|
||||||
@@ -1498,7 +1555,7 @@ alblock="$(awk '/^auth_lines\(\) \{/,/^}/' "$BS")"
|
|||||||
# The blocks quote themselves (the awk program uses single quotes), so they are
|
# The blocks quote themselves (the awk program uses single quotes), so they are
|
||||||
# sourced from a file instead of being spliced into a single-quoted bash -c.
|
# sourced from a file instead of being spliced into a single-quoted bash -c.
|
||||||
fnfile="$(mktemp)"
|
fnfile="$(mktemp)"
|
||||||
printf '%s\n' "$prblock" "$pablock" "$poblock" "$oblock" "$palblock" "$ahblock" "$alblock" "$wrblock" > "$fnfile"
|
printf '%s\n' "$prblock" "$pablock" "$poblock" "$oblock" "$palblock" "$ahblock" "$alblock" "$wablock" "$wrblock" > "$fnfile"
|
||||||
|
|
||||||
rdir="$(mktemp -d)"
|
rdir="$(mktemp -d)"
|
||||||
cat > "$rdir/felis.host.toml" <<'TOML'
|
cat > "$rdir/felis.host.toml" <<'TOML'
|
||||||
@@ -1539,6 +1596,9 @@ run_write() { # out-file [state-dir] [database-deployment]; under the installer'
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
trap '\''echo "ERR near line $LINENO (exit $?)" >&2'\'' ERR
|
trap '\''echo "ERR near line $LINENO (exit $?)" >&2'\'' ERR
|
||||||
log() { :; }
|
log() { :; }
|
||||||
|
die() { printf "DIE: %s\n" "$*" >&2; exit 1; }
|
||||||
|
remember_temp() { :; }
|
||||||
|
[ -z "${CAT_FAILS:-}" ] || cat() { head -c 40; return 1; }
|
||||||
persisted_smtp_block() { :; }
|
persisted_smtp_block() { :; }
|
||||||
persisted_auth_source_blocks() { :; }
|
persisted_auth_source_blocks() { :; }
|
||||||
. "$FNFILE"
|
. "$FNFILE"
|
||||||
@@ -1603,6 +1663,16 @@ else
|
|||||||
diff "$rdir/out.toml" "$rdir/out2.toml" | head
|
diff "$rdir/out.toml" "$rdir/out2.toml" | head
|
||||||
fails=$((fails + 1))
|
fails=$((fails + 1))
|
||||||
fi
|
fi
|
||||||
|
expect "the config is private: its url holds the database password" "-rw-------" "$(ls -l "$rdir/out.toml" | cut -c1-10)"
|
||||||
|
# A re-run that cannot finish writing (a full disk) keeps the config it carries from.
|
||||||
|
out="$(CAT_FAILS=1 run_write "$rdir/felis.host.toml" 2>&1 || true)"
|
||||||
|
expect "a config write that fails halfway dies" "DIE: could not write $rdir/felis.host.toml; it is left as it was" "$out"
|
||||||
|
if cmp -s "$rdir/out.toml" "$rdir/felis.host.toml"; then
|
||||||
|
echo "PASS a config write that fails halfway leaves the old config whole"
|
||||||
|
else
|
||||||
|
echo "FAIL a failed write cut felis.host.toml short:"; head -3 "$rdir/felis.host.toml"; fails=$((fails + 1))
|
||||||
|
fi
|
||||||
|
rm -f "$rdir"/felis.host.toml.*
|
||||||
|
|
||||||
# --- installer re-runs keep [auth]; a different root domain is refused ------------------
|
# --- installer re-runs keep [auth]; a different root domain is refused ------------------
|
||||||
# The Cloudflare edge setup writes access_jwt_aud and client_ip_header into [auth] (the
|
# The Cloudflare edge setup writes access_jwt_aud and client_ip_header into [auth] (the
|
||||||
@@ -2110,7 +2180,7 @@ esac
|
|||||||
# must say so loudly.
|
# must say so loudly.
|
||||||
|
|
||||||
ofile="$(mktemp)"
|
ofile="$(mktemp)"
|
||||||
for fn in validate_offsite_settings persisted_offsite_block offsite_block offsite_enabled configure_offsite install_offsite_timer summary_offsite; do
|
for fn in write_file_atomic validate_offsite_settings persisted_offsite_block offsite_block offsite_enabled configure_offsite install_offsite_timer summary_offsite; do
|
||||||
blk="$(awk "/^${fn}\\(\\) \\{/,/^}/" "$BS")"
|
blk="$(awk "/^${fn}\\(\\) \\{/,/^}/" "$BS")"
|
||||||
[ -n "$blk" ] || { echo "FAIL: no ${fn} found in $BS"; exit 1; }
|
[ -n "$blk" ] || { echo "FAIL: no ${fn} found in $BS"; exit 1; }
|
||||||
[ "$(printf '%s\n' "$blk" | wc -l)" -lt 120 ] \
|
[ "$(printf '%s\n' "$blk" | wc -l)" -lt 120 ] \
|
||||||
@@ -2126,6 +2196,7 @@ run_offsite() { # script; runs with the off-site functions sourced
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
die() { printf "DIE: %s\n" "$*"; exit 1; }
|
die() { printf "DIE: %s\n" "$*"; exit 1; }
|
||||||
log() { printf "LOG: %s\n" "$*"; }; ok() { printf "OK: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
|
log() { printf "LOG: %s\n" "$*"; }; ok() { printf "OK: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
|
||||||
|
remember_temp() { :; }
|
||||||
systemctl() { printf "SYSTEMCTL: %s\n" "$*" >&2; }
|
systemctl() { printf "SYSTEMCTL: %s\n" "$*" >&2; }
|
||||||
fakefelis() { printf "RUN: %s\n" "$*" >&2; [ -z "${CHECK_FAILS:-}" ] || { echo "bucket: access denied" >&2; return 1; }
|
fakefelis() { printf "RUN: %s\n" "$*" >&2; [ -z "${CHECK_FAILS:-}" ] || { echo "bucket: access denied" >&2; return 1; }
|
||||||
[ -z "${KEY_MISMATCH:-}" ] || { echo "the bucket records key id 1111111111111111, this key is 2222222222222222" >&2; return 3; }
|
[ -z "${KEY_MISMATCH:-}" ] || { echo "the bucket records key id 1111111111111111, this key is 2222222222222222" >&2; return 3; }
|
||||||
@@ -2225,6 +2296,14 @@ out="$(FELIS_OFFSITE_ENDPOINT=https://s3.example FELIS_OFFSITE_BUCKET=felis \
|
|||||||
FELIS_OFFSITE_KEY=c29tZXRoaW5nIGVsc2UgZW50aXJlbHkgZGlmZmVyZW50IQ== run_offsite configure_offsite)"
|
FELIS_OFFSITE_KEY=c29tZXRoaW5nIGVsc2UgZW50aXJlbHkgZGlmZmVyZW50IQ== run_offsite configure_offsite)"
|
||||||
expect "a different key is refused" "DIE: FELIS_OFFSITE_KEY differs from the key in" "$out"
|
expect "a different key is refused" "DIE: FELIS_OFFSITE_KEY differs from the key in" "$out"
|
||||||
expect "the refusal leaves the key alone" "FELIS_OFFSITE_KEY='${key}'" "$(cat "$odir/offsite.env")"
|
expect "the refusal leaves the key alone" "FELIS_OFFSITE_KEY='${key}'" "$(cat "$odir/offsite.env")"
|
||||||
|
# A re-run that cannot finish writing offsite.env (a full disk) keeps the key that sealed
|
||||||
|
# the bucket: a file cut short before that line would have a new key minted next run.
|
||||||
|
out="$(FELIS_OFFSITE_ENDPOINT=https://s3.example FELIS_OFFSITE_BUCKET=felis \
|
||||||
|
FELIS_OFFSITE_ACCESS_KEY=AK3 run_offsite 'cat() { head -c 30; return 1; }; configure_offsite')"
|
||||||
|
expect "a write of offsite.env that fails halfway dies" "DIE: could not write $odir/offsite.env; it is left as it was" "$out"
|
||||||
|
expect "a failed write keeps the key that sealed the bucket" "FELIS_OFFSITE_KEY='${key}'" "$(cat "$odir/offsite.env")"
|
||||||
|
expect "a failed write keeps the credentials that worked" "FELIS_OFFSITE_ACCESS_KEY='AK2'" "$(cat "$odir/offsite.env")"
|
||||||
|
rm -f "$odir"/offsite.env.*
|
||||||
|
|
||||||
rm -f "$odir/offsite.env"
|
rm -f "$odir/offsite.env"
|
||||||
out="$(FELIS_OFFSITE_ENDPOINT=https://s3.example FELIS_OFFSITE_BUCKET=felis run_offsite configure_offsite)"
|
out="$(FELIS_OFFSITE_ENDPOINT=https://s3.example FELIS_OFFSITE_BUCKET=felis run_offsite configure_offsite)"
|
||||||
|
|||||||
Reference in new issue
Block a user