589 lines
22 KiB
Bash
589 lines
22 KiB
Bash
#!/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:-<empty>}"
|
||
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:-<empty>}, ожидалось ${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:-<empty>}, ожидался 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:-<empty>}"
|
||
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 <<EOF
|
||
|
||
Готово.
|
||
|
||
Проверка с вашей рабочей машины (не закрывая текущую admin-сессию):
|
||
|
||
ssh -i ~/.ssh/han_vm2_tunnel -N \\
|
||
-L 16433:${PG_HOST}:${PG_PORT} \\
|
||
${TUNNEL_USER}@<VM2_PUBLIC_IP>
|
||
|
||
В другом терминале:
|
||
|
||
nc -zv 127.0.0.1 16433
|
||
|
||
Подключение к БД через туннель (пример):
|
||
|
||
psql "postgresql://<USER>:<PASSWORD>@127.0.0.1:16433/<DB>?sslmode=verify-full&sslrootcert=<PATH_TO_CA>"
|
||
|
||
Что запрещено для ${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 "$@"
|