lightweight-server/ochenstarik-server-1.sh
2026-07-15 13:41:11 +07:00

391 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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" <<EOF
# Managed by ochenstarik-server-1.sh
vm.swappiness=${SWAPPINESS}
EOF
chmod 644 "$SYSCTL_FILE"
sysctl -p "$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'