#!/bin/bash
# install-pkcs11.sh — install and configure OpenSSL + pkcs11-provider + Primus
#
# Supported platforms are listed in README.md beside this script. They are not repeated here:
# the two copies drifted, and the one inside the script is the copy a customer reads.
#
# INSTALLER_VERSION is rewritten by scripts/release-stamp.sh at release time. On a working tree
# it reads 0.0.0-dev, which is how support can tell a checkout from a released copy.
# -u is on: an unset variable is a bug here, not an empty string. Every value that is
# genuinely optional is written with an explicit default (${VAR:-...}), so the guard is left
# to catch the misspelled name — which used to expand to nothing and carry on. That is worth
# catching in this script in particular: the names it expands are paths it deletes from and
# config it rewrites, and an empty one does not fail, it just aims somewhere else.
set -euo pipefail
set -E

INSTALLER_VERSION="1.0.0"
# The status, line and command are captured in the trap string and passed in, rather than read
# from inside the handler: $LINENO in a function is the handler's own line, not the one that
# failed, and $? would be the handler's.
_abort() {
    printf "\n\033[0;31m[ABORT]\033[0m install-pkcs11.sh exited at line %d (rc=%d)\n  command: %s\n" \
        "$2" "$1" "$3" >&2
}
trap '_abort "$?" "$LINENO" "$BASH_COMMAND"' ERR

# ── colours and glyphs ────────────────────────────────────────────────────────
DIM='\033[2m'; BOLD='\033[1m'
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'

if [[ -n "${NO_UNICODE:-}" || "${LANG:-}" =~ ^C(\.|$) ]]; then
    GLYPH_OK='[+]'; GLYPH_DOT='-'; GLYPH_DIAMOND='*'
    GLYPH_WARN='[!]'; GLYPH_ERR='[x]'
    RAIL='|'; RAIL_BOT='+--'
else
    GLYPH_OK='✓'; GLYPH_DOT='·'; GLYPH_DIAMOND='◇'
    GLYPH_WARN='⚠'; GLYPH_ERR='✗'
    RAIL='│'; RAIL_BOT='└'
fi

STEP_INDEX=0; STEP_TOTAL=6

rail()  { printf "${DIM}${RAIL}${NC}\n"; }
step()  {
    STEP_INDEX=$((STEP_INDEX + 1))
    printf "${DIM}${RAIL}${NC}\n"
    printf "${CYAN}${GLYPH_DIAMOND}${NC}  ${CYAN}${BOLD}[%d/%d]${NC} ${BOLD}%s${NC}\n" \
        "${STEP_INDEX}" "${STEP_TOTAL}" "$1"
    printf "${DIM}${RAIL}${NC}\n"
}
info()  { printf "${DIM}${RAIL}${NC}  ${DIM}${GLYPH_DOT} %s${NC}\n" "$*"; }
note()  { printf "${DIM}${RAIL}${NC}  ${GREEN}${GLYPH_OK}${NC} %s\n" "$*"; }
warn()  { printf "${DIM}${RAIL}${NC}  ${YELLOW}${GLYPH_WARN}${NC} %s\n" "$*"; }
die()   {
    printf "${DIM}${RAIL}${NC}  ${RED}${GLYPH_ERR}${NC} %s\n${RAIL_BOT}\n" "$*" >&2
    printf "${RED}[ABORT]${NC} Installation failed.\n" >&2
    # Pause only when a terminal is actually there to pause for. Without this
    # guard the read fails in any non-interactive run (CI, a pipe, sudo with no
    # tty) and the ERR trap buries the real message under its own backtrace.
    if [ -t 1 ] && [ -r /dev/tty ]; then
        printf "Press Enter to close." >&2
        read -r _ < /dev/tty 2>/dev/null || true
    fi
    exit 1
}

