#!/bin/bash
# /usr/lib/keel/overlays/coraza/state enabled|disabled|recheck
#
# Turns the Coraza overlay of Keel Web on or off (handbook decisions 0030
# and 0041). Coraza is an Nginx module, not a unit, so keel spec apply has
# nothing of its own to start or stop: this is what it runs instead
# (packages/README.md in Keel-Linux/common, "The Web overlays").
#
# enabled: links the module into modules-enabled/ and Coraza's
# configuration into conf.d/, tests the result with `nginx -t`, reloads
# Nginx, and then checks what `nginx -t` cannot: a rule Coraza refuses
# passes `nginx -t`, and then every worker dies at start and every request
# hangs (upstream corazawaf/coraza-nginx#139). So after the reload the
# Core Rule Set probe of the overlay manifest's waf-blocks check must get
# 403 and /keel-health 204, within KEEL_CORAZA_TRIES attempts; otherwise
# the links go back to what they were, Nginx is reloaded again, and the
# hook fails, saying why.
#
# disabled: removes both links, so the module is not loaded at all (the
# engine costs memory in every worker even when no server uses it), tests
# and reloads, and checks that /keel-health and the probe both get 204.
#
# recheck: what the dpkg trigger of keel-overlay-coraza runs when the rule
# set, the module or the engine is upgraded. With Coraza enabled and Nginx
# running, it reloads and checks as enabled does, and turns Coraza off
# when the new files fail the check.
#
# A failed reload rolls back as a failed check does. Runs are serialised
# with flock(1) on KEEL_CORAZA_LOCK, waiting at most KEEL_CORAZA_LOCK_WAIT
# seconds. Only these runs are checked: a reload made any other way (by
# hand, at a certificate renewal, by the nginx-reload trigger of another
# package) is not; Monit's waf-blocks and nginx checks report it.
#
# Exit codes: 0 done or unchanged, 1 refused or rolled back, 2 usage.
#
# The KEEL_* variables exist for tests/coraza-state.bats; on a machine the
# defaults are the paths of trixie's nginx.
set -euo pipefail

NGINX_DIR="${KEEL_NGINX_DIR:-/etc/nginx}"
MODULE_CONF="${KEEL_CORAZA_MODULE_CONF:-/usr/share/nginx/modules-available/mod-http-coraza.conf}"
PID_FILE="${KEEL_NGINX_PID:-/run/nginx.pid}"
TRIES="${KEEL_CORAZA_TRIES:-10}"
INTERVAL="${KEEL_CORAZA_INTERVAL:-1}"
LOCK="${KEEL_CORAZA_LOCK:-/run/lock/keel-overlay-coraza.lock}"
LOCK_WAIT="${KEEL_CORAZA_LOCK_WAIT:-120}"

MODULE_LINK="$NGINX_DIR/modules-enabled/50-mod-http-coraza.conf"
WAF_CONF="$NGINX_DIR/coraza/keel.conf"
WAF_LINK="$NGINX_DIR/conf.d/keel-coraza.conf"
HEALTH="http://[::1]/keel-health"
# the probe of the waf-blocks check of /usr/share/keel/overlays/coraza.yaml
PROBE="$HEALTH?keel-waf-probe=%3Cscript%3Ealert(1)%3C%2Fscript%3E"
ISSUE="corazawaf/coraza-nginx#139"
# Every statement below is kept on one line, messages included, so that
# kcov counts the same lines everywhere: with continued lines it measured
# 84 of 84 on Debian 13 and 82 of 84 on the CI runner.
NOT_RUNNING="nginx is not running: start it first, since Coraza is only turned on where its probe can be checked"
NO_WORKER="no nginx worker is running: a rule Coraza refuses passes nginx -t and kills every worker ($ISSUE); the reason is in Nginx's error log"
IN_USE="nginx -t refuses the configuration without Coraza (does a site use its directives?); the links are put back"

say() {
    echo "coraza: $*"
}

fail() {
    say "$*" >&2
    exit 1
}

