#!/usr/bin/env bash
# Ubuntu 24.04 LTS, Bash 4.4+. Supports a script file and bash -c.
# Intended for a fresh VPS. No private SSH keys are ever requested.
# Version 1.6.1: SSH runtime directory, NetBird Cloud only, connection diagnostics.
set -Eeuo pipefail
umask 077
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
export UCF_FORCE_CONFFOLD=1

BLUE='' GREEN='' RED='' YELLOW='' RESET=''
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
    BLUE=$'\e[1;36m'; GREEN=$'\e[1;32m'; RED=$'\e[1;31m'
    YELLOW=$'\e[1;33m'; RESET=$'\e[0m'
fi
say() { printf '%s%s%s\n' "$1" "$2" "$RESET"; }
die() { say "$RED" "$*"; exit 1; }
ask() { printf '%s' "$1" >&3; IFS= read -r "$2" <&3 || die 'Ввод прерван.'; }
yes_no() {
    local reply
    while :; do
        ask "$1 [y/N]: " reply
        case "$reply" in
            y|Y|yes|YES|д|Д|да|Да) return 0 ;;
            ''|n|N|no|NO|н|Н|нет|Нет) return 1 ;;
        esac
    done
}
cleanup() { [[ -z ${TMP:-} ]] || rm -rf -- "$TMP"; }
check_sshd() {
    # /run is temporary; a socket-activated/stopped service may not own it yet.
    install -d -o root -g root -m 0755 /run/sshd
    /usr/sbin/sshd "$@"
}

APT=(apt-get -o DPkg::Lock::Timeout=300 -o Acquire::Retries=3
    -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
FAILURES=()
LAST_OK=0
STEP=0
AUTO_UPDATE_CONFIG=/etc/apt/apt.conf.d/99zz-vps-orchestrated-updates
AUTO_UPDATE_TIMERS=(apt-daily.timer apt-daily-upgrade.timer)
AUTO_UPDATE_SERVICES=(apt-daily.service apt-daily-upgrade.service)
run_step() {
    local label=$1 rc
    shift
    STEP=$((STEP + 1))
    say "$BLUE" "[$STEP] $label…"
    printf '\n=== %s | %s ===\n' "$(date -Is)" "$label" >>"$LOG"
    # Do NOT put this subshell/function call in `if` or `||`: Bash would
    # suppress errexit for the entire function and hide intermediate failures.
    set +e
    (
        set -Eeuo pipefail
        trap 'rc=$?; printf "ERROR: line %s, exit %s\n" "$LINENO" "$rc" >&2; exit "$rc"' ERR
        "$@"
    ) </dev/null >>"$LOG" 2>&1
    rc=$?
    set -e
    if (( rc == 0 )); then
        LAST_OK=1
        say "$GREEN" "    Успешно: $label"
    else
        LAST_OK=0
        FAILURES+=("$label (код $rc)")
        say "$RED" "    Ошибка: $label (код $rc). Подробности: $LOG"
        say "$YELLOW" '    При любой ошибке автоматическая перезагрузка отменяется.'
        if ! yes_no 'Продолжить доступные следующие этапы?'; then
            die "Остановлено. Уже выполненные этапы сохранены. Журнал: $LOG"
        fi
    fi
}

collect_inputs() {
    local answer record line key_type key_blob comment count first_key='' valid
    say "$BLUE" 'VPS bootstrap 1.6.1: подготовка чистой VPS с Ubuntu 24.04 LTS'
    say "$YELLOW" 'Заранее откройте новый SSH-порт в firewall панели VPS и проверьте доступ к консоли провайдера.'
    say "$YELLOW" 'При успехе сервер автоматически перезагрузится. При ошибке потребуется ваш ответ.'
    while :; do
        ask "Существующий пользователь для SSH-ключей [${SUDO_USER:-root}]: " LOGIN_USER
        LOGIN_USER=${LOGIN_USER:-${SUDO_USER:-root}}
        if [[ $LOGIN_USER =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] && record=$(getent passwd "$LOGIN_USER"); then
            IFS=: read -r _ _ LOGIN_UID LOGIN_GID _ LOGIN_HOME LOGIN_SHELL <<<"$record"
            if [[ -d $LOGIN_HOME && $LOGIN_SHELL != */nologin && $LOGIN_SHELL != */false ]]; then break; fi
        fi
        say "$RED" 'Нужен существующий пользователь с домашним каталогом и рабочей оболочкой.'
    done
    while :; do
        ask 'Новый SSH-порт (1–65535, кроме 22/80/443) [2222]: ' SSH_PORT
        SSH_PORT=${SSH_PORT:-2222}
        if [[ $SSH_PORT =~ ^[0-9]{1,5}$ ]]; then
            SSH_PORT=$((10#$SSH_PORT))
            if (( SSH_PORT > 0 && SSH_PORT < 65536 && SSH_PORT != 22 && SSH_PORT != 80 && SSH_PORT != 443 )); then
                if ! ss -H -ltn "sport = :$SSH_PORT" | grep -q .; then break; fi
                say "$RED" 'Этот TCP-порт уже занят.'
            fi
        fi
    done
    while :; do
        ask 'Реверс-прокси: 1 — NGINX + Certbot, 2 — Caddy [1]: ' answer
        case ${answer:-1} in 1|nginx|NGINX) PROXY=nginx; break ;; 2|caddy|CADDY) PROXY=caddy; break ;; esac
    done
    EMAIL=skysat331@gmail.com
    if [[ $PROXY == nginx ]]; then
        while :; do
            ask 'Email для регистрации Certbot [skysat331@gmail.com]: ' answer
            EMAIL=${answer:-skysat331@gmail.com}
            [[ $EMAIL =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] && break
        done
    fi
    while :; do
        say "$BLUE" 'Вставьте ровно ДВА РАЗНЫХ публичных SSH-ключа, по одному на строку.'
        say "$BLUE" 'После второй строки нажмите Enter, затем Ctrl+D на пустой строке.'
        cat <&3 >"$TMP/keys.input"
        : >"$TMP/keys"
        count=0; valid=1; first_key=''
        while IFS= read -r line || [[ -n $line ]]; do
            line=${line%$'\r'}
            [[ -z $line ]] && continue
            read -r key_type key_blob comment <<<"$line"
            case "$key_type" in
                ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh.com|sk-ecdsa-sha2-nistp256@openssh.com) ;;
                *) valid=0; break ;;
            esac
            printf '%s %s\n' "$key_type" "$key_blob" >"$TMP/key.check"
            if ! ssh-keygen -lf "$TMP/key.check" >/dev/null 2>&1; then valid=0; break; fi
            [[ "$key_type $key_blob" != "$first_key" ]] || { valid=0; break; }
            first_key="$key_type $key_blob"
            printf '%s\n' "$line" >>"$TMP/keys"
            count=$((count + 1))
        done <"$TMP/keys.input"
        (( valid == 1 && count == 2 )) && break
        say "$RED" 'Нужны два разных валидных публичных ключа OpenSSH, без опций перед ключом. Повторите ввод.'
    done
    while :; do
        printf 'NetBird setup key (ввод скрыт): ' >&3
        IFS= read -r -s NETBIRD_KEY <&3 || die 'Ввод прерван.'
        printf '\n' >&3
        [[ $NETBIRD_KEY =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]] && break
        say "$RED" 'Ожидается UUID вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.'
    done
    # /run is tmpfs. Pass the secret via a file, not process arguments or logs.
    printf '%s' "$NETBIRD_KEY" >"$TMP/netbird.key"
    unset NETBIRD_KEY NB_SETUP_KEY NB_SETUP_KEY_FILE
    # This workflow uses NetBird Cloud; there is no self-hosted URL prompt.
    say "$YELLOW" 'IPV6=no в UFW не отключает IPv6 в Linux. Режим 1 отключает его также через sysctl.'
    while :; do
        ask 'IPv6: 1 — отключить в системе + IPV6=no; 2 — сохранить и фильтровать UFW [1]: ' answer
        case ${answer:-1} in
            1)
                if [[ ${SSH_CONNECTION:-} == *:* ]]; then
                    say "$RED" 'Текущая SSH-сессия использует IPv6: выберите 2 или перезапустите скрипт через IPv4.'
                else IPV6_MODE=disable; break; fi ;;
            2) IPV6_MODE=keep; break ;;
        esac
    done
    say "$YELLOW" 'Блокировка служебного ICMP может нарушить PMTU и диагностику. Рекомендуется блокировать только ping.'
    while :; do
        ask 'ICMP: 1 — только echo-request; 2 — перечисленные в ТЗ типы IPv4 [1]: ' answer
        case ${answer:-1} in 1) ICMP_MODE=ping; break ;; 2) ICMP_MODE=strict; break ;; esac
    done
    say "$BLUE" "Текущее ядро: $(uname -r). Для специальных cloud-ядер обычно стоит сохранить текущую ветку."
    while :; do
        ask 'Ядро: 1 — установить HWE 24.04; 2 — сохранить текущую ветку ядра [1]: ' answer
        case ${answer:-1} in 1) HWE=yes; break ;; 2) HWE=no; break ;; esac
    done
    collect_blacklist_inputs
    printf '\nSSH: %s, порт %s | Прокси: %s | IPv6: %s | ICMP: %s | HWE: %s\n' \
        "$LOGIN_USER" "$SSH_PORT" "$PROXY" "$IPV6_MODE" "$ICMP_MODE" "$HWE"
    say "$YELLOW" 'UFW будет настроен заново: входящие 80/tcp, 443/tcp, SSH-порт и весь входящий трафик wt0.'
    say "$YELLOW" 'Парольный SSH-вход отключается для всех; root сможет входить по ключу. Существующие ключи сохраняются.'
    say "$YELLOW" 'Устанавливаются пакеты и официальные установщики Docker/NetBird от имени root.'
    say "$YELLOW" 'Фоновые обновления и автоочистка APT будут отключены; дальнейшее обслуживание — через Ansible.'
    [[ $PROXY != nginx ]] || say "$YELLOW" "Certbot зарегистрирует $EMAIL с согласием с условиями Let's Encrypt."
    yes_no 'Ключи ваши, новый порт разрешён у провайдера, консоль доступна. Начать с перезагрузкой при успехе?' || exit 0
}

