#!/usr/bin/env bash
#
# The kiosk's anonymous troubleshooting FTP server (architecture.md).
#
# Listens on all network interfaces (Ethernet LAN, WiFi Hotspot) for maintenance
# and troubleshooting. Provides read/write access to /srv/karaoke without requiring
# passwords.
#
# Security:
#   1. Switched on/off on-demand from the admin console.
#   2. Automatically turns off and goes to sleep after 15 minutes of inactivity.
#   3. Confined (chrooted) strictly to /srv/karaoke as an unprivileged user.
#   4. **One writable directory, and only one.** Everything under the chroot is
#      readable; `_ftp` is the only place an anonymous session can write. That
#      is not a limitation, it is the design: this server is reachable by every
#      telephone on the party hotspot, and a session that could write anywhere
#      under /srv/karaoke could overwrite the song library or the database that
#      describes it.
#
# The drop directory:
#   Songs are dropped into `_ftp` and taken into the library by a button on the
#   admin console, which validates them exactly as an upload through the page
#   is validated - antivirus, duplicate check, UltraStar parse - and deletes
#   what it refuses. See 05-web/server/archstar_web/drop.py.
#
#   It is created HERE as well as by the web service, on purpose. The two
#   halves ship in the same package but do not start together, and a machine
#   where the FTP server came up first would otherwise offer a chroot with
#   nowhere to write in it - which reads, in an FTP client, as "this server is
#   broken". It was read exactly that way once.
#
# Usage:
#   archstar-ftp run             start vsftpd and enforce the 15-minute inactivity timeout
#   archstar-ftp note-stopped    record that the FTP server is offline
#
# Configuration & Environment:
#   ARCHSTAR_FTP_CONF            where the generated vsftpd configuration lives
#                                (default: /run/archstar/ftp/vsftpd.conf)
#   ARCHSTAR_FTP_STATUS          status published for the web API
#                                (default: /run/archstar/ftp.json)
#   ARCHSTAR_FTP_ROOT            chroot path (default: /srv/karaoke)
#   ARCHSTAR_FTP_LOG_DIR         where FTP logs go (default: /srv/karaoke/logs/servers/ftp)
#   ARCHSTAR_VSFTPD              vsftpd executable (default: vsftpd)
#   ARCHSTAR_FTP_IDLE_TIMEOUT    inactivity sleep duration in seconds (default: 900 = 15 min)
#   ARCHSTAR_FTP_CHECK_INTERVAL  polling interval in seconds (default: 5)
#   ARCHSTAR_FTP_ACTIONS         actions directory to update settings on timeout
#                                (default: /usr/lib/archstar-actions/actions)
#

set -euo pipefail

PORT=21
PASV_MIN=40000
PASV_MAX=40050

CONF="${ARCHSTAR_FTP_CONF:-/run/archstar/ftp/vsftpd.conf}"
STATUS_FILE="${ARCHSTAR_FTP_STATUS:-/run/archstar/ftp.json}"
FTP_ROOT="${ARCHSTAR_FTP_ROOT:-/srv/karaoke}"
LOG_DIR="${ARCHSTAR_FTP_LOG_DIR:-/srv/karaoke/logs/servers/ftp}"
VSFTPD="${ARCHSTAR_VSFTPD:-vsftpd}"
IDLE_TIMEOUT="${ARCHSTAR_FTP_IDLE_TIMEOUT:-900}"
CHECK_INTERVAL="${ARCHSTAR_FTP_CHECK_INTERVAL:-5}"
ACTIONS_DIR="${ARCHSTAR_FTP_ACTIONS:-/usr/lib/archstar-actions/actions}"
# Where songs are dropped, relative to the chroot. The same name the web
# service uses (DROP_DIRECTORY in drop.py); test-ftp.sh reads both and fails if
# they ever stop agreeing, because a drop-box under two names is two drop-boxes
# and one of them is never emptied.
DROP_DIR_NAME="${ARCHSTAR_FTP_DROP_NAME:-_ftp}"
# Every parameter this machine has, in one file at the top of the chroot
# (`settings_file.py`). The same name the web service writes, checked against
# it by test-ftp.sh: a settings file under two names is a file somebody edits
# and a machine that never reads it.
SETTINGS_FILE_NAME="${ARCHSTAR_FTP_SETTINGS_NAME:-settings.json}"
ARCHSTAR_RELEASE_FILE="${ARCHSTAR_RELEASE_FILE:-/etc/archstar-release}"

die() {
    printf 'ftp error: %s\n' "$*" >&2
    exit 1
}

# The account the web service and the game run as, which is who has to be able
# to move an uploaded song into the library afterwards. Read from the file the
# installer wrote; the documented default when there is none.
kiosk_user() {
    local user="${ARCHSTAR_KIOSK_USER:-}"
    if [[ -z "${user}" && -r "${ARCHSTAR_RELEASE_FILE}" ]]; then
        user="$(sed -n 's/^KIOSK_USER=//p' "${ARCHSTAR_RELEASE_FILE}" \
                | head -n1 | tr -d "\"' ")"
    fi
    printf '%s\n' "${user:-ultrastar}"
}

