mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
417 lines
16 KiB
Bash
417 lines
16 KiB
Bash
#!/usr/bin/env bash
|
|
# ProxMenux change journal — recording side.
|
|
#
|
|
# What a sysadmin holds against a tool like this one is not that it
|
|
# changes things: it is that afterwards nobody can say what it changed.
|
|
# Reading the script does not answer it either — a function of four
|
|
# hundred lines may alter two values, and the reader has no way to know
|
|
# which two.
|
|
#
|
|
# So the rule here is that a change is recorded because it could not be
|
|
# made any other way. These helpers are the writing path: they capture
|
|
# what was there, make the change, and record both. A function that uses
|
|
# them is auditable without its author having remembered anything, and a
|
|
# function that writes directly is a bug we can find by grepping.
|
|
#
|
|
# Nothing here needs sqlite, python or network access. Each entry is one
|
|
# small JSON file written whole into a spool directory, which the Monitor
|
|
# reads and consolidates. One file per entry means no two concurrent
|
|
# scripts can interleave a line, and an interrupted write leaves a file
|
|
# the reader skips rather than a corrupted log.
|
|
#
|
|
# Usage:
|
|
# source /usr/local/share/proxmenux/scripts/pmx_journal.sh
|
|
# pmx_journal_context "optimize_logrotate" "1.1"
|
|
# pmx_write_file /etc/logrotate.conf <<EOF
|
|
# ...
|
|
# EOF
|
|
# pmx_enable_service log2ram
|
|
#
|
|
# Everything degrades quietly: if the journal cannot be written, the
|
|
# change still happens. Recording must never be the reason an operation
|
|
# fails on somebody's host.
|
|
|
|
PMX_JOURNAL_ROOT="${PMX_JOURNAL_ROOT:-/usr/local/share/proxmenux/changes}"
|
|
PMX_JOURNAL_SPOOL="$PMX_JOURNAL_ROOT/spool"
|
|
PMX_JOURNAL_OBJECTS="$PMX_JOURNAL_ROOT/objects"
|
|
|
|
# Set by pmx_journal_context; every entry carries them.
|
|
PMX_JOURNAL_FUNCTION="${PMX_JOURNAL_FUNCTION:-}"
|
|
PMX_JOURNAL_VERSION="${PMX_JOURNAL_VERSION:-}"
|
|
PMX_JOURNAL_SOURCE="${PMX_JOURNAL_SOURCE:-${SCRIPT_SOURCE:-}}"
|
|
|
|
# Which function is making the changes that follow. Called once at the
|
|
# top of a function, so the entries it produces are attributable to it
|
|
# rather than to whichever script happened to source this file.
|
|
pmx_journal_context() {
|
|
PMX_JOURNAL_FUNCTION="${1:-unknown}"
|
|
PMX_JOURNAL_VERSION="${2:-}"
|
|
PMX_JOURNAL_SOURCE="${3:-${SCRIPT_SOURCE:-$(basename "${BASH_SOURCE[-1]:-unknown}")}}"
|
|
}
|
|
|
|
_pmx_journal_ready() {
|
|
mkdir -p "$PMX_JOURNAL_SPOOL" "$PMX_JOURNAL_OBJECTS" 2>/dev/null || return 1
|
|
chmod 700 "$PMX_JOURNAL_ROOT" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
# JSON string escaping in pure bash: no jq dependency on the recording
|
|
# side, because the recording side runs before anything is installed.
|
|
_pmx_json_escape() {
|
|
local text="$1"
|
|
text="${text//\\/\\\\}"
|
|
text="${text//\"/\\\"}"
|
|
text="${text//$'\n'/\\n}"
|
|
text="${text//$'\r'/\\r}"
|
|
text="${text//$'\t'/\\t}"
|
|
printf '%s' "$text"
|
|
}
|
|
|
|
# The largest file whose contents are worth keeping. Configuration is
|
|
# measured in kilobytes; a binary is measured in megabytes and shows no
|
|
# useful difference, so past this the journal records that the file was
|
|
# there and what it hashed to, and stops short of copying it. A host that
|
|
# fills its disk with captured binaries is a worse outcome than a change
|
|
# whose contents cannot be shown.
|
|
PMX_JOURNAL_MAX_OBJECT="${PMX_JOURNAL_MAX_OBJECT:-1048576}"
|
|
|
|
# Set by _pmx_store_object. Reported through globals rather than printed
|
|
# because a command substitution runs in a subshell: anything the helper
|
|
# set there would be lost on the way back, and the caller would record
|
|
# every capture as unrecoverable.
|
|
PMX_LAST_DIGEST=""
|
|
PMX_LAST_OBJECT_STORED=false
|
|
|
|
# Stores a file's contents and returns its digest, so an entry references
|
|
# the bytes rather than embedding them. Content is kept once however many
|
|
# times it is captured.
|
|
_pmx_store_object() {
|
|
local path="$1"
|
|
PMX_LAST_DIGEST=""
|
|
PMX_LAST_OBJECT_STORED=false
|
|
[ -f "$path" ] || return 1
|
|
local digest
|
|
digest="$(sha256sum "$path" 2>/dev/null | cut -d' ' -f1)" || return 1
|
|
[ -n "$digest" ] || return 1
|
|
PMX_LAST_DIGEST="$digest"
|
|
|
|
local size
|
|
size="$(stat -c %s "$path" 2>/dev/null || echo 0)"
|
|
if [ "$size" -gt "$PMX_JOURNAL_MAX_OBJECT" ] 2>/dev/null; then
|
|
# The digest still identifies what was there; the bytes are not
|
|
# kept, and the entry will say the change cannot be undone from
|
|
# the journal alone.
|
|
return 0
|
|
fi
|
|
|
|
local target="$PMX_JOURNAL_OBJECTS/${digest:0:2}/$digest"
|
|
if [ ! -f "$target" ]; then
|
|
mkdir -p "$(dirname "$target")" 2>/dev/null || return 1
|
|
cp "$path" "$target.tmp.$$" 2>/dev/null || return 1
|
|
chmod 600 "$target.tmp.$$" 2>/dev/null || true
|
|
mv "$target.tmp.$$" "$target" 2>/dev/null || return 1
|
|
fi
|
|
PMX_LAST_OBJECT_STORED=true
|
|
}
|
|
|
|
# Writes one entry. Callers pass key=value pairs; values are escaped
|
|
# here so no caller has to think about JSON.
|
|
_pmx_journal_record() {
|
|
_pmx_journal_ready || return 0
|
|
local entry="" key value first=1
|
|
for pair in "$@"; do
|
|
key="${pair%%=*}"
|
|
value="${pair#*=}"
|
|
[ "$first" = 1 ] && first=0 || entry+=","
|
|
# A key ending in _raw carries a number or a literal such as
|
|
# true/false/null and is written unquoted.
|
|
if [ "${key%_raw}" != "$key" ]; then
|
|
entry+="\"${key%_raw}\":${value}"
|
|
else
|
|
entry+="\"$key\":\"$(_pmx_json_escape "$value")\""
|
|
fi
|
|
done
|
|
local file
|
|
file="$PMX_JOURNAL_SPOOL/$(date +%s)-$$-${RANDOM}.json"
|
|
printf '{%s}\n' "$entry" > "$file.tmp" 2>/dev/null || return 0
|
|
chmod 600 "$file.tmp" 2>/dev/null || true
|
|
mv "$file.tmp" "$file" 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
_pmx_journal_common() {
|
|
printf '%s\n' \
|
|
"recorded_at_raw=$(date +%s)" \
|
|
"function=${PMX_JOURNAL_FUNCTION:-unknown}" \
|
|
"function_version=${PMX_JOURNAL_VERSION:-}" \
|
|
"source=${PMX_JOURNAL_SOURCE:-unknown}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Configuration: files this host had, and what they became
|
|
# ---------------------------------------------------------------------
|
|
|
|
# Replaces a file with what arrives on stdin, capturing what was there.
|
|
#
|
|
# pmx_write_file /etc/logrotate.conf <<EOF
|
|
# ...
|
|
# EOF
|
|
pmx_write_file() {
|
|
local path="$1"
|
|
local temp before after existed="false"
|
|
temp="$(mktemp)" || { cat > "$path"; return $?; }
|
|
cat > "$temp"
|
|
|
|
local kept="true"
|
|
if [ -f "$path" ]; then
|
|
existed="true"
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
fi
|
|
# The change itself. Permissions of an existing file are preserved by
|
|
# writing through it rather than replacing the inode.
|
|
if ! cat "$temp" > "$path" 2>/dev/null; then
|
|
rm -f "$temp"
|
|
return 1
|
|
fi
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
rm -f "$temp"
|
|
# Writing the same bytes back is not a change. Recording it would
|
|
# fill the journal with entries a reader has to open to discover
|
|
# nothing happened — which is exactly what re-running an idempotent
|
|
# post-install does.
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=write_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"existed_raw=$existed" \
|
|
"capture=$([ "$existed" = true ] && echo present || echo created)" \
|
|
"revert=$([ "$existed" = true ] && echo restore || echo remove)" \
|
|
"exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Applies a sed expression in place, capturing the file first.
|
|
#
|
|
# pmx_edit_file /etc/default/grub 's/^X=.*/X=1/'
|
|
pmx_edit_file() {
|
|
local path="$1"; shift
|
|
[ -f "$path" ] || return 1
|
|
local before after kept
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
sed -i "$@" "$path" || return 1
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
# An expression that matched nothing is not a change, and recording
|
|
# it would fill the journal with entries a reader has to dismiss.
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=edit_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"expression=$*" "capture=present" "revert=restore" \
|
|
"exactness=$([ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Removes a file, keeping its contents so the removal can be undone.
|
|
pmx_remove_file() {
|
|
local path="$1"
|
|
[ -e "$path" ] || return 0
|
|
local before kept
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
rm -f "$path" || return 1
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=remove_file" "target=$path" \
|
|
"before=${before:-}" "after=" "capture=present" \
|
|
"revert=restore" \
|
|
"exactness=$([ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Adds to a file, keeping what was there.
|
|
#
|
|
# Appending looks like it needs no capture — the previous content is
|
|
# still in the file — but the journal shows a change as the difference
|
|
# between two states, and a reader asking what a function did to a file
|
|
# should not have to reconstruct the first state by subtracting.
|
|
#
|
|
# printf 'ulimit -n 1048576\n' | pmx_append_file /root/.profile
|
|
pmx_append_file() {
|
|
local path="$1"
|
|
local temp before after existed="false"
|
|
temp="$(mktemp)" || { cat >> "$path"; return $?; }
|
|
cat > "$temp"
|
|
|
|
local kept="true"
|
|
if [ -f "$path" ]; then
|
|
existed="true"
|
|
_pmx_store_object "$path"
|
|
before="$PMX_LAST_DIGEST"; kept="$PMX_LAST_OBJECT_STORED"
|
|
fi
|
|
if ! cat "$temp" >> "$path" 2>/dev/null; then
|
|
rm -f "$temp"
|
|
return 1
|
|
fi
|
|
_pmx_store_object "$path"; after="$PMX_LAST_DIGEST"
|
|
rm -f "$temp"
|
|
[ "$before" = "$after" ] && return 0
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=append_file" "target=$path" \
|
|
"before=${before:-}" "after=${after:-}" \
|
|
"capture=$([ "$existed" = true ] && echo present || echo created)" \
|
|
"revert=$([ "$existed" = true ] && echo restore || echo remove)" \
|
|
"exactness=$([ "$existed" != true ] || [ "$kept" = true ] && echo exact || echo none)"
|
|
}
|
|
|
|
# Applies a setting through the command that owns it, capturing the
|
|
# state that command reports before and after.
|
|
#
|
|
# Some settings have no file to write: the timezone, whether the clock is
|
|
# disciplined, a bootloader entry. The tool that owns them is the only
|
|
# thing that can read them back, so it is asked twice — before and after
|
|
# — and the journal records the two answers.
|
|
#
|
|
# pmx_apply_setting "timezone" "timedatectl show -p Timezone --value" \
|
|
# timedatectl set-timezone "$timezone"
|
|
pmx_apply_setting() {
|
|
local name="$1" reader="$2"; shift 2
|
|
local before after
|
|
before="$(eval "$reader" 2>/dev/null | head -c 400)"
|
|
"$@" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(eval "$reader" 2>/dev/null | head -c 400)"
|
|
# A setting already at the wanted value is not a change.
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=apply_setting" "target=$name" \
|
|
"before_state=$before" "after_state=$after" "command=$*" \
|
|
"capture=present" "revert=reapply" \
|
|
"result=$([ $status -eq 0 ] && echo ok || echo failed)" \
|
|
"exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Installation: what was not on this host and now is
|
|
# ---------------------------------------------------------------------
|
|
|
|
# Installs packages, recording which ones actually arrived.
|
|
#
|
|
# What is recorded is the difference the operation made, not what was
|
|
# asked for: a package already present is not a change, and the
|
|
# dependencies apt pulled in are, even though nobody named them.
|
|
pmx_install_pkg() {
|
|
local -a requested=("$@")
|
|
[ ${#requested[@]} -gt 0 ] || return 0
|
|
|
|
local before_list after_list added
|
|
before_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)"
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y "${requested[@]}" >/dev/null 2>&1
|
|
local status=$?
|
|
after_list="$(dpkg-query -W -f='${binary:Package}\n' 2>/dev/null | sort -u)"
|
|
added="$(comm -13 <(printf '%s\n' "$before_list") <(printf '%s\n' "$after_list") | tr '\n' ' ')"
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=installation" "operation=install_package" \
|
|
"target=${requested[*]}" "installed=${added% }" \
|
|
"result=$([ $status -eq 0 ] && echo ok || echo failed)" \
|
|
"capture=present" "revert=purge" \
|
|
"exactness=$([ -n "${added// /}" ] && echo partial || echo none)"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Services: what was running, and what runs now
|
|
# ---------------------------------------------------------------------
|
|
|
|
_pmx_service_state() {
|
|
local unit="$1"
|
|
printf '%s/%s' \
|
|
"$(systemctl is-enabled "$unit" 2>/dev/null || echo unknown)" \
|
|
"$(systemctl is-active "$unit" 2>/dev/null || echo unknown)"
|
|
}
|
|
|
|
pmx_enable_service() {
|
|
local unit="$1"
|
|
local before after
|
|
before="$(_pmx_service_state "$unit")"
|
|
systemctl enable --now "$unit" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(_pmx_service_state "$unit")"
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=enable_service" "target=$unit" \
|
|
"before_state=$before" "after_state=$after" \
|
|
"capture=present" "revert=disable" "exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
pmx_disable_service() {
|
|
local unit="$1"
|
|
local before after
|
|
before="$(_pmx_service_state "$unit")"
|
|
systemctl disable --now "$unit" >/dev/null 2>&1
|
|
local status=$?
|
|
after="$(_pmx_service_state "$unit")"
|
|
[ "$before" = "$after" ] && return $status
|
|
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=configuration" "operation=disable_service" "target=$unit" \
|
|
"before_state=$before" "after_state=$after" \
|
|
"capture=present" "revert=enable" "exactness=exact"
|
|
return $status
|
|
}
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Execution: what ProxMenux ran on the user's behalf
|
|
# ---------------------------------------------------------------------
|
|
|
|
# For work ProxMenux launches but does not decide: a system upgrade, a
|
|
# rebuild. Recording it as a change of ours would claim authorship of
|
|
# whatever apt decided; recording nothing would leave a host that changed
|
|
# under the reader's feet with no trace of why.
|
|
pmx_record_execution() {
|
|
local description="$1"; shift
|
|
local command="$*"
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=execution" "operation=run_command" \
|
|
"target=$description" "command=$command" \
|
|
"capture=none" "revert=none" "exactness=none"
|
|
}
|
|
|
|
# Records that a function was applied without being able to say what it
|
|
# changed — the state before it ran is not knowable. Used by the
|
|
# registration path so a host carries an honest account of what was
|
|
# applied before the journal existed.
|
|
pmx_record_applied() {
|
|
local tool="$1" version="$2" state="${3:-applied}"
|
|
local -a fields
|
|
mapfile -t fields < <(_pmx_journal_common)
|
|
_pmx_journal_record "${fields[@]}" \
|
|
"class=registration" "operation=$state" "target=$tool" \
|
|
"function_version=$version" "capture=unknown" \
|
|
"revert=none" "exactness=none"
|
|
}
|