#!/bin/sh
# confkeeper-setup — доводить свіжовстановлений пакет до працюючої
# системи: база, схема, сертифікат колектора, перший адміністратор,
# зворотний проксі, служби.
#
# ОКРЕМА КОМАНДА, А НЕ ШМАТОК POSTINST, і це не косметика. Постінсталяція
# виконується один раз і в чужому потоці виводу; коли на півдорозі
# з'ясовується, що база не та або сертифіката немає, людині потрібно
# щось, що можна полагодити й ЗАПУСТИТИ ЗАНОВО. Саме тому все нижче
# ідемпотентне: кожен крок спершу питає, чи він уже зроблений.
#
# Постінсталяція просто викликає цю команду.
#
# НІЧОГО НЕ ПЕРЕРОБЛЯЄ. Якщо база вже налаштована, конфіг заповнений, а
# адміністратор заведений — команда нічого не чіпає й виходить нулем.
# Єдине, що вона робить із наявним, — накочує нові міграції при
# оновленні пакета.
set -eu

CONF_DIR=/etc/confkeeper
ENV_FILE="$CONF_DIR/confkeeper.conf"
SECRETS="$CONF_DIR/secrets.conf"
OWNER="$CONF_DIR/owner.conf"
USER_NAME=confkeeper
PGVER=16

say()  { echo "confkeeper-setup: $*"; }
warn() { echo "confkeeper-setup: $*" >&2; }

[ "$(id -u)" = 0 ] || { warn "потрібен root"; exit 1; }

# Прапорці командного рядка перебивають конфіг. Потрібні тоді, коли
# команду запускають руками, щоб щось перерішити, — а саме для цього
# вона й існує окремо від постінсталяції.
OPT_PROXY="" OPT_DOMAIN="" OPT_TLS="" OPT_EMAIL="" OPT_LOCAL_AGENT=0 OPT_REISSUE=0
while [ $# -gt 0 ]; do
    case "$1" in
        --proxy=*)  OPT_PROXY=${1#*=} ;;
        --domain=*) OPT_DOMAIN=${1#*=} ;;
        --tls=*)    OPT_TLS=${1#*=} ;;
        --email=*)  OPT_EMAIL=${1#*=} ;;
        --local-agent) OPT_LOCAL_AGENT=1 ;;
        --reissue-cert) OPT_REISSUE=1 ;;
        -h|--help)
            cat <<EOF
confkeeper-setup — довести встановлене до працюючої системи.

  --proxy=auto|caddy|nginx|apache|none   який проксі налаштувати
  --domain=ІМ'Я                          під яким іменем видно систему
  --tls=auto|letsencrypt|selfsigned|none звідки брати сертифікат
  --email=АДРЕСА                         пошта для Let's Encrypt
  --local-agent                          завести агента на цій же машині
  --reissue-cert                         перевипустити сертифікат колектора
                                         (ключ зберігається — агенти не ламаються)

Без прапорців усе береться з $CONF_DIR/confkeeper.conf.
Повторний запуск безпечний: кожен крок питає, чи він уже зроблений.
EOF
            exit 0 ;;
        *) warn "невідомий прапорець: $1"; exit 2 ;;
    esac
    shift
done

# Чи нас запустив dpkg. Усередині постінсталяції НЕ МОЖНА кликати apt:
# dpkg тримає власний замок, і виклик зависне назавжди, а не відмовить.
# Тому проксі ми там лише налаштовуємо, а не ставимо.
IN_DPKG=0
[ -n "${DPKG_RUNNING_VERSION:-}" ] && IN_DPKG=1

# Домен визначається ОДИН раз і рано, бо потрібен не лише проксі.
#
# Спершу він задавався всередині setup_proxy, а make_cert уже на нього
# посилався — і той виконується РАНІШЕ. При `set -u` це не помилка в
# журналі, а мовчазний обрив скрипта посеред налаштування: база
# накочена, служби не запущені, і причини не видно ніде. Та сама вада,
# від якої тут закривають застосунок.
domain=""
# Чи ім'я вгадане з hostname, а не назване людиною. Від цього залежить
# не лише текст: вгадане ім'я — найчастіша причина того, що сертифікат
# колектора виписаний не на те, що набирають агенти.
DOMAIN_GUESSED=0

# TLS визначає tls_mode, і лише він — усередині setup_proxy. А
# setup_proxy виходить РАНІШЕ за нього в трьох випадках:
# CONFKEEPER_PROXY=none, жодного з трьох проксі на машині (а саме так
# виглядає `apt install --no-install-recommends confkeeper`) і названий
# проксі, якого з-під dpkg не поставити.
#
# Порожнє значення тут — не косметика. Наступний крок
# (check_through_proxy) читає $TLS, і при `set -u` установка на машині
# без проксі ОБРИВАЛАСЬ саме там — тобто до `systemctl restart`, і
# служби не піднімались узагалі. Апт при цьому казав, що пакет
# установлено, а в журналі лишався один рядок про незадану змінну.
TLS=""

# Паролі ЛИШЕ в hex, і це не примха. Пароль ролі їде всередині URL
# `postgres://роль:пароль@host/db`. `/` обриває його й перетворює хвіст
# на ім'я бази; `%` читається як початок %XX, помилки немає, а пароль
# мовчки стає іншим рядком. Виглядає це не як зіпсований рядок у
# конфізі, а як «усе зламалось», і коштувало це вже півдня.
rand_hex() { head -c "${1:-24}" /dev/urandom | od -An -tx1 | tr -d ' \n'; }

conf_get() { sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1; }
conf_set() {
    # Правка на місці, зі збереженням прав. Новий файл поруч і mv
    # зіпсував би власника й режим, а в цих файлах лежать паролі.
    f=$1 k=$2 v=$3
    if grep -q "^$k=" "$f"; then
        # mktemp дає 0600, і саме це тут і потрібно: у цих файлах паролі,
        # а тимчасовий лежить у /tmp. Доти перед sed стояло ще
        # `cp -p "$f" "$tmp"` — воно нічого не зберігало (наступний
        # рядок однаково перезаписує tmp), лише розширювало права
        # тимчасового файла до групових прав конфігу.
        tmp=$(mktemp)
        sed "s|^$k=.*|$k=$v|" "$f" > "$tmp" && cat "$tmp" > "$f"
        rm -f "$tmp"
    else
        printf '%s=%s\n' "$k" "$v" >> "$f"
    fi
}
unset_or_placeholder() {
    case "$1" in ""|*ПАРОЛЬ*|*example.com*) return 0 ;; *) return 1 ;; esac
}

# Тепер, коли conf_get оголошено, домен можна порахувати.
resolve_domain() {
    # Прапорець → змінна середовища → конфіг → hostname. Змінну тут
    # теж читаємо: конфіг не «джерелиться», а ім'я змінної однакове, і
    # перше, що робить людина, — пише `CONFKEEPER_DOMAIN=… setup`.
    # Мовчки проігнорувати це було б гірше за відмову.
    domain=${OPT_DOMAIN:-${CONFKEEPER_DOMAIN:-$(conf_get "$ENV_FILE" CONFKEEPER_DOMAIN)}}
    if [ -z "$domain" ]; then
        domain=$(hostname -f 2>/dev/null || hostname)
        DOMAIN_GUESSED=1
    elif [ -f "$ENV_FILE" ]; then
        # Назване ім'я запам'ятовуємо. Інакше `--domain=` діє один
        # запуск, наступний знову вгадує з hostname і — що гірше —
        # знову скаржиться на сертифікат, який щойно виписали правильно.
        conf_set "$ENV_FILE" CONFKEEPER_DOMAIN "$domain"
    fi
}