collect_blacklist_inputs() {
    local value default_port=22 default_networks='' default_iface=tun0
    if [[ -n ${SSH_CONNECTION:-} ]]; then default_networks=${SSH_CONNECTION%% *}; fi
    if [[ -f /opt/as_blacklist/config.json ]]; then
        if python3 - /opt/as_blacklist/config.json >"$TMP/as-defaults" <<'AS_DEFAULTS_PY'
import json, sys
c = json.load(open(sys.argv[1]))
print(c['vpn_iface'])
print(' '.join(c['management_networks']))
AS_DEFAULTS_PY
        then
            { IFS= read -r default_iface; IFS= read -r value; } <"$TMP/as-defaults"
            default_networks="$value $default_networks"
        fi
    fi
    AS_SSH_PORT=${SSH_PORT:-}
    if [[ -z $AS_SSH_PORT ]]; then
        if value=$(check_sshd -T 2>/dev/null | awk '$1 == "port" && !seen { print $2; seen=1 }'); then
            default_port=${value:-22}
        fi
        while :; do
            ask "Действующий SSH-порт для исключений AS blacklist [$default_port]: " value
            value=${value:-$default_port}
            if [[ $value =~ ^[0-9]{1,5}$ ]] && (( 10#$value >= 1 && 10#$value <= 65535 )); then
                AS_SSH_PORT=$((10#$value)); break
            fi
        done
    fi
    say "$YELLOW" 'AS blacklist блокирует новые входящие соединения из списка. Это списки подсетей, а не распознавание всех ботов.'
    say "$YELLOW" 'VK блокируется только в FORWARD с указанного VPN-интерфейса. Для Xray без TUN этот путь не работает.'
    while :; do
        ask "Входящий VPN-интерфейс для блокировки VK [$default_iface]: " AS_VPN_IFACE
        AS_VPN_IFACE=${AS_VPN_IFACE:-$default_iface}
        [[ $AS_VPN_IFACE =~ ^[A-Za-z0-9_.:-]{1,15}$ ]] && break
    done
    say "$BLUE" 'Укажите IP/CIDR администратора и сервера Ansible через пробел; исключение действует только для SSH.'
    while :; do
        ask "Исключения SSH [${default_networks:-нет}; '-' — без исключений]: " AS_MANAGEMENT_NETWORKS
        AS_MANAGEMENT_NETWORKS=${AS_MANAGEMENT_NETWORKS:-$default_networks}
        [[ $AS_MANAGEMENT_NETWORKS != '-' ]] || AS_MANAGEMENT_NETWORKS=''
        if python3 - "$AS_VPN_IFACE" "$AS_SSH_PORT" "$AS_MANAGEMENT_NETWORKS" "$TMP/as-config.json" <<'AS_INPUT_PY'
import ipaddress, json, pathlib, sys
iface, port, raw, destination = sys.argv[1:]
try:
    nets = [ipaddress.ip_network(s, strict=False) for s in raw.replace(',', ' ').split()]
    if len(nets) > 64 or any(n.prefixlen == 0 for n in nets):
        raise ValueError('Too many networks or a default route')
except ValueError:
    print('Нужны корректные IP/CIDR; исключения 0.0.0.0/0 и ::/0 не допускаются.', file=sys.stderr)
    sys.exit(1)
pathlib.Path(destination).write_text(json.dumps({
    'vpn_iface': iface, 'ssh_port': int(port),
    'management_networks': sorted(set(map(str, nets)))}, indent=2) + '\n')
AS_INPUT_PY
        then break; fi
    done
    say "$BLUE" "AS/VK: интерфейс $AS_VPN_IFACE, SSH-порт $AS_SSH_PORT; обновление ежедневно и через 15 минут после старта."
}

wait_cloud_init() {
    local rc=0
    if ! command -v cloud-init >/dev/null; then
        printf 'cloud-init is not installed; nothing to wait for\n'
        return 0
    fi
    # Capture this particular exit code without suppressing other step errors.
    timeout 600 cloud-init status --wait --long || rc=$?
    case "$rc" in
        0) return 0 ;;
        2)
            printf 'WARNING: cloud-init completed with recoverable errors (exit 2); proceeding.\n'
            say "$YELLOW" "    cloud-init завершён с некритическими ошибками; подробности в $LOG. Продолжаю." >&3
            return 0 ;;
        *)
            printf 'cloud-init did not complete successfully (exit %s); package operations will be skipped.\n' "$rc" >&2
            return "$rc" ;;
    esac
}
unit_exists() {
    local load_state
    load_state=$(systemctl show --property=LoadState --value "$1") || return
    [[ -n $load_state && $load_state != not-found ]]
}
wait_for_apt_jobs() {
    local deadline=$((SECONDS + 900)) unit state busy
    while :; do
        busy=0
        for unit in "${AUTO_UPDATE_SERVICES[@]}"; do
            if unit_exists "$unit"; then
                state=$(systemctl show --property=ActiveState --value "$unit")
                case "$state" in active|activating|deactivating|reloading) busy=1 ;; esac
            fi
        done
        (( busy )) || return 0
        if (( SECONDS >= deadline )); then
            printf 'Timed out waiting for existing APT jobs; no APT/dpkg process was killed.\n' >&2
            return 124
        fi
        sleep 5
    done
}
disable_auto_updates() {
    local unit
    install -d -m 700 "$BACKUP/automatic-updates"
    if [[ -e $AUTO_UPDATE_CONFIG ]]; then
        cp -a "$AUTO_UPDATE_CONFIG" "$BACKUP/automatic-updates/previous-policy.conf"
    fi
    : >"$BACKUP/automatic-updates/units.before"
    for unit in "${AUTO_UPDATE_TIMERS[@]}" "${AUTO_UPDATE_SERVICES[@]}" unattended-upgrades.service; do
        if unit_exists "$unit"; then
            systemctl show --property=Id,LoadState,ActiveState,UnitFileState "$unit" \
                >>"$BACKUP/automatic-updates/units.before"
        fi
    done
    # Last-loaded APT override also covers cron's apt.systemd.daily invocation.
    cat >"$TMP/orchestrated-updates.conf" <<'EOF'
// Managed by VPS bootstrap: maintenance is performed by Ansible.
APT::Periodic::Enable "0";
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Download-Upgradeable-Packages "0";
APT::Periodic::Unattended-Upgrade "0";
APT::Periodic::AutocleanInterval "0";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
EOF
    install -m 644 "$TMP/orchestrated-updates.conf" "$AUTO_UPDATE_CONFIG"
    for unit in "${AUTO_UPDATE_TIMERS[@]}"; do
        if unit_exists "$unit"; then
            systemctl disable --now "$unit"
            systemctl mask "$unit"
        fi
    done
    # Mask without --now: an already running package transaction may finish.
    for unit in "${AUTO_UPDATE_SERVICES[@]}"; do
        if unit_exists "$unit"; then systemctl mask "$unit"; fi
    done
    wait_for_apt_jobs
    if unit_exists unattended-upgrades.service; then
        # This unit is a shutdown helper, not the apt-daily upgrade worker.
        systemctl disable --now unattended-upgrades.service
        systemctl mask unattended-upgrades.service
    fi
    check_auto_updates_disabled
}
check_auto_updates_disabled() {
    local unit state expected
    for unit in "${AUTO_UPDATE_TIMERS[@]}" "${AUTO_UPDATE_SERVICES[@]}" unattended-upgrades.service; do
        if unit_exists "$unit"; then
            state=$(systemctl show --property=UnitFileState --value "$unit")
            if [[ $state != masked ]]; then
                printf 'Automatic update unit is not persistently masked: %s (%s)\n' "$unit" "$state" >&2
                return 1
            fi
            state=$(systemctl show --property=ActiveState --value "$unit")
            case "$state" in
                inactive|failed) ;;
                *) printf 'Automatic update unit is still running: %s (%s)\n' "$unit" "$state" >&2; return 1 ;;
            esac
        fi
    done
    apt-config dump >"$TMP/apt-effective.conf"
    for expected in 'APT::Periodic::Enable "0";' 'APT::Periodic::Update-Package-Lists "0";' \
        'APT::Periodic::Download-Upgradeable-Packages "0";' 'APT::Periodic::Unattended-Upgrade "0";' \
        'APT::Periodic::AutocleanInterval "0";' 'Unattended-Upgrade::Automatic-Reboot "false";' \
        'Unattended-Upgrade::Automatic-Reboot-WithUsers "false";'; do
        if ! grep -Fxq "$expected" "$TMP/apt-effective.conf"; then
            printf 'APT override is missing or overridden elsewhere: %s\n' "$expected" >&2
            return 1
        fi
    done
}
update_system() {
    "${APT[@]}" update -o APT::Update::Error-Mode=any
    "${APT[@]}" -y upgrade
}
final_update_checks() {
    check_auto_updates_disabled
    "${APT[@]}" check
    dpkg --audit >"$TMP/dpkg-audit"
    if [[ -s $TMP/dpkg-audit ]]; then
        cat "$TMP/dpkg-audit" >&2
        return 1
    fi
    check_sshd -t
    systemctl is-active --quiet ssh.service
}
install_utilities() {
    "${APT[@]}" -y install vim nano curl htop unzip gnupg ufw lsb-release ca-certificates \
        openssh-server openssh-client python3 iproute2
}
install_keys() {
    local ssh_dir="$LOGIN_HOME/.ssh"
    [[ ! -L $ssh_dir && ! -L $ssh_dir/authorized_keys ]]
    install -d -m 700 -o "$LOGIN_UID" -g "$LOGIN_GID" "$ssh_dir"
    if [[ -f $ssh_dir/authorized_keys ]]; then
        cp -a "$ssh_dir/authorized_keys" "$BACKUP/authorized_keys"
    fi
    # Dedupe by key type + blob, including existing keys with authorized_keys options.
    python3 - "$ssh_dir/authorized_keys" "$TMP/keys" <<'PY'
import pathlib, sys
p, incoming = map(pathlib.Path, sys.argv[1:])
old = p.read_text() if p.exists() else ''
new = []
for line in incoming.read_text().splitlines():
    kind, blob, *_ = line.split()
    existing = [s.split() for s in old.splitlines() if not s.lstrip().startswith('#')]
    if any(any(parts[i:i+2] == [kind, blob] for i in range(1, len(parts)-1))
           for parts in existing):
        raise RuntimeError('An entered key already has authorized_keys restrictions; review it manually')
    if not any(parts[:2] == [kind, blob] for parts in existing):
        new.append(line)
if new:
    p.write_text(old + ('\n' if old and not old.endswith('\n') else '') + '\n'.join(new) + '\n')
elif not p.exists():
    p.touch()
PY
    chown "$LOGIN_UID:$LOGIN_GID" "$ssh_dir/authorized_keys"
    chmod 600 "$ssh_dir/authorized_keys"
    # StrictModes rejects a home directory writable by group/others.
    [[ $(stat -c %u "$LOGIN_HOME") == "$LOGIN_UID" || $(stat -c %u "$LOGIN_HOME") == 0 ]]
    chmod go-w "$LOGIN_HOME"
    ssh-keygen -lf "$ssh_dir/authorized_keys"
}
download_script() {
    curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --connect-timeout 20 --max-time 300 "$1" -o "$2"
    test -s "$2"
    sh -n "$2"
}
install_docker() {
    download_script https://get.docker.com "$TMP/docker-install.sh"
    ( umask 022; sh "$TMP/docker-install.sh" )
    systemctl enable --now docker
    docker info
    docker compose version
}
install_proxy() {
    if [[ $PROXY == nginx ]]; then
        "${APT[@]}" -y install nginx certbot python3-certbot-nginx
        nginx -t
        systemctl enable --now nginx
        systemctl is-active --quiet nginx
        if ! certbot show_account --non-interactive; then
            certbot register --non-interactive --agree-tos --no-eff-email --email "$EMAIL"
        fi
        systemctl enable --now certbot.timer
    else
        "${APT[@]}" -y install debian-keyring debian-archive-keyring apt-transport-https
        curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --max-time 300 \
            https://dl.cloudsmith.io/public/caddy/stable/gpg.key -o "$TMP/caddy.key"
        gpg --batch --yes --dearmor -o "$TMP/caddy.gpg" "$TMP/caddy.key"
        install -m 644 "$TMP/caddy.gpg" /usr/share/keyrings/caddy-stable-archive-keyring.gpg
        curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --max-time 300 \
            https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt -o "$TMP/caddy.list"
        install -m 644 "$TMP/caddy.list" /etc/apt/sources.list.d/caddy-stable.list
        "${APT[@]}" update -o APT::Update::Error-Mode=any
        "${APT[@]}" -y install caddy
        install -d -m 750 -o caddy -g caddy /var/log/caddy
        caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
        systemctl enable --now caddy
        systemctl is-active --quiet caddy
    fi
}
install_netbird() {
    download_script https://pkgs.netbird.io/install.sh "$TMP/netbird-install.sh"
    ( umask 022; sh "$TMP/netbird-install.sh" )
    systemctl enable --now netbird
    systemctl is-active --quiet netbird
}
connect_netbird() {
    timeout 180 netbird up --setup-key-file "$TMP/netbird.key" --management-url https://api.netbird.io:443
    rm -f "$TMP/netbird.key"
    netbird status
    ip -4 address show dev wt0 | grep -q 'inet '
}
check_netbird_status_json() {
    python3 - "$1" <<'NB_STATUS_PY'
import json, sys
try:
    with open(sys.argv[1]) as f:
        data = json.load(f)
except (OSError, ValueError) as exc:
    print(f'Cannot read NetBird status JSON: {exc}', file=sys.stderr)
    sys.exit(2)
management = data.get('management') if isinstance(data, dict) else None
connected = management.get('connected') if isinstance(management, dict) else None
if type(connected) is not bool:
    print('Unsupported NetBird status JSON: expected management.connected boolean; '
          'this does not prove that NetBird is disconnected.', file=sys.stderr)
    sys.exit(2)
print('NetBird management: ' + ('connected' if connected else 'disconnected'))
sys.exit(0 if connected else 1)
NB_STATUS_PY
}
wait_netbird_connected() {
    local attempt rc
    for attempt in {1..12}; do
        rc=0
        printf 'NetBird management check: attempt %s/12\n' "$attempt"
        if timeout 10 netbird status --json >"$TMP/netbird.status"; then
            check_netbird_status_json "$TMP/netbird.status" || rc=$?
            if (( rc == 0 )); then return 0; fi
            if (( rc == 2 )); then break; fi
        else
            rc=$?
            printf 'NetBird status command failed (exit %s)\n' "$rc" >&2
        fi
        if (( attempt < 12 )); then sleep 5; fi
    done
    printf 'NetBird verification failed. Collecting current state; no restart or re-login is performed.\n' >&2
    netbird version || true
    timeout 15 netbird status || true
    systemctl show netbird.service --property=ActiveState,SubState,Result || true
    if [[ -s $TMP/netbird.status ]]; then
        cp "$TMP/netbird.status" "${LOG%.log}.netbird-status.json" || true
        printf 'NetBird JSON snapshot: %s\n' "${LOG%.log}.netbird-status.json" >&2
    fi
    return 1
}

