#!/bin/bash
# /usr/lib/keel-web/http-mode
#
# Port 80 of Keel Web's default site after the machine's certificate
# (handbook decision 0079), and the location of ACME's http-01 challenges
# on port 80 after the routes of the site (keel-web 0.4.3).
# keel-web-http.service runs it at boot, after keel-host-keys.service and
# before Nginx and Anubis start, at each start of Nginx, after each
# installation or upgrade of the package, and again each time
# keel-web-http.path sees /usr/local/share/ca-certificates/cert.crt
# change: when confconsole's dehydrated-wrapper writes a certificate from
# ACME, when keel apply makes a self-signed one again (tls.acme.enabled
# false), or when an operator replaces it.
#
# The certificate is self-signed, missing or unreadable: port 80 serves
# the site as 443 does. Nothing stands at
# /etc/nginx/keel-web/http-redirect.conf, and /etc/anubis/keel-web-http.env
# is linked, so Anubis sets its cookies without Secure and a browser on
# port 80 keeps the pass of its challenge.
#
# The certificate is one that a certificate authority issued: port 80
# redirects to HTTPS, except ACME's http-01 challenges.
# /etc/nginx/keel-web/http-redirect.conf is linked, the Anubis link is
# removed, and Anubis's cookies are Secure again.
#
# Self-signed is what keel's inspect/certificate.py says: the subject is
# the issuer, and the authority key identifier is absent or is the
# subject key identifier. `openssl verify` cannot tell: cert.crt is in
# /usr/local/share/ca-certificates, so update-ca-certificates trusts the
# self-signed certificate too.
#
# The challenges: a route that serves ACME's challenge itself keeps it.
# The public server on port 80 of each site includes one link to
# /etc/nginx/snippets/keel-web-acme.conf, keel-web's own location for the
# challenges: /etc/nginx/keel-web/site-acme.conf for the default site
# (keel-default), /etc/nginx/keel-web/front-acme.conf for the Anubis front
# (default-anubis). Each link is there unless a *.conf file of the routes
# directory that the same server includes has a prefix location for
# /.well-known/acme-challenge (with ^~ or without, with the last slash or
# without): /etc/nginx/keel-default.d for the default site,
# /etc/nginx/keel-front.d for the front. Nginx refuses two prefix
# locations with one path ("duplicate location") and then does not start.
# A commented line does not count. An exact location (=) and a regular
# expression do not count: Nginx accepts them beside keel-web's ^~, and
# keel-web's location wins. Each link is decided for its own site,
# whichever site is enabled, so the anubis state hook can change the site
# without this script.
#
# Changed links are tested with one `nginx -t` and Nginx reloaded once
# when it runs; a refused test or a failed reload puts every link back as
# it was and fails. A changed Anubis link has a running
# anubis@keel.service restart. Nginx wants keel-web-http.service (its
# drop-in), so each start of Nginx, the restart of dehydrated-wrapper
# included, runs this first, while Nginx does not run: then nothing is
# tested, and Nginx reads the links at its start. A file that is not a
# link where a link goes is the operator's: it is left alone, and the run
# fails. Each link points to a file of the package.
#
# Exit codes: 0 done or unchanged, 1 refused or rolled back, 2 usage.
#
# The KEEL_* variables exist for tests/http-mode.bats; on a machine the
# defaults are the paths of trixie's nginx and of the anubis package.
set -euo pipefail

NGINX_DIR="${KEEL_NGINX_DIR:-/etc/nginx}"
PID_FILE="${KEEL_NGINX_PID:-/run/nginx.pid}"
CERT="${KEEL_WEB_CERT:-/usr/local/share/ca-certificates/cert.crt}"
SHARE="${KEEL_WEB_SHARE:-/usr/share/keel-web}"
ANUBIS_LINK="${KEEL_WEB_ANUBIS_ENV:-/etc/anubis/keel-web-http.env}"
ANUBIS_UNIT=anubis@keel.service

REDIRECT_LINK="$NGINX_DIR/keel-web/http-redirect.conf"
REDIRECT_FILE="$SHARE/http-redirect.conf"
ANUBIS_FILE="$SHARE/anubis-http.env"
SITE_ACME_LINK="$NGINX_DIR/keel-web/site-acme.conf"
FRONT_ACME_LINK="$NGINX_DIR/keel-web/front-acme.conf"
ACME_FILE="$NGINX_DIR/snippets/keel-web-acme.conf"
# a prefix location for the path of ACME's challenges, which is a
# duplicate of keel-web's own location to Nginx
CHALLENGE='^[[:space:]]*location[[:space:]]+(\^~[[:space:]]*)?/\.well-known/acme-challenge(/|[[:space:]{]|$)'

say() {
    echo "keel-web: $*"
}

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

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

# the value of FIELD (subject, issuer, ski, aki) in the text of
# `openssl x509 -noout -subject -issuer -ext ...`
# (an extension's value is the line after its name; OpenSSL 1.1 wrote
# "keyid:" before the authority key). Every statement is on one line, so
# that kcov counts each.
field() {
    local want="$1" line key=""
    while IFS= read -r line; do
        case "$line" in
            subject=*) [ "$want" != subject ] || echo "${line#subject=}" ;;
            issuer=*) [ "$want" != issuer ] || echo "${line#issuer=}" ;;
            *"Subject Key Identifier"*) key=ski ;;
            *"Authority Key Identifier"*) key=aki ;;
            *)
                [ -n "$key" ] || continue
                line="${line#"${line%%[![:space:]]*}"}"
                [ "$want" != "$key" ] || echo "${line#keyid:}"
                key=""
                ;;
        esac
    done <<< "$2"
}