# =====================================================================
# 1. БАЗА
# =====================================================================
#
# Заводиться САМА лише тоді, коли всі три умови разом:
#   * DSN у конфізі ще не заповнений (інакше базу вибрали за нас);
#   * на машині є локальний кластер PostgreSQL потрібної версії;
#   * бази confkeeper у ньому ще немає.
#
# Третя умова — найважливіша. Наявна база означає, що тут уже щось
# працювало, і автоматика мусить зупинитись, а не вгадувати пароль ролі
# чи, гірше, перестворювати те, у чому лежать конфіги всієї мережі.
# Перезапуск СУБД двома способами. systemctl — штатний, але є
# середовища, де його немає зовсім (контейнер, chroot для перевірки
# образу), а кластер там цілком живий і керується pg_ctlcluster. Якщо
# покладатись лише на systemd, у таких місцях розширення просто не
# ввімкнеться, і причина буде незрозуміла.
pg_restart() {
    if command -v systemctl >/dev/null 2>&1 && systemctl list-units >/dev/null 2>&1; then
        systemctl restart "postgresql@$PGVER-main" && return 0
    fi
    pg_ctlcluster "$PGVER" main restart 2>/dev/null ||
        pg_ctlcluster "$PGVER" main start
}

provision_db() {
    owner_dsn=$(conf_get "$OWNER" CONFKEEPER_DSN)
    unset_or_placeholder "$owner_dsn" || { say "DSN уже заданий — базу не чіпаю"; return 0; }

    command -v pg_lsclusters >/dev/null 2>&1 || {
        say "локального PostgreSQL не видно — базу заводьте самі (див. README)"
        return 1
    }
    port=$(pg_lsclusters -h 2>/dev/null | awk -v v="$PGVER" '$1==v && $2=="main"{print $3; exit}')
    [ -n "$port" ] || {
        say "немає кластера PostgreSQL $PGVER/main — базу заводьте самі"
        return 1
    }

    # TimescaleDB вмикається НЕ командою: розширення вантажиться при
    # старті СУБД, тож потрібен рядок у postgresql.conf і перезапуск.
    # Правимо через pg_conftool (штатний у Debian), а не sed по файлу,
    # і ОБОВ'ЯЗКОВО зі збереженням того, що там уже є: затерти чуже
    # shared_preload_libraries означає вимкнути чуже розширення на
    # сусідній базі.
    cur=$(pg_conftool "$PGVER" main show shared_preload_libraries 2>/dev/null |
          sed "s/.*= *//; s/'//g" | tr -d ' ')
    case ",$cur," in
        *,timescaledb,*) ;;
        *)
            new=timescaledb
            [ -n "$cur" ] && new="$cur,timescaledb"
            pg_conftool "$PGVER" main set shared_preload_libraries "$new"
            say "увімкнув timescaledb у postgresql.conf (було: ${cur:-порожньо})"
            pg_restart
            say "PostgreSQL перезапущено — без цього розширення не вмикається"
            ;;
    esac

    if su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='confkeeper'\"" |
         grep -q 1; then
        cat >&2 <<EOF

confkeeper-setup: база «confkeeper» у цьому кластері ВЖЕ Є, а конфіг порожній.

Нічого не чіпаю: у ній можуть лежати конфіги всього парку, а пароль
ролі відомий лише вам. Впишіть DSN у $OWNER і
$ENV_FILE і запустіть confkeeper-setup ще раз.
EOF
        return 1
    fi

    OWNER_PW=$(rand_hex 24)
    APP_PW=$(rand_hex 24)
    WRK_PW=$(rand_hex 24)

    # РОЛЬ-ВЛАСНИК НЕ СУПЕРКОРИСТУВАЧ, і це головне рішення цього блоку.
    #
    # У docker-compose власником схеми виходив суперкористувач — його
    # створює сам образ Postgres. Для пакета так робити не можна: DSN
    # власника лежить у файлі на диску, і якщо той файл колись витече,
    # із суперкористувачем зловмисник отримає ВЕСЬ кластер, включно з
    # чужими базами на тій самій машині.
    #
    # Два атрибути, і кожен потрібен для конкретного кроку:
    #
    # CREATEROLE — мігратор видає паролі ролям застосунку. У
    # PostgreSQL 16 така роль не може ні створити суперкористувача, ні
    # втрутитись у ролі, якими не адмініструє.
    #
    # BYPASSRLS — 50 таблиць схеми позначені FORCE ROW LEVEL SECURITY,
    # тобто політики діють НАВІТЬ НА ВЛАСНИКА таблиці. Без цього
    # атрибута накат зупиняється на першому ж засіві словників:
    # «new row violates row-level security policy». У compose цього не
    # видно, бо там власником виходить суперкористувач, а він обходить
    # політики завжди.
    #
    # Це не послаблення, а задокументований стан: deploy/
    # RLS-EXISTING-INSTALL.md, крок 1, очікує рівно
    # confkeeper=t, confkeeper_app=f, confkeeper_worker=t.
    su postgres -c "psql -v ON_ERROR_STOP=1 -q" <<SQL
CREATE ROLE confkeeper LOGIN CREATEROLE BYPASSRLS PASSWORD '$OWNER_PW';
CREATE DATABASE confkeeper OWNER confkeeper;
SQL

    # Далі — рівно те, чого НЕ може навіть CREATEROLE, і тому це
    # робиться тут, суперкористувачем, один раз.
    #
    # 1. Розширення. Частина з них «довірені» й доступні власнику бази,
    #    частина ні; розбиратись, яке саме в якій версії, — зайва
    #    залежність від версії. Ставимо всі тут, а міграція побачить
    #    IF NOT EXISTS і просто пройде повз.
    #
    # 2. BYPASSRLS у ролі фонових тактів. Цей атрибут видає ЛИШЕ
    #    суперкористувач — саме на ньому й зупинився перший прогін
    #    («permission denied to create role»).
    #
    # 3. ADMIN OPTION власнику на обидві ролі. Без нього мігратор не
    #    зможе видати їм паролі: роль із CREATEROLE міняє пароль лише
    #    тим ролям, якими адмініструє.
    su postgres -c "psql -v ON_ERROR_STOP=1 -q -d confkeeper" <<'SQL'
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS timescaledb;
SQL
    su postgres -c "psql -v ON_ERROR_STOP=1 -q" <<'SQL'
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'confkeeper_app') THEN
    CREATE ROLE confkeeper_app NOLOGIN;
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'confkeeper_worker') THEN
    CREATE ROLE confkeeper_worker NOLOGIN BYPASSRLS;
  END IF;
END $$;
GRANT confkeeper_app    TO confkeeper WITH ADMIN OPTION;
GRANT confkeeper_worker TO confkeeper WITH ADMIN OPTION;
SQL
    say "заведено базу, роль-власника (без прав суперкористувача) і ролі застосунку"

    dsn() { printf 'postgres://%s:%s@127.0.0.1:%s/confkeeper?sslmode=disable' "$1" "$2" "$port"; }
    conf_set "$OWNER"    CONFKEEPER_DSN        "$(dsn confkeeper "$OWNER_PW")"
    conf_set "$ENV_FILE" CONFKEEPER_DSN        "$(dsn confkeeper_app "$APP_PW")"
    conf_set "$ENV_FILE" CONFKEEPER_DSN_WORKER "$(dsn confkeeper_worker "$WRK_PW")"
    say "DSN трьох ролей записано"
}

