From 138affaa54422fcff35cab3022b843c060d6d5a4 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 14 Jul 2026 13:11:28 +0700 Subject: [PATCH] Publish modular server setup scripts --- .gitattributes | 4 + .gitignore | 19 ++ README.md | 196 ++++++++++++- Ubuntu server.txt | 13 + ochenstarik-server-1.sh | 48 ++++ ochenstarik-server-2.sh | 45 +++ ochenstarik-server-3.env.example | 6 + ochenstarik-server-4.env.example | 3 + ochenstarik-server-tg-4.sh | 193 +++++++++++++ ochenstarik-server-user-3.sh | 340 +++++++++++++++++++++++ ochenstarik-server-vpn-5.sh | 461 +++++++++++++++++++++++++++++++ 11 files changed, 1327 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Ubuntu server.txt create mode 100644 ochenstarik-server-1.sh create mode 100644 ochenstarik-server-2.sh create mode 100644 ochenstarik-server-3.env.example create mode 100644 ochenstarik-server-4.env.example create mode 100644 ochenstarik-server-tg-4.sh create mode 100644 ochenstarik-server-user-3.sh create mode 100644 ochenstarik-server-vpn-5.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3f33c4f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +*.env.example text eol=lf +*.md text eol=lf +*.txt text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bd4e1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Local configuration and secrets +.env +.env.* +!*.env.example +env.txt +password.hash +telegram.conf + +# Keys and certificates +*.key +*.pem +id_rsa* +id_ed25519* + +# Editor and operating-system files +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index eddfa33..38d3922 100644 --- a/README.md +++ b/README.md @@ -1 +1,195 @@ -# lightweight-server \ No newline at end of file +# Быстрая настройка Ubuntu Server + +Набор независимых Bash-скриптов для первоначальной настройки Ubuntu Server. Можно запустить только нужные модули или выполнить их по порядку для полной настройки. + +## Что входит в проект + +| Файл | Назначение | Можно запускать отдельно | +| --- | --- | --- | +| `ochenstarik-server-1.sh` | Создаёт swap-файл 2 ГБ и задаёт `vm.swappiness=20` | Да | +| `ochenstarik-server-2.sh` | Обновляет систему, ставит серверные и офисные пакеты, настраивает часовой пояс и UFW | Да | +| `ochenstarik-server-user-3.sh` | Создаёт администратора, устанавливает SSH-ключ, переносит SSH на порт 20202, включает fail2ban | После установки необходимых пакетов; проще всего сначала запустить скрипт 2 | +| `ochenstarik-server-tg-4.sh` | Отправляет уведомления в Telegram при успешном входе по SSH | После установки OpenSSH и `curl`; обычно после скрипта 2 или 3 | +| `ochenstarik-server-vpn-5.sh` | Устанавливает Xray и направляет системный трафик через VLESS + REALITY | Да; зависимости устанавливаются автоматически | + +Файлы `*.env.example` являются только примерами. Не записывайте настоящие пароли, токены и приватные SSH-ключи в Git. + +## Требования + +- Ubuntu Server или совместимая Debian-система с `systemd`; +- доступ `root` или пользователь с `sudo`; +- подключение к интернету; +- рекомендуется сохранить текущую SSH-сессию открытой до проверки нового входа. + +## Получение проекта + +```bash +sudo apt-get update +sudo apt-get install -y git +git clone https://github.com/ochenstarik-ui/lightweight-server.git +cd lightweight-server +chmod 700 ./*.sh +``` + +Перед запуском любого выбранного файла можно проверить его синтаксис: + +```bash +bash -n ./ИМЯ-СКРИПТА.sh +``` + +## 1. Настройка swap + +Файл: `ochenstarik-server-1.sh`. + +Создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен. + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-1.sh +chmod 700 ochenstarik-server-1.sh +bash -n ochenstarik-server-1.sh +sudo ./ochenstarik-server-1.sh +``` + +Проверка: + +```bash +swapon --show +sysctl vm.swappiness +``` + +## 2. Базовые пакеты, часовой пояс и брандмауэр + +Файл: `ochenstarik-server-2.sh`. + +Скрипт обновляет систему, устанавливает OpenSSH, UFW, fail2ban, инструменты обработки документов, изображений, аудио и видео, а также задаёт часовой пояс `Asia/Novosibirsk`. + +Открываются TCP-порты: + +- `22` — временный SSH-порт; +- `20202` — новый SSH-порт; +- `80`, `443` — HTTP и HTTPS; +- `2096`, `40000`, `63636` — порты приложений. + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-2.sh +chmod 700 ochenstarik-server-2.sh +bash -n ochenstarik-server-2.sh +sudo ./ochenstarik-server-2.sh +``` + +Проверка: + +```bash +sudo ufw status verbose +timedatectl +``` + +## 3. Администратор и защита SSH + +Файл: `ochenstarik-server-user-3.sh`. + +Скрипт интерактивно запрашивает имя нового администратора, SSH-ключ и пароль. Затем он: + +- создаёт или обновляет пользователя; +- добавляет пользователя в группу `sudo`; +- запрещает вход `root` по SSH; +- переносит SSH на порт `20202`; +- настраивает fail2ban; +- по выбору управляет правилами UFW. + +Для отдельного запуска сначала установите зависимости: + +```bash +sudo apt-get update +sudo apt-get install -y sudo ufw fail2ban openssl openssh-server +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-user-3.sh +chmod 700 ochenstarik-server-user-3.sh +bash -n ochenstarik-server-user-3.sh +sudo ./ochenstarik-server-user-3.sh +``` + +Не закрывая текущую сессию, откройте второй терминал и проверьте вход: + +```bash +ssh -p 20202 ИМЯ_ПОЛЬЗОВАТЕЛЯ@IP_СЕРВЕРА +``` + +Только после успешной проверки можно удалить временное правило порта 22: + +```bash +sudo ufw delete allow 22/tcp +``` + +## 4. Telegram-уведомления о входах по SSH + +Файл: `ochenstarik-server-tg-4.sh`. + +Создайте бота через `@BotFather`, отправьте ему сообщение и узнайте ID чата. Скрипт запросит токен и ID интерактивно, отправит тестовое сообщение и подключит уведомления через PAM. + +Для отдельного запуска: + +```bash +sudo apt-get update +sudo apt-get install -y curl openssh-server logrotate +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-tg-4.sh +chmod 700 ochenstarik-server-tg-4.sh +bash -n ochenstarik-server-tg-4.sh +sudo ./ochenstarik-server-tg-4.sh +``` + +Журнал уведомлений: + +```bash +sudo tail -f /var/log/ochenstarik-ssh-login-telegram.log +``` + +Токен сохраняется на сервере в `/etc/ochenstarik-server/telegram.conf` с ограниченными правами. Не добавляйте этот файл в репозиторий. + +## 5. Системный VPN через Xray + +Файл: `ochenstarik-server-vpn-5.sh`. + +Скрипт устанавливает Xray и необходимые пакеты, принимает прямую ссылку `vless://` либо HTTPS-ссылку подписки 3x-ui и настраивает маршрутизацию через `nftables`. Поддерживается VLESS с транспортом TCP/RAW и REALITY. + +```bash +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-vpn-5.sh +chmod 700 ochenstarik-server-vpn-5.sh +bash -n ochenstarik-server-vpn-5.sh +sudo ./ochenstarik-server-vpn-5.sh +``` + +Во время запуска ссылка вводится скрыто. Скрипт сравнивает внешний IP до и после настройки и использует автоматический откат при ошибке. SSH и ответы сервисов на портах `443` и `63636` остаются на прямом маршруте. + +Проверка маршрутизации: + +```bash +sudo /usr/local/sbin/ochenstarik-xray-routing status +curl -4 https://api.ipify.org +``` + +Отключение системного VPN: + +```bash +sudo ./ochenstarik-server-vpn-5.sh --disable +``` + +## Полная установка + +Если нужны все модули, запускайте их по очереди: + +```bash +sudo ./ochenstarik-server-1.sh +sudo ./ochenstarik-server-2.sh +sudo ./ochenstarik-server-user-3.sh +sudo ./ochenstarik-server-tg-4.sh +sudo ./ochenstarik-server-vpn-5.sh +``` + +VPN и Telegram-уведомления являются необязательными этапами. + +## Безопасность + +- Всегда проверяйте скачанные скрипты перед запуском от `root`. +- Не публикуйте токены Telegram, пароли, приватные SSH-ключи и ссылки VLESS. +- Не закрывайте действующую SSH-сессию во время переноса SSH на новый порт. +- Перед настройкой на рабочем сервере сделайте резервную копию важных данных. diff --git a/Ubuntu server.txt b/Ubuntu server.txt new file mode 100644 index 0000000..cc70a56 --- /dev/null +++ b/Ubuntu server.txt @@ -0,0 +1,13 @@ +Актуальная инструкция находится в README.md: +https://github.com/ochenstarik-ui/lightweight-server#readme + +Можно установить только нужный модуль. Пример для базовой настройки: + +sudo apt-get update +sudo apt-get install -y curl +curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-2.sh +chmod 700 ochenstarik-server-2.sh +bash -n ochenstarik-server-2.sh +sudo ./ochenstarik-server-2.sh + +Ссылки на отдельную установку остальных модулей приведены в README.md. diff --git a/ochenstarik-server-1.sh b/ochenstarik-server-1.sh new file mode 100644 index 0000000..90c6551 --- /dev/null +++ b/ochenstarik-server-1.sh @@ -0,0 +1,48 @@ +#!/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" + +log() { printf '[+] %s\n' "$*"; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +[[ "$EUID" -eq 0 ]] || die "Run this script as root" +for command_name in awk blkid fallocate mkswap swapon sysctl; do + command -v "$command_name" >/dev/null 2>&1 || die "Required command not found: $command_name" +done + +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" +swapon --show +printf '\nNow run ochenstarik-server-2.sh as root.\n' \ No newline at end of file diff --git a/ochenstarik-server-2.sh b/ochenstarik-server-2.sh new file mode 100644 index 0000000..0ca929b --- /dev/null +++ b/ochenstarik-server-2.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly SSH_PORT="20202" +readonly TIMEZONE="Asia/Novosibirsk" + +log() { printf '[+] %s\n' "$*"; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +[[ "$EUID" -eq 0 ]] || die "Run this script as root" +export DEBIAN_FRONTEND=noninteractive + +log "Updating the operating system" +apt-get update +apt-get upgrade -y + +log "Installing server and document-processing packages" +apt-get install -y \ + sudo ufw fail2ban curl ca-certificates openssl openssh-server logrotate \ + poppler-utils qpdf ghostscript ocrmypdf \ + tesseract-ocr tesseract-ocr-eng tesseract-ocr-rus \ + libreoffice pandoc antiword catdoc imagemagick libimage-exiftool-perl webp \ + ffmpeg mediainfo p7zip-full unzip zip unrar jq yq csvkit sqlite3 \ + python3-pip python3-venv mc + +log "Setting timezone to ${TIMEZONE}" +timedatectl set-timezone "$TIMEZONE" + +log "Configuring UFW" +# Keep port 22 open until step 3 moves SSH to 20202 and the new login is tested. +ufw allow "22/tcp" +ufw allow "${SSH_PORT}/tcp" +ufw allow "80/tcp" +ufw allow "2096/tcp" +ufw allow "443/tcp" +ufw allow "40000/tcp" +ufw allow "63636/tcp" +ufw --force enable + +log "Installed versions" +printf 'Timezone: %s\n' "$(timedatectl show --property=Timezone --value)" +ufw status verbose + +printf '\nDone. Now run ochenstarik-server-3.sh as root.\n' +printf 'Do not remove the UFW rule for port 22 until SSH login on port %s succeeds.\n' "$SSH_PORT" diff --git a/ochenstarik-server-3.env.example b/ochenstarik-server-3.env.example new file mode 100644 index 0000000..152035d --- /dev/null +++ b/ochenstarik-server-3.env.example @@ -0,0 +1,6 @@ +NEW_USERNAME=youruser +SSH_PORT=20202 +SSH_PUBLIC_KEY=ssh-ed25519 AAAA_REPLACE_WITH_YOUR_PUBLIC_KEY user@example +PASSWORD_AUTH=no +PASSWORDLESS_SUDO=no +MANAGE_UFW=yes diff --git a/ochenstarik-server-4.env.example b/ochenstarik-server-4.env.example new file mode 100644 index 0000000..ba7a1a8 --- /dev/null +++ b/ochenstarik-server-4.env.example @@ -0,0 +1,3 @@ +TG_BOT_TOKEN=REPLACE_WITH_BOT_TOKEN +TG_CHAT_ID=REPLACE_WITH_CHAT_ID +SERVER_IP= diff --git a/ochenstarik-server-tg-4.sh b/ochenstarik-server-tg-4.sh new file mode 100644 index 0000000..7b13d71 --- /dev/null +++ b/ochenstarik-server-tg-4.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly CONFIG_FILE="/etc/ochenstarik-server/telegram.conf" +readonly NOTIFY_SCRIPT="/usr/local/libexec/ochenstarik-ssh-login-telegram.sh" +readonly LOG_FILE="/var/log/ochenstarik-ssh-login-telegram.log" +readonly LOGROTATE_FILE="/etc/logrotate.d/ochenstarik-ssh-login-telegram" +readonly PAM_SSHD="/etc/pam.d/sshd" +readonly LEGACY_NOTIFY_SCRIPT="/usr/local/libexec/ssh-login-telegram.sh" + +log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +backup_file() { + local file="$1" + [[ -f "$file" ]] || return 0 + cp -a -- "$file" "${file}.bak.$(date +%F-%H%M%S-%N)" +} + +is_ipv4() { + local ip="$1" octet + local -a octets + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + IFS=. read -r -a octets <<< "$ip" + for octet in "${octets[@]}"; do + (( 10#$octet <= 255 )) || return 1 + done +} + +send_message() { + local token="$1" chat_id="$2" text_file="$3" + local response_file http_code + response_file="$(mktemp)" + chmod 600 "$response_file" + + if ! http_code="$({ + printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$token" + printf 'request = "POST"\n' + printf 'silent\nshow-error\nmax-time = 10\n' + } | curl --config - --output "$response_file" --write-out '%{http_code}' \ + --data-urlencode "chat_id=${chat_id}" --data-urlencode "text@${text_file}")"; then + rm -f -- "$response_file" + return 1 + fi + + if [[ "$http_code" == 200 ]] && grep -q '"ok":true' "$response_file"; then + rm -f -- "$response_file" + return 0 + fi + + warn "API Telegram вернул код HTTP ${http_code}" + sed -n '1p' "$response_file" >&2 || true + rm -f -- "$response_file" + return 1 +} + +[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root" +for command_name in curl sshd; do + command -v "$command_name" >/dev/null 2>&1 || die "Не найдена команда: $command_name; сначала запустите второй скрипт" +done +[[ -f "$PAM_SSHD" && ! -L "$PAM_SSHD" ]] || die "Не найден файл конфигурации PAM для SSH: $PAM_SSHD" + +printf 'Создайте бота через @BotFather, отправьте ему хотя бы одно сообщение, затем укажите данные бота.\n' +while :; do + read -rsp 'Токен Telegram-бота: ' TG_BOT_TOKEN + printf '\n' + [[ "$TG_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]] && break + warn "Неверный формат токена" +done + +while :; do + read -rp 'ID чата Telegram: ' TG_CHAT_ID + [[ "$TG_CHAT_ID" =~ ^-?[0-9]+$ ]] && break + warn "ID чата должен быть числом" +done + +SERVER_IP="$(curl -fsS -4 --max-time 10 https://ipv4.icanhazip.com 2>/dev/null || true)" +SERVER_IP="$(printf '%s' "$SERVER_IP" | tr -d '\r\n')" +if ! is_ipv4 "$SERVER_IP"; then + SERVER_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$' | head -n1 || true)" +fi + +umask 077 +test_message="$(mktemp)" +trap 'rm -f -- "${test_message:-}"' EXIT +cat > "$test_message" </dev/null || hostname) +IP сервера: ${SERVER_IP:-неизвестно} +Дата и время: $(date '+%d.%m.%Y %H:%M:%S %Z') +EOF +send_message "$TG_BOT_TOKEN" "$TG_CHAT_ID" "$test_message" || die "Не удалось отправить тестовое сообщение; проверьте токен, ID чата и отправьте боту команду /start" +log "Тестовое сообщение отправлено в Telegram" + +install -d -m 700 -o root -g root /etc/ochenstarik-server /usr/local/libexec +backup_file "$CONFIG_FILE" +cat > "$CONFIG_FILE" < "$NOTIFY_SCRIPT" <<'HOOK' +#!/usr/bin/env bash +set -Euo pipefail + +readonly CONFIG_FILE="/etc/ochenstarik-server/telegram.conf" +readonly LOG_FILE="/var/log/ochenstarik-ssh-login-telegram.log" +[[ -r "$CONFIG_FILE" ]] || exit 0 +umask 077 + +log_msg() { + local level="$1" message="$2" + printf '%s [%s] %s\n' "$(date '+%F %T %Z')" "$level" "$message" >> "$LOG_FILE" + command -v logger >/dev/null 2>&1 && logger -t ochenstarik-ssh-login-telegram -- "[$level] $message" || true +} + +read_config_value() { + local key="$1" + sed -n "s/^${key}=//p" "$CONFIG_FILE" | head -n1 | tr -d '\r' +} + +TG_BOT_TOKEN="$(read_config_value TG_BOT_TOKEN)" +TG_CHAT_ID="$(read_config_value TG_CHAT_ID)" +SERVER_IP="$(read_config_value SERVER_IP)" +[[ "$TG_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]] || { log_msg ERROR "Неверный токен Telegram в конфигурации"; exit 0; } +[[ "$TG_CHAT_ID" =~ ^-?[0-9]+$ ]] || { log_msg ERROR "Неверный ID чата Telegram в конфигурации"; exit 0; } + +text_file="$(mktemp)" +response_file="$(mktemp)" +trap 'rm -f -- "$text_file" "$response_file"' EXIT + +cat > "$text_file" </dev/null || hostname) +IP сервера: ${SERVER_IP:-неизвестно} +Пользователь: ${PAM_USER:-неизвестно} +IP подключения: ${PAM_RHOST:-неизвестно} +Служба: ${PAM_SERVICE:-неизвестно} +Терминал: ${PAM_TTY:-неизвестно} +Дата: $(date '+%d.%m.%Y') +Время: $(date '+%H:%M:%S %Z') +MESSAGE + +if ! http_code="$({ + printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$TG_BOT_TOKEN" + printf 'request = "POST"\n' + printf 'silent\nshow-error\nmax-time = 10\n' +} | curl --config - --output "$response_file" --write-out '%{http_code}' \ + --data-urlencode "chat_id=${TG_CHAT_ID}" --data-urlencode "text@${text_file}" 2>> "$LOG_FILE")"; then + log_msg ERROR "Ошибка отправки в Telegram: пользователь=${PAM_USER:-неизвестно}, IP=${PAM_RHOST:-неизвестно}" + exit 0 +fi + +if [[ "$http_code" == 200 ]] && grep -q '"ok":true' "$response_file"; then + log_msg INFO "Уведомление отправлено: пользователь=${PAM_USER:-неизвестно}, IP=${PAM_RHOST:-неизвестно}" +else + log_msg ERROR "API Telegram вернул код HTTP ${http_code}: пользователь=${PAM_USER:-неизвестно}" +fi +exit 0 +HOOK +chmod 700 "$NOTIFY_SCRIPT" + +touch "$LOG_FILE" +chown root:root "$LOG_FILE" +chmod 600 "$LOG_FILE" +cat > "$LOGROTATE_FILE" <> "$PAM_SSHD" +fi + +sshd -t || die "Проверка sshd после настройки PAM завершилась ошибкой" +log "Telegram-уведомления включены для каждого успешного входа по SSH" +printf 'Файл журнала: %s\n' "$LOG_FILE" +printf 'Для проверки откройте новое SSH-подключение к серверу.\n' \ No newline at end of file diff --git a/ochenstarik-server-user-3.sh b/ochenstarik-server-user-3.sh new file mode 100644 index 0000000..896a702 --- /dev/null +++ b/ochenstarik-server-user-3.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly ENV_FILE="${ENV_FILE:-/root/setup-data/env.txt}" +readonly HASH_FILE="${HASH_FILE:-/root/setup-data/password.hash}" +readonly SSHD_DROPIN="/etc/ssh/sshd_config.d/00-hermes-hardening.conf" +readonly FAIL2BAN_JAIL="/etc/fail2ban/jail.d/hermes.local" + +log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +backup_file() { + local file="$1" + [[ -f "$file" ]] || return 0 + cp -a -- "$file" "${file}.bak.$(date +%F-%H%M%S-%N)" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +read_env_value() { + local key="$1" line value + line="$(grep -m1 -E "^[[:space:]]*${key}[[:space:]]*=" "$ENV_FILE" || true)" + [[ -n "$line" ]] || return 0 + line="${line//$'\r'/}" + value="$(trim "${line#*=}")" + + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + + printf '%s' "$value" | tr -d '\r' +} + +require_regular_root_file() { + local file="$1" + [[ -f "$file" && ! -L "$file" ]] || die "Required regular file not found: $file" + [[ "$(stat -c '%u' "$file")" == 0 ]] || die "$file must be owned by root" + chmod 600 -- "$file" +} + +validate_boolean() { + local name="$1" value="$2" + [[ "$value" == yes || "$value" == no ]] || die "$name must be 'yes' or 'no'" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" +} + +collect_user_configuration() { + local new_username ssh_public_key pass pass2 candidate_uid + local key_method source_username source_home source_key_file candidate_key key_test + + log "Collecting configuration for a new administrative user" + while :; do + read -rp 'New username: ' new_username + if [[ ! "$new_username" =~ ^[a-z_][a-z0-9_-]{0,31}$ || "$new_username" == root ]]; then + warn "Use 1-32 lowercase letters, digits, underscore or hyphen; root is not allowed" + continue + fi + if id "$new_username" >/dev/null 2>&1; then + candidate_uid="$(id -u "$new_username")" + if (( candidate_uid < 1000 || candidate_uid == 65534 )); then + warn "System account cannot be modified: $new_username" + continue + fi + warn "User $new_username already exists; its password and SSH key will be updated" + fi + break + done + + while :; do + printf '\nSSH key setup:\n' + printf ' 1 - copy a working key from an existing user (recommended)\n' + printf ' 2 - paste a public key manually\n' + read -rp 'Select [1]: ' key_method + key_method="${key_method:-1}" + + if [[ "$key_method" == 1 ]]; then + read -rp 'Existing username [hermes]: ' source_username + source_username="${source_username:-hermes}" + + if ! id "$source_username" >/dev/null 2>&1; then + warn "User does not exist: $source_username" + continue + fi + + source_home="$(getent passwd "$source_username" | cut -d: -f6)" + source_key_file="${source_home}/.ssh/authorized_keys" + if [[ ! -f "$source_key_file" || -L "$source_key_file" ]]; then + warn "A regular authorized_keys file was not found for $source_username" + continue + fi + + ssh_public_key="" + key_test="$(mktemp)" + chmod 600 "$key_test" + while IFS= read -r candidate_key || [[ -n "$candidate_key" ]]; do + candidate_key="${candidate_key//$'\r'/}" + [[ -n "$candidate_key" && "$candidate_key" != \#* ]] || continue + printf '%s\n' "$candidate_key" > "$key_test" + if ssh-keygen -l -f "$key_test" >/dev/null 2>&1; then + ssh_public_key="$candidate_key" + break + fi + done < "$source_key_file" + rm -f -- "$key_test" + + if [[ -z "$ssh_public_key" ]]; then + warn "No valid SSH public key was found for $source_username" + continue + fi + log "The SSH key was copied from user $source_username" + break + fi + + if [[ "$key_method" == 2 ]]; then + read -rp "Paste the SSH public key for ${new_username}: " ssh_public_key + if [[ "$ssh_public_key" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh.com|sk-ecdsa-sha2-nistp256@openssh.com)[[:space:]]+[A-Za-z0-9+/]+={0,3}([[:space:]].*)?$ ]]; then + break + fi + warn "Public key format is invalid" + continue + fi + + warn "Select 1 or 2" + done + + while :; do + read -rsp "Password for ${new_username}: " pass + printf '\n' + read -rsp 'Repeat password: ' pass2 + printf '\n' + [[ -n "$pass" ]] || { warn "Password must not be empty"; continue; } + [[ "$pass" == "$pass2" ]] && break + warn "Passwords do not match" + done + + install -d -m 700 -o root -g root "$(dirname "$ENV_FILE")" "$(dirname "$HASH_FILE")" + umask 077 + printf '%s' "$pass" | openssl passwd -6 -stdin > "$HASH_FILE" + cat > "$ENV_FILE" </dev/null 2>&1; then + existing_uid="$(id -u "$NEW_USERNAME")" + (( existing_uid >= 1000 && existing_uid != 65534 )) || die "Refusing to modify a system account: $NEW_USERNAME" +fi +[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT must be numeric" +(( 10#$SSH_PORT >= 1 && 10#$SSH_PORT <= 65535 )) || die "SSH_PORT must be between 1 and 65535" +validate_boolean PASSWORD_AUTH "$PASSWORD_AUTH" +validate_boolean PASSWORDLESS_SUDO "$PASSWORDLESS_SUDO" +validate_boolean MANAGE_UFW "$MANAGE_UFW" +[[ "$NEW_PASSWORD_HASH" == \$* && "$NEW_PASSWORD_HASH" != *:* ]] || die "Invalid password hash" + +if [[ -z "$SSH_PUBLIC_KEY" ]]; then + die "SSH_PUBLIC_KEY is required; refusing to configure a password-only SSH account" +fi + +log "Checking packages installed by step 2" + +for command_name in sshd ssh-keygen visudo systemctl; do + require_command "$command_name" +done + +key_check="$(mktemp)" +trap 'rm -f -- "${key_check:-}"' EXIT +chmod 600 "$key_check" +printf '%s\n' "$SSH_PUBLIC_KEY" > "$key_check" +ssh-keygen -l -f "$key_check" >/dev/null 2>&1 || die "SSH_PUBLIC_KEY is not a valid public key" + +if id "$NEW_USERNAME" >/dev/null 2>&1; then + log "User $NEW_USERNAME already exists" +else + log "Creating user $NEW_USERNAME" + useradd --create-home --shell /bin/bash -- "$NEW_USERNAME" +fi + +log "Setting password hash and sudo membership for $NEW_USERNAME" +printf '%s:%s\n' "$NEW_USERNAME" "$NEW_PASSWORD_HASH" | chpasswd -e +usermod -aG sudo -- "$NEW_USERNAME" + +SUDOERS_FILE="/etc/sudoers.d/${NEW_USERNAME}" +backup_file "$SUDOERS_FILE" +if [[ "$PASSWORDLESS_SUDO" == yes ]]; then + printf '%s ALL=(ALL:ALL) NOPASSWD:ALL\n' "$NEW_USERNAME" > "$SUDOERS_FILE" +else + printf '%s ALL=(ALL:ALL) ALL\n' "$NEW_USERNAME" > "$SUDOERS_FILE" +fi +chmod 440 "$SUDOERS_FILE" +visudo -cf "$SUDOERS_FILE" >/dev/null || die "sudoers validation failed" + +user_home="$(getent passwd "$NEW_USERNAME" | cut -d: -f6)" +user_group="$(id -gn "$NEW_USERNAME")" +[[ -n "$user_home" && "$user_home" == /* ]] || die "Could not determine user home" +[[ -d "$user_home" && ! -L "$user_home" ]] || die "User home is not a regular directory: $user_home" + +# OpenSSH StrictModes rejects keys when the home directory, .ssh or +# authorized_keys has unsafe ownership or permissions. Repair all three on +# every run so an existing user can be fixed by running this script again. +chown "$NEW_USERNAME:$user_group" "$user_home" +chmod 750 "$user_home" +install -d -m 700 -o "$NEW_USERNAME" -g "$user_group" "$user_home/.ssh" +touch -- "$user_home/.ssh/authorized_keys" +chown "$NEW_USERNAME:$user_group" "$user_home/.ssh/authorized_keys" +chmod 600 "$user_home/.ssh/authorized_keys" +if ! grep -Fqx -- "$SSH_PUBLIC_KEY" "$user_home/.ssh/authorized_keys"; then + printf '%s\n' "$SSH_PUBLIC_KEY" >> "$user_home/.ssh/authorized_keys" +fi + +if command -v restorecon >/dev/null 2>&1; then + restorecon -RF "$user_home/.ssh" || warn "SELinux context could not be restored" +fi + +ssh-keygen -l -f "$user_home/.ssh/authorized_keys" >/dev/null 2>&1 || \ + die "The installed authorized_keys file contains no valid SSH key" +log "Installed SSH key fingerprint: $(ssh-keygen -l -f "$user_home/.ssh/authorized_keys" | head -n1)" + +log "Writing isolated SSH configuration" +install -d -m 755 /etc/ssh/sshd_config.d +backup_file "$SSHD_DROPIN" +cat > "$SSHD_DROPIN" < "$FAIL2BAN_JAIL" </dev/null 2>&1; then + SSH_SERVICE="$service_name" + break + fi +done +[[ -n "$SSH_SERVICE" ]] || die "SSH systemd service not found" +systemctl restart "$SSH_SERVICE" +systemctl enable "$SSH_SERVICE" >/dev/null 2>&1 || true + +systemctl is-active --quiet "$SSH_SERVICE" || die "SSH service is not active" +fail2ban-client status sshd || warn "fail2ban sshd status check failed" + +printf '\nDone.\n' +printf 'New SSH port: %s\n' "$SSH_PORT" +printf 'Administrative user: %s\n' "$NEW_USERNAME" +printf 'Password SSH authentication: %s\n' "$PASSWORD_AUTH" +printf 'Passwordless sudo: %s\n' "$PASSWORDLESS_SUDO" +printf '\nOpen a NEW terminal and test before closing the current root session:\n' +printf 'ssh -p %s %s@\n' "$SSH_PORT" "$NEW_USERNAME" +printf 'After that login succeeds, remove the temporary port 22 rule: sudo ufw delete allow 22/tcp\n' +printf 'To enable Telegram login notifications, run ochenstarik-server-tg-4.sh.\n' diff --git a/ochenstarik-server-vpn-5.sh b/ochenstarik-server-vpn-5.sh new file mode 100644 index 0000000..59d0705 --- /dev/null +++ b/ochenstarik-server-vpn-5.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly XRAY_CONFIG="/usr/local/etc/xray/config.json" +readonly XRAY_DIR="/usr/local/etc/xray" +readonly STATE_DIR="/etc/ochenstarik-xray" +readonly NFT_FILE="${STATE_DIR}/routing.nft" +readonly ROUTE_HELPER="/usr/local/sbin/ochenstarik-xray-routing" +readonly ROUTE_SERVICE="/etc/systemd/system/ochenstarik-xray-routing.service" +readonly TPROXY_PORT="12345" +readonly SOCKS_PORT="10808" + +log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } +die() { printf '[x] %s\n' "$*" >&2; exit 1; } + +TMP_DIR="" +cleanup() { + [[ -z "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR" +} +trap cleanup EXIT + +public_ipv4() { + local ip + ip="$(curl -fsS -4 --max-time 12 https://api.ipify.org 2>/dev/null \ + || curl -fsS -4 --max-time 12 https://ipv4.icanhazip.com 2>/dev/null \ + || true)" + printf '%s' "$ip" | tr -d '\r\n' +} + +valid_ipv4() { + local ip="$1" octet + local -a octets + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + IFS=. read -r -a octets <<< "$ip" + for octet in "${octets[@]}"; do + (( 10#$octet <= 255 )) || return 1 + done +} + +stop_rollback_timer() { + systemctl stop ochenstarik-xray-rollback.timer >/dev/null 2>&1 || true + systemctl reset-failed ochenstarik-xray-rollback.timer \ + ochenstarik-xray-rollback.service >/dev/null 2>&1 || true +} + +[[ "$EUID" -eq 0 ]] || die "Запустите этот скрипт от имени root" + +if [[ "${1:-}" == "--disable" ]]; then + systemctl disable --now ochenstarik-xray-routing.service >/dev/null 2>&1 || true + [[ ! -x "$ROUTE_HELPER" ]] || "$ROUTE_HELPER" stop + log "Системная маршрутизация через Xray отключена" + if command -v curl >/dev/null 2>&1; then + printf 'Текущий внешний IP: %s\n' "$(public_ipv4)" + fi + exit 0 +fi + +export DEBIAN_FRONTEND=noninteractive +log "Установка системных зависимостей" +apt-get update +apt-get install -y curl ca-certificates jq nftables iproute2 python3 + +if systemctl is-active --quiet ochenstarik-xray-routing.service; then + warn "Обнаружена действующая маршрутизация Xray; временно отключаю её для проверки прямого IP" + systemctl disable --now ochenstarik-xray-routing.service +elif systemctl is-enabled --quiet ochenstarik-xray-routing.service 2>/dev/null; then + systemctl disable ochenstarik-xray-routing.service +fi + +BEFORE_IP="$(public_ipv4)" +valid_ipv4 "$BEFORE_IP" || die "Не удалось определить исходный внешний IPv4" +printf 'Внешний IP до подключения VPN: %s\n' "$BEFORE_IP" + +printf 'Вставьте прямую ссылку vless:// или HTTPS-ссылку подписки 3x-ui. Ввод будет скрыт.\n' +read -rsp 'Ссылка VLESS/подписки: ' SUBSCRIPTION_INPUT +printf '\n' +[[ "$SUBSCRIPTION_INPUT" == vless://* || "$SUBSCRIPTION_INPUT" == https://* ]] \ + || die "Поддерживаются только ссылки vless:// и https://" + +TMP_DIR="$(mktemp -d)" +chmod 700 "$TMP_DIR" +RAW_FILE="${TMP_DIR}/subscription.txt" +LINK_FILE="${TMP_DIR}/vless-link.txt" +GENERATED_CONFIG="${TMP_DIR}/config.json" +INSTALLER_FILE="${TMP_DIR}/install-release.sh" + +if [[ "$SUBSCRIPTION_INPUT" == vless://* ]]; then + printf '%s' "$SUBSCRIPTION_INPUT" > "$RAW_FILE" +else + { + printf 'url = "%s"\n' "$SUBSCRIPTION_INPUT" + printf 'fail\nsilent\nshow-error\nlocation\n' + printf 'proto = "=https"\ntlsv1.2\nmax-time = 30\n' + } | curl --config - > "$RAW_FILE" \ + || die "Не удалось загрузить подписку" +fi +unset SUBSCRIPTION_INPUT +chmod 600 "$RAW_FILE" + +python3 - "$RAW_FILE" "$LINK_FILE" <<'PY' +import base64 +import pathlib +import sys + +raw = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip() + +def find_vless(text: str): + for line in text.replace("\r", "").split("\n"): + line = line.strip() + if line.startswith("vless://"): + return line + return None + +link = find_vless(raw) +if not link: + try: + padded = raw.replace("-", "+").replace("_", "/") + padded += "=" * (-len(padded) % 4) + decoded = base64.b64decode(padded, validate=False).decode("utf-8") + except Exception as exc: + raise SystemExit(f"Подписка не является Base64/VLESS: {exc}") + link = find_vless(decoded) + +if not link: + raise SystemExit("В подписке не найдена ссылка vless://") + +pathlib.Path(sys.argv[2]).write_text(link, encoding="utf-8") +PY +chmod 600 "$LINK_FILE" + +VPN_IP="$(python3 - "$LINK_FILE" "$GENERATED_CONFIG" "$TPROXY_PORT" "$SOCKS_PORT" <<'PY' +import json +import pathlib +import socket +import sys +import urllib.parse + +link = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip() +output = pathlib.Path(sys.argv[2]) +tproxy_port = int(sys.argv[3]) +socks_port = int(sys.argv[4]) + +uri = urllib.parse.urlsplit(link) +if uri.scheme.lower() != "vless": + raise SystemExit("Ожидалась ссылка vless://") + +client_id = urllib.parse.unquote(uri.username or "") +host = uri.hostname or "" +port = uri.port +query = {key: values[-1] for key, values in urllib.parse.parse_qs( + uri.query, keep_blank_values=True +).items()} + +if not client_id or not host or not port: + raise SystemExit("В ссылке отсутствует UUID, адрес или порт") + +transport = query.get("type", "tcp").lower() +if transport not in {"tcp", "raw"}: + raise SystemExit(f"Поддерживается только TCP/RAW, получено: {transport}") + +security = query.get("security", "").lower() +if security != "reality": + raise SystemExit(f"Поддерживается только REALITY, получено: {security or 'пусто'}") + +server_name = query.get("sni", "") +password = query.get("pbk", "") +fingerprint = query.get("fp", "chrome") or "chrome" +short_id = query.get("sid", "") +spider_x = query.get("spx", "/") or "/" +flow = query.get("flow", "") +encryption = query.get("encryption", "none") or "none" + +if not server_name or not password: + raise SystemExit("В REALITY-ссылке отсутствуют параметры sni или pbk") + +try: + addresses = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM) +except socket.gaierror as exc: + raise SystemExit(f"Не удалось определить IPv4 VPN-сервера: {exc}") + +vpn_ip = addresses[0][4][0] +settings = { + "address": vpn_ip, + "port": port, + "id": client_id, + "encryption": encryption, +} +if flow: + settings["flow"] = flow + +config = { + "log": { + "loglevel": "warning", + "access": "/var/log/xray/access.log", + "error": "/var/log/xray/error.log", + }, + "inbounds": [ + { + "tag": "transparent-in", + "port": tproxy_port, + "protocol": "tunnel", + "settings": { + "allowedNetwork": "tcp,udp", + "followRedirect": True, + }, + "sniffing": { + "enabled": True, + "destOverride": ["http", "tls", "quic"], + }, + "streamSettings": { + "sockopt": {"tproxy": "tproxy"}, + }, + }, + { + "tag": "socks-in", + "listen": "127.0.0.1", + "port": socks_port, + "protocol": "socks", + "settings": {"udp": True}, + }, + ], + "outbounds": [ + { + "tag": "vpn-out", + "protocol": "vless", + "settings": settings, + "streamSettings": { + "network": "raw", + "security": "reality", + "realitySettings": { + "serverName": server_name, + "fingerprint": fingerprint, + "password": password, + "shortId": short_id, + "spiderX": spider_x, + }, + "sockopt": {"mark": 2}, + }, + } + ], +} + +output.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +print(vpn_ip) +PY +)" || die "Не удалось разобрать VLESS-ссылку" +valid_ipv4 "$VPN_IP" || die "Получен некорректный IPv4 VPN-сервера" +chmod 600 "$GENERATED_CONFIG" +log "Подписка разобрана: VLESS + TCP/RAW + REALITY" + +log "Установка или обновление Xray из официального репозитория XTLS" +curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh \ + -o "$INSTALLER_FILE" +chmod 700 "$INSTALLER_FILE" +bash "$INSTALLER_FILE" install + +command -v xray >/dev/null 2>&1 || die "Xray не установлен" +install -d -m 755 "$XRAY_DIR" /var/log/xray +touch /var/log/xray/access.log /var/log/xray/error.log + +log "Проверка конфигурации Xray" +xray run -test -config "$GENERATED_CONFIG" \ + || die "Xray отклонил сгенерированную конфигурацию" + +XRAY_BACKUP="" +if [[ -f "$XRAY_CONFIG" ]]; then + XRAY_BACKUP="${XRAY_CONFIG}.bak.$(date +%F-%H%M%S-%N)" + cp -a -- "$XRAY_CONFIG" "$XRAY_BACKUP" +fi + +XRAY_GROUP="$(id -gn nobody 2>/dev/null || printf 'nogroup')" +install -o root -g "$XRAY_GROUP" -m 640 "$GENERATED_CONFIG" "$XRAY_CONFIG" +chown nobody:"$XRAY_GROUP" /var/log/xray/access.log /var/log/xray/error.log +chmod 600 /var/log/xray/access.log /var/log/xray/error.log +systemctl daemon-reload +systemctl enable --now xray.service +systemctl restart xray.service +sleep 2 + +if ! systemctl is-active --quiet xray.service; then + [[ -z "$XRAY_BACKUP" ]] || cp -a -- "$XRAY_BACKUP" "$XRAY_CONFIG" + systemctl restart xray.service >/dev/null 2>&1 || true + die "Служба Xray не запустилась; предыдущая конфигурация восстановлена" +fi + +log "Проверка VLESS через локальный SOCKS-порт ${SOCKS_PORT}" +PROXY_IP="$(curl -fsS -4 --max-time 20 \ + --socks5-hostname "127.0.0.1:${SOCKS_PORT}" \ + https://api.ipify.org 2>/dev/null || true)" +PROXY_IP="$(printf '%s' "$PROXY_IP" | tr -d '\r\n')" +if ! valid_ipv4 "$PROXY_IP"; then + [[ -z "$XRAY_BACKUP" ]] || cp -a -- "$XRAY_BACKUP" "$XRAY_CONFIG" + systemctl restart xray.service >/dev/null 2>&1 || true + die "VLESS не прошёл предварительную проверку через SOCKS" +fi +printf 'IP через локальный прокси Xray: %s\n' "$PROXY_IP" +[[ "$PROXY_IP" != "$BEFORE_IP" ]] \ + || die "IP через Xray совпадает с исходным; системная маршрутизация не включена" + +SSH_PORT="$(sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }' || true)" +[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || SSH_PORT="20202" + +install -d -m 700 -o root -g root "$STATE_DIR" +cat > "$NFT_FILE" < "$ROUTE_HELPER" </dev/null 2>&1 \ + && nft delete table ip ochenstarik_xray || true + nft list table ip6 ochenstarik_xray6 >/dev/null 2>&1 \ + && nft delete table ip6 ochenstarik_xray6 || true + while ip rule del priority 100 fwmark 0x1/0x1 table 100 2>/dev/null; do :; done + ip route flush table 100 2>/dev/null || true +} + +case "\${1:-}" in + start) + stop_routes + ip route add local 0.0.0.0/0 dev lo table 100 + ip rule add priority 100 fwmark 0x1/0x1 table 100 + nft -f "\$NFT_FILE" + ;; + stop) + stop_routes + ;; + restart) + stop_routes + "\$0" start + ;; + status) + ip rule show + ip route show table 100 + nft list table ip ochenstarik_xray + ;; + *) + echo "Использование: \$0 {start|stop|restart|status}" >&2 + exit 2 + ;; +esac +EOF +chmod 700 "$ROUTE_HELPER" + +cat > "$ROUTE_SERVICE" </dev/null 2>&1 || true + stop_rollback_timer + die "Итоговая проверка IP не пройдена; маршрутизация автоматически отключена" +fi + +systemctl enable ochenstarik-xray-routing.service >/dev/null +stop_rollback_timer + +printf '\nНастройка завершена успешно.\n' +printf 'IP до VPN: %s\n' "$BEFORE_IP" +printf 'IP через Xray: %s\n' "$PROXY_IP" +printf 'IP после VPN: %s\n' "$AFTER_IP" +printf 'SSH-порт %s и ответы сервисов 443/63636 оставлены напрямую.\n' "$SSH_PORT" +printf 'Публичный IPv6 заблокирован, чтобы исключить обход VPN.\n' +printf '\nОтключить системный VPN:\n %s --disable\n' "$0" +printf 'Проверить маршрутизацию:\n %s status\n' "$ROUTE_HELPER"