# The one writable place in the chroot, and every bit of its mode is
# load-bearing.
#
# **World-writable** because an anonymous session is mapped onto `ftp` or
# `nobody`, which is not the account that owns this partition - so without it
# there is nowhere at all to write, which is exactly the "the FTP server has a
# problem" this fixes. **Sticky** because a world-writable directory always
# wants it: one uploader cannot then delete another's file. And **owned by the
# kiosk account**, which is what lets the web service move what it accepts and
# delete what it refuses, whoever uploaded it - the owner of a sticky directory
# may unlink anything in it.
#
# Nothing else in the tree is touched. An anonymous session can read the whole
# chroot and write in this one directory, which is the whole permission model.
prepare_drop_directory() {
    local owner drop="${FTP_ROOT}/${DROP_DIR_NAME}"
    owner="$(kiosk_user)"

    if ! install -d -m 1777 "${drop}" 2>/dev/null; then
        printf 'archstar-ftp: cannot create %s; uploads will have nowhere to go\n' \
            "${drop}" >&2
        return 0
    fi
    # `install -d` on a directory that already exists leaves its mode alone on
    # some coreutils, so the mode is set again rather than assumed.
    chmod 1777 "${drop}" 2>/dev/null \
        || printf 'archstar-ftp: cannot set the mode of %s\n' "${drop}" >&2
    if id -u "${owner}" >/dev/null 2>&1; then
        chown "${owner}:${owner}" "${drop}" 2>/dev/null \
            || printf 'archstar-ftp: cannot give %s to %s\n' "${drop}" "${owner}" >&2
    fi
    printf 'archstar-ftp: songs may be dropped in /%s\n' "${DROP_DIR_NAME}"
}

# The one *file* an anonymous session may write, and its mode is the whole of
# how.
#
# World-writable so a session mapped onto `ftp` or `nobody` can replace its
# contents; in a directory that stays the kiosk account's, so it cannot be
# unlinked, renamed, or joined by anything else - unlinking needs write
# permission on the directory, and nothing but the kiosk account has it. That
# is the narrowest opening this could have: `_ftp` is a place to put files,
# and this is one file to overwrite.
#
# Not created here if it is missing. The web service is its only author - it
# writes the current settings, the allowed words and all - and a placeholder
# left by this script would be a document nobody wrote sitting where the real
# one goes. What this does is make sure the mode survives, the way
# `prepare_drop_directory` makes sure the drop-box's does.
prepare_settings_file() {
    local file="${FTP_ROOT}/${SETTINGS_FILE_NAME}"
    [[ -f "${file}" ]] || {
        printf 'archstar-ftp: %s is not there yet; the web service writes it\n' \
            "/${SETTINGS_FILE_NAME}" >&2
        return 0
    }
    chmod 0666 "${file}" 2>/dev/null \
        || printf 'archstar-ftp: cannot make %s writable; it will be read-only\n' \
            "${file}" >&2
    printf 'archstar-ftp: the settings are in /%s\n' "${SETTINGS_FILE_NAME}"
}

write_status() {
    local up="$1"
    local idle="${2:-0}"
    local expires="${3:-0}"
    local reason="${4:-}"

    install -d "$(dirname "${STATUS_FILE}")"
    local tmp="${STATUS_FILE}.tmp.$$"
    
    if [[ "${up}" == "true" ]]; then
        cat >"${tmp}" <<EOF
{
  "available": true,
  "up": true,
  "address": "0.0.0.0",
  "port": ${PORT},
  "pasv_min": ${PASV_MIN},
  "pasv_max": ${PASV_MAX},
  "idle_timeout_seconds": ${IDLE_TIMEOUT},
  "idle_seconds": ${idle},
  "expires_in_seconds": ${expires}
}
EOF
    else
        if [[ -n "${reason}" ]]; then
            cat >"${tmp}" <<EOF
{
  "available": true,
  "up": false,
  "reason": "${reason}"
}
EOF
        else
            cat >"${tmp}" <<EOF
{
  "available": true,
  "up": false
}
EOF
        fi
    fi
    mv -f "${tmp}" "${STATUS_FILE}"
}

note_stopped() {
    write_status false 0 0 ""
}