# =====================================================================
# 2. СХЕМА
# =====================================================================
#
# Ідемпотентно: на вже накоченій базі мігратор каже «схема актуальна».
# Тому той самий виклик годиться і для першої установки, і для оновлення.
#
# DSN передаються ОТОЧЕННЯМ, не аргументами: аргументи видно в `ps`
# будь-кому на машині, а в DSN лежить пароль.
migrate() {
    owner_dsn=$(conf_get "$OWNER" CONFKEEPER_DSN)
    app_dsn=$(conf_get "$ENV_FILE" CONFKEEPER_DSN)
    wrk_dsn=$(conf_get "$ENV_FILE" CONFKEEPER_DSN_WORKER)
    if unset_or_placeholder "$owner_dsn$app_dsn$wrk_dsn"; then
        warn "DSN заповнені не всі три — схему не чіпаю"
        return 1
    fi
    CONFKEEPER_DSN="$owner_dsn" \
    CONFKEEPER_DSN_APP="$app_dsn" \
    CONFKEEPER_DSN_WORKER="$wrk_dsn" \
        /usr/bin/confkeeper-migrate
}

# =====================================================================
# 3. СЕРТИФІКАТ КОЛЕКТОРА
# =====================================================================
#
# Колектор без TLS не піднімається свідомо: цим каналом ідуть паролі
# від обладнання. Але вимагати сертифікат ДО першого запуску означало б,
# що «встановив і працює» не буває ніколи.
#
# Тому тут народжується самопідписаний — на ім'я цієї машини. Він
# СПРАВЖНІЙ TLS: канал шифрований, а агент перевіряє сервер за цим же
# сертифікатом, переданим йому як CONFKEEPER_CA. Для мережі, де обидва
# боки ваші, цього досить. Замінити на виписаний центром сертифікації
# можна будь-коли — просто поклавши інші файли.
# Гачок на поновлення сертифіката.
#
# НАВІЩО. Колектор читає сертифікат ОДИН раз, при старті. Certbot
# поновлює файли мовчки й служб не чіпає — він про них не знає. Тобто
# через шістдесят днів на диску лежав би новий сертифікат, а колектор
# віддавав би старий, доки той не протермінується. Тоді в ОДИН день
# відвалюються ВСІ агенти одразу, а на машині все виглядає справним:
# веб відкривається (його віддає проксі, а той перечитує сам), файли
# свіжі, служба запущена.
#
# Друга половина тієї ж біди — права. Certbot кладе поновлені файли в
# archive/ під root, і група губиться. Навіть перезапуск без цього не
# допоміг би: служба не від root і ключ не прочитає.
#
# Тому гачок робить обидві речі, і саме в такому порядку.
install_renew_hook() {
    # Корінь змінною, а не рядком: інакше цю функцію нема як прогнати
    # інакше як на машині з certbot і від root, тобто її не перевіряли б
    # узагалі. Поза перевіркою значення одне.
    le_root=${LE_ROOT:-/etc/letsencrypt}
    dir="$le_root/renewal-hooks/deploy"
    [ -d "$le_root" ] || return 0
    mkdir -p "$dir" || return 0
    cat > "$dir/confkeeper.sh" <<HOOK
#!/bin/sh
# Ставиться confkeeper-setup. Виконується certbot після КОЖНОГО
# успішного поновлення сертифіката.
set -eu

# Права спершу: без них перезапуск підніме службу, яка не прочитає ключ.
chgrp -R $USER_NAME $le_root/live $le_root/archive 2>/dev/null || true
chmod -R g+rX $le_root/live $le_root/archive 2>/dev/null || true

# try-restart, а не restart: якщо колектор зупинений свідомо, гачок не
# має його піднімати.
command -v systemctl >/dev/null 2>&1 && systemctl try-restart confkeeper-collector || true
HOOK
    chmod 0755 "$dir/confkeeper.sh"
    say "гачок поновлення: $dir/confkeeper.sh (перезапускає колектор і лагодить права)"
}

# Які імена покриває сертифікат і чи є серед них потрібне.
#
# Перевіряється через openssl, а не очима: саме ця розбіжність і є
# помилкою, яку шукаємо, тож вірити тому, що ми колись туди записали,
# не можна. Вивід openssl тут C-локальний, «does match» проти «does NOT
# match» — надійніше за код повернення, бо той між версіями різний.
cert_names() {
    openssl x509 -noout -ext subjectAltName -in "$1" 2>/dev/null |
        tr -d ' ' | tr ',' '\n' |
        sed -n -e 's/^DNS://p' -e 's/^IPAddress://p' | tr '\n' ' '
}

# АДРЕСА — ЦЕ НЕ ІМ'Я, і для TLS це дві різні перевірки.
#
# Перевіряючи адресу, клієнт дивиться ЛИШЕ IP-SAN; ім'я, записане як
# DNS:10.0.0.5, його не покриває. Доти установник писав будь-який
# домен як DNS, і встановлення «без домену, за адресою» давало
# сертифікат, якого агент не приймає:
#
#   x509: certificate is valid for 127.0.0.1, not 10.0.0.5
#
# Виглядає як помилка там, де все зроблено за інструкцією, — тому
# адресу тепер видно і при випуску (san нижче), і при перевірці
# (-checkip замість -checkhost).
# Рядок SAN для самого імені системи: адреса — IP:, усе інше — DNS:.
san_self() {
    if is_ip "$1"; then printf 'IP:%s' "$1"; else printf 'DNS:%s' "$1"; fi
}

is_ip() {
    case "$1" in
        *:*)       return 0 ;;   # IPv6
        *[!0-9.]*) return 1 ;;   # є щось, крім цифр і крапок — це ім'я
        *.*.*.*)   return 0 ;;   # чотири октети
        *)         return 1 ;;
    esac
}

cert_covers() {
    if is_ip "$2"; then
        openssl x509 -noout -checkip "$2" -in "$1" 2>/dev/null | grep -q 'does match'
    else
        openssl x509 -noout -checkhost "$2" -in "$1" 2>/dev/null | grep -q 'does match'
    fi
}

