#!/bin/sh
# Demerzel agent — install and run as a systemd service.
#
#   curl -fsSL https://get.demerzel.cloud/service.sh | sudo sh
#   curl -fsSL https://get.demerzel.cloud/service.sh | sudo sh -s <enrollment-token>
#
# Idempotent. It:
#   0. installs the agent binary if it is not there yet (install.sh);
#   1. creates the unprivileged `demerzel` system user and /etc/demerzel;
#   2. gives the agent an identity, preferring (in order):
#        a. an enrollment already present in /etc/demerzel (reused as-is),
#        b. an enrollment you already did as your own user
#           (~/.config/demerzel) — migrated over, so no re-enroll,
#        c. a fresh enrollment, if you pass a token as the argument,
#        d. otherwise pairing: it prints a code and a link, and waits until
#           someone approves it in Demerzel (or an AI assistant does, over MCP);
#   3. installs, enables and starts the systemd service.
#
# Web access logs (opt-in, Active Defense). They are root:adm 0640 and the agent
# is deliberately not in adm, so it cannot read them until you allow it:
#
#   sudo sh service.sh grant-web-logs [extra-log-dir ...]
#   sudo sh service.sh revoke-web-logs [extra-log-dir ...]
#
# grant-web-logs gives the `demerzel` user read access to the web server log
# directories (/var/log/apache2, /var/log/nginx, plus any you name) with POSIX
# ACLs, including a default ACL so logs logrotate creates later stay readable. It
# grants nothing else: not adm, not the journal, not auth.log. The agent reads the
# access logs and sends per-address aggregates only — never a raw line.
#
# sshd's login messages (opt-in, Active Defense: the SSH sensor). They live in
# the system journal, which the agent may not read until you allow it:
#
#   sudo sh service.sh grant-ssh-logs
#   sudo sh service.sh revoke-ssh-logs
#   sudo sh service.sh grant-fail2ban-logs
#   sudo sh service.sh revoke-fail2ban-logs
#
# grant-ssh-logs adds the `demerzel` user to systemd-journal — the only way
# journald lets an account read system messages. The agent then asks journalctl
# for sshd's messages only (_COMM=sshd) and sends per-address counts of failed
# logins: never a line, never the name of an account that exists here.
#
# grant-fail2ban-logs adds the user to adm, which may read /var/log/fail2ban.log
# (root:adm 640, recreated so at each rotation). The agent reads only the lines
# fail2ban's actions wrote, and sends each jail's bans and unbans: jail, address,
# time.
#
# Hardening audits (opt-in): the agent reads the report of the audit tool you
# already use — Lynis, OpenSCAP — and Demerzel shows and interprets it. The agent
# never runs it (it needs root) and never installs it: you pick the tool.
#
#   sudo sh service.sh grant-audit lynis [--from /var/log/lynis-report.dat]
#   sudo sh service.sh grant-audit lynis --run daily|weekly
#   sudo sh service.sh grant-audit openscap --from /path/to/results.xml
#   sudo sh service.sh grant-audit openscap --run weekly --profile <id> [--datastream <ds.xml>]
#   sudo sh service.sh revoke-audit lynis|openscap
#
# Both create /var/lib/demerzel/audits/<tool>, root:demerzel 0750, where the
# agent reads one report. --from (the default for Lynis, which writes
# /var/log/lynis-report.dat) copies your own run's report there each time it
# changes, with a systemd path unit. --run schedules the tool itself with a
# systemd timer, as root, writing straight to the spool. From a report the
# agent sends the score, the counts and each finding's identifier, status,
# severity and title — never the tool's free-text details.
#
# Env overrides: DEMERZEL_INTERVAL (default 1m), DEMERZEL_CONFIG (default
# /etc/demerzel), DEMERZEL_AUTO_UPDATE (default off — set to 1/true/yes/on to have
# the agent update itself; otherwise updates are manual via `demerzel-agent --update`).
set -eu

CONFIG="${DEMERZEL_CONFIG:-/etc/demerzel}"
INTERVAL="${DEMERZEL_INTERVAL:-1m}"
TOKEN="${1:-}"
USER_NAME="demerzel"