card() {
    local title="$1"; shift
    local lines=("$@")
    local inner=$(( ${#title} + 7 ))
    local L
    for L in "${lines[@]}"; do
        (( ${#L} + 4 > inner )) && inner=$(( ${#L} + 4 ))
    done
    local i dashes
    printf "${CYAN}${GLYPH_DIAMOND}${NC}  ${BOLD}%s${NC} " "${title}"
    dashes=$(( inner - ${#title} - 3 ))
    (( dashes < 4 )) && dashes=4
    for (( i=0; i<dashes; i++ )); do printf "─"; done
    printf "╮\n"
    printf "${DIM}│${NC}"; for (( i=0; i<inner; i++ )); do printf " "; done; printf "${DIM}│${NC}\n"
    for L in "${lines[@]}"; do
        local pad=$(( inner - ${#L} - 4 )); (( pad < 0 )) && pad=0
        printf "${DIM}│${NC}  %s" "${L}"
        for (( i=0; i<pad; i++ )); do printf " "; done
        printf "  ${DIM}│${NC}\n"
    done
    printf "${DIM}│${NC}"; for (( i=0; i<inner; i++ )); do printf " "; done; printf "${DIM}│${NC}\n"
    printf "${DIM}├"; for (( i=0; i<inner; i++ )); do printf "─"; done; printf "╯${NC}\n"
    rail
}

# ── reading a secret from the terminal ───────────────────────────────────────
# Echo is turned off around the PIN prompts. An interrupt inside that window used to leave
# the terminal with echo still off: the shell dies inside `read` and the `stty echo` written
# after it never runs, so the operator types blind from then on and the cure (`stty sane`)
# is not obvious from a prompt that no longer shows anything. The settings in force are
# saved and restored from a trap, so an interrupt leaves the terminal as it was found.
TTY_STATE=""
restore_tty() {
    # The saved settings, not a bare `stty echo` - whatever else the prompt changed is put
    # back too, and an unreadable /dev/tty (no terminal at all) is not an error here.
    [ -n "$TTY_STATE" ] && stty "$TTY_STATE" < /dev/tty 2>/dev/null \
        || stty echo < /dev/tty 2>/dev/null || true
    TTY_STATE=""
}

# read_secret VARNAME — read one line from the terminal without echoing it.
# EXIT is trapped as well as INT/TERM: `read` hitting EOF trips set -e, and the restore on
# the far side of it would be skipped exactly as an interrupt skips it.
read_secret() {
    # Put back whatever INT/TERM/EXIT handlers were already installed instead of clearing them.
    # This script has only an ERR trap today, so the two are equivalent now - but a bare
    # `trap -` silently drops a handler added later at top level, and that failure presents as
    # a cleanup that stops running with nothing to say why. `trap -p` prints nothing for an
    # unset signal, so the eval is a no-op in the common case.
    local prev_traps
    prev_traps="$(trap -p INT TERM EXIT)"

    TTY_STATE="$(stty -g < /dev/tty 2>/dev/null || true)"
    trap 'restore_tty; printf "\n" >&2; exit 130' INT
    trap 'restore_tty; printf "\n" >&2; exit 143' TERM
    trap 'restore_tty' EXIT

    stty -echo < /dev/tty 2>/dev/null || true
    read -r "$1" < /dev/tty

    restore_tty
    trap - INT TERM EXIT
    eval "$prev_traps"
    printf "\n"
}

# ── usage ────────────────────────────────────────────────────────────────

# This is user-facing text. Keep clean and simple!
# Ask Product Management if unsure about the exact naming conventions.
print_usage_long() {
    echo "Usage: $0 <subcommand>"
    echo ""
    echo "Subcommands:"
    echo "  install            everything: install-pkcs11, configure-pkcs11, install-openssl, configure-openssl (default)"
    echo "  install-pkcs11     install/reinstall the Primus PKCS#11 Provider"
    echo "  install-openssl    install OpenSSL + pkcs11-provider and clear any old openssl.cnf pkcs11 block"
    echo "  configure-pkcs11   write /etc/primus/primus.cfg and .secrets.cfg"
    echo "  configure-openssl  write the openssl.cnf pkcs11 block (needs the packages + primus.cfg)"
    echo "  uninstall          full removal: config (primus.cfg + .secrets.cfg + openssl.cnf pkcs11 block),"
    echo "                     and packages (Primus PKCS#11 Provider, OpenSSL, pkcs11-provider)."
    echo "  remove-config      remove primus.cfg + .secrets.cfg and the openssl.cnf pkcs11 block only"
    echo ""
    echo "Options:"
    echo "  --help, -h         show this help"
    echo "  --version, -V      print the installer's release version"
    echo ""
    echo "Environment variables:"
    echo "  NO_UNICODE         use ASCII glyphs instead of Unicode, if set to NO_UNICODE=1"
}

# ── subcommand ────────────────────────────────────────────────────────────────
SUBCOMMAND="${1:-install}"
case "$SUBCOMMAND" in
    install|install-pkcs11|install-openssl|configure-pkcs11|configure-openssl|uninstall|remove-config) ;;
    -V|--version)
        echo "install-pkcs11.sh ${INSTALLER_VERSION}"
        exit 0 ;;
    -h|--help)
        print_usage_long
        exit 0 ;;
    *)
        echo "Unknown command: $SUBCOMMAND" >&2
        echo "Usage: $0 install|install-pkcs11|install-openssl|configure-pkcs11|configure-openssl|uninstall|remove-config|--version" >&2
        exit 1 ;;
esac

# ── root check ────────────────────────────────────────────────────────────────
[ "$(id -u)" -eq 0 ] || die "Run as root or with sudo."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_USER="${SUDO_USER:-root}"

# ── OS and package manager detection ─────────────────────────────────────────
[ -f /etc/os-release ] || die "Cannot detect OS (/etc/os-release missing)."
. /etc/os-release

# ${ID:-} rather than $ID: /etc/os-release is not guaranteed to define it — minimal and
# container images ship nearly empty ones — and under `set -u` a bare $ID would abort with
# bash's own "unbound variable" instead of the die below, which names the real problem.
case "${ID:-}" in
    debian|ubuntu|linuxmint|pop)
        PM=apt ;;
    rhel|centos|fedora|rocky|almalinux|ol)
        command -v dnf >/dev/null 2>&1 && PM=dnf || PM=yum ;;
    *)
        die "Unsupported distro '${ID:-unknown}'. Only apt- and dnf/yum-based systems supported." ;;
esac

# Package managers get their own stdin: apt/dpkg read and discard whatever is
# queued on the terminal, which swallowed the operator's type-ahead (and made
# the installer impossible to drive from a script on apt-based distros).
pm_update()  { case "$PM" in apt) apt-get update -qq </dev/null ;; dnf) dnf makecache -q </dev/null ;; yum) yum makecache -q </dev/null ;; esac; }
pm_check()   { case "$PM" in apt) apt-cache show "$1" >/dev/null 2>&1 ;; dnf) dnf info "$1" >/dev/null 2>&1 ;; yum) yum info "$1" >/dev/null 2>&1 ;; esac; }
pm_install() { case "$PM" in apt) apt-get install -y "$@" </dev/null ;; dnf) dnf install -y "$@" </dev/null ;; yum) yum install -y "$@" </dev/null ;; esac; }
pkg_list()   { case "$PM" in apt) dpkg -L "$1" 2>/dev/null ;; dnf|yum) rpm -ql "$1" 2>/dev/null ;; esac; }
pkg_version(){ case "$PM" in apt) dpkg-query -W -f='${Version}' "$1" 2>/dev/null ;; dnf|yum) rpm -q --qf '%{VERSION}-%{RELEASE}' "$1" 2>/dev/null ;; esac; }
pm_remove()  { case "$PM" in apt) apt-get remove -y "$@" </dev/null ;; dnf) dnf remove -y "$@" </dev/null ;; yum) yum remove -y "$@" </dev/null ;; esac; }
# The pkcs11 packages get purged, not removed: the Primus package ships
# /etc/primus/primus.cfg and .secrets.cfg as conffiles. A plain remove leaves
# dpkg holding records for files this script has deleted, and dpkg then refuses
# to restore them on the next install — the vendor postinst chowns a missing
# file and the package ends up unconfigured. Purging clears those records.
# openssl is only removed, never purged, so /etc/ssl/openssl.cnf survives.
# (rpm has no purge; removing a package already drops its %config files.)
pm_purge()   { case "$PM" in apt) apt-get purge -y "$@" </dev/null ;; dnf) dnf remove -y "$@" </dev/null ;; yum) yum remove -y "$@" </dev/null ;; esac; }
pkg_owner()  { case "$PM" in apt) dpkg -S "$1" 2>/dev/null | head -1 | cut -d: -f1 ;; dnf|yum) rpm -qf "$1" 2>/dev/null ;; esac; }

# ── paths ─────────────────────────────────────────────────────────────────────
# Fixed by the Primus package and by the driver itself, which looks for its
# config at /etc/primus/primus.cfg. Overridable here only for a relocated or
# hand-unpacked install — the driver must be told about any change too.
PRIMUS_PREFIX="${PRIMUS_PREFIX:-/usr/local/primus}"
HSM_DRIVER="${HSM_DRIVER:-$PRIMUS_PREFIX/lib/libprimusP11.so}"
PPIN="${PPIN:-$PRIMUS_PREFIX/bin/ppin}"
PRIMUS_CFG="${PRIMUS_CFG:-/etc/primus/primus.cfg}"
PROFILE_SCRIPT="${PROFILE_SCRIPT:-/etc/profile.d/primus.sh}"

# Where the PATH script has to be hooked in for an interactive NON-login shell, which is the
# only kind that reads neither /etc/profile nor the profile.d loop it runs. Debian and Ubuntu
# read /etc/bash.bashrc for those and never source /etc/profile.d from it, so the hook is
# needed. The RHEL family has no /etc/bash.bashrc at all - its /etc/bashrc walks
# /etc/profile.d/*.sh itself - so it needs no hook, and the old default made the installer
# create a file that nothing on the machine ever reads. Empty means "no hook needed here".
# Measured on debian 12, debian 13, ubuntu 24.04, rockylinux 9, almalinux 10, centos stream10
# and fedora: the two files never coexist. An explicit SYSTEM_BASHRC still wins either way.
case "$PM" in
    apt)     SYSTEM_BASHRC="${SYSTEM_BASHRC-/etc/bash.bashrc}" ;;
    dnf|yum) SYSTEM_BASHRC="${SYSTEM_BASHRC-}" ;;
esac

# Opening line of the block this script appends to openssl.cnf. Both the
# reconfigure and the removal path delete from this marker to end of file.
CNF_MARKER='# ── Securosys Primus pkcs11-provider'

# The openssl.cnf sections this script owns: the ones install-openssl clears out, and
# the ones configure-openssl counts afterwards to confirm it left exactly one of each.
# One list, because two of them drifting apart is how a section gets written and never
# dropped again. The check already read this name; nothing ever assigned it, so the
# loop ran zero times and the notes around it still read as a pass (Redmine #9841).
CNF_VERIFY_SECTIONS="provider_sect default_sect pkcs11_sect"

# ── section headers ──────────────────────────────────────────────────────────
# [foo] and [ foo ] are the same section to OpenSSL, and openssl.cnf uses both spellings
# freely - the stock Debian file writes most of its own headers spaced ([ ca ], [ req ],
# [ v3_ca ]) and a few tight ([openssl_init]). Every place that looks for a section goes
# through this one pattern, because four hand-written copies of it had already drifted:
# two matched both spellings and two matched only the tight one, so the same file could be
# read as having a section and as not having it depending on which function asked.
cnf_sect_re() { echo "^[[:space:]]*\\[[[:space:]]*$1[[:space:]]*\\][[:space:]]*\$"; }