backup_network() {
    local item
    mkdir -p "$BACKUP/network"
    : >"$BACKUP/network/files"
    for item in etc/ssh/sshd_config etc/default/ufw etc/ufw \
        etc/systemd/system/ssh.service.d/00-socket.conf \
        etc/systemd/system/ssh.socket.d/addresses.conf \
        etc/sysctl.d/99-zz-vps-bootstrap-ipv6.conf; do
        [[ ! -e /$item ]] || printf '%s\n' "$item" >>"$BACKUP/network/files"
    done
    tar -C / -cpf "$BACKUP/network/config.tar" -T "$BACKUP/network/files"
    systemctl is-enabled ssh.socket >"$BACKUP/network/socket.enabled" || true
    systemctl is-active ssh.socket >"$BACKUP/network/socket.active" || true
    systemctl is-enabled ssh.service >"$BACKUP/network/ssh.enabled" || true
    ufw status >"$BACKUP/network/ufw.status"
    sysctl -a 2>/dev/null | awk '/^net.ipv6.conf\..+\.disable_ipv6 = [01]$/ {print}' \
        >"$BACKUP/network/ipv6.runtime"
    {
        printf '#!/usr/bin/env bash\nset -u\n'
        printf 'BACKUP=%q\n' "$BACKUP"
        printf 'YELLOW=""; RED=""; RESET=""\nexec 3>&1\n'
        printf '[[ $EUID == 0 ]] || { echo "Run with sudo bash"; exit 1; }\n'
        declare -f say rollback_network
        printf 'rollback_network\n'
    } >"$BACKUP/restore-network.sh"
    chmod 700 "$BACKUP/restore-network.sh"
}
rollback_network() {
    # Best-effort rollback; errors are logged and reported, never claimed away.
    local bad=0
    say "$YELLOW" 'Ошибка настройки сети: выполняется откат SSH/UFW/IPv6.' >&3
    ufw --force disable || bad=1
    rm -f /etc/sysctl.d/99-zz-vps-bootstrap-ipv6.conf
    tar -C / -xpf "$BACKUP/network/config.tar" || bad=1
    if [[ -s $BACKUP/network/ipv6.runtime ]]; then
        sysctl -p "$BACKUP/network/ipv6.runtime" || bad=1
    fi
    systemctl daemon-reload || bad=1
    systemctl stop ssh.service ssh.socket || bad=1
    if grep -qx enabled "$BACKUP/network/socket.enabled"; then
        systemctl enable ssh.socket || bad=1
    else systemctl disable ssh.socket || bad=1; fi
    if grep -qx enabled "$BACKUP/network/ssh.enabled"; then
        systemctl enable ssh.service || bad=1
    else systemctl disable ssh.service || bad=1; fi
    if grep -qx active "$BACKUP/network/socket.active"; then
        systemctl start ssh.socket || bad=1
    else systemctl start ssh.service || bad=1; fi
    if grep -qx 'Status: active' "$BACKUP/network/ufw.status"; then ufw --force enable || bad=1; fi
    if (( bad )); then
        say "$RED" "Откат завершился с ошибками. Используйте консоль провайдера и копию $BACKUP/network/config.tar" >&3
    else say "$YELLOW" 'Файлы SSH/UFW восстановлены; проверьте доступ и IPv6-адреса.' >&3; fi
    return "$bad"
}
network_abort() {
    local rc=$1
    trap - ERR INT TERM HUP
    set +e
    printf 'Network configuration failed (exit %s); attempting rollback\n' "$rc" >&2
    rollback_network
    exit "$rc"
}
configure_network() {
    # Validate the old configuration before starting a network transaction.
    check_sshd -t
    backup_network
    trap 'network_abort "$?"' ERR
    trap 'network_abort 130' INT
    trap 'network_abort 143' TERM
    trap 'network_abort 129' HUP
    local root_login=prohibit-password
    # Prepend managed global settings before cloud-init includes. Do not
    # overwrite unrelated provider settings; validate effective values below.
    python3 - "$SSH_PORT" "$root_login" <<'PY'
import pathlib, re, sys
p = pathlib.Path('/etc/ssh/sshd_config')
s = p.read_text()
s = re.sub(r'(?ms)^# BEGIN VPS-BOOTSTRAP\n.*?^# END VPS-BOOTSTRAP\n', '', s)
block = f'''# BEGIN VPS-BOOTSTRAP
Port {sys.argv[1]}
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitRootLogin {sys.argv[2]}
AuthorizedKeysFile .ssh/authorized_keys
# END VPS-BOOTSTRAP
'''
p.write_text(block + s)
PY
    check_sshd -t
    check_ssh_config
    # Rules exist before UFW is enabled and before the SSH listener changes.
    ufw --force reset
    if [[ $IPV6_MODE == disable ]]; then
        sed -i 's/^IPV6=.*/IPV6=no/' /etc/default/ufw
        cat >/etc/sysctl.d/99-zz-vps-bootstrap-ipv6.conf <<'EOF'
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
        sysctl -p /etc/sysctl.d/99-zz-vps-bootstrap-ipv6.conf
        check_ipv6_disabled
    else
        sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
    fi
    ufw default deny incoming
    ufw default allow outgoing
    ufw default deny routed
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw allow "$SSH_PORT/tcp"
    ufw allow in on wt0
    edit_icmp
    ufw --force enable
    ufw reload
    ufw status verbose
    # Use the conventional service, so a socket unit cannot retain port 22.
    systemctl disable --now ssh.socket
    rm -f /etc/systemd/system/ssh.service.d/00-socket.conf
    rm -f /etc/systemd/system/ssh.socket.d/addresses.conf
    systemctl daemon-reload
    systemctl enable ssh.service
    systemctl restart ssh.service
    systemctl is-active --quiet ssh.service
    check_ssh_listener
    ufw status | grep -qx 'Status: active'
    trap - ERR INT TERM HUP
}
check_ssh_config() {
    local remote=127.0.0.1 local_ip=127.0.0.1 unused setting
    if [[ -n ${SSH_CONNECTION:-} ]]; then
        read -r remote unused local_ip unused <<<"$SSH_CONNECTION"
    fi
    check_sshd -T -C "user=$LOGIN_USER,host=$remote,addr=$remote,laddr=$local_ip,lport=$SSH_PORT" >"$TMP/sshd.effective"
    for setting in "port $SSH_PORT" 'passwordauthentication no' \
        'kbdinteractiveauthentication no' 'pubkeyauthentication yes' \
        'authenticationmethods publickey' 'authorizedkeysfile .ssh/authorized_keys'; do
        if ! grep -qx "$setting" "$TMP/sshd.effective"; then
            printf 'Unexpected effective SSH setting; required: %s\n' "$setting" >&2
            cat "$TMP/sshd.effective" >&2
            return 1
        fi
    done
    if [[ $(grep -c '^port ' "$TMP/sshd.effective") != 1 ]]; then
        printf 'Additional Port directives found in SSH configuration\n' >&2
        return 1
    fi
    if [[ $LOGIN_USER == root ]]; then
        grep -Eq '^permitrootlogin (prohibit-password|without-password|yes)$' "$TMP/sshd.effective"
    fi
}
check_ssh_listener() {
    local i
    for i in {1..20}; do
        if ss -H -ltnp "sport = :$SSH_PORT" | grep -q 'sshd'; then break; fi
        sleep 1
    done
    ss -H -ltnp "sport = :$SSH_PORT" | grep -q 'sshd'
    # Local protocol check; it does NOT prove access through provider firewall.
    timeout 10 ssh-keyscan -T 5 -p "$SSH_PORT" 127.0.0.1 >"$TMP/ssh.hostkeys"
    test -s "$TMP/ssh.hostkeys"
    if ss -H -ltn "sport = :22" | grep -q .; then
        printf 'Unexpected listener on port 22\n' >&2
        return 1
    fi
}
check_ipv6_disabled() {
    local value
    for value in /proc/sys/net/ipv6/conf/*/disable_ipv6; do
        [[ $(cat "$value") == 1 ]]
    done
}
edit_icmp() {
    python3 - "$ICMP_MODE" "$IPV6_MODE" <<'PY'
import pathlib, re, sys
mode, ipv6 = sys.argv[1:]
types = {'echo-request'}
if mode == 'strict':
    types |= {'destination-unreachable', 'time-exceeded', 'parameter-problem', 'source-quench'}
p = pathlib.Path('/etc/ufw/before.rules')
text = p.read_text()
for chain in ('input', 'forward'):
    for kind in types:
        if chain == 'forward' and kind == 'source-quench':
            continue
        pattern = rf'(?m)^(-A ufw-before-{chain} -p icmp --icmp-type {kind} -j )(ACCEPT|DROP)\s*$'
        text, count = re.subn(pattern, r'\g<1>DROP', text)
        if not count:
            if kind == 'source-quench':
                text = text.replace('\nCOMMIT', f'\n-A ufw-before-{chain} -p icmp --icmp-type {kind} -j DROP\nCOMMIT', 1)
            else:
                raise RuntimeError(f'Missing expected UFW ICMP rule: {chain}/{kind}')
p.write_text(text)
if ipv6 == 'keep':
    p = pathlib.Path('/etc/ufw/before6.rules')
    text = p.read_text()
    text, count = re.subn(r'(?m)^(-A ufw6-before-(?:input|forward) -p icmpv6 --icmpv6-type echo-request -j )ACCEPT\s*$', r'\g<1>DROP', text)
    if count < 2:
        raise RuntimeError('Missing expected UFW IPv6 ping rules')
    p.write_text(text)
PY
}
install_kernel() {
    "${APT[@]}" update -o APT::Update::Error-Mode=any
    "${APT[@]}" -y --install-recommends install linux-generic-hwe-24.04
    dpkg-query -W -f='${Status}\n' linux-generic-hwe-24.04 | grep -qx 'install ok installed'
    # Preserve the running kernel and GA/cloud metapackages as a recovery path.
    # Package maintainer scripts generate initramfs and update the bootloader.
    find /boot -maxdepth 1 -name 'vmlinuz-*-generic' -type f | grep -q .
}
install_as_blacklist() {
    "${APT[@]}" update -o APT::Update::Error-Mode=any
    "${APT[@]}" -y install nftables curl python3
    [[ ! -L /opt/as_blacklist ]]
    install -d -m 700 /opt/as_blacklist "$BACKUP/as-blacklist"
    if unit_exists as-blacklist-update.timer; then systemctl stop as-blacklist-update.timer; fi
    exec 8>/opt/as_blacklist/.apply.lock
    flock -w 300 8
    local file
    for file in apply.sh build_rules.py config.json last-good.nft; do
        if [[ -f /opt/as_blacklist/$file ]]; then
            cp -a "/opt/as_blacklist/$file" "$BACKUP/as-blacklist/$file"
        fi
    done
    for file in as-blacklist-restore.service as-blacklist-update.service as-blacklist-update.timer; do
        if [[ -f /etc/systemd/system/$file ]]; then
            cp -a "/etc/systemd/system/$file" "$BACKUP/as-blacklist/$file"
        fi
    done
    cat >/opt/as_blacklist/apply.sh <<'AS_APPLY_SH'
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LC_ALL=C
BASE=/opt/as_blacklist
[[ $EUID == 0 ]] || { echo 'Run as root' >&2; exit 1; }
cd "$BASE"
exec 9>"$BASE/.apply.lock"
flock -w 300 9
WORK=$(mktemp -d "$BASE/.update.XXXXXX")
trap 'rm -rf -- "$WORK"' EXIT
case "${1:-}" in
    --restore)
        test -s "$BASE/last-good.nft"
        nft --check --file "$BASE/last-good.nft"
        nft --file "$BASE/last-good.nft"
        printf 'Cached AS blacklists restored at %s\n' "$(date -Is)"
        exit 0 ;;
    '') ;;
    *) echo 'Usage: apply.sh [--restore]' >&2; exit 2 ;;
esac
FILES=(blacklist.nft blacklist-v4.nft blacklist-v6.nft blacklist-vk.nft blacklist-vk-v4.nft blacklist-vk-v6.nft)
for name in "${FILES[@]}"; do
    curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
        --connect-timeout 15 --max-time 60 --retry 2 --retry-max-time 180 \
        --max-filesize 5242880 \
        "https://raw.githubusercontent.com/C24Be/AS_Network_List/main/blacklists_nftables/$name" \
        --output "$WORK/$name"
    test -s "$WORK/$name"
done
python3 "$BASE/build_rules.py" "$WORK" "$BASE/config.json" "$WORK/rules.nft"
nft --check --file "$WORK/rules.nft"
# One nft transaction: an error retains the old table, with no append duplicates.
nft --file "$WORK/rules.nft"
# Publish the boot cache only after successful application.
install -m 600 "$WORK/rules.nft" "$BASE/.last-good.nft.new"
mv -f "$BASE/.last-good.nft.new" "$BASE/last-good.nft"
for name in "${FILES[@]}"; do
    install -m 600 "$WORK/$name" "$BASE/$name"
done
printf 'AS and VK blacklists successfully updated at %s\n' "$(date -Is)"
AS_APPLY_SH
    cat >/opt/as_blacklist/build_rules.py <<'AS_BUILDER_PY'
#!/usr/bin/env python3
"""Read only network sets from upstream nft files; generate our own rules."""
import ipaddress
import json
import pathlib
import re
import sys

FILES = {
    'blacklist.nft': 'blacklist',
    'blacklist-v4.nft': 'blacklist',
    'blacklist-v6.nft': 'blacklist',
    'blacklist-vk.nft': 'blacklist_vk',
    'blacklist-vk-v4.nft': 'blacklist_vk',
    'blacklist-vk-v6.nft': 'blacklist_vk',
}
SET = re.compile(
    r'set\s+(\w+)\s*\{\s*type\s+(ipv[46]_addr)\s*;?\s*'
    r'flags\s+interval\s*;?\s*'
    r'(?:elements\s*=\s*\{([^{}]*)\}\s*;?\s*)?\}', re.S)


def parse_file(path, prefix):
    if not 0 < path.stat().st_size <= 5 * 1024 * 1024:
        raise ValueError(f'{path.name}: invalid file size')
    text = re.sub(r'#[^\n]*', '', path.read_text(encoding='utf-8'))
    table = re.fullmatch(r'\s*table\s+inet\s+filter\s*\{(.*)\}\s*', text, re.S)
    if not table:
        raise ValueError(f'{path.name}: unexpected nft table/commands')
    body = table.group(1)
    pos, sets = 0, {}
    for match in SET.finditer(body):
        if body[pos:match.start()].strip():
            raise ValueError(f'{path.name}: unexpected commands before a set')
        name, kind, elements = match.groups()
        version = 4 if kind == 'ipv4_addr' else 6
        if name != f'{prefix}_v{version}' or name in sets:
            raise ValueError(f'{path.name}: unexpected or duplicate set {name}')
        networks = set()
        if elements and elements.strip():
            parts = elements.strip().rstrip(',').split(',')
            for item in parts:
                network = ipaddress.ip_network(item.strip(), strict=True)
                if network.version != version or network.prefixlen == 0:
                    raise ValueError(f'{path.name}: unexpected network {network}')
                networks.add(network)
        sets[name] = networks
        pos = match.end()
    if body[pos:].strip() or set(sets) != {f'{prefix}_v4', f'{prefix}_v6'}:
        raise ValueError(f'{path.name}: incomplete sets or unexpected commands')
    return sets


def build(source_dir, config):
    iface = config['vpn_iface']
    port = config['ssh_port']
    if not isinstance(iface, str) or not re.fullmatch(r'[A-Za-z0-9_.:-]{1,15}', iface):
        raise ValueError('Invalid VPN interface')
    if type(port) is not int or not 1 <= port <= 65535:
        raise ValueError('Invalid SSH port')
    management = config['management_networks']
    if not isinstance(management, list) or not all(isinstance(x, str) for x in management):
        raise ValueError('Invalid SSH management networks')
    sets = {name: set() for name in ('blacklist_v4', 'blacklist_v6', 'blacklist_vk_v4', 'blacklist_vk_v6')}
    for filename, prefix in FILES.items():
        for name, nets in parse_file(source_dir / filename, prefix).items():
            sets[name].update(nets)
    for prefix in ('blacklist', 'blacklist_vk'):
        if not sets[prefix + '_v4'] and not sets[prefix + '_v6']:
            raise ValueError(f'{prefix}: empty blacklist; keeping last good version')
    sets['management_v4'], sets['management_v6'] = set(), set()
    for item in management:
        network = ipaddress.ip_network(item, strict=False)
        if network.prefixlen == 0:
            raise ValueError('A management exception must not cover the entire Internet')
        sets[f'management_v{network.version}'].add(network)
    # destroy is supported by Ubuntu 24.04 nftables 1.0.9, even if table is absent.
    lines = ['# Generated locally. Replace ONLY the dedicated AS blacklist table.',
             'destroy table inet as_blacklist', 'table inet as_blacklist {']
    for name, networks in sets.items():
        version = 4 if name.endswith('_v4') else 6
        lines += [f'    set {name} {{', f'        type ipv{version}_addr;', '        flags interval;']
        merged = list(ipaddress.collapse_addresses(networks))
        if merged:
            lines += ['        elements = { ' + ', '.join(map(str, merged)) + ' }']
        lines += ['    }']
    lines += [
        '    chain input {',
        '        type filter hook input priority -10; policy accept;',
        '        iifname "lo" accept',
        '        ct state established,related accept',
        f'        ip saddr @management_v4 tcp dport {port} counter accept',
        f'        ip6 saddr @management_v6 tcp dport {port} counter accept',
        '        ip saddr @blacklist_v4 counter reject',
        '        ip6 saddr @blacklist_v6 counter reject',
        '    }',
        '    chain forward {',
        '        type filter hook forward priority -10; policy accept;',
        f'        iifname {json.dumps(iface)} ip daddr @blacklist_vk_v4 counter reject',
        f'        iifname {json.dumps(iface)} ip6 daddr @blacklist_vk_v6 counter reject',
        '    }', '}', '',
    ]
    return '\n'.join(lines)


def main():
    source_dir, config_path, output = map(pathlib.Path, sys.argv[1:])
    config = json.loads(config_path.read_text())
    output.write_text(build(source_dir, config), encoding='utf-8')
    print('All six source files validated; generated dedicated inet as_blacklist table.')


if __name__ == '__main__':
    main()
AS_BUILDER_PY
    install -m 600 "$TMP/as-config.json" /opt/as_blacklist/config.json
    chmod 700 /opt/as_blacklist/apply.sh
    chmod 600 /opt/as_blacklist/build_rules.py
    flock -u 8
    exec 8>&-
    # First download, validation and application before enabling the timer.
    /opt/as_blacklist/apply.sh
    cat >/etc/systemd/system/as-blacklist-restore.service <<'AS_RESTORE_UNIT'
[Unit]
Description=Restore cached AS and VK blacklists before networking
DefaultDependencies=no
After=local-fs.target nftables.service
Before=network-pre.target ufw.service docker.service shutdown.target
Wants=network-pre.target
Conflicts=shutdown.target
ConditionPathExists=/opt/as_blacklist/last-good.nft

[Service]
Type=oneshot
ExecStart=/opt/as_blacklist/apply.sh --restore
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
AS_RESTORE_UNIT
    cat >/etc/systemd/system/as-blacklist-update.service <<'AS_UPDATE_UNIT'
[Unit]
Description=Update and apply AS and VK blacklists
After=network-online.target as-blacklist-restore.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/as_blacklist/apply.sh
TimeoutStartSec=20min
AS_UPDATE_UNIT
    cat >/etc/systemd/system/as-blacklist-update.timer <<'AS_TIMER_UNIT'
[Unit]
Description=Daily timer for AS and VK blacklists update

[Timer]
OnCalendar=daily
OnBootSec=15min
Persistent=true
Unit=as-blacklist-update.service

[Install]
WantedBy=timers.target
AS_TIMER_UNIT
    chmod 644 /etc/systemd/system/as-blacklist-{restore.service,update.service,update.timer}
    systemctl daemon-reload
    systemctl enable --now as-blacklist-restore.service as-blacklist-update.timer
    check_as_blacklist
    if ! ip link show dev "$AS_VPN_IFACE" >/dev/null 2>&1; then
        say "$YELLOW" "    Интерфейс $AS_VPN_IFACE пока отсутствует: правила VK начнут действовать после его появления." >&3
    fi
}
check_as_blacklist() {
    local object
    for object in blacklist_v4 blacklist_v6 blacklist_vk_v4 blacklist_vk_v6 management_v4 management_v6; do
        nft list set inet as_blacklist "$object"
    done
    nft list chain inet as_blacklist input
    nft list chain inet as_blacklist forward
    test -s /opt/as_blacklist/last-good.nft
    systemctl is-enabled --quiet as-blacklist-restore.service
    systemctl is-enabled --quiet as-blacklist-update.timer
    systemctl is-active --quiet as-blacklist-update.timer
    check_sshd -t
}
final_checks() {
    local unit
    printf 'CHECK: automatic update policy\n'
    check_auto_updates_disabled
    printf 'CHECK: AS blacklist\n'
    check_as_blacklist
    printf 'CHECK: SSH configuration and listener\n'
    check_sshd -t
    check_ssh_config
    check_ssh_listener
    for unit in ssh docker "$PROXY" netbird; do
        printf 'CHECK: service %s\n' "$unit"
        systemctl is-active --quiet "$unit"
        systemctl is-enabled --quiet "$unit"
    done
    ufw status | grep -qx 'Status: active'
    if [[ $IPV6_MODE == disable ]]; then check_ipv6_disabled; fi
    ip -4 address show dev wt0 | grep -q 'inet '
    wait_netbird_connected
    docker info
    if [[ $PROXY == nginx ]]; then nginx -t; else caddy validate --config /etc/caddy/Caddyfile; fi
}
main() {
    local finish_updates=0 blacklist_only=0
    case "${1:-}" in
        '') ;;
        --finish-updates) finish_updates=1 ;;
        --blacklist-only) blacklist_only=1 ;;
        *) die 'Использование: bash ubuntu24-bootstrap.sh [--finish-updates | --blacklist-only]' ;;
    esac
    (( $# <= 1 )) || die 'Передано слишком много аргументов.'
    [[ $EUID == 0 ]] || die 'Запустите: sudo bash ubuntu24-bootstrap.sh'
    [[ -t 0 && -t 1 ]] || die 'Нужен интерактивный терминал. Запускайте файл, не curl | bash.'
    exec 3<>/dev/tty
    # shellcheck source=/dev/null
    source /etc/os-release
    [[ $ID == ubuntu && $VERSION_ID == 24.04 ]] || die 'Поддерживается только Ubuntu 24.04 LTS.'
    [[ -d /run/systemd/system ]] || die 'Нужна VPS с systemd.'
    if systemd-detect-virt --container --quiet; then die 'Контейнеры LXC/OpenVZ/Docker не поддерживаются.'; fi
    local command_name
    for command_name in ssh-keygen ss flock getent python3; do
        command -v "$command_name" >/dev/null || die "До запуска необходима команда $command_name."
    done
    exec 9>/run/lock/vps-bootstrap.lock
    flock -n 9 || die 'Другой экземпляр скрипта уже работает.'
    TMP=$(mktemp -d /run/vps-bootstrap.XXXXXX)
    trap cleanup EXIT
    trap 'exit 130' INT
    trap 'exit 143' TERM
    trap 'exit 129' HUP
    if (( blacklist_only )); then
        say "$BLUE" 'VPS bootstrap 1.6.1: установка AS/VK blacklist на настроенную VPS'
        collect_blacklist_inputs
        yes_no 'Установить blacklists, включить таймер и перезагрузить VPS через минуту после успеха?' || exit 0
    elif (( finish_updates )); then
        say "$BLUE" 'VPS bootstrap 1.6.1: завершение обновления уже настроенной VPS'
        say "$BLUE" 'Будут отключены фоновые задания APT и выполнены apt update/upgrade с проверкой.'
        collect_blacklist_inputs
        yes_no 'Начать и перезагрузить VPS через минуту после успешного завершения?' || exit 0
    else
        collect_inputs
    fi
    local stamp base_ok keys_ok network_ok nb_ok preparation_ok
    stamp=$(date +%Y%m%d-%H%M%S)-$$
    LOG="/var/log/vps-bootstrap-$stamp.log"
    BACKUP="/var/backups/vps-bootstrap-$stamp"
    install -d -m 700 "$BACKUP"
    touch "$LOG"; chmod 600 "$LOG"
    printf 'VPS bootstrap version 1.6.1\n' >>"$LOG"
    say "$BLUE" "Журнал: $LOG"
    say "$BLUE" "Копии настроек: $BACKUP"
    run_step 'Ожидание завершения cloud-init' wait_cloud_init
    preparation_ok=$LAST_OK
    if (( preparation_ok && !blacklist_only )); then
        run_step 'Отключение автообновлений APT для управления через Ansible' disable_auto_updates
        preparation_ok=$LAST_OK
    fi
    if (( preparation_ok && !blacklist_only )); then
        run_step 'Обновление пакетов Ubuntu' update_system
    fi
    if (( preparation_ok && blacklist_only )); then
        run_step 'Установка AS/VK blacklists, восстановления при загрузке и таймера' install_as_blacklist
    elif (( preparation_ok && finish_updates )); then
        run_step 'Установка AS/VK blacklists, восстановления при загрузке и таймера' install_as_blacklist
        run_step 'Проверка пакетов, SSH и отключения автообновлений' final_update_checks
    elif (( preparation_ok )); then
        run_step 'Установка базовых утилит' install_utilities
        base_ok=$LAST_OK
        if (( base_ok )); then
            run_step 'Установка и проверка двух SSH-ключей' install_keys
            keys_ok=$LAST_OK
            run_step 'Установка Docker и Compose' install_docker
            run_step "Установка $PROXY" install_proxy
            run_step 'Установка NetBird' install_netbird
            nb_ok=$LAST_OK
            if (( nb_ok )); then run_step 'Подключение NetBird' connect_netbird; fi
            rm -f "$TMP/netbird.key"
            network_ok=0
            if (( keys_ok )); then
                run_step 'Настройка SSH, UFW, IPv6 и ICMP с проверкой и откатом при ошибке' configure_network
                network_ok=$LAST_OK
            else say "$YELLOW" 'Сетевая настройка пропущена: SSH-ключи не установлены.'; fi
            if [[ $HWE == yes ]]; then run_step 'Установка последнего доступного HWE-ядра Ubuntu 24.04' install_kernel; fi
            if (( network_ok )); then run_step 'Установка AS/VK blacklists, восстановления при загрузке и таймера' install_as_blacklist; fi
            if (( network_ok )); then run_step 'Итоговая проверка служб и доступа SSH' final_checks; fi
        else
            say "$YELLOW" 'Зависимые этапы пропущены: установка базовых утилит не завершена.'
        fi
    else
        say "$YELLOW" 'Остальные этапы пропущены: подготовка пакетного менеджера не завершена.'
    fi
    if (( ${#FAILURES[@]} )); then
        say "$RED" 'Завершено с ошибками; автоматическая перезагрузка отменена:'
        printf '  • %s\n' "${FAILURES[@]}"
        say "$YELLOW" "Исправьте ошибки по журналу $LOG. Копии: $BACKUP"
        exit 1
    fi
    say "$GREEN" 'Все этапы успешно завершены. Перезагрузка через одну минуту.'
    if (( finish_updates || blacklist_only )); then
        say "$BLUE" 'После загрузки подключайтесь с прежними пользователем, SSH-портом и ключом.'
    else
        say "$BLUE" "Подключение после загрузки: ssh -p $SSH_PORT $LOGIN_USER@IP_СЕРВЕРА"
    fi
    say "$BLUE" 'Отменить запланированную перезагрузку: sudo shutdown -c'
    if ! shutdown -r +1 'VPS bootstrap completed' >>"$LOG" 2>&1; then
        die "Не удалось запланировать перезагрузку. Журнал: $LOG"
    fi
}

# bash -c has no BASH_SOURCE[0]; default to $0 while preserving source-only tests.
if [[ ${BASH_SOURCE[0]:-$0} == "$0" ]]; then main "$@"; fi