# 0 when the first certificate of $CERT is one a certificate authority
# issued; 1 when it is self-signed, missing or unreadable
issued() {
    local text subject aki
    local fields=(-subject -issuer -ext "subjectKeyIdentifier,authorityKeyIdentifier" -nameopt RFC2253)
    text="$(openssl x509 -in "$CERT" -noout "${fields[@]}" 2>/dev/null)" || return 1
    subject="$(field subject "$text")"
    [ "$subject" = "$(field issuer "$text")" ] || return 0
    aki="$(field aki "$text")"
    [ -n "$aki" ] && [ "$aki" != "$(field ski "$text")" ]
}

# a file of the operator's where a link of this script goes is never
# replaced
refuse_file() {
    if [ -e "$1" ] && [ ! -L "$1" ]; then
        fail "$1 is not a link; it is left alone, move it away first"
    fi
}

# set_link LINK FILE yes|no: LINK points to FILE (yes) or is gone (no);
# prints "changed" when it changed something
set_link() {
    local link="$1" file="$2" want="$3"
    if [ "$want" = yes ]; then
        [ "$(readlink "$link" || true)" != "$file" ] || return 0
        mkdir -p "$(dirname "$link")"
        ln -sfn "$file" "$link"
    else
        [ -L "$link" ] || return 0
        rm -f "$link"
    fi
    echo changed
}

# the first *.conf file of the routes directory DIR that has its own
# location for ACME's challenges; 1 when no file has one
own_challenge() {
    local file
    for file in "$1"/*.conf; do
        [ -f "$file" ] || continue
        grep -qE "$CHALLENGE" "$file" || continue
        echo "$file"
        return 0
    done
    return 1
}

# yes when keel-web's challenge location goes in the site whose routes are
# in DIR (its link LINK); no, said in one line, when a route has its own
# (on stderr: stdout is the answer)
acme_wanted() {
    local route
    if route="$(own_challenge "$1")"; then
        say "$route serves ACME's challenge itself: $2 is not linked" >&2
        echo no
        return 0
    fi
    echo yes
}

# LINKS[i] points to FILES[i] or is gone, after WANTS[i]
put_links() {
    local i
    for i in "${!LINKS[@]}"; do
        set_link "${LINKS[$i]}" "${FILES[$i]}" "${WANTS[$i]}"
    done
}

# every link as it was before this run
restore_links() {
    local i
    for i in "${!LINKS[@]}"; do
        rm -f "${LINKS[$i]}"
        [ -z "${BEFORE[$i]}" ] || ln -sfn "${BEFORE[$i]}" "${LINKS[$i]}"
    done
}

# the links Nginx reads, tested and reloaded when Nginx runs; every link
# put back on failure
apply_nginx() {
    local link changed
    BEFORE=()
    for link in "${LINKS[@]}"; do
        BEFORE+=("$(readlink "$link" || true)")
    done
    changed="$(put_links)"
    [ -n "$changed" ] || return 0
    running || return 0
    if ! nginx -t; then
        restore_links
        fail "nginx -t refused the configuration; the links in $NGINX_DIR/keel-web are put back"
    fi
    if ! nginx -s reload; then
        restore_links
        nginx -s reload || :
        fail "nginx -s reload failed; the links in $NGINX_DIR/keel-web are put back"
    fi
}

# Anubis's cookies, the service restarted when it runs
apply_anubis() {
    local changed
    changed="$(set_link "$ANUBIS_LINK" "$ANUBIS_FILE" "$1")"
    [ -n "$changed" ] || return 0
    # An Anubis that is not active (stopped, not installed, or starting
    # after this service) reads the file at its start.
    systemctl --quiet is-active "$ANUBIS_UNIT" || return 0
    # --no-block: Anubis starts after keel-web-http.service, which runs
    # this, so a blocking restart would wait for itself
    systemctl --no-block restart "$ANUBIS_UNIT" || fail "$ANUBIS_UNIT did not restart; it reads $ANUBIS_LINK at its next start"
}

main() {
    local link redirect=no anubis=yes
    local mode="serves the site as 443 does: $CERT is self-signed or not there"
    for link in "$REDIRECT_LINK" "$SITE_ACME_LINK" "$FRONT_ACME_LINK" "$ANUBIS_LINK"; do
        refuse_file "$link"
    done
    if issued; then
        redirect=yes
        anubis=no
        mode="redirects to HTTPS: $CERT is a certificate authority's"
    fi
    LINKS=("$REDIRECT_LINK" "$SITE_ACME_LINK" "$FRONT_ACME_LINK")
    FILES=("$REDIRECT_FILE" "$ACME_FILE" "$ACME_FILE")
    WANTS=("$redirect")
    WANTS+=("$(acme_wanted "$NGINX_DIR/keel-default.d" "$SITE_ACME_LINK")")
    WANTS+=("$(acme_wanted "$NGINX_DIR/keel-front.d" "$FRONT_ACME_LINK")")
    apply_nginx
    apply_anubis "$anubis"
    say "port 80 $mode"
}

[ "$#" -eq 0 ] || {
    echo "usage: http-mode" >&2
    exit 2
}
main
