#!/bin/bash

# One-time setup for the per-domain SSL certificate upload feature.
#
# Creates the directories the app writes to, ensures ownership matches
# the php-fpm user (so PHP can write) with group-read for caddy (so
# the running Caddy process can read certs and snippet files), and
# patches /etc/caddy/Caddyfile to expose the admin API on a unix
# socket and import per-domain snippets.
#
# Idempotent — safe to re-run.
#
# Usage: setup-custom-domain-ssl.sh [web-user] [caddy-user] [--dry-run]
#   --dry-run  Report what would change, and print any snippet that would be
#              inserted, without modifying anything.

set -euo pipefail

DRY_RUN=0
POSITIONAL=()
for arg in "$@"; do
    case "$arg" in
        --dry-run) DRY_RUN=1 ;;
        *) POSITIONAL+=("$arg") ;;
    esac
done
set -- ${POSITIONAL[@]+"${POSITIONAL[@]}"}

WEB_USER="${1:-www-data}"
CADDY_USER="${2:-caddy}"
CERT_DIR="/var/lib/multiportal/certs"
CADDY_CONF_D="/etc/caddy/conf.d"
CADDY_LOG_DIR="/var/log/caddy"
CADDYFILE="/etc/caddy/Caddyfile"
ADMIN_SOCKET_DIR="/run/caddy"

# Name of the shared snippet that every portal-written per-domain snippet
# imports. Must match CustomDomainCertManager::BACKEND_COMMON_SNIPPET.
SNIPPET_NAME="backend_common"
CADDYFILE_LOCK="${CADDYFILE}.mp.lock"

# Resolve through symlinks so a script linked into e.g. /usr/local/bin still
# finds the repo it actually lives in.
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
REPO_ROOT="$(dirname "$(dirname "$SCRIPT_PATH")")"

# Every state-changing command goes through run(), so --dry-run is a real
# no-op rather than "no-op except the parts I forgot".
run() {
    if [[ "$DRY_RUN" == "1" ]]; then
        echo "[dry-run] $*"
    else
        "$@"
    fi
}

if [[ $EUID -ne 0 ]]; then
    echo "This script needs root (run with sudo)." >&2
    exit 1
fi

if ! id -u "$WEB_USER" >/dev/null 2>&1; then
    echo "Web user '$WEB_USER' does not exist." >&2
    exit 1
fi
if ! id -u "$CADDY_USER" >/dev/null 2>&1; then
    echo "Caddy user '$CADDY_USER' does not exist." >&2
    exit 1
fi

if ! id -nG "$CADDY_USER" | tr ' ' '\n' | grep -qx "$WEB_USER"; then
    echo "Adding $CADDY_USER to $WEB_USER group so PHP-written files are readable by Caddy..."
    run usermod -aG "$WEB_USER" "$CADDY_USER"
fi

# Caddy talks to PHP via its default TCP admin endpoint at localhost:2019.
# We tried a unix socket originally but Caddy 2.x resets the socket's
# perms to 0200 on every reload, which made the socket unusable after
# the first reload. TCP localhost has no perms to fight and is bound to
# 127.0.0.1 only, so it's not externally reachable.

echo "Creating $CERT_DIR (owner $WEB_USER:$CADDY_USER, mode 0750)..."
run mkdir -p "$CERT_DIR"
run chown "$WEB_USER:$CADDY_USER" "$CERT_DIR"
run chmod 0750 "$CERT_DIR"

echo "Creating $CADDY_CONF_D (owner $WEB_USER:$CADDY_USER, mode 0750)..."
run mkdir -p "$CADDY_CONF_D"
run chown "$WEB_USER:$CADDY_USER" "$CADDY_CONF_D"
run chmod 0750 "$CADDY_CONF_D"

if [[ ! -d "$CADDY_LOG_DIR" ]]; then
    echo "Creating $CADDY_LOG_DIR (owner $CADDY_USER:$CADDY_USER, mode 0755)..."
    run mkdir -p "$CADDY_LOG_DIR"
    run chown "$CADDY_USER:$CADDY_USER" "$CADDY_LOG_DIR"
    run chmod 0755 "$CADDY_LOG_DIR"
