Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user