#!/usr/bin/env bash
#
# NovaSync — bare-metal Minecraft server installer
#
# Usage (one paste, on a fresh Ubuntu 22.04 / 24.04 or Debian 13 server):
#     curl -fsSL https://seidrlabs.online/novasync/install.sh | sudo bash
#
# What this does:
#   1. Checks the OS and that a Java 21 package exists for it
#   2. Installs Java 21 (OpenJDK headless), plus curl, jq, python3, sudo, ufw
#   3. Creates a system user `nova` and an install directory `/opt/nova`
#   4. Downloads the latest Paper 1.21.11 build from PaperMC and checks its SHA-256
#   5. Accepts the Mojang EULA on the user's behalf (visible in eula.txt)
#   6. Downloads the latest NovaSync plugin from seidrlabs.online/novasync
#   7. Configures server.properties defaults. Right after the first start it replaces the
#      plugin's default dashboard login (admin / changeme) with a RANDOM password and checks
#      it by logging in. The dashboard port is kept closed to outside traffic until then
#      (where iptables is available).
#   8. Installs and enables a systemd service (auto-restart on crash)
#   9. Adds ufw rules for Minecraft / Bedrock / voicechat. The dashboard port is
#      NOT opened unless you set NOVA_OPEN_DASHBOARD=1: a small firewall rule that
#      does not depend on ufw (nova-dashboard-guard.service, started at every boot)
#      keeps it closed to other computers, and the installer reads it back before it
#      says so. HTTP and HTTPS are opened only if you ask for HTTPS with a domain name.
#  10. Starts the server, waits for boot, prints connection details and the
#      dashboard login
#
# Re-run any time to upgrade Paper + NovaSync to latest (an existing dashboard
# password and an existing HTTPS setup are left alone).
# Logs everything to /var/log/nova-install.log (or /tmp/ on read-only roots).
# The log never contains the dashboard password or NOVA_NETWORK_KEY.
#
# Tested on clean Ubuntu 22.04, Ubuntu 24.04 and Debian 13 systems (see
# https://seidrlabs.online/novasync/ for the date). Debian 12 has no Java 21
# package, so it is refused with a clear message.
#

set -euo pipefail

# ─── Configuration (override via env vars) ───────────────────────────────────
INSTALL_DIR="${INSTALL_DIR:-/opt/nova}"
NOVA_USER="${NOVA_USER:-nova}"
PAPER_VERSION="${PAPER_VERSION:-1.21.11}"
SERVICE_NAME="${SERVICE_NAME:-nova-minecraft}"
HEAP_GB="${HEAP_GB:-}"   # empty = auto-detect from /proc/meminfo

SYNC_BASE="${SYNC_BASE:-https://seidrlabs.online/novasync/downloads}"
# 2.16.358 — base URL the OS-updates helper + sudoers are fetched from. Same
# host as SYNC_BASE but a different path because /downloads is JAR-only.
INSTALLER_ASSETS_BASE="${INSTALLER_ASSETS_BASE:-https://seidrlabs.online/novasync/installer}"
# PaperMC retired its v2 API (HTTP 410 "sunset"); this is the current one (Fill v3).
PAPER_API="${PAPER_API:-https://fill.papermc.io/v3/projects/paper}"

MC_PORT="${MC_PORT:-25565}"
BEDROCK_PORT="${BEDROCK_PORT:-19132}"
# The plugin's dashboard listens on 8585 and this installer cannot move it, so a different DASHBOARD_PORT is
# refused (main checks) instead of guarding a port the plugin is not on.
REQUESTED_DASHBOARD_PORT="${DASHBOARD_PORT:-}"
DASHBOARD_PORT=8585
VOICECHAT_PORT="${VOICECHAT_PORT:-24454}"
HTTP_PORT="${HTTP_PORT:-80}"
HTTPS_PORT="${HTTPS_PORT:-443}"

# nginx reverse proxy + optional Let's Encrypt HTTPS.
# Set NOVA_DOMAIN + NOVA_EMAIL non-interactively to skip the prompt and
# auto-issue a cert (e.g. NOVA_DOMAIN=play.example.com NOVA_EMAIL=me@x.com).
# Leave empty to be prompted, or skip HTTPS entirely with NOVA_SKIP_HTTPS=1.
NOVA_DOMAIN="${NOVA_DOMAIN:-}"
NOVA_EMAIL="${NOVA_EMAIL:-}"
NOVA_SKIP_HTTPS="${NOVA_SKIP_HTTPS:-}"

# The dashboard port is closed to other computers by default, with or without ufw (see settle_dashboard_guard).
# NOVA_OPEN_DASHBOARD=1 opens it (plain http: the password then crosses the internet unencrypted).
NOVA_OPEN_DASHBOARD="${NOVA_OPEN_DASHBOARD:-}"

# Optional pre-seed for network.key. Set to the canonical shared secret used by
# your existing NovaSync hub when adding a second/third/Nth node to an existing
# network — saves the operator from a "fresh install registers with the hub
# under a random UUID, hub returns 401, dashboard shows Not in directory yet"
# round-trip after the first-run wizard. If empty (default), the plugin
# auto-generates a per-node random key on first onEnable as before. See
# project_novasync_eu_novalink_network_key_fix_2026_05_14.md memory entry.
NOVA_NETWORK_KEY="${NOVA_NETWORK_KEY:-}"

PLAYIT_HOSTNAME=""   # set by detect_playit() if a tunnel is found
HTTPS_ALREADY_LIVE="" # set by setup_nginx_proxy() when our https vhost and its certificate are already in place
HTTPS_DOMAIN_LIVE=""  # set by setup_https_optional() on cert success — used by print_summary()
DASH_PASSWORD=""      # set by secure_dashboard_login() when it generates one — printed once, never logged
FIREWALL_STATE="none" # none | inactive | active — set by configure_firewall()
DASH_FW_CLOSED=""     # set by configure_firewall() once it has read back that ufw has no allow rule for the dashboard port

LOG="/var/log/nova-install.log"

# ─── Pretty output ───────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
    R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; D='\033[1m'; N='\033[0m'
else
    R=''; G=''; Y=''; B=''; D=''; N=''
fi

step()    { echo -e "${B}${D}==>${N} ${D}$1${N}"; }
info()    { echo -e "    $1"; }
success() { echo -e "${G}OK${N}  $1"; }
warn()    { echo -e "${Y}!!  $1${N}"; }
# The service is enabled (create_systemd_service) and may be running with the default login. If the installer ends
# before the login has been proven safe, stop it and keep it from starting at boot: the temporary firewall rule does
# not survive a reboot, the default login would.
lock_down_if_unsecured() {
    [[ -n "${DASH_LIVE:-}" && -z "${DASH_SECURED:-}" && -z "${DASH_LOCKED:-}" ]] || return 0
    DASH_LOCKED=1
    systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
    echo -e "${Y}!!  The server was stopped and DISABLED (it will not start at boot) because its dashboard login could not be proven safe.${N}" >&2
    echo -e "    Run this installer again, or change the dashboard login yourself, then: systemctl enable --now ${SERVICE_NAME}" >&2
}

fail()    {
    echo -e "${R}!!  $1${N}" >&2
    lock_down_if_unsecured
    if [[ -n "${DASH_GUARD_V4:-}" && -z "${DASH_LIVE:-}" ]]; then
        release_dashboard_guard   # nothing has been started yet: no reason to leave the port shut
    fi
    if [[ -n "${DASH_GUARD_V4:-}" ]]; then
        echo -e "${Y}!!  Port ${DASHBOARD_PORT} was closed to outside traffic during the install and stays closed until the next reboot." >&2
        echo -e "    To reopen it sooner: iptables -D INPUT -p tcp --dport ${DASHBOARD_PORT} ! -i lo -m comment --comment nova-installer-temp -j DROP${N}" >&2
    fi
    exit 1
}