make_cert() {
    crt="$CONF_DIR/collector.crt"
    key="$CONF_DIR/collector.key"

    # НАЯВНИЙ СЕРТИФІКАТ — ЩЕ НЕ ПРИЧИНА МОВЧАТИ.
    #
    # Раніше тут був просто `[ -f "$crt" ] && return 0`, і це давало
    # пастку в два ходи: встановили без домену (сертифікат виписався на
    # ім'я машини), потім дописали CONFKEEPER_DOMAIN і перезапустили
    # установник — а той відповів «сертифікат уже є» й не зробив нічого.
    # Агент падав на `x509: certificate is valid for <hostname>, not
    # <домен>`, і зв'язку з тим першим встановленням не було видно.
    # ПЕРЕВИПУСК — ЄДИНИЙ ВИПАДОК, КОЛИ НАЯВНИЙ СЕРТИФІКАТ ЧІПАЮТЬ.
    #
    # Без прапорця установник не торкається його ніколи: це документ,
    # до якого прив'язані агенти, і мовчки замінити його означало б
    # зупинити парк посеред звичайного оновлення.
    #
    # ЧУЖИЙ СЕРТИФІКАТ НЕ ПЕРЕВИПУСКАЄМО. Якщо колектор узяв Let's
    # Encrypt, CONFKEEPER_CERT указує за межі /etc/confkeeper, і
    # зробити замість нього самопідписаний означало б тихо зняти
    # систему зі справжнього сертифіката.
    if [ "$OPT_REISSUE" = 1 ]; then
        cur=$(conf_get "$ENV_FILE" CONFKEEPER_CERT)
        case "$cur" in
            ""|"$crt") ;;
            *) warn "сертифікат колектора не наш ($cur) — перевипускати не буду"
               warn "   це сертифікат від центру; поновлює його той, хто видав"
               return 0 ;;
        esac
    fi

    if [ -f "$crt" ] && [ "$OPT_REISSUE" != 1 ]; then
        if ! command -v openssl >/dev/null 2>&1; then
            say "сертифікат колектора вже є"
        elif cert_covers "$crt" "$domain"; then
            say "сертифікат колектора вже є і покриває $domain"
        else
            warn "сертифікат колектора НЕ покриває $domain"
            warn "   виписаний на: $(cert_names "$crt")"
            warn "   агенти, які набирають $domain, не з'єднаються:"
            warn "   x509: certificate is valid for …, not $domain"
            warn "   перевипустити, НЕ ламаючи агентів (ключ зберігається):"
            warn "     confkeeper-setup --domain=$domain --reissue-cert"
        fi
        return 0
    fi

    # СПЕРШУ ШУКАЄМО ВЖЕ ВИПИСАНИЙ НА ЦЕ Ж ІМ'Я.
    #
    # Колектор стоїть не за проксі: агенти йдуть на 9443 напряму й
    # перевіряють ЙОГО сертифікат. Через це люди роблять типову
    # помилку — отримують Let's Encrypt на домен, бачать зелений замок
    # у браузері й вважають, що агенти теж покриті. Не покриті.
    #
    # Але якщо агенти набирають ТЕ САМЕ ім'я, що й веб, сертифікат
    # можна просто взяти той. Тоді проблема зникає для найчастішого
    # випадку, а не лише описується в документації.
    le="/etc/letsencrypt/live/$domain"
    if [ -r "$le/fullchain.pem" ] && [ -r "$le/privkey.pem" ]; then
        conf_set "$ENV_FILE" CONFKEEPER_CERT "$le/fullchain.pem"
        conf_set "$ENV_FILE" CONFKEEPER_KEY  "$le/privkey.pem"
        # Читати їх має користувач служби: certbot кладе ключі під
        # root, і без цього колектор мовчки не підніметься.
        chgrp -R "$USER_NAME" /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
        chmod -R g+rX /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null || true
        install_renew_hook
        say "колектор узяв сертифікат Let's Encrypt на $domain — той самий, що й веб"
        say "   агентам CONFKEEPER_CA не потрібен: сертифікат справжній"
        return 0
    fi
    command -v openssl >/dev/null 2>&1 || { warn "немає openssl — сертифікат зробіть самі"; return 1; }

    # ІМ'Я БЕРЕТЬСЯ З ДОМЕНУ, А НЕ З HOSTNAME.
    #
    # Тут була помилка, яка коштувала живого встановлення. Стояло
    # `cn=$(hostname -f)`, тобто сертифікат виписувався на ім'я МАШИНИ
    # («ConfKeeper»), а агенти набирають ім'я, під яким систему видно
    # зовні («cfk.example.net»). Перевірка TLS порівнює саме друге, і
    # реєстрація падала з `certificate is valid for ConfKeeper,
    # localhost, not cfk.example.net`.
    #
    # Hostname лишається додатковим іменем — звідти ходить і сам
    # установник під час перевірки, і локальний агент на тій же машині.
    cn=$domain
    self=$(san_self "$domain")
    san="$self,DNS:localhost,IP:127.0.0.1"
    h=$(hostname -f 2>/dev/null || hostname)
    if [ -n "$h" ] && [ "$h" != "$domain" ]; then
        san="$self,DNS:$h,DNS:localhost,IP:127.0.0.1"
    fi
    # SAN обов'язковий: самого CN сучасні клієнти не дивляться вже
    # багато років, і агент відмовився б з'єднуватись.
    #
    # КЛЮЧ ЗБЕРІГАЄМО, ЯКЩО ВІН УЖЕ Є. Агенти пришпилені до відкритого
    # КЛЮЧА (CONFKEEPER_PIN, spki256), а не до сертифіката: новий
    # сертифікат на тому самому ключі має той самий відбиток, тож парк
    # переживає перевипуск і не помічає його. Зробити тут новий ключ
    # означало б зупинити всіх агентів одночасно — заради продовження
    # строку або доданого імені в SAN.
    umask 077
    if [ -f "$key" ]; then
        openssl req -x509 -key "$key" -new -sha256 -days 3650 \
            -out "$crt" \
            -subj "/CN=$cn" \
            -addext "subjectAltName=$san" >/dev/null 2>&1
        kept=1
    else
        openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
            -keyout "$key" -out "$crt" \
            -subj "/CN=$cn" \
            -addext "subjectAltName=$san" >/dev/null 2>&1
        kept=0
    fi
    chown root:"$USER_NAME" "$crt" "$key"
    chmod 0640 "$crt" "$key"
    conf_set "$ENV_FILE" CONFKEEPER_CERT "$crt"
    conf_set "$ENV_FILE" CONFKEEPER_KEY  "$key"
    if [ "$kept" = 1 ]; then
        say "сертифікат колектора перевипущено на ім'я $cn — ключ той самий"
        say "   відбиток не змінився: агенти з CONFKEEPER_PIN працюють далі"
        say "   агентам із CONFKEEPER_CA потрібна нова копія $crt"
    else
        say "зроблено самопідписаний сертифікат колектора на ім'я $cn"
        say "   агентам його віддає токен приєднання (відбиток у ньому)"
    fi
    if [ "$DOMAIN_GUESSED" = 1 ]; then
        # Домену нам не сказали, тож ім'я вгадане з hostname. Якщо
        # агенти набиратимуть інше — не з'єднаються, і сказати про це
        # треба ЗАРАЗ, а не чекати на незрозумілу помилку x509 у
        # агента через тиждень.
        #
        # ПОРАДА НЕ МАЄ ЗНОСИТИ КЛЮЧ. Доти тут стояло `rm -f $crt $key`
        # і повторний запуск установника. На першому встановленні це
        # виглядає безневинно — агентів ще немає. Але цей самий текст
        # друкується і при `--reissue-cert`, тобто рівно тоді, коли
        # парк УЖЕ є: агенти пришпилені до відкритого КЛЮЧА
        # (CONFKEEPER_PIN, spki256), і новий ключ замовкає їх усіх
        # одночасно. Нижче — той самий результат без видалення ключа;
        # заради цього --reissue-cert і робили.
        warn "імені системи ніхто не вказував — узято hostname ($cn)"
        warn "   якщо агенти набиратимуть інше ім'я, перевірка TLS не пройде"
        warn "   задати правильне (ключ зберігається, агенти не ламаються):"
        warn "     confkeeper-setup --domain=ваше.ім'я --reissue-cert"
    fi
}