fi

# Admin endpoint is now TCP localhost:2019 (Caddy's default) —
# no socket directory needed.

# Strip any prior admin unix-socket directive from the Caddyfile —
# we now use the default TCP localhost:2019 endpoint.
if grep -q 'admin unix//run/caddy/admin.sock' "$CADDYFILE"; then
    echo "Removing legacy admin unix socket directive from $CADDYFILE..."
    run cp "$CADDYFILE" "$CADDYFILE.pre-cert.$(date +%Y%m%d-%H%M%S).bak"
    run sed -i -E '/^[[:space:]]*admin[[:space:]]+unix\/\/run\/caddy\/admin\.sock.*$/d' "$CADDYFILE"
fi

# ── Caddyfile: ensure the (backend_common) snippet and the conf.d import ──────
#
# Every per-domain snippet the portal writes contains `import backend_common`.
# Caddy resolves imports top-down as it walks the file, so that snippet must be
# DEFINED ABOVE the `import conf.d/*.caddy` line that pulls those snippets in.
# When it is missing entirely, Caddy falls back to treating the name as a file
# and reports "File to import not found: backend_common" — every cert upload
# then dies at `caddy reload`.
#
# Both edits are applied as ONE staged rewrite: render to a temp file inside
# /etc/caddy (so the relative `import conf.d/*.caddy` still resolves), validate
# that temp file, and only then move it into place. A broken Caddyfile never
# touches disk, not even momentarily.

# Print the snippet body. Host-specific values are EXTRACTED from the operator's
# existing vhost rather than reconstructed: those tokens are already correct, and
# copying them verbatim handles unix sockets, TCP fastcgi pools, non-Debian
# socket layouts and custom wss ports for free. Everything else is the canonical
# body from docs/Caddyfile_Reference.md. No `log` directive — that is per-host
# and each per-domain snippet emits its own.
render_snippet() {
    local site_root="$1" fastcgi="$2" wss="$3"
    cat <<SNIPPET
($SNIPPET_NAME) {
    @css path *.css
    header @css Content-Type text/css

    root * ${site_root}
    encode gzip
    file_server

    php_fastcgi ${fastcgi} {
        index index.php
        env PHP_FCGI_MAX_REQUESTS 1000
    }

    header {
        Access-Control-Allow-Origin *
        Access-Control-Allow-Methods "GET, POST, OPTIONS"
        X-Frame-Options SAMEORIGIN
        Access-Control-Allow-Headers "Content-Type, Authorization"
    }

    reverse_proxy /wss* ${wss} {
        header_up Host {host}
        header_up X-Real-IP {remote}
    }
}
SNIPPET
}

# Emit the line number on which the global options block closes, or 0 when there
# is no global block (so the snippet goes at the very top), or -1 when the block
# is unbalanced and we must not guess.
#
# The global options block, if present, must be the FIRST block in a Caddyfile,
# and it legitimately nests (`on_demand_tls { ask ... }`), so "the first }" is
# wrong. Quoted spans are blanked and comments dropped before counting, which
# keeps `respond "}"` and `# }` from skewing the depth. Caddy placeholders such
# as {host} and {$VAR} are brace-balanced on their own line, so they cancel out.
global_block_end_line() {
    awk '
    function clean(s,   i, c, out, q) {
        out = ""; q = 0
        for (i = 1; i <= length(s); i++) {
            c = substr(s, i, 1)
            if (q)          { if (c == "\"") q = 0; out = out " "; continue }
            if (c == "\"")  { q = 1; out = out " "; continue }
            if (c == "#")   break
            out = out c
        }
        return out
    }
    BEGIN { started = 0; depth = 0; done = 0 }
    {
        line = clean($0)
        trimmed = line
        gsub(/^[ \t]+|[ \t]+$/, "", trimmed)
        if (!started) {
            if (trimmed == "") next
            # A global options block opens with a bare "{". Anything else --
            # "site.com {", "(snippet) {" -- means there is no global block, so
            # the snippet belongs at the very top. Match on the leading brace
            # rather than equality so a single-line "{ debug }" is handled too.
            if (substr(trimmed, 1, 1) != "{") { print 0; done = 1; exit }
            started = 1
        }
        opens = gsub(/\{/, "{", line)
        closes = gsub(/\}/, "}", line)
        depth += opens - closes
        if (depth <= 0) { print NR; done = 1; exit }
    }
    END { if (!done) print (started ? -1 : 0) }
    ' "$1"
}

