#!/usr/bin/env bash set -Eeuo pipefail readonly SWAPFILE="/swapfile" readonly SWAPSIZE="2G" readonly SWAPPINESS="20" readonly SYSCTL_FILE="/etc/sysctl.d/60-hermes-swap.conf" readonly CONFIG_DIR="/etc/ochenstarik-server" readonly LOCALE_BACKUP="${CONFIG_DIR}/locale-before-selection.conf" readonly LOCALE_ABSENT_MARKER="${CONFIG_DIR}/locale-before-selection.absent" readonly LOCALE_PACKAGES="${CONFIG_DIR}/locale-installed-packages.list" readonly LEGACY_LOCALE_BACKUP="${CONFIG_DIR}/locale-before-russian.conf" readonly LEGACY_LOCALE_ABSENT_MARKER="${CONFIG_DIR}/locale-before-russian.absent" readonly LEGACY_LOCALE_PACKAGES="${CONFIG_DIR}/russian-locale-installed-packages.list" readonly PROGRAM_PACKAGES="${CONFIG_DIR}/step1-installed-packages.list" readonly -a PROGRAM_GROUP_TITLES=( "Терминал и повседневная работа" "Диагностика сети" "Мониторинг системы и дисков" "Безопасность и обслуживание Ubuntu" "Разработка и автоматизация" "Архивы, таблицы и локальные данные" "Документы, PDF и OCR" "Изображения, аудио и видео" "Резервное копирование и синхронизация" ) readonly -a PROGRAM_GROUP_DESCRIPTIONS=( "git, wget, curl, jq/yq, ripgrep/fd/fzf, lnav, редакторы, mc, tmux, htop/btop, ncdu, rsync" "DNS/ping/MTR/traceroute, tcpdump, nmap, netcat, HTTPie, whois, ethtool и socat" "iotop, sysstat, SMART, датчики, atop, nethogs, vnStat и инструменты процессов" "auditd, AppArmor tools, Lynis, needrestart, автоматические обновления, debsums, ACL/xattr" "компилятор, ShellCheck, Python pip/venv и pipx" "7-Zip/ZIP/RAR, SQLite, csvkit, клиенты PostgreSQL и Redis" "Poppler, qpdf, Ghostscript, OCRmyPDF/Tesseract, LibreOffice, Pandoc, antiword/catdoc" "ImageMagick, ExifTool, WebP, FFmpeg и MediaInfo" "restic, BorgBackup и rclone" ) readonly -a PROGRAM_GROUP_PACKAGES=( "git wget curl ca-certificates jq yq ripgrep fd-find fzf lnav tree less nano vim mc tmux screen htop btop ncdu lsof strace rsync" "dnsutils iputils-ping mtr-tiny traceroute tcpdump nmap netcat-openbsd httpie whois ethtool socat" "iotop sysstat smartmontools lm-sensors atop nethogs vnstat psmisc" "auditd apparmor-utils lynis needrestart unattended-upgrades debsums acl attr" "build-essential shellcheck python3-pip python3-venv pipx" "p7zip-full unzip zip unrar sqlite3 csvkit postgresql-client redis-tools" "poppler-utils qpdf ghostscript ocrmypdf tesseract-ocr tesseract-ocr-eng tesseract-ocr-rus libreoffice pandoc antiword catdoc" "imagemagick libimage-exiftool-perl webp ffmpeg mediainfo" "restic borgbackup rclone" ) APT_UPDATED=no log() { printf '[+] %s\n' "$*"; } warn() { printf '[!] %s\n' "$*" >&2; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } package_is_installed() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -Fqx 'install ok installed' } ensure_package_tools() { local command_name for command_name in apt-get apt-cache dpkg-query; do command -v "$command_name" >/dev/null 2>&1 \ || die "Required command not found: $command_name" done } ensure_apt_updated() { [[ "$APT_UPDATED" == no ]] || return 0 ensure_package_tools log "Обновление списка пакетов" apt-get update APT_UPDATED=yes } record_new_packages() { local destination="$1" package_name shift install -d -m 700 -o root -g root "$CONFIG_DIR" [[ ! -L "$destination" ]] || die "Отказ от записи через ссылку: $destination" touch "$destination" for package_name in "$@"; do grep -Fqx -- "$package_name" "$destination" \ || printf '%s\n' "$package_name" >> "$destination" done chown root:root "$destination" chmod 600 "$destination" } migrate_legacy_locale_state() { local package_name install -d -m 700 -o root -g root "$CONFIG_DIR" [[ ! -L "$LOCALE_BACKUP" && ! -L "$LOCALE_ABSENT_MARKER" \ && ! -L "$LOCALE_PACKAGES" ]] || die "Файл состояния локали не должен быть ссылкой" if [[ -f "$LEGACY_LOCALE_BACKUP" && ! -L "$LEGACY_LOCALE_BACKUP" \ && ! -e "$LOCALE_BACKUP" ]]; then mv -- "$LEGACY_LOCALE_BACKUP" "$LOCALE_BACKUP" fi if [[ -f "$LEGACY_LOCALE_ABSENT_MARKER" \ && ! -L "$LEGACY_LOCALE_ABSENT_MARKER" && ! -e "$LOCALE_ABSENT_MARKER" ]]; then mv -- "$LEGACY_LOCALE_ABSENT_MARKER" "$LOCALE_ABSENT_MARKER" fi if [[ -f "$LEGACY_LOCALE_PACKAGES" && ! -L "$LEGACY_LOCALE_PACKAGES" ]]; then touch "$LOCALE_PACKAGES" while IFS= read -r package_name || [[ -n "$package_name" ]]; do [[ -z "$package_name" ]] || grep -Fqx -- "$package_name" "$LOCALE_PACKAGES" \ || printf '%s\n' "$package_name" >> "$LOCALE_PACKAGES" done < "$LEGACY_LOCALE_PACKAGES" rm -f -- "$LEGACY_LOCALE_PACKAGES" fi } save_original_locale() { migrate_legacy_locale_state if [[ ! -e "$LOCALE_BACKUP" && ! -e "$LOCALE_ABSENT_MARKER" ]]; then if [[ -f /etc/default/locale && ! -L /etc/default/locale ]]; then cp -a -- /etc/default/locale "$LOCALE_BACKUP" chmod 600 "$LOCALE_BACKUP" else : > "$LOCALE_ABSENT_MARKER" chmod 600 "$LOCALE_ABSENT_MARKER" fi fi } choose_terminal_language() { local answer selected_locale selected_language primary_language package_name index local current_locale="${LANG:-не задана}" local -a titles=( "Не менять язык терминала" "Русский — ru_RU.UTF-8" "English (US) — en_US.UTF-8" "Deutsch — de_DE.UTF-8" "Français — fr_FR.UTF-8" "Español — es_ES.UTF-8" "Italiano — it_IT.UTF-8" "Português (Brasil) — pt_BR.UTF-8" "Polski — pl_PL.UTF-8" "Українська — uk_UA.UTF-8" "Türkçe — tr_TR.UTF-8" "简体中文 — zh_CN.UTF-8" "日本語 — ja_JP.UTF-8" "한국어 — ko_KR.UTF-8" "Другая локаль из полного списка" ) local -a locales=( "" ru_RU.UTF-8 en_US.UTF-8 de_DE.UTF-8 fr_FR.UTF-8 es_ES.UTF-8 it_IT.UTF-8 pt_BR.UTF-8 pl_PL.UTF-8 uk_UA.UTF-8 tr_TR.UTF-8 zh_CN.UTF-8 ja_JP.UTF-8 ko_KR.UTF-8 "" ) local -a language_values=( "" ru_RU:ru en_US:en de_DE:de fr_FR:fr es_ES:es it_IT:it pt_BR:pt pl_PL:pl uk_UA:uk tr_TR:tr zh_CN:zh ja_JP:ja ko_KR:ko "" ) local -a language_packages=( "" "language-pack-ru manpages-ru" language-pack-en language-pack-de language-pack-fr language-pack-es language-pack-it language-pack-pt language-pack-pl language-pack-uk language-pack-tr language-pack-zh-hans language-pack-ja language-pack-ko "" ) local -a supported_locales packages=(locales) available_packages=() \ newly_installed=() extra_packages=() while :; do printf '\nВыберите язык новых терминальных сессий (сейчас: %s):\n' "$current_locale" for index in "${!titles[@]}"; do printf ' %d) %s\n' "$((index + 1))" "${titles[index]}" done read -rp 'Номер языка [1 — не менять]: ' answer || die "Ввод прерван" answer="${answer:-1}" [[ "$answer" =~ ^[0-9]+$ ]] && ((answer >= 1 && answer <= ${#titles[@]})) && break warn "Введите номер от 1 до ${#titles[@]}" done ((answer == 1)) && { log "Язык терминала не изменён"; return 0; } ensure_apt_updated save_original_locale if ((answer == ${#titles[@]})); then package_is_installed locales || newly_installed+=(locales) apt-get install -y locales mapfile -t supported_locales < <(awk '{print $1}' /usr/share/i18n/SUPPORTED | sort -u) choose_numbered_option "Выберите локаль из полного списка:" selected_locale \ "${supported_locales[@]}" primary_language="${selected_locale%%_*}" selected_language="${selected_locale%%.*}:${primary_language}" packages+=("language-pack-${primary_language}") else selected_locale="${locales[answer - 1]}" selected_language="${language_values[answer - 1]}" read -r -a extra_packages <<< "${language_packages[answer - 1]}" packages+=("${extra_packages[@]}") fi for package_name in "${packages[@]}"; do [[ -n "$package_name" ]] || continue if apt-cache show "$package_name" >/dev/null 2>&1; then available_packages+=("$package_name") package_is_installed "$package_name" || newly_installed+=("$package_name") else warn "Языковой пакет недоступен и будет пропущен: $package_name" fi done export DEBIAN_FRONTEND=noninteractive ((${#available_packages[@]} == 0)) || apt-get install -y "${available_packages[@]}" locale-gen "$selected_locale" update-locale LANG="$selected_locale" LANGUAGE="$selected_language" \ LC_MESSAGES="$selected_locale" record_new_packages "$LOCALE_PACKAGES" "${newly_installed[@]}" log "Выбрана локаль ${selected_locale}. Она применится при следующем входе" } choose_and_install_programs() { local answer token group_index package_name local -a selected_groups=() group_packages=() requested=() available=() newly_installed=() local -A selected=() seen=() printf '\nВыберите наборы программ для установки:\n' for group_index in "${!PROGRAM_GROUP_TITLES[@]}"; do printf ' %d) %s\n %s\n' "$((group_index + 1))" \ "${PROGRAM_GROUP_TITLES[group_index]}" "${PROGRAM_GROUP_DESCRIPTIONS[group_index]}" done printf ' 0) Не устанавливать дополнительные программы\n' printf '\nВведите номера через пробел, например: 1 2 4 9.\n' read -rp 'Выбор [Enter — установить все наборы]: ' answer || die "Ввод прерван" answer="${answer//,/ }" if [[ -z "${answer//[[:space:]]/}" ]]; then for group_index in "${!PROGRAM_GROUP_TITLES[@]}"; do selected_groups+=("$group_index") done elif [[ "$answer" =~ ^[[:space:]]*0[[:space:]]*$ ]]; then log "Установка дополнительных программ пропущена" return 0 else for token in $answer; do [[ "$token" =~ ^[0-9]+$ ]] \ || die "Некорректный номер набора: $token" ((token >= 1 && token <= ${#PROGRAM_GROUP_TITLES[@]})) \ || die "Номер набора вне диапазона: $token" group_index="$((token - 1))" [[ -n "${selected[$group_index]:-}" ]] || selected_groups+=("$group_index") selected[$group_index]=yes done fi ensure_apt_updated for group_index in "${selected_groups[@]}"; do log "Выбран набор: ${PROGRAM_GROUP_TITLES[group_index]}" read -r -a group_packages <<< "${PROGRAM_GROUP_PACKAGES[group_index]}" for package_name in "${group_packages[@]}"; do [[ -n "${seen[$package_name]:-}" ]] && continue seen[$package_name]=yes requested+=("$package_name") done done for package_name in "${requested[@]}"; do if apt-cache show "$package_name" >/dev/null 2>&1; then available+=("$package_name") package_is_installed "$package_name" || newly_installed+=("$package_name") else warn "Пакет отсутствует в подключённых репозиториях и пропущен: $package_name" fi done ((${#available[@]} > 0)) || die "Ни один выбранный пакет не найден" export DEBIAN_FRONTEND=noninteractive apt-get install -y "${available[@]}" record_new_packages "$PROGRAM_PACKAGES" "${newly_installed[@]}" log "Выбранные программы установлены" } choose_numbered_option() { local prompt="$1" result_variable="$2" answer index shift 2 local -a options=("$@") ((${#options[@]} > 0)) || die "No options are available for: ${prompt}" while :; do printf '\n%s\n' "$prompt" for index in "${!options[@]}"; do printf ' %d) %s\n' "$((index + 1))" "${options[index]}" done read -rp "Enter the option number: " answer || die "Input was interrupted" if [[ "$answer" =~ ^[0-9]+$ ]] && ((answer >= 1 && answer <= ${#options[@]})); then printf -v "$result_variable" '%s' "${options[answer - 1]}" return 0 fi warn "Enter a number from 1 to ${#options[@]}" done } choose_timezone_from_full_list() { local result_variable="$1" region selected_timezone local -a regions region_timezones mapfile -t regions < <(timedatectl list-timezones | awk -F/ 'NF > 1 && !seen[$1]++ { print $1 }') choose_numbered_option "Select a region:" region "${regions[@]}" mapfile -t region_timezones < <(timedatectl list-timezones | awk -F/ -v selected_region="$region" '$1 == selected_region') choose_numbered_option "Select a timezone in ${region}:" selected_timezone "${region_timezones[@]}" printf -v "$result_variable" '%s' "$selected_timezone" } choose_timezone() { local current_timezone timezone candidate local full_list_option="Other timezone (select by region)" local -a timezone_options=() current_timezone="$(timedatectl show --property=Timezone --value 2>/dev/null || true)" current_timezone="${current_timezone:-Asia/Novosibirsk}" for candidate in \ "$current_timezone" \ UTC \ Europe/Moscow \ Europe/Kaliningrad \ Asia/Yekaterinburg \ Asia/Omsk \ Asia/Novosibirsk \ Asia/Krasnoyarsk \ Asia/Irkutsk \ Asia/Yakutsk \ Asia/Vladivostok \ Asia/Magadan \ Asia/Kamchatka; do if timedatectl list-timezones | grep -Fqx -- "$candidate" && [[ ! " ${timezone_options[*]} " =~ " ${candidate} " ]]; then timezone_options+=("$candidate") fi done timezone_options+=("$full_list_option") choose_numbered_option "Select the server timezone (current: ${current_timezone}):" timezone "${timezone_options[@]}" if [[ "$timezone" == "$full_list_option" ]]; then choose_timezone_from_full_list timezone fi timedatectl list-timezones | grep -Fqx -- "$timezone" || die "Unknown timezone: $timezone" timedatectl set-timezone "$timezone" log "Timezone set to ${timezone}" } [[ "$EUID" -eq 0 ]] || die "Run this script as root" for command_name in awk blkid fallocate grep mkswap swapon sysctl timedatectl; do command -v "$command_name" >/dev/null 2>&1 || die "Required command not found: $command_name" done choose_timezone choose_terminal_language choose_and_install_programs log "Configuring ${SWAPSIZE} swap file" if [[ ! -e "$SWAPFILE" ]]; then fallocate -l "$SWAPSIZE" "$SWAPFILE" chmod 600 "$SWAPFILE" mkswap "$SWAPFILE" elif [[ ! -f "$SWAPFILE" || -L "$SWAPFILE" ]]; then die "$SWAPFILE exists but is not a regular non-symlink file" else chmod 600 "$SWAPFILE" swap_type="$(blkid -p -s TYPE -o value "$SWAPFILE" 2>/dev/null || true)" [[ "$swap_type" == swap ]] || die "$SWAPFILE exists but does not contain a swap signature" fi if ! swapon --show=NAME --noheadings | awk '{$1=$1}; $0 == "/swapfile" { found=1 } END { exit !found }'; then swapon "$SWAPFILE" fi if ! awk '$1 == "/swapfile" && $3 == "swap" { found=1 } END { exit !found }' /etc/fstab; then cp -a /etc/fstab "/etc/fstab.bak.$(date +%F-%H%M%S-%N)" printf '%s none swap sw 0 0\n' "$SWAPFILE" >> /etc/fstab fi cat > "$SYSCTL_FILE" </dev/null log "Server memory setup is complete" printf 'Timezone: %s\n' "$(timedatectl show --property=Timezone --value)" swapon --show printf '\nNow run ochenstarik-server-2.sh as root.\n'