# Short hostname without hostname(1), which is absent from minimal Fedora/RHEL
# and container images (its failure used to abort the installer mid-prompt).
short_hostname() {
    local h="${HOSTNAME:-}"
    [ -n "$h" ] || h="$(uname -n 2>/dev/null)"
    echo "${h%%.*}"
}

# ── primus.cfg helpers ────────────────────────────────────────────────────────
# Unique partition ($1 = user_name) or proxy ($1 = proxy_user) names referenced
# by the current primus.cfg. Prints nothing when there is no config.
# primus.cfg with every comment removed. libconfig allows /* ... */ blocks, and
# the vendor template ships whole example HSM blocks commented out that way —
# reading an "id =" or "user_name =" out of one of those would configure the
# wrong slot or purge the wrong partition, so strip them before matching.
# The trailing '|| true' matters: with `set -e -o pipefail` a grep that matches
# nothing would otherwise abort the whole script at the caller's assignment.
cfg_live() {
    [ -f "$PRIMUS_CFG" ] || return 0
    # Comment depth is counted rather than closed on the first '*/'. The vendor
    # template wraps a whole example section in /* ... */ and puts inline
    # /*--- HSM0 ... */ labels inside it; closing on the first '*/' would make
    # the rest of that example look live, and we would read a slot id or a
    # partition name out of config the operator meant to keep commented.
    awk 'BEGIN{d=0}
         { s=$0; out=""
           while (length(s)>0) {
             o=index(s,"/*"); c=index(s,"*/")
             if (d>0) {
               if (o>0 && o<c) { d++; s=substr(s,o+2) }
               else if (c>0)   { d--; s=substr(s,c+2) }
               else            { s="" }
             } else {
               if (o==0) { out=out s; s="" }
               else      { out=out substr(s,1,o-1); s=substr(s,o+2); d++ }
             }
           }
           print out }' "$PRIMUS_CFG" \
      | sed 's;//.*;;' | grep -v '^[[:space:]]*#' || true
}

# sed -n ...p so only lines that really match are printed — the vendor template
# aligns some values with extra spaces ('user_name  = "X";'), which a plain
# substitution would pass through verbatim as a bogus "name".
cfg_names() {
    cfg_live | sed -n "s/.*$1[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" | sort -u || true
}

# First PKCS#11 slot id declared in primus.cfg — the "id = N;" of slot0. Lets
# configure-openssl emit pkcs11-module-slot-id when it runs on its own, instead
# of depending on SLOT_IDS being set by configure-pkcs11 in the same process.
# The '^[[:space:]]*id' anchor is what keeps it from matching client_id.
cfg_slot_id() {
    cfg_live | grep -m1 '^[[:space:]]*id[[:space:]]*=' \
        | sed 's/.*=[[:space:]]*\([0-9][0-9]*\).*/\1/' || true
}

# Drop the stored ppin secrets for everything the current primus.cfg references.
purge_ppin_secrets() {
    [ -f "$PRIMUS_CFG" ] || return 0
    if [ ! -x "$PPIN" ]; then
        warn "ppin not found at $PPIN — partition secrets left in place."
        return 0
    fi
    info "Removing ppin secrets for existing partitions..."
    local NAME
    for NAME in $(cfg_names user_name); do
        info "  ppin -r -e $NAME"
        "$PPIN" -r -e "$NAME" || true
    done
    for NAME in $(cfg_names proxy_user); do
        info "  ppin -x -e $NAME"
        "$PPIN" -x -e "$NAME" || true
    done
}

# Remove every provider-related section from the config, so the installer can
# write a complete, single set of its own. Used by the openssl/pkcs11-provider
# install path, which owns this configuration; the file is backed up first and
# [openssl_init] (with its "providers =" pointer) is kept or recreated.
purge_provider_config() {
    local f="$1" bak
    bak="${f}.bak-$(date '+%Y%m%d%H%M%S')"
    cp -p "$f" "$bak" || die "Could not back up $f"
    # Set but never read: the backup this records is what a failed or reverted run would restore
    # from, and nothing restores yet. Redmine #9832 decides whether it becomes a restore path or
    # goes away; until then the name is the only record that the backup was taken deliberately.
    # shellcheck disable=SC2034
    CNF_BACKUP="$bak"
    note "Backed up $f to $bak"

    # our own block first, then any section named below, wherever it appears
    sed -i "/^${CNF_MARKER}/,\$d" "$f"
    # NB: openssl.cnf writes headers both as [foo] and as [ foo ] — the stock
    # Debian file uses the spaced form ([ ca ], [ CA_default ], ...). Matching
    # only the tight form made this delete everything from the first dropped
    # section up to the next tight header, i.e. most of the file.
    awk -v secs="$CNF_VERIFY_SECTIONS" '
      BEGIN { n=split(secs,a," "); for (i=1;i<=n;i++) want[a[i]]=1 }
      /^[[:space:]]*\[[[:space:]]*[A-Za-z0-9_.-]+[[:space:]]*\][[:space:]]*$/ {
          name=$0; gsub(/[][[:space:]]/,"",name)
          drop = (name in want); if (!drop) print
          next
      }
      !drop { print }
    ' "$f" > "${f}.tmp" && cat "${f}.tmp" > "$f" && rm -f "${f}.tmp"

    # The pointer must not outlive the section: with config_diagnostics = 1 (the
    # Debian default) a "providers = provider_sect" naming a section that no
    # longer exists makes EVERY openssl invocation fail, including the one
    # configure-openssl uses to find this file. Comment it out; configure-openssl
    # un-comments it again when it writes the sections back.
    sed -i 's/^[[:space:]]*providers[[:space:]]*=[[:space:]]*provider_sect[[:space:]]*$/# providers = provider_sect/' "$f"

    if ! grep -qE "$(cnf_sect_re openssl_init)" "$f"; then
        printf 'openssl_conf = openssl_init\n\n[openssl_init]\n# providers = provider_sect\n\n' \
            | cat - "$f" > "${f}.tmp" && cat "${f}.tmp" > "$f" && rm -f "${f}.tmp"
        note "Added [openssl_init] with providers = provider_sect"
    fi
    note "Removed the previous provider configuration from $f"
}

# Add "key = value" inside an existing section, so the distro's own
# [provider_sect] / [default_sect] are extended rather than re-declared. Stock
# openssl.cnf already ships both; appending our own copies made OpenSSL merge
# same-named sections, which works but leaves each of them defined twice.
# Returns 1 when the section does not exist, so the caller emits it instead.
#
# Each line written here records WHICH of the two things happened to it, because removal has to
# reverse them differently: a line this script INSERTED is deleted, a line it OVERWROTE is put
# back to what it said. A single tag for both cannot express that, and deletes the operator's
# own entry along with ours.
# The original text runs from CNF_TAG_MOD to end of line with no closing delimiter, so an
# original containing brackets or parentheses cannot truncate what is restored.
CNF_TAG="# added by install-pkcs11.sh"
CNF_TAG_MOD="# install-pkcs11.sh replaced: "