banner() {
    cat <<'EOF'

   _   _                  ____
  | \ | | _____   ____ _ / ___| _   _ _ __   ___
  |  \| |/ _ \ \ / / _` |\___ \| | | | '_ \ / __|
  | |\  | (_) \ V / (_| | ___) | |_| | | | | (__
  |_| \_|\___/ \_/ \__,_||____/ \__, |_| |_|\___|
                                |___/

  Voice-controlled Minecraft companion + admin assistant
  https://seidrlabs.online/novasync/

EOF
}

# ─── Pre-flight checks ───────────────────────────────────────────────────────
require_root() {
    if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
        fail "Run with sudo:  curl -fsSL https://seidrlabs.online/novasync/install.sh | sudo bash"
    fi
}

require_supported_os() {
    [[ -f /etc/os-release ]] || fail "Cannot detect OS — /etc/os-release missing"
    # shellcheck disable=SC1091
    . /etc/os-release
    case "${ID:-}" in
        ubuntu|debian) success "OS: ${PRETTY_NAME}" ;;
        *) warn "Untested OS: ${PRETTY_NAME:-unknown} — proceeding anyway" ;;
    esac
}

require_supported_arch() {
    case "$(uname -m)" in
        x86_64|amd64|aarch64|arm64) success "Arch: $(uname -m)" ;;
        *) fail "Unsupported architecture: $(uname -m). Need x86_64 or arm64." ;;
    esac
}

setup_log() {
    if ! touch "$LOG" 2>/dev/null; then
        LOG="/tmp/nova-install.log"
        : > "$LOG"
        warn "Cannot write /var/log/nova-install.log — using $LOG"
    else
        : > "$LOG"
    fi
    info "Log: $LOG"
}

# 2.17.155 W7-F16 — Top-level cleanup trap for any temp files registered via
# `register_tmp <path>`. Catches SIGINT (Ctrl-C), SIGTERM, and normal EXIT so
# secret-bearing temp files (playit secret_key) and download staging files
# don't leak. Each registered path is rm-f'd best-effort.
INSTALLER_TEMP_FILES=()
register_tmp() {
    INSTALLER_TEMP_FILES+=("$1")
}
cleanup_tmp_files() {
    local f rc=$?
    # any non-zero end (set -e, a signal, fail) after the service exists and before the login is proven safe
    if [[ $rc -ne 0 ]]; then lock_down_if_unsecured; fi
    # An installer that stops before the server was ever started has no reason to keep the port shut.
    if [[ -n "${DASH_GUARD_V4:-}" && -z "${DASH_LIVE:-}" ]]; then release_dashboard_guard || true; fi
    for f in "${INSTALLER_TEMP_FILES[@]}"; do
        [[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
    done
}
trap cleanup_tmp_files EXIT INT TERM

# Everything inside the service account's own tree ($INSTALL_DIR/server and below) is written AS
# the service account, never as root. The account owns those directories, so on a re-run it could
# have put a symlink where a directory or file used to be; a root-owned write would follow the link
# to anywhere on the machine, while a write made as the account can only reach what the account
# can already reach.
as_nova() { runuser -u "$NOVA_USER" -- "$@"; }

# put_as_nova <staged-file> <mode> <destination>: the service account removes whatever is at the
# destination first (a symlink is removed itself, never followed), then installs the new file.
# The explicit rm matters: install(1) of coreutils 8.x (Ubuntu 22.04) stats THROUGH an existing
# symlink first and gives up when its target is out of reach, instead of replacing the link.
put_as_nova() {
    as_nova rm -f -- "$3" && as_nova install -m "$2" "$1" "$3"
}

# A root-made temp file the service account may read (group nova, 0640), for as_nova install to copy.
stage_file() {
    local t
    t="$(mktemp)" || return 1
    register_tmp "$t"
    chgrp "$NOVA_USER" "$t" && chmod 0640 "$t" || return 1
    printf '%s\n' "$t"
}

# Write stdin to FILE with MODE, as the service account. The text is staged in a root-made temp file and
# put in place by the service account with install(1), which removes an existing FILE (a symlink
# included) before creating it, so a planted link is replaced, never followed.
write_owned() {   # write_owned <file> <mode>
    local f="$1" mode="$2" tmp
    tmp="$(stage_file)" || fail "mktemp failed"
    cat > "$tmp"
    put_as_nova "$tmp" "$mode" "$f" || fail "could not write $f"
    rm -f "$tmp"
}

# ─── Install steps ───────────────────────────────────────────────────────────
detect_heap_gb() {
    # Granny-safe heap defaults — never starve the OS, since the same laptop or
    # VPS may also run playit, Discord, browser, monitoring agents, etc.
    # Tested 2026-05-01 on 12GB laptop: old formula gave 10GB heap → 200Mi
    # available RAM → SSH froze for 30s during boot. New rule: ≥8G systems
    # always keep 4GB free for OS. Power users override with HEAP_GB env var.
    #
    # 2026-05-14: bumped 3-4G reserve from 1G → 2G. The previous "total-1G"
    # rule on a 3.7GB Hetzner CX21 gave 3G heap, then Paper non-heap (~400MB)
    # + Geyser native (~100MB) + ViaVersion/Floodgate/SVC/LuckPerms/CoreProtect
    # + JIT + metaspace + OS pushed RSS to ~3.8GB → JVM heap paged to swap →
    # tick-blocking disk I/O on every GC sweep → severe lag. 2G heap on the
    # same 3.7G box: 750MB heap-used / 2048MB-max, 946MB OS available, 0 swap.
    #   ≤2G       → 1G heap
    #   3-4G      → total - 2G heap (2G for OS + Paper non-heap + plugins)
    #   5-7G      → total - 2G heap (2G for OS)
    #   ≥8G       → total - 4G heap, capped at 12G (G1GC scales poorly above ~12G)
    local total_kb total_gb heap
    total_kb=$(grep -E '^MemTotal:' /proc/meminfo 2>/dev/null | awk '{print $2}')
    if [[ -z "$total_kb" ]]; then echo 2; return; fi
    total_gb=$(( (total_kb + 524288) / 1048576 ))   # round to nearest GB
    if   [[ $total_gb -le 2 ]]; then heap=1
    elif [[ $total_gb -le 4 ]]; then heap=$(( total_gb - 2 ))
    elif [[ $total_gb -le 7 ]]; then heap=$(( total_gb - 2 ))
    else
        heap=$(( total_gb - 4 ))
        [[ $heap -gt 12 ]] && heap=12
    fi
    [[ $heap -lt 1 ]] && heap=1
    echo "$heap"
}

detect_playit() {
    # 2.15.444 — Detect a running playit tunnel that forwards to our MC port.
    # Multi-method: local toml → systemd journal → playit CLI → cloud API →
    # interactive prompt → fall back to WAN IP (existing behavior).
    # Runs as root (install.sh is sudo'd), so we CAN read /etc/playit/playit.toml.
    # Bug fix: 2.15.441's toml parser found nothing because the local toml only
    # has secret_key + api_url + mappings=[] — tunnels live in the playit cloud.
    step "Checking for Playit tunnel..."
    local toml="/etc/playit/playit.toml"
    if [[ ! -f "$toml" ]]; then
        info "No playit.toml found — skipping tunnel detection"
        return
    fi

    # Check playit daemon is running
    if ! systemctl is-active --quiet playit 2>/dev/null \
       && ! pgrep -x playit >/dev/null 2>&1; then
        info "playit.toml found but daemon not running — skipping"
        return
    fi

    info "Playit agent detected and running — probing for tunnel hostname"

    # --- Method 1: Parse [[tunnels]] from local toml (older playit configs) ---
    local domain=""
    domain=$(awk -v port="$MC_PORT" '
        /^\[\[tunnels/ { in_tunnel=1; cur_port=""; cur_domain=""; next }
        /^\[/          { if (in_tunnel && cur_port==port && cur_domain!="") { print cur_domain; exit }; in_tunnel=0; next }
        !in_tunnel     { next }
        {
            gsub(/^[[:space:]]+|[[:space:]]+$/, "")
            idx = index($0, "=")
            if (idx == 0) next
            key = substr($0, 1, idx-1)
            val = substr($0, idx+1)
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
            gsub(/^"|"$/, "", val)
            if (key == "local_port") cur_port = val
            if (key == "friend_facing" && val != "") cur_domain = val
            if (key == "custom"          && val != "" && cur_domain == "") cur_domain = val
            if (key == "assigned_domain" && val != "" && cur_domain == "") cur_domain = val
            if (key == "name"            && val != "" && cur_domain == "") cur_domain = val
        }
        END { if (in_tunnel && cur_port==port && cur_domain!="") print cur_domain }
    ' "$toml" 2>/dev/null)

    if [[ -n "$domain" ]]; then
        PLAYIT_HOSTNAME="$domain"
        success "Playit tunnel detected from local toml — public-host: $domain"
        return
    fi

    info "No [[tunnels]] in local toml (cloud-managed) — trying other methods"

    # --- Method 2: Parse systemd journal for tunnel hostnames ---
    # Playit agent logs tunnel addresses on startup (e.g. "tunnel ready",
    # routing info with *.joinmc.link / *.ply.gg subdomains). Grab the first
    # domain-like token matching known playit suffixes.
    domain=$(journalctl -u playit --no-pager -n 500 2>/dev/null \
        | grep -oP '[\w][\w.-]*\.(joinmc\.link|ply\.gg)' \
        | head -1) || true

    if [[ -n "$domain" ]]; then
        PLAYIT_HOSTNAME="$domain"
        success "Playit tunnel detected from systemd journal — public-host: $domain"
        return
    fi

    # --- Method 3: Try playit CLI (if the binary supports listing tunnels) ---
    if command -v playit &>/dev/null; then
        # Try several subcommands — playit CLI format varies by version
        # 2.17.155 W7-F12 — split the subcmd string into an array so passing it
        # to `playit` is explicit (no glob expansion, no SC2086 footgun).
        for subcmd in "tunnels list" "tunnels" "status"; do
            local cli_out
            local sc_args=()
            read -ra sc_args <<< "$subcmd"
            cli_out=$(playit "${sc_args[@]}" 2>/dev/null) || continue
            domain=$(echo "$cli_out" \
                | grep -iP "(:${MC_PORT}\b|port\s*[:=]\s*${MC_PORT})" \
                | grep -oP '[\w][\w.-]*\.(joinmc\.link|ply\.gg|playit\.gg)' \
                | head -1) || true
            if [[ -n "$domain" ]]; then
                PLAYIT_HOSTNAME="$domain"
                success "Playit tunnel detected via CLI — public-host: $domain"
                return
            fi
        done
    fi

    # --- Method 4: Try playit cloud API with agent secret_key ---
    # The toml has the agent's secret_key. We attempt to query the playit
    # cloud for this agent's tunnels. The API format is not guaranteed stable
    # so this is a best-effort attempt.
    local secret_key
    secret_key=$(grep -oP 'secret_key\s*=\s*"\K[^"]+' "$toml" 2>/dev/null) || true
    if [[ -n "$secret_key" ]]; then
        # 2.17.155 W7-F13 — Was: secret_key passed as inline curl args (-H +
        # -d), visible in /proc/<pid>/cmdline to any local user during the
        # request. Now: write the auth header and JSON body to chmod-600 temp
        # files and use curl's `@<file>` syntax — secret stays out of argv.
        local hdr_file body_file
        hdr_file=$(mktemp) && chmod 600 "$hdr_file"
        register_tmp "$hdr_file"
        body_file=$(mktemp) && chmod 600 "$body_file"
        register_tmp "$body_file"
        printf 'Authorization: agent-key %s\n' "$secret_key" > "$hdr_file"
        printf '{"secret_key":"%s"}' "$secret_key" > "$body_file"
        local api_resp
        # Attempt 4a: Try REST-style agent tunnels endpoint
        api_resp=$(curl -fsSm 10 \
            -H "@$hdr_file" \
            "https://api.playit.gg/agent/tunnels" 2>/dev/null) || true
        if [[ -z "$api_resp" ]]; then
            # Attempt 4b: Try JSON-RPC style
            api_resp=$(curl -fsSm 10 -X POST \
                -H "Content-Type: application/json" \
                -d "@$body_file" \
                "https://api.playit.cloud/agent" 2>/dev/null) || true
        fi
        # Scrub secret-bearing temp files regardless of outcome.
        rm -f "$hdr_file" "$body_file"
        if [[ -n "$api_resp" ]]; then
            # Extract any domain from JSON response
            domain=$(echo "$api_resp" \
                | grep -oP '"(?:domain|assigned_domain|custom_domain|friend_facing|assigned_srv)"\s*:\s*"[^"]*"' \
                | head -1 \
                | grep -oP ':\s*"\K[^"]+') || true
            if [[ -n "$domain" ]]; then
                PLAYIT_HOSTNAME="$domain"
                success "Playit tunnel detected via cloud API — public-host: $domain"
                return
            fi
        fi
        info "Playit cloud API did not return usable tunnel info"
    fi

    # --- Method 5: Interactive prompt (only when stdin is a tty) ---
    # When install.sh is piped via curl|bash, stdin is the pipe → -t 0 is false
    # → this block is skipped (correct: granny can't answer prompts mid-pipe).
    # When run directly (sudo bash install.sh), the admin can type their domain.
    if [[ -t 0 ]]; then
        echo ""
        warn "Playit is running but tunnels are cloud-managed — cannot auto-detect hostname"
        info "Open https://playit.gg → Tunnels to find your public address"
        info "(It looks like: yourname.joinmc.link or your custom domain)"
        echo -n "  Enter your playit tunnel hostname (or press Enter to skip): "
        read -r user_domain
        if [[ -n "$user_domain" ]]; then
            PLAYIT_HOSTNAME="$user_domain"
            success "Using provided hostname: $user_domain"
            return
        fi
    fi

    # --- All methods exhausted ---
    warn "Playit detected but could not determine tunnel hostname"
    info "Falling back to WAN IP auto-detect. Set your tunnel hostname manually"
    info "after install: Dashboard → Settings → Network → Public host"
}

configure_playit_host() {
    # 2.15.441 — After first boot, if playit was detected, patch the plugin's
    # config.yml so public-host shows the tunnel hostname instead of the WAN IP.
    # Only runs if detect_playit() found a hostname earlier.
    [[ -z "$PLAYIT_HOSTNAME" ]] && return

    local config="$INSTALL_DIR/server/plugins/NovaSyncPlugin/config.yml"
    if [[ ! -f "$config" ]]; then
        warn "NovaSyncPlugin config.yml not found — playit hostname not injected"
        info "Set it manually in the dashboard: Settings → Integrations → Network → Public host"
        return
    fi

    # Only patch if current value is empty, "auto", or a raw IPv4
    local current
    current=$(grep -E '^\s*public-host:' "$config" | head -1 \
        | sed -E 's/.*public-host:\s*"?([^"]*)"?.*/\1/' | tr -d '[:space:]') || true

    if [[ -z "$current" || "$current" == "auto" || "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        as_nova sed -i "s|^\(\s*public-host:\).*|\1 \"${PLAYIT_HOSTNAME}\"|" "$config"
        success "Patched config.yml: public-host → $PLAYIT_HOSTNAME (was: ${current:-empty})"
    else
        info "public-host already set to '$current' — not overwriting with playit hostname"
    fi
}

seed_network_key() {
    # Pre-seed network.key into the freshly-generated config.yml when the
    # operator passed NOVA_NETWORK_KEY=... at install time (e.g. for joining
    # an existing NovaSync network or registering with the shared hub
    # directory). Must run AFTER configure_server_properties (bootstrap Paper
    # boot has generated config.yml) and BEFORE start_server (so the real
    # boot reads the seeded value on the first onEnable, no restart needed).
    #
    # The plugin's SEC-M6 boot path migrates literal sensitive values from
    # config.yml into the encrypted credentials store, so this leaves a
    # placeholder + encrypted store entry on first boot — identical to the
    # state you get from typing the key into the dashboard's Network tab.
    [[ -z "$NOVA_NETWORK_KEY" ]] && return

    step "Seeding network.key from NOVA_NETWORK_KEY env var..."

    # Length guard. Real keys are 32+ chars (UUIDs are 36); anything below 16
    # is almost certainly a typo or a placeholder string ("changeme", "test").
    if [[ ${#NOVA_NETWORK_KEY} -lt 16 ]]; then
        warn "NOVA_NETWORK_KEY is suspiciously short (${#NOVA_NETWORK_KEY} chars) — skipping seed."
        info "Real keys are typically 32+ chars (UUIDs work well). Set a longer value or paste via the dashboard Network tab after the wizard."
        return
    fi

    local config="$INSTALL_DIR/server/plugins/NovaSyncPlugin/config.yml"
    if [[ ! -f "$config" ]]; then
        warn "NovaSyncPlugin config.yml not found at $config — network.key not seeded"
        info "The bootstrap Paper boot didn't generate config.yml. Check $LOG"
        info "Workaround: open the dashboard Network tab after the wizard and paste the key manually."
        return
    fi

    # The placeholder `${private:network.key}` is unique enough — api.key and
    # license.key have their own distinct placeholder strings, so this won't
    # clobber the wrong line. Done via Python to keep the literal characters
    # out of sed's interpretation path (some keys may contain regex chars,
    # forward slashes, or shell metas).
    local seeded
    # The key travels in the environment, not on the command line (argv is readable by every
    # local user through /proc). The file is replaced atomically, never written through a link.
    seeded=$(NOVA_SEED_VALUE="$NOVA_NETWORK_KEY" runuser -u "$NOVA_USER" -- python3 - "$config" <<'PY' 2>>"$LOG"
import os, sys, tempfile
cfg = sys.argv[1]
val = os.environ["NOVA_SEED_VALUE"]
placeholder = "${private:network.key}"
with open(cfg, encoding="utf-8") as fh:
    text = fh.read()
if placeholder not in text:
    # Already replaced (re-run case) or unexpected layout — leave alone.
    print("ALREADY", end="")
    sys.exit(0)
st = os.stat(cfg)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(cfg))
with os.fdopen(fd, "w", encoding="utf-8") as out:
    out.write(text.replace(placeholder, val, 1))
os.chmod(tmp, st.st_mode & 0o777)
os.chown(tmp, st.st_uid, st.st_gid)
os.replace(tmp, cfg)
print("SEEDED", end="")
PY
) || {
        warn "network.key seed failed — Python edit raised an error. See $LOG"
        info "Workaround: open the dashboard Network tab after the wizard and paste the key manually."
        return
    }

    if [[ "$seeded" == "SEEDED" ]]; then
        success "network.key seeded into config.yml — first onEnable will migrate it into the encrypted store"
    elif [[ "$seeded" == "ALREADY" ]]; then
        info "config.yml already has a literal network.key (re-run case) — left alone."
    fi
}

DASH_GUARD_RULE=(-p tcp --dport "$DASHBOARD_PORT" ! -i lo -m comment --comment nova-installer-temp -j DROP)
DASH_GUARD_V4=""
DASH_GUARD_V6=""
# A reverse proxy left by an earlier install (nginx -> 127.0.0.1:port) reaches the dashboard over loopback, which the rule
# above lets through. While the login is unproven, local connections to the port are dropped unless they come from root
# (the installer's own checks); the proxy then answers 502 instead of passing remote users to a default login.
DASH_GUARD_OUT_RULE=(-p tcp --dport "$DASHBOARD_PORT" -m owner ! --uid-owner 0 -m comment --comment nova-installer-temp -j REJECT --reject-with tcp-reset)
DASH_GUARD_OUT_V4=""
DASH_GUARD_OUT_V6=""
DASH_LIVE=""
DASH_SECURED=""
DASH_LOCKED=""
# The lasting guard: a boot-time unit that keeps the dashboard port closed to other computers whether or not ufw is on.
GUARD_UNIT="nova-dashboard-guard"
GUARD_SCRIPT="/usr/local/sbin/nova-dashboard-guard.sh"
GUARD_RULE=(-p tcp --dport "$DASHBOARD_PORT" ! -i lo -m comment --comment nova-dashboard-guard -j DROP)
GUARD_PERSISTENT=""   # set once the boot-time guard has been read back (unit enabled and active, rule in force)

guard_dashboard_port() {
    # The plugin's dashboard listens on every network address and, until its password is changed,
    # the login is admin / changeme. So this runs BEFORE anything that starts the plugin (the
    # bootstrap boot included): outside traffic to that one port is dropped, the rule is read back
    # with -C to prove it is in force, and if it cannot be put in place the installer stops. It is
    # removed by secure_dashboard_login once the password is changed (and by the exit trap if the
    # installer stops before the server was ever started).
    step "Closing port ${DASHBOARD_PORT} to outside traffic until the dashboard password is changed..."
    if ! command -v iptables >/dev/null 2>&1; then
        DEBIAN_FRONTEND=noninteractive apt-get install -y -qq iptables >>"$LOG" 2>&1 || true
    fi
    command -v iptables >/dev/null 2>&1 \
        || fail "iptables is not available and could not be installed, so port ${DASHBOARD_PORT} cannot be kept closed while the default login exists. Nothing has been started."
    iptables -C INPUT "${DASH_GUARD_RULE[@]}" >/dev/null 2>&1 \
        || iptables -I INPUT 1 "${DASH_GUARD_RULE[@]}" >>"$LOG" 2>&1 \
        || fail "Could not add the firewall rule that keeps port ${DASHBOARD_PORT} closed. Nothing has been started."
    iptables -C INPUT "${DASH_GUARD_RULE[@]}" >/dev/null 2>&1 \
        || fail "The firewall rule for port ${DASHBOARD_PORT} was added but could not be read back. Nothing has been started."
    DASH_GUARD_V4=1
    # IPv6: the same rule when ip6tables works; if it does not, that is only acceptable on a machine with no global IPv6 address.
    if command -v ip6tables >/dev/null 2>&1 \
       && { ip6tables -C INPUT "${DASH_GUARD_RULE[@]}" >/dev/null 2>&1 || ip6tables -I INPUT 1 "${DASH_GUARD_RULE[@]}" >>"$LOG" 2>&1; } \
       && ip6tables -C INPUT "${DASH_GUARD_RULE[@]}" >/dev/null 2>&1; then
        DASH_GUARD_V6=1
    elif [[ -r /proc/net/if_inet6 ]] && awk '$4=="00"{f=1} END{exit !f}' /proc/net/if_inet6; then
        fail "This machine has a public IPv6 address but the IPv6 firewall rule for port ${DASHBOARD_PORT} could not be put in place. Nothing has been started."
    fi
    # existing dashboard proxy (a managed vhost of ours or a custom one): its loopback path is closed too, and that rule is REQUIRED
    local proxy_present=""
    if [[ -d /etc/nginx ]] && grep -rqE "proxy_pass[[:space:]]+https?://(127\.0\.0\.1|localhost|\[::1\]):${DASHBOARD_PORT}([/;[:space:]]|$)" /etc/nginx/ 2>/dev/null; then
        proxy_present=1
    fi
    if iptables -C OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >/dev/null 2>&1 \
       || iptables -I OUTPUT 1 "${DASH_GUARD_OUT_RULE[@]}" >>"$LOG" 2>&1; then
        iptables -C OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >/dev/null 2>&1 && DASH_GUARD_OUT_V4=1
    fi
    if command -v ip6tables >/dev/null 2>&1 \
       && { ip6tables -C OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >/dev/null 2>&1 || ip6tables -I OUTPUT 1 "${DASH_GUARD_OUT_RULE[@]}" >>"$LOG" 2>&1; } \
       && ip6tables -C OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >/dev/null 2>&1; then
        DASH_GUARD_OUT_V6=1
    fi
    if [[ -n "$proxy_present" && -z "$DASH_GUARD_OUT_V4" ]]; then
        fail "An nginx proxy to the dashboard exists on this machine and the firewall rule that keeps it away from the dashboard during the install could not be put in place. Nothing has been started."
    fi
    [[ -n "$DASH_GUARD_OUT_V4" && -n "$proxy_present" ]] && info "An existing nginx proxy to the dashboard is cut off until the login is proven safe (it answers 502 meanwhile)."
    success "Port ${DASHBOARD_PORT} is closed to outside traffic (IPv4$([[ -n "$DASH_GUARD_V6" ]] && echo " and IPv6")), checked"
}

release_dashboard_guard() {
    if [[ -n "$DASH_GUARD_V4" ]]; then iptables  -D INPUT "${DASH_GUARD_RULE[@]}" >>"$LOG" 2>&1 || true; DASH_GUARD_V4=""; fi
    if [[ -n "$DASH_GUARD_V6" ]]; then ip6tables -D INPUT "${DASH_GUARD_RULE[@]}" >>"$LOG" 2>&1 || true; DASH_GUARD_V6=""; fi
    if [[ -n "$DASH_GUARD_OUT_V4" ]]; then iptables  -D OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >>"$LOG" 2>&1 || true; DASH_GUARD_OUT_V4=""; fi
    if [[ -n "$DASH_GUARD_OUT_V6" ]]; then ip6tables -D OUTPUT "${DASH_GUARD_OUT_RULE[@]}" >>"$LOG" 2>&1 || true; DASH_GUARD_OUT_V6=""; fi
}

# HTTP status of a dashboard login attempt. The password goes in on stdin, never on the command line.
dashboard_login_code() {   # dashboard_login_code <user> <password>
    printf '{"username":"%s","password":"%s"}' "$1" "$2" \
        | curl -s -o /dev/null -w '%{http_code}' -m 10 -X POST -H 'Content-Type: application/json' \
               --data-binary @- "http://127.0.0.1:${DASHBOARD_PORT}/dashboard/login" 2>/dev/null || echo 000
}

secure_dashboard_login() {
    # The plugin ships with the dashboard login admin / changeme. Replace it, through the plugin's
    # own change-credentials call, with a random password, then PROVE it by logging in: the new
    # password must log in (200) and the old one must be refused (401). Only a 401 for the default
    # login counts as "the default is not in use"; any other answer (200 = default works, 000, 5xx,
    # 429 ...) is never read as "safe": the installer stops with the port still closed.
    step "Securing the dashboard login..."
    local up="" i code
    for i in 1 2 3 4 5 6 7 8 9 10; do
        case "$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://127.0.0.1:${DASHBOARD_PORT}/dashboard" 2>/dev/null || echo 000)" in
            200|302) up=1; break ;;
        esac
        sleep 3
    done
    [[ -n "$up" ]] || fail "The dashboard did not answer, so its login could not be checked and the default login (admin / changeme) may still be active. Change the login before opening port ${DASHBOARD_PORT}."
    code="$(dashboard_login_code admin changeme)"
    case "$code" in
        401)
            info "The default login (admin / changeme) is refused on this server — left as it is."
            DASH_SECURED=1
            return ;;   # the temporary block stays until settle_dashboard_guard has put the lasting one in place
        200) ;;
        *)
            fail "Checking the default dashboard login gave HTTP ${code}, not a clear yes or no, so it cannot be treated as safe. Change the login yourself before opening port ${DASHBOARD_PORT}." ;;
    esac
    warn "The default login (admin / changeme) works — replacing it with a random password."
    local pw
    pw=$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))') || pw=""
    [[ ${#pw} -ge 20 ]] || fail "Could not generate a password, so the default login is still active. Change it yourself before opening port ${DASHBOARD_PORT}."
    local ccode new old
    ccode=$(printf '{"currentPassword":"changeme","newPassword":"%s"}' "$pw" \
        | curl -s -o /dev/null -w '%{http_code}' -m 15 -X POST -H 'Content-Type: application/json' --data-binary @- \
               "http://127.0.0.1:${DASHBOARD_PORT}/api/credentials/change" 2>>"$LOG") || ccode=000
    new="$(dashboard_login_code admin "$pw")"
    old="$(dashboard_login_code admin changeme)"
    if [[ "$new" == "200" && "$old" == "401" ]]; then
        DASH_PASSWORD="$pw"
        DASH_SECURED=1
        ( umask 077; printf 'NovaSync dashboard login (created %s)\nusername: admin\npassword: %s\n' \
            "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$pw" > /root/nova-dashboard-login.txt )
        success "Random dashboard password set and checked by logging in (saved to /root/nova-dashboard-login.txt, readable by root only)"
    else
        fail "The password change could not be confirmed (change call HTTP ${ccode}, new password login HTTP ${new}, old default login HTTP ${old}), so the default login may still be active. Change it yourself before opening port ${DASHBOARD_PORT}."
    fi
}

# The plugin's dashboard listens on every address and cannot be told otherwise. Unless exposure was asked for
# (NOVA_OPEN_DASHBOARD=1), the port therefore stays closed to other computers by a rule that does NOT depend on ufw
# (ufw is switched off on a fresh server, and filters nothing then) and that comes back at every boot: a oneshot unit
# ordered before the network comes up. Loopback is not blocked, so the SSH tunnel and an nginx proxy keep working.
# The temporary block of the install is released only after this one has been read back; if it cannot be proven the
# installer stops and the temporary block stays until the next reboot.
settle_dashboard_guard() {
    local unitf="/etc/systemd/system/${GUARD_UNIT}.service" v6_global=""
    [[ -r /proc/net/if_inet6 ]] && awk '$4=="00"{f=1} END{exit !f}' /proc/net/if_inet6 && v6_global=1
    if [[ -n "$NOVA_OPEN_DASHBOARD" ]]; then
        # exposure was asked for: take away any lasting guard an earlier run put in place
        if [[ -e "$unitf" || -e "$GUARD_SCRIPT" ]]; then
            systemctl disable --now "$GUARD_UNIT" >>"$LOG" 2>&1 || true
            rm -f "$unitf" "$GUARD_SCRIPT"
            systemctl daemon-reload >>"$LOG" 2>&1 || true
            if iptables -C INPUT "${GUARD_RULE[@]}" >/dev/null 2>&1; then
                warn "The firewall rule that kept port ${DASHBOARD_PORT} closed is still in place. Remove it: iptables -D INPUT -p tcp --dport ${DASHBOARD_PORT} ! -i lo -m comment --comment nova-dashboard-guard -j DROP"
            else
                info "The lasting block on port ${DASHBOARD_PORT} from an earlier install was removed (NOVA_OPEN_DASHBOARD is set)."
            fi
        fi
        release_dashboard_guard
        return 0
    fi
    step "Keeping port ${DASHBOARD_PORT} closed to other computers, also after a reboot..."
    cat > "$GUARD_SCRIPT" <<EOS
#!/bin/sh
# Written by the NovaSync installer. Keeps port ${DASHBOARD_PORT} (the NovaSync dashboard) closed to connections from other
# computers; loopback (an SSH tunnel, a local nginx proxy) is not affected. To open the port on purpose, run the
# installer again with NOVA_OPEN_DASHBOARD=1: that removes this file and the unit ${GUARD_UNIT}.service.
RULE="-p tcp --dport ${DASHBOARD_PORT} ! -i lo -m comment --comment nova-dashboard-guard -j DROP"
case "\$1" in
    start)
        rc=0
        iptables -C INPUT \$RULE 2>/dev/null || iptables -I INPUT 1 \$RULE || rc=1
        if command -v ip6tables >/dev/null 2>&1; then
            ip6tables -C INPUT \$RULE 2>/dev/null || ip6tables -I INPUT 1 \$RULE \
                || { [ -r /proc/net/if_inet6 ] && awk '\$4=="00"{f=1} END{exit !f}' /proc/net/if_inet6 && rc=1; }
        elif [ -r /proc/net/if_inet6 ] && awk '\$4=="00"{f=1} END{exit !f}' /proc/net/if_inet6; then
            rc=1
        fi
        exit \$rc ;;
    stop)
        iptables -D INPUT \$RULE 2>/dev/null
        command -v ip6tables >/dev/null 2>&1 && ip6tables -D INPUT \$RULE 2>/dev/null
        exit 0 ;;
    *) echo "usage: \$0 start|stop" >&2; exit 2 ;;
esac
EOS
    chmod 0755 "$GUARD_SCRIPT"; chown root:root "$GUARD_SCRIPT"
    cat > "$unitf" <<EOU
[Unit]
Description=NovaSync: keep port ${DASHBOARD_PORT} closed to other computers (written by the NovaSync installer)
DefaultDependencies=no
After=local-fs.target
Before=network-pre.target shutdown.target
Wants=network-pre.target
Conflicts=shutdown.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${GUARD_SCRIPT} start
ExecStop=${GUARD_SCRIPT} stop

[Install]
WantedBy=multi-user.target
EOU
    chmod 0644 "$unitf"
    systemctl daemon-reload >>"$LOG" 2>&1
    systemctl enable "$GUARD_UNIT" >>"$LOG" 2>&1 \
        || fail "Could not enable ${GUARD_UNIT}.service, so port ${DASHBOARD_PORT} would be open again after a reboot."
    systemctl restart "$GUARD_UNIT" >>"$LOG" 2>&1 \
        || fail "Could not start ${GUARD_UNIT}.service, so the lasting block on port ${DASHBOARD_PORT} is not in force."
    # read back: enabled, active, and the rule itself (IPv6 too where the machine has a public IPv6 address)
    systemctl is-enabled --quiet "$GUARD_UNIT" 2>/dev/null && systemctl is-active --quiet "$GUARD_UNIT" 2>/dev/null \
        || fail "${GUARD_UNIT}.service is not enabled and active after it was started, so the lasting block on port ${DASHBOARD_PORT} is not proven."
    iptables -C INPUT "${GUARD_RULE[@]}" >/dev/null 2>&1 \
        || fail "The lasting firewall rule for port ${DASHBOARD_PORT} could not be read back (IPv4)."
    if [[ -n "$v6_global" ]]; then
        ip6tables -C INPUT "${GUARD_RULE[@]}" >/dev/null 2>&1 \
            || fail "This machine has a public IPv6 address and the lasting IPv6 rule for port ${DASHBOARD_PORT} could not be read back."
    fi
    GUARD_PERSISTENT=1
    release_dashboard_guard
    success "Port ${DASHBOARD_PORT} stays closed to other computers, also after a reboot (${GUARD_UNIT}.service, checked)"
}

choose_heap() {
    step "Choosing heap size..."
    if [[ -z "$HEAP_GB" ]]; then
        HEAP_GB=$(detect_heap_gb)
        local total_kb total_gb
        total_kb=$(grep -E '^MemTotal:' /proc/meminfo 2>/dev/null | awk '{print $2}')
        total_gb=$(( (total_kb + 524288) / 1048576 ))
        info "System has ${total_gb}G RAM total → using ${HEAP_GB}G heap"
        info "(Override at install time:   HEAP_GB=4 curl -fsSL ... | sudo bash)"
        info "(Change later from dashboard: Server Settings → Save RAM)"
    else
        info "Using HEAP_GB=${HEAP_GB} from environment"
    fi
    success "Heap: ${HEAP_GB}G"
}

install_java() {
    step "Installing Java 21+ and the tools this installer needs..."
    info "Updating apt..."
    DEBIAN_FRONTEND=noninteractive apt-get update -qq >>"$LOG" 2>&1 \
        || fail "apt-get update failed — see $LOG"
    info "Installing curl, jq, python3, sudo, ufw, unattended-upgrades..."
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
        curl ca-certificates jq python3 sudo ufw unattended-upgrades >>"$LOG" 2>&1 \
        || fail "Could not install the basic tools — see $LOG"
    if command -v java >/dev/null; then
        local java_major
        java_major=$(java -version 2>&1 | head -1 | grep -oE '"[0-9]+' | tr -d '"')
        if [[ -n "$java_major" && "$java_major" -ge 21 ]]; then
            success "Java ${java_major} already present: $(java -version 2>&1 | head -1)"
            return
        fi
    fi
    local candidate
    candidate=$(apt-cache policy openjdk-21-jdk-headless 2>/dev/null | awk '/Candidate:/ {print $2}')
    if [[ -z "$candidate" || "$candidate" == "(none)" ]]; then
        fail "This system has no Java 21 package (openjdk-21-jdk-headless). Use Ubuntu 22.04 or 24.04, or Debian 13. Debian 12 has no Java 21 package."
    fi
    info "Installing openjdk-21-jdk-headless ${candidate}..."
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openjdk-21-jdk-headless >>"$LOG" 2>&1 \
        || fail "Java 21 install failed — see $LOG"
    success "Installed: $(java -version 2>&1 | head -1)"
}

create_user_and_dirs() {
    step "Creating user '$NOVA_USER' and directory '$INSTALL_DIR'..."
    if id "$NOVA_USER" >/dev/null 2>&1; then
        success "User '$NOVA_USER' already exists"
    else
        useradd --system --create-home --home-dir "$INSTALL_DIR" --shell /bin/bash "$NOVA_USER"
        success "Created user '$NOVA_USER' (system)"
    fi
    # Root touches only the top directory itself. Everything below it belongs to the service
    # account and is created and written as that account (see as_nova), so a link the account
    # might plant there can never steer a root write. Links in the places we use are refused.
    mkdir -p "$INSTALL_DIR"
    chown "$NOVA_USER:$NOVA_USER" "$INSTALL_DIR"
    local d
    for d in "$INSTALL_DIR/server" "$INSTALL_DIR/server/plugins"; do
        [[ -L "$d" ]] && fail "$d is a symbolic link. Refusing to install through it: remove it and run the installer again."
    done
    as_nova mkdir -p "$INSTALL_DIR/server/plugins" \
        || fail "Could not create $INSTALL_DIR/server/plugins as '$NOVA_USER' — is something in the way?"
    for d in "$INSTALL_DIR/server" "$INSTALL_DIR/server/plugins"; do
        if [[ -L "$d" || ! -d "$d" ]]; then
            fail "$d is a symbolic link (or not a directory). Refusing to install through it — remove it and run the installer again."
        fi
        [[ "$(stat -c %U "$d")" == "$NOVA_USER" ]] \
            || fail "$d is not owned by '$NOVA_USER' — fix that (chown $NOVA_USER:$NOVA_USER $d) and run the installer again."
    done
    success "Directory ready: $INSTALL_DIR/server"
}

download_paper() {
    step "Downloading Paper $PAPER_VERSION..."
    local server_dir="$INSTALL_DIR/server"
    local build_url="$PAPER_API/versions/$PAPER_VERSION/builds/latest"

    info "Asking PaperMC for the latest build..."
    local meta name url sha
    meta=$(curl -fsSL --max-time 30 "$build_url" 2>>"$LOG") \
        || fail "PaperMC API unreachable ($build_url)"
    name=$(printf '%s' "$meta" | jq -r '.downloads["server:default"].name // empty' 2>>"$LOG") || name=""
    url=$(printf '%s' "$meta"  | jq -r '.downloads["server:default"].url // empty' 2>>"$LOG") || url=""
    sha=$(printf '%s' "$meta"  | jq -r '.downloads["server:default"].checksums.sha256 // empty' 2>>"$LOG") || sha=""
    [[ "$name" =~ ^paper-[0-9A-Za-z._-]+\.jar$ && "$sha" =~ ^[0-9a-f]{64}$ && -n "$url" ]] \
        || fail "PaperMC returned no usable download for $PAPER_VERSION (name, URL and SHA-256 are all required)"
    case "$url" in
        https://fill-data.papermc.io/*|https://api.papermc.io/*|https://fill.papermc.io/*) ;;
        *) fail "PaperMC pointed at an unexpected download host: $url" ;;
    esac
    info "Latest build: $name"

    local jar_path="$server_dir/$name"
    if [[ -f "$jar_path" ]] && [[ "$(sha256sum "$jar_path" | awk '{print $1}')" == "$sha" ]]; then
        success "$name already downloaded and its SHA-256 matches"
    else
        info "Downloading $name..."
        # Download as root into a private temp file, check the SHA-256 PaperMC published, and only
        # then put it in place. A failed or tampered download never replaces the working jar.
        local tmp_jar
        tmp_jar="$(mktemp)" || fail "mktemp failed"
        register_tmp "$tmp_jar"
        curl -fsSL --max-time 600 "$url" -o "$tmp_jar" 2>>"$LOG" \
            || { rm -f "$tmp_jar"; fail "Paper download failed"; }
        [[ "$(sha256sum "$tmp_jar" | awk '{print $1}')" == "$sha" ]] \
            || { rm -f "$tmp_jar"; fail "Paper download does not match the SHA-256 PaperMC published — refusing to use it"; }
        chgrp "$NOVA_USER" "$tmp_jar" && chmod 0644 "$tmp_jar"
        put_as_nova "$tmp_jar" 0644 "$jar_path" || fail "could not place $name"
        rm -f "$tmp_jar"
        # Purge older paper jars now that the new one is committed.
        as_nova find "$server_dir" -maxdepth 1 -name 'paper-*.jar' ! -name "$name" -delete 2>/dev/null || true
        success "Saved $name (SHA-256 verified)"
    fi

    as_nova ln -sfn "$name" "$server_dir/server.jar" || fail "could not link server.jar"
}

# 2.16.282 — Geyser's "new update available" chat banner that fires on player
# join is suppressed via NovaSyncPlugin.onEnable() applyGeyserConfigPatches().
# It idempotently sets `notify-on-server-startup: false` in
# plugins/Geyser-Spigot/config.yml on every server restart. We don't patch it
# here in install.sh because Geyser isn't installed yet at this point —
# operators install it via the dashboard's "Install Recommended Plugins" flow
# after first boot. NovaSync auto-patches on the next restart after Geyser
# arrives. No installer-side action needed.

accept_eula() {
    step "Accepting Mojang EULA..."
    local eula="$INSTALL_DIR/server/eula.txt"
    write_owned "$eula" 0644 <<EOF
# Mojang EULA accepted by the NovaSync installer on $(date -u +%Y-%m-%dT%H:%M:%SZ)
# Full text: https://aka.ms/MinecraftEULA
eula=true
EOF
    success "EULA accepted (see $eula)"
}

download_novasync() {
    step "Downloading NovaSync plugin..."
    local plugin_dir="$INSTALL_DIR/server/plugins"
    local cb version
    cb=$(date +%s)
    # Cache-bust VERSION.txt — Cloudflare's edge cache otherwise lags new ships
    # by up to its TTL (originally observed: laptop fetched VERSION=2.15.424
    # but got the body of 2.15.419 from a stale latest.jar cache entry on
    # 2026-05-01 Round 2). Version itself is small so the cache-bust is cheap.
    version=$(curl -fsSL "$SYNC_BASE/VERSION.txt?cb=$cb" 2>>"$LOG" | tr -d '[:space:]') \
        || fail "Could not fetch NovaSync VERSION.txt"
    [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Invalid VERSION.txt content: '$version'"
    info "Latest NovaSync version: $version"

    local jar="NovaSyncPlugin-${version}.jar"
    local jar_path="$plugin_dir/$jar"

    if [[ -f "$jar_path" ]]; then
        success "NovaSync $version already installed"
    else
        info "Downloading $jar..."
        # 2.17.154 W7-H7 — Atomic JAR replace (see download_paper for rationale).
        # Was: delete-all-then-download, which on a curl failure left the
        # plugins directory empty and the server failed to boot.
        # Fetch the VERSIONED URL directly — different cache key per version,
        # so we never get stale bytes from a symlink update lag. The ?cb param
        # is belt-and-suspenders for fresh deploys where Cloudflare hasn't
        # cached the version yet but might have a 404 cached for the URL.
        local tmp_jar
        tmp_jar="$(mktemp)" || fail "mktemp failed"
        register_tmp "$tmp_jar"
        curl -fsSL --max-time 600 "$SYNC_BASE/${jar}?cb=$cb" -o "$tmp_jar" 2>>"$LOG" \
            || { rm -f "$tmp_jar" 2>/dev/null; fail "NovaSync download failed"; }
        # Verify the jar was actually downloaded (not a 404 page) BEFORE
        # committing to the final path.
        local size
        size=$(wc -c < "$tmp_jar")
        [[ $size -gt 100000 ]] || { rm -f "$tmp_jar"; fail "Downloaded jar too small ($size bytes) — likely a 404. Check $SYNC_BASE/$jar"; }
        chgrp "$NOVA_USER" "$tmp_jar" && chmod 0644 "$tmp_jar"
        put_as_nova "$tmp_jar" 0644 "$jar_path" || fail "could not place $jar"
        rm -f "$tmp_jar"
        # Purge older NovaSync jars now that the new one is committed.
        as_nova find "$plugin_dir" -maxdepth 1 -name 'NovaSyncPlugin-*.jar' ! -name "$jar" -delete 2>/dev/null || true
        success "Saved $jar (${size} bytes)"
    fi
}

configure_server_properties() {
    step "Configuring server.properties..."
    local props="$INSTALL_DIR/server/server.properties"
    local server_dir="$INSTALL_DIR/server"

    if [[ ! -f "$props" ]]; then
        info "First boot to generate configs (10–30s)..."
        # 2.17.155 W7-F14 — Bootstrap Paper boot is config-gen only (no players,
        # no world load past spawn-chunk seed). Capped at 512M so it never
        # competes with the installer + curl + apt for RAM on a ≤2 GB host.
        # The real Paper boot uses HEAP_GB via jvm.env once the installer
        # finishes.
        (
            cd "$server_dir"
            runuser -u "$NOVA_USER" -- timeout 90 java -Xms256M -Xmx512M -jar server.jar nogui >>"$LOG" 2>&1 || true
        )
    fi

    if [[ -f "$props" ]]; then
        # Granny-friendly defaults (only set if missing or different)
        as_nova sed -i \
            -e "s|^motd=.*|motd=A NovaSync server|" \
            -e "s|^server-port=.*|server-port=$MC_PORT|" \
            -e "s|^enable-status=.*|enable-status=true|" \
            "$props"
        success "server.properties configured"
    else
        warn "server.properties not generated — check $LOG"
    fi
}

write_jvm_env() {
    # NovaSyncPlugin's ServerControlManager reads/writes this exact format
    # (XMS=, XMX=, JVM_FLAGS=). Writing this file pre-emptively lets the
    # dashboard "Save RAM" button work immediately on first boot, and means
    # start.sh sources the right values from boot one.
    step "Writing jvm.env (heap settings)..."
    local mb=$(( HEAP_GB * 1024 ))
    local env_file="$INSTALL_DIR/server/jvm.env"
    # 2.17.155 W7-F15 — Pre-create the file with mode 600 + correct ownership
    # BEFORE the heredoc writes anything. The previous pattern (`cat > file`,
    # then chown afterwards) left a transient window where the file existed
    # with default umask 0644 (world-readable) plus root ownership. Pre-create
    # idiom uses `install` to set perms+owner atomically; subsequent `>`
    # truncate preserves the existing inode's mode.
    write_owned "$env_file" 0600 <<EOF
# NovaSyncPlugin-managed JVM heap settings.
# Sourced by start.sh on each restart.
# Edit via the dashboard (Server Settings → Save RAM) — direct edits will be
# overwritten when the dashboard updates them.
XMS=${mb}M
XMX=${mb}M
JVM_FLAGS="--add-modules=jdk.incubator.vector -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+AlwaysPreTouch"
EOF
    success "jvm.env written (XMS=XMX=${mb}M, mode 0600)"
}

ensure_start_script() {
    # NovaSyncPlugin auto-generates start.sh on its first onEnable() — but we
    # need it to exist BEFORE the systemd unit fires for the very first time
    # (otherwise systemctl start fails because ExecStart points at a missing
    # file). Write a minimal stub here; the plugin auto-upgrades it to the
    # full v5+ template (pile-up guard, JAR detection, exit-code translation
    # for systemd) on first boot.
    step "Writing start.sh (supervisor wrapper)..."
    local start_sh="$INSTALL_DIR/server/start.sh"
    write_owned "$start_sh" 0755 <<'STUB_EOF'
#!/bin/bash
# Minimal start.sh stub — NovaSync plugin auto-upgrades to v5+ on first boot.
# DO NOT edit; will be overwritten.
cd "$(dirname "$0")"
[ -f jvm.env ] && . ./jvm.env
: "${XMS:=1024M}"
: "${XMX:=2048M}"
: "${JVM_FLAGS:=--add-modules=jdk.incubator.vector -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+AlwaysPreTouch}"
JAR=""
if   [ -f paperclip.jar ]; then JAR=paperclip.jar
elif [ -f paper.jar ];     then JAR=paper.jar
else JAR=$(ls -1t paper-*.jar 2>/dev/null | grep -v backup | head -1); fi
if [ -z "$JAR" ]; then echo "FATAL: no Paper JAR" >&2; exit 1; fi
touch .nova-supervised
trap 'rm -f .nova-supervised' EXIT INT TERM
java -Xms$XMS -Xmx$XMX $JVM_FLAGS -jar "$JAR" nogui
code=$?
if [ -f .nova-stop ]; then rm -f .nova-stop; exit 0; fi
[ "$code" -eq 0 ] && exit 1   # signal restart to systemd
exit $code
STUB_EOF
    success "start.sh stub written (plugin will upgrade on first boot)"
}

create_systemd_service() {
    step "Creating systemd service '$SERVICE_NAME'..."
    local unit="/etc/systemd/system/${SERVICE_NAME}.service"

    # ExecStart calls start.sh — NOT java directly. This is REQUIRED for the
    # NovaSync dashboard's "Save RAM" feature to work: ServerControlManager
    # detects systemd-direct-java-exec as a broken state, but with start.sh
    # in front (creating .nova-supervised marker on launch), the plugin sees
    # itself as properly supervised and the RAM button writes jvm.env safely.
    # The OS-updates helper (run through sudo from inside this service) writes a timer override and
    # a needrestart override. With ProtectSystem=strict those paths are read-only inside the
    # service, so they are created now and listed in ReadWritePaths below.
    mkdir -p /etc/systemd/system/apt-daily-upgrade.timer.d /etc/needrestart/conf.d
    # the OS-updates helper keeps its run-now lock here (root only); it must be writable from inside the service sandbox
    install -d -m 0700 -o root -g root /var/lib/nova-os-updates
    cat > "$unit" <<EOF
[Unit]
Description=NovaSync Minecraft server
Documentation=https://seidrlabs.online/novasync/
Wants=network-online.target
After=network-online.target ${GUARD_UNIT}.service

[Service]
Type=simple
User=$NOVA_USER
Group=$NOVA_USER
WorkingDirectory=$INSTALL_DIR/server
ExecStart=$INSTALL_DIR/server/start.sh
Restart=on-failure
RestartSec=10
SuccessExitStatus=143
TimeoutStopSec=120

# Hardening — 2.16.360: NoNewPrivileges=no (was yes). The plugin invokes
# sudo -n /usr/local/bin/nova-os-updates.sh for the Owner→OS Updates panel,
# and NoNewPrivileges blocks ALL setuid (including sudo) regardless of
# sudoers. The narrowly-scoped sudoers entry (single binary, NOPASSWD,
# inputs validated by the helper itself) is the trust boundary instead.
# Other hardening lines below still apply.
NoNewPrivileges=no
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=$INSTALL_DIR /etc/systemd/system/apt-daily-upgrade.timer.d /etc/needrestart/conf.d /var/lib/nova-os-updates
PrivateTmp=yes
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

    systemctl daemon-reload
    systemctl enable "$SERVICE_NAME" >>"$LOG" 2>&1
    DASH_LIVE=1   # enabled from here: a reboot would start it, so the login must be proven safe before the installer ends well
    success "Service installed and enabled (ExecStart -> start.sh)"
}

configure_firewall() {
    step "Configuring firewall (ufw)..."
    if ! command -v ufw >/dev/null; then
        FIREWALL_STATE="none"
        warn "ufw not installed — skipping. Game ports to open yourself if you use another firewall:"
        info "  ${MC_PORT}/tcp+udp (Java MC), ${BEDROCK_PORT}/udp (Bedrock), ${VOICECHAT_PORT}/udp (voicechat)"
        return
    fi

    ufw allow "${MC_PORT}/tcp"        comment 'NovaSync: Minecraft Java'   >>"$LOG" 2>&1 || true
    ufw allow "${MC_PORT}/udp"        comment 'NovaSync: Minecraft UDP'    >>"$LOG" 2>&1 || true
    ufw allow "${BEDROCK_PORT}/udp"   comment 'NovaSync: Geyser Bedrock'   >>"$LOG" 2>&1 || true
    ufw allow "${VOICECHAT_PORT}/udp" comment 'NovaSync: Voicechat'        >>"$LOG" 2>&1 || true
    if [[ -n "$NOVA_OPEN_DASHBOARD" ]]; then
        ufw allow "${DASHBOARD_PORT}/tcp" comment 'NovaSync: Dashboard'    >>"$LOG" 2>&1 || true
        warn "NOVA_OPEN_DASHBOARD is set: port ${DASHBOARD_PORT} is open (plain http — the password is not encrypted on the way)."
    else
        # Older versions of this installer allowed the dashboard port. Unless exposure was asked for, that rule goes
        # (ufw removes both address families with one delete), and the result is read back before anything says "closed".
        local n=0
        while [[ $n -lt 6 ]] && ufw show added 2>/dev/null | grep -qE "(^| )${DASHBOARD_PORT}(/tcp|/udp)?( |$)"; do
            ufw --force delete allow "${DASHBOARD_PORT}/tcp" >>"$LOG" 2>&1 \
                || ufw --force delete allow "${DASHBOARD_PORT}" >>"$LOG" 2>&1 || break
            n=$((n + 1))
        done
        if ufw show added 2>/dev/null | grep -qE "(^| )${DASHBOARD_PORT}(/tcp|/udp)?( |$)"; then
            warn "A ufw rule for port ${DASHBOARD_PORT} from an earlier install could not be removed. Remove it yourself: ufw delete allow ${DASHBOARD_PORT}/tcp"
        else
            DASH_FW_CLOSED=1
            [[ $n -gt 0 ]] && info "Removed ${n} ufw rule(s) that an earlier install had added for port ${DASHBOARD_PORT}."
        fi
    fi

    local ufw_first
    ufw_first="$(ufw status 2>/dev/null | head -1 || true)"
    if grep -q inactive <<<"$ufw_first"; then
        FIREWALL_STATE="inactive"
        info "ufw rules added but ufw itself is inactive (it filters nothing until you enable it)."
    else
        FIREWALL_STATE="active"
    fi
    success "Firewall rules added"
}

# 2.16.358 — Install nova-os-updates helper + sudoers + sane defaults so the
# Owner → 🛡 OS Updates dashboard can manage Ubuntu/Debian unattended-upgrades
# without an SSH session. The helper script + sudoers file ship as standalone
# assets at $INSTALLER_ASSETS_BASE so we don't need a repo clone here.
# Idempotent — re-running install.sh just overwrites with the latest helper.
setup_os_updates() {
    step "Setting up OS-updates dashboard helper..."

    local helper_url="$INSTALLER_ASSETS_BASE/scripts/nova-os-updates.sh"
    local sudoers_url="$INSTALLER_ASSETS_BASE/sudoers/nova-os-updates"
    local helper_dst="/usr/local/bin/nova-os-updates.sh"
    local sudoers_dst="/etc/sudoers.d/nova-os-updates"

    local helper_tmp sudoers_tmp
    helper_tmp="$(mktemp)" || { warn "mktemp failed — skipping OS-updates helper"; return 0; }
    register_tmp "$helper_tmp"
    sudoers_tmp="$(mktemp)" || { rm -f "$helper_tmp"; warn "mktemp failed — skipping OS-updates helper"; return 0; }
    register_tmp "$sudoers_tmp"

    if ! curl -fsSL "$helper_url" -o "$helper_tmp" 2>>"$LOG"; then
        warn "Could not fetch nova-os-updates.sh from $helper_url — skipping"
        rm -f "$helper_tmp" "$sudoers_tmp"
        return 0
    fi
    if ! curl -fsSL "$sudoers_url" -o "$sudoers_tmp" 2>>"$LOG"; then
        warn "Could not fetch sudoers fragment from $sudoers_url — skipping"
        rm -f "$helper_tmp" "$sudoers_tmp"
        return 0
    fi

    # The fragment is written for the configured service account only (the downloaded file is a template with __NOVA_USER__);
    # no other account name receives the grant.
    if ! grep -q '__NOVA_USER__' "$sudoers_tmp"; then
        warn "The sudoers template has no __NOVA_USER__ placeholder — skipping OS-updates helper install"
        rm -f "$helper_tmp" "$sudoers_tmp"
        return 0
    fi
    sed -i "s/__NOVA_USER__/${NOVA_USER}/g" "$sudoers_tmp"
    # Validate sudoers BEFORE placement — a bad sudoers file bricks sudo.
    if ! visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then
        warn "Sudoers validation failed — skipping OS-updates helper install"
        rm -f "$helper_tmp" "$sudoers_tmp"
        return 0
    fi

    install -m 0755 -o root -g root "$helper_tmp"  "$helper_dst"
    install -m 0440 -o root -g root "$sudoers_tmp" "$sudoers_dst"
    rm -f "$helper_tmp" "$sudoers_tmp"

    # Automatic upgrades must really be on: the unattended-upgrades package is installed (install_java) and switched on
    # explicitly here, then the result is read back. Anything that did not work is reported, never counted as success.
    local os_ok=1
    command -v unattended-upgrade >/dev/null 2>&1 || { warn "unattended-upgrade is not installed"; os_ok=""; }
    printf 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\n' > /etc/apt/apt.conf.d/52nova-auto-upgrades \
        || os_ok=""
    # (output captured first: with pipefail, grep -q closing the pipe early makes the left side fail with SIGPIPE)
    local apt_cfg os_status
    apt_cfg="$(apt-config dump 2>/dev/null || true)"
    grep -q 'APT::Periodic::Unattended-Upgrade "1"' <<<"$apt_cfg" || { warn "automatic upgrades are not switched on in apt"; os_ok=""; }
    # Sane defaults: enabled, daily 04:00 server-local, defer Paper restart ON.
    # the helper's Paper restart deferral also has to cover a service name chosen with SERVICE_NAME
    local sn_tmp; sn_tmp="$(mktemp)"; register_tmp "$sn_tmp"
    printf '%s\n' "$SERVICE_NAME" > "$sn_tmp"
    install -m 0644 -o root -g root "$sn_tmp" /etc/nova-os-updates.service-name >>"$LOG" 2>&1 \
        || { warn "could not record the service name for the OS-updates helper"; os_ok=""; }
    "$helper_dst" enable         >>"$LOG" 2>&1 || { warn "could not enable the apt upgrade timer"; os_ok=""; }
    "$helper_dst" set-time 04:00 >>"$LOG" 2>&1 || { warn "could not set the upgrade time"; os_ok=""; }
    "$helper_dst" defer-paper on >>"$LOG" 2>&1 || { warn "could not set the Paper restart deferral"; os_ok=""; }
    os_status="$("$helper_dst" status 2>>"$LOG" || true)"
    grep -q '"enabled":true' <<<"$os_status" || { warn "the apt upgrade timer is not enabled"; os_ok=""; }
    if [[ -n "$os_ok" ]]; then
        success "OS updates: unattended-upgrades installed and on, daily 04:00, Paper restart deferred (checked)"
        info "Manage from the dashboard at Owner → 🛡 OS Updates."
    else
        warn "OS updates are NOT fully set up (see above and $LOG). Automatic security upgrades may not run on this server."
    fi
}

detect_existing_nginx_config() {
    # Returns one of:
    #   "ours"   — our managed config exists and carries our signature → safe to update
    #   "custom" — some other config already proxies to dashboard (e.g. SA's HTTPS
    #              vhost that pre-dated this installer) → leave it alone
    #   "none"   — no nginx config touches the dashboard yet → fresh install
    local ours="/etc/nginx/sites-enabled/nova-dashboard"
    if [[ -f "$ours" ]] && grep -q '# NOVA_INSTALLER_SIGNATURE:' "$ours" 2>/dev/null; then
        echo "ours"; return
    fi
    if [[ -d /etc/nginx ]] && \
       grep -rq "proxy_pass[[:space:]]\+http://127\.0\.0\.1:${DASHBOARD_PORT}\b" /etc/nginx/ 2>/dev/null; then
        echo "custom"; return
    fi
    echo "none"
}

NGINX_VHOST="${NGINX_VHOST:-/etc/nginx/sites-available/nova-dashboard}"
NGINX_ENABLED="${NGINX_ENABLED:-/etc/nginx/sites-enabled/nova-dashboard}"
LE_LIVE_DIR="${LE_LIVE_DIR:-/etc/letsencrypt/live}"

# Stage 1 vhost: answers only the Let's Encrypt challenge on the plain-http port. Nothing else is
# served and NOTHING is proxied, so the dashboard is not reachable through nginx until a certificate exists.
write_vhost_acme_only() {
    cat > "$NGINX_VHOST" <<EOF
# nova-dashboard — managed by the NovaSync installer. Do not edit by hand.
# NOVA_INSTALLER_SIGNATURE: v2 stage=acme-only
#
# ${NOVA_DOMAIN}: only the Let's Encrypt challenge is answered here. The dashboard is not
# proxied until a certificate has been issued (the installer then replaces this file).
server {
    listen ${HTTP_PORT};
    listen [::]:${HTTP_PORT};
    server_name ${NOVA_DOMAIN};

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 404;
    }
}
EOF
}

# Stage 2 vhost, written only after the certificate files exist: plain http redirects to https
# (except the challenge), and the dashboard is proxied on the https port only.
write_vhost_https() {
    local port_suffix=""
    [[ "$HTTPS_PORT" == "443" ]] || port_suffix=":${HTTPS_PORT}"
    cat > "$NGINX_VHOST" <<EOF
# nova-dashboard — managed by the NovaSync installer. Do not edit by hand.
# NOVA_INSTALLER_SIGNATURE: v2 stage=https
#
# https://${NOVA_DOMAIN}/dashboard → http://127.0.0.1:${DASHBOARD_PORT}. Certificate: Let's Encrypt,
# renewed by certbot's timer (webroot method, nginx reloaded after each renewal).
server {
    listen ${HTTP_PORT};
    listen [::]:${HTTP_PORT};
    server_name ${NOVA_DOMAIN};

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://\$host${port_suffix}\$request_uri;
    }
}

server {
    listen ${HTTPS_PORT} ssl;
    listen [::]:${HTTPS_PORT} ssl;
    server_name ${NOVA_DOMAIN};

    ssl_certificate     ${LE_LIVE_DIR}/${NOVA_DOMAIN}/fullchain.pem;
    ssl_certificate_key ${LE_LIVE_DIR}/${NOVA_DOMAIN}/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:novasync:10m;

    # Bigger upload ceiling for icon uploads / pack imports
    client_max_body_size 32M;

    # Long-lived connections for SSE / dashboard live updates
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;

    location / {
        proxy_pass http://127.0.0.1:${DASHBOARD_PORT};
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_buffering off;
    }
}
EOF
}

# Test the config and reload nginx (start it if it is not running). Returns non-zero on any failure.
nginx_apply() {
    nginx -t >>"$LOG" 2>&1 || return 1
    systemctl enable nginx >>"$LOG" 2>&1 || true
    if systemctl is-active --quiet nginx; then
        systemctl reload nginx >>"$LOG" 2>&1 || return 1
    else
        systemctl start nginx >>"$LOG" 2>&1 || return 1
    fi
}

# Back to the safe stage: the challenge-only vhost, no proxy. Used by every failure path.
nginx_back_to_acme_only() {
    write_vhost_acme_only
    nginx_apply || { warn "nginx could not be reloaded — see $LOG. No proxy to the dashboard is configured."; return 1; }
}

setup_nginx_proxy() {
    # Only called when the owner gave a domain (HTTPS was asked for). Stage 1: nginx answers the
    # Let's Encrypt challenge for THAT domain and nothing else. The dashboard is proxied only after
    # setup_https_optional has a certificate (stage 2). There is no plain-http proxy at any point.
    # Idempotent: re-running keeps a working https vhost of ours for the same domain, and never
    # rewrites a vhost certbot has turned into an HTTPS one (that would drop its certificate lines).
    step "Setting up nginx for ${NOVA_DOMAIN} (certificate challenge on port ${HTTP_PORT} first)..."

    local existing
    existing=$(detect_existing_nginx_config)

    if [[ "$existing" == "custom" ]]; then
        info "Existing nginx vhost already proxies to :${DASHBOARD_PORT} — leaving it alone."
        info "(Looks like a manually-configured HTTPS or custom setup. To let this"
        info " installer manage nginx, remove your config from /etc/nginx/sites-enabled/"
        info " and re-run. Your dashboard remains reachable via your existing setup.)"
        return 1
    fi

    if [[ "$existing" == "ours" ]] && grep -q 'managed by Certbot' "$NGINX_VHOST" 2>/dev/null; then
        info "The HTTPS vhost certbot set up earlier is left exactly as it is."
        return 1
    fi
    if [[ "$existing" == "ours" ]] && grep -q 'stage=https' "$NGINX_VHOST" 2>/dev/null \
       && grep -q "server_name ${NOVA_DOMAIN};" "$NGINX_VHOST" 2>/dev/null \
       && [[ -s "${LE_LIVE_DIR}/${NOVA_DOMAIN}/fullchain.pem" ]]; then
        info "The HTTPS vhost this installer made for ${NOVA_DOMAIN} is in place with its certificate — left as it is."
        HTTPS_ALREADY_LIVE=1
        return 0
    fi

    # Install nginx if missing
    if ! command -v nginx >/dev/null; then
        info "Installing nginx..."
        DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx >>"$LOG" 2>&1 \
            || { warn "nginx install failed — see $LOG. Skipping HTTPS."; return 1; }
    fi

    write_vhost_acme_only
    ln -sf "$NGINX_VHOST" "$NGINX_ENABLED"

    if ! nginx -t >>"$LOG" 2>&1; then
        warn "nginx -t failed — see $LOG. Nothing was enabled."
        rm -f "$NGINX_ENABLED"
        return 1
    fi

    mkdir -p /var/www/html/.well-known/acme-challenge

    # Let's Encrypt must reach port 80, and the finished site is served on 443.
    if command -v ufw >/dev/null; then
        ufw allow "${HTTP_PORT}/tcp"  comment 'NovaSync: HTTP (Lets Encrypt + redirect)' >>"$LOG" 2>&1 || true
        ufw allow "${HTTPS_PORT}/tcp" comment 'NovaSync: HTTPS dashboard'               >>"$LOG" 2>&1 || true
    fi

    nginx_apply || { warn "nginx could not be started or reloaded — see $LOG"; return 1; }
    success "nginx is answering the certificate challenge for ${NOVA_DOMAIN} on port ${HTTP_PORT} (dashboard not proxied yet)"
}

collect_https_choice() {
    # Optional HTTPS needs a domain name and an email. They come from NOVA_DOMAIN + NOVA_EMAIL, or
    # from a prompt when a terminal is available (`curl ... | sudo bash` makes stdin a pipe, so the
    # prompt reads /dev/tty). NOVA_SKIP_HTTPS=1, no terminal, or answering no: HTTPS is skipped
    # and no web server is installed.
    if [[ -n "$NOVA_SKIP_HTTPS" ]]; then
        info "NOVA_SKIP_HTTPS set — skipping HTTPS setup."
        NOVA_DOMAIN=""; return
    fi
    if [[ -z "$NOVA_DOMAIN" ]]; then
        if [[ -e /dev/tty ]] && [[ -r /dev/tty ]] && ( : < /dev/tty ) 2>/dev/null; then
            local response=""
            echo ""
            echo "  Add HTTPS for the dashboard now? You'll need a domain name (A-record) pointing at this server."
            echo -n "  Set up HTTPS via Let's Encrypt? (y/N) "
            read -r response < /dev/tty 2>/dev/null || response=""
            case "${response,,}" in
                y|yes)
                    echo -n "  Domain (e.g. play.example.com): "
                    read -r NOVA_DOMAIN < /dev/tty 2>/dev/null || NOVA_DOMAIN=""
                    echo -n "  Email for cert renewal notices: "
                    read -r NOVA_EMAIL < /dev/tty 2>/dev/null || NOVA_EMAIL=""
                    ;;
                *)
                    info "No HTTPS. Reach the dashboard through an SSH tunnel (shown at the end)."
                    info "(Enable later: NOVA_DOMAIN=foo.example.com NOVA_EMAIL=you@example.com curl … | sudo bash)"
                    return
                    ;;
            esac
        else
            info "No domain given (NOVA_DOMAIN unset, no terminal to ask) — no HTTPS and no web server installed."
            info "(Enable later: NOVA_DOMAIN=foo.example.com NOVA_EMAIL=you@example.com curl … | sudo bash)"
            return
        fi
    fi
    NOVA_DOMAIN=$(echo "$NOVA_DOMAIN" | tr -d '[:space:]')
    NOVA_EMAIL=$(echo "$NOVA_EMAIL" | tr -d '[:space:]')
    if [[ -z "$NOVA_DOMAIN" || -z "$NOVA_EMAIL" ]]; then
        warn "Domain or email empty — skipping HTTPS."
        info "Re-run with NOVA_DOMAIN and NOVA_EMAIL set to enable HTTPS."
        NOVA_DOMAIN=""
        return
    fi
    if [[ ! "$NOVA_DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]]; then
        warn "'$NOVA_DOMAIN' does not look like a domain name — skipping HTTPS."
        NOVA_DOMAIN=""
    fi
}

setup_https_optional() {
    # Let's Encrypt certificate through certbot's webroot method, then the https vhost. Runs only
    # after setup_nginx_proxy made the challenge-only vhost live. Any failure leaves (or puts back)
    # the challenge-only vhost: the dashboard is never proxied without a working certificate.
    local domain="$NOVA_DOMAIN" email="$NOVA_EMAIL"
    if [[ -n "${HTTPS_ALREADY_LIVE:-}" ]]; then
        HTTPS_DOMAIN_LIVE="$domain"
        info "HTTPS for ${domain} was already set up — dashboard at https://${domain}/dashboard"
        return
    fi
    if [[ ! -f "$NGINX_ENABLED" ]] || ! grep -q '# NOVA_INSTALLER_SIGNATURE:' "$NGINX_ENABLED" 2>/dev/null; then
        info "nginx is not set up by this installer — skipping HTTPS setup."
        return
    fi

    step "HTTPS via Let's Encrypt for ${domain}..."

    # Soft DNS check: does the domain resolve to this server's public IP?
    info "Verifying DNS for $domain..."
    local public_ip resolved_ip
    public_ip=$(curl -fsSL --max-time 5 https://api.ipify.org 2>/dev/null \
             || curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null \
             || echo "")
    # getent exits 2 for a name that does not resolve; with set -e and pipefail that would end the whole installer here
    resolved_ip=$(getent hosts "$domain" 2>/dev/null | awk '{print $1; exit}') || resolved_ip=""
    if [[ -n "$public_ip" && -n "$resolved_ip" && "$public_ip" != "$resolved_ip" ]]; then
        warn "DNS mismatch: $domain → $resolved_ip, this server is $public_ip"
        warn "Let's Encrypt will probably fail. Continuing anyway — fix DNS if cert request fails."
    elif [[ -n "$resolved_ip" ]]; then
        success "DNS OK: $domain → $resolved_ip"
    else
        warn "Could not resolve $domain — make sure DNS is set up first."
    fi

    info "Installing certbot..."
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq certbot >>"$LOG" 2>&1 \
        || { warn "certbot install failed — see $LOG. The dashboard is NOT reachable through ${domain}."; return; }

    info "Requesting Let's Encrypt certificate for $domain..."
    if certbot certonly --webroot -w /var/www/html -d "$domain" \
        --non-interactive --agree-tos -m "$email" --no-eff-email \
        --deploy-hook "systemctl reload nginx" >>"$LOG" 2>&1 \
       && [[ -s "${LE_LIVE_DIR}/${domain}/fullchain.pem" && -s "${LE_LIVE_DIR}/${domain}/privkey.pem" ]]; then
        write_vhost_https
        if nginx_apply; then
            HTTPS_DOMAIN_LIVE="$domain"
            success "HTTPS enabled — dashboard at https://${domain}/dashboard"
            info "Cert auto-renews via certbot's systemd timer (run 'systemctl list-timers certbot' to confirm)"
        else
            warn "nginx would not accept the HTTPS vhost — see $LOG. Putting the challenge-only vhost back: the dashboard is NOT reachable through ${domain}."
            nginx_back_to_acme_only || true
        fi
    else
        warn "certbot failed — the dashboard is not reachable through ${domain} (nothing is proxied without a certificate)."
        info "Common causes: DNS not pointed here yet, port ${HTTP_PORT} blocked from internet, LE rate limit."
        info "See $LOG for details. Retry later by running the installer again with NOVA_DOMAIN and NOVA_EMAIL set."
    fi
}

start_server() {
    step "Starting NovaSync..."
    # 2.17.154 W7-H8 — Graceful restart path. Previously: unconditional
    # `systemctl restart`. On a re-run install against a live server with
    # active players, that fires SIGTERM and Paper exits within seconds —
    # players see "Internal Server Error" instead of a clean disconnect
    # message. Now: detect active service, send `systemctl stop` (SIGTERM,
    # which Paper handles gracefully — broadcasts "Server closed" and saves
    # worlds), wait up to 60s for clean exit, then start. New installs (no
    # active service) skip the stop step entirely so first-run timing is
    # unchanged.
    if systemctl is-active --quiet "$SERVICE_NAME"; then
        info "Server is running — requesting graceful shutdown (up to 60s)..."
        systemctl stop "$SERVICE_NAME" 2>>"$LOG" || warn "stop returned non-zero — proceeding with start anyway"
        local stop_timeout=60 stop_elapsed=0
        while systemctl is-active --quiet "$SERVICE_NAME"; do
            if [[ $stop_elapsed -ge $stop_timeout ]]; then
                warn "Server still alive after ${stop_timeout}s graceful — sending KILL"
                systemctl kill --signal=SIGKILL "$SERVICE_NAME" 2>>"$LOG" || true
                break
            fi
            sleep 2
            stop_elapsed=$((stop_elapsed + 2))
        done
    fi
    DASH_LIVE=1   # from here the dashboard is up with whatever login it has: the guard must outlive an abort
    systemctl start "$SERVICE_NAME"

    local timeout=180 elapsed=0
    info "Waiting for Paper boot (up to ${timeout}s)..."
    while ! grep -q 'Done.*For help' "$INSTALL_DIR/server/logs/latest.log" 2>/dev/null; do
        if [[ $elapsed -ge $timeout ]]; then
            warn "Server still booting after ${timeout}s — check 'systemctl status $SERVICE_NAME'"
            return
        fi
        sleep 3
        elapsed=$((elapsed + 3))
    done
    success "Paper is up"
}

wait_for_dashboard() {
    step "Waiting for dashboard to come online..."
    # 2.16.359 — bumped 90s → 180s. Plugin reports onEnable COMPLETE in ~17s but
    # the HTTP server can take a further 60-90s to bind on slower hosts (laptop
    # 2026-05-08 install hit the 90s limit even though dashboard came up clean).
    local timeout=180 elapsed=0 code
    while [[ $elapsed -lt $timeout ]]; do
        code=$(curl -s -o /dev/null -w '%{http_code}' \
            "http://localhost:${DASHBOARD_PORT}/dashboard" --max-time 3 2>/dev/null || echo 000)
        if [[ "$code" == "200" ]]; then
            success "Dashboard ready (HTTP 200 on /dashboard)"
            return
        fi
        sleep 3
        elapsed=$((elapsed + 3))
    done
    warn "Dashboard didn't respond at /dashboard within ${timeout}s"
    info "Check 'sudo journalctl -u ${SERVICE_NAME} -n 50' for plugin errors."
}

print_summary() {
    # 2.15.441 — Use playit hostname if detected, otherwise fall back to WAN IP
    local connect_host wan_ip
    wan_ip=$(curl -fsSL --max-time 5 https://api.ipify.org 2>/dev/null \
          || curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null \
          || echo "<your-server-ip>")
    if [[ -n "$PLAYIT_HOSTNAME" ]]; then
        connect_host="$PLAYIT_HOSTNAME"
    else
        connect_host="$wan_ip"
    fi

    local login_block access_block fw_block
    if [[ -n "$DASH_PASSWORD" ]]; then
        login_block="    username: admin
    password: ${DASH_PASSWORD}
    (also saved in /root/nova-dashboard-login.txt, readable by root only; it is not in the install log)"
    else
        login_block="    Not printed: this run did not create a password (the default login was not in use).
    Use the one you set earlier, or /root/nova-dashboard-login.txt if this installer created it."
    fi

    if [[ -n "$HTTPS_DOMAIN_LIVE" ]]; then
        access_block="    https://${HTTPS_DOMAIN_LIVE}/dashboard"
    else
        access_block="    From your own computer, open an SSH tunnel:
        ssh -L ${DASHBOARD_PORT}:127.0.0.1:${DASHBOARD_PORT} <your-user>@${wan_ip}
    then browse to  http://127.0.0.1:${DASHBOARD_PORT}/dashboard"
    fi
    if [[ -n "$NOVA_OPEN_DASHBOARD" ]]; then
        access_block="${access_block}
    Port ${DASHBOARD_PORT} was opened because NOVA_OPEN_DASHBOARD is set:
        http://${wan_ip}:${DASHBOARD_PORT}/dashboard   (plain http — the password is not encrypted on the way)"
    fi

    local ufw_line
    case "$FIREWALL_STATE" in
        active)   if [[ -n "$DASH_FW_CLOSED" || -n "$NOVA_OPEN_DASHBOARD" ]]; then
                      ufw_line="    ufw is active and has no allow rule for port ${DASHBOARD_PORT} (checked)."
                  else
                      ufw_line="    ufw is active, but a ufw allow rule for port ${DASHBOARD_PORT} from an earlier install is still there (see the warning above). Remove it: ufw delete allow ${DASHBOARD_PORT}/tcp"
                  fi ;;
        inactive) ufw_line="    ufw is installed but switched off, so it filters nothing else on this machine.
    To switch it on, allow your SSH port first or you lock yourself out:
        sudo ufw allow <your-ssh-port>/tcp && sudo ufw enable" ;;
        *)        ufw_line="    No firewall tool was found by this installer, so nothing else on this machine is filtered by it." ;;
    esac
    if [[ -n "$GUARD_PERSISTENT" ]]; then
        fw_block="    Port ${DASHBOARD_PORT} is closed to connections from other computers by a firewall rule this installer
    put in place (${GUARD_UNIT}.service, started at every boot, checked). The SSH tunnel above is not affected.
    To open the port on purpose, run the installer again with NOVA_OPEN_DASHBOARD=1 (plain http).
${ufw_line}"
    elif [[ -n "$NOVA_OPEN_DASHBOARD" ]]; then
        fw_block="    Port ${DASHBOARD_PORT} is OPEN because NOVA_OPEN_DASHBOARD is set: the dashboard listens on every address and
    is reachable from the internet over plain http, protected only by the password above.
${ufw_line}"
    else
        fw_block="    Port ${DASHBOARD_PORT} is not covered by the installer's lasting firewall rule (see the messages above).
${ufw_line}"
    fi

    cat <<EOF

${G}${D}═══════════════════════════════════════════════════════════════${N}
${G}${D}                   NovaSync is live!${N}
${G}${D}═══════════════════════════════════════════════════════════════${N}

${D}Connect from Minecraft:${N}
    Java Edition:    ${connect_host}:${MC_PORT}
    Bedrock Edition: ${connect_host} on port ${BEDROCK_PORT}

${D}Dashboard login:${N}
${login_block}

${D}Open the dashboard:${N}
${access_block}

${D}Firewall:${N}
${fw_block}

${D}Manage your server:${N}
    Status:   sudo systemctl status ${SERVICE_NAME}
    Stop:     sudo systemctl stop ${SERVICE_NAME}
    Restart:  sudo systemctl restart ${SERVICE_NAME}
    Logs:     sudo journalctl -u ${SERVICE_NAME} -f

${D}Upgrade:${N}
    curl -fsSL https://seidrlabs.online/novasync/install.sh | sudo bash
    (re-run any time — it picks up the latest Paper + NovaSync)

${D}Help:${N}  https://seidrlabs.online/novasync/

EOF
}

# ─── Main ────────────────────────────────────────────────────────────────────
main() {
    banner
    require_root
    [[ "$NOVA_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] \
        || fail "NOVA_USER='${NOVA_USER}' is not a valid account name (lowercase letters, digits, - and _). Nothing was changed."
    [[ "$SERVICE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] \
        || fail "SERVICE_NAME='${SERVICE_NAME}' is not a valid service name (letters, digits, - _ and ., up to 64 characters). Nothing was changed."
    if [[ -n "$REQUESTED_DASHBOARD_PORT" && "$REQUESTED_DASHBOARD_PORT" != "8585" ]]; then
        fail "DASHBOARD_PORT=${REQUESTED_DASHBOARD_PORT} is not supported: the NovaSync dashboard listens on 8585 and this installer cannot move it, so it would guard the wrong port. Unset DASHBOARD_PORT. Nothing was changed."
    fi
    setup_log
    require_supported_os
    require_supported_arch
    install_java
    create_user_and_dirs
    guard_dashboard_port
    download_paper
    accept_eula
    download_novasync
    # 2.17.155 W7-F14 — choose_heap MUST run before configure_server_properties
    # so the bootstrap Paper boot (line ~508) can pick a heap that fits the
    # host. On ≤1GB boxes the old order tried -Xmx1G hardcoded against an
    # un-chosen heap, competing with the installer itself for RAM and risking
    # OOM-kill on the first-boot config-gen pass.
    choose_heap
    configure_server_properties
    seed_network_key
    detect_playit
    write_jvm_env
    ensure_start_script
    create_systemd_service
    configure_firewall
    setup_os_updates
    start_server
    wait_for_dashboard
    secure_dashboard_login
    settle_dashboard_guard
    collect_https_choice
    if [[ -n "$NOVA_DOMAIN" ]]; then
        if setup_nginx_proxy; then setup_https_optional; fi
    fi
    configure_playit_host
    print_summary
}

# NOVA_INSTALLER_SOURCE_ONLY=1 lets the tests load the functions without running the install.
[[ -n "${NOVA_INSTALLER_SOURCE_ONLY:-}" ]] || main "$@"