# The web log directories grant-web-logs / revoke-web-logs act on: the stock ones
# that exist, plus any named on the command line.
web_log_dirs() {
  for d in /var/log/apache2 /var/log/nginx "$@"; do
    [ -d "$d" ] && printf '%s\n' "$d"
  done
  return 0
}

AUDIT_DIR=/var/lib/demerzel/audits

# audit_report names the file the agent reads for a tool (audits.Parsers).
audit_report() {
  case "$1" in
    lynis) echo report.dat ;;
    openscap) echo results.xml ;;
  esac
}

case "$TOKEN" in
  grant-audit | revoke-audit)
    if [ "$(id -u)" != 0 ]; then
      echo "demerzel: this needs root — re-run with sudo." >&2
      exit 1
    fi
    action="$TOKEN"
    tool="${2:-}"
    case "$tool" in
      lynis | openscap) ;;
      *)
        echo "demerzel: name the audit tool — lynis or openscap." >&2
        exit 1
        ;;
    esac
    shift 2
    unit="demerzel-audit-$tool"
    dir="$AUDIT_DIR/$tool"
    report="$dir/$(audit_report "$tool")"
    # Whatever was set up before goes first: a tool is copied or run, not both.
    for u in "$unit.path" "$unit.timer" "$unit.service"; do
      systemctl disable --now "$u" >/dev/null 2>&1 || true
      rm -f "/etc/systemd/system/$u"
    done
    if [ "$action" = revoke-audit ]; then
      systemctl daemon-reload
      rm -rf "$dir"
      echo "demerzel: the agent no longer reads $tool's report"
      exit 0
    fi
    if ! getent group "$USER_NAME" >/dev/null 2>&1; then
      echo "demerzel: no '$USER_NAME' group yet — install the service first." >&2
      exit 1
    fi
    from="" run="" profile="" datastream=""
    while [ $# -gt 0 ]; do
      case "$1" in
        --from) from="${2:-}"; shift 2 ;;
        --run) run="${2:-}"; shift 2 ;;
        --profile) profile="${2:-}"; shift 2 ;;
        --datastream) datastream="${2:-}"; shift 2 ;;
        *)
          echo "demerzel: unknown option $1" >&2
          exit 1
          ;;
      esac
    done
    if [ -n "$from" ] && [ -n "$run" ]; then
      echo "demerzel: --from copies your own run's report, --run schedules one: pick one." >&2
      exit 1
    fi
    # They go into a unit's command line: plain paths and identifiers only.
    for v in "$from" "$profile" "$datastream"; do
      case "$v" in
        *[!A-Za-z0-9._/:-]*)
          echo "demerzel: '$v' — only letters, digits and . _ / : - are accepted." >&2
          exit 1
          ;;
      esac
    done
    case "$run" in
      "" | daily | weekly) ;;
      *)
        echo "demerzel: --run takes daily or weekly." >&2
        exit 1
        ;;
    esac
    if [ -z "$run" ] && [ -z "$from" ]; then
      if [ "$tool" = lynis ]; then
        from=/var/log/lynis-report.dat
      else
        echo "demerzel: say where your OpenSCAP results are (--from <results.xml>), or --run it." >&2
        exit 1
      fi
    fi
    if [ -n "$run" ]; then
      tmp="$report.tmp"
      # The report lands under a temporary name and is moved in whole, so the
      # agent never reads a half-written one.
      publish="chgrp $USER_NAME $tmp && chmod 0640 $tmp && mv -f $tmp $report"
      if [ "$tool" = lynis ]; then
        bin=$(command -v lynis 2>/dev/null || true)
        if [ -z "$bin" ]; then
          echo "demerzel: lynis is not installed — install it first (sudo apt-get install lynis)." >&2
          exit 1
        fi
        exec_line="$bin audit system --cronjob --quiet --report-file $tmp --log-file /var/log/lynis.log; [ -s $tmp ] && $publish"
      else
        bin=$(command -v oscap 2>/dev/null || true)
        if [ -z "$bin" ]; then
          echo "demerzel: oscap is not installed — install it first (sudo apt-get install openscap-scanner ssg-debian)." >&2
          exit 1
        fi
        if [ -z "$datastream" ] && [ -r /etc/os-release ]; then
          # ssg-debian names its datastreams after the release: ssg-debian12-ds.xml.
          # shellcheck disable=SC1091
          datastream=$(. /etc/os-release && echo "/usr/share/xml/scap/ssg/content/ssg-${ID}${VERSION_ID%%.*}-ds.xml")
        fi
        if [ ! -r "$datastream" ]; then
          echo "demerzel: no SCAP datastream at '$datastream' — name yours with --datastream." >&2
          exit 1
        fi
        if [ -z "$profile" ]; then
          echo "demerzel: name the profile to audit against (--profile); list them with — oscap info $datastream" >&2
          exit 1
        fi
        # oscap exits 2 when a rule fails, which is a run, not an error.
        exec_line="$bin xccdf eval --profile $profile --results $tmp $datastream >/dev/null; [ -s $tmp ] && $publish"
      fi
    else
      exec_line="if [ -s $from ]; then install -m 0640 -o root -g $USER_NAME $from $report; fi"
    fi
    mkdir -p "$dir"
    chown root:"$USER_NAME" "$AUDIT_DIR" "$dir"
    chmod 0750 "$AUDIT_DIR" "$dir"
    cat > "/etc/systemd/system/$unit.service" <<UNIT
