#!/usr/bin/env bash # Создание OS-пользователя tunnel на VM2 для SSH local port forwarding к PostgreSQL/PgBouncer. # # Контракт: arch-06 — только local forwarding, PermitOpen allow-list, без sudo/shell. # На VM2 по умолчанию AllowTcpForwarding=no; исключение только для tunnel. # # Использование (на VM2 под root): # TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \ # PG_HOST=192.168.0.210 \ # PG_PORT=6433 \ # bash setup-tunnel-user.sh # # Опционально: # TUNNEL_USER=tunnel # TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" # ограничить источники SSH # TUNNEL_ALLOW_ANY_SOURCE=true # осознанно разрешить ключу вход с любого адреса # EXTRA_PERMIT_OPEN="192.168.0.210:5433" # доп. endpoint (direct PG) # SSHD_MAIN_CONF=/etc/ssh/sshd_config.d/00-han-chat-vm2.conf # # По умолчанию TUNNEL_SOURCE_CIDRS обязателен. Разрешение любого источника # требует явного TUNNEL_ALLOW_ANY_SOURCE=true и должно компенсироваться SG/firewall. set -Eeuo pipefail IFS=$'\n\t' TUNNEL_USER="${TUNNEL_USER:-tunnel}" TUNNEL_AUTHORIZED_KEY_FILE="${TUNNEL_AUTHORIZED_KEY_FILE:-}" TUNNEL_SOURCE_CIDRS="${TUNNEL_SOURCE_CIDRS:-}" TUNNEL_ALLOW_ANY_SOURCE="${TUNNEL_ALLOW_ANY_SOURCE:-false}" PG_HOST="${PG_HOST:-192.168.0.210}" PG_PORT="${PG_PORT:-6433}" EXTRA_PERMIT_OPEN="${EXTRA_PERMIT_OPEN:-}" SSHD_MAIN_CONF="${SSHD_MAIN_CONF:-/etc/ssh/sshd_config.d/00-han-chat-vm2.conf}" SSHD_TUNNEL_CONF="/etc/ssh/sshd_config.d/50-han-tunnel-user.conf" AUTHORIZED_KEYS_DIR="/etc/ssh/authorized_keys" AUTHORIZED_KEYS_FILE="${AUTHORIZED_KEYS_DIR}/${TUNNEL_USER}" declare -a PERMIT_OPENS=() log() { printf '[setup-tunnel] %s\n' "$*"; } step() { printf '\n=== %s ===\n' "$*"; } die() { printf '[setup-tunnel] ERROR: %s\n' "$*" >&2; exit 1; } require_root() { [[ "$(id -u)" -eq 0 ]] || die "Скрипт нужно запускать от root" } require_commands() { local command for command in awk chmod cp getent grep id install mktemp mv passwd python3 rm \ ssh-keygen sshd stat systemctl useradd usermod; do command -v "$command" >/dev/null 2>&1 || die "Не найдена обязательная команда: ${command}" done } validate_tunnel_user() { [[ "$TUNNEL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ || die "Некорректное имя пользователя: ${TUNNEL_USER}" } validate_source_policy() { [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" || "$TUNNEL_ALLOW_ANY_SOURCE" == "false" ]] \ || die "TUNNEL_ALLOW_ANY_SOURCE должен быть true или false" if [[ -n "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then die "Задайте либо TUNNEL_SOURCE_CIDRS, либо TUNNEL_ALLOW_ANY_SOURCE=true, но не оба" fi if [[ -z "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" != "true" ]]; then die "TUNNEL_SOURCE_CIDRS обязателен; для осознанного отказа задайте TUNNEL_ALLOW_ANY_SOURCE=true" fi if [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then log "ВНИМАНИЕ: источник SSH-ключа не ограничен; доступ должен ограничиваться SG/firewall" fi } validate_cidrs() { local cidrs=$1 local err normalized [[ -n "$cidrs" ]] || return 0 [[ "$cidrs" =~ ^[0-9A-Fa-f:./,[:space:]]+$ ]] \ || die "TUNNEL_SOURCE_CIDRS содержит недопустимые символы" if ! err=$(python3 - "$cidrs" 2>&1 <<'PY' import ipaddress import sys parts = [raw.strip() for raw in sys.argv[1].split(",")] if not parts or any(not part for part in parts): print("TUNNEL_SOURCE_CIDRS содержит пустой элемент", file=sys.stderr) raise SystemExit(1) for cidr in parts: try: ipaddress.ip_network(cidr, strict=False) except ValueError as exc: print(f"Некорректный CIDR: {cidr} ({exc})", file=sys.stderr) raise SystemExit(1) from exc PY ); then die "${err:-TUNNEL_SOURCE_CIDRS содержит некорректный CIDR}" fi normalized=$(python3 - "$cidrs" <<'PY' import ipaddress import sys print(",".join( str(ipaddress.ip_network(part.strip(), strict=False)) for part in sys.argv[1].split(",") )) PY ) TUNNEL_SOURCE_CIDRS=$normalized } validate_host_port() { local host=$1 port=$2 label=$3 [[ -n "$host" ]] || die "${label}: host пустой" [[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]] \ || die "${label}: host содержит недопустимые символы" [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \ || die "${label}: некорректный port ${port}" } parse_host_port_item() { local item=$1 label=$2 local host port item="${item//[[:space:]]/}" [[ -n "$item" ]] || die "${label}: пустой host:port" [[ "$item" == *:* ]] || die "${label}: ожидается host:port, получено ${item}" host="${item%%:*}" port="${item##*:}" [[ "$host" != "$port" ]] || die "${label}: отсутствует port в ${item}" validate_host_port "$host" "$port" "$label" printf '%s:%s\n' "$host" "$port" } collect_permit_opens() { local item host_port existing duplicate local -a extra=() validate_host_port "$PG_HOST" "$PG_PORT" "PG" PERMIT_OPENS=("${PG_HOST}:${PG_PORT}") if [[ -n "$EXTRA_PERMIT_OPEN" ]]; then IFS=',' read -r -a extra <<< "$EXTRA_PERMIT_OPEN" ((${#extra[@]} > 0)) || die "EXTRA_PERMIT_OPEN не содержит endpoint" for item in "${extra[@]}"; do host_port=$(parse_host_port_item "$item" "EXTRA_PERMIT_OPEN") duplicate=false for existing in "${PERMIT_OPENS[@]}"; do [[ "$existing" == "$host_port" ]] && duplicate=true done if [[ "$duplicate" == "false" ]]; then PERMIT_OPENS+=("$host_port") fi done fi } validate_key_file() { local file=$1 local lines [[ -n "$file" && -f "$file" && -s "$file" ]] \ || die "Не найден TUNNEL_AUTHORIZED_KEY_FILE: ${file:-}" ssh-keygen -l -f "$file" >/dev/null 2>&1 || die "Некорректный SSH public key: ${file}" mapfile -t lines < <(grep -Ev '^[[:space:]]*(#|$)' "$file") ((${#lines[@]} == 1)) || die "Файл ключа должен содержать ровно одну строку без комментариев: ${file}" [[ "${lines[0]}" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] \ || die "Разрешён только чистый ssh-ed25519 без SSH-опций: ${file}" } read_ed25519_public_key() { local file=$1 local line key_type key_data ignored_comment line=$(grep -Ev '^[[:space:]]*(#|$)' "$file") IFS=$' \t' read -r key_type key_data ignored_comment <<< "$line" [[ "$key_type" == "ssh-ed25519" ]] || die "Разрешён только ssh-ed25519" [[ "$key_data" =~ ^[A-Za-z0-9+/=]+$ ]] || die "Некорректное тело публичного ключа" ED25519_KEY_TYPE=$key_type ED25519_KEY_DATA=$key_data # Входной комментарий не влияет на авторизацию и намеренно не переносится. ED25519_KEY_COMMENT=han-vm2-db-tunnel } build_authorized_keys_line() { local key_file=$1 local from_prefix="" options host_port read_ed25519_public_key "$key_file" options='restrict,port-forwarding' if [[ -n "$TUNNEL_SOURCE_CIDRS" ]]; then from_prefix="from=\"${TUNNEL_SOURCE_CIDRS}\"," fi for host_port in "${PERMIT_OPENS[@]}"; do options+=",permitopen=\"${host_port}\"" done if [[ -n "$from_prefix" ]]; then printf '%s%s %s %s %s\n' \ "$from_prefix" "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT" else printf '%s %s %s %s\n' \ "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT" fi } create_or_update_user() { step "Создание пользователя ${TUNNEL_USER}" local actual_home actual_shell primary_group home_owner if id "$TUNNEL_USER" >/dev/null 2>&1; then IFS=: read -r _ _ _ _ _ actual_home actual_shell < <(getent passwd "$TUNNEL_USER") primary_group=$(id -gn "$TUNNEL_USER") [[ "$actual_home" == "/home/${TUNNEL_USER}" ]] \ || die "Существующий ${TUNNEL_USER} имеет неожиданный home: ${actual_home}" [[ "$primary_group" == "$TUNNEL_USER" ]] \ || die "Существующий ${TUNNEL_USER} имеет неожиданную primary group: ${primary_group}" [[ "$actual_shell" == "/usr/sbin/nologin" ]] \ || die "Отказываюсь переоборудовать существующего ${TUNNEL_USER} с shell ${actual_shell}" [[ -d "$actual_home" && ! -L "$actual_home" ]] \ || die "Home ${actual_home} отсутствует, не является каталогом или является симлинком" home_owner=$(stat -c '%U' "$actual_home") [[ "$home_owner" == "$TUNNEL_USER" ]] \ || die "Home ${actual_home} принадлежит ${home_owner}, ожидался ${TUNNEL_USER}" log "Найден ранее созданный выделенный пользователь ${TUNNEL_USER}" else getent group "$TUNNEL_USER" >/dev/null 2>&1 \ && die "Группа ${TUNNEL_USER} уже существует без одноимённого пользователя" useradd --create-home --user-group --home-dir "/home/${TUNNEL_USER}" \ --shell /usr/sbin/nologin "$TUNNEL_USER" fi passwd -l "$TUNNEL_USER" >/dev/null usermod --lock --groups "" "$TUNNEL_USER" [[ "$(id -nG "$TUNNEL_USER")" == "$TUNNEL_USER" ]] \ || die "Не удалось удалить дополнительные группы пользователя ${TUNNEL_USER}" } build_authorized_keys_candidate() { local target=$1 build_authorized_keys_line "$TUNNEL_AUTHORIZED_KEY_FILE" > "$target" # sshd читает AuthorizedKeysFile с правами целевого пользователя. # Файл остаётся root-owned и поэтому 0644 не позволяет tunnel изменить ключ. chmod 0644 "$target" } build_sshd_tunnel_candidate() { local target=$1 host_port { printf 'Match User %s\n' "$TUNNEL_USER" printf ' AuthorizedKeysFile %s/%%u\n' "$AUTHORIZED_KEYS_DIR" echo ' AllowTcpForwarding local' echo ' AllowStreamLocalForwarding no' echo ' GatewayPorts no' echo ' AllowAgentForwarding no' echo ' X11Forwarding no' echo ' PermitTTY no' echo ' PermitTunnel no' echo ' PermitUserRC no' echo ' MaxSessions 0' printf ' PermitOpen' for host_port in "${PERMIT_OPENS[@]}"; do printf ' %s' "$host_port" done printf '\nMatch all\n' } > "$target" chmod 0644 "$target" } build_main_config_candidate() { local target=$1 allow_line user local -a allow_users=() [[ -f "$SSHD_MAIN_CONF" ]] || die "Не найден ${SSHD_MAIN_CONF}" [[ ! -L "$SSHD_MAIN_CONF" ]] || die "${SSHD_MAIN_CONF} не должен быть симлинком" ! grep -Eq '^[[:space:]]*Match([[:space:]]|$)' "$SSHD_MAIN_CONF" \ || die "${SSHD_MAIN_CONF} должен содержать только глобальные настройки, без Match" python3 - "$SSHD_MAIN_CONF" "$target" "$TUNNEL_USER" <<'PY' from pathlib import Path import re import sys source = Path(sys.argv[1]) target = Path(sys.argv[2]) user = sys.argv[3] lines = source.read_text(encoding="utf-8").splitlines(keepends=True) matches = [ index for index, line in enumerate(lines) if re.match(r"^\s*AllowUsers\s+", line) ] if len(matches) != 1: print( f"{source}: ожидалась ровно одна глобальная директива AllowUsers, найдено {len(matches)}", file=sys.stderr, ) raise SystemExit(1) index = matches[0] tokens = lines[index].split() if user not in tokens[1:]: newline = "\n" if lines[index].endswith("\n") else "" lines[index] = f"AllowUsers {' '.join(tokens[1:] + [user])}{newline}" Path(target).write_text("".join(lines), encoding="utf-8") PY allow_line=$(awk '/^[[:space:]]*AllowUsers[[:space:]]+/ {print; exit}' "$target") IFS=' ' read -r -a allow_users <<< "$allow_line" ((${#allow_users[@]} >= 2)) || die "AllowUsers не содержит пользователей" for user in "${allow_users[@]:1}"; do [[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ || die "Неподдерживаемый шаблон AllowUsers: ${user}; перечислите реальные локальные учётные записи" getent passwd "$user" >/dev/null \ || die "AllowUsers содержит несуществующего пользователя: ${user}" done chmod 0644 "$target" } verify_endpoint_from_host() { local endpoint=$1 local host="${endpoint%%:*}" local port="${endpoint##*:}" if command -v nc >/dev/null 2>&1; then nc -z -w 5 "$host" "$port" \ && log "TCP ${endpoint} доступен" \ || die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall" elif command -v timeout >/dev/null 2>&1; then timeout 5 bash -c 'echo > "/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null \ && log "TCP ${endpoint} доступен" \ || die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall" else log "nc и timeout не установлены; пропускаю TCP-проверку" fi } verify_network_from_host() { local endpoint step "Проверка сетевой доступности разрешённых endpoint с VM2" for endpoint in "${PERMIT_OPENS[@]}"; do verify_endpoint_from_host "$endpoint" done } atomic_install() { local source=$1 target=$2 mode=$3 local temporary temporary=$(mktemp "${target}.tmp.XXXXXX") || return 1 if ! install -o root -g root -m "$mode" "$source" "$temporary"; then rm -f -- "$temporary" return 1 fi if ! mv -Tf -- "$temporary" "$target"; then rm -f -- "$temporary" return 1 fi } backup_file() { local target=$1 name=$2 backup_dir=$3 if [[ -e "$target" || -L "$target" ]]; then cp -a -- "$target" "${backup_dir}/${name}" : > "${backup_dir}/${name}.existed" fi } restore_file() { local target=$1 name=$2 backup_dir=$3 rm -f -- "$target" if [[ -f "${backup_dir}/${name}.existed" ]]; then cp -a -- "${backup_dir}/${name}" "$target" fi } rollback_transaction() { local backup_dir=$1 log "Откат SSH-конфигурации и authorized_keys" restore_file "$SSHD_MAIN_CONF" main.conf "$backup_dir" restore_file "$SSHD_TUNNEL_CONF" tunnel.conf "$backup_dir" restore_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$backup_dir" } assert_effective_value() { local config=$1 key=$2 expected=$3 actual actual=$(awk -v key="$key" '$1 == key {$1=""; sub(/^ /, ""); print; exit}' <<< "$config") if [[ "$actual" != "$expected" ]]; then log "Эффективный sshd ${key}=${actual:-}, ожидалось ${expected}" return 1 fi } verify_effective_sshd_config() { local effective endpoint permit_open authorized_keys_value allow_user_found=false local -a effective_permit_opens=() effective=$(sshd -T -C "user=${TUNNEL_USER},host=localhost,addr=127.0.0.1") \ || return 1 assert_effective_value "$effective" allowtcpforwarding local || return 1 assert_effective_value "$effective" allowstreamlocalforwarding no || return 1 assert_effective_value "$effective" allowagentforwarding no || return 1 assert_effective_value "$effective" x11forwarding no || return 1 assert_effective_value "$effective" gatewayports no || return 1 assert_effective_value "$effective" permittty no || return 1 assert_effective_value "$effective" permittunnel no || return 1 assert_effective_value "$effective" permituserrc no || return 1 assert_effective_value "$effective" maxsessions 0 || return 1 assert_effective_value "$effective" passwordauthentication no || return 1 assert_effective_value "$effective" kbdinteractiveauthentication no || return 1 assert_effective_value "$effective" pubkeyauthentication yes || return 1 authorized_keys_value=$(awk ' $1 == "authorizedkeysfile" {$1=""; sub(/^ /, ""); print; exit} ' <<< "$effective") if [[ "$authorized_keys_value" != "${AUTHORIZED_KEYS_DIR}/%u" \ && "$authorized_keys_value" != "$AUTHORIZED_KEYS_FILE" ]]; then log "Эффективный AuthorizedKeysFile=${authorized_keys_value:-}, ожидался root-owned путь" return 1 fi permit_open=$(awk ' $1 == "permitopen" { for (i = 2; i <= NF; i++) values = values " " $i } END {sub(/^ /, "", values); print values} ' <<< "$effective") IFS=' ' read -r -a effective_permit_opens <<< "$permit_open" if ((${#effective_permit_opens[@]} != ${#PERMIT_OPENS[@]})); then log "Эффективный PermitOpen содержит неожиданный набор: ${permit_open:-}" return 1 fi for endpoint in "${PERMIT_OPENS[@]}"; do if [[ " ${permit_open} " != *" ${endpoint} "* ]]; then log "Эффективный PermitOpen не содержит ${endpoint}" return 1 fi done if awk -v user="$TUNNEL_USER" ' $1 == "allowusers" { for (i = 2; i <= NF; i++) if ($i == user) found = 1 } END {exit !found} ' <<< "$effective"; then allow_user_found=true fi if [[ "$allow_user_found" != "true" ]]; then log "Эффективный AllowUsers не содержит ${TUNNEL_USER}" return 1 fi } detect_ssh_service() { if systemctl cat ssh.service >/dev/null 2>&1; then printf 'ssh\n' elif systemctl cat sshd.service >/dev/null 2>&1; then printf 'sshd\n' else die "Не найден systemd-сервис ssh.service или sshd.service" fi } prepare_authorized_keys_dir() { local owner mode if [[ -e "$AUTHORIZED_KEYS_DIR" || -L "$AUTHORIZED_KEYS_DIR" ]]; then [[ -d "$AUTHORIZED_KEYS_DIR" && ! -L "$AUTHORIZED_KEYS_DIR" ]] \ || die "${AUTHORIZED_KEYS_DIR} должен быть обычным каталогом, не симлинком" owner=$(stat -c '%U:%G' "$AUTHORIZED_KEYS_DIR") [[ "$owner" == "root:root" ]] \ || die "${AUTHORIZED_KEYS_DIR} принадлежит ${owner}, ожидался root:root" mode=$(stat -c '%a' "$AUTHORIZED_KEYS_DIR") (( (8#$mode & 8#022) == 0 )) \ || die "${AUTHORIZED_KEYS_DIR} доступен для записи группе или остальным: mode ${mode}" else install -d -o root -g root -m 0755 "$AUTHORIZED_KEYS_DIR" fi } install_and_reload_sshd() { step "Атомарная установка, проверка и reload sshd" local work_dir=$1 main_candidate=$2 tunnel_candidate=$3 key_candidate=$4 local ssh_service ssh_service=$(detect_ssh_service) prepare_authorized_keys_dir backup_file "$SSHD_MAIN_CONF" main.conf "$work_dir" backup_file "$SSHD_TUNNEL_CONF" tunnel.conf "$work_dir" backup_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$work_dir" if ! atomic_install "$main_candidate" "$SSHD_MAIN_CONF" 0644; then rollback_transaction "$work_dir" die "Не удалось установить ${SSHD_MAIN_CONF}; выполнен откат" fi if ! atomic_install "$tunnel_candidate" "$SSHD_TUNNEL_CONF" 0644; then rollback_transaction "$work_dir" die "Не удалось установить ${SSHD_TUNNEL_CONF}; выполнен откат" fi if ! sshd -t; then rollback_transaction "$work_dir" die "sshd -t не прошёл; исходная конфигурация восстановлена" fi if ! verify_effective_sshd_config; then rollback_transaction "$work_dir" die "Эффективная конфигурация sshd не прошла проверку; выполнен откат" fi if ! atomic_install "$key_candidate" "$AUTHORIZED_KEYS_FILE" 0644; then rollback_transaction "$work_dir" die "Не удалось установить ${AUTHORIZED_KEYS_FILE}; выполнен откат" fi if ! systemctl reload "$ssh_service"; then rollback_transaction "$work_dir" if sshd -t; then systemctl reload "$ssh_service" \ || log "КРИТИЧНО: не удалось reload исходной конфигурации ${ssh_service}" else log "КРИТИЧНО: исходная конфигурация после отката не проходит sshd -t" fi die "Reload ${ssh_service} не выполнен; конфигурация восстановлена" fi log "sshd reload выполнен; ключ хранится в root-owned ${AUTHORIZED_KEYS_FILE}" } print_usage() { cat < В другом терминале: nc -zv 127.0.0.1 16433 Подключение к БД через туннель (пример): psql "postgresql://:@127.0.0.1:16433/?sslmode=verify-full&sslrootcert=" Что запрещено для ${TUNNEL_USER}: - sudo / shell / доступ к секретам приложения - remote forwarding (-R), произвольный SOCKS, agent/X11 - forwarding на адреса вне PermitOpen Политика источника ключа: - CIDR: ${TUNNEL_SOURCE_CIDRS:-не задан} - осознанно разрешён любой источник: ${TUNNEL_ALLOW_ANY_SOURCE} EOF } main() { local work_dir main_candidate tunnel_candidate key_candidate require_root require_commands validate_tunnel_user validate_source_policy validate_cidrs "$TUNNEL_SOURCE_CIDRS" validate_key_file "$TUNNEL_AUTHORIZED_KEY_FILE" collect_permit_opens verify_network_from_host create_or_update_user work_dir=$(mktemp -d /run/setup-tunnel-user.XXXXXX) trap "rm -rf -- '$work_dir'" EXIT main_candidate="${work_dir}/main.conf.candidate" tunnel_candidate="${work_dir}/tunnel.conf.candidate" key_candidate="${work_dir}/authorized_keys.candidate" build_main_config_candidate "$main_candidate" build_sshd_tunnel_candidate "$tunnel_candidate" build_authorized_keys_candidate "$key_candidate" install_and_reload_sshd \ "$work_dir" "$main_candidate" "$tunnel_candidate" "$key_candidate" print_usage } main "$@"