ensure_in_section() {
    local f="$1" sect="$2" key="$3" val="$4"
    grep -qE "$(cnf_sect_re "$sect")" "$f" || return 1
    awk -v sect="$sect" -v key="$key" -v val="$val" -v tag="$CNF_TAG" -v modtag="$CNF_TAG_MOD" '
      function ends_with(s, t,   n) { n = length(s) - length(t); return (n >= 0 && substr(s, n + 1) == t) }
      function flush_insert() {
          if (!found && pending) { print key " = " val "   " tag; pending=0 }
      }
      # Compare the section NAME, not the spelling of its header: strip the brackets and the
      # padding, then test. This used to match "[" sect "]" tightly, which read the spaced
      # [ provider_sect ] of the stock Debian file as absent and made the caller declare a
      # second one. Any bracketed line still closes the section we are inside.
      /^[[:space:]]*\[/ {
          name=$0
          sub(/^[[:space:]]*\[[[:space:]]*/, "", name)
          sub(/[[:space:]]*\].*$/, "", name)
          if (name == sect) { print; inside=1; pending=1; found=0; next }
          if (inside) { flush_insert(); inside=0 }
          print; next
      }
      inside && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" {
          # An existing entry, commented or not, becomes the active one - and the line records
          # whose it was. Re-running over our own output must not nest: a line already carrying
          # either tag keeps the provenance it has, or a second run would record what the first
          # run wrote as the thing to restore.
          if (ends_with($0, tag)) {
              print key " = " val "   " tag
          } else if (index($0, modtag)) {
              print key " = " val "   " modtag substr($0, index($0, modtag) + length(modtag))
          } else {
              orig = $0
              sub(/^[[:space:]]+/, "", orig); sub(/[[:space:]]+$/, "", orig)
              print key " = " val "   " modtag orig
          }
          found=1; pending=0; next
      }
      { print }
      END { flush_insert() }
    ' "$f" > "${f}.tmp" && cat "${f}.tmp" > "$f" && rm -f "${f}.tmp"
    info "[$sect] ${key} = ${val}"
}

# The stored PIN does not only live in the config itself. purge_provider_config copies
# openssl.cnf to <file>.bak-<timestamp> before rewriting it, so a config that carried a PIN
# leaves that PIN in the copy — at the mode of the original, which is world-readable on every
# distribution in tests/distro-matrix.tsv. Nothing ever deleted those copies, so an operator
# who ran remove-config precisely to get the PIN off the machine was left with one readable
# copy of it per install-openssl run, indefinitely, with nothing naming them as secrets.
#
# The line is replaced rather than the backup deleted. These are the only record of what the
# config looked like before this script touched it, and #9832 may yet turn them into a restore
# path; a backup restored after this still yields a working config, one that prompts for the
# PIN at runtime, which is what removing it asked for.
purge_cnf_backup_pins() {
    local base="$1" bak scrubbed=0
    [ -n "$base" ] || return 0
    for bak in "$base".bak-*; do
        # An unmatched glob comes through literally, hence the -f test rather than a count.
        [ -f "$bak" ] || continue
        grep -qE '^[[:space:]]*pkcs11-module-token-pin[[:space:]]*=' "$bak" || continue
        sed -i "s|^[[:space:]]*pkcs11-module-token-pin[[:space:]]*=.*|# pkcs11-module-token-pin removed by install-pkcs11.sh on $(date '+%Y-%m-%d %H:%M:%S')|" "$bak"
        scrubbed=$((scrubbed + 1))
    done
    if [ "$scrubbed" -gt 0 ]; then
        note "Removed the stored PIN from ${scrubbed} config backup(s) of $base"
    fi
}

# ── config removal (shared by uninstall and remove-config) ───────────────────
# Must run while ppin and openssl are still installed — it needs ppin to drop the
# partition secrets and 'openssl version -d' to locate the config it edited.
remove_config() {
    if [ -f "$PRIMUS_CFG" ]; then
        purge_ppin_secrets
        rm -f "$PRIMUS_CFG"
        note "Removed $PRIMUS_CFG"
    else
        info "No $PRIMUS_CFG found."
    fi

    DEFAULT_CNF=""
    if command -v openssl >/dev/null 2>&1; then
        DEFAULT_CNF=$(OPENSSL_CONF=/dev/null openssl version -d 2>/dev/null | awk -F'"' '{print $2}')/openssl.cnf
    else
        case "$PM" in
            apt)     DEFAULT_CNF=/etc/ssl/openssl.cnf ;;
            dnf|yum) DEFAULT_CNF=/etc/pki/tls/openssl.cnf ;;
        esac
        warn "openssl not found on PATH — guessing config location: $DEFAULT_CNF"
    fi

    DEFAULT_CNF=$(readlink -f "$DEFAULT_CNF" 2>/dev/null || echo "$DEFAULT_CNF")

    if [ -n "$DEFAULT_CNF" ] && [ -f "$DEFAULT_CNF" ]; then
        if grep -qF "$CNF_MARKER" "$DEFAULT_CNF"; then
            sed -i "/^${CNF_MARKER}/,\$d" "$DEFAULT_CNF"
            note "Removed pkcs11-provider block from $DEFAULT_CNF"
        else
            info "No pkcs11-provider block found in $DEFAULT_CNF"
        fi

        # Lines this script added to sections that ALREADY existed sit above the marker, so
        # the strip above never reaches them. configure-openssl run on its own writes exactly
        # those: purge_provider_config is not on that path, so [provider_sect] is the file's
        # own and "pkcs11 = pkcs11_sect" is inserted into it rather than written inside our
        # block. Left behind, that line points at a [pkcs11_sect] the strip just deleted.
        # This is what the tags are for. Each reverses differently: a line we inserted is
        # deleted, a line we overwrote is put back to the original recorded after CNF_TAG_MOD.
        local tagged restored
        tagged=$(grep -c -- "$CNF_TAG"'$' "$DEFAULT_CNF" || true)
        restored=$(grep -cF -- "$CNF_TAG_MOD" "$DEFAULT_CNF" || true)
        if [ "${tagged:-0}" -gt 0 ] || [ "${restored:-0}" -gt 0 ]; then
            awk -v tag="$CNF_TAG" -v modtag="$CNF_TAG_MOD" '
              function ends_with(s, t,   n) { n = length(s) - length(t); return (n >= 0 && substr(s, n + 1) == t) }
              { i = index($0, modtag)
                if (i)                 { print substr($0, i + length(modtag)); next }
                if (ends_with($0, tag)) { next }
                print }
            ' "$DEFAULT_CNF" > "${DEFAULT_CNF}.tmp" \
                && cat "${DEFAULT_CNF}.tmp" > "$DEFAULT_CNF" && rm -f "${DEFAULT_CNF}.tmp"
            if [ "${tagged:-0}" -gt 0 ]; then
                note "Removed ${tagged} line(s) added by this script to pre-existing sections"
            fi
            if [ "${restored:-0}" -gt 0 ]; then
                note "Restored ${restored} line(s) this script had overwritten"
            fi
        fi

        # The pointer must not outlive the section it names: with config_diagnostics = 1 (the
        # Debian default) a "providers = provider_sect" naming a section that is gone makes
        # EVERY openssl invocation on the host fail — including the ones this script and its
        # uninstall path run. purge_provider_config comments it unconditionally because it
        # always deletes the section; here the section may be the file's own and still stand
        # (we only took our own lines back out of it), so the pointer is only retired when
        # nothing is left to point at. configure-openssl un-comments it again when it writes.
        # Both bracket spellings are matched: openssl.cnf uses [foo] and [ foo ] alike.
        if ! grep -qE "$(cnf_sect_re provider_sect)" "$DEFAULT_CNF"; then
            sed -i 's/^[[:space:]]*providers[[:space:]]*=[[:space:]]*provider_sect[[:space:]]*$/# providers = provider_sect/' "$DEFAULT_CNF"
            info "Commented out 'providers = provider_sect' — the section it named is gone"
        fi
    else
        warn "OpenSSL config not found at ${DEFAULT_CNF:-<unknown>} — skipping."
    fi

    # Outside the branch above on purpose: the backups exist independently of the file they
    # were taken from, and a missing openssl.cnf is no reason to leave a PIN readable.
    purge_cnf_backup_pins "$DEFAULT_CNF"
}