# Refuse to auto-insert and tell the operator exactly what to paste. Used for
# every case where we cannot establish the right values with confidence —
# guessing here would silently degrade the sites we are trying to fix.
warn_manual_snippet() {
    echo "WARN: $CADDYFILE does not usefully define the ($SNIPPET_NAME) snippet." >&2
    echo "      Certificate uploads will fail at 'caddy reload' until it does." >&2
    echo "      Not inserting it automatically because: $1" >&2
    echo "      Add the following above your host blocks, adjusting to match your vhost:" >&2
    echo "" >&2
    render_snippet "${SITE_ROOT:-/path/to/multiportal/backend/web}" \
                   "${FASTCGI:-unix//run/php/php8.2-fpm.sock}" \
                   "${WSS_TARGET:-localhost:8081}" >&2
    echo "" >&2
}

# Serialise the read-modify-move against concurrent runs (Ansible retries, two
# operators). The PHP side already serialises on a MariaDB advisory lock; this
# shell path had no guard at all.
exec 9>"$CADDYFILE_LOCK"
flock -w 10 9 || echo "WARN: could not lock $CADDYFILE_LOCK; proceeding unlocked." >&2

SNIPPET_DEF_LINE="$(grep -nm1 -E "^[[:space:]]*\($SNIPPET_NAME\)[[:space:]]*\{" "$CADDYFILE" | cut -d: -f1 || true)"
IMPORT_LINE="$(grep -nm1 -E '^[[:space:]]*import[[:space:]]+conf\.d/\*\.caddy' "$CADDYFILE" | cut -d: -f1 || true)"

NEED_SNIPPET=0
NEED_IMPORT=0
[[ -z "$SNIPPET_DEF_LINE" ]] && NEED_SNIPPET=1
[[ -z "$IMPORT_LINE" ]] && NEED_IMPORT=1

# Defined, but below the import that consumes it: present yet useless. Moving a
# block around someone's Caddyfile is a bigger liberty than appending to it, so
# say so and stop rather than rewrite.
if [[ -n "$SNIPPET_DEF_LINE" && -n "$IMPORT_LINE" && "$SNIPPET_DEF_LINE" -gt "$IMPORT_LINE" ]]; then
    echo "WARN: ($SNIPPET_NAME) is defined on line $SNIPPET_DEF_LINE, below the" >&2
    echo "      'import conf.d/*.caddy' on line $IMPORT_LINE. Caddy resolves imports" >&2
    echo "      top-down, so it will never be registered. Move the snippet above that" >&2
    echo "      line, then re-run this script." >&2
fi

if [[ "$NEED_SNIPPET" == "0" && "$NEED_IMPORT" == "0" ]]; then
    echo "Caddyfile already defines ($SNIPPET_NAME) and imports conf.d — skipping."
else
    SCAFFOLD_BLOCKED=""

    if [[ "$NEED_SNIPPET" == "1" ]]; then
        # An import we cannot read might already define the snippet; inserting a
        # second definition is a Caddy redeclaration error.
        FOREIGN_IMPORTS="$(grep -E '^[[:space:]]*import[[:space:]]+' "$CADDYFILE" 2>/dev/null \
            | awk '{print $2}' \
            | grep -vE "^(conf\.d/\*\.caddy|$SNIPPET_NAME)$" || true)"

        SITE_ROOT="$(grep -m1 -E '^[[:space:]]*root[[:space:]]+\*[[:space:]]+' "$CADDYFILE" | awk '{print $3}' || true)"
        FASTCGI="$(grep -m1 -E '^[[:space:]]*php_fastcgi[[:space:]]+' "$CADDYFILE" | awk '{print $2}' || true)"
        WSS_TARGET="$(grep -m1 -E '^[[:space:]]*reverse_proxy[[:space:]]+/wss\*' "$CADDYFILE" | awk '{print $3}' || true)"

        [[ -z "$SITE_ROOT" ]] && SITE_ROOT="$REPO_ROOT/backend/web"
        [[ -z "$WSS_TARGET" ]] && WSS_TARGET="localhost:8081"

        INSERT_AFTER="$(global_block_end_line "$CADDYFILE")"

        if [[ -n "$FOREIGN_IMPORTS" ]]; then
            SCAFFOLD_BLOCKED="the Caddyfile imports other files ($(echo "$FOREIGN_IMPORTS" | tr '\n' ' ')) which may already define it"
        elif [[ -z "$FASTCGI" ]]; then
            # No safe default: the socket path varies by distro, and TCP pools
            # have no socket at all. Deriving it from the systemd unit name is
            # wrong on RHEL, on TCP pools, and on multi-pool hosts.
            SCAFFOLD_BLOCKED="no 'php_fastcgi' directive found to copy the backend target from"
        elif [[ ! -d "$SITE_ROOT" ]]; then
            SCAFFOLD_BLOCKED="derived web root '$SITE_ROOT' does not exist"
        elif [[ "$INSERT_AFTER" == "-1" ]]; then
            SCAFFOLD_BLOCKED="could not find where the global options block ends"
        fi
    fi

    if [[ -n "$SCAFFOLD_BLOCKED" ]]; then
        warn_manual_snippet "$SCAFFOLD_BLOCKED"
    else
        TMP="$CADDYFILE.mp-scaffold.$$"
        trap 'rm -f "$TMP"' EXIT

        {
            if [[ "$NEED_SNIPPET" == "1" && "$INSERT_AFTER" -gt 0 ]]; then
                head -n "$INSERT_AFTER" "$CADDYFILE"
            fi
            if [[ "$NEED_SNIPPET" == "1" ]]; then
                echo ""
                echo "# Shared backend config imported by every host block and by the"
                echo "# per-domain snippets in conf.d/ — managed by the portal setup script."
                render_snippet "$SITE_ROOT" "$FASTCGI" "$WSS_TARGET"
                echo ""
                tail -n +"$((INSERT_AFTER + 1))" "$CADDYFILE"
            else
                cat "$CADDYFILE"
            fi

            if [[ "$NEED_IMPORT" == "1" ]]; then
                echo ""
                echo "# Per-domain uploaded SSL certificate snippets — managed by the portal."
                echo "import conf.d/*.caddy"
            fi
        } > "$TMP"

        # `caddy validate` runs as CADDY_USER, so the staged file has to look
        # exactly like the real one to that user.
        chown --reference="$CADDYFILE" "$TMP"
        chmod --reference="$CADDYFILE" "$TMP"

        if [[ "$DRY_RUN" == "1" ]]; then
            echo "[dry-run] would rewrite $CADDYFILE as follows:"
            diff -u "$CADDYFILE" "$TMP" || true
            rm -f "$TMP"
        elif su -s /bin/sh -c "caddy validate --config $TMP" "$CADDY_USER" >/dev/null 2>&1; then
            cp "$CADDYFILE" "$CADDYFILE.pre-cert.$(date +%Y%m%d-%H%M%S).bak"
            [[ "$NEED_SNIPPET" == "1" ]] && echo "Inserting ($SNIPPET_NAME) snippet into $CADDYFILE..."
            [[ "$NEED_IMPORT" == "1" ]] && echo "Appending 'import conf.d/*.caddy' to $CADDYFILE..."
            mv "$TMP" "$CADDYFILE"
        else
            echo "Staged Caddyfile failed validation — $CADDYFILE left untouched." >&2
            echo "Validation output:" >&2
            su -s /bin/sh -c "caddy validate --config $TMP" "$CADDY_USER" >&2 || true
            rm -f "$TMP"
            # The failure is often unrelated to us (a pre-existing broken snippet
            # in conf.d/, an uncreatable log file). Don't abort the whole setup.
            warn_manual_snippet "the staged Caddyfile did not validate (see output above)"
        fi

        trap - EXIT
    fi
fi

exec 9>&-

# Grant php-fpm write access to the snippet directory. Ubuntu's php-fpm unit
# ships with ProtectSystem=full, which mounts /etc read-only *inside the
# service's own mount namespace only*. That's why a manual `sudo -u www-data
# touch` succeeds but the portal's snippet write to /etc/caddy/conf.d fails
# with EROFS even though ownership and the real mount are read-write. A
# systemd drop-in with ReadWritePaths punches a hole for just this one dir.
# /var/lib/multiportal/certs is not listed — /var stays writable under
# ProtectSystem=full, so only the /etc path needs the override.
#
# Hosts that have been through a PHP upgrade often leave several php*-fpm.service
# units loaded at once. Patching whichever one systemd happens to list first would
# leave the portal's own workers sandboxed while this script reported success, so
# take an explicit override if given, accept a lone candidate, and otherwise resolve
# the unit that owns a running php-fpm master through its cgroup. Never guess.
FPM_UNITS=()
PHP_FPM_UNIT="${PHP_FPM_UNIT:-}"
FPM_AMBIGUOUS=0

if [[ -z "$PHP_FPM_UNIT" ]]; then
    while IFS= read -r unit; do
        FPM_UNITS+=("$unit")
    done < <(systemctl list-units --type=service --state=loaded --no-legend 'php*-fpm.service' 2>/dev/null | awk 'NF {print $1}')

    if [[ ${#FPM_UNITS[@]} -eq 1 ]]; then
        PHP_FPM_UNIT="${FPM_UNITS[0]}"
    elif [[ ${#FPM_UNITS[@]} -gt 1 ]]; then
        for pid in $(pgrep -x php-fpm 2>/dev/null || true); do
            cgroup="$(tail -n1 "/proc/$pid/cgroup" 2>/dev/null || true)"
            for unit in "${FPM_UNITS[@]}"; do
                if [[ "${cgroup##*/}" == "$unit" ]]; then
                    PHP_FPM_UNIT="$unit"
                    break 2
                fi
            done
        done
        [[ -z "$PHP_FPM_UNIT" ]] && FPM_AMBIGUOUS=1
    fi
fi

NEEDS_FPM_RESTART=0
if [[ -n "$PHP_FPM_UNIT" ]]; then
    FPM_DROPIN_DIR="/etc/systemd/system/${PHP_FPM_UNIT}.d"
    FPM_DROPIN="${FPM_DROPIN_DIR}/multiportal-caddy-writable.conf"
    FPM_DROPIN_BODY="# Managed by multiportal setup-custom-domain-ssl.sh — do not edit.
# Lets php-fpm write per-domain Caddy snippets under ProtectSystem sandboxing.
[Service]
ReadWritePaths=$CADDY_CONF_D"
    if [[ ! -f "$FPM_DROPIN" ]] || [[ "$(cat "$FPM_DROPIN")" != "$FPM_DROPIN_BODY" ]]; then
        echo "Installing php-fpm drop-in $FPM_DROPIN (ReadWritePaths=$CADDY_CONF_D)..."
        if [[ "$DRY_RUN" == "1" ]]; then
            echo "[dry-run] would write $FPM_DROPIN and run 'systemctl daemon-reload'"
        else
            mkdir -p "$FPM_DROPIN_DIR"
            printf '%s\n' "$FPM_DROPIN_BODY" > "$FPM_DROPIN"
            systemctl daemon-reload
        fi
        NEEDS_FPM_RESTART=1
    else
        echo "php-fpm drop-in already up to date — skipping."
    fi
elif [[ "$FPM_AMBIGUOUS" == "1" ]]; then
    echo "WARN: several php*-fpm.service units are loaded and none is running, so the" >&2
    echo "      one serving the portal cannot be identified — skipping systemd drop-in:" >&2
    printf '        %s\n' "${FPM_UNITS[@]}" >&2
    echo "      Re-run with the unit named explicitly, e.g.:" >&2
    echo "        sudo PHP_FPM_UNIT=${FPM_UNITS[0]} $0 $WEB_USER $CADDY_USER" >&2
else
    echo "WARN: no loaded php*-fpm.service unit found — skipping systemd drop-in." >&2
    echo "      If php-fpm runs under systemd with ProtectSystem, add manually:" >&2
    echo "      ReadWritePaths=$CADDY_CONF_D  (then 'systemctl restart <your-php-fpm>')." >&2
fi

# Remove any prior systemd drop-in that fought socket perms — no longer
# relevant now that we use the default TCP admin endpoint.
DROPIN_FILE="/etc/systemd/system/caddy.service.d/admin-socket-perms.conf"
NEEDS_DROPIN_RESTART=0
if [[ -f "$DROPIN_FILE" ]]; then
    echo "Removing legacy systemd drop-in $DROPIN_FILE..."
    run rm -f "$DROPIN_FILE"
    run systemctl daemon-reload
    NEEDS_DROPIN_RESTART=1
fi

echo "Validating new Caddyfile..."
if ! su -s /bin/sh -c "caddy validate --config $CADDYFILE" "$CADDY_USER" >/dev/null 2>&1; then
    echo "Caddyfile validation failed. Inspect $CADDYFILE — restore from latest .bak if needed." >&2
    su -s /bin/sh -c "caddy validate --config $CADDYFILE" "$CADDY_USER" || true
    # Under --dry-run nothing was written, so the file on disk is the *old* one;
    # its failing to validate is expected when that's the problem we'd have fixed.
    [[ "$DRY_RUN" == "1" ]] || exit 1
fi

# Restart if we just removed the drop-in or stripped the admin directive,
# else a normal reload is enough.
if [[ "$NEEDS_DROPIN_RESTART" == "1" ]]; then
    echo "Restarting Caddy (admin endpoint changed)..."
    run systemctl restart caddy
else
    echo "Reloading Caddy..."
    run systemctl reload caddy
fi

# A ReadWritePaths change alters the service's mount namespace, which only
# takes effect when workers are re-spawned — reload is not enough here.
if [[ "$NEEDS_FPM_RESTART" == "1" ]]; then
    echo "Restarting $PHP_FPM_UNIT (systemd sandbox paths changed)..."
    run systemctl restart "$PHP_FPM_UNIT"
fi

# Verify the admin endpoint is reachable on TCP localhost:2019.
sleep 1
if curl -fsS --max-time 2 http://localhost:2019/config/ >/dev/null 2>&1; then
    echo "Admin endpoint reachable: http://localhost:2019"
else
    echo "WARN: http://localhost:2019 did not respond — check 'systemctl status caddy'." >&2
fi

cat <<EOF

Setup complete.

Directories:
  $CERT_DIR      (cert+key files, written by $WEB_USER)
  $CADDY_CONF_D  (per-domain Caddyfile snippets)

systemd:
  php-fpm drop-in grants ReadWritePaths=$CADDY_CONF_D so php-fpm can write
  snippets under ProtectSystem sandboxing (unit: ${PHP_FPM_UNIT:-none detected}).

Caddy admin endpoint: http://localhost:2019 (default, 127.0.0.1-only)
PHP-FPM can reload Caddy via:
  caddy reload --config $CADDYFILE --address localhost:2019

Next: log in to the portal as a reseller/tenant with branding-management
permission, open the custom-domain card, click the SSL key icon on a
verified domain, and upload a cert + key.
EOF