[Unit]
Description=Hand $tool's report to the Demerzel agent

[Service]
Type=oneshot
ExecStart=/bin/sh -c '$exec_line'
UNIT
    if [ -n "$run" ]; then
      cat > "/etc/systemd/system/$unit.timer" <<UNIT
[Unit]
Description=Run $tool $run for the Demerzel agent

[Timer]
OnCalendar=$run
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
UNIT
      systemctl daemon-reload
      systemctl enable --now "$unit.timer" >/dev/null 2>&1
      echo "demerzel: $tool runs $run as root; the agent reads its report from $dir"
      echo "demerzel: run it now with — sudo systemctl start $unit.service"
    else
      cat > "/etc/systemd/system/$unit.path" <<UNIT
[Unit]
Description=Copy $tool's report for the Demerzel agent when it changes

[Path]
PathChanged=$from
Unit=$unit.service

[Install]
WantedBy=paths.target
UNIT
      systemctl daemon-reload
      systemctl enable --now "$unit.path" >/dev/null 2>&1
      systemctl start "$unit.service" 2>/dev/null || true
      if [ -s "$report" ]; then
        echo "demerzel: the agent now reads $tool's report, copied from $from on each change"
      else
        echo "demerzel: set up — the agent will read $from once $tool has written it"
      fi
    fi
    exit 0
    ;;
  grant-ssh-logs | revoke-ssh-logs)
    if [ "$(id -u)" != 0 ]; then
      echo "demerzel: this needs root — re-run with sudo." >&2
      exit 1
    fi
    if ! id "$USER_NAME" >/dev/null 2>&1; then
      echo "demerzel: no '$USER_NAME' user yet — install the service first." >&2
      exit 1
    fi
    if ! getent group systemd-journal >/dev/null 2>&1; then
      echo "demerzel: no systemd-journal group — this host does not run journald." >&2
      exit 1
    fi
    if [ "$TOKEN" = grant-ssh-logs ]; then
      gpasswd -a "$USER_NAME" systemd-journal >/dev/null
      echo "demerzel: $USER_NAME may now read sshd's messages in the journal"
    else
      gpasswd -d "$USER_NAME" systemd-journal >/dev/null 2>&1 || true
      echo "demerzel: $USER_NAME can no longer read the journal"
    fi
    # A group is picked up when the process starts.
    systemctl try-restart demerzel-agent 2>/dev/null || true
    exit 0
    ;;
  grant-fail2ban-logs | revoke-fail2ban-logs)
    if [ "$(id -u)" != 0 ]; then
      echo "demerzel: this needs root — re-run with sudo." >&2
      exit 1
    fi
    if ! id "$USER_NAME" >/dev/null 2>&1; then
      echo "demerzel: no '$USER_NAME' user yet — install the service first." >&2
      exit 1
    fi
    if [ "$TOKEN" = grant-fail2ban-logs ]; then
      if [ ! -e /var/log/fail2ban.log ]; then
        echo "demerzel: no /var/log/fail2ban.log — is fail2ban installed, logging to a file?" >&2
        exit 1
      fi
      # Debian's fail2ban.log is root:adm 640, and logrotate recreates it so:
      # adm is the one lasting way to read it.
      gpasswd -a "$USER_NAME" adm >/dev/null
      echo "demerzel: $USER_NAME may now read fail2ban's log (via the adm group)"
    else
      gpasswd -d "$USER_NAME" adm >/dev/null 2>&1 || true
      echo "demerzel: $USER_NAME can no longer read fail2ban's log"
    fi
    systemctl try-restart demerzel-agent 2>/dev/null || true
    exit 0
    ;;
  grant-web-logs | revoke-web-logs)
    if [ "$(id -u)" != 0 ]; then
      echo "demerzel: this needs root — re-run with sudo." >&2
      exit 1
    fi
    if ! command -v setfacl >/dev/null 2>&1; then
      echo "demerzel: setfacl not found — install it first: sudo apt-get install acl" >&2
      exit 1
    fi
    if ! id "$USER_NAME" >/dev/null 2>&1; then
      echo "demerzel: no '$USER_NAME' user yet — install the service first." >&2
      exit 1
    fi
    action="$TOKEN"
    shift
    dirs=$(web_log_dirs "$@")
    if [ -z "$dirs" ]; then
      echo "demerzel: no web log directory found (looked for /var/log/apache2, /var/log/nginx${1:+, $*})." >&2
      exit 1
    fi
    for d in $dirs; do
      if [ "$action" = grant-web-logs ]; then
        # Enter and list the directory, read the logs already there, and — via the
        # default ACL — every log created in it later (logrotate, a new vhost).
        setfacl -m "u:$USER_NAME:rx" "$d"
        setfacl -d -m "u:$USER_NAME:r" "$d"
        find "$d" -maxdepth 1 -type f -exec setfacl -m "u:$USER_NAME:r" {} +
        echo "demerzel: $USER_NAME may now read the logs in $d"
      else
        setfacl -x "u:$USER_NAME" "$d" 2>/dev/null || true
        setfacl -d -x "u:$USER_NAME" "$d" 2>/dev/null || true
        find "$d" -maxdepth 1 -type f -exec setfacl -x "u:$USER_NAME" {} + 2>/dev/null || true
        echo "demerzel: $USER_NAME can no longer read the logs in $d"
      fi
    done
    exit 0
    ;;