# ── uninstall ─────────────────────────────────────────────────────────────────
cmd_uninstall() {
    card "Uninstall" \
        "Removes the configuration (primus.cfg, ppin secrets, the openssl.cnf" \
        "pkcs11 block), purges pkcs11-provider and the Primus PKCS#11 Provider" \
        "package (so their conffiles do not block a later install), removes" \
        "openssl without purging it, and drops the 'primus' group, the PATH" \
        "script and the bashrc hook. /etc/ssl/openssl.cnf is left in place." \
        "" \
        "To remove only the configuration and keep the packages installed," \
        "run '$0 remove-config' instead."

    warn "Removing 'openssl' can affect other software on this system that depends on it."
    printf "${DIM}${RAIL}${NC}  Continue? [y/N]: "
    read -r confirm_uninstall < /dev/tty
    case "$confirm_uninstall" in
        y|Y) ;;
        *) info "Aborted."; exit 0 ;;
    esac

    DRIVER_PKG=""
    if [ -f "$HSM_DRIVER" ]; then
        DRIVER_PKG=$(pkg_owner "$HSM_DRIVER") || true
    fi

    # Config first: ppin and openssl must still be installed for this to work.
    info "Removing configuration..."
    remove_config
    rail

    PKGS_TO_PURGE=(pkcs11-provider)
    [ -n "$DRIVER_PKG" ] && PKGS_TO_PURGE+=("$DRIVER_PKG")

    info "Purging: ${PKGS_TO_PURGE[*]}"
    pm_purge "${PKGS_TO_PURGE[@]}" || warn "Some pkcs11 packages may not have been installed or failed to purge."

    info "Removing: openssl (its own config is kept)"
    pm_remove openssl || warn "openssl may not have been installed or failed to remove."

    if [ -z "$DRIVER_PKG" ] && [ -f "$HSM_DRIVER" ]; then
        warn "Could not determine owning package for $HSM_DRIVER — file left in place."
    fi

    info "Cleaning up system leftovers..."
    if getent group primus >/dev/null 2>&1; then
        groupdel primus 2>/dev/null && note "Removed 'primus' group" \
            || warn "Could not remove 'primus' group (members may still be assigned)"
    fi

    if [ -f "$PROFILE_SCRIPT" ]; then
        rm -f "$PROFILE_SCRIPT"
        note "Removed $PROFILE_SCRIPT"
    fi

    # /etc/bash.bashrc is checked even where this script would no longer write it: until this
    # release it was appended to on every distro, so an RHEL-family machine installed with an
    # earlier copy carries the hook in a file that was created for it and read by nothing.
    # Uninstall is the only chance to take that back.
    # On apt SYSTEM_BASHRC *is* /etc/bash.bashrc, so the two entries name one file. The body is
    # idempotent, so visiting it twice is harmless today - but the loop reads as though it covers
    # two distinct files, and any future body that is not idempotent would run twice on every
    # apt host.
    SEEN_BASHRC=""
    for BASHRC in "$SYSTEM_BASHRC" /etc/bash.bashrc; do
        [ -n "$BASHRC" ] && [ -f "$BASHRC" ] || continue
        case " $SEEN_BASHRC " in *" $BASHRC "*) continue ;; esac
        SEEN_BASHRC="$SEEN_BASHRC $BASHRC"
        grep -qF "$PROFILE_SCRIPT" "$BASHRC" 2>/dev/null || continue
        sed -i "\|$PROFILE_SCRIPT|d" "$BASHRC"
        note "Removed PATH hook from $BASHRC"
    done

    rail
    note "Uninstall complete."
}

# ── remove-config ─────────────────────────────────────────────────────────────
cmd_remove_config() {
    card "Remove config" \
        "Removes /etc/primus/primus.cfg (and its ppin secrets) and the" \
        "pkcs11-provider block from the default OpenSSL config." \
        "" \
        "Packages are left untouched — run '$0 uninstall' for a full removal."

    printf "${DIM}${RAIL}${NC}  Continue? [y/N]: "
    read -r confirm_cfg < /dev/tty
    case "$confirm_cfg" in
        y|Y) ;;
        *) info "Aborted."; exit 0 ;;
    esac

    remove_config

    rail
    note "Config removal complete."
}