master() {
    local pid
    pid="$(cat "$PID_FILE" 2>/dev/null)" || return 1
    [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid"
}

# code URL: the status code Nginx gives URL, 000 when it does not answer
code() {
    curl --globoff --silent --output /dev/null --max-time 2 --write-out '%{http_code}' "$1" || true
}

# answers CODE URL: URL gets CODE within TRIES attempts. A reload is
# asynchronous: the old workers may serve the first requests.
answers() {
    local want="$1" url="$2" try
    for try in $(seq "$TRIES"); do
        GOT="$(code "$url")"
        [ "$GOT" = "$want" ] && return 0
        [ "$try" -lt "$TRIES" ] && sleep "$INTERVAL"
    done
    return 1
}

# the module and the WAF configuration both linked: the enabled state
linked() {
    [ "$(readlink "$MODULE_LINK")" = "$MODULE_CONF" ] && [ "$(readlink "$WAF_LINK")" = "$WAF_CONF" ]
}

# a file of the operator's where a link goes is never replaced
refuse_files() {
    local path
    for path in "$MODULE_LINK" "$WAF_LINK"; do
        if [ -e "$path" ] && [ ! -L "$path" ]; then
            fail "$path is not a link; it is left alone, move it away first"
        fi
    done
}

# serving PID: the workers of the master PID that still take requests; one
# told to quit is titled "nginx: worker process is shutting down"
serving() {
    pgrep -P "$1" -xf 'nginx: worker process' || true
}

# reload PID: reload, then wait until no worker of before the reload still
# takes requests. `nginx -s reload` only signals the master, so a probe
# sent at once can reach an old worker; with the rule set already loaded
# there, it would get 403 from workers about to go while the new ones die
# (measured: an upgrade of coreruleset under an enabled Coraza). After the
# wait a probe reaches the new workers, or none at all.
reload() {
    local pid="$1" before try worker old
    before=" $(serving "$pid" | tr '\n' ' ')"
    nginx -s reload || return 1
    for try in $(seq "$TRIES"); do
        old=no
        for worker in $(serving "$pid"); do
            [[ "$before" == *" $worker "* ]] && old=yes
        done
        [ "$old" = no ] && return 0
        [ "$try" -lt "$TRIES" ] && sleep "$INTERVAL"
    done
    return 0
}

blocking() {
    answers 403 "$PROBE" && answers 204 "$HEALTH"
}

why_not_blocking() {
    local workers
    # pgrep exits 1 when it finds none, which is the answer wanted
    workers="$({ pgrep -P "$1" || true; } | wc -l)"
    if [ "$workers" -eq 0 ]; then
        echo "$NO_WORKER"
    else
        echo "the probe got $GOT, not 403"
    fi
}

enable() {
    local pid had_module=no reason
    pid="$(master)" || fail "$NOT_RUNNING"
    refuse_files
    if linked; then
        blocking || fail "enabled, but $(why_not_blocking "$pid")"
        say "unchanged (enabled: the probe got 403 and /keel-health 204)"
        return 0
    fi
    [ -L "$MODULE_LINK" ] && had_module=yes
    ln -sfn "$MODULE_CONF" "$MODULE_LINK"
    ln -sfn "$WAF_CONF" "$WAF_LINK"
    checked "$pid" "$had_module"
    say "enabled: the probe got 403 and /keel-health 204"
}

# checked PID HAD_MODULE: with both links made, test, reload and check;
# anything that fails rolls the links back and fails the run
checked() {
    local pid="$1" had_module="$2" reason
    if ! nginx -t; then
        rollback "$had_module"
        fail "nginx -t refused the configuration with Coraza; rolled back"
    fi
    if ! reload "$pid"; then
        rollback "$had_module"
        fail "nginx -s reload failed; rolled back, Coraza is off"
    fi
    blocking && return 0
    reason="$(why_not_blocking "$pid")"
    rollback "$had_module"
    reload "$pid" || fail "$reason; rolled back, but nginx -s reload failed after the rollback"
    answers 204 "$HEALTH" || fail "$reason; rolled back, but nginx does not answer /keel-health after the rollback either (got $GOT)"
    fail "$reason; rolled back, Coraza is off"
}

recheck() {
    local pid
    if ! linked; then
        say "nothing to recheck (disabled)"
        return 0
    fi
    if ! pid="$(master)"; then
        say "enabled; nginx is not running, nothing to recheck"
        return 0
    fi
    checked "$pid" no
    say "rechecked: the probe got 403 and /keel-health 204"
}

rollback() {
    rm -f "$WAF_LINK"
    [ "$1" = yes ] || rm -f "$MODULE_LINK"
}

disable() {
    refuse_files
    if [ ! -L "$MODULE_LINK" ] && [ ! -L "$WAF_LINK" ]; then
        say "unchanged (disabled)"
        return 0
    fi
    local module waf pid
    module="$(readlink "$MODULE_LINK" || true)"
    waf="$(readlink "$WAF_LINK" || true)"
    rm -f "$MODULE_LINK" "$WAF_LINK"
    if ! nginx -t; then
        [ -z "$module" ] || ln -s "$module" "$MODULE_LINK"
        [ -z "$waf" ] || ln -s "$waf" "$WAF_LINK"
        fail "$IN_USE"
    fi
    if ! pid="$(master)"; then
        say "disabled (nginx is not running: the module is loaded by no one)"
        return 0
    fi
    reload "$pid" || fail "disabled, but nginx -s reload failed: the running workers keep Coraza until Nginx restarts"
    # the probe passing is the new workers answering, without the WAF
    answers 204 "$PROBE" && answers 204 "$HEALTH" || fail "disabled, but nginx does not answer /keel-health and its probe with 204 (got $GOT)"
    say "disabled: the module is not loaded"
}

if [[ ! "${1:-}" =~ ^(enabled|disabled|recheck)$ ]]; then
    echo "usage: state enabled|disabled|recheck" >&2
    exit 2
fi

# one run at a time: keel apply, the dpkg trigger and an operator
exec 9>"$LOCK"
flock -w "$LOCK_WAIT" 9 || fail "another run holds $LOCK; nothing changed"

case "$1" in
    enabled) enable ;;
    disabled) disable ;;
    recheck) recheck ;;
esac