generate_config() {
    local target_dir
    target_dir="$(dirname "${CONF}")"
    install -d "${target_dir}"
    install -d -m 0755 "${LOG_DIR}"

    # Determine unprivileged username to map anonymous requests to
    local owner
    owner="$(kiosk_user)"
    local ftp_user="kiosk"
    if ! id -u "${ftp_user}" >/dev/null 2>&1; then
        if id -u ftp >/dev/null 2>&1; then
            ftp_user="ftp"
        elif id -u nobody >/dev/null 2>&1; then
            ftp_user="nobody"
        else
            ftp_user="$(id -un)"
        fi
    fi

    cat >"${CONF}" <<EOF
# ArchStar Anonymous FTP Server Configuration (Generated)
listen=YES
listen_ipv6=NO
listen_port=${PORT}

anonymous_enable=YES
no_anon_password=YES
local_enable=NO
write_enable=YES
anon_upload_enable=YES
anon_mkdir_write_enable=YES
anon_other_write_enable=YES

anon_root=${FTP_ROOT}
anon_world_readable_only=NO
anon_umask=000
ftp_username=${ftp_user}
# An anonymous upload lands owned by the account that has to move it into the
# library, rather than by the unprivileged one it was written as. Without this
# the web service depends on owning the *directory* to delete what it refuses,
# which is true and fragile; with it, it owns the files outright.
chown_uploads=YES
chown_username=${owner}
allow_writeable_chroot=YES
seccomp_sandbox=NO

pasv_enable=YES
pasv_min_port=${PASV_MIN}
pasv_max_port=${PASV_MAX}
pasv_promiscuous=YES

xferlog_enable=YES
xferlog_file=${LOG_DIR}/vsftpd.log
vsftpd_log_file=${LOG_DIR}/vsftpd.log
log_ftp_protocol=YES

ftpd_banner=ArchStar karaoke kiosk. Everything here is readable; put songs in /${DROP_DIR_NAME} - the console imports them.
connect_from_port_20=YES
idle_session_timeout=300
data_connection_timeout=300
dirmessage_enable=NO
EOF
    chmod 0600 "${CONF}"
}

has_active_connections() {
    local pid="$1"
    # Check 1: Any child worker processes spawned by vsftpd
    if pgrep -P "${pid}" >/dev/null 2>&1; then
        return 0
    fi

    # Check 2: Established TCP sockets on port 21 or passive ports via ss if available
    if command -v ss >/dev/null 2>&1; then
        local count
        count="$(ss -t state established "( sport = :${PORT} or sport >= :${PASV_MIN} and sport <= :${PASV_MAX} )" 2>/dev/null | wc -l || echo 0)"
        if (( count > 1 )); then
            return 0
        fi
    fi

    return 1
}

get_log_mtime() {
    local logfile="${LOG_DIR}/vsftpd.log"
    if [[ -f "${logfile}" ]]; then
        stat -c %Y "${logfile}" 2>/dev/null || stat -f %m "${logfile}" 2>/dev/null || echo 0
    else
        echo 0
    fi
}

run_server() {
    # Before vsftpd, not after: a client that connects in the first second
    # must find the drop directory already there.
    prepare_drop_directory
    prepare_settings_file
    generate_config

    # Launch vsftpd in background
    "${VSFTPD}" "${CONF}" &
    local vsftpd_pid=$!

    # Setup trap to kill vsftpd on termination
    trap 'kill -TERM "${vsftpd_pid}" 2>/dev/null || true; note_stopped; exit 0' INT TERM EXIT

    local last_activity
    last_activity="$(date +%s)"
    local last_log_mtime
    last_log_mtime="$(get_log_mtime)"

    write_status true 0 "${IDLE_TIMEOUT}" ""

    while kill -0 "${vsftpd_pid}" 2>/dev/null; do
        sleep "${CHECK_INTERVAL}"

        local now
        now="$(date +%s)"

        # Check log file activity
        local current_log_mtime
        current_log_mtime="$(get_log_mtime)"
        if (( current_log_mtime > last_log_mtime )); then
            last_log_mtime="${current_log_mtime}"
            last_activity="${now}"
        fi

        # Check socket / process activity
        if has_active_connections "${vsftpd_pid}"; then
            last_activity="${now}"
        fi

        local idle_sec=$(( now - last_activity ))
        local expires_sec=$(( IDLE_TIMEOUT - idle_sec ))
        if (( expires_sec < 0 )); then
            expires_sec=0
        fi

        write_status true "${idle_sec}" "${expires_sec}" ""

        # Check if inactivity timeout reached
        if (( idle_sec >= IDLE_TIMEOUT )); then
            printf 'archstar-ftp: no activity for %s seconds, putting FTP server to sleep\n' "${IDLE_TIMEOUT}"
            kill -TERM "${vsftpd_pid}" 2>/dev/null || true
            
            # Switch setting to off so the UI and systemd reflect the inactive state
            if [[ -x "${ACTIONS_DIR}/ftp" ]]; then
                "${ACTIONS_DIR}/ftp" off || true
            elif [[ -f "/etc/archstar/settings/ftp" ]]; then
                printf 'off\n' > "/etc/archstar/settings/ftp" || true
            fi
            
            write_status false 0 0 "idle_timeout"
            trap - INT TERM EXIT
            exit 0
        fi
    done

    trap - INT TERM EXIT
    note_stopped
}

command="${1:-run}"
case "${command}" in
    run)
        run_server
        ;;
    note-stopped)
        note_stopped
        ;;
    *)
        die "unknown command: '${command}'; use 'run' or 'note-stopped'"
        ;;
esac