esac

# Auto-update is opt-in. Wire it into the unit's ExecStart when asked — the env
# var alone would NOT reach the service, since systemd starts with a clean
# environment, so the flag has to be baked into the unit here.
AUTO_UPDATE_ARG=""
case "$(printf '%s' "${DEMERZEL_AUTO_UPDATE:-}" | tr '[:upper:]' '[:lower:]')" in
  1 | true | yes | on) AUTO_UPDATE_ARG=" --auto-update" ;;
esac

if [ "$(id -u)" != 0 ]; then
  echo "demerzel: this needs root — re-run with sudo:" >&2
  echo "  curl -fsSL https://get.demerzel.cloud/service.sh | sudo sh${TOKEN:+ -s $TOKEN}" >&2
  exit 1
fi

# Locate the binary (installed by install.sh into /usr/local/bin by default).
BIN=$(command -v demerzel-agent 2>/dev/null || true)
if [ -z "$BIN" ] && [ -x /usr/local/bin/demerzel-agent ]; then
  BIN=/usr/local/bin/demerzel-agent
fi
if [ -z "$BIN" ]; then
  echo "demerzel: installing the agent binary…"
  curl -fsSL https://get.demerzel.cloud/install.sh | sh
  BIN=$(command -v demerzel-agent 2>/dev/null || true)
  [ -z "$BIN" ] && [ -x /usr/local/bin/demerzel-agent ] && BIN=/usr/local/bin/demerzel-agent
  if [ -z "$BIN" ]; then
    echo "demerzel: the agent binary could not be installed — see https://docs.demerzel.cloud/install-agent/install/" >&2
    exit 1
  fi
fi

# 1. Unprivileged user + config directory.
if ! id "$USER_NAME" >/dev/null 2>&1; then
  useradd --system --home "$CONFIG" --shell /usr/sbin/nologin "$USER_NAME"
  echo "demerzel: created system user '$USER_NAME'"
fi
mkdir -p "$CONFIG"

