diff --git a/README.md b/README.md index 811f0bd..3807057 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ | Файл | Назначение | Можно запускать отдельно | | --- | --- | --- | -| `ochenstarik-server-1.sh` | Создаёт swap-файл 2 ГБ и задаёт `vm.swappiness=20` | Да | -| `ochenstarik-server-2.sh` | Обновляет систему, ставит серверные и офисные пакеты, настраивает часовой пояс и UFW | Да | -| `ochenstarik-server-user-3.sh` | Создаёт администратора, позволяет выбрать SSH-порт, включает fail2ban или отдельно добавляет правила UFW | После установки необходимых пакетов; проще всего сначала запустить скрипт 2 | +| `ochenstarik-server-1.sh` | Предлагает выбрать часовой пояс, создаёт swap-файл 2 ГБ и задаёт `vm.swappiness=20` | Да | +| `ochenstarik-server-2.sh` | Предлагает выбрать будущий SSH-порт, обновляет систему, ставит пакеты и настраивает UFW | Да | +| `ochenstarik-server-user-3.sh` | Создаёт администратора, переносит SSH на порт из этапа 2, проверяет права и включает fail2ban | После установки необходимых пакетов; проще всего сначала запустить скрипт 2 | | `ochenstarik-server-tg-4.sh` | Отправляет уведомления в Telegram при успешном входе по SSH | После установки OpenSSH и `curl`; обычно после скрипта 2 или 3 | | `ochenstarik-server-vpn-5.sh` | Устанавливает Xray и направляет системный трафик через VLESS + REALITY | Да; зависимости устанавливаются автоматически | @@ -37,11 +37,11 @@ chmod 700 ./*.sh bash -n ./ИМЯ-СКРИПТА.sh ``` -## 1. Настройка swap +## 1. Часовой пояс и настройка swap Файл: `ochenstarik-server-1.sh`. -Создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен. +Сначала предлагает ввести часовой пояс в формате IANA, например `Asia/Novosibirsk`, `Europe/Moscow` или `UTC`, и проверяет его через `timedatectl`. Затем создаёт `/swapfile` размером 2 ГБ, добавляет его в `/etc/fstab` и устанавливает `vm.swappiness=20`. Повторный запуск предусмотрен. ```bash curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-1.sh @@ -61,12 +61,12 @@ sysctl vm.swappiness Файл: `ochenstarik-server-2.sh`. -Скрипт обновляет систему, устанавливает OpenSSH, UFW, fail2ban, инструменты обработки документов, изображений, аудио и видео, а также задаёт часовой пояс `Asia/Novosibirsk`. +Скрипт запрашивает SSH-порт, который будет применён на этапе 3, сохраняет его в `/etc/ochenstarik-server/ssh-port.conf`, обновляет систему и устанавливает OpenSSH, UFW, fail2ban, инструменты обработки документов, изображений, аудио и видео. Открываются TCP-порты: - `22` — временный SSH-порт; -- `20202` — новый SSH-порт; +- выбранный SSH-порт — будущий порт SSH; - `80`, `443` — HTTP и HTTPS; - `2096`, `40000`, `63636` — порты приложений. @@ -93,10 +93,12 @@ timedatectl 1. новая установка или повторная настройка администратора и SSH; 2. только добавление открытых портов в UFW без изменения пользователя и SSH. -В режиме новой установки скрипт интерактивно запрашивает имя администратора, SSH-порт, SSH-ключ и пароль. Затем он: +В режиме новой установки скрипт интерактивно запрашивает имя администратора, SSH-ключ и пароль. SSH-порт автоматически читается из защищённого файла, созданного этапом 2. Если этап 2 не выполнялся, скрипт предложит выбрать порт вручную. Затем он: - создаёт или обновляет пользователя; - добавляет пользователя в группу `sudo`; +- назначает пользователю его домашнюю папку и всё содержимое `.ssh`; +- устанавливает права `700` для `.ssh` и `600` для `authorized_keys` и остальных файлов ключей; - запрещает вход `root` по SSH; - переносит SSH на выбранный порт и добавляет для него правило UFW; - настраивает fail2ban; @@ -162,7 +164,7 @@ sudo tail -f /var/log/ochenstarik-ssh-login-telegram.log Файл: `ochenstarik-server-vpn-5.sh`. -Скрипт устанавливает Xray и необходимые пакеты, принимает прямую ссылку `vless://` либо HTTPS-ссылку подписки 3x-ui и настраивает маршрутизацию через `nftables`. Поддерживается VLESS с транспортом TCP/RAW и REALITY. +Скрипт устанавливает Xray и необходимые пакеты, принимает прямую ссылку `vless://` либо HTTPS-ссылку подписки 3x-ui и настраивает маршрутизацию через `nftables`. Если подписка содержит VLESS-ссылки на нескольких портах, скрипт выводит доступные порты и просит выбрать нужный. Поддерживается VLESS с транспортом TCP/RAW и REALITY. ```bash curl -fLO https://raw.githubusercontent.com/ochenstarik-ui/lightweight-server/main/ochenstarik-server-vpn-5.sh diff --git a/Ubuntu server.txt b/Ubuntu server.txt index cc70a56..0e32ec0 100644 --- a/Ubuntu server.txt +++ b/Ubuntu server.txt @@ -1,7 +1,7 @@ Актуальная инструкция находится в README.md: https://github.com/ochenstarik-ui/lightweight-server#readme -Можно установить только нужный модуль. Пример для базовой настройки: +Можно установить только нужный модуль. Первый скрипт предлагает выбрать часовой пояс, второй — будущий SSH-порт, а пятый — порт VLESS из подписки. Пример для базовой настройки: sudo apt-get update sudo apt-get install -y curl diff --git a/ochenstarik-server-1.sh b/ochenstarik-server-1.sh index 90c6551..53392cc 100644 --- a/ochenstarik-server-1.sh +++ b/ochenstarik-server-1.sh @@ -7,13 +7,34 @@ readonly SWAPPINESS="20" readonly SYSCTL_FILE="/etc/sysctl.d/60-hermes-swap.conf" log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } +choose_timezone() { + local current_timezone timezone + + current_timezone="$(timedatectl show --property=Timezone --value 2>/dev/null || true)" + current_timezone="${current_timezone:-Asia/Novosibirsk}" + printf 'Select an IANA timezone, for example Asia/Novosibirsk, Europe/Moscow or UTC.\n' + while :; do + read -rp "Timezone [${current_timezone}]: " timezone + timezone="${timezone:-$current_timezone}" + if timedatectl list-timezones | grep -Fqx -- "$timezone"; then + timedatectl set-timezone "$timezone" + log "Timezone set to ${timezone}" + return 0 + fi + warn "Unknown timezone: $timezone" + done +} + [[ "$EUID" -eq 0 ]] || die "Run this script as root" -for command_name in awk blkid fallocate mkswap swapon sysctl; do +for command_name in awk blkid fallocate grep mkswap swapon sysctl timedatectl; do command -v "$command_name" >/dev/null 2>&1 || die "Required command not found: $command_name" done +choose_timezone + log "Configuring ${SWAPSIZE} swap file" if [[ ! -e "$SWAPFILE" ]]; then fallocate -l "$SWAPSIZE" "$SWAPFILE" @@ -44,5 +65,6 @@ chmod 644 "$SYSCTL_FILE" sysctl -p "$SYSCTL_FILE" >/dev/null log "Server memory setup is complete" +printf 'Timezone: %s\n' "$(timedatectl show --property=Timezone --value)" swapon --show -printf '\nNow run ochenstarik-server-2.sh as root.\n' \ No newline at end of file +printf '\nNow run ochenstarik-server-2.sh as root.\n' diff --git a/ochenstarik-server-2.sh b/ochenstarik-server-2.sh index 0ca929b..43d3b09 100644 --- a/ochenstarik-server-2.sh +++ b/ochenstarik-server-2.sh @@ -1,13 +1,47 @@ #!/usr/bin/env bash set -Eeuo pipefail -readonly SSH_PORT="20202" -readonly TIMEZONE="Asia/Novosibirsk" +readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf" +readonly DEFAULT_SSH_PORT="20202" log() { printf '[+] %s\n' "$*"; } +warn() { printf '[!] %s\n' "$*" >&2; } die() { printf '[x] %s\n' "$*" >&2; exit 1; } +is_valid_port() { + local port="$1" + [[ "$port" =~ ^[0-9]{1,5}$ ]] || return 1 + (( 10#$port >= 1 && 10#$port <= 65535 )) +} + +choose_ssh_port() { + local default_port="$DEFAULT_SSH_PORT" saved_port selected_port + + if [[ -e "$SSH_PORT_CONFIG" ]]; then + [[ -f "$SSH_PORT_CONFIG" && ! -L "$SSH_PORT_CONFIG" ]] \ + || die "$SSH_PORT_CONFIG must be a regular non-symlink file" + saved_port="$(sed -n 's/^SSH_PORT=//p' "$SSH_PORT_CONFIG" | head -n1 | tr -d '\r')" + if is_valid_port "$saved_port"; then + default_port="$((10#$saved_port))" + else + warn "Ignoring invalid saved SSH port: ${saved_port:-empty}" + fi + fi + + while :; do + read -rp "SSH port for step 3 [${default_port}]: " selected_port + selected_port="${selected_port:-$default_port}" + if is_valid_port "$selected_port"; then + SSH_PORT="$((10#$selected_port))" + return 0 + fi + warn "SSH port must be a number between 1 and 65535" + done +} + [[ "$EUID" -eq 0 ]] || die "Run this script as root" +SSH_PORT="" +choose_ssh_port export DEBIAN_FRONTEND=noninteractive log "Updating the operating system" @@ -23,11 +57,14 @@ apt-get install -y \ 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" +install -d -m 700 -o root -g root "$(dirname "$SSH_PORT_CONFIG")" +printf 'SSH_PORT=%s\n' "$SSH_PORT" > "$SSH_PORT_CONFIG" +chown root:root "$SSH_PORT_CONFIG" +chmod 600 "$SSH_PORT_CONFIG" +log "Saved SSH port ${SSH_PORT} for step 3" log "Configuring UFW" -# Keep port 22 open until step 3 moves SSH to 20202 and the new login is tested. +# Keep port 22 open until step 3 moves SSH to the selected port and the new login is tested. ufw allow "22/tcp" ufw allow "${SSH_PORT}/tcp" ufw allow "80/tcp" @@ -41,5 +78,6 @@ 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 '\nDone. Now run ochenstarik-server-user-3.sh as root.\n' +printf 'Step 3 will move SSH to port %s.\n' "$SSH_PORT" printf 'Do not remove the UFW rule for port 22 until SSH login on port %s succeeds.\n' "$SSH_PORT" diff --git a/ochenstarik-server-user-3.sh b/ochenstarik-server-user-3.sh index 14df4ad..31fc28f 100644 --- a/ochenstarik-server-user-3.sh +++ b/ochenstarik-server-user-3.sh @@ -5,6 +5,7 @@ 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" +readonly SSH_PORT_CONFIG="/etc/ochenstarik-server/ssh-port.conf" log() { printf '[+] %s\n' "$*"; } warn() { printf '[!] %s\n' "$*" >&2; } @@ -151,7 +152,7 @@ add_ufw_ports_interactive() { } collect_user_configuration() { - local new_username ssh_port default_ssh_port saved_ssh_port + local new_username ssh_port default_ssh_port saved_ssh_port selected_step2_port local ssh_public_key pass pass2 candidate_uid local key_method source_username source_home source_key_file candidate_key key_test @@ -174,22 +175,33 @@ collect_user_configuration() { done default_ssh_port="20202" - if [[ -f "$ENV_FILE" ]]; then + if [[ -e "$SSH_PORT_CONFIG" ]]; then + [[ -f "$SSH_PORT_CONFIG" && ! -L "$SSH_PORT_CONFIG" ]] \ + || die "$SSH_PORT_CONFIG must be a regular non-symlink file" + selected_step2_port="$(sed -n 's/^SSH_PORT=//p' "$SSH_PORT_CONFIG" | head -n1 | tr -d '\r')" + is_valid_port "$selected_step2_port" \ + || die "Invalid SSH port in $SSH_PORT_CONFIG" + ssh_port="$((10#$selected_step2_port))" + log "Using SSH port ${ssh_port} selected in step 2" + elif [[ -f "$ENV_FILE" ]]; then saved_ssh_port="$(read_env_value SSH_PORT)" if is_valid_port "$saved_ssh_port"; then default_ssh_port="$saved_ssh_port" fi fi - while :; do - read -rp "SSH port [${default_ssh_port}]: " ssh_port - ssh_port="${ssh_port:-$default_ssh_port}" - if is_valid_port "$ssh_port"; then - ssh_port="$((10#$ssh_port))" - break - fi - warn "SSH port must be a number between 1 and 65535" - done + if [[ -z "${ssh_port:-}" ]]; then + warn "The SSH port from step 2 was not found; select it now" + while :; do + read -rp "SSH port [${default_ssh_port}]: " ssh_port + ssh_port="${ssh_port:-$default_ssh_port}" + if is_valid_port "$ssh_port"; then + ssh_port="$((10#$ssh_port))" + break + fi + warn "SSH port must be a number between 1 and 65535" + done + fi while :; do printf '\nSSH key setup:\n' @@ -329,7 +341,7 @@ fi log "Checking packages installed by step 2" -for command_name in sshd ssh-keygen visudo systemctl; do +for command_name in find sshd ssh-keygen visudo systemctl; do require_command "$command_name" done @@ -370,13 +382,28 @@ user_group="$(id -gn "$NEW_USERNAME")" # 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" +[[ ! -L "$user_home/.ssh" ]] || die "Refusing to use a symbolic link as $user_home/.ssh" install -d -m 700 -o "$NEW_USERNAME" -g "$user_group" "$user_home/.ssh" +[[ ! -L "$user_home/.ssh/authorized_keys" ]] \ + || die "Refusing to use a symbolic link as $user_home/.ssh/authorized_keys" 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 +chown -R --no-dereference "$NEW_USERNAME:$user_group" "$user_home/.ssh" +find "$user_home/.ssh" -xdev -type d -exec chmod 700 {} + +find "$user_home/.ssh" -xdev -type f -exec chmod 600 {} + + +id -nG "$NEW_USERNAME" | tr ' ' '\n' | grep -Fqx sudo \ + || die "$NEW_USERNAME was not added to the sudo group" +[[ "$(stat -c '%U:%G' "$user_home/.ssh")" == "$NEW_USERNAME:$user_group" ]] \ + || die "Incorrect owner for $user_home/.ssh" +[[ "$(stat -c '%a' "$user_home/.ssh")" == 700 ]] \ + || die "Incorrect permissions for $user_home/.ssh" +[[ "$(stat -c '%U:%G' "$user_home/.ssh/authorized_keys")" == "$NEW_USERNAME:$user_group" ]] \ + || die "Incorrect owner for authorized_keys" +[[ "$(stat -c '%a' "$user_home/.ssh/authorized_keys")" == 600 ]] \ + || die "Incorrect permissions for authorized_keys" if command -v restorecon >/dev/null 2>&1; then restorecon -RF "$user_home/.ssh" || warn "SELinux context could not be restored" diff --git a/ochenstarik-server-vpn-5.sh b/ochenstarik-server-vpn-5.sh index 59d0705..085c42c 100644 --- a/ochenstarik-server-vpn-5.sh +++ b/ochenstarik-server-vpn-5.sh @@ -81,7 +81,9 @@ printf '\n' TMP_DIR="$(mktemp -d)" chmod 700 "$TMP_DIR" RAW_FILE="${TMP_DIR}/subscription.txt" +LINKS_FILE="${TMP_DIR}/vless-links.txt" LINK_FILE="${TMP_DIR}/vless-link.txt" +PORTS_FILE="${TMP_DIR}/vless-ports.txt" GENERATED_CONFIG="${TMP_DIR}/config.json" INSTALLER_FILE="${TMP_DIR}/install-release.sh" @@ -98,36 +100,93 @@ fi unset SUBSCRIPTION_INPUT chmod 600 "$RAW_FILE" -python3 - "$RAW_FILE" "$LINK_FILE" <<'PY' +python3 - "$RAW_FILE" "$LINKS_FILE" "$PORTS_FILE" <<'PY' import base64 import pathlib import sys +import urllib.parse raw = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip() def find_vless(text: str): + result = [] for line in text.replace("\r", "").split("\n"): line = line.strip() if line.startswith("vless://"): - return line - return None + result.append(line) + return result -link = find_vless(raw) -if not link: +links = find_vless(raw) +if not links: 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) + links = find_vless(decoded) -if not link: +links = list(dict.fromkeys(links)) +if not links: raise SystemExit("В подписке не найдена ссылка vless://") -pathlib.Path(sys.argv[2]).write_text(link, encoding="utf-8") +ports = [] +for link in links: + try: + port = urllib.parse.urlsplit(link).port + except ValueError as exc: + raise SystemExit(f"Некорректный порт в VLESS-ссылке: {exc}") + if port is None: + raise SystemExit("В одной из VLESS-ссылок отсутствует порт") + if port not in ports: + ports.append(port) + +pathlib.Path(sys.argv[2]).write_text("\n".join(links) + "\n", encoding="utf-8") +pathlib.Path(sys.argv[3]).write_text( + "\n".join(str(port) for port in ports) + "\n", encoding="utf-8" +) +PY +chmod 600 "$LINKS_FILE" "$PORTS_FILE" + +mapfile -t SUBSCRIPTION_PORTS < "$PORTS_FILE" +(( ${#SUBSCRIPTION_PORTS[@]} > 0 )) || die "В подписке не найдены доступные порты" +printf 'Доступные порты в подписке: %s\n' "${SUBSCRIPTION_PORTS[*]}" + +while :; do + read -rp "Выберите порт подписки [${SUBSCRIPTION_PORTS[0]}]: " SUBSCRIPTION_PORT + SUBSCRIPTION_PORT="${SUBSCRIPTION_PORT:-${SUBSCRIPTION_PORTS[0]}}" + [[ "$SUBSCRIPTION_PORT" =~ ^[0-9]{1,5}$ ]] \ + && (( 10#$SUBSCRIPTION_PORT >= 1 && 10#$SUBSCRIPTION_PORT <= 65535 )) \ + || { warn "Порт должен быть числом от 1 до 65535"; continue; } + SUBSCRIPTION_PORT="$((10#$SUBSCRIPTION_PORT))" + + port_found="no" + for available_port in "${SUBSCRIPTION_PORTS[@]}"; do + if [[ "$SUBSCRIPTION_PORT" == "$available_port" ]]; then + port_found="yes" + break + fi + done + [[ "$port_found" == yes ]] && break + warn "Порт ${SUBSCRIPTION_PORT} отсутствует в подписке" +done + +python3 - "$LINKS_FILE" "$LINK_FILE" "$SUBSCRIPTION_PORT" <<'PY' +import pathlib +import sys +import urllib.parse + +links = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() +selected_port = int(sys.argv[3]) +for link in links: + if urllib.parse.urlsplit(link).port == selected_port: + pathlib.Path(sys.argv[2]).write_text(link, encoding="utf-8") + break +else: + raise SystemExit(f"VLESS-ссылка для порта {selected_port} не найдена") PY chmod 600 "$LINK_FILE" +log "Выбран порт подписки ${SUBSCRIPTION_PORT}" VPN_IP="$(python3 - "$LINK_FILE" "$GENERATED_CONFIG" "$TPROXY_PORT" "$SOCKS_PORT" <<'PY' import json