Publish modular server setup scripts

This commit is contained in:
Ochenstarik 2026-07-14 13:11:28 +07:00
parent e4878e8f5d
commit 138affaa54
11 changed files with 1327 additions and 1 deletions

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
*.sh text eol=lf
*.env.example text eol=lf
*.md text eol=lf
*.txt text eol=lf

19
.gitignore vendored Normal file
View file

@ -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

196
README.md
View file

@ -1 +1,195 @@
# lightweight-server
# Быстрая настройка 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 на новый порт.
- Перед настройкой на рабочем сервере сделайте резервную копию важных данных.

13
Ubuntu server.txt Normal file
View file

@ -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.

48
ochenstarik-server-1.sh Normal file
View file

@ -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" <<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"
swapon --show
printf '\nNow run ochenstarik-server-2.sh as root.\n'

45
ochenstarik-server-2.sh Normal file
View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,3 @@
TG_BOT_TOKEN=REPLACE_WITH_BOT_TOKEN
TG_CHAT_ID=REPLACE_WITH_CHAT_ID
SERVER_IP=

193
ochenstarik-server-tg-4.sh Normal file
View file

@ -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" <<EOF
✅ Telegram-уведомления о входах по SSH настроены
Сервер: $(hostname -f 2>/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" <<EOF
TG_BOT_TOKEN=${TG_BOT_TOKEN}
TG_CHAT_ID=${TG_CHAT_ID}
SERVER_IP=${SERVER_IP}
EOF
chmod 600 "$CONFIG_FILE"
cat > "$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" <<MESSAGE
🔐 Успешный вход на сервер по SSH
Сервер: $(hostname -f 2>/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" <<EOF
${LOG_FILE} {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0600 root root
}
EOF
chmod 644 "$LOGROTATE_FILE"
backup_file "$PAM_SSHD"
if grep -Fq -- "$LEGACY_NOTIFY_SCRIPT" "$PAM_SSHD"; then
sed -i "\|${LEGACY_NOTIFY_SCRIPT}|d" "$PAM_SSHD"
fi
if ! grep -Fq -- "$NOTIFY_SCRIPT" "$PAM_SSHD"; then
printf 'session optional pam_exec.so seteuid %s\n' "$NOTIFY_SCRIPT" >> "$PAM_SSHD"
fi
sshd -t || die "Проверка sshd после настройки PAM завершилась ошибкой"
log "Telegram-уведомления включены для каждого успешного входа по SSH"
printf 'Файл журнала: %s\n' "$LOG_FILE"
printf 'Для проверки откройте новое SSH-подключение к серверу.\n'

View file

@ -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" <<EOF
NEW_USERNAME=${new_username}
SSH_PORT=20202
SSH_PUBLIC_KEY=${ssh_public_key}
PASSWORD_AUTH=no
PASSWORDLESS_SUDO=no
MANAGE_UFW=yes
EOF
chmod 600 "$ENV_FILE" "$HASH_FILE"
}
[[ "$EUID" -eq 0 ]] || die "Run this script as root"
require_command openssl
require_command ssh-keygen
[[ ! -L "$ENV_FILE" && ! -L "$HASH_FILE" ]] || die "Configuration files must not be symbolic links"
if [[ -e "$ENV_FILE" ]]; then
require_regular_root_file "$ENV_FILE"
backup_file "$ENV_FILE"
fi
if [[ -e "$HASH_FILE" ]]; then
require_regular_root_file "$HASH_FILE"
backup_file "$HASH_FILE"
fi
collect_user_configuration
require_regular_root_file "$ENV_FILE"
require_regular_root_file "$HASH_FILE"
# Parse only known dotenv keys. Never source a root-owned configuration file.
NEW_USERNAME="$(read_env_value NEW_USERNAME)"
SSH_PORT="$(read_env_value SSH_PORT)"
SSH_PUBLIC_KEY="$(read_env_value SSH_PUBLIC_KEY)"
PASSWORD_AUTH="$(read_env_value PASSWORD_AUTH)"
PASSWORDLESS_SUDO="$(read_env_value PASSWORDLESS_SUDO)"
MANAGE_UFW="$(read_env_value MANAGE_UFW)"
SSH_PORT="${SSH_PORT:-20202}"
PASSWORD_AUTH="${PASSWORD_AUTH:-no}"
PASSWORDLESS_SUDO="${PASSWORDLESS_SUDO:-no}"
MANAGE_UFW="${MANAGE_UFW:-no}"
NEW_PASSWORD_HASH="$(tr -d '\r\n' < "$HASH_FILE")"
[[ "$NEW_USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "Invalid NEW_USERNAME"
[[ "$NEW_USERNAME" != root ]] || die "NEW_USERNAME must not be root"
if id "$NEW_USERNAME" >/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" <<EOF
# Managed by ochenstarik-server-3.sh
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication ${PASSWORD_AUTH}
PubkeyAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
EOF
chmod 644 "$SSHD_DROPIN"
sshd -t || die "sshd syntax validation failed"
effective_port="$(sshd -T | awk '$1 == "port" { print $2; exit }')"
effective_root="$(sshd -T | awk '$1 == "permitrootlogin" { print $2; exit }')"
effective_password="$(sshd -T | awk '$1 == "passwordauthentication" { print $2; exit }')"
[[ "$effective_port" == "$SSH_PORT" ]] || die "Effective SSH port is $effective_port, expected $SSH_PORT"
[[ "$effective_root" == no ]] || die "Effective PermitRootLogin is $effective_root, expected no"
[[ "$effective_password" == "$PASSWORD_AUTH" ]] || die "Effective PasswordAuthentication is $effective_password, expected $PASSWORD_AUTH"
if [[ "$MANAGE_UFW" == yes ]]; then
log "Allowing SSH, HTTPS, and application ports in UFW without resetting existing rules"
ufw allow "${SSH_PORT}/tcp"
ufw allow "443/tcp"
ufw allow "63636/tcp"
ufw --force enable
else
warn "MANAGE_UFW=no: firewall was not enabled or modified"
fi
log "Configuring fail2ban without replacing existing jail.local"
install -d -m 755 /etc/fail2ban/jail.d
backup_file "$FAIL2BAN_JAIL"
cat > "$FAIL2BAN_JAIL" <<EOF
[sshd]
enabled = true
port = ${SSH_PORT}
bantime = 1h
findtime = 10m
maxretry = 5
EOF
fail2ban-client -t
systemctl enable --now fail2ban
systemctl restart fail2ban
log "Restarting SSH service"
SSH_SERVICE=""
for service_name in ssh sshd; do
if systemctl cat "$service_name" >/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@<server-ip>\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'

461
ochenstarik-server-vpn-5.sh Normal file
View file

@ -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" <<EOF
table ip ochenstarik_xray {
set bypass_ipv4 {
type ipv4_addr
flags interval
elements = {
0.0.0.0/8,
10.0.0.0/8,
100.64.0.0/10,
127.0.0.0/8,
169.254.0.0/16,
172.16.0.0/12,
192.0.0.0/24,
192.0.2.0/24,
192.168.0.0/16,
198.18.0.0/15,
198.51.100.0/24,
203.0.113.0/24,
224.0.0.0/4,
240.0.0.0/4,
${BEFORE_IP},
${VPN_IP}
}
}
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
meta mark 1 meta l4proto tcp tproxy to 127.0.0.1:${TPROXY_PORT} accept
meta mark 1 meta l4proto udp tproxy to 127.0.0.1:${TPROXY_PORT} accept
}
chain output {
type route hook output priority mangle; policy accept;
meta mark 2 return
ip daddr @bypass_ipv4 return
tcp sport { ${SSH_PORT}, 443, 63636 } return
ip protocol tcp meta mark set 1
ip protocol udp meta mark set 1
}
}
table ip6 ochenstarik_xray6 {
set bypass_ipv6 {
type ipv6_addr
flags interval
elements = {
::/128,
::1/128,
fc00::/7,
fe80::/10,
ff00::/8
}
}
chain output {
type filter hook output priority filter; policy accept;
meta mark 2 return
ip6 daddr @bypass_ipv6 return
tcp sport { ${SSH_PORT}, 443, 63636 } return
reject with icmpv6 type admin-prohibited
}
}
EOF
chmod 600 "$NFT_FILE"
cat > "$ROUTE_HELPER" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
NFT_FILE="${NFT_FILE}"
stop_routes() {
nft list table ip ochenstarik_xray >/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" <<EOF
[Unit]
Description=Ochenstarik Xray system traffic routing
After=network-online.target xray.service
Wants=network-online.target
Requires=xray.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${ROUTE_HELPER} start
ExecStop=${ROUTE_HELPER} stop
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$ROUTE_SERVICE"
systemctl daemon-reload
log "Запуск маршрутизации с автоматическим откатом через 2 минуты"
systemd-run --quiet --unit=ochenstarik-xray-rollback --on-active=2m \
"$ROUTE_HELPER" stop
if ! systemctl start ochenstarik-xray-routing.service; then
stop_rollback_timer
"$ROUTE_HELPER" stop
die "Не удалось применить правила маршрутизации"
fi
sleep 2
AFTER_IP="$(public_ipv4)"
if ! valid_ipv4 "$AFTER_IP" || [[ "$AFTER_IP" != "$PROXY_IP" ]]; then
"$ROUTE_HELPER" stop
systemctl stop ochenstarik-xray-routing.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"