# ── install-pkcs11 ────────────────────────────────────────────────────────────
cmd_install_pkcs11() {
    step "Primus PKCS#11 Provider"

    INSTALL_DRIVER=1
    if [ -f "$HSM_DRIVER" ]; then
        note "Primus PKCS#11 Provider already installed at $HSM_DRIVER"
        printf "${DIM}${RAIL}${NC}  Reinstall/override? [y/N]: "
        read -r reinstall_driver < /dev/tty
        case "$reinstall_driver" in y|Y) ;; *) INSTALL_DRIVER=0 ;; esac
    fi

    if [ "$INSTALL_DRIVER" -eq 1 ]; then
        PRIMUS_PKG=""
        FOUND_PKGS=()
        for f in "$SCRIPT_DIR"/*.deb "$SCRIPT_DIR"/*.rpm; do
            [ -f "$f" ] && FOUND_PKGS+=("$f")
        done

        if [ "${#FOUND_PKGS[@]}" -eq 1 ]; then
            PRIMUS_PKG="${FOUND_PKGS[0]}"
            info "Found package: $PRIMUS_PKG"
        elif [ "${#FOUND_PKGS[@]}" -gt 1 ]; then
            info "Multiple packages found in $SCRIPT_DIR:"
            for i in "${!FOUND_PKGS[@]}"; do
                printf "${DIM}${RAIL}${NC}    %d) %s\n" "$((i+1))" "${FOUND_PKGS[$i]}"
            done
            while true; do
                printf "${DIM}${RAIL}${NC}  Select package [1-%d]: " "${#FOUND_PKGS[@]}"
                read -r sel < /dev/tty
                [[ "$sel" =~ ^[1-9][0-9]*$ ]] && [ "$sel" -le "${#FOUND_PKGS[@]}" ] && \
                    { PRIMUS_PKG="${FOUND_PKGS[$((sel-1))]}"; break; }
                warn "Invalid selection."
            done
        fi

        if [ -z "$PRIMUS_PKG" ]; then
            while true; do
                printf "${DIM}${RAIL}${NC}  Path to Primus PKCS#11 package (.deb or .rpm): "
                read -r PRIMUS_PKG < /dev/tty
                [ -f "$PRIMUS_PKG" ] && break
                warn "File not found: $PRIMUS_PKG"
            done
        fi

        info "Installing Primus PKCS#11 Provider..."
        pm_install "$PRIMUS_PKG"
        [ -f "$HSM_DRIVER" ] || die "Primus PKCS#11 Provider not found at $HSM_DRIVER after install."
        note "Primus PKCS#11 Provider installed: $HSM_DRIVER"
    fi

    printf 'export PATH="%s/bin:$PATH"\n' "$PRIMUS_PREFIX" > "$PROFILE_SCRIPT"
    chmod 644 "$PROFILE_SCRIPT"
    if [ -n "$SYSTEM_BASHRC" ]; then
        grep -qF "$PROFILE_SCRIPT" "$SYSTEM_BASHRC" 2>/dev/null || \
            printf '\n[ -f %s ] && . %s\n' "$PROFILE_SCRIPT" "$PROFILE_SCRIPT" >> "$SYSTEM_BASHRC"
    fi
    note "Added $PRIMUS_PREFIX/bin to PATH"

    step "System user"
    PRIMUS_USER="${SUDO_USER:-}"
    if [ -n "$PRIMUS_USER" ]; then
        getent group primus >/dev/null 2>&1 || die "primus group not found — Primus package may not have installed correctly."
        usermod -a -G primus "$PRIMUS_USER"
        note "Added $PRIMUS_USER to primus group"
        info "Group membership takes effect after re-login."
        info "Temporary workaround (current shell only): newgrp primus"
        info "  or: sudo login -f $PRIMUS_USER"
    else
        info "Running as root — skipping primus group assignment."
        info "To grant access to a user, run: usermod -a -G primus <username>"
    fi
}

# ── install-openssl ──────────────────────────────────────────────────────────
cmd_install_openssl() {
    step "OpenSSL + pkcs11-provider"
    info "Updating package lists..."
    pm_update

    if ! pm_check pkcs11-provider; then
        die "pkcs11-provider is not available in the configured repositories. Add the appropriate repository and re-run."
    fi

    info "Installing openssl and pkcs11-provider..."
    pm_install openssl pkcs11-provider
    note "openssl $(OPENSSL_CONF=/dev/null openssl version | awk '{print $2}')"
    note "pkcs11-provider $(pkg_version pkcs11-provider)"

    # Installing the packages means owning their configuration, so any previous
    # provider config is removed here. Nothing is written: configure-openssl is
    # the only command that writes a config.
    DEFAULT_CNF=$(OPENSSL_CONF=/dev/null openssl version -d 2>/dev/null | awk -F'"' '{print $2}')/openssl.cnf
    DEFAULT_CNF=$(readlink -f "$DEFAULT_CNF" 2>/dev/null || echo "$DEFAULT_CNF")
    if [ -f "$DEFAULT_CNF" ]; then
        purge_provider_config "$DEFAULT_CNF"
    else
        warn "No OpenSSL config at $DEFAULT_CNF — nothing to clear."
    fi
    info "Run '$0 configure-openssl' to write the provider configuration."
}

# ── configure-openssl ─────────────────────────────────────────────────────────
# Writes the provider block in the system openssl.cnf. That single block config-
# ures both OpenSSL (which providers to load) and the pkcs11-provider itself
# (the pkcs11-module-* keys). Needs openssl + pkcs11-provider already installed.
cmd_configure_openssl() {
    step "OpenSSL configuration"

    command -v openssl >/dev/null 2>&1 || die "openssl not found on PATH — run '$0 install-openssl' first."

    if [ -z "${P11_SO:-}" ]; then
        P11_SO=$(pkg_list pkcs11-provider | grep 'pkcs11\.so$' | head -1) || true
    fi
    [ -z "${P11_SO:-}" ] && die "pkcs11.so not found in pkcs11-provider package files — run '$0 install-openssl' first."
    [ -f "$P11_SO" ] || die "pkcs11 module listed at $P11_SO but missing on disk."
    note "pkcs11 module: $P11_SO"

    [ -f "$HSM_DRIVER" ] || \
        warn "Primus PKCS#11 Provider not found at $HSM_DRIVER — the provider will fail to load until it is installed."

    DEFAULT_CNF=$(OPENSSL_CONF=/dev/null openssl version -d 2>/dev/null | awk -F'"' '{print $2}')/openssl.cnf
    [ -f "$DEFAULT_CNF" ] || die "Default OpenSSL config not found at $DEFAULT_CNF"
    # On Debian/Ubuntu OPENSSLDIR is /usr/lib/ssl and openssl.cnf there is a
    # symlink to /etc/ssl/openssl.cnf. sed -i replaces a symlink with a regular
    # file, which would silently fork the config in two, so edit the real path.
    DEFAULT_CNF=$(readlink -f "$DEFAULT_CNF" 2>/dev/null || echo "$DEFAULT_CNF")

    # The slot to pin comes from the driver config; without it the provider
    # block would be incomplete, so this is fatal rather than a silent omission.
    SLOT_ID="${SLOT_IDS[0]:-$(cfg_slot_id)}"
    if [ -z "$SLOT_ID" ]; then
        [ -f "$PRIMUS_CFG" ] \
            && die "No slot id found in $PRIMUS_CFG — re-run '$0 configure-pkcs11'." \
            || die "$PRIMUS_CFG not found — run '$0 configure-pkcs11' first to define the slot."
    fi
    info "Slot id: $SLOT_ID"

    # Validate before touching anything. A [pkcs11_sect] that sits BEFORE our
    # marker (or exists with no marker at all) was written by something else,
    # and appending a second one would not override it cleanly: OpenSSL merges
    # same-named sections and the LAST duplicate of a key wins, so the two
    # blocks silently fight — a stale module path in one can beat a working one
    # in the other. Bail out here, while the file is still intact: this check
    # used to run after the block strip, which deleted our own working block
    # and only then aborted.
    MARKER_LINE=$(grep -nF "$CNF_MARKER" "$DEFAULT_CNF" | head -1 | cut -d: -f1) || true
    FOREIGN=$(grep -nE "$(cnf_sect_re pkcs11_sect)" \
                   "$DEFAULT_CNF" | head -1 | cut -d: -f1) || true
    if [ -n "${FOREIGN:-}" ] && { [ -z "${MARKER_LINE:-}" ] || [ "$FOREIGN" -lt "$MARKER_LINE" ]; }; then
        warn "$DEFAULT_CNF already declares [pkcs11_sect] at line $FOREIGN, and it"
        warn "was not written by this script (not under the '${CNF_MARKER}' marker)."
        warn "Nothing has been changed."
        die  "Remove that block by hand, or run '$0 install-openssl' (or '$0 install') to replace it."
    fi


    PIN_VALUE=""

    if grep -qF "$CNF_MARKER" "$DEFAULT_CNF" 2>/dev/null; then
        info "Existing pkcs11-provider config found in $DEFAULT_CNF — reconfiguring."
        sed -i "/^${CNF_MARKER}/,\$d" "$DEFAULT_CNF"
    fi

    printf "${DIM}${RAIL}${NC}  Store PIN in OpenSSL config? Avoids the runtime PIN prompt [y/N]: "
    read -r store_pin < /dev/tty
    case "$store_pin" in
        y|Y)
            warn "PIN will be stored in plaintext in $DEFAULT_CNF."
            while true; do
                printf "${DIM}${RAIL}${NC}  PIN (will not echo): "
                read_secret PIN_VALUE
                [ -z "$PIN_VALUE" ] && { warn "PIN cannot be empty."; continue; }
                printf "${DIM}${RAIL}${NC}  Confirm PIN: "
                read_secret PIN_CONFIRM
                [ "$PIN_VALUE" = "$PIN_CONFIRM" ] && break
                warn "PINs do not match."
            done
            ;;
        *)
            info "PIN not stored — OpenSSL will prompt at runtime."
            ;;
    esac


    sed -i 's/^[[:space:]]*#[[:space:]]*providers = provider_sect/providers = provider_sect/' "$DEFAULT_CNF"

    grep -q '^[[:space:]]*providers[[:space:]]*=[[:space:]]*provider_sect' "$DEFAULT_CNF" || \
        warn "'providers = provider_sect' not found active in $DEFAULT_CNF — providers may not load."

    # Extend the sections the file already has; only declare the missing ones.
    NEED_PROVIDER_SECT=0; NEED_DEFAULT_SECT=0
    if ensure_in_section "$DEFAULT_CNF" provider_sect default default_sect; then
        ensure_in_section "$DEFAULT_CNF" provider_sect pkcs11 pkcs11_sect
    else
        NEED_PROVIDER_SECT=1
    fi
    ensure_in_section "$DEFAULT_CNF" default_sect activate 1 || NEED_DEFAULT_SECT=1

    info "Updating $DEFAULT_CNF..."

    # One emitter for the whole block: every key is padded to the same column,
    # and an optional key that has no value is simply not written — no blank
    # line where an unset variable used to expand.
    kv() { printf '%-40s = %s\n' "$1" "$2"; }

    {
        printf '\n%s ─────────────────────────────────────────\n' "$CNF_MARKER"
        printf '# Added by install-pkcs11.sh on %s\n\n' "$(date '+%Y-%m-%d %H:%M:%S')"

        if [ "${NEED_PROVIDER_SECT:-0}" = "1" ]; then
            printf '[provider_sect]\n'
            kv default default_sect
            kv pkcs11  pkcs11_sect
        fi

        if [ "${NEED_DEFAULT_SECT:-0}" = "1" ]; then
            printf '\n[default_sect]\n'
            kv activate 1
        fi

        printf '\n[pkcs11_sect]\n'
        kv module             "$P11_SO"
        kv pkcs11-module-path "$HSM_DRIVER"
        [ -n "$PIN_VALUE" ] && kv pkcs11-module-token-pin "$PIN_VALUE"
        kv pkcs11-module-encode-provider-uri-to-pem true
        [ -n "$SLOT_ID" ]   && kv pkcs11-module-slot-id "$SLOT_ID"
        # On by default: the Primus driver's atexit handler tears itself down
        # before libcrypto's provider teardown calls back into it, so the CLI
        # segfaults (exit 139) after the work is done, masking real exit codes.
        # Skipping finalization avoids it and is harmless for one-shot CLI use.
        kv pkcs11-module-quirks no-deinit
        kv activate 1
    } >> "$DEFAULT_CNF"

    note "Configuration written to $DEFAULT_CNF"

    # Structural checks only — this command never invokes openssl. Loading the
    # provider here would initialise the PKCS#11 module and reach the HSM,
    # which an install step has no business doing.
    #
    # [foo] and [ foo ] are the same section to OpenSSL, and the stock Debian file uses
    # the spaced form for its own headers. Counting only the tight one would report a
    # duplicate as "exactly once" — the reassuring half of a check that is not checking.
    local sect n
    for sect in $CNF_VERIFY_SECTIONS; do
        n=$(grep -cE "$(cnf_sect_re "$sect")" "$DEFAULT_CNF" || true)
        if [ "$n" -eq 1 ]; then
            info "[$sect] appears exactly once"
        elif [ "$n" -eq 0 ]; then
            warn "[$sect] is missing from $DEFAULT_CNF — the provider will not load."
        else
            warn "[$sect] appears $n times in $DEFAULT_CNF — expected exactly once."
            warn "  they are merged and the last value of a key wins: a stale entry can beat a good one."
        fi
    done
    info "Not verified here: loading the provider would contact the HSM."
    info "Check it yourself with: openssl list -providers"
}

# ── configure-pkcs11 ──────────────────────────────────────────────────────────
# Writes /etc/primus/primus.cfg — the config of the Primus PKCS#11 driver — and
# stores its partition secrets through ppin. Nothing here touches OpenSSL.
cmd_configure_pkcs11() {
    step "Primus PKCS#11 Provider configuration"

    clean_primus_cfg() {
        purge_ppin_secrets
        rm -f "$PRIMUS_CFG"
        info "Removed $PRIMUS_CFG"
    }

    if [ "${CONFIGURE_NOASK:-}" = "1" ]; then
        conf_primus="y"
    else
        printf "${DIM}${RAIL}${NC}  Configure Primus (primus.cfg, secrets)? [Y/n]: "
        read -r conf_primus < /dev/tty
    fi
    case "$conf_primus" in n|N) info "Skipping Primus HSM configuration."; SKIP_CFG_WRITE=1 ;; esac

    if [ -z "${SKIP_CFG_WRITE:-}" ]; then
        if [ -f "$PRIMUS_CFG" ]; then
            info "Existing primus.cfg found — reconfiguring."
            clean_primus_cfg
        fi

        printf "${DIM}${RAIL}${NC}  Number of HSMs (default: 1): "
        read -r HSM_COUNT < /dev/tty; HSM_COUNT="${HSM_COUNT:-1}"
        [[ "$HSM_COUNT" =~ ^[1-9][0-9]*$ ]] || die "HSM count must be a positive integer."

        HSM_HOSTS=(); HSM_PORTS=()
        for i in $(seq 0 $((HSM_COUNT-1))); do
            printf "${DIM}${RAIL}${NC}  HSM $((i+1)) hostname or IP: "
            read -r HSM_HOSTS[$i] < /dev/tty
            [ -z "${HSM_HOSTS[$i]}" ] && die "HSM hostname is required."
            printf "${DIM}${RAIL}${NC}  HSM $((i+1)) port (default: 2310): "
            read -r p < /dev/tty; HSM_PORTS[$i]="${p:-2310}"
        done

        printf "${DIM}${RAIL}${NC}  Number of slots (default: 1): "
        read -r SLOT_COUNT < /dev/tty; SLOT_COUNT="${SLOT_COUNT:-1}"
        [[ "$SLOT_COUNT" =~ ^[1-9][0-9]*$ ]] || die "Slot count must be a positive integer."

        printf "${DIM}${RAIL}${NC}  Use proxy (CloudHSM)? [y/N]: "
        read -r use_proxy < /dev/tty
        case "$use_proxy" in y|Y) USE_PROXY=1 ;; *) USE_PROXY=0 ;; esac

        SLOT_PARTITIONS=(); SLOT_CLIENT_IDS=(); SLOT_IDS=(); SLOT_PROXY_USERS=()
        for j in $(seq 0 $((SLOT_COUNT-1))); do
            rail
            info "Slot $((j+1)):"
            printf "${DIM}${RAIL}${NC}    Partition name (user_name): "
            read -r SLOT_PARTITIONS[$j] < /dev/tty
            [ -z "${SLOT_PARTITIONS[$j]}" ] && die "Partition name is required."
            DEFAULT_CLIENT_ID="$(short_hostname)"
            printf "${DIM}${RAIL}${NC}    Client ID, to identify this machine in HSM request logs (default: ${DEFAULT_CLIENT_ID}): "
            read -r c < /dev/tty; SLOT_CLIENT_IDS[$j]="${c:-$DEFAULT_CLIENT_ID}"
            # The only value in this interview that reaches primus.cfg unquoted: it is
            # written as `id = %s;`, where libconfig wants an integer. A typo does not fail
            # here - it fails later, and badly. cfg_slot_id reads this same field back with
            # a substitution that is not anchored to a match, so a non-numeric id makes it
            # print the whole config line: `id = abc;` comes back as "          id        =
            # abc;", and standalone configure-openssl writes that into openssl.cnf as the
            # value of pkcs11-module-slot-id. "1x" silently becomes 1, and a leading zero
            # is carried through to a slot the driver may never report. Re-asked rather
            # than fatal: this sits at the bottom of a long interview, and a die here
            # throws away every answer already given.
            while true; do
                printf "${DIM}${RAIL}${NC}    PKCS#11 slot ID (default: $j): "
                read -r s < /dev/tty; s="${s:-$j}"
                [[ "$s" =~ ^(0|[1-9][0-9]*)$ ]] && { SLOT_IDS[$j]="$s"; break; }
                warn "Slot ID must be a non-negative integer without leading zeros."
            done
            if [ "$USE_PROXY" -eq 1 ]; then
                printf "${DIM}${RAIL}${NC}    Proxy user: "
                read -r SLOT_PROXY_USERS[$j] < /dev/tty
                [ -z "${SLOT_PROXY_USERS[$j]}" ] && die "Proxy user is required when proxy is enabled."
            fi
        done

        mkdir -p "$(dirname "$PRIMUS_CFG")"
        {
            printf 'version = "1.0";\n'
            printf 'primus: {\n'
            printf '  connect_on_init = true;\n'
            printf '  wait_delay      = 250;\n'
            printf '  wait_max_tries  = 5;\n\n'
            printf '  hsms: {\n'
            for i in $(seq 0 $((HSM_COUNT-1))); do
                printf '    hsm%d: {\n' "$i"
                printf '      host     = "%s";\n' "${HSM_HOSTS[$i]}"
                printf '      port     = "%s";\n' "${HSM_PORTS[$i]}"
                printf '      priority = %d;\n' "$i"
                printf '      slots: {\n'
                for j in $(seq 0 $((SLOT_COUNT-1))); do
                    printf '        slot%d: {\n' "$j"
                    printf '          client_id = "%s";\n' "${SLOT_CLIENT_IDS[$j]}"
                    printf '          user_name = "%s";\n' "${SLOT_PARTITIONS[$j]}"
                    printf '          id        = %s;\n'   "${SLOT_IDS[$j]}"
                    if [ "$USE_PROXY" -eq 1 ]; then
                        printf '          proxy_user = "%s";\n' "${SLOT_PROXY_USERS[$j]}"
                    fi
                    printf '        };\n'
                done
                printf '      };\n'
                printf '    };\n'
            done
            printf '  };\n\n'
            printf '  log: {\n'
            printf '    write_log_file = false;\n'
            printf '    write_syslog   = true;\n'
            printf '    trace_level    = 4;\n'
            printf '    trace_mask     = 0x00;\n'
            printf '  };\n'
            printf '};\n'
        } > "$PRIMUS_CFG"
        note "Written $PRIMUS_CFG"
    fi

    # ── ppin ──────────────────────────────────────────────────────────────────
    if [ -z "${SKIP_CFG_WRITE:-}" ] && [ -x "$PPIN" ]; then
        PARTITIONS=$(cfg_names user_name)
        for PART in $PARTITIONS; do
            printf "${DIM}${RAIL}${NC}  Configure secret for '%s'? [Y/n]: " "$PART"
            read -r ppin_yn < /dev/tty
            case "$ppin_yn" in n|N) info "Skipping '$PART'."; continue ;; esac
            info "Running: ppin -a -e $PART"
            rail
            "$PPIN" -a -e "$PART"
            note "ppin completed for '$PART'"
        done

        if grep -q 'proxy_user' "$PRIMUS_CFG" 2>/dev/null; then
            rail
            info "Configuring proxy passwords..."
            PROXY_USERS=$(cfg_names proxy_user)
            for PROXY in $PROXY_USERS; do
                printf "${DIM}${RAIL}${NC}  Configure proxy password for '%s'? [Y/n]: " "$PROXY"
                read -r ppin_yn < /dev/tty
                case "$ppin_yn" in n|N) info "Skipping proxy '$PROXY'."; continue ;; esac
                info "Running: ppin -p -e $PROXY"
                rail
                "$PPIN" -p -e "$PROXY"
                note "ppin proxy completed for '$PROXY'"
            done
        fi
    elif [ -z "${SKIP_CFG_WRITE:-}" ]; then
        die "ppin not found at $PPIN — run '$0 install-pkcs11' first."
    fi
}

# ── install (orchestrator) ────────────────────────────────────────────────────
# Installs and configures everything, unconditionally. Use the individual
# subcommands (install-pkcs11 / install-openssl / configure-*) for a partial
# install — this one asks nothing about scope.
cmd_install() {
    card "Securosys Primus PKCS#11 Installer" \
        "Installs and configures the Primus PKCS#11 Provider," \
        "OpenSSL, and pkcs11-provider on ${PRETTY_NAME:-this system}." \
        "" \
        "Installing for user : ${INSTALL_USER}" \
        "NOTE: '${INSTALL_USER}' will be added to the 'primus' group" \
        "      and authorized as a ppin user." \
        "" \
        "For a partial install use: $0 --help"

    cmd_install_pkcs11

    CONFIGURE_NOASK=1
    cmd_configure_pkcs11
    cmd_install_openssl
    cmd_configure_openssl

    step "Smoke test"
    info "Running: openssl list -providers  (first connection may take up to 30s)"
    [ -z "${PIN_VALUE:-}" ] && info "PIN not stored — OpenSSL may prompt for a PIN here."

    RESULT=$(openssl list -providers 2>&1) || true
    printf '%s\n' "$RESULT"

    DEFAULT_OK=0; PKCS11_OK=0
    if echo "$RESULT" | grep -q "name: OpenSSL Default Provider"; then DEFAULT_OK=1; fi
    if awk '/pkcs11/{f=1} f && /status:/{if (/active/) found=1; f=0} END{exit !found}' <<< "$RESULT"; then
        PKCS11_OK=1
    fi

    rail
    PASS=0; FAIL=0
    _row() {
        if [ "$2" -eq 1 ]; then
            printf "${DIM}${RAIL}${NC}  ${GREEN}${GLYPH_OK}${NC} %s\n" "$1"; PASS=$((PASS+1))
        else
            printf "${DIM}${RAIL}${NC}  ${RED}${GLYPH_ERR}${NC} %s\n" "$1"; FAIL=$((FAIL+1))
        fi
    }
    _row "default provider active" "$DEFAULT_OK"
    _row "pkcs11 provider active"  "$PKCS11_OK"

    SUMMARY_LINES=()
    SUMMARY_LINES+=("Primus PKCS#11 Provider :  $HSM_DRIVER")
    [ -f "$PRIMUS_CFG" ]      && SUMMARY_LINES+=("Primus config  :  $PRIMUS_CFG")
    [ -n "${PRIMUS_USER:-}" ] && SUMMARY_LINES+=("primus group   :  $PRIMUS_USER")
    SUMMARY_LINES+=("openssl        :  $(OPENSSL_CONF=/dev/null openssl version | awk '{print $2}')")
    SUMMARY_LINES+=("pkcs11-provider:  $(pkg_version pkcs11-provider 2>/dev/null)")
    SUMMARY_LINES+=("pkcs11 module  :  ${P11_SO:-}")
    SUMMARY_LINES+=("OpenSSL config :  ${DEFAULT_CNF:-}")
    [ -n "${PIN_VALUE:-}" ] \
        && SUMMARY_LINES+=("PIN            :  stored in config") \
        || SUMMARY_LINES+=("PIN            :  prompted at runtime")
    SUMMARY_LINES+=("")
    SUMMARY_LINES+=("PASSED: $PASS   FAILED: $FAIL")

    card "Installation Summary" "${SUMMARY_LINES[@]}"

    if [ "$FAIL" -eq 0 ]; then
        note "Installation complete. pkcs11 provider loads automatically with every openssl command."
    else
        # The smoke test is this installer's own acceptance check, and it just failed: the
        # packages are installed and the config is written, but openssl did not come up with
        # the providers this script exists to give it. Exiting 0 on that is how a broken
        # install travels through a pipeline that reads status and not output, and lands on a
        # customer machine reported as a success. The card above stays the readable account of
        # what happened; the status is what anything automated has to go on.
        warn "Installation finished with failures — see details above."
        warn "The packages and the configuration are in place; the provider did not load."
        exit 1
    fi
}

# ── dispatch ──────────────────────────────────────────────────────────────────
case "$SUBCOMMAND" in
    install)
        STEP_TOTAL=6
        cmd_install ;;
    install-pkcs11)
        STEP_TOTAL=2
        cmd_install_pkcs11
        note "Primus PKCS#11 Provider install complete." ;;
    install-openssl)
        STEP_TOTAL=1
        cmd_install_openssl
        note "OpenSSL pkcs11-provider install complete." ;;
    configure-pkcs11)
        STEP_TOTAL=1
        cmd_configure_pkcs11
        note "Primus PKCS#11 Provider configuration complete." ;;
    configure-openssl)
        STEP_TOTAL=1
        cmd_configure_openssl
        note "OpenSSL configuration complete." ;;
    uninstall)
        cmd_uninstall ;;
    remove-config)
        cmd_remove_config ;;
esac
