#!/usr/bin/env bash # install.sh — разворачивает выходной сервер каскада AmneziaWG или релей одной командой: # wget -qO- https://w.dev.onov.ru | sudo bash # Выход ставит закреплённый по SHA-256 установщик bivlked с --yes внутри одноразового # systemd-юнита (режим --resume): обрыв SSH его не прерывает, ребуты продолжают установку. # Роль определяется сама (есть /etc/awg-relay.env — релей, стоит AmneziaWG — выход), на чистом сервере — меню # или W_ROLE=exit|relay. Параметры без вопросов: W_ROLE W_NAME W_RELAY_IP W_PORT W_ENDPOINT W_UPSTREAM W_PUBLIC_IP W_FORCE=1. set -euo pipefail # Неизменяемые константы. scripts/build.sh встраивает этот файл в dist/install.sh, dist/vpn # и dist/relay вместо строки-маркера, поэтому собранные скрипты самодостаточны. # BOOTSTRAP_ROOT — префикс всех абсолютных путей, нужен только тестам (в проде пуст). # shellcheck disable=SC2034 # Версия подставляется сборкой (CI_COMMIT_TAG, иначе git describe --tags --always, иначе dev). readonly TOOLS_VERSION="v1.0.0" readonly BASE_URL="https://w.dev.onov.ru" # Установщик bivlked v6.2.1, закреплённый по коммиту и SHA-256. readonly AWG_INSTALLER_URL="https://raw.githubusercontent.com/bivlked/amneziawg-installer/4eed2dd3bb892bae9d1ccfd4183ef5a1464061ca/install_amneziawg.sh" # TEST_INSTALLER_SHA256 — единственное тестовое исключение (подмена заглушкой установщика), в проде не задаётся. readonly AWG_INSTALLER_SHA256="${TEST_INSTALLER_SHA256:-75940a6c92c3734f7f8bc43d9f499b572b430144c79b7342a7bdc6f21969cf91}" readonly FS_ROOT="${BOOTSTRAP_ROOT:-}" readonly AWG_DIR="$FS_ROOT/root/awg" readonly AWG_STATE_FILE="$AWG_DIR/setup_state" readonly AWG_STATE_DONE="99" readonly AWG_MANAGE="$AWG_DIR/manage_amneziawg.sh" # Настройки официального установщика: имя сервера — строка export AWG_SERVER_NAME='…'. readonly AWG_CONFIG="$AWG_DIR/awgsetup_cfg.init" readonly AWG_SERVER_NAME_KEY="AWG_SERVER_NAME" # Так установщик называет сервер, если в настройках имени нет. readonly AWG_SERVER_NAME_DEFAULT="AWG Server" readonly AWG_SERVER_CONF="$FS_ROOT/etc/amnezia/amneziawg/awg0.conf" readonly BOOTSTRAP_DIR="$FS_ROOT/opt/awg-bootstrap" readonly BOOTSTRAP_UNIT="awg-bootstrap.service" readonly BOOTSTRAP_UNIT_FILE="$FS_ROOT/etc/systemd/system/$BOOTSTRAP_UNIT" readonly BOOTSTRAP_NAME_FILE="$BOOTSTRAP_DIR/server_name" readonly BOOTSTRAP_LOG="$FS_ROOT/var/log/awg-bootstrap.log" readonly BOOTSTRAP_MOTD="$FS_ROOT/etc/motd.d/awg-bootstrap" readonly BOOTSTRAP_MAX_ATTEMPTS=6 readonly BOOTSTRAP_SELF="$BOOTSTRAP_DIR/install.sh" readonly BOOTSTRAP_INSTALLER="$BOOTSTRAP_DIR/install_amneziawg.sh" readonly BOOTSTRAP_VPN="$BOOTSTRAP_DIR/vpn" readonly BOOTSTRAP_ARGS_FILE="$BOOTSTRAP_DIR/args" readonly BOOTSTRAP_ATTEMPTS_FILE="$BOOTSTRAP_DIR/attempts" # Метка полной переустановки (W_FORCE=1): pending — установщик ещё не запускался с --force, running — запускался. readonly BOOTSTRAP_FORCE_FILE="$BOOTSTRAP_DIR/force" readonly FORCE_PENDING="pending" readonly FORCE_RUNNING="running" readonly BOOTSTRAP_LOCK_FILE="$BOOTSTRAP_DIR/lock" # Публичный IP выхода, который задали при запуске: в юните нет TTY, спросить его там нельзя. readonly BOOTSTRAP_PUBLIC_IP_FILE="$BOOTSTRAP_DIR/public_ip" readonly VPN_BIN="$FS_ROOT/usr/local/bin/vpn" # Релей (российский сервер, пробрасывающий UDP на выход). readonly RELAY_BIN="$FS_ROOT/usr/local/bin/relay" readonly RELAY_ENV="$FS_ROOT/etc/awg-relay.env" readonly RELAY_UNIT="awg-relay.service" readonly RELAY_UNIT_FILE="$FS_ROOT/etc/systemd/system/$RELAY_UNIT" readonly RELAY_DROPIN_NAME="awg-relay.conf" readonly RELAY_SYSCTL_FILE="$FS_ROOT/etc/sysctl.d/90-awg-relay.conf" readonly RELAY_MODULES_FILE="$FS_ROOT/etc/modules-load.d/awg-relay.conf" # Drop-in'ы и MOTD, которые установщик релея кладёт рядом (relay uninstall их убирает). readonly RELAY_DROPIN_DOCKER="$FS_ROOT/etc/systemd/system/docker.service.d/$RELAY_DROPIN_NAME" readonly RELAY_DROPIN_UFW="$FS_ROOT/etc/systemd/system/ufw.service.d/$RELAY_DROPIN_NAME" readonly RELAY_MOTD="$FS_ROOT/etc/motd.d/awg-relay" # Выход (FI): свой sysctl и список известных релеев. readonly EXIT_SYSCTL_FILE="$FS_ROOT/etc/sysctl.d/90-awg-exit.conf" # Выход тоже грузит nf_conntrack при загрузке: иначе sysctl net.netfilter.* из EXIT_SYSCTL_FILE не применится. readonly EXIT_MODULES_FILE="$FS_ROOT/etc/modules-load.d/awg-exit.conf" readonly EXIT_MODULES_CONTENT="nf_conntrack" readonly EXIT_RELAYS_FILE="$BOOTSTRAP_DIR/relays" # Метка прямого режима (vpn relay off): повторный запуск установщика не требует релей и не закрывает порт. readonly EXIT_DIRECT_FILE="$BOOTSTRAP_DIR/direct" # Диапазон случайного порта релея. readonly RELAY_PORT_MIN=20000 readonly RELAY_PORT_MAX=60000 readonly CLIENT_KEEPALIVE=25 # Цепочки iptables релея. readonly RELAY_CHAIN_PRE="AWGR_PRE" readonly RELAY_CHAIN_POST="AWGR_POST" readonly RELAY_CHAIN_FWD="AWGR_FWD" # sysctl релея; на выходе только conntrack_udp_timeout_stream (остальное ставит bivlked). readonly RELAY_SYSCTL_CONTENT="net.ipv4.ip_forward=1 net.core.rmem_max=16777216 net.core.wmem_max=16777216 net.core.netdev_max_backlog=5000 net.netfilter.nf_conntrack_udp_timeout_stream=180 net.netfilter.nf_conntrack_max=262144" # relay uninstall без Docker выключает форвардинг (с Docker он нужен контейнерам). readonly RELAY_FORWARD_OFF="net.ipv4.ip_forward=0" readonly EXIT_SYSCTL_CONTENT="net.netfilter.nf_conntrack_udp_timeout_stream=180" # Терминал для вопросов: при "wget -qO- … | sudo bash" stdin занят текстом скрипта. # W_TTY нужен только тестам. readonly TTY_DEFAULT="/dev/tty" # Границы проверки ввода и подбора порта (src/lib.sh). readonly DOMAIN_MAX_LEN=253 readonly DOMAIN_LABEL_MAX=63 readonly PORT_PICK_ATTEMPTS=50 # Адрес, до которого ищем маршрут, чтобы узнать исходящий IP сервера. readonly PUBLIC_IP_PROBE_ADDR="1.1.1.1" # Ошибка conntrack -D "0 entries" — код 1, это не сбой. readonly CONNTRACK_NOT_FOUND_RC=1 # Установка релея (src/relay-install.sh). readonly RELAY_MODULES_CONTENT="nf_conntrack" readonly RELAY_APT_PACKAGES="iptables conntrack" readonly RELAY_DROPIN_CONTENT="[Service] ExecStartPost=-/bin/systemctl --no-block restart $RELAY_UNIT" # Юниты, после которых релей должен перезапускаться: они пересоздают правила iptables. readonly RELAY_DROPIN_UNITS="docker.service ufw.service" # Выход: файрвол UFW и клиенты (src/exit-firewall.sh, src/exit-install.sh). # Комментарии правил UFW: по ним находим свои правила в "ufw status". readonly EXIT_RELAY_COMMENT="AWG relay" readonly EXIT_PUBLIC_COMMENT="AmneziaWG VPN" readonly EXIT_PORT_KEY="AWG_PORT" readonly EXIT_PUBLIC_IP_PLACEHOLDER="<публичный IP этого сервера>" # vpn relay (src/vpn.sh): ключ адреса для клиентов в настройках установщика. readonly AWG_ENDPOINT_KEY="AWG_ENDPOINT" # Общие функции: вывод, загрузка, проверка ввода, вопросы пользователю. # scripts/build.sh встраивает файл по маркеру "# @lib". Нужны константы (RELAY_PORT_MIN/MAX, TTY_DEFAULT). # shellcheck shell=bash # log_hook <строка> — необязательный приёмник копии вывода: установщик выхода определяет # его, чтобы дублировать сообщения в лог установки. Если функции нет, ничего не происходит. log() { local line line="[$(date +'%F %T')] $*" echo "$line" if declare -F log_hook >/dev/null; then log_hook "$line"; fi return 0 } die() { local line echo "ОШИБКА: $*" >&2 line="[$(date +'%F %T')] ОШИБКА: $*" if declare -F log_hook >/dev/null; then log_hook "$line"; fi exit 1 } # fetch — скачать файл через curl, а если его нет, через wget. # Код 127 (команда не найдена/не запускается) переключает на следующий инструмент. # Пишет во временный файл и подменяет dest атомарно: читающий его процесс не увидит половину. fetch() { local url="$1" dest="$2" rc=127 if command -v curl >/dev/null; then curl -fsSL --retry 5 --retry-all-errors -o "$dest.part" "$url" && rc=0 || rc=$? fi if [[ $rc -eq 127 ]] && command -v wget >/dev/null; then wget -qO "$dest.part" "$url" && rc=0 || rc=$? fi [[ $rc -eq 127 ]] && die "нет ни curl, ни wget: установите один из них (apt install -y curl)" if [[ $rc -ne 0 ]]; then rm -f "$dest.part" return "$rc" fi mv -f "$dest.part" "$dest" } require_root() { [[ ${BOOTSTRAP_EUID:-$EUID} -eq 0 ]] || die "нужны права root: запустите через sudo" } # valid_ipv4 — четыре октета 0–255 без ведущих нулей. valid_ipv4() { [[ $1 =~ ^(0|[1-9][0-9]{0,2})(\.(0|[1-9][0-9]{0,2})){3}$ ]] || return 1 local o local IFS=. for o in $1; do ((o <= 255)) || return 1; done } # valid_port — 1–65535. valid_port() { [[ $1 =~ ^[1-9][0-9]{0,4}$ ]] && (($1 <= 65535)) } # valid_domain — имя по RFC 1123: метки до 63 символов, всего до 253, хотя бы одна точка, # последняя метка не из одних цифр. valid_domain() { local d="$1" ((${#d} <= DOMAIN_MAX_LEN)) || return 1 local mid=$((DOMAIN_LABEL_MAX - 2)) label label="[A-Za-z0-9]([A-Za-z0-9-]{0,$mid}[A-Za-z0-9])?" [[ $d =~ ^(${label}\.)+${label}$ ]] || return 1 [[ ${d##*.} =~ ^[0-9]+$ ]] && return 1 return 0 } # valid_upstream valid_upstream() { [[ $1 == *:* ]] || return 1 valid_ipv4 "${1%:*}" && valid_port "${1##*:}" } # valid_endpoint — домен или IPv4. valid_endpoint() { valid_domain "$1" || valid_ipv4 "$1"; } # valid_role_choice — выбор в меню роли. valid_role_choice() { [[ $1 == 1 || $1 == 2 ]]; } # valid_yes_no — ответ y/yes/n/no (регистр не важен). valid_yes_no() { local a="${1,,}" [[ $a == y || $a == yes || $a == n || $a == no ]] } # is_yes — ответ-согласие: y или yes (регистр не важен). is_yes() { local a="${1,,}" [[ $a == y || $a == yes ]] } # is_private_ipv4 — 10/8, 172.16/12, 192.168/16, 100.64/10, 127/8. is_private_ipv4() { local a b IFS=. read -r a b _ <<<"$1" ((a == 10 || a == 127)) && return 0 ((a == 172 && b >= 16 && b <= 31)) && return 0 ((a == 192 && b == 168)) && return 0 ((a == 100 && b >= 64 && b <= 127)) && return 0 return 1 } # tty_available — есть ли терминал для вопросов (тот же путь, что использует ask). tty_available() { local tty="${W_TTY:-$TTY_DEFAULT}" { : <"$tty"; } 2>/dev/null } # ask — спросить значение у терминала ($W_TTY, по умолчанию /dev/tty). # Без терминала печатает default, а если его нет — die. Повторяет вопрос, пока валидатор не пройдёт. # Вопрос идёт в stderr, ответ — в stdout. ask() { local prompt="$1" default="$2" validator="$3" tty="${W_TTY:-$TTY_DEFAULT}" fd answer if ! { : <"$tty"; } 2>/dev/null; then [[ -n $default ]] || die "$prompt: нет терминала и нет значения по умолчанию" echo "$default" return 0 fi exec {fd}<"$tty" while true; do if [[ -n $default ]]; then printf '%s [%s]: ' "$prompt" "$default" >&2 else printf '%s: ' "$prompt" >&2 fi if ! IFS= read -r answer <&"$fd"; then exec {fd}<&- [[ -n $default ]] || die "$prompt: ввод закончился, а значения по умолчанию нет" echo >&2 echo "$default" return 0 fi [[ -n $answer ]] || answer="$default" if [[ -n $answer ]] && "$validator" "$answer"; then exec {fd}<&- echo "$answer" return 0 fi echo "Некорректное значение, повторите ввод." >&2 done } # env_or_ask — значение из окружения (проверенное) или ask. env_or_ask() { local var="$1" if [[ -n ${!var:-} ]]; then "$4" "${!var}" || die "$var некорректно" echo "${!var}" return 0 fi ask "$2" "$3" "$4" } # random_free_port — случайный свободный UDP-порт из диапазона RELAY_PORT_MIN–RELAY_PORT_MAX. random_free_port() { local i p for ((i = 0; i < PORT_PICK_ATTEMPTS; i++)); do p="$(shuf -i "$RELAY_PORT_MIN-$RELAY_PORT_MAX" -n 1)" if [[ -z $(ss -Hlun "sport = :$p") ]]; then echo "$p" return 0 fi done die "не удалось подобрать свободный порт" } # public_ipv4 — W_PUBLIC_IP, иначе src маршрута до PUBLIC_IP_PROBE_ADDR; если он частный (NAT) — спросить. public_ipv4() { if [[ -n ${W_PUBLIC_IP:-} ]]; then valid_ipv4 "$W_PUBLIC_IP" || die "W_PUBLIC_IP некорректно" echo "$W_PUBLIC_IP" return 0 fi local src src="$(ip -4 route get "$PUBLIC_IP_PROBE_ADDR" 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n1)" || src="" if [[ -n $src ]] && valid_ipv4 "$src" && ! is_private_ipv4 "$src"; then echo "$src" return 0 fi ask "Публичный IP этого сервера" "" valid_ipv4 } # read_kv_file — значение первой строки KEY=value. Файл не выполняется (ни source, ни eval). # Значение может содержать пробелы, но не $ ` ' " \ ; и управляющие символы — иначе код 1. read_kv_file() { local file="$1" key="$2" line value [[ -r $file ]] || return 1 while IFS= read -r line || [[ -n $line ]]; do [[ $line == "$key="* ]] || continue value="${line#*=}" case $value in *[[:cntrl:]]* | *'$'* | *'`'* | *"'"* | *'"'* | *\\* | *';'*) return 1 ;; esac printf '%s\n' "$value" return 0 done <"$file" return 1 } # Неизменяемые константы вопроса об имени сервера. scripts/build.sh встраивает этот файл # вместе с src/server-name.sh по маркеру "# @server-name" в dist/install.sh (и в другие установщики). # shellcheck disable=SC2034 # Терминал для вопроса: при "wget -qO- … | sudo bash" stdin занят текстом скрипта. # BOOTSTRAP_TTY нужен только тестам. readonly SERVER_NAME_TTY="${BOOTSTRAP_TTY:-/dev/tty}" readonly SERVER_NAME_PROMPT="Имя сервера в приложении Amnezia" # Как у официального установщика AmneziaWG: имя уходит в JSON vpn:// и в awgsetup_cfg.init ('…'). readonly SERVER_NAME_MAX_BYTES=128 readonly SERVER_NAME_RULES="Имя: без кавычек, обратной косой черты и управляющих символов, до 128 байт (64 буквы кириллицы). Повторите ввод." # Вопрос об имени сервера, которое показывает приложение Amnezia (поле description в vpn://). # Общий код установщиков AmneziaWG и Xray: scripts/build.sh встраивает его по маркеру "# @server-name". # shellcheck shell=bash # server_name_trim <строка> — без пробелов по краям. server_name_trim() { local s="$1" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } # server_name_valid <имя> — правила validate_server_name официального установщика AmneziaWG. server_name_valid() { local n="$1" LC_ALL=C [[ -n $n ]] || return 1 ((${#n} <= SERVER_NAME_MAX_BYTES)) || return 1 [[ $n == *[\'\"\\]* || $n == *[[:cntrl:]]* ]] && return 1 [[ $n == "$(server_name_trim "$n")" ]] } # ask_server_name <по умолчанию> — спросить имя в терминале и напечатать его. # Enter — значение по умолчанию; нет терминала или ввод закончился — тоже оно (без зависания). # Подсказка идёт в stderr: stdout занят результатом. ask_server_name() { local def="$1" input if ! (: <"$SERVER_NAME_TTY") 2>/dev/null; then printf '%s' "$def" return 0 fi exec 3<"$SERVER_NAME_TTY" while true; do printf '%s [%s]: ' "$SERVER_NAME_PROMPT" "$def" >&2 if ! IFS= read -r input <&3; then input=$def echo >&2 break fi input=$(server_name_trim "$input") [[ -n $input ]] || input=$def server_name_valid "$input" && break echo "$SERVER_NAME_RULES" >&2 done exec 3<&- printf '%s' "$input" } # server_name_from_env <переменная> <значение> — проверенное имя из окружения или ошибка. server_name_from_env() { local name name=$(server_name_trim "$2") server_name_valid "$name" || die "$1 некорректно: без кавычек, обратной косой черты и управляющих символов, до $SERVER_NAME_MAX_BYTES байт" printf '%s' "$name" } # Правила релея (iptables/conntrack) и чтение/запись /etc/awg-relay.env. # scripts/build.sh встраивает файл по маркеру "# @relay-rules" в dist/relay и dist/install.sh. # Нужны constants.sh, lib.sh, server-name.sh. Клиентские адреса не читаются и не печатаются. # shellcheck shell=bash relay_ipt() { # args… — iptables с ожиданием xtables-замка iptables -w "$@" } # relay_rules_flush — убрать переходы в свои цепочки и сами цепочки. Отсутствие цепочек не ошибка. relay_rules_flush() { local spec table hook chain for spec in "nat PREROUTING $RELAY_CHAIN_PRE" "nat POSTROUTING $RELAY_CHAIN_POST" "filter FORWARD $RELAY_CHAIN_FWD"; do read -r table hook chain <<<"$spec" while relay_ipt -t "$table" -C "$hook" -j "$chain" 2>/dev/null; do relay_ipt -t "$table" -D "$hook" -j "$chain" 2>/dev/null || break done relay_ipt -t "$table" -F "$chain" 2>/dev/null || true relay_ipt -t "$table" -X "$chain" 2>/dev/null || true done } # relay_uplink_dev — интерфейс маршрута по умолчанию (пусто, если его нет). relay_uplink_dev() { ip -4 route show default 2>/dev/null | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -n1 || true } # relay_rules_apply — узкий DNAT: только udp на порт релея и только к апстриму. # После двух ACCEPT — DROP транзита uplink→uplink: на VPS с политикой FORWARD ACCEPT иначе релей # пересылал бы чужой транзит. Мосты Docker (uplink→docker0) это правило не задевает. relay_rules_apply() { local port="$1" up_ip="$2" up_port="$3" uplink local ct="-m conntrack --ctstate DNAT --ctorigdstport $port" uplink=$(relay_uplink_dev) relay_rules_flush relay_ipt -t nat -N "$RELAY_CHAIN_PRE" relay_ipt -t nat -N "$RELAY_CHAIN_POST" relay_ipt -t filter -N "$RELAY_CHAIN_FWD" relay_ipt -t nat -A "$RELAY_CHAIN_PRE" -p udp --dport "$port" -m addrtype --dst-type LOCAL -j DNAT --to-destination "$up_ip:$up_port" # shellcheck disable=SC2086 relay_ipt -t nat -A "$RELAY_CHAIN_POST" -p udp -d "$up_ip" --dport "$up_port" $ct -j MASQUERADE # shellcheck disable=SC2086 relay_ipt -t filter -A "$RELAY_CHAIN_FWD" -p udp -d "$up_ip" --dport "$up_port" $ct -j ACCEPT relay_ipt -t filter -A "$RELAY_CHAIN_FWD" -p udp -s "$up_ip" --sport "$up_port" -m conntrack --ctstate ESTABLISHED,RELATED -m conntrack --ctstate DNAT --ctorigdstport "$port" -j ACCEPT # Ответное правило: два отдельных -m conntrack (--ctstate в одном модуле дважды нельзя), условия складываются по И. if [[ -n $uplink ]]; then relay_ipt -t filter -A "$RELAY_CHAIN_FWD" -i "$uplink" -o "$uplink" -j DROP else log "ВНИМАНИЕ: не найден маршрут по умолчанию — транзит uplink→uplink не запрещён (relay apply после появления сети)" fi # Переходы ставятся первыми: Docker и UFW вставляют свои правила в начало FORWARD. relay_ipt -t nat -I PREROUTING 1 -j "$RELAY_CHAIN_PRE" relay_ipt -t nat -I POSTROUTING 1 -j "$RELAY_CHAIN_POST" relay_ipt -t filter -I FORWARD 1 -j "$RELAY_CHAIN_FWD" } # relay_rules_check — 0, если цепочки на месте, переход в каждую стоит первым # правилом своего хука, DNAT ведёт на ожидаемый upstream и (если uplink известен) стоит DROP транзита. relay_rules_check() { local port="$1" up_ip="$2" up_port="$3" spec table hook chain first uplink for spec in "nat PREROUTING $RELAY_CHAIN_PRE" "nat POSTROUTING $RELAY_CHAIN_POST" "filter FORWARD $RELAY_CHAIN_FWD"; do read -r table hook chain <<<"$spec" relay_ipt -t "$table" -S "$chain" >/dev/null 2>&1 || return 1 first=$(relay_ipt -t "$table" -S "$hook" 2>/dev/null | sed -n 2p) || return 1 [[ $first == "-A $hook -j $chain" ]] || return 1 done uplink=$(relay_uplink_dev) if [[ -n $uplink ]]; then relay_ipt -t filter -S "$RELAY_CHAIN_FWD" 2>/dev/null | grep -qxF -- "-A $RELAY_CHAIN_FWD -i $uplink -o $uplink -j DROP" || return 1 fi relay_ipt -t nat -S "$RELAY_CHAIN_PRE" 2>/dev/null | grep -qF -- "--dport $port -m addrtype --dst-type LOCAL -j DNAT --to-destination $up_ip:$up_port" } # relay_conntrack_flush — сбросить сессии порта, чтобы клиенты пошли на новый upstream. # Код 1 с "0 flow entries" — норма (сессий не было); любая другая ошибка — сбой: иначе при # keepalive 25 старые сессии не протухнут и клиенты останутся на прежнем выходе. relay_conntrack_flush() { local out rc=0 msg command -v conntrack >/dev/null || die "нет conntrack: apt install -y conntrack" out=$(conntrack -D -p udp --orig-port-dst "$1" 2>&1) || rc=$? ((rc == 0)) && return 0 if ((rc == CONNTRACK_NOT_FOUND_RC)) && [[ $out == *"0 flow entries"* ]]; then return 0 fi # Вывод conntrack -D содержит удалённые записи с адресами клиентов: печатаем только последнюю # строку без src=/dst=, а оставшиеся IPv4 маскируем. msg=$(grep -v -e 'src=' -e 'dst=' <<<"$out" | tail -n1 | sed -E 's/[0-9]{1,3}(\.[0-9]{1,3}){3}//g') || msg="" die "conntrack не сбросил сессии (код $rc): ${msg:-нет сообщения}" } # relay_stats — packets= — счётчик первого правила AWGR_FWD (клиент→upstream: в nat виден лишь первый пакет потока); три строки packets=, sessions=, replied=. Адреса клиентов не печатаются. relay_stats() { local port="$1" packets ct sessions replied packets=$(relay_ipt -t filter -L "$RELAY_CHAIN_FWD" -vxn 2>/dev/null | awk 'NR == 3 { print $1 }') || packets="" ct=$(conntrack -L -p udp --orig-port-dst "$port" 2>/dev/null || true) sessions=$(grep -c . <<<"$ct" || true) replied=$(grep . <<<"$ct" | grep -vc 'UNREPLIED' || true) printf 'packets=%s\nsessions=%s\nreplied=%s\n' "${packets:-0}" "${sessions:-0}" "${replied:-0}" } # relay_env_get — значение из /etc/awg-relay.env с проверкой. Файл не выполняется. # Присвоение вызывать отдельной строкой от local, иначе die внутри $() теряется. relay_env_get() { local key="$1" value [[ -r $RELAY_ENV ]] || die "нет файла настроек релея $RELAY_ENV" value=$(read_kv_file "$RELAY_ENV" "$key") || die "в $RELAY_ENV нет $key или значение содержит недопустимые символы" case $key in RELAY_NAME) server_name_valid "$value" || die "в $RELAY_ENV некорректное имя релея" ;; RELAY_PORT) valid_port "$value" || die "в $RELAY_ENV некорректный порт: $value" ;; RELAY_UPSTREAM) [[ -z $value ]] || valid_upstream "$value" || die "в $RELAY_ENV некорректный upstream: $value" ;; RELAY_PUBLIC_IP) [[ -z $value ]] || valid_ipv4 "$value" || die "в $RELAY_ENV некорректный публичный IP: $value" ;; *) die "relay_env_get: неизвестный ключ $key" ;; esac printf '%s\n' "$value" } # relay_env_write — атомарная запись env, права 0600. relay_env_write() { local name="$1" port="$2" upstream="$3" public_ip="$4" server_name_valid "$name" || die "некорректное имя релея" # read_kv_file такие значения отвергает, поэтому не пишем то, что потом не прочитать. case $name in *'$'* | *'`'* | *';'*) die "в имени релея нельзя \$ \` ;" ;; esac valid_port "$port" || die "некорректный порт релея: $port" [[ -z $upstream ]] || valid_upstream "$upstream" || die "некорректный upstream: $upstream" [[ -z $public_ip ]] || valid_ipv4 "$public_ip" || die "некорректный публичный IP: $public_ip" mkdir -p "$(dirname "$RELAY_ENV")" ( umask 077 printf 'RELAY_NAME=%s\nRELAY_PORT=%s\nRELAY_UPSTREAM=%s\nRELAY_PUBLIC_IP=%s\n' \ "$name" "$port" "$upstream" "$public_ip" >"$RELAY_ENV.part" ) chmod 600 "$RELAY_ENV.part" mv -f "$RELAY_ENV.part" "$RELAY_ENV" } # Установка и обновление релея. scripts/build.sh встраивает файл по маркеру "# @relay-install" в dist/install.sh. # Нужны constants.sh, lib.sh, server-name.sh, relay-rules.sh. # shellcheck shell=bash # relay_dropin_file — путь drop-in'а для юнита из RELAY_DROPIN_UNITS. relay_dropin_file() { case $1 in docker.service) echo "$RELAY_DROPIN_DOCKER" ;; ufw.service) echo "$RELAY_DROPIN_UFW" ;; esac } # relay_unit_text — текст юнита; пути без префикса FS_ROOT (он нужен только тестам). relay_unit_text() { local bin="${RELAY_BIN#"$FS_ROOT"}" cat </dev/null && command -v conntrack >/dev/null && return 0 log "Устанавливаю $RELAY_APT_PACKAGES" export DEBIAN_FRONTEND=noninteractive apt-get update # shellcheck disable=SC2086 apt-get install -y $RELAY_APT_PACKAGES } # relay_install_binary — скачать relay, проверить синтаксис, поставить в RELAY_BIN. relay_install_binary() { local tmp tmp=$(mktemp) fetch "$BASE_URL/relay" "$tmp" || { rm -f "$tmp" die "не удалось скачать $BASE_URL/relay" } bash -n "$tmp" || { rm -f "$tmp" die "скачанный relay повреждён (ошибка синтаксиса)" } mkdir -p "$(dirname "$RELAY_BIN")" install -m 755 "$tmp" "$RELAY_BIN" rm -f "$tmp" } # relay_install_unit — юнит и drop-in'ы для существующих docker.service/ufw.service, затем restart. relay_install_unit() { local unit file mkdir -p "$(dirname "$RELAY_UNIT_FILE")" relay_unit_text >"$RELAY_UNIT_FILE" for unit in $RELAY_DROPIN_UNITS; do systemctl cat "$unit" >/dev/null 2>&1 || continue file=$(relay_dropin_file "$unit") mkdir -p "$(dirname "$file")" printf '%s\n' "$RELAY_DROPIN_CONTENT" >"$file" done systemctl daemon-reload systemctl enable "$RELAY_UNIT" systemctl restart "$RELAY_UNIT" } relay_write_motd() { # имя mkdir -p "$(dirname "$RELAY_MOTD")" printf 'Релей AmneziaWG %s: relay status\n' "$1" >"$RELAY_MOTD" } # relay_selfcheck — по строке ✔/✖ на проверку. Код 1, если есть хотя бы одна ✖. relay_selfcheck() { local port up bad=0 fwd port=$(relay_env_get RELAY_PORT) up=$(relay_env_get RELAY_UPSTREAM) fwd=$(sysctl -n net.ipv4.ip_forward 2>/dev/null || true) if [[ $fwd == 1 ]]; then echo "✔ ip_forward=1"; else echo "✖ ip_forward=${fwd:-?}, нужно 1" bad=1 fi if [[ $(systemctl is-active "$RELAY_UNIT" 2>/dev/null || true) == active ]]; then echo "✔ юнит $RELAY_UNIT active" else echo "✖ юнит $RELAY_UNIT не active" bad=1 fi if [[ -z $up ]]; then echo "— правила: upstream не задан (relay upstream :<порт>)" elif relay_rules_check "$port" "${up%:*}" "${up##*:}"; then echo "✔ правила: DNAT $port/udp → $up" else echo "✖ правила для $up не найдены" bad=1 fi # DNAT-релей ничего не слушает локально: порт не должен быть занят другим процессом. if [[ -z $(ss -Hlun "sport = :$port" 2>/dev/null || true) ]]; then echo "✔ порт $port/udp не занят другим процессом" else echo "✖ порт $port/udp занят другим процессом" bad=1 fi if ((bad)); then echo "Есть ошибки. Подробности: relay status, journalctl -u $RELAY_UNIT" return 1 fi } # relay_install — первичная установка. Порт и upstream из существующего env сохраняются. relay_install() { local name port upstream public_ip def_name="" saved_pub="" relay_install_deps if [[ -r $RELAY_ENV ]]; then def_name=$(relay_env_get RELAY_NAME) port=$(relay_env_get RELAY_PORT) upstream=$(relay_env_get RELAY_UPSTREAM) saved_pub=$(relay_env_get RELAY_PUBLIC_IP) [[ -z ${W_PORT:-} ]] || log "W_PORT игнорируется: сохранён порт $port (смена порта: relay uninstall и новая установка)" [[ -z ${W_UPSTREAM:-} ]] || log "W_UPSTREAM игнорируется: сохранён upstream ${upstream:-пусто} (смена: relay upstream :<порт>)" else if [[ -n ${W_PORT:-} ]]; then valid_port "$W_PORT" || die "W_PORT некорректно: нужен порт 1–65535" [[ -z $(ss -Hlun "sport = :$W_PORT") ]] || die "порт $W_PORT/udp уже занят: выберите другой W_PORT" port=$W_PORT else port=$(random_free_port) fi upstream="" if [[ -n ${W_UPSTREAM:-} ]]; then valid_upstream "$W_UPSTREAM" || die "W_UPSTREAM некорректно: нужен вид IP:порт" upstream=$W_UPSTREAM fi fi [[ -n $def_name ]] || def_name=$(hostname) name=$(env_or_ask W_NAME "Название сервера" "$def_name" server_name_valid) if [[ -n $saved_pub && -z ${W_PUBLIC_IP:-} ]]; then public_ip=$saved_pub else public_ip=$(public_ipv4) fi relay_env_write "$name" "$port" "$upstream" "$public_ip" mkdir -p "$(dirname "$RELAY_SYSCTL_FILE")" "$(dirname "$RELAY_MODULES_FILE")" printf '%s\n' "$RELAY_SYSCTL_CONTENT" >"$RELAY_SYSCTL_FILE" printf '%s\n' "$RELAY_MODULES_CONTENT" >"$RELAY_MODULES_FILE" modprobe nf_conntrack || log "modprobe nf_conntrack не удался: проверьте модуль ядра" sysctl --system >/dev/null || log "sysctl --system вернул ошибку: проверьте self-check ниже" relay_install_binary relay_install_unit relay_write_motd "$name" local rc=0 relay_selfcheck || rc=1 echo echo "На FI выполните:" echo "wget -qO- $BASE_URL | sudo W_ROLE=exit W_RELAY_IP=$public_ip W_PORT=$port bash" echo "Откройте UDP-порт $port в файрволе панели хостера (Yandex Cloud, VK Cloud и т.п.)" return "$rc" } # relay_update — обновить relay и юнит. Env, порт и upstream не трогаются; W_NAME меняет только имя. relay_update() { [[ -r $RELAY_ENV ]] || die "релей не установлен: нет $RELAY_ENV" local name port upstream public_ip name=$(relay_env_get RELAY_NAME) port=$(relay_env_get RELAY_PORT) upstream=$(relay_env_get RELAY_UPSTREAM) public_ip=$(relay_env_get RELAY_PUBLIC_IP) if [[ -n ${W_NAME:-} ]]; then name=$(server_name_from_env W_NAME "$W_NAME") relay_env_write "$name" "$port" "$upstream" "$public_ip" relay_write_motd "$name" fi relay_install_binary relay_install_unit relay_selfcheck } # Выходной сервер: порт AWG, список релеев, UFW только для релеев, keepalive клиентов. # scripts/build.sh встраивает файл по маркеру "# @exit-firewall" в dist/install.sh и dist/vpn. # Нужны constants.sh и lib.sh. # shellcheck shell=bash # exit_port — UDP-порт AmneziaWG из строки "export AWG_PORT=N" в настройках установщика. exit_port() { local p [[ -r $AWG_CONFIG ]] || return 1 p=$(sed -n "s/^export $EXIT_PORT_KEY=['\"]\{0,1\}\([0-9]\{1,5\}\).*/\1/p" "$AWG_CONFIG" | tail -n1) valid_port "$p" || return 1 echo "$p" } # awg_config_set <КЛЮЧ> <значение> — заменить (или дописать) строку export КЛЮЧ='значение' в AWG_CONFIG. # Файл не sourc'ится и правится awk'ом; значение без кавычек, бэкслеша и переводов строк (иначе die). awg_config_set() { local key="$1" value="$2" case $value in *\'* | *\\* | *$'\n'* | *$'\r'*) die "недопустимое значение для $key" ;; esac [[ -f $AWG_CONFIG ]] || die "нет файла настроек $AWG_CONFIG" awk -v key="$key" -v line="export $key='$value'" ' index($0, "export " key "=") == 1 { if (!done) print line; done = 1; next } { print } END { if (!done) print line } ' "$AWG_CONFIG" >"$AWG_CONFIG.part" || { rm -f "$AWG_CONFIG.part" die "не удалось обновить $AWG_CONFIG" } cat "$AWG_CONFIG.part" >"$AWG_CONFIG" rm -f "$AWG_CONFIG.part" } # awg_config_get <КЛЮЧ> — значение из строки export КЛЮЧ='значение' в AWG_CONFIG (без source). awg_config_get() { [[ -r $AWG_CONFIG ]] || return 0 sed -n "s/^export $1=['\"]\{0,1\}\([^'\"]*\).*/\1/p" "$AWG_CONFIG" | tail -n1 } # relays_list — IP релеев из EXIT_RELAYS_FILE (по одному в строке), только корректные IPv4. relays_list() { local ip [[ -r $EXIT_RELAYS_FILE ]] || return 0 while IFS= read -r ip || [[ -n $ip ]]; do if valid_ipv4 "$ip"; then echo "$ip" elif [[ -n $ip ]]; then log "ВНИМАНИЕ: в $EXIT_RELAYS_FILE пропущена некорректная строка: $ip" >&2 fi done <"$EXIT_RELAYS_FILE" return 0 } # exit_direct_mode — выход работает напрямую, без релея (после vpn relay off). exit_direct_mode() { [[ -e $EXIT_DIRECT_FILE ]] } # relays_save — перезаписать список релеев (атомарно, без дублей, порядок сохраняется). relays_save() { local ip seen=" " tmp="$EXIT_RELAYS_FILE.part" mkdir -p "${EXIT_RELAYS_FILE%/*}" : >"$tmp" for ip in "$@"; do if ! valid_ipv4 "$ip"; then rm -f "$tmp" die "некорректный IP релея: $ip" fi [[ $seen == *" $ip "* ]] && continue seen+="$ip " echo "$ip" >>"$tmp" done mv -f "$tmp" "$EXIT_RELAYS_FILE" } # exit_ufw_relay_rules — наши правила из "ufw status" (комментарий "AWG relay"): строки " <порт>". exit_ufw_relay_rules() { ufw status 2>/dev/null | awk -v tag="# $EXIT_RELAY_COMMENT" ' index($0, tag) && $1 ~ /^[0-9]+\/udp$/ { for (i = 2; i <= NF; i++) if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { split($1, p, "/"); print $i, p[1]; break } }' } exit_require_ufw() { command -v ufw >/dev/null || die "ufw не найден: файрвол выхода не настроить" } # exit_ufw_active — UFW включён (иначе его правила не действуют). exit_ufw_active() { ufw status 2>/dev/null | grep -q '^Status: active' } # exit_ufw_delete <правило ufw…> — удалить правило. Отсутствие правила не ошибка, # любой другой сбой — код 1. exit_ufw_delete() { local out rc=0 out=$(ufw delete "$@" 2>&1) || rc=$? if ((rc != 0)) && [[ $out != *"non-existent"* ]]; then log "ОШИБКА: ufw delete $* не удалось: $out" return 1 fi return 0 } # exit_firewall_lockdown — порт AWG открыт только для релеев из списка. Идемпотентно. # Публичное правило bivlked ("ufw allow <порт>/udp") удаляется (ufw убирает и v4, и v6 вариант); # отсутствие правила не ошибка. Пустой список релеев файрвол не трогает: иначе VPN закрылся бы для всех. exit_firewall_lockdown() { local -a relays have local port ip line rport keep mapfile -t relays < <(relays_list) if ((${#relays[@]} == 0)); then log "Список релеев пуст — файрвол не меняю (порт остаётся открытым как есть)." return 0 fi exit_require_ufw if ! exit_ufw_active; then log "ОШИБКА: UFW выключен: правила не действуют. Включите: ufw enable (сначала убедитесь, что SSH разрешён)" return 1 fi port=$(exit_port) || { log "Не удалось определить порт AWG из $AWG_CONFIG — файрвол не изменён." return 1 } mapfile -t have < <(exit_ufw_relay_rules) exit_ufw_delete allow "$port/udp" || return 1 # Лишние правила: IP убран из списка или порт сменился. for line in "${have[@]}"; do read -r ip rport <<<"$line" keep=0 [[ $rport == "$port" && " ${relays[*]} " == *" $ip "* ]] && keep=1 ((keep)) || exit_ufw_delete allow proto udp from "$ip" to any port "$rport" || return 1 done for ip in "${relays[@]}"; do printf '%s\n' "${have[@]}" | grep -qxF "$ip $port" && continue ufw allow proto udp from "$ip" to any port "$port" comment "$EXIT_RELAY_COMMENT" >/dev/null || { log "ОШИБКА: не удалось добавить правило UFW для релея $ip" return 1 } done log "Файрвол: порт $port/udp открыт только для релеев: ${relays[*]}" } # exit_firewall_open — вернуть публичное правило, снять правила релеев (vpn relay off). exit_firewall_open() { local -a have local port line ip rport exit_require_ufw port=$(exit_port) || die "не удалось определить порт AWG из $AWG_CONFIG" mapfile -t have < <(exit_ufw_relay_rules) for line in "${have[@]}"; do read -r ip rport <<<"$line" exit_ufw_delete allow proto udp from "$ip" to any port "$rport" || return 1 done ufw allow "$port/udp" comment "$EXIT_PUBLIC_COMMENT" >/dev/null || { log "ОШИБКА: не удалось открыть порт $port/udp в UFW" return 1 } log "Файрвол: порт $port/udp снова открыт для всех." } # exit_clients — имена клиентов: файлы $AWG_DIR/<имя>.conf, у которых есть парный <имя>.vpnuri # (его создаёт установщик на каждого клиента; серверные и служебные файлы парного нет). exit_clients() { local f name for f in "$AWG_DIR"/*.conf; do [[ -f $f ]] || continue name=${f##*/} name=${name%.conf} [[ -f $AWG_DIR/$name.vpnuri ]] && echo "$name" done return 0 } # exit_keepalive_all — PersistentKeepalive 25 у каждого клиента (через manage modify). # Клиенты, у которых значение уже стоит, пропускаются: modify перезапускает сервис. exit_keepalive_all() { local name while IFS= read -r name; do grep -qx "PersistentKeepalive = $CLIENT_KEEPALIVE" "$AWG_DIR/$name.conf" && continue bash "$AWG_MANAGE" modify "$name" PersistentKeepalive "$CLIENT_KEEPALIVE" >/dev/null || log "Не удалось выставить keepalive клиенту $name" done < <(exit_clients) return 0 } # exit_endpoint_check <домен> — смотрит ли домен на один из релеев. Предупреждает, но не падает. exit_endpoint_check() { local domain="$1" ip local -a relays mapfile -t relays < <(relays_list) while read -r ip _; do [[ " ${relays[*]} " == *" $ip "* ]] && return 0 done < <(getent ahostsv4 "$domain" 2>/dev/null | sort -u) ip=$(getent ahostsv4 "$domain" 2>/dev/null | awk 'NR==1 {print $1}') || ip="" log "ВНИМАНИЕ: $domain указывает на ${ip:-ничего (не резолвится)}, а не на релей (${relays[*]:-список пуст}). Клиенты не подключатся, пока DNS не исправлен." return 1 } # Установка и обновление выходного сервера каскада (FI): официальный установщик bivlked, # закреплённый по коммиту и SHA-256, запускается в одноразовом systemd-юните (--resume): # обрыв SSH его не прерывает, а после каждого ребута юнит запускает его снова. # scripts/build.sh встраивает файл по маркеру "# @exit-install" в dist/install.sh. # Нужны constants.sh, lib.sh, server-name.sh, exit-firewall.sh; точка входа (main, RESUME) — в install.sh. # shellcheck shell=bash # log_hook — приёмник lib.sh: запускатель дублирует сообщения в лог установки. # Под юнитом stdout уже дописывается в лог (StandardOutput=append:), поэтому там хук молчит. log_hook() { [[ ${EXIT_LOG_TEE:-0} -eq 1 && ${RESUME:-0} -eq 0 ]] || return 0 echo "$1" >>"$BOOTSTRAP_LOG" 2>/dev/null || true } # install_self — положить копию скрипта в BOOTSTRAP_DIR и продолжить работу оттуда: # её запускает systemd-юнит. При запуске через pipe ($0 = bash) файла скрипта нет, # тогда копия скачивается с BASE_URL. Переменные W_* при этом сохраняются (окружение). install_self() { local self="$BOOTSTRAP_SELF" [[ $0 == "$self" ]] && return 0 mkdir -p "$BOOTSTRAP_DIR" "${BOOTSTRAP_LOG%/*}" chmod 700 "$BOOTSTRAP_DIR" if [[ -f $0 && $0 == *install.sh ]]; then cp "$0" "$self.part" mv -f "$self.part" "$self" else fetch "$BASE_URL/install.sh" "$self" || die "не удалось скачать $BASE_URL/install.sh" fi chmod 700 "$self" # stdin при запуске через pipe — это текст скрипта: дочерние процессы не должны его читать. exec bash "$self" "$@" /dev/null || true) [[ $st == active || $st == activating ]] } enable_resume_unit() { mkdir -p "${BOOTSTRAP_UNIT_FILE%/*}" cat >"$BOOTSTRAP_UNIT_FILE" <"$f" echo "$n" } attempts() { local f="$BOOTSTRAP_ATTEMPTS_FILE" if [[ -f $f ]]; then cat "$f"; else echo 0; fi } shutdown_pending() { [[ $(systemctl is-system-running 2>/dev/null || true) == "stopping" ]] && return 0 systemctl list-jobs 2>/dev/null | grep -qE 'reboot\.target|poweroff\.target' } # verify_installer — SHA-256 сохранённой копии установщика. При несовпадении файл удаляется. verify_installer() { local sum sum=$(sha256sum "$BOOTSTRAP_INSTALLER" | cut -d' ' -f1) [[ $sum == "$AWG_INSTALLER_SHA256" ]] && return 0 rm -f "$BOOTSTRAP_INSTALLER" write_motd fail die "контрольная сумма установщика не совпала (ожидалась $AWG_INSTALLER_SHA256, получена $sum)" } fetch_installer() { fetch "$AWG_INSTALLER_URL" "$BOOTSTRAP_INSTALLER" || die "не удалось скачать установщик: $AWG_INSTALLER_URL" verify_installer } # run_installer — запустить сохранённую копию установщика с --yes и сохранёнными флагами. # Копия скачивается один раз, но сумма проверяется перед каждым запуском: файл в BOOTSTRAP_DIR # мог подменить кто-то другой. run_installer() { # [--force] — флаг действует только на этот запуск и в args не сохраняется local extra="" rc=0 local -a args [[ -f $BOOTSTRAP_INSTALLER ]] || fetch_installer verify_installer [[ -f $BOOTSTRAP_ARGS_FILE ]] && extra=$(<"$BOOTSTRAP_ARGS_FILE") read -ra args <<<"$extra" # Имя — отдельным аргументом: в args пробелы разделяют флаги, а в имени они допустимы. [[ -s $BOOTSTRAP_NAME_FILE ]] && args+=("--server-name=$(<"$BOOTSTRAP_NAME_FILE")") [[ ${1:-} != --force ]] || args+=(--force) log "Запуск установщика AmneziaWG (попытка $(bump_attempts) из $BOOTSTRAP_MAX_ATTEMPTS): --yes ${args[*]}" bash "$BOOTSTRAP_INSTALLER" --yes "${args[@]}" "$BOOTSTRAP_MOTD" } # exit_public_ip — публичный IP выхода: сохранённый при запуске, иначе src маршрута. # Здесь нет TTY (юнит), поэтому частный адрес не спрашивается, а заменяется подсказкой. exit_public_ip() { local ip="" if [[ -s $BOOTSTRAP_PUBLIC_IP_FILE ]]; then ip=$(<"$BOOTSTRAP_PUBLIC_IP_FILE") if valid_ipv4 "$ip"; then echo "$ip" return 0 fi fi ip=$(W_PUBLIC_IP="" W_TTY=/dev/null public_ipv4 2>/dev/null) || ip="" echo "${ip:-$EXIT_PUBLIC_IP_PLACEHOLDER}" } # exit_save_public_ip — запомнить W_PUBLIC_IP для юнита: окружения у него не будет. exit_save_public_ip() { [[ -n ${W_PUBLIC_IP:-} ]] || return 0 valid_ipv4 "$W_PUBLIC_IP" || die "W_PUBLIC_IP некорректно" printf '%s\n' "$W_PUBLIC_IP" >"$BOOTSTRAP_PUBLIC_IP_FILE" } # exit_add_relay — добавить релей в список (существующие остаются). exit_add_relay() { local -a cur mapfile -t cur < <(relays_list) relays_save "${cur[@]}" "$1" } # finish_ok — установка завершена: файрвол только для релеев, sysctl, keepalive клиентам, vpn. # В прямом режиме (vpn relay off) релей не нужен и файрвол не трогается. finish_ok() { local -a relays if exit_direct_mode; then log "прямой режим (vpn relay off): релей не требуется, файрвол не меняю. Вернуть каскад: vpn relay add " else mapfile -t relays < <(relays_list) ((${#relays[@]})) || finish_fail "нет ни одного релея: порт открыт для всех; задайте W_RELAY_IP и повторите" exit_firewall_lockdown || finish_fail "не удалось ограничить порт AWG релеями: порт может быть открыт всем. Лог: $BOOTSTRAP_LOG" fi mkdir -p "${EXIT_SYSCTL_FILE%/*}" "${EXIT_MODULES_FILE%/*}" printf '%s\n' "$EXIT_MODULES_CONTENT" >"$EXIT_MODULES_FILE" printf '%s\n' "$EXIT_SYSCTL_CONTENT" >"$EXIT_SYSCTL_FILE" # Модуль до sysctl: ключи net.netfilter.* появляются только после загрузки nf_conntrack. modprobe "$EXIT_MODULES_CONTENT" || log "modprobe $EXIT_MODULES_CONTENT не удался: проверьте модуль ядра" sysctl --system >/dev/null || log "sysctl --system вернул ошибку: проверьте $EXIT_SYSCTL_FILE" exit_keepalive_all install_vpn disable_resume_unit rm -f "$BOOTSTRAP_ATTEMPTS_FILE" "$BOOTSTRAP_FORCE_FILE" write_motd ok log "Готово. Выходной сервер установлен, управление клиентами: vpn help" exit_direct_mode && return 0 local ip port ip=$(exit_public_ip) if port=$(exit_port); then log "На релее выполните: relay upstream $ip:$port" if ! valid_ipv4 "$ip"; then log "Публичный IP не определён (сервер за NAT?): подставьте его вместо '$ip' вручную." fi fi } finish_fail() { write_motd fail die "$1" } # exit_worker — режим --resume, работает внутри юнита. Один экземпляр за раз (flock). exit_worker() { exec 9>"$BOOTSTRAP_LOCK_FILE" flock -n 9 || { log "Установка уже выполняется другим процессом — выхожу." return 0 } # Метка pending: идёт полная переустановка, «уже установлено» до запуска с --force не считается. local force_mark="" [[ -f $BOOTSTRAP_FORCE_FILE ]] && force_mark=$(<"$BOOTSTRAP_FORCE_FILE") # pending, но установщик уже запускался (attempts > 0) и шаг не 99: --force подействовал, а ребут # убил воркер раньше записи running. Повторный --force снёс бы установку ещё раз. if [[ $force_mark == "$FORCE_PENDING" && $(awg_state) != "$AWG_STATE_DONE" && $(attempts) -gt 0 ]]; then force_mark=$FORCE_RUNNING printf '%s\n' "$FORCE_RUNNING" >"$BOOTSTRAP_FORCE_FILE" log "Метка переустановки pending после ребута, установка уже идёт (шаг $(awg_state)) — продолжаю без --force." fi if [[ $force_mark != "$FORCE_PENDING" ]] && is_installed; then finish_ok return 0 fi if [[ $(attempts) -ge $BOOTSTRAP_MAX_ATTEMPTS ]]; then disable_resume_unit finish_fail "установщик не завершился за $BOOTSTRAP_MAX_ATTEMPTS попыток, автоповтор выключен. Лог: $BOOTSTRAP_LOG" fi write_motd progress local rc=0 if [[ $force_mark == "$FORCE_PENDING" ]]; then run_installer --force || rc=$? # running — только когда --force подействовал (успех или установщик сбросил шаг): тогда после # ребута он продолжит без --force. Ранний сбой при шаге 99 оставляет pending и не считается успехом. if [[ $rc -eq 0 || $(awg_state) != "$AWG_STATE_DONE" ]]; then printf '%s\n' "$FORCE_RUNNING" >"$BOOTSTRAP_FORCE_FILE" else finish_fail "переустановка не началась: установщик завершился с кодом $rc, прежняя установка не тронута. Лог: $BOOTSTRAP_LOG. Повторить: sudo $BOOTSTRAP_SELF" fi else run_installer || rc=$? fi if is_installed; then finish_ok return 0 fi if shutdown_pending; then log "Сервер уходит в перезагрузку, после неё установка продолжится сама." return 0 fi # Юнит остаётся включённым: установка повторится при следующей загрузке. finish_fail "установщик завершился с кодом $rc (шаг $(awg_state)). Лог: $BOOTSTRAP_LOG. Повторить: sudo $BOOTSTRAP_SELF" } # follow_unit — показывать лог с позиции offset, пока юнит работает. follow_unit() { local offset="$1" tail_pid tail -c +"$((offset + 1))" -F "$BOOTSTRAP_LOG" 2>/dev/null & tail_pid=$! while unit_running; do sleep 2; done sleep 1 kill "$tail_pid" 2>/dev/null || true wait "$tail_pid" 2>/dev/null || true } log_size() { if [[ -f $BOOTSTRAP_LOG ]]; then wc -c <"$BOOTSTRAP_LOG"; else echo 0; fi } report() { if is_installed; then log "Готово. Выходной сервер установлен, управление клиентами: vpn help" elif shutdown_pending; then log "Сервер уходит в перезагрузку, установка продолжится сама. После входа по SSH ход установки: tail -f $BOOTSTRAP_LOG" else die "установка не завершилась. Лог: $BOOTSTRAP_LOG. Повторить: sudo $BOOTSTRAP_SELF" fi } # choose_server_name <по умолчанию> — имя из W_NAME или ответ на вопрос. choose_server_name() { if [[ -n ${W_NAME:-} ]]; then server_name_from_env W_NAME "$W_NAME" else ask_server_name "$1" fi } # configured_server_name — имя из настроек установленного AmneziaWG. configured_server_name() { local name name=$(sed -n "s/^export $AWG_SERVER_NAME_KEY='\(.*\)'\$/\1/p" "$AWG_CONFIG" | tail -n1) printf '%s' "${name:-$AWG_SERVER_NAME_DEFAULT}" } # rename_installed_server — сменить имя у уже установленного AmneziaWG: правится строка в # awgsetup_cfg.init, новые клиенты получают новое имя, старые ссылки — после vpn regen. rename_installed_server() { # Повторный запуск ничего не спрашивает: имя меняется только по W_NAME. [[ -n ${W_NAME:-} && -f $AWG_CONFIG ]] || return 0 local current name current=$(configured_server_name) name=$(choose_server_name "$current") [[ $name != "$current" ]] || return 0 awg_config_set "$AWG_SERVER_NAME_KEY" "$name" log "Имя сервера изменено: '$current' → '$name'. Перевыпустить ссылки клиентов: vpn regen" } # exit_update — повторный запуск на установленном выходе: имя, vpn, файрвол, keepalive. # W_RELAY_IP добавляет релей в список; порт и endpoint у установленного сервера не меняются. exit_update() { if [[ -f $BOOTSTRAP_FORCE_FILE ]]; then rm -f "$BOOTSTRAP_FORCE_FILE" log "Снята незавершённая метка переустановки ($BOOTSTRAP_FORCE_FILE): без W_FORCE=1 переустановки не будет." fi rename_installed_server log "Выходной сервер уже установлен — обновляю команду vpn и файрвол." local relay_ip="" if exit_direct_mode; then # Прямой режим включён вручную (vpn relay off): релей не требуется, вернуть его — vpn relay add. [[ -z ${W_RELAY_IP:-} ]] || log "прямой режим: W_RELAY_IP игнорируется — добавить релей: vpn relay add $W_RELAY_IP" elif [[ -n ${W_RELAY_IP:-} ]]; then valid_ipv4 "$W_RELAY_IP" || die "W_RELAY_IP некорректно" exit_add_relay "$W_RELAY_IP" elif [[ -z $(relays_list) ]]; then # Без релеев порт остался бы открытым для всех: спрашиваем (без терминала — ошибка с подсказкой). relay_ip=$(env_or_ask W_RELAY_IP "Список релеев пуст — IP релея (задайте W_RELAY_IP)" "" valid_ipv4) exit_add_relay "$relay_ip" fi [[ -z ${W_PORT:-} ]] || log "W_PORT игнорируется: порт установленного сервера не меняется." [[ -z ${W_ENDPOINT:-} ]] || log "W_ENDPOINT игнорируется: endpoint установленного сервера не меняется." exit_save_public_ip fetch "$BASE_URL/vpn" "$BOOTSTRAP_VPN" || die "не удалось скачать $BASE_URL/vpn" finish_ok } # exit_launcher — ручной запуск: спросить параметры, подготовить файлы, стартовать юнит, показать лог. exit_launcher() { EXIT_LOG_TEE=1 if unit_running; then log "Установка уже идёт — показываю лог (Ctrl+C не прерывает установку)." follow_unit "$(log_size)" report return 0 fi # W_FORCE=1 у установленного выхода — полная переустановка (подтверждена в main): обычный путь # нового запуска, а установщик получит --force один раз (метка force). if is_installed && [[ ${W_FORCE:-} != 1 ]]; then exit_update return 0 fi # Переустановка (W_FORCE=1 у установленного выхода): порт, endpoint и список релеев берутся # из текущей установки, иначе сменились бы порт/адрес у всех клиентов и связка с релеем. local name saved="" relay_ip="" port endpoint flags cur_port="" cur_endpoint="" local -a cur_relays=() if is_installed; then cur_port=$(exit_port) || cur_port="" cur_endpoint=$(awg_config_get "$AWG_ENDPOINT_KEY") mapfile -t cur_relays < <(relays_list) fi [[ -s $BOOTSTRAP_NAME_FILE ]] && saved=$(<"$BOOTSTRAP_NAME_FILE") name=$(choose_server_name "${saved:-$(hostname)}") if ((${#cur_relays[@]})); then # Список релеев сохраняется, W_RELAY_IP добавляется к нему. if [[ -n ${W_RELAY_IP:-} ]]; then valid_ipv4 "$W_RELAY_IP" || die "W_RELAY_IP некорректно" relay_ip=$W_RELAY_IP fi else relay_ip=$(env_or_ask W_RELAY_IP "IP релея (российского сервера)" "" valid_ipv4) fi # Порт выхода = порт релея = порт в конфигах клиентов: случайного значения по умолчанию нет. if [[ -z ${W_PORT:-} && -z $cur_port ]] && ! tty_available; then die "не задан порт: укажите W_PORT=<порт релея> — его печатает установщик релея в команде для выхода" fi port=$(env_or_ask W_PORT "UDP-порт релея (из команды, напечатанной релеем)" "$cur_port" valid_port) endpoint=$(env_or_ask W_ENDPOINT "Домен релея для клиентов (Enter — IP релея)" \ "${cur_endpoint:-${relay_ip:-${cur_relays[0]:-}}}" valid_endpoint) # Без терминала ask возвращает значение по умолчанию без проверки: оно из файлов, проверяем. valid_port "$port" || die "некорректный порт: $port" valid_endpoint "$endpoint" || die "некорректный endpoint: $endpoint (задайте W_ENDPOINT)" if valid_ipv4 "$endpoint"; then log "ВНИМАНИЕ: endpoint клиентов — IP $endpoint; при смене релея придётся перевыпускать конфиги." fi mkdir -p "$BOOTSTRAP_DIR" printf '%s' "$name" >"$BOOTSTRAP_NAME_FILE" log "Имя сервера в приложении Amnezia: $name" if ((${#cur_relays[@]})); then relays_save "${cur_relays[@]}" ${relay_ip:+"$relay_ip"} else # Новая установка: список заменяется, чтобы повтор с исправленным IP не оставил опечатку. relays_save "$relay_ip" fi # Релей задан — прямого режима больше нет. rm -f "$EXIT_DIRECT_FILE" exit_save_public_ip flags="--port=$port --endpoint=$endpoint" [[ -z ${AWG_INSTALL_ARGS:-} ]] || flags+=" $AWG_INSTALL_ARGS" printf '%s' "$flags" >"$BOOTSTRAP_ARGS_FILE" if [[ ${W_FORCE:-} == 1 ]]; then printf '%s\n' "$FORCE_PENDING" >"$BOOTSTRAP_FORCE_FILE" else rm -f "$BOOTSTRAP_FORCE_FILE" fi if ! valid_ipv4 "$endpoint"; then exit_endpoint_check "$endpoint" || true; fi # Всё, что нужно из сети, скачивается сейчас: после ребута сеть может подняться не сразу. fetch "$BASE_URL/vpn" "$BOOTSTRAP_VPN" || die "не удалось скачать $BASE_URL/vpn" [[ -f $BOOTSTRAP_INSTALLER ]] || fetch_installer verify_installer # Лимит попыток защищает от бесконечных ребутов по кругу, а не от ручного повтора. rm -f "$BOOTSTRAP_ATTEMPTS_FILE" : >>"$BOOTSTRAP_LOG" chmod 640 "$BOOTSTRAP_LOG" enable_resume_unit log "Установка запущена в фоне (юнит $BOOTSTRAP_UNIT) — обрыв SSH её не прервёт, Ctrl+C тоже." local offset offset=$(log_size) systemctl start --no-block "$BOOTSTRAP_UNIT" follow_unit "$offset" report } RESUME=0 [[ ${1:-} == "--resume" ]] && RESUME=1 # detect_role — печатает exit | relay | none по признакам установки на этом сервере. detect_role() { local relay=0 exit_found=0 found="" if [[ -e $RELAY_ENV ]]; then relay=1 fi if is_installed || [[ -n $(awg_state) ]]; then exit_found=1 found="AmneziaWG: установлен или устанавливается ($AWG_STATE_FILE, $AWG_MANAGE)" elif unit_running; then exit_found=1 found="идёт установка выхода (юнит $BOOTSTRAP_UNIT)" elif [[ -e $EXIT_RELAYS_FILE ]]; then exit_found=1 found="есть список релеев $EXIT_RELAYS_FILE" fi if ((relay && exit_found)); then die "на сервере найдены признаки обеих ролей: релей ($RELAY_ENV) и выход ($found). Уберите лишнее вручную и повторите." fi if ((relay)); then echo relay elif ((exit_found)); then echo exit else echo none fi } # choose_role — роль для новой установки: W_ROLE или меню. choose_role() { if [[ -n ${W_ROLE:-} ]]; then [[ $W_ROLE == exit || $W_ROLE == relay ]] || die "W_ROLE: exit или relay" echo "$W_ROLE" return 0 fi tty_available || die "нет терминала для выбора: укажите W_ROLE=exit|relay" { echo "Тип установки:" echo " 1) Выходной сервер (Финляндия)" echo " 2) Релей (Россия)" } >&2 # Отдельной строкой и без ветки «по умолчанию»: при EOF (Ctrl+D) ask падает в подсказке # команды, ответ пуст — ничего не ставим. local choice choice=$(ask "Выберите [1/2]" "" valid_role_choice) || choice="" case $choice in 1) echo exit ;; 2) echo relay ;; *) die "тип установки не выбран" ;; esac } # confirm_reinstall — W_FORCE=1 на установленном выходе пересоздаёт ключи и клиентов: нужно подтверждение. confirm_reinstall() { [[ ${TUNNEL_REEXEC:-} != 1 && ${W_YES:-} != 1 ]] || return 0 tty_available || die "полная переустановка требует подтверждения: задайте W_YES=1 (нет терминала)" echo "ВНИМАНИЕ: W_FORCE=1 — выходной сервер будет установлен заново, ключи и клиенты пересоздадутся." >&2 local answer answer=$(ask "Переустановить выходной сервер? [y/N]" "n" valid_yes_no) || answer="" is_yes "$answer" || die "переустановка отменена" } # run_exit — выход ставится/обновляется через install_self (юнит, переживающий обрыв SSH). # W_ROLE экспортируется до перезапуска: копия скрипта не должна снова показывать меню. run_exit() { export W_ROLE=exit TUNNEL_REEXEC=1 install_self "$@" exit_launcher } # run_relay — релею юнит установки не нужен; stdin отдаётся /dev/null, чтобы apt-get и прочие # дочерние процессы не прочитали остаток скрипта из pipe (вопросы идут через tty-файл). run_relay() { # install|update exec