# =====================================================================
# 4. ЗВОРОТНИЙ ПРОКСІ
# =====================================================================
#
# Інтерфейс вшитий у бінарник, тож окремого фронтенду тут немає — проксі
# потрібен рівно для TLS назовні. Налаштовуємо той, що вже стоїть.
#
# Заголовків безпеки в цих конфігах НЕМАЄ свідомо: їх ставить сам
# застосунок. У nginx `add_header` ДОДАВ БИ другий Content-Security-Policy,
# а браузер застосовує всі такі заголовки перетином — вийшла б політика,
# якої не писав ніхто.
setup_proxy() {
    want=${OPT_PROXY:-$(conf_get "$ENV_FILE" CONFKEEPER_PROXY)}
    [ -n "$want" ] || want=auto

    case "$want" in
        none) say "проксі вимкнено (CONFKEEPER_PROXY=none)"; return 0 ;;
        auto)
            # Порядок: Caddy, nginx, Apache. Caddy перший, бо він
            # з'являється лише коли його поставили свідомо, тоді як
            # nginx приїздить сам через recommends пакета.
            if   have_caddy;  then want=caddy
            elif have_nginx;  then want=nginx
            elif have_apache; then want=apache
            else
                say "проксі не знайдено. Поставте один із трьох і повторіть:"
                say "   apt install caddy   &&  confkeeper-setup --proxy=caddy"
                say "   apt install nginx   &&  confkeeper-setup --proxy=nginx"
                say "   apt install apache2 &&  confkeeper-setup --proxy=apache"
                return 1
            fi
            ;;
        caddy|nginx|apache) ;;
        # ОПИСКА МУСИТЬ ВІДМОВИТИ ТУТ, А НЕ ЗА ЧОТИРИ РЯДКИ НИЖЧЕ.
        #
        # Доти `--proxy=ngnix` доїжджало до виклику "proxy_$want", тобто
        # до «proxy_ngnix: not found» і виходу 127. З командного рядка це
        # хоч видно, а з постінсталяції пакета проксі виклик обгорнутий
        # у `|| true` — установка мовчки лишалась без проксі, і причину
        # не казав ніхто.
        *)  warn "невідомий проксі: $want (є: auto, caddy, nginx, apache, none)"
            return 1 ;;
    esac

    # Названий, але не встановлений — ставимо самі. Лише поза dpkg:
    # див. IN_DPKG вище.
    if ! have_"$want" 2>/dev/null; then
        if [ "$IN_DPKG" = 1 ]; then
            say "$want не встановлено. Після установки виконайте:"
            say "   apt install $(pkg_of "$want") && confkeeper-setup --proxy=$want"
            return 1
        fi
        say "ставлю $want"
        DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$(pkg_of "$want")" >/dev/null 2>&1 ||
            { warn "не вдалось поставити $(pkg_of "$want")"; return 1; }
    fi

    tls_mode
    say "проксі: $want, домен: $domain, сертифікат: $TLS"
    drop_other_proxies "$want"
    "proxy_$want"
}

# Звільнити порти 80 і 443 для обраного проксі.
#
# ПРИБРАТИ НАШ САЙТ МАЛО, і це коштувало окремого прогону. Два проксі
# на одній машині конфліктують ЗАВЖДИ, а не лише через наш конфіг:
# пакет Caddy у Debian несе власний блок :80 у головному Caddyfile, а
# nginx і Apache мають свої типові сайти. Тому після зміни проксі
# новий падав рядком «bind: address already in use», а старий лишався
# працювати без жодного сайту — тобто не відповідав ніхто.
#
# ЧУЖІ САЙТИ — МЕЖА, ЯКУ НЕ ПЕРЕХОДИМО. Якщо на старому проксі є ще
# чиїсь сайти, зупинити його означало б тихо зламати їх. Тоді ми
# кажемо про конфлікт прямо й лишаємо рішення людині: це її машина і
# її сайти.
drop_other_proxies() {
    keep=$1
    [ "$keep" != nginx ]  && free_port_nginx
    [ "$keep" != apache ] && free_port_apache
    [ "$keep" != caddy ]  && free_port_caddy
    return 0
}

stop_svc() {
    command -v systemctl >/dev/null 2>&1 || return 0
    systemctl stop "$1" >/dev/null 2>&1 || true
    systemctl disable "$1" >/dev/null 2>&1 || true
}

free_port_nginx() {
    [ -d /etc/nginx/sites-enabled ] || return 0
    rm -f /etc/nginx/sites-enabled/confkeeper
    # Штатний сайт дистрибутива за чужий НЕ рахуємо: це вітальна
    # сторінка, яку ніхто не ставив свідомо. Саме через неї перехід з
    # Apache колись лишив порт зайнятим, і новий проксі не піднявся.
    others=$(ls -1 /etc/nginx/sites-enabled/ 2>/dev/null | grep -vc '^default$') || others=0
    if [ "$others" -eq 0 ]; then
        stop_svc nginx
        say "зупинив nginx — інших сайтів у ньому не було"
    else
        nginx -t >/dev/null 2>&1 && reload_svc nginx
        warn "nginx лишив працювати: у ньому ще $others сайт(ів)."
        warn "   Він тримає порти 80 і 443, тож $keep може не піднятись."
    fi
}

free_port_apache() {
    [ -d /etc/apache2/sites-enabled ] || return 0
    a2dissite confkeeper confkeeper-ssl >/dev/null 2>&1 || true
    # 000-default.conf і default-ssl.conf — штатні сайти Debian, не
    # чужі. Рахувати їх за чужі означало б ніколи не зупиняти Apache.
    others=$(ls -1 /etc/apache2/sites-enabled/ 2>/dev/null |
             grep -vcE '^(000-default|default-ssl)\.conf$') || others=0
    if [ "$others" -eq 0 ]; then
        stop_svc apache2
        say "зупинив Apache — інших сайтів у ньому не було"
    else
        apache2ctl configtest >/dev/null 2>&1 && reload_svc apache2
        warn "Apache лишив працювати: у ньому ще $others сайт(ів)."
        warn "   Він тримає порти 80 і 443, тож $keep може не піднятись."
    fi
}

free_port_caddy() {
    [ -d /etc/caddy ] || return 0
    rm -f /etc/caddy/conf.d/confkeeper.caddy
    others=$(ls -1 /etc/caddy/conf.d/ 2>/dev/null | wc -l)
    if [ "$others" -eq 0 ]; then
        # Зупиняємо завжди, коли своїх сайтів у conf.d не лишилось:
        # штатний Caddyfile однаково тримає :80 власним блоком, тобто
        # без сайтів він лише заважає.
        stop_svc caddy
        say "зупинив Caddy — інших сайтів у ньому не було"
    else
        warn "Caddy лишив працювати: у ньому ще $others сайт(ів)."
        warn "   Він тримає порти 80 і 443, тож $keep може не піднятись."
    fi
}

have_caddy()  { command -v caddy    >/dev/null 2>&1; }
have_nginx()  { [ -d /etc/nginx/sites-available ]; }
have_apache() { [ -d /etc/apache2/sites-available ]; }

pkg_of() {
    case "$1" in
        caddy)  echo caddy ;;
        nginx)  echo nginx ;;
        apache) echo apache2 ;;
    esac
}

# TLS вирішується ОДИН раз і однаково для всіх трьох проксі.
#
# auto означає «справжній сертифікат, якщо це взагалі можливо». Ознака
# можливості — крапка в імені І заповнена пошта. Друга умова не
# формальність: без пошти ACME не повідомить про протермінування, а
# головне — порожня пошта тут означає, що ніхто не підтвердив, що цей
# вузол публічний.
#
# Чому не «спробувати й подивитись»: невдала спроба в Caddy — це не
# помилка, а цикл повторів на тридцять діб, протягом яких HTTPS мовчить,
# а HTTP на нього перенаправляє. Перевірено живцем.
tls_mode() {
    TLS=${OPT_TLS:-$(conf_get "$ENV_FILE" CONFKEEPER_TLS)}
    [ -n "$TLS" ] || TLS=auto
    EMAIL=${OPT_EMAIL:-$(conf_get "$ENV_FILE" CONFKEEPER_TLS_EMAIL)}
    if [ "$TLS" = auto ]; then
        case "$domain" in
            *.*) [ -n "$EMAIL" ] && TLS=letsencrypt || TLS=selfsigned ;;
            *)   TLS=selfsigned ;;
        esac
    fi
}

# Самопідписаний сертифікат для nginx і Apache. Caddy має власний
# внутрішній центр сертифікації й цього не потребує.
#
# 3650 днів навмисно: сертифікат, який треба перевипускати щороку,
# у закритому контурі одного разу просто протермінується, і ніхто не
# помітить, доки не перестане працювати.
make_web_cert() {
    crt=/etc/confkeeper/web.crt
    key=/etc/confkeeper/web.key
    [ -f "$crt" ] && return 0
    command -v openssl >/dev/null 2>&1 || { warn "немає openssl"; return 1; }
    umask 077
    openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
        -keyout "$key" -out "$crt" -subj "/CN=$domain" \
        -addext "subjectAltName=$(san_self "$domain"),DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1 ||
        { warn "не вдалось зробити сертифікат"; return 1; }
    chmod 0644 "$crt"; chmod 0640 "$key"
    say "зроблено локальний сертифікат на $domain (діє 10 років)"
}

