Publish modular server setup scripts
This commit is contained in:
parent
e4878e8f5d
commit
138affaa54
11 changed files with 1327 additions and 1 deletions
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal 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
19
.gitignore
vendored
Normal 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
196
README.md
|
|
@ -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
13
Ubuntu server.txt
Normal 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
48
ochenstarik-server-1.sh
Normal 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
45
ochenstarik-server-2.sh
Normal 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"
|
||||
6
ochenstarik-server-3.env.example
Normal file
6
ochenstarik-server-3.env.example
Normal 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
|
||||
3
ochenstarik-server-4.env.example
Normal file
3
ochenstarik-server-4.env.example
Normal 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
193
ochenstarik-server-tg-4.sh
Normal 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'
|
||||
340
ochenstarik-server-user-3.sh
Normal file
340
ochenstarik-server-user-3.sh
Normal 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
461
ochenstarik-server-vpn-5.sh
Normal 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"
|
||||
Loading…
Reference in a new issue