#!/usr/bin/bash
# eldric-logs-apply-rsyslog - the ONLY privileged step of the logs role (5.0.179, MAIN GO 2026-10-10).
#
# The eldric daemon runs unprivileged (User=eldric, ProtectSystem=strict). It renders the rsyslog
# configuration and writes it to ONE fixed drop file; eldric-logs-apply.path starts this script as
# root when that file changes. The script applies that file and nothing else:
#   - exactly DROP (a fixed name); any other file in the drop directory is ignored
#   - a regular file, not a symlink, owned by the eldric user, not writable by group/other, < 256 KiB
#   - written by the generator (first line is its header)
#   - nothing that can run a program or pull in other files: modules only imudp/imtcp/impstats,
#     actions only omfile, legacy directives only $AllowedSender, no include(), no omprog/mmexternal,
#     no legacy "^program" or "|pipe" outputs (rsyslog would run those AS ROOT)
#   - installed only if `rsyslogd -N1` accepts the whole system configuration with it; otherwise the
#     previous file is put back
# The outcome goes to RESULT (JSON, with the sha256 of the applied drop file), which the daemon reads.
# The *_OVERRIDE variables exist for the test rig only; the packaged unit sets none of them.
set -u
umask 077
DIR="${ELDRIC_LOGS_APPLY_DIR_OVERRIDE:-/var/lib/eldric-logs-apply}"
DROP="$DIR/50-eldric.conf"
RESULT="$DIR/result.json"
TARGET="${ELDRIC_LOGS_APPLY_TARGET_OVERRIDE:-/etc/rsyslog.d/50-eldric.conf}"
CHECK="${ELDRIC_LOGS_APPLY_CHECK_OVERRIDE:-rsyslogd -N1}"
RESTART="${ELDRIC_LOGS_APPLY_RESTART_OVERRIDE:-systemctl restart rsyslog}"
OWNER="${ELDRIC_LOGS_APPLY_OWNER_OVERRIDE:-eldric}"
HEADER='# Generated by Eldric (logs module).'

result() {  # result <ok:true|false> <step> <detail>
  python3 - "$RESULT" "$1" "$2" "$3" "${SUM:-}" <<'PY'
import json, os, sys, time
path, ok, step, detail, sha = sys.argv[1:6]
tmp = path + ".tmp"
with open(tmp, "w") as f:
    json.dump({"ok": ok == "true", "step": step, "detail": detail[-4000:], "sha256": sha,
               "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}, f)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
PY
  [ "$1" = true ] && exit 0 || exit 1
}

[ -e "$DROP" ] || exit 0                                   # nothing dropped: nothing to do
# One applier at a time: two runs would race on $TARGET.prev and could restore the wrong file.
exec 9>"$DIR/.apply.lock" || exit 1
flock 9 || exit 1
[ -L "$DROP" ] && result false refused "the drop file is a symlink"
[ -f "$DROP" ] || result false refused "the drop file is not a regular file"
own=$(stat -c %U "$DROP" 2>/dev/null || stat -f %Su "$DROP")
[ "$own" = "$OWNER" ] || result false refused "the drop file is owned by '$own', not '$OWNER'"
mode=$(stat -c %a "$DROP" 2>/dev/null || stat -f %Lp "$DROP")
case "$mode" in *[2367][0-7]|*[0-7][2367]) result false refused "the drop file is writable by group or others (mode $mode)";; esac
size=$(wc -c < "$DROP")
[ "$size" -le 262144 ] || result false refused "the drop file is larger than 256 KiB"
SUM=$(sha256sum "$DROP" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$DROP" | cut -d' ' -f1)
# Already applied, unchanged: nothing to do (the path unit also fires for the NetFlow drop file).
if [ -f "$RESULT" ] && grep -q '"ok": *true' "$RESULT" && grep -q "\"sha256\": *\"$SUM\"" "$RESULT"; then exit 0; fi
head -n1 "$DROP" | grep -qF "$HEADER" || result false refused "the drop file was not written by the Eldric generator"

# Content: nothing that can execute or include. Comments are stripped before the checks.
body=$(sed -e 's/#.*$//' "$DROP")
if echo "$body" | grep -Eiq 'omprog|mmexternal|omuxsock|ompipe|include[[:space:]]*\(|\$IncludeConfig|\$ModLoad'; then
  result false refused "the drop file loads a program, a pipe or another file"
fi
# global(): the generator writes exactly one, global(umask="0027"), so the files it asks for are
# readable by the eldric group under the unit's UMask (OPS, 5.0.181). Any other global() setting is
# refused - it would change rsyslog process-wide.
if echo "$body" | grep -Eiq 'global[[:space:]]*\('; then
  echo "$body" | grep -Ei 'global[[:space:]]*\(' | grep -Evx '[[:space:]]*global\(umask="0027"\)[[:space:]]*' | grep -q . \
    && result false refused "only global(umask=\"0027\") is allowed"
fi
bad_mod=$(echo "$body" | grep -Eio 'module[[:space:]]*\([^)]*load[[:space:]]*=[[:space:]]*"[^"]*"' \
          | sed -E 's/.*load[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/' | grep -Evx 'imudp|imtcp|impstats' | head -1)
[ -z "$bad_mod" ] || result false refused "module '$bad_mod' is not allowed"
bad_act=$(echo "$body" | grep -Eio 'action[[:space:]]*\([^)]*type[[:space:]]*=[[:space:]]*"[^"]*"' \
          | sed -E 's/.*type[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/' | grep -Evx 'omfile' | head -1)
[ -z "$bad_act" ] || result false refused "action type '$bad_act' is not allowed"
bad_dollar=$(echo "$body" | grep -Eo '^[[:space:]]*\$[A-Za-z]+' | sed -E 's/^[[:space:]]*//' | grep -vx '\$AllowedSender' | head -1)
[ -z "$bad_dollar" ] || result false refused "directive '$bad_dollar' is not allowed"
# Legacy selector outputs: "<selector> ^program" / "<selector> |pipe" / "<selector> :omprog:".
if echo "$body" | grep -Eq '^[[:space:]]*[*a-z0-9.,;!=]+[[:space:]]+[\^|]'; then
  result false refused "the drop file has a legacy program or pipe output"
fi

# Install: back up, put in place, check the WHOLE config, restore on failure, restart.
had=0; [ -f "$TARGET" ] && { cp -p "$TARGET" "$TARGET.prev" && had=1; }
cp "$DROP" "$TARGET.new" && chmod 0644 "$TARGET.new" && mv -f "$TARGET.new" "$TARGET" \
  || result false install "could not write $TARGET"
if ! out=$($CHECK 2>&1); then
  if [ "$had" = 1 ]; then mv -f "$TARGET.prev" "$TARGET"; else rm -f "$TARGET"; fi
  result false check "rsyslog rejected the configuration; the previous file was restored: $out"
fi
if ! out=$($RESTART 2>&1); then
  result false restart "the configuration is valid and installed, but restarting rsyslog failed: $out"
fi
rm -f "$TARGET.prev"
result true restarted "applied"