# --------------------------------------------------------------- Caddy
proxy_caddy() {
    site=/etc/caddy/conf.d/confkeeper.caddy
    if [ -f "$site" ]; then
        say "конфіг Caddy уже є — не перезаписую, лише вмикаю"
        reload_svc caddy
        return 0
    fi
    install -d -m 0755 /etc/caddy/conf.d
    install -d -m 0750 -o caddy -g caddy /var/log/caddy 2>/dev/null || true

    # tls internal — власний центр сертифікації Caddy. Видає миттєво,
    # без інтернету й без ACME, і сам перевипускає. Саме це робить
    # HTTPS робочим «з першої секунди» там, де публічного імені немає.
    case "$TLS" in
        letsencrypt) tls_line="	# сертифікат Caddy бере сам, через ACME" ;;
        none)        tls_line="	tls internal" ;;
        *)           tls_line="	tls internal" ;;
    esac
    sed -e "s|@DOMAIN@|$domain|g" -e "s|@TLS@|$tls_line|" \
        /usr/share/confkeeper/caddy.conf.example > "$site"
    chmod 0644 "$site"

    # ОДИН РЯДОК У ЧУЖОМУ ФАЙЛІ. У Debian пакет Caddy дає один
    # /etc/caddy/Caddyfile без conf.d і без import — на відміну від
    # nginx і Apache, куди конфіг просто кладеться окремим файлом.
    # Наслідок, який варто знати: це conffile пакета, тож при оновленні
    # самого Caddy dpkg запитає, що робити зі зміненим файлом.
    if ! grep -q "conf.d/\*.caddy" /etc/caddy/Caddyfile 2>/dev/null; then
        {
            echo
            echo "# Додано пакетом confkeeper: підхоплює сайти з conf.d."
            echo "import /etc/caddy/conf.d/*.caddy"
        } >> /etc/caddy/Caddyfile
        say "дописав import у /etc/caddy/Caddyfile (одним рядком)"
    fi

    if caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
        reload_svc caddy
        if [ "$TLS" = letsencrypt ]; then
            say "Caddy візьме сертифікат сам. Якщо $domain не видно з"
            say "   інтернету на портах 80 і 443, HTTPS мовчатиме — тоді"
            say "   поставте CONFKEEPER_TLS=selfsigned і повторіть."
        else
            say "Caddy: HTTPS працює одразу, сертифікат його власний"
            say "   (у браузері буде попередження — так і має бути)"
        fi
    else
        warn "конфіг Caddy не прийнявся — лишив у $site"
        warn "   caddy validate --config /etc/caddy/Caddyfile"
    fi
}

# --------------------------------------------------------------- nginx
# ПРАВИЛО, СПІЛЬНЕ ДЛЯ ВСІХ ТРЬОХ: конфіг, який уже є, НЕ
# перезаписуємо — там могли бути правки адміністратора. Але
# увімкненість сайту й запуск служби забезпечуємо ЗАВЖДИ.
#
# Доти перевірка дивилась лише на наявність файла в sites-available і
# при поверненні на цей проксі виходила одразу — а посилання в
# sites-enabled на той момент уже прибрало звільнення портів. Сайт
# лишався вимкненим, служба зупиненою, і не відповідав ніхто.
proxy_nginx() {
    site=/etc/nginx/sites-available/confkeeper
    if [ -f "$site" ]; then
        say "конфіг nginx уже є — не перезаписую, лише вмикаю"
        ln -sf "$site" /etc/nginx/sites-enabled/confkeeper
        if nginx -t >/dev/null 2>&1; then
            reload_svc nginx
        else
            warn "наявний конфіг nginx не проходить перевірку — розберіться:"
            warn "   nginx -t"
        fi
        return 0
    fi

    # Чи можемо стати сайтом за замовчуванням. Беремо цю роль лише
    # тоді, коли на неї не претендує ніхто, крім штатної вітальної
    # сторінки Debian: забрати її в чужого сайту означало б тихо
    # зламати його.
    other=$(grep -rl "default_server" /etc/nginx/sites-enabled/ 2>/dev/null |
            grep -v '/default$' | head -1)
    if [ -z "$other" ]; then
        dflt=default_server
        if [ -L /etc/nginx/sites-enabled/default ]; then
            rm -f /etc/nginx/sites-enabled/default
            say "вимкнув вітальну сторінку nginx — вона забирала всі запити на IP"
        fi
    else
        dflt=""
        say "сайт за замовчуванням уже зайнятий ($other) — беру лише ім'я"
    fi
    # Той самий вибір потрібен і шаблону з TLS.
    NGINX_DEFAULT=$dflt

    sed -e "s|@DOMAIN@|$domain|g" -e "s|@DEFAULT@|$dflt|g" \
        /usr/share/confkeeper/nginx.conf.example > "$site"
    ln -sf "$site" /etc/nginx/sites-enabled/confkeeper
    if ! nginx -t >/dev/null 2>&1; then
        warn "конфіг nginx не прийнявся — лишив у $site, увімкніть самі"
        rm -f /etc/nginx/sites-enabled/confkeeper
        return 1
    fi
    reload_svc nginx
    web_tls nginx
}

# --------------------------------------------------------------- Apache
proxy_apache() {
    site=/etc/apache2/sites-available/confkeeper.conf
    if [ -f "$site" ]; then
        say "конфіг Apache уже є — не перезаписую, лише вмикаю"
        a2enmod proxy proxy_http proxy_wstunnel headers ssl rewrite >/dev/null 2>&1 || true
        a2ensite confkeeper >/dev/null 2>&1 || true
        [ -f /etc/apache2/sites-available/confkeeper-ssl.conf ] &&
            a2ensite confkeeper-ssl >/dev/null 2>&1
        if apache2ctl configtest >/dev/null 2>&1; then
            reload_svc apache2
        else
            warn "наявний конфіг Apache не проходить перевірку:"
            warn "   apache2ctl configtest"
        fi
        return 0
    fi
    sed "s|@DOMAIN@|$domain|g" /usr/share/confkeeper/apache.conf.example > "$site"
    a2enmod proxy proxy_http proxy_wstunnel headers ssl rewrite >/dev/null 2>&1 || true
    a2ensite confkeeper >/dev/null 2>&1 || true
    # Те саме, що й з вітальною сторінкою nginx: 000-default відповідає
    # на будь-яке ім'я, і запит на IP-адресу потрапляє в неї, а не до
    # нас. Вимикаємо, лише якщо інших сайтів немає — чужі не чіпаємо.
    if [ "$(ls -1 /etc/apache2/sites-enabled/ 2>/dev/null |
            grep -vcE '^(confkeeper|000-default|default-ssl)' || echo 0)" -eq 0 ]; then
        a2dissite 000-default >/dev/null 2>&1 && say "вимкнув вітальну сторінку Apache"
    fi
    if ! apache2ctl configtest >/dev/null 2>&1; then
        warn "конфіг Apache не прийнявся — лишив у $site"
        a2dissite confkeeper >/dev/null 2>&1 || true
        return 1
    fi
    reload_svc apache2
    web_tls apache
}