# Auto-update needs the service user to replace its own binary — and it CANNOT
# write /usr/local/bin, which is root-owned (a rename over the binary needs write
# on the directory, not just the file). So the binary's real home becomes a
# directory the service user owns; /usr/local/bin/demerzel-agent stays as a
# symlink so `demerzel-agent` still works on everyone's PATH. Owning the directory
# is what matters: the agent can then swap the binary in place, whoever owns it.
AGENT_HOME=/opt/demerzel
AGENT_BIN="$AGENT_HOME/demerzel-agent"
SRC="$(readlink -f "$BIN")"
mkdir -p "$AGENT_HOME"
if [ "$SRC" != "$AGENT_BIN" ]; then
  cp -f "$SRC" "$AGENT_BIN"
  ln -sfn "$AGENT_BIN" /usr/local/bin/demerzel-agent
  echo "demerzel: agent relocated to $AGENT_BIN (self-updatable), symlinked on PATH"
fi
chown -R "$USER_NAME:$USER_NAME" "$AGENT_HOME"
chmod 755 "$AGENT_HOME"
BIN="$AGENT_BIN"

# 2. Identity — reuse, migrate, or enroll.
has_identity() { [ -f "$1/agent.crt" ] && [ -f "$1/agent.key" ] && [ -f "$1/identity.json" ]; }

if has_identity "$CONFIG"; then
  echo "demerzel: reusing existing identity in $CONFIG"
else
  migrated=""
  # If you already enrolled as your own user, adopt that identity instead of
  # asking you to enroll again.
  if [ -n "${SUDO_USER:-}" ]; then
    home=$(getent passwd "$SUDO_USER" | cut -d: -f6)
    if [ -n "$home" ] && has_identity "$home/.config/demerzel"; then
      echo "demerzel: migrating existing enrollment from $home/.config/demerzel"
      cp -a "$home/.config/demerzel/." "$CONFIG/"
      migrated=1
    fi
  fi
  if [ -z "$migrated" ]; then
    if [ -z "$TOKEN" ]; then
      # No token: pair. It prints a code and a link, and returns once someone
      # approved it in Demerzel. Nothing to copy onto this server.
      # An agent older than 0.32 cannot pair: bring it up to date first.
      if ! "$BIN" --help 2>&1 | grep -q 'demerzel-agent pair'; then
        echo "demerzel: updating the agent so it can pair…"
        "$BIN" --update
      fi
      "$BIN" pair --config-dir "$CONFIG"
    else
      echo "demerzel: enrolling…"
      "$BIN" --config-dir "$CONFIG" --enroll "$TOKEN"
    fi
  fi
fi

# Hand the identity to the service user and lock it down.
chown -R "$USER_NAME:$USER_NAME" "$CONFIG"
chmod 700 "$CONFIG"

# 3. systemd unit. BINDIR (the service-owned /opt/demerzel) is in ReadWritePaths
# so the agent can replace itself when updating — ProtectSystem=strict otherwise
# mounts the whole tree read-only.
BINDIR=$(dirname "$BIN")
cat > /etc/systemd/system/demerzel-agent.service <<UNIT
[Unit]
Description=Demerzel agent
After=network-online.target
Wants=network-online.target

[Service]
User=$USER_NAME
ExecStart=$BIN --config-dir $CONFIG --interval $INTERVAL$AUTO_UPDATE_ARG
Restart=on-failure
RestartSec=10
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=$CONFIG $BINDIR -/run/demerzel/decisions/local -/run/demerzel/decisions/fleet

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload
systemctl enable demerzel-agent
# restart, not `enable --now`: on a re-run the service is already active, and only
# a restart adopts the regenerated unit — a new ExecStart, or the relocated binary
# a still-running agent would otherwise keep executing from its old path.
systemctl restart demerzel-agent

echo
systemctl --no-pager --full status demerzel-agent || true
echo
if [ -n "$AUTO_UPDATE_ARG" ]; then
  echo "demerzel: auto-update is ON — the agent updates itself when a new release is published."
else
  echo "demerzel: auto-update is OFF. Enable it by re-running with DEMERZEL_AUTO_UPDATE=1,"
  echo "         or update manually with — sudo demerzel-agent --update"
fi
echo "demerzel: service is up. Follow logs with — journalctl -u demerzel-agent -f"
