mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 14:48:56 +01:00
feat: initial guard-lib - shared tamper-resistance primitives
Extracts the chattr+bind-mount+systemd-watcher file-locking pattern (and a new pacman PreTransaction package-block primitive) out of screen-locker and steam-backlog-enforcer into a standalone, config-driven library so both projects (and steam-backlog-enforcer's new block-gaming feature) can share one implementation instead of maintaining parallel copies. - guardctl: CLI entry point (file-guard, package-block subcommands) - lib/file_guard.sh: chattr +i / bind-mount / systemd-path-watcher / pacman hook enforcement for a single target file, keyed by /etc/guard-lib/targets config; includes `sync` (adopt current content as canonical - for a caller's own legitimate edit) distinct from `pacman-relock` (revert drift) - lib/package_block.sh: time-locked pacman PreTransaction hook that refuses to (re)install a given package while a lock file's `until` timestamp is in the future; supports --bind-mount to compose with file-guard on the same lock file - pacman-hooks/: the installed hook scripts + .hook definitions - systemd/: guard-file@.path/.service watcher units, guard-bind-mount@.service Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFNiYQQgSLAkiBXswyimPq
This commit is contained in:
commit
c99cb8373e
59
guard-lib/guardctl
Executable file
59
guard-lib/guardctl
Executable file
@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# guardctl - unified CLI for guard-lib's file-guard and package-block primitives.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GUARD_LIB_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# When installed to /usr/local/bin, lib/ lives alongside it at
|
||||||
|
# /usr/local/lib/guard-lib; fall back to the repo layout for local runs.
|
||||||
|
if [[ -d "$GUARD_LIB_ROOT/lib" ]]; then
|
||||||
|
LIB_DIR="$GUARD_LIB_ROOT/lib"
|
||||||
|
elif [[ -d "/usr/local/lib/guard-lib" ]]; then
|
||||||
|
LIB_DIR="/usr/local/lib/guard-lib"
|
||||||
|
else
|
||||||
|
echo "guardctl: error: cannot locate lib/ directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=lib/common.sh
|
||||||
|
source "$LIB_DIR/common.sh"
|
||||||
|
# shellcheck source=lib/file_guard.sh
|
||||||
|
source "$LIB_DIR/file_guard.sh"
|
||||||
|
# shellcheck source=lib/package_block.sh
|
||||||
|
source "$LIB_DIR/package_block.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: guardctl <primitive> <subcommand> [args...]
|
||||||
|
|
||||||
|
Primitives:
|
||||||
|
file-guard install <name> --target <path> [--canonical <path>] [--bind-mount] [--plugin <script>] [--also-watch <path>]...
|
||||||
|
file-guard enforce <name>
|
||||||
|
file-guard unlock <name>
|
||||||
|
file-guard uninstall <name> [--keep-canonical]
|
||||||
|
file-guard status <name>
|
||||||
|
file-guard canonical-path <name>
|
||||||
|
file-guard bind-mount <name>
|
||||||
|
|
||||||
|
package-block start <name> --package <pkg> --lock-file <path> --days <N>
|
||||||
|
package-block check <name>
|
||||||
|
package-block status <name>
|
||||||
|
package-block end <name>
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local primitive="$1"
|
||||||
|
shift
|
||||||
|
case "$primitive" in
|
||||||
|
file-guard) file_guard_main "$@" ;;
|
||||||
|
package-block) package_block_main "$@" ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) usage; die "unknown primitive '$primitive'" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
44
guard-lib/install.sh
Executable file
44
guard-lib/install.sh
Executable file
@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Installs guard-lib: guardctl to /usr/local/bin, its lib/ to
|
||||||
|
# /usr/local/lib/guard-lib, systemd unit templates, and the generic
|
||||||
|
# unlock-all/relock-all pacman hooks. Safe to re-run (idempotent copy).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "install.sh must be run as root (it writes to /usr/local, /etc/systemd, /etc/pacman.d)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
for tool in jq chattr lsattr systemctl; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || {
|
||||||
|
echo "install.sh: required tool '$tool' not found on PATH" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Installing guardctl to /usr/local/bin ..."
|
||||||
|
install -m 755 "$SCRIPT_DIR/guardctl" /usr/local/bin/guardctl
|
||||||
|
|
||||||
|
echo "Installing lib/ to /usr/local/lib/guard-lib ..."
|
||||||
|
install -d -m 755 /usr/local/lib/guard-lib
|
||||||
|
install -m 644 "$SCRIPT_DIR"/lib/*.sh /usr/local/lib/guard-lib/
|
||||||
|
|
||||||
|
echo "Installing systemd unit templates ..."
|
||||||
|
install -m 644 "$SCRIPT_DIR"/systemd/guard-file@.path /etc/systemd/system/
|
||||||
|
install -m 644 "$SCRIPT_DIR"/systemd/guard-file@.service /etc/systemd/system/
|
||||||
|
install -m 644 "$SCRIPT_DIR"/systemd/guard-bind-mount@.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "Installing generic pacman unlock-all/relock-all hooks ..."
|
||||||
|
install -d -m 755 /etc/guard-lib/pacman-hooks
|
||||||
|
install -m 755 "$SCRIPT_DIR/pacman-hooks/guard-lib-unlock-all.sh" /etc/guard-lib/pacman-hooks/
|
||||||
|
install -m 755 "$SCRIPT_DIR/pacman-hooks/guard-lib-relock-all.sh" /etc/guard-lib/pacman-hooks/
|
||||||
|
install -d -m 755 /etc/pacman.d/hooks
|
||||||
|
install -m 644 "$SCRIPT_DIR/pacman-hooks/10-guard-lib-unlock-all.hook" /etc/pacman.d/hooks/
|
||||||
|
install -m 644 "$SCRIPT_DIR/pacman-hooks/90-guard-lib-relock-all.hook" /etc/pacman.d/hooks/
|
||||||
|
|
||||||
|
install -d -m 755 /etc/guard-lib/targets /etc/guard-lib/blocks /etc/guard-lib/canonical
|
||||||
|
|
||||||
|
echo "guard-lib installed. Try: guardctl file-guard install dummy-test --target /etc/guard-test-file"
|
||||||
132
guard-lib/lib/common.sh
Normal file
132
guard-lib/lib/common.sh
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Shared helpers for guardctl subcommands. Sourced, never executed directly.
|
||||||
|
|
||||||
|
GUARD_LIB_CONF_DIR="${GUARD_LIB_CONF_DIR:-/etc/guard-lib}"
|
||||||
|
GUARD_LIB_TARGETS_DIR="$GUARD_LIB_CONF_DIR/targets"
|
||||||
|
GUARD_LIB_BLOCKS_DIR="$GUARD_LIB_CONF_DIR/blocks"
|
||||||
|
GUARD_LIB_LOG="${GUARD_LIB_LOG:-/var/log/guard-lib.log}"
|
||||||
|
GUARD_LIB_BIN="${GUARD_LIB_BIN:-/usr/local/bin/guardctl}"
|
||||||
|
GUARD_LIB_SYSTEMD_DIR="${GUARD_LIB_SYSTEMD_DIR:-/etc/systemd/system}"
|
||||||
|
GUARD_LIB_PACMAN_HOOKS_DIR="${GUARD_LIB_PACMAN_HOOKS_DIR:-/etc/pacman.d/hooks}"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "guardctl: error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
|
die "must be run as root"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
log_hook() {
|
||||||
|
# Best-effort log write; never fail the caller because logging failed.
|
||||||
|
local msg="$1"
|
||||||
|
mkdir -p "$(dirname "$GUARD_LIB_LOG")" 2>/dev/null || true
|
||||||
|
printf '%s %s\n' "$(date -Is)" "$msg" >>"$GUARD_LIB_LOG" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
require_name() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -n "$name" ]] || die "instance name is required"
|
||||||
|
[[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || die "instance name '$name' has invalid characters"
|
||||||
|
}
|
||||||
|
|
||||||
|
target_conf_path() {
|
||||||
|
printf '%s/%s.conf' "$GUARD_LIB_TARGETS_DIR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
block_conf_path() {
|
||||||
|
printf '%s/%s.conf' "$GUARD_LIB_BLOCKS_DIR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Populates TARGET, CANONICAL, BIND_MOUNT, PLUGIN, ALSO_WATCH from a target
|
||||||
|
# conf file. Fails loudly if the instance is not registered - callers must
|
||||||
|
# not silently no-op on a missing config, since that is exactly the
|
||||||
|
# silent-loss-of-protection failure mode this tool exists to avoid.
|
||||||
|
# Sets these as global output vars for callers in file_guard.sh - shellcheck
|
||||||
|
# can't see the cross-file usage, hence the disables below.
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
load_target_conf() {
|
||||||
|
local name="$1"
|
||||||
|
local conf
|
||||||
|
conf="$(target_conf_path "$name")"
|
||||||
|
[[ -f "$conf" ]] || die "no such file-guard instance: $name (missing $conf)"
|
||||||
|
TARGET=""
|
||||||
|
CANONICAL=""
|
||||||
|
BIND_MOUNT="no"
|
||||||
|
PLUGIN=""
|
||||||
|
ALSO_WATCH=""
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$conf"
|
||||||
|
[[ -n "$TARGET" ]] || die "$conf did not set TARGET"
|
||||||
|
[[ -n "$CANONICAL" ]] || CANONICAL="$GUARD_LIB_CONF_DIR/canonical/$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets global output vars PACKAGE/LOCK_FILE/FILE_GUARD_NAME for callers in
|
||||||
|
# package_block.sh - same cross-file contract as load_target_conf above.
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
load_block_conf() {
|
||||||
|
local name="$1"
|
||||||
|
local conf
|
||||||
|
conf="$(block_conf_path "$name")"
|
||||||
|
[[ -f "$conf" ]] || die "no such package-block instance: $name (missing $conf)"
|
||||||
|
PACKAGE=""
|
||||||
|
LOCK_FILE=""
|
||||||
|
FILE_GUARD_NAME=""
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$conf"
|
||||||
|
[[ -n "$PACKAGE" ]] || die "$conf did not set PACKAGE"
|
||||||
|
[[ -n "$LOCK_FILE" ]] || die "$conf did not set LOCK_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs a plugin function if the plugin script defines it. No-op otherwise.
|
||||||
|
plugin_call() {
|
||||||
|
local fn="$1"
|
||||||
|
if declare -f "$fn" >/dev/null 2>&1; then
|
||||||
|
"$fn"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_plugin() {
|
||||||
|
local plugin="$1"
|
||||||
|
if [[ -n "$plugin" ]]; then
|
||||||
|
[[ -f "$plugin" ]] || die "plugin script not found: $plugin"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$plugin"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock_file() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || return 0
|
||||||
|
chattr -i -a "$path" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
lock_file() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || return 0
|
||||||
|
chattr +i "$path" 2>/dev/null || log_hook "WARNING: chattr +i failed on $path (unsupported filesystem?)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tears down a self-bind-mount read-only layer (see fg_bind_mount) so the
|
||||||
|
# underlying file becomes writable again. A read-only bind mount blocks
|
||||||
|
# writes and metadata changes (chattr included) at the VFS layer
|
||||||
|
# regardless of the inode's own permissions/attributes, so this must run
|
||||||
|
# before any write to a bind-mounted target - chattr alone is not enough.
|
||||||
|
collapse_bind_mount() {
|
||||||
|
local path="$1"
|
||||||
|
local i=0
|
||||||
|
while mountpoint -q "$path" 2>/dev/null; do
|
||||||
|
umount -l "$path" 2>/dev/null || break
|
||||||
|
i=$((i + 1))
|
||||||
|
((i > 20)) && break
|
||||||
|
done
|
||||||
|
# A while loop that exits via its (now-false) condition returns
|
||||||
|
# non-zero, which - combined with `set -e` and callers using
|
||||||
|
# `[[ COND ]] && collapse_bind_mount ...` - would silently abort the
|
||||||
|
# calling function right after a successful collapse. This is
|
||||||
|
# best-effort teardown; always report success to the caller.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
350
guard-lib/lib/file_guard.sh
Normal file
350
guard-lib/lib/file_guard.sh
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# guardctl file-guard subcommands. Sourced by guardctl, never executed directly.
|
||||||
|
|
||||||
|
restore_from_canonical() {
|
||||||
|
local name="$1"
|
||||||
|
[[ -e "$CANONICAL" ]] || return 1
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
mkdir -p "$(dirname "$TARGET")"
|
||||||
|
cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
|
||||||
|
log_hook "file-guard $name: restored $TARGET from canonical (drift/missing detected)"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot_canonical() {
|
||||||
|
local name="$1"
|
||||||
|
mkdir -p "$(dirname "$CANONICAL")"
|
||||||
|
unlock_file "$CANONICAL"
|
||||||
|
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
|
||||||
|
lock_file "$CANONICAL"
|
||||||
|
log_hook "file-guard $name: snapshotted $TARGET as new canonical"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_ensure_templates_installed() {
|
||||||
|
for unit in guard-file@.path guard-file@.service guard-bind-mount@.service; do
|
||||||
|
[[ -f "$GUARD_LIB_SYSTEMD_DIR/$unit" ]] || \
|
||||||
|
die "missing $GUARD_LIB_SYSTEMD_DIR/$unit - run guard-lib's install.sh first"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_install() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
require_name "$name"
|
||||||
|
local target="" canonical="" bind_mount="no" plugin="" also_watch=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--target) target="$2"; shift 2 ;;
|
||||||
|
--canonical) canonical="$2"; shift 2 ;;
|
||||||
|
--bind-mount) bind_mount="yes"; shift ;;
|
||||||
|
--plugin) plugin="$2"; shift 2 ;;
|
||||||
|
--also-watch) also_watch+=("$2"); shift 2 ;;
|
||||||
|
*) die "file-guard install: unknown argument '$1'" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "$target" ]] || die "file-guard install: --target is required"
|
||||||
|
target="$(realpath -m "$target")"
|
||||||
|
[[ -n "$plugin" ]] && plugin="$(realpath -m "$plugin")"
|
||||||
|
require_root
|
||||||
|
fg_ensure_templates_installed
|
||||||
|
|
||||||
|
local conf
|
||||||
|
conf="$(target_conf_path "$name")"
|
||||||
|
[[ -f "$conf" ]] && die "file-guard instance '$name' already installed (uninstall it first)"
|
||||||
|
[[ -n "$canonical" ]] || canonical="$GUARD_LIB_CONF_DIR/canonical/$name"
|
||||||
|
|
||||||
|
mkdir -p "$GUARD_LIB_TARGETS_DIR" "$(dirname "$canonical")"
|
||||||
|
cat >"$conf" <<EOF
|
||||||
|
TARGET="$target"
|
||||||
|
CANONICAL="$canonical"
|
||||||
|
BIND_MOUNT="$bind_mount"
|
||||||
|
PLUGIN="$plugin"
|
||||||
|
ALSO_WATCH="${also_watch[*]}"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
TARGET="$target"
|
||||||
|
CANONICAL="$canonical"
|
||||||
|
if [[ ! -e "$canonical" ]]; then
|
||||||
|
[[ -e "$target" ]] || die "file-guard install: target '$target' does not exist and no canonical to seed from"
|
||||||
|
snapshot_canonical "$name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$GUARD_LIB_SYSTEMD_DIR/guard-file@$name.path.d"
|
||||||
|
{
|
||||||
|
echo "[Path]"
|
||||||
|
echo "PathModified=$target"
|
||||||
|
for w in "${also_watch[@]}"; do
|
||||||
|
echo "PathModified=$w"
|
||||||
|
done
|
||||||
|
} >"$GUARD_LIB_SYSTEMD_DIR/guard-file@$name.path.d/override.conf"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now "guard-file@$name.path"
|
||||||
|
|
||||||
|
if [[ "$bind_mount" == "yes" ]]; then
|
||||||
|
systemctl enable --now "guard-bind-mount@$name.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fg_enforce "$name"
|
||||||
|
log_hook "file-guard $name: installed (target=$target bind_mount=$bind_mount)"
|
||||||
|
echo "installed file-guard '$name' for $target"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_enforce() {
|
||||||
|
local name="$1"
|
||||||
|
load_target_conf "$name"
|
||||||
|
# shellcheck disable=SC2153 # PLUGIN is set by load_target_conf, not a typo of local `plugin`
|
||||||
|
load_plugin "$PLUGIN"
|
||||||
|
|
||||||
|
plugin_call pre_action
|
||||||
|
|
||||||
|
if [[ ! -e "$TARGET" ]]; then
|
||||||
|
restore_from_canonical "$name" || die "cannot restore $TARGET: no canonical copy exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local validated=1
|
||||||
|
if declare -f validate >/dev/null 2>&1; then
|
||||||
|
if ! validate "$TARGET"; then
|
||||||
|
validated=0
|
||||||
|
if [[ -e "$CANONICAL" ]]; then
|
||||||
|
restore_from_canonical "$name"
|
||||||
|
else
|
||||||
|
plugin_call emergency_fix
|
||||||
|
fi
|
||||||
|
plugin_call post_restore_action
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$validated" -eq 1 ]]; then
|
||||||
|
if [[ -e "$CANONICAL" ]]; then
|
||||||
|
if ! cmp -s "$TARGET" "$CANONICAL"; then
|
||||||
|
restore_from_canonical "$name"
|
||||||
|
plugin_call post_restore_action
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
snapshot_canonical "$name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collapse before the final chattr dance too, not just for restores:
|
||||||
|
# if this instance is bind-mounted and already mounted read-only
|
||||||
|
# (e.g. every time enforce runs with no drift to restore), chattr
|
||||||
|
# itself fails against a read-only mount - silently leaving the
|
||||||
|
# target with no immutable flag at all.
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
lock_file "$TARGET"
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||||
|
log_hook "file-guard $name: enforce ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_unlock() {
|
||||||
|
local name="$1"
|
||||||
|
require_root
|
||||||
|
load_target_conf "$name"
|
||||||
|
# shellcheck disable=SC2153 # PLUGIN set by load_target_conf, not a typo
|
||||||
|
load_plugin "$PLUGIN"
|
||||||
|
echo "About to unlock file-guard '$name' ($TARGET) for editing."
|
||||||
|
read -rp "Reason for unlocking: " reason
|
||||||
|
[[ -n "$reason" ]] || die "a reason is required"
|
||||||
|
log_hook "file-guard $name: UNLOCK requested by ${SUDO_USER:-$(whoami)}: $reason"
|
||||||
|
echo "Unlocking in 45s (Ctrl-C to abort)..."
|
||||||
|
sleep 45
|
||||||
|
# Stop the watcher before unlocking: chattr alone (no content write)
|
||||||
|
# fires PathModified, and the resulting enforce pass unconditionally
|
||||||
|
# re-locks the target even with no drift - which would silently
|
||||||
|
# re-lock this file out from under the editor. Confirmed live during
|
||||||
|
# the shutdown-schedule migration (see guard-lib project notes).
|
||||||
|
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
log_hook "file-guard $name: unlocked for editing"
|
||||||
|
|
||||||
|
local before after
|
||||||
|
before="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||||
|
# Deliberately unquoted: $EDITOR commonly carries arguments (e.g.
|
||||||
|
# "code --wait", "emacsclient -t") that must be word-split.
|
||||||
|
${EDITOR:-vi} "$TARGET"
|
||||||
|
after="$(sha256sum "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||||
|
|
||||||
|
if [[ "$before" != "$after" ]]; then
|
||||||
|
if declare -f validate >/dev/null 2>&1 && ! validate "$TARGET"; then
|
||||||
|
echo "New content fails this instance's validate() check - reverting your edit; canonical unchanged." >&2
|
||||||
|
log_hook "file-guard $name: edit REJECTED (failed validate), reverting to canonical"
|
||||||
|
[[ -e "$CANONICAL" ]] && cp --preserve=mode,ownership,timestamps "$CANONICAL" "$TARGET"
|
||||||
|
else
|
||||||
|
# Update canonical to match: without this, re-locking below
|
||||||
|
# fires the watcher, which would see the new content as
|
||||||
|
# "drift" against the stale canonical and silently revert
|
||||||
|
# this legitimate edit.
|
||||||
|
log_hook "file-guard $name: content changed, updating canonical"
|
||||||
|
unlock_file "$CANONICAL"
|
||||||
|
cp --preserve=mode,ownership,timestamps "$TARGET" "$CANONICAL"
|
||||||
|
lock_file "$CANONICAL"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_hook "file-guard $name: no content change"
|
||||||
|
fi
|
||||||
|
|
||||||
|
lock_file "$TARGET"
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||||
|
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||||
|
log_hook "file-guard $name: re-locked after edit"
|
||||||
|
echo "Re-locked '$name'."
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_uninstall() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local keep_canonical=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--keep-canonical) keep_canonical=1; shift ;;
|
||||||
|
*) die "file-guard uninstall: unknown argument '$1'" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
require_root
|
||||||
|
load_target_conf "$name"
|
||||||
|
systemctl disable --now "guard-file@$name.path" 2>/dev/null || true
|
||||||
|
systemctl disable --now "guard-bind-mount@$name.service" 2>/dev/null || true
|
||||||
|
rm -rf "${GUARD_LIB_SYSTEMD_DIR:?}/guard-file@$name.path.d"
|
||||||
|
systemctl daemon-reload
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
if [[ "$keep_canonical" -eq 0 ]]; then
|
||||||
|
unlock_file "$CANONICAL"
|
||||||
|
rm -f "$CANONICAL"
|
||||||
|
fi
|
||||||
|
rm -f "$(target_conf_path "$name")"
|
||||||
|
log_hook "file-guard $name: uninstalled (keep_canonical=$keep_canonical)"
|
||||||
|
echo "uninstalled file-guard '$name'"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_status() {
|
||||||
|
local name="$1"
|
||||||
|
load_target_conf "$name"
|
||||||
|
echo "name: $name"
|
||||||
|
echo "target: $TARGET"
|
||||||
|
echo "canonical: $CANONICAL"
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT/PLUGIN/ALSO_WATCH set by load_target_conf, not typos
|
||||||
|
echo "bind_mount: $BIND_MOUNT"
|
||||||
|
echo "plugin: ${PLUGIN:-<none>}"
|
||||||
|
echo "also_watch: ${ALSO_WATCH:-<none>}"
|
||||||
|
if [[ -e "$TARGET" ]]; then
|
||||||
|
local attrs
|
||||||
|
attrs="$(lsattr "$TARGET" 2>/dev/null | awk '{print $1}')"
|
||||||
|
echo "target attrs: ${attrs:-unknown}"
|
||||||
|
else
|
||||||
|
echo "target: MISSING"
|
||||||
|
fi
|
||||||
|
local unit_state
|
||||||
|
unit_state="$(systemctl is-active "guard-file@$name.path" 2>/dev/null || true)"
|
||||||
|
echo "path unit: ${unit_state:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_canonical_path() {
|
||||||
|
# Machine-readable single-value output: the canonical path for an
|
||||||
|
# instance, so consumers with bespoke unlock flows (that can't go
|
||||||
|
# through `unlock`/`enforce` directly) don't have to hardcode or
|
||||||
|
# duplicate guard-lib's canonical-path convention.
|
||||||
|
local name="$1"
|
||||||
|
load_target_conf "$name"
|
||||||
|
echo "$CANONICAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_pacman_unlock() {
|
||||||
|
# Quiet, non-interactive unlock used only by the generic pacman
|
||||||
|
# PreTransaction hook - no typed reason, no editor. The pacman
|
||||||
|
# transaction itself is the sanctioned reason.
|
||||||
|
#
|
||||||
|
# Stops the path unit, not just chattr -i's the target: chattr alone
|
||||||
|
# fires PathModified, and if the watcher's enforce pass wins the race
|
||||||
|
# against pacman's own write, it silently re-locks the file before
|
||||||
|
# pacman gets to it, breaking the very transaction this hook exists
|
||||||
|
# to allow. Matches the pre-existing hosts-guard pacman hooks, which
|
||||||
|
# stop hosts-guard.path/nsswitch-guard.path/resolved-guard.path for
|
||||||
|
# the same reason.
|
||||||
|
local name="$1"
|
||||||
|
load_target_conf "$name"
|
||||||
|
systemctl stop "guard-file@${name}.path" 2>/dev/null || true
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
log_hook "file-guard $name: pacman-unlock (pre-transaction)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_pacman_relock() {
|
||||||
|
# Counterpart to fg_pacman_unlock, used only by the generic pacman
|
||||||
|
# PostTransaction hook: re-enforce (drift-restore + chattr +i, and -
|
||||||
|
# if this instance is bind-mounted - re-establish the read-only
|
||||||
|
# bind mount, since fg_enforce does that itself now) then restart
|
||||||
|
# the path unit that pacman-unlock stopped.
|
||||||
|
local name="$1"
|
||||||
|
fg_enforce "$name"
|
||||||
|
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||||
|
log_hook "file-guard $name: pacman-relock (post-transaction)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_sync_canonical() {
|
||||||
|
# Counterpart to fg_pacman_unlock for callers that legitimately EDITED
|
||||||
|
# the target (not pacman's own hook flow, which wants drift reverted).
|
||||||
|
# Adopts the target's current content as the new canonical, instead of
|
||||||
|
# fg_enforce's drift-check-and-revert - which would otherwise silently
|
||||||
|
# undo the very edit the caller just made. Assumes the watcher was
|
||||||
|
# already stopped by a prior fg_pacman_unlock; does not stop it itself.
|
||||||
|
local name="$1"
|
||||||
|
load_target_conf "$name"
|
||||||
|
snapshot_canonical "$name"
|
||||||
|
# shellcheck disable=SC2153 # BIND_MOUNT set by load_target_conf, not a typo
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && collapse_bind_mount "$TARGET"
|
||||||
|
unlock_file "$TARGET"
|
||||||
|
lock_file "$TARGET"
|
||||||
|
[[ "$BIND_MOUNT" == "yes" ]] && _reapply_bind_mount "$name"
|
||||||
|
systemctl start "guard-file@${name}.path" 2>/dev/null || true
|
||||||
|
log_hook "file-guard $name: synced canonical to current content"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Self-bind-mounts TARGET over itself, then remounts that layer
|
||||||
|
# read-only. This is a mount-level lock independent of chattr: even a
|
||||||
|
# successful `chattr -i` bypass still can't write through a read-only
|
||||||
|
# mount. Deliberately does NOT bind CANONICAL over TARGET - that would
|
||||||
|
# merge them onto the same inode, so chattr/writes to either would hit
|
||||||
|
# both and "restore from canonical" would become meaningless.
|
||||||
|
_reapply_bind_mount() {
|
||||||
|
local name="$1"
|
||||||
|
if mountpoint -q "$TARGET" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mount --bind "$TARGET" "$TARGET"
|
||||||
|
mount -o remount,ro,bind "$TARGET"
|
||||||
|
log_hook "file-guard $name: bind-mounted $TARGET read-only"
|
||||||
|
}
|
||||||
|
|
||||||
|
fg_bind_mount() {
|
||||||
|
local name="$1"
|
||||||
|
require_root
|
||||||
|
load_target_conf "$name"
|
||||||
|
_reapply_bind_mount "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
file_guard_main() {
|
||||||
|
local sub="$1"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
install) fg_install "$@" ;;
|
||||||
|
enforce) fg_enforce "$@" ;;
|
||||||
|
unlock) fg_unlock "$@" ;;
|
||||||
|
pacman-unlock) fg_pacman_unlock "$@" ;;
|
||||||
|
uninstall) fg_uninstall "$@" ;;
|
||||||
|
status) fg_status "$@" ;;
|
||||||
|
canonical-path) fg_canonical_path "$@" ;;
|
||||||
|
bind-mount) fg_bind_mount "$@" ;;
|
||||||
|
pacman-relock) fg_pacman_relock "$@" ;;
|
||||||
|
sync) fg_sync_canonical "$@" ;;
|
||||||
|
*) die "file-guard: unknown subcommand '$sub' (expected install|enforce|unlock|pacman-unlock|pacman-relock|sync|uninstall|status|canonical-path|bind-mount)" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
148
guard-lib/lib/package_block.sh
Normal file
148
guard-lib/lib/package_block.sh
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# guardctl package-block subcommands. Sourced by guardctl, never executed directly.
|
||||||
|
|
||||||
|
pb_hook_path() {
|
||||||
|
printf '%s/guard-lib-block-%s.hook' "$GUARD_LIB_PACMAN_HOOKS_DIR" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pb_start() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
require_name "$name"
|
||||||
|
local package="" lock_file="" days="" bind_mount_args=()
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--package) package="$2"; shift 2 ;;
|
||||||
|
--lock-file) lock_file="$2"; shift 2 ;;
|
||||||
|
--days) days="$2"; shift 2 ;;
|
||||||
|
--bind-mount) bind_mount_args=(--bind-mount); shift ;;
|
||||||
|
*) die "package-block start: unknown argument '$1'" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "$package" ]] || die "package-block start: --package is required"
|
||||||
|
[[ -n "$lock_file" ]] || die "package-block start: --lock-file is required"
|
||||||
|
[[ "$days" =~ ^[0-9]+$ ]] || die "package-block start: --days must be a positive integer"
|
||||||
|
require_root
|
||||||
|
|
||||||
|
local conf
|
||||||
|
conf="$(block_conf_path "$name")"
|
||||||
|
[[ -f "$conf" ]] && die "package-block instance '$name' already active (end it first)"
|
||||||
|
|
||||||
|
lock_file="$(realpath -m "$lock_file")"
|
||||||
|
local started_at until
|
||||||
|
started_at="$(date +%s)"
|
||||||
|
until=$((started_at + days * 86400))
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$lock_file")"
|
||||||
|
jq -n --argjson started_at "$started_at" --argjson until "$until" --argjson days "$days" \
|
||||||
|
'{started_at: $started_at, until: $until, days: $days}' >"$lock_file"
|
||||||
|
|
||||||
|
local fg_name="${name}-lock"
|
||||||
|
fg_install "$fg_name" --target "$lock_file" "${bind_mount_args[@]}"
|
||||||
|
|
||||||
|
mkdir -p "$GUARD_LIB_BLOCKS_DIR"
|
||||||
|
cat >"$conf" <<EOF
|
||||||
|
PACKAGE="$package"
|
||||||
|
LOCK_FILE="$lock_file"
|
||||||
|
FILE_GUARD_NAME="$fg_name"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$GUARD_LIB_PACMAN_HOOKS_DIR"
|
||||||
|
cat >"$(pb_hook_path "$name")" <<EOF
|
||||||
|
[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Type = Package
|
||||||
|
Target = $package
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = guard-lib: package-block '$name' guarding $package
|
||||||
|
When = PreTransaction
|
||||||
|
Exec = $GUARD_LIB_BIN package-block check $name
|
||||||
|
AbortOnFail
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_hook "package-block $name: started, blocking '$package' until $(date -d "@$until" -Is)"
|
||||||
|
echo "package-block '$name' active: '$package' blocked until $(date -d "@$until")"
|
||||||
|
}
|
||||||
|
|
||||||
|
pb_check() {
|
||||||
|
local name="$1"
|
||||||
|
local conf
|
||||||
|
conf="$(block_conf_path "$name")"
|
||||||
|
if [[ ! -f "$conf" ]]; then
|
||||||
|
log_hook "package-block $name: WARNING check ran with no registered instance, allowing (fail-open)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
load_block_conf "$name"
|
||||||
|
# shellcheck disable=SC2153 # LOCK_FILE/PACKAGE set by load_block_conf, not typos
|
||||||
|
if [[ ! -f "$LOCK_FILE" ]]; then
|
||||||
|
log_hook "package-block $name: WARNING lock file missing, allowing (fail-open)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local until
|
||||||
|
until="$(jq -r '.until // empty' "$LOCK_FILE" 2>/dev/null)"
|
||||||
|
if [[ ! "$until" =~ ^[0-9]+$ ]]; then
|
||||||
|
log_hook "package-block $name: WARNING lock file malformed, allowing (fail-open)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local now
|
||||||
|
now="$(date +%s)"
|
||||||
|
if [[ "$now" -lt "$until" ]]; then
|
||||||
|
# shellcheck disable=SC2153 # PACKAGE set by load_block_conf, not a typo
|
||||||
|
echo "guard-lib: '$PACKAGE' is blocked by package-block '$name' until $(date -d "@$until")." >&2
|
||||||
|
echo "guard-lib: this is intentional and cannot be lifted by a pacman transaction." >&2
|
||||||
|
log_hook "package-block $name: BLOCKED transaction against $PACKAGE (until $until)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_hook "package-block $name: lock expired ($until <= $now), allowing"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pb_status() {
|
||||||
|
local name="$1"
|
||||||
|
load_block_conf "$name"
|
||||||
|
echo "name: $name"
|
||||||
|
echo "package: $PACKAGE"
|
||||||
|
echo "lock file: $LOCK_FILE"
|
||||||
|
if [[ -f "$LOCK_FILE" ]]; then
|
||||||
|
local until now
|
||||||
|
until="$(jq -r '.until // empty' "$LOCK_FILE" 2>/dev/null)"
|
||||||
|
now="$(date +%s)"
|
||||||
|
if [[ "$until" =~ ^[0-9]+$ ]]; then
|
||||||
|
if [[ "$now" -lt "$until" ]]; then
|
||||||
|
echo "status: active, blocked until $(date -d "@$until")"
|
||||||
|
else
|
||||||
|
echo "status: expired at $(date -d "@$until") (run 'package-block end $name' to clean up)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "status: lock file malformed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "status: lock file missing"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pb_end() {
|
||||||
|
local name="$1"
|
||||||
|
require_root
|
||||||
|
load_block_conf "$name"
|
||||||
|
rm -f "$(pb_hook_path "$name")"
|
||||||
|
fg_uninstall "$FILE_GUARD_NAME"
|
||||||
|
rm -f "$LOCK_FILE"
|
||||||
|
rm -f "$(block_conf_path "$name")"
|
||||||
|
log_hook "package-block $name: ended (package=$PACKAGE)"
|
||||||
|
echo "package-block '$name' ended; '$PACKAGE' is no longer blocked by guard-lib"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_block_main() {
|
||||||
|
local sub="$1"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
start) pb_start "$@" ;;
|
||||||
|
check) pb_check "$@" ;;
|
||||||
|
status) pb_status "$@" ;;
|
||||||
|
end) pb_end "$@" ;;
|
||||||
|
*) die "package-block: unknown subcommand '$sub' (expected start|check|status|end)" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
12
guard-lib/pacman-hooks/10-guard-lib-unlock-all.hook
Normal file
12
guard-lib/pacman-hooks/10-guard-lib-unlock-all.hook
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Trigger]
|
||||||
|
Operation = Upgrade
|
||||||
|
Operation = Install
|
||||||
|
Operation = Remove
|
||||||
|
Type = Package
|
||||||
|
Target = *
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = guard-lib: unlocking protected files before pacman transaction
|
||||||
|
When = PreTransaction
|
||||||
|
Exec = /bin/bash /etc/guard-lib/pacman-hooks/guard-lib-unlock-all.sh
|
||||||
|
NeedsTargets
|
||||||
12
guard-lib/pacman-hooks/90-guard-lib-relock-all.hook
Normal file
12
guard-lib/pacman-hooks/90-guard-lib-relock-all.hook
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Trigger]
|
||||||
|
Operation = Upgrade
|
||||||
|
Operation = Install
|
||||||
|
Operation = Remove
|
||||||
|
Type = Package
|
||||||
|
Target = *
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = guard-lib: re-locking protected files after pacman transaction
|
||||||
|
When = PostTransaction
|
||||||
|
Exec = /bin/bash /etc/guard-lib/pacman-hooks/guard-lib-relock-all.sh
|
||||||
|
NeedsTargets
|
||||||
20
guard-lib/pacman-hooks/guard-lib-relock-all.sh
Executable file
20
guard-lib/pacman-hooks/guard-lib-relock-all.sh
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PostTransaction hook script: re-enforce every registered file-guard
|
||||||
|
# instance (restores drift if pacman legitimately rewrote the file, then
|
||||||
|
# re-locks it). Same fail-open discipline as guard-lib-unlock-all.sh - a
|
||||||
|
# single broken instance logs a warning and the loop continues.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TARGETS_DIR="${GUARD_LIB_TARGETS_DIR:-/etc/guard-lib/targets}"
|
||||||
|
GUARDCTL="${GUARD_LIB_BIN:-/usr/local/bin/guardctl}"
|
||||||
|
|
||||||
|
if [[ -d "$TARGETS_DIR" ]]; then
|
||||||
|
for conf in "$TARGETS_DIR"/*.conf; do
|
||||||
|
[[ -e "$conf" ]] || continue
|
||||||
|
name="$(basename "$conf" .conf)"
|
||||||
|
"$GUARDCTL" file-guard pacman-relock "$name" 2>/dev/null || \
|
||||||
|
echo "guard-lib: WARNING failed to re-lock '$name' after transaction (continuing)" >&2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
23
guard-lib/pacman-hooks/guard-lib-unlock-all.sh
Executable file
23
guard-lib/pacman-hooks/guard-lib-unlock-all.sh
Executable file
@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PreTransaction hook script: quiet-unlock every registered file-guard
|
||||||
|
# instance so pacman can write through to files it legitimately manages
|
||||||
|
# (e.g. nsswitch.conf on a glibc upgrade, resolved.conf on a systemd
|
||||||
|
# upgrade). Deliberately never aborts: one broken instance config must not
|
||||||
|
# block every pacman transaction on the system, which is a much worse
|
||||||
|
# outcome than a guarded file briefly going unprotected mid-transaction
|
||||||
|
# (relock-all in PostTransaction restores it).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TARGETS_DIR="${GUARD_LIB_TARGETS_DIR:-/etc/guard-lib/targets}"
|
||||||
|
GUARDCTL="${GUARD_LIB_BIN:-/usr/local/bin/guardctl}"
|
||||||
|
|
||||||
|
if [[ -d "$TARGETS_DIR" ]]; then
|
||||||
|
for conf in "$TARGETS_DIR"/*.conf; do
|
||||||
|
[[ -e "$conf" ]] || continue
|
||||||
|
name="$(basename "$conf" .conf)"
|
||||||
|
"$GUARDCTL" file-guard pacman-unlock "$name" 2>/dev/null || \
|
||||||
|
echo "guard-lib: WARNING failed to unlock '$name' before transaction (continuing)" >&2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
12
guard-lib/systemd/guard-bind-mount@.service
Normal file
12
guard-lib/systemd/guard-bind-mount@.service
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=guard-lib bind-mount enforcement for %i
|
||||||
|
After=local-fs.target
|
||||||
|
Before=network-pre.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/local/bin/guardctl file-guard bind-mount %i
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
10
guard-lib/systemd/guard-file@.path
Normal file
10
guard-lib/systemd/guard-file@.path
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=guard-lib file-guard watcher for %i
|
||||||
|
# PathModified= for this instance is supplied by a drop-in written at
|
||||||
|
# `guardctl file-guard install` time, under guard-file@%i.path.d/override.conf.
|
||||||
|
# This template unit is never started directly without that drop-in.
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
6
guard-lib/systemd/guard-file@.service
Normal file
6
guard-lib/systemd/guard-file@.service
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=guard-lib file-guard enforcement for %i
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/guardctl file-guard enforce %i
|
||||||
183
guard-lib/verify-stage1.sh
Executable file
183
guard-lib/verify-stage1.sh
Executable file
@ -0,0 +1,183 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Live root verification for guard-lib Stage 1: proves file-guard and
|
||||||
|
# package-block actually work against a real target and real pacman
|
||||||
|
# transactions before Stage 2 touches anything in /etc that matters
|
||||||
|
# (hosts, nsswitch, resolved). Must be run as root. Always cleans up
|
||||||
|
# after itself (throwaway target + test packages), whether checks pass
|
||||||
|
# or fail, so it is safe to re-run.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "verify-stage1.sh must be run as root: sudo $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TEST_TARGET=/etc/guard-test-file
|
||||||
|
FG_NAME=dummy-test
|
||||||
|
PB_NAME=steam-test
|
||||||
|
PB_LOCK=/etc/guard-lib/blocks/steam-test-lock.json
|
||||||
|
HARMLESS_PKG=cowsay
|
||||||
|
BLOCKED_TEST_PKG=sl
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
FAILURES=()
|
||||||
|
CLEANUP_DONE=0
|
||||||
|
|
||||||
|
step() {
|
||||||
|
echo
|
||||||
|
echo "=== $1 ==="
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
PASS_COUNT=$((PASS_COUNT + 1))
|
||||||
|
echo " PASS: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
FAILURES+=("$1")
|
||||||
|
echo " FAIL: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
[[ "$CLEANUP_DONE" -eq 1 ]] && return
|
||||||
|
CLEANUP_DONE=1
|
||||||
|
step "Cleanup"
|
||||||
|
guardctl package-block end "$PB_NAME" >/dev/null 2>&1 && echo " ended package-block '$PB_NAME'" || true
|
||||||
|
guardctl file-guard uninstall "$FG_NAME" >/dev/null 2>&1 && echo " uninstalled file-guard '$FG_NAME'" || true
|
||||||
|
rm -f "$TEST_TARGET"
|
||||||
|
pacman -Qi "$BLOCKED_TEST_PKG" >/dev/null 2>&1 && pacman -R --noconfirm "$BLOCKED_TEST_PKG" >/dev/null 2>&1 || true
|
||||||
|
pacman -Qi "$HARMLESS_PKG" >/dev/null 2>&1 && pacman -R --noconfirm "$HARMLESS_PKG" >/dev/null 2>&1 || true
|
||||||
|
echo " removed $TEST_TARGET, $HARMLESS_PKG, $BLOCKED_TEST_PKG"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
step "1. Install guard-lib"
|
||||||
|
if "$SCRIPT_DIR/install.sh"; then
|
||||||
|
pass "install.sh ran cleanly"
|
||||||
|
else
|
||||||
|
fail "install.sh failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "2. Install file-guard on a throwaway target"
|
||||||
|
echo "hello" >"$TEST_TARGET"
|
||||||
|
if guardctl file-guard install "$FG_NAME" --target "$TEST_TARGET"; then
|
||||||
|
pass "file-guard install succeeded"
|
||||||
|
else
|
||||||
|
fail "file-guard install failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
attrs="$(lsattr "$TEST_TARGET" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [[ "$attrs" == *i* ]]; then
|
||||||
|
pass "target is immutable after install (attrs: $attrs)"
|
||||||
|
else
|
||||||
|
fail "target is NOT immutable after install (attrs: $attrs)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "3. Tamper the target and confirm auto-revert"
|
||||||
|
chattr -i "$TEST_TARGET" 2>/dev/null || true
|
||||||
|
echo "TAMPERED" >"$TEST_TARGET"
|
||||||
|
sleep 2
|
||||||
|
content="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
|
||||||
|
if [[ "$content" == "hello" ]]; then
|
||||||
|
pass "content auto-reverted to canonical after tamper"
|
||||||
|
else
|
||||||
|
fail "content did NOT revert (got: '$content')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
attrs="$(lsattr "$TEST_TARGET" 2>/dev/null | awk '{print $1}')"
|
||||||
|
if [[ "$attrs" == *i* ]]; then
|
||||||
|
pass "target re-locked (immutable) after revert"
|
||||||
|
else
|
||||||
|
fail "target NOT re-locked after revert (attrs: $attrs)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "3b. file-guard unlock: legitimate edit updates canonical and survives re-enforce"
|
||||||
|
FAKE_EDITOR="$(mktemp)"
|
||||||
|
cat >"$FAKE_EDITOR" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
echo "hello-edited" >"$1"
|
||||||
|
EOF
|
||||||
|
chmod +x "$FAKE_EDITOR"
|
||||||
|
if EDITOR="$FAKE_EDITOR" guardctl file-guard unlock "$FG_NAME" <<<"verify-stage1 test"; then
|
||||||
|
pass "file-guard unlock completed"
|
||||||
|
else
|
||||||
|
fail "file-guard unlock exited non-zero"
|
||||||
|
fi
|
||||||
|
content="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
|
||||||
|
if [[ "$content" == "hello-edited" ]]; then
|
||||||
|
pass "unlock edit applied to target"
|
||||||
|
else
|
||||||
|
fail "unlock edit NOT applied to target (got: '$content')"
|
||||||
|
fi
|
||||||
|
canonical_content="$(cat "$(guardctl file-guard canonical-path "$FG_NAME")" 2>/dev/null || echo '<missing>')"
|
||||||
|
if [[ "$canonical_content" == "hello-edited" ]]; then
|
||||||
|
pass "unlock updated canonical to match edit"
|
||||||
|
else
|
||||||
|
fail "canonical NOT updated after unlock (got: '$canonical_content') - legitimate edits would be reverted by the watcher"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
content_after_wait="$(cat "$TEST_TARGET" 2>/dev/null || echo '<missing>')"
|
||||||
|
if [[ "$content_after_wait" == "hello-edited" ]]; then
|
||||||
|
pass "edit survives after re-enforce (not reverted)"
|
||||||
|
else
|
||||||
|
fail "edit was reverted after unlock (got: '$content_after_wait')"
|
||||||
|
fi
|
||||||
|
rm -f "$FAKE_EDITOR"
|
||||||
|
|
||||||
|
step "4. Confirm a harmless pacman transaction still works (critical: generic hook must not brick pacman)"
|
||||||
|
echo " (showing pacman's own output live - a slow mirror can take a minute or two, this is not a hang)"
|
||||||
|
if pacman -S --noconfirm --needed "$HARMLESS_PKG"; then
|
||||||
|
pass "pacman -S $HARMLESS_PKG succeeded"
|
||||||
|
else
|
||||||
|
fail "pacman -S $HARMLESS_PKG FAILED - the generic unlock-all/relock-all hook may be broken"
|
||||||
|
fi
|
||||||
|
if pacman -R --noconfirm "$HARMLESS_PKG"; then
|
||||||
|
pass "pacman -R $HARMLESS_PKG succeeded (cleanup)"
|
||||||
|
else
|
||||||
|
fail "pacman -R $HARMLESS_PKG failed (test package may be left installed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "5. package-block: block install of a throwaway package"
|
||||||
|
if guardctl package-block start "$PB_NAME" --package "$BLOCKED_TEST_PKG" --lock-file "$PB_LOCK" --days 1; then
|
||||||
|
pass "package-block start succeeded"
|
||||||
|
else
|
||||||
|
fail "package-block start failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " (expecting this install to be ABORTED by the package-block hook)"
|
||||||
|
if pacman -S --noconfirm "$BLOCKED_TEST_PKG"; then
|
||||||
|
fail "pacman -S $BLOCKED_TEST_PKG succeeded while blocked (should have been aborted)"
|
||||||
|
else
|
||||||
|
pass "pacman -S $BLOCKED_TEST_PKG was blocked as expected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "6. package-block: end restores normal installs"
|
||||||
|
if guardctl package-block end "$PB_NAME"; then
|
||||||
|
pass "package-block end succeeded"
|
||||||
|
else
|
||||||
|
fail "package-block end failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if pacman -S --noconfirm --needed "$BLOCKED_TEST_PKG"; then
|
||||||
|
pass "pacman -S $BLOCKED_TEST_PKG succeeded after block ended"
|
||||||
|
else
|
||||||
|
fail "pacman -S $BLOCKED_TEST_PKG FAILED after block ended (should be allowed now)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=================================================="
|
||||||
|
echo "Stage 1 live verification: $PASS_COUNT passed, $FAIL_COUNT failed"
|
||||||
|
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
||||||
|
echo "Failures:"
|
||||||
|
for f in "${FAILURES[@]}"; do
|
||||||
|
echo " - $f"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ALL STAGE 1 LIVE CHECKS PASSED"
|
||||||
|
exit 0
|
||||||
Loading…
Reference in New Issue
Block a user