# enable потрібен окремо: службу могли вимкнути при попередній зміні
# проксі, і тоді вона не піднялась би після перезавантаження машини.
reload_svc() {
    command -v systemctl >/dev/null 2>&1 || return 0
    systemctl enable "$1" >/dev/null 2>&1 || true
    # Якщо служба у стані failed (а саме так буває після конфлікту за
    # порт), reload її не підніме — лише restart.
    if systemctl is-failed --quiet "$1" 2>/dev/null; then
        systemctl restart "$1" >/dev/null 2>&1 || true
        return 0
    fi
    systemctl reload "$1" >/dev/null 2>&1 || systemctl restart "$1" >/dev/null 2>&1 || true
}

# TLS для nginx і Apache. Caddy сюди не заходить — у нього свій шлях.
#
# ПОРЯДОК ВАЖЛИВИЙ: спершу пробуємо справжній, і ЛИШЕ при невдачі
# ставимо локальний. Навпаки не можна: certbot, побачивши вже
# налаштований 443, поводиться інакше.
web_tls() {
    kind=$1
    case "$TLS" in
        none) say "TLS вимкнено (CONFKEEPER_TLS=none) — лише HTTP"; return 0 ;;
        letsencrypt)
            if command -v certbot >/dev/null 2>&1; then
                say "замовляю сертифікат Let's Encrypt на $domain"
                if certbot --"$kind" -n --agree-tos -m "$EMAIL" \
                     -d "$domain" --redirect >/dev/null 2>&1; then
                    say "сертифікат отримано, поновлення — таймером certbot"
                    return 0
                fi
                warn "Let's Encrypt відмовив (ім'я не видно ззовні?)."
                warn "   Ставлю локальний, щоб HTTPS працював: мертвий сайт"
                warn "   гірший за самопідписаний. Коли ім'я запрацює:"
                warn "   certbot --$kind -d $domain"
            elif [ "$IN_DPKG" = 0 ]; then
                # Поза dpkg можна поставити самі — і треба: інакше
                # «--tls=letsencrypt» означало б лише попередження.
                say "ставлю certbot"
                if DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
                     certbot "python3-certbot-$kind" >/dev/null 2>&1 &&
                   certbot --"$kind" -n --agree-tos -m "$EMAIL" \
                     -d "$domain" --redirect >/dev/null 2>&1; then
                    say "сертифікат отримано, поновлення — таймером certbot"
                    return 0
                fi
                warn "не вийшло — ставлю локальний сертифікат"
            else
                warn "certbot не встановлено, а з-під dpkg його не поставити."
                warn "   Ставлю локальний. Потім, якщо ім'я видно ззовні:"
                warn "   apt install certbot python3-certbot-$kind"
                warn "   confkeeper-setup --tls=letsencrypt"
            fi
            ;;
    esac
    make_web_cert || return 1
    write_tls_site "$kind" /etc/confkeeper/web.crt /etc/confkeeper/web.key
}

# Конфіг із TLS — ОКРЕМИЙ ШАБЛОН, а не латання наявного через sed.
#
# Спершу тут стояли три sed поспіль. Перевірка живцем показала рівно
# те, заради чого так не роблять: перенаправлення не застосувалось
# ВЗАГАЛІ (nginx і Apache віддавали 200 по відкритому HTTP), а
# повідомлення при цьому казало «HTTP перекинуто». Тобто крок брехав, і
# помітити це можна було лише запитом ззовні.
#
# Шаблон натомість видно цілком, і він правильний за побудовою: окремий
# блок на :80, який віддає ЛИШЕ acme-challenge, і блок на :443 із
# застосунком. Перенаправлення на рівні сервера накрило б і
# acme-challenge; усередині `location /` — лишило б відкритими
# /api/v1/ws і /api/v1/downloads/.
write_tls_site() {
    kind=$1 crt=$2 key=$3
    case "$kind" in
        nginx)
            sed -e "s|@DOMAIN@|$domain|g" -e "s|@DEFAULT@|${NGINX_DEFAULT:-}|g" \
                -e "s|@CRT@|$crt|g" -e "s|@KEY@|$key|g" \
                /usr/share/confkeeper/nginx-tls.conf.example \
                > /etc/nginx/sites-available/confkeeper
            install -d -m 0755 /var/www/html/.well-known/acme-challenge
            if nginx -t >/dev/null 2>&1; then
                reload_svc nginx
                say "nginx: HTTPS працює, HTTP перекинуто (крім acme-challenge)"
            else
                warn "конфіг nginx із TLS не прийнявся — повертаю HTTP-варіант"
                sed -e "s|@DOMAIN@|$domain|g" -e "s|@DEFAULT@|${NGINX_DEFAULT:-}|g" \
                    /usr/share/confkeeper/nginx.conf.example \
                    > /etc/nginx/sites-available/confkeeper
                nginx -t >/dev/null 2>&1 && reload_svc nginx
            fi
            ;;
        apache)
            sed -e "s|@DOMAIN@|$domain|g" \
                -e "s|@CRT@|$crt|g" -e "s|@KEY@|$key|g" \
                /usr/share/confkeeper/apache-tls.conf.example \
                > /etc/apache2/sites-available/confkeeper.conf
            a2enmod rewrite ssl >/dev/null 2>&1 || true
            install -d -m 0755 /var/www/html/.well-known/acme-challenge
            if apache2ctl configtest >/dev/null 2>&1; then
                reload_svc apache2
                say "Apache: HTTPS працює, HTTP перекинуто (крім acme-challenge)"
            else
                warn "конфіг Apache із TLS не прийнявся — повертаю HTTP-варіант"
                sed "s|@DOMAIN@|$domain|g" \
                    /usr/share/confkeeper/apache.conf.example \
                    > /etc/apache2/sites-available/confkeeper.conf
                apache2ctl configtest >/dev/null 2>&1 && reload_svc apache2
            fi
            ;;
    esac
}


# =====================================================================
# 4-А. ПЕРЕВІРКА ЧЕРЕЗ ПРОКСІ
# =====================================================================
#
# Налаштували — і одразу питаємо ЗЗОВНІ, що з цього вийшло. Бо три з
# чотирьох типових помилок конфігурації проксі не дають ані помилки,
# ані сліду в журналі: система працює, виглядає справною і не робить
# того, що обіцяє.
#
# Запит іде на петлю з правильним Host: так він проходить увесь шлях
# через проксі, не потребуючи ані DNS, ані доступу ззовні.
check_through_proxy() {
    command -v curl >/dev/null 2>&1 || return 0
    command -v systemctl >/dev/null 2>&1 || return 0

    # Проксі не налаштовували (див. TLS="" на початку файла) — питати
    # «через проксі» нема чого: на 80-му порті або не відповідає ніхто,
    # або відповідає чужий сайт, і будь-яка скарга нижче вказувала б не
    # на нас.
    # :- тут навмисно, хоч TLS і виставлена на початку файла: цю
    # функцію перевіряють окремо (scripts/check-packaging.sh), і
    # залежність від того, що хтось вище не забув оголосити змінну, —
    # це рівно та вада, яку перевірка й ловить.
    [ -n "${TLS:-}" ] || return 0

    # ПИТАЄМО ПО HTTPS, КОЛИ TLS Є. Інакше перевірка нічого не
    # доводить: HTTP тепер перекидає на HTTPS, а в перенаправленні
    # заголовків застосунку немає — отже подвійний CSP вона не
    # побачила б ЖОДНОГО разу. Перевірка, яка не може спрацювати,
    # гірша за її відсутність: вона створює враження, що дивились.
    #
    # --resolve, а не DNS: ім'я може ще нікуди не вказувати, а
    # перевірити треба саме той віртуальний хост, що налаштували.
    if [ "$TLS" = none ]; then
        hdr=$(curl -sS -I --max-time 10 -H "Host: $domain" http://127.0.0.1/ 2>/dev/null)
    else
        # -k навмисно: сертифікат тут часто локальний, і перевіряємо
        # ми не довіру до нього, а заголовки застосунку.
        hdr=$(curl -skS -I --max-time 10 --resolve "$domain:443:127.0.0.1"               "https://$domain/" 2>/dev/null)
    fi
    if [ -z "$hdr" ]; then
        say "перевірити через проксі не вийшло — зробіть це самі:"
        say "   curl -kI --resolve $domain:443:127.0.0.1 https://$domain/"
        return 0
    fi

    code=$(printf '%s' "$hdr" | head -1 | awk '{print $2}')

    # ПОДВІЙНИЙ CSP. Якщо заголовок віддали двічі, браузер застосовує
    # обидва ОДНОЧАСНО — виживає лише спільне. Два майже однакові
    # списки дають третій, суворіший, якого ніхто не складав, і
    # частина інтерфейсу мовчки не працює.
    n=$(printf '%s' "$hdr" | grep -ci '^content-security-policy:') || n=0
    if [ "$n" -gt 1 ]; then
        warn "Content-Security-Policy віддається $n рази."
        warn "   Браузер застосує всі разом, перетином — вийде політика,"
        warn "   якої не писав ніхто, і частина інтерфейсу мовчки зламається."
        warn "   Приберіть add_header з конфігу проксі: заголовки ставить"
        warn "   сам застосунок."
    elif [ "$n" -eq 0 ] && [ "${code:-}" != "308" ] && [ "${code:-}" != "301" ]; then
        warn "відповідь без Content-Security-Policy — запит, схоже, не"
        warn "   доходить до застосунку. Перевірте, чий сайт відповідає."
    fi

    case "${code:-}" in
        200|301|302|308|401) say "перевірка через проксі: відповідь $code" ;;
        "")  warn "проксі не відповів на 127.0.0.1" ;;
        *)   warn "проксі відповів $code — відкрийте $domain і подивіться" ;;
    esac
}

