#!/bin/sh
# ctfy-appd — PID 1 of an AWD gamebox's app container.
#
# A deliberately small process manager, and what it does *not* do is the
# contract a defender plays under:
#
# - It starts the service **once**, when the container starts.
# - It **never restarts** a service that exits. A service killed by a bad
#   edit, or crashed by an attacker, stays down — losing SLA every round —
#   until the defender runs `appctl start`. Like a real process nobody
#   supervises.
# - It **never watches the source.** An edit does nothing until the
#   defender runs `appctl restart`; the running process keeps the code it
#   loaded.
# - It **never restores** a deleted source. The shipped original stays in
#   the image at $APP_PRISTINE for the defender to copy back by hand. The
#   one exception is an empty volume at boot, which is seeded from it —
#   that is provisioning, not repair.
#
# What it *is* for: the service is never PID 1, so a dead service leaves
# the container — and the defender's shell — standing; and it is the
# parent of the service, so the service is reaped rather than left a
# zombie that `kill -0` would report alive.
#
# The box configures it in its Dockerfile:
#
#   APP_SRC       the source the defender edits (on the shared volume)
#   APP_PRISTINE  the shipped original of APP_SRC
#   APP_CMD       how to run the service (exec'd, so its pid is the app's)
#   APP_BUILD     optional: builds the runnable from APP_SRC before a start
#                 when the source changed; a failed build keeps the last
#                 good one and writes /run/app/build.log
#   APP_PREPARE   optional: run once at boot (e.g. create a data dir)
#
# `appctl` talks to it: it writes a request to /run/app/request and sends
# SIGUSR1; the answer comes back in /run/app/reply.
set -u
: "${APP_SRC:?APP_SRC is not set}"
: "${APP_PRISTINE:?APP_PRISTINE is not set}"
: "${APP_CMD:?APP_CMD is not set}"
APP_BUILD="${APP_BUILD:-}"
APP_PREPARE="${APP_PREPARE:-}"

RUN=/run/app
LOG=/var/log/app.log
mkdir -p "$RUN" "$(dirname "$LOG")"
: >>"$LOG"
rm -f "$RUN/pid" "$RUN/request" "$RUN/reply" "$RUN/build.log"
echo "$APP_SRC" >"$RUN/src"
echo "$APP_PRISTINE" >"$RUN/pristine"
if [ -n "$APP_BUILD" ]; then echo "$RUN/build.log" >"$RUN/buildlog"; fi

note() { echo "ctfy-appd: $*" >>"$LOG"; }
stamp() { stat -c %Y "$APP_SRC" 2>/dev/null || echo 0; }

[ -n "$APP_PREPARE" ] && sh -c "$APP_PREPARE"
# Provisioning, not repair: a fresh volume is empty.
[ -e "$APP_SRC" ] || cp "$APP_PRISTINE" "$APP_SRC"

# Classic-AWD "open" shell exposure (#1725). Off unless the platform sets
# CTFY_OPEN_SHELL=1 — a match whose `shell_exposure` is `open`. When on,
# sshd runs *in this app container*, on the game network, so every team in
# the match reaches it at the box's arena address and gaining a shell is
# itself an attack. The starting credential is CTFY_OPEN_SHELL_PASSWORD
# (root); the defender rotates it with `appctl passwd`.
#
# ⚠️ **The shell sees only what this container sees** — the current
# round's /answers/flag, exactly what the app already serves. The checker
# and any rotator are separate containers on the management network; their
# $ANSWER_* groundtruth is not on a volume this container mounts and not
# reachable over the (internal) game network. That containment is the one
# invariant the open shell must never cross, so sshd runs *here*, in the
# attackable app service, never anywhere with a wider view.
open_shell() {
    [ "${CTFY_OPEN_SHELL:-}" = 1 ] || return 0
    if ! command -v sshd >/dev/null 2>&1; then
        note "open shell requested but sshd is not installed in this base"
        return 0
    fi
    printf 'root:%s\n' "${CTFY_OPEN_SHELL_PASSWORD:-}" | chpasswd 2>>"$LOG" \
        || note "open shell: could not set the starting credential"
    mkdir -p /run/sshd /var/empty 2>/dev/null || true
    ssh-keygen -A >>"$LOG" 2>&1 || note "open shell: ssh-keygen -A failed"
    # -o overrides rather than editing sshd_config, so the box works
    # whatever the base image's default config says about root logins.
    /usr/sbin/sshd \
        -o PermitRootLogin=yes \
        -o PasswordAuthentication=yes \
        -o UsePAM=no \
        >>"$LOG" 2>&1 \
        && note "open shell: sshd listening on :22" \
        || note "open shell: sshd failed to start"
}
open_shell

pid=""
built=""

running() { [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; }

build() {
    # Only on a changed source: a start must not wait on an unchanged
    # compile.
    [ -z "$APP_BUILD" ] && return 0
    now=$(stamp)
    [ "$now" = "$built" ] && return 0
    if sh -c "$APP_BUILD" >"$RUN/build.log.next" 2>&1; then
        built=$now
        : >"$RUN/build.log"
        rm -f "$RUN/build.log.next"
        note "built from $APP_SRC"
    else
        mv "$RUN/build.log.next" "$RUN/build.log"
        note "build FAILED; starting the previous build (see $RUN/build.log)"
    fi
}

start() {
    running && return 0
    build
    if [ -n "$APP_BUILD" ]; then
        # The stamp of the source the running process was *built* from.
        echo "${built:-0}" >"$RUN/stamp"
    else
        stamp >"$RUN/stamp"
    fi
    sh -c "exec $APP_CMD" >>"$LOG" 2>&1 &
    pid=$!
    echo "$pid" >"$RUN/pid"
    note "started pid $pid"
}

stop() {
    if running; then
        kill "$pid" 2>/dev/null
        i=0
        while running && [ "$i" -lt 10 ]; do
            sleep 0.5
            i=$((i + 1))
        done
        running && kill -9 "$pid" 2>/dev/null
        wait "$pid" 2>/dev/null
        note "stopped pid $pid"
    fi
    pid=""
    rm -f "$RUN/pid"
}

answer() {
    echo "$1" >"$RUN/reply.next"
    mv "$RUN/reply.next" "$RUN/reply"
}

handle() {
    req=$(cat "$RUN/request" 2>/dev/null || true)
    rm -f "$RUN/request"
    case "$req" in
        start) start && answer ok || answer failed ;;
        stop) stop && answer ok || answer failed ;;
        *) answer "unknown request: $req" ;;
    esac
}

trap handle USR1
trap 'stop; exit 0' TERM INT

# Forward the service's output to the container log, so an operator
# reading `docker logs` sees what `appctl logs` sees.
follower=""
from=+1
start
while true; do
    # ⚠️ `[ -n ]` first: busybox `kill -0 ""` answers success, so an
    # unset pid read as a live follower and none was ever started.
    { [ -n "$follower" ] && kill -0 "$follower" 2>/dev/null; } || {
        # The whole log the first time; only what is new after a
        # follower was killed, so a restart does not replay history.
        tail -n "$from" -F "$LOG" 2>/dev/null &
        follower=$!
        from=0
    }
    if [ -n "$pid" ]; then
        # Returns when the service exits — or early, when appctl signals.
        wait "$pid"
        code=$?
        if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then
            note "pid $pid exited ($code) — NOT restarting; run: appctl start"
            pid=""
            rm -f "$RUN/pid"
        fi
    else
        sleep 1
    fi
done