# =====================================================================
# 5. ПЕРШИЙ АДМІНІСТРАТОР
# =====================================================================
#
# Пароль ГЕНЕРУЄТЬСЯ і показується один раз. Обліковий запис позначений
# як такий, що мусить його змінити (міграція 0182): до зміни в системі
# не можна зробити нічого.
#
# Інакше й бути не може: пароль, надрукований установником, осідає в
# журналі apt, у прокручуванні термінала й у чиємусь знімку екрана. Він
# мусить бути одноразовим, як запрошення, а не як пароль.
make_admin() {
    owner_dsn=$(conf_get "$OWNER" CONFKEEPER_DSN)
    if CONFKEEPER_DSN="$owner_dsn" /usr/bin/confkeeper-user -tenant default -list \
         >/dev/null 2>&1; then
        say "кабінет і користувачі вже є — адміністратора не чіпаю"
        return 0
    fi
    name=${CONFKEEPER_TENANT_NAME:-ConfKeeper}
    echo
    echo "=============================================================="
    CONFKEEPER_DSN="$owner_dsn" /usr/bin/confkeeper-user \
        -tenant default -create-tenant "$name" \
        -login admin -role owner -name "Адміністратор" -generate-password
    echo "=============================================================="
    echo "Пароль одноразовий: система вимагатиме змінити його при вході."
    echo
}

# =====================================================================
# АГЕНТ НА ЦІЙ САМІЙ МАШИНІ
# =====================================================================
#
# НАЙЧАСТІШИЙ ВИПАДОК, І ДОТИ НАЙДОВШИЙ. Щоб завести агента поруч із
# сервером, людина йшла в кабінет по код приєднання, верталась у
# термінал, вписувала адресу, код і довіру до сертифіката — три речі,
# які ця машина й так знає про себе краще за неї. Копіювати тут нема
# чого й нема куди: сервер, агент і сертифікат — на одному диску.
#
# Тому крок просто просить у себе токен (confkeeper-join) і віддає
# його агентові (confkeeper-agent -join). Обидві команди — ті самі, що
# й для віддаленого агента; інакший тут лише 127.0.0.1.
#
# ІДЕМПОТЕНТНО, як і все в цьому скрипті: якщо посвідчення вже лежить,
# нового запрошення НЕ створюємо. Інакше кожен повторний запуск
# установника (а його для цього й зроблено повторюваним) палив би нове
# запрошення й переписував робочий конфіг агента.
local_agent() {
    [ "$OPT_LOCAL_AGENT" = 1 ] || return 0

    if [ -f /var/lib/confkeeper/agent.json ]; then
        say "локальний агент уже зареєстрований — лишаю як є"
        return 0
    fi
    if ! command -v confkeeper-agent >/dev/null 2>&1; then
        warn "агента на машині немає: apt install confkeeper-agent (далі повторіть із --local-agent)"
        return 0
    fi
    if ! command -v confkeeper-join >/dev/null 2>&1; then
        warn "немає confkeeper-join — оновіть пакет confkeeper"
        return 0
    fi

    # Порт колектора — той, який він справді слухає. Формат буває і
    # ':9443', і '0.0.0.0:9443'; беремо те, що після останньої
    # двокрапки.
    listen=$(conf_get "$ENV_FILE" CONFKEEPER_LISTEN)
    port=${listen##*:}
    case "$port" in ''|*[!0-9]*) port=9443 ;; esac

    # 127.0.0.1, А НЕ ІМ'Я СИСТЕМИ. Петля не залежить ні від DNS, ні
    # від каналу назовні: впаде інтернет — локальний агент і далі
    # бачитиме локальний колектор. Перевірку імені заміняє відбиток
    # сертифіката, який confkeeper-join кладе в сам токен.
    #
    # Токен НІДЕ НЕ ДРУКУЄМО: він одноразовий, але до першого
    # використання це право завести агента від імені кабінету, а вивід
    # установника читають і зберігають.
    if ! tok=$(confkeeper-join -server "127.0.0.1:$port" \
                   -name "$(hostname -s 2>/dev/null || hostname) (локальний)"); then
        warn "не вдалося видати запрошення локальному агентові"
        return 0
    fi
    if confkeeper-agent -join "$tok"; then
        say "локальний агент заведений"
    else
        warn "агент не піднявся — journalctl -u confkeeper-agent -n 30"
    fi
    unset tok
}

# =====================================================================
# Порядок
# =====================================================================
resolve_domain

rc=0
provision_db || rc=1
if [ "$rc" = 0 ] || ! unset_or_placeholder "$(conf_get "$OWNER" CONFKEEPER_DSN)"; then
    migrate || exit 1
    make_cert || true
    make_admin || true
else
    exit 1
fi

# Проксі налаштовується НЕЗАЛЕЖНО від systemd.
#
# Спершу цей виклик стояв усередині гілки systemd, і це була помилка:
# конфіг сайту — звичайний файл, написати його можна будь-де, а systemd
# потрібен рівно для одного — перезавантажити службу. Без нього
# установка мовчки лишалась без конфігу проксі, і ззовні це виглядало
# як «ConfKeeper не відповідає».
setup_proxy || true
check_through_proxy || true

if command -v systemctl >/dev/null 2>&1; then
    systemctl daemon-reload || true
    systemctl enable confkeeper-api confkeeper-collector >/dev/null 2>&1 || true
    # restart, а не start: при оновленні служби працюють на СТАРОМУ
    # бінарнику, і без перезапуску оновлення тихо не відбулось би.
    systemctl restart confkeeper-api confkeeper-collector ||
        warn "служби не піднялись — journalctl -u confkeeper-api -n 50"
    # Агент заводиться ПІСЛЯ перезапуску колектора: він одразу йде
    # реєструватись, і йому є куди йти лише тепер.
    local_agent || true
    say "готово. Стан: systemctl status confkeeper-api confkeeper-collector"
else
    say "systemd не знайдено — схема накочена, служби запускайте самі"
fi
exit 0
