From a31f1a1090202a6249b98ef2e6290222cdf3bd7f Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 14 Apr 2026 00:04:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20WireGuard=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + README.md | 271 +++++++++++++++++++++++++++ client/install_client.sh | 350 +++++++++++++++++++++++++++++++++++ lib/common.sh | 231 +++++++++++++++++++++++ read.me | 0 server/install_server.sh | 352 ++++++++++++++++++++++++++++++++++++ server/wg-peerctl.sh | 200 ++++++++++++++++++++ templates/wg0.conf.template | 7 + 8 files changed, 1416 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 client/install_client.sh create mode 100755 lib/common.sh create mode 100644 read.me create mode 100755 server/install_server.sh create mode 100755 server/wg-peerctl.sh create mode 100644 templates/wg0.conf.template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..387dd78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.log +*.tmp +*.swp +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d683061 --- /dev/null +++ b/README.md @@ -0,0 +1,271 @@ +# WireGuard Server + Client Automation + +Проект автоматизирует установку и настройку WireGuard-сервера и WireGuard-клиента на Debian/Ubuntu. + +Основная идея: один репозиторий с понятными Bash-скриптами, которые можно повторно запускать без бессмысленной поломки уже существующей конфигурации. + +## Назначение проекта + +- Быстро развернуть WireGuard-сервер (`wg-quick@wg0`) с автозапуском через `systemd`. +- Включить IP forwarding и NAT для выхода клиентов в интернет через сервер. +- Установить легкий GUI для управления (`wireguard-ui` в Docker). +- Автоматизировать добавление клиента с клиентской машины через SSH на сервер. +- Поддержать 2 режима маршрутизации клиента: + - полный туннель (весь трафик через VPN) + - выборочный туннель (только заданные сети) + +## Поддерживаемые ОС + +- Debian 11/12 +- Ubuntu 22.04/24.04 + +Скрипты ориентированы на Debian-family (APT, systemd). + +## Структура проекта + +- `README.md` +- `.gitignore` +- `lib/common.sh` — общие функции +- `templates/wg0.conf.template` — шаблон базового `wg0.conf` +- `server/install_server.sh` — установка сервера + GUI +- `server/wg-peerctl.sh` — helper для регистрации peer на сервере +- `client/install_client.sh` — установка и автонастройка клиента + +## Архитектура решения + +- Сервер разворачивается нативно на `wg-quick` + `systemd` (стабильность после reboot). +- GUI (`wireguard-ui`) запускается в Docker, но работает с тем же `/etc/wireguard`, где лежит серверный конфиг. +- Клиентский скрипт: + 1. генерирует ключи локально, + 2. подключается к серверу по SSH, + 3. вызывает `/usr/local/sbin/wg-peerctl add ...`, + 4. получает параметры подключения, + 5. пишет локальный `/etc/wireguard/wg0.conf`, + 6. запускает и включает `wg-quick@wg0`. + +## Почему выбран GUI `wireguard-ui` + +- Легкий для VPS (один контейнер). +- Понятный веб-интерфейс. +- Не требует переносить основной WireGuard в Docker: VPN остается в нативном `systemd`. +- Проще обслуживание: серверная сеть и NAT остаются под полным контролем Bash-скрипта. + +## Какие пакеты устанавливаются + +### Сервер + +- `wireguard`, `wireguard-tools` +- `iproute2`, `iptables` +- `curl`, `ca-certificates`, `openssl`, `qrencode` +- `docker.io`, `docker-compose-plugin` + +### Клиент + +- `wireguard`, `wireguard-tools` +- `iproute2` +- `openssh-client`, `sshpass` +- `ca-certificates` + +## Установка сервера + +1. Клонировать/открыть репозиторий. +2. Запустить: + +```bash +sudo bash server/install_server.sh +``` + +Скрипт интерактивно спросит недостающие данные: +- публичный IP (если не определился автоматически) +- порт WireGuard +- параметры GUI (логин/пароль) + +### Non-interactive пример + +```bash +sudo bash server/install_server.sh \ + --non-interactive \ + --wg-interface wg0 \ + --wg-port 51820 \ + --wg-network 10.66.66.0/24 \ + --wg-address 10.66.66.1/24 \ + --server-public-ip 203.0.113.10 \ + --default-iface eth0 \ + --gui-enable yes \ + --gui-host vpn.example.com \ + --gui-port 5000 \ + --gui-user admin \ + --gui-password 'StrongPass123!' +``` + +## Установка клиента + +Запускать на клиентской машине: + +```bash +sudo bash client/install_client.sh +``` + +Скрипт попросит: +- адрес сервера +- SSH-учетные данные +- режим маршрутизации (full/split) +- список сетей (если `split`) + +### Non-interactive пример (SSH-ключ) + +```bash +sudo bash client/install_client.sh \ + --non-interactive \ + --server-host 203.0.113.10 \ + --server-user root \ + --ssh-auth key \ + --mode full \ + --client-name laptop-01 +``` + +### Non-interactive пример (SSH-пароль) + +```bash +sudo bash client/install_client.sh \ + --non-interactive \ + --server-host 203.0.113.10 \ + --server-user root \ + --ssh-auth password \ + --ssh-password 'your_password' \ + --mode split \ + --allowed-ips 10.0.0.0/8,192.168.0.0/16 \ + --client-name laptop-02 +``` + +## Как создаются ключи + +- Сервер: `/etc/wireguard/server_private.key` и `/etc/wireguard/server_public.key`. +- Клиент: `/etc/wireguard/wg0_client_private.key`, `..._public.key`, `..._psk.key`. +- Повторный запуск не перегенерирует ключи без необходимости. + +## Как задаются маршруты + +Клиентский скрипт поддерживает режимы: + +1. `full` — `AllowedIPs = 0.0.0.0/0,::/0` +2. `split` — `AllowedIPs` задается списком сетей + +Для `full` добавляется route-exception до IP WireGuard-сервера, чтобы не потерять SSH/доступ при переключении default route. + +## Как включается автозапуск + +Используется `systemd`: + +- Сервер: `wg-quick@wg0.service` +- Клиент: `wg-quick@wg0.service` + +Скрипты выполняют `systemctl enable --now ...`. + +## Как открыть GUI + +После установки сервера GUI доступен по адресу: + +```text +http://: +``` + +Пример: + +```text +http://203.0.113.10:5000 +``` + +Логин/пароль задаются во время установки сервера. + +## Взаимодействие клиента с сервером + +- Клиент генерирует локальные ключи. +- Клиент по SSH вызывает на сервере `wg-peerctl add`. +- Серверный helper: + - выделяет IP клиенту внутри VPN-сети, + - добавляет peer идемпотентно, + - применяет конфиг, + - возвращает параметры подключения. +- Клиент собирает локальный `wg0.conf`, запускает интерфейс и автозапуск. + +## Примеры использования + +### Проверка статуса на сервере + +```bash +sudo systemctl status wg-quick@wg0 +sudo wg show +``` + +### Ручное добавление peer через helper + +```bash +sudo /usr/local/sbin/wg-peerctl add \ + --client-name test-client \ + --client-public-key '' +``` + +### Проверка клиента + +```bash +sudo wg show +ip route +curl -4 ifconfig.me +``` + +## Безопасность и ограничения + +- Приватные ключи не пишутся в лог. +- Конфиги и ключи сохраняются с ограниченными правами (`600`). +- Перед перезаписью конфигов делаются backup-файлы. +- SSH по паролю поддержан, но менее безопасен, чем SSH-ключи. +- В non-interactive режиме пароль в командной строке может попасть в shell history — лучше использовать SSH-ключ. +- Скрипты предполагают root-доступ на сервере для изменения `/etc/wireguard` и `systemd`. +- GUI запускается без TLS по умолчанию (HTTP). Для production рекомендуется ставить reverse proxy (Nginx/Caddy) с HTTPS и ограничением доступа. + +## Диагностика и устранение проблем + +1. WireGuard не поднялся: +```bash +sudo systemctl status wg-quick@wg0 +sudo journalctl -u wg-quick@wg0 -n 100 --no-pager +sudo wg show +``` + +2. Не работает интернет у клиента: +```bash +ip route +sudo wg show +``` +Проверьте `AllowedIPs`, `ip_forward`, NAT и внешний интерфейс сервера. + +3. Клиент не добавляется по SSH: +- Проверьте доступность SSH (`ssh user@server`). +- Проверьте, что на сервере установлен helper: +```bash +ls -l /usr/local/sbin/wg-peerctl +``` + +4. GUI недоступен: +```bash +sudo docker ps +sudo docker logs wireguard-ui --tail=100 +sudo ss -tulpn | grep 5000 +``` + +## Важные пути + +- Серверный конфиг: `/etc/wireguard/wg0.conf` +- Метаданные сервера: `/etc/wireguard/wg-meta.env` +- Клиентский конфиг: `/etc/wireguard/wg0.conf` +- Server install log: `/var/log/wireguard-server-install.log` +- Client install log: `/var/log/wireguard-client-install.log` +- Peer helper log: `/var/log/wireguard-peerctl.log` + +## Справка по ключам + +```bash +bash server/install_server.sh --help +bash client/install_client.sh --help +``` diff --git a/client/install_client.sh b/client/install_client.sh new file mode 100755 index 0000000..ab61787 --- /dev/null +++ b/client/install_client.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=../lib/common.sh +source "${PROJECT_ROOT}/lib/common.sh" + +LOG_FILE="/var/log/wireguard-client-install.log" +NON_INTERACTIVE=0 + +WG_INTERFACE="wg0" +CLIENT_NAME="" +CLIENT_DNS="" +TUNNEL_MODE="" +SPLIT_ALLOWED_IPS="" + +SERVER_HOST="" +SERVER_USER="root" +SSH_PORT="22" +SSH_AUTH_METHOD="key" +SSH_PASSWORD="" + +KEEPALIVE="25" + +usage() { + cat <<'USAGE' +Установка WireGuard-клиента и автоматическая регистрация на сервере. + +Использование: + install_client.sh [опции] + +Опции: + --non-interactive Режим без вопросов + --interface Интерфейс клиента (по умолчанию: wg0) + --client-name Имя клиента (по умолчанию: hostname) + --mode full: весь трафик, split: только выбранные сети + --allowed-ips Для режима split + --dns DNS для клиента (если не задан, берется с сервера) + + --server-host IP/домен WireGuard-сервера + --server-user SSH-пользователь (по умолчанию: root) + --ssh-port SSH-порт (по умолчанию: 22) + --ssh-auth Тип SSH-аутентификации (по умолчанию: key) + --ssh-password Пароль SSH (используйте осторожно) + + -h, --help Показать помощь +USAGE +} + +on_error() { + local code=$? + log_error "Клиентская настройка завершилась с ошибкой (код: ${code}). Лог: ${LOG_FILE}" + exit "$code" +} +trap on_error ERR + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) + NON_INTERACTIVE=1; shift ;; + --interface) + WG_INTERFACE="$2"; shift 2 ;; + --client-name) + CLIENT_NAME="$2"; shift 2 ;; + --mode) + TUNNEL_MODE="$2"; shift 2 ;; + --allowed-ips) + SPLIT_ALLOWED_IPS="$2"; shift 2 ;; + --dns) + CLIENT_DNS="$2"; shift 2 ;; + --server-host) + SERVER_HOST="$2"; shift 2 ;; + --server-user) + SERVER_USER="$2"; shift 2 ;; + --ssh-port) + SSH_PORT="$2"; shift 2 ;; + --ssh-auth) + SSH_AUTH_METHOD="$2"; shift 2 ;; + --ssh-password) + SSH_PASSWORD="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + die "Неизвестный аргумент: $1" + ;; + esac + done +} + +validate_inputs() { + is_valid_port "$SSH_PORT" || die "Некорректный SSH-порт: $SSH_PORT" + [[ "$SSH_AUTH_METHOD" == "key" || "$SSH_AUTH_METHOD" == "password" ]] || die "--ssh-auth должен быть key или password" + + if [[ -z "$SERVER_HOST" ]]; then + die "Не указан --server-host" + fi + + if [[ "$TUNNEL_MODE" == "split" ]]; then + [[ -n "$SPLIT_ALLOWED_IPS" ]] || die "Для режима split укажите --allowed-ips" + is_valid_cidr_list "$SPLIT_ALLOWED_IPS" || die "Некорректный список --allowed-ips" + fi +} + +collect_inputs() { + if [[ -z "$CLIENT_NAME" ]]; then + CLIENT_NAME="$(hostname -s)" + fi + CLIENT_NAME="$(sanitize_name "$CLIENT_NAME")" + [[ -n "$CLIENT_NAME" ]] || die "Некорректное имя клиента" + + if [[ -z "$SERVER_HOST" && ! $NON_INTERACTIVE -eq 1 ]]; then + read -r -p "Введите IP/домен сервера: " SERVER_HOST + fi + + if [[ -z "$TUNNEL_MODE" ]]; then + if ((NON_INTERACTIVE)); then + TUNNEL_MODE="full" + else + echo "Выберите режим маршрутизации:" + echo " 1) весь трафик через VPN (full)" + echo " 2) только выбранные сети (split)" + read -r -p "Ваш выбор [1/2, по умолчанию 1]: " mode_choice + case "${mode_choice:-1}" in + 1) TUNNEL_MODE="full" ;; + 2) TUNNEL_MODE="split" ;; + *) die "Некорректный выбор" ;; + esac + fi + fi + + if [[ "$TUNNEL_MODE" == "split" && -z "$SPLIT_ALLOWED_IPS" ]]; then + if ((NON_INTERACTIVE)); then + die "В non-interactive для split укажите --allowed-ips" + fi + read -r -p "Введите CIDR-сети через запятую (например 10.0.0.0/8,192.168.0.0/16): " SPLIT_ALLOWED_IPS + fi + + if [[ "$SSH_AUTH_METHOD" == "password" && -z "$SSH_PASSWORD" ]]; then + if ((NON_INTERACTIVE)); then + die "Для --ssh-auth password нужно указать --ssh-password" + fi + ask_secret "Введите SSH-пароль для ${SERVER_USER}@${SERVER_HOST}" SSH_PASSWORD + fi + + validate_inputs +} + +install_packages() { + apt_install_if_missing wireguard wireguard-tools iproute2 openssh-client sshpass ca-certificates +} + +ssh_base_cmd() { + echo "ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -p ${SSH_PORT} ${SERVER_USER}@${SERVER_HOST}" +} + +run_ssh() { + local remote_cmd="$1" + local base + base="$(ssh_base_cmd)" + + if [[ "$SSH_AUTH_METHOD" == "password" ]]; then + require_cmd sshpass + SSHPASS="$SSH_PASSWORD" sshpass -e bash -c "$base \"$remote_cmd\"" + else + bash -c "$base \"$remote_cmd\"" + fi +} + +resolve_server_ip() { + getent ahostsv4 "$SERVER_HOST" | awk '{print $1; exit}' +} + +generate_keys() { + mkdir -p /etc/wireguard + chmod 700 /etc/wireguard + + CLIENT_PRIV_KEY_PATH="/etc/wireguard/${WG_INTERFACE}_client_private.key" + CLIENT_PUB_KEY_PATH="/etc/wireguard/${WG_INTERFACE}_client_public.key" + CLIENT_PSK_PATH="/etc/wireguard/${WG_INTERFACE}_client_psk.key" + + if [[ ! -f "$CLIENT_PRIV_KEY_PATH" ]]; then + umask 077 + wg genkey | tee "$CLIENT_PRIV_KEY_PATH" | wg pubkey > "$CLIENT_PUB_KEY_PATH" + wg genpsk > "$CLIENT_PSK_PATH" + log_success "Сгенерированы клиентские ключи" + else + if [[ ! -f "$CLIENT_PUB_KEY_PATH" ]]; then + wg pubkey < "$CLIENT_PRIV_KEY_PATH" > "$CLIENT_PUB_KEY_PATH" + fi + if [[ ! -f "$CLIENT_PSK_PATH" ]]; then + wg genpsk > "$CLIENT_PSK_PATH" + fi + log_info "Ключи клиента уже существуют, переиспользую" + fi + + safe_chmod_600 "$CLIENT_PRIV_KEY_PATH" + safe_chmod_600 "$CLIENT_PUB_KEY_PATH" + safe_chmod_600 "$CLIENT_PSK_PATH" +} + +register_peer_on_server() { + local pub psk remote_cmd response + pub="$(cat "$CLIENT_PUB_KEY_PATH")" + psk="$(cat "$CLIENT_PSK_PATH")" + + remote_cmd="/usr/local/sbin/wg-peerctl add --client-name '${CLIENT_NAME}' --client-public-key '${pub}' --client-preshared-key '${psk}' --persistent-keepalive '${KEEPALIVE}'" + response="$(run_ssh "$remote_cmd")" + + SERVER_RESPONSE_RAW="$response" + + SERVER_STATUS="$(echo "$response" | awk -F= '/^STATUS=/{print $2; exit}')" + CLIENT_ADDRESS="$(echo "$response" | awk -F= '/^CLIENT_ADDRESS=/{print $2; exit}')" + SERVER_PUBLIC_KEY="$(echo "$response" | awk -F= '/^SERVER_PUBLIC_KEY=/{print $2; exit}')" + SERVER_ENDPOINT="$(echo "$response" | awk -F= '/^SERVER_ENDPOINT=/{print $2; exit}')" + SERVER_DNS_REMOTE="$(echo "$response" | awk -F= '/^SERVER_DNS=/{print $2; exit}')" + + [[ -n "$SERVER_PUBLIC_KEY" ]] || die "Сервер не вернул SERVER_PUBLIC_KEY" + [[ -n "$CLIENT_ADDRESS" ]] || die "Сервер не вернул CLIENT_ADDRESS" + [[ -n "$SERVER_ENDPOINT" ]] || die "Сервер не вернул SERVER_ENDPOINT" +} + +build_allowed_ips() { + if [[ "$TUNNEL_MODE" == "full" ]]; then + ALLOWED_IPS="0.0.0.0/0,::/0" + else + ALLOWED_IPS="$SPLIT_ALLOWED_IPS" + fi +} + +build_route_hooks_if_needed() { + PRE_UP="" + POST_DOWN="" + + if [[ "$TUNNEL_MODE" != "full" ]]; then + return + fi + + local server_ip gw iface + server_ip="$(resolve_server_ip || true)" + gw="$(detect_default_gateway || true)" + iface="$(detect_default_iface || true)" + + if [[ -n "$server_ip" && -n "$gw" && -n "$iface" ]]; then + PRE_UP="PreUp = ip route add ${server_ip}/32 via ${gw} dev ${iface} 2>/dev/null || true" + POST_DOWN="PostDown = ip route del ${server_ip}/32 via ${gw} dev ${iface} 2>/dev/null || true" + else + log_warn "Не удалось вычислить route-exception до сервера. Использую стандартное поведение wg-quick." + fi +} + +write_client_config() { + local conf="/etc/wireguard/${WG_INTERFACE}.conf" + local dns + + dns="$CLIENT_DNS" + if [[ -z "$dns" ]]; then + dns="${SERVER_DNS_REMOTE:-1.1.1.1}" + fi + + if [[ -f "$conf" ]]; then + backup_file "$conf" + fi + + { + echo "[Interface]" + echo "PrivateKey = $(cat "$CLIENT_PRIV_KEY_PATH")" + echo "Address = ${CLIENT_ADDRESS}" + echo "DNS = ${dns}" + if [[ -n "$PRE_UP" ]]; then + echo "$PRE_UP" + fi + if [[ -n "$POST_DOWN" ]]; then + echo "$POST_DOWN" + fi + echo + echo "[Peer]" + echo "PublicKey = ${SERVER_PUBLIC_KEY}" + echo "PresharedKey = $(cat "$CLIENT_PSK_PATH")" + echo "Endpoint = ${SERVER_ENDPOINT}" + echo "AllowedIPs = ${ALLOWED_IPS}" + echo "PersistentKeepalive = ${KEEPALIVE}" + } > "$conf" + + safe_chmod_600 "$conf" +} + +apply_client_config() { + local unit="wg-quick@${WG_INTERFACE}.service" + if systemctl list-unit-files | grep -q "^${unit}"; then + systemctl restart "$unit" || true + fi + systemd_enable_now "$unit" +} + +print_routes_info() { + log_info "Текущая таблица маршрутов клиента (до применения уже была прочитана системой):" + ip route show | sed 's/^/ /' +} + +print_summary() { + local st + st="$(systemctl is-active "wg-quick@${WG_INTERFACE}" 2>/dev/null || true)" + + cat <> "$LOG_FILE" + fi +} + +log_info() { + local msg="$*" + printf '%b[INFO]%b %s\n' "$C_BLUE" "$C_RESET" "$msg" + _write_log "INFO" "$msg" +} + +log_warn() { + local msg="$*" + printf '%b[WARN]%b %s\n' "$C_YELLOW" "$C_RESET" "$msg" + _write_log "WARN" "$msg" +} + +log_error() { + local msg="$*" + printf '%b[ERR ]%b %s\n' "$C_RED" "$C_RESET" "$msg" >&2 + _write_log "ERROR" "$msg" +} + +log_success() { + local msg="$*" + printf '%b[ OK ]%b %s\n' "$C_GREEN" "$C_RESET" "$msg" + _write_log "SUCCESS" "$msg" +} + +die() { + log_error "$*" + exit 1 +} + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + die "Скрипт должен выполняться от root." + fi +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Не найдена команда: $cmd" +} + +check_os_supported() { + if [[ ! -r /etc/os-release ]]; then + die "Не найден файл /etc/os-release, определить ОС не удалось." + fi + # shellcheck disable=SC1091 + source /etc/os-release + local id="${ID:-}" + local like="${ID_LIKE:-}" + if [[ "$id" != "debian" && "$id" != "ubuntu" && "$like" != *"debian"* ]]; then + die "Поддерживаются только Debian/Ubuntu. Обнаружено: ${PRETTY_NAME:-unknown}" + fi +} + +apt_install_if_missing() { + local pkgs=() + local pkg + for pkg in "$@"; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + pkgs+=("$pkg") + fi + done + + if ((${#pkgs[@]} == 0)); then + log_info "Все необходимые пакеты уже установлены." + return + fi + + log_info "Устанавливаю пакеты: ${pkgs[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y "${pkgs[@]}" +} + +backup_file() { + local file="$1" + if [[ -f "$file" ]]; then + local backup="${file}.bak.$(date +%Y%m%d_%H%M%S)" + cp -a "$file" "$backup" + log_info "Создана резервная копия: $backup" + fi +} + +ensure_line_in_file() { + local line="$1" + local file="$2" + touch "$file" + if ! grep -Fxq "$line" "$file"; then + printf '%s\n' "$line" >> "$file" + fi +} + +set_kv_in_file() { + local key="$1" + local value="$2" + local file="$3" + touch "$file" + if grep -Eq "^${key}=" "$file"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$file" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +is_valid_port() { + local p="$1" + [[ "$p" =~ ^[0-9]+$ ]] || return 1 + ((p >= 1 && p <= 65535)) +} + +is_valid_cidr_list() { + local input="$1" + local cidr + IFS=',' read -r -a _cidrs <<< "$input" + for cidr in "${_cidrs[@]}"; do + cidr="$(echo "$cidr" | xargs)" + [[ -n "$cidr" ]] || return 1 + if ! [[ "$cidr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$|^::/0$ ]]; then + return 1 + fi + done +} + +ask_with_default() { + local prompt="$1" + local default="$2" + local var_name="$3" + local answer + + read -r -p "$prompt [$default]: " answer + answer="${answer:-$default}" + printf -v "$var_name" '%s' "$answer" +} + +ask_secret() { + local prompt="$1" + local var_name="$2" + local answer + read -r -s -p "$prompt: " answer + echo + printf -v "$var_name" '%s' "$answer" +} + +confirm() { + local prompt="$1" + local ans + read -r -p "$prompt [y/N]: " ans + [[ "$ans" =~ ^([yY][eE][sS]|[yY])$ ]] +} + +detect_public_ip() { + local ip + for url in "https://api.ipify.org" "https://ifconfig.me/ip" "https://icanhazip.com"; do + if ip="$(curl -4fsS --max-time 5 "$url" 2>/dev/null | tr -d '[:space:]')"; then + if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + echo "$ip" + return 0 + fi + fi + done + return 1 +} + +detect_default_iface() { + ip route show default 2>/dev/null | awk '{print $5; exit}' +} + +detect_default_gateway() { + ip route show default 2>/dev/null | awk '{print $3; exit}' +} + +safe_chmod_600() { + local file="$1" + [[ -f "$file" ]] || return 0 + chmod 600 "$file" +} + +safe_chmod_700() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + chmod 700 "$dir" +} + +systemd_enable_now() { + local unit="$1" + systemctl daemon-reload + systemctl enable --now "$unit" +} + +sanitize_name() { + local input="$1" + echo "$input" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_.-' | sed 's/^[-.]*//;s/[-.]*$//' +} + +random_alnum() { + local len="${1:-20}" + tr -dc 'A-Za-z0-9' Имя интерфейса WireGuard (по умолчанию: wg0) + --wg-port Порт WireGuard (по умолчанию: 51820) + --wg-network Подсеть VPN (по умолчанию: 10.66.66.0/24) + --wg-address Адрес сервера в VPN (по умолчанию: 10.66.66.1/24) + --server-public-ip Публичный IP сервера + --server-dns DNS для клиентов (по умолчанию: 1.1.1.1) + --default-iface Внешний интерфейс для NAT + + --gui-enable Включить GUI wireguard-ui (по умолчанию: yes) + --gui-host Домен/IP для открытия GUI + --gui-port Порт GUI (по умолчанию: 5000) + --gui-user Логин GUI (по умолчанию: admin) + --gui-password Пароль GUI (если не указан, будет запрос) + + -h, --help Показать помощь +USAGE +} + +on_error() { + local code=$? + log_error "Установка прервана с ошибкой (код: ${code}). Подробности в ${LOG_FILE}" + exit "$code" +} +trap on_error ERR + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) + NON_INTERACTIVE=1; shift ;; + --wg-interface) + WG_INTERFACE="$2"; shift 2 ;; + --wg-port) + WG_PORT="$2"; shift 2 ;; + --wg-network) + WG_NETWORK="$2"; shift 2 ;; + --wg-address) + WG_ADDRESS="$2"; shift 2 ;; + --server-public-ip) + SERVER_PUBLIC_IP="$2"; shift 2 ;; + --server-dns) + SERVER_DNS="$2"; shift 2 ;; + --default-iface) + DEFAULT_IFACE="$2"; shift 2 ;; + --gui-enable) + GUI_ENABLE="$2"; shift 2 ;; + --gui-host) + GUI_HOST="$2"; shift 2 ;; + --gui-port) + GUI_PORT="$2"; shift 2 ;; + --gui-user) + GUI_USER="$2"; shift 2 ;; + --gui-password) + GUI_PASSWORD="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + die "Неизвестный аргумент: $1" + ;; + esac + done +} + +validate_inputs() { + is_valid_port "$WG_PORT" || die "Некорректный порт WireGuard: $WG_PORT" + is_valid_port "$GUI_PORT" || die "Некорректный порт GUI: $GUI_PORT" + + if [[ ! "$WG_NETWORK" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then + die "Некорректная сеть WG: $WG_NETWORK" + fi + + if [[ ! "$WG_ADDRESS" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[12][0-9]|3[0-2])$ ]]; then + die "Некорректный адрес WG сервера: $WG_ADDRESS" + fi +} + +collect_inputs() { + if [[ -z "$DEFAULT_IFACE" ]]; then + DEFAULT_IFACE="$(detect_default_iface || true)" + fi + if [[ -z "$DEFAULT_IFACE" ]]; then + if ((NON_INTERACTIVE)); then + die "Не удалось определить внешний интерфейс. Передайте --default-iface" + fi + read -r -p "Введите внешний интерфейс (например eth0): " DEFAULT_IFACE + fi + + if [[ -z "$SERVER_PUBLIC_IP" ]]; then + SERVER_PUBLIC_IP="$(detect_public_ip || true)" + fi + if [[ -z "$SERVER_PUBLIC_IP" ]]; then + if ((NON_INTERACTIVE)); then + die "Не удалось определить публичный IP. Передайте --server-public-ip" + fi + read -r -p "Введите публичный IP сервера: " SERVER_PUBLIC_IP + fi + + if [[ -z "$GUI_HOST" ]]; then + GUI_HOST="$SERVER_PUBLIC_IP" + fi + + if [[ "$GUI_ENABLE" == "yes" ]]; then + if [[ -z "$GUI_PASSWORD" ]]; then + if ((NON_INTERACTIVE)); then + GUI_PASSWORD="$(random_alnum 16)" + log_warn "Пароль GUI не задан. Сгенерирован случайный пароль." + else + ask_secret "Введите пароль GUI (${GUI_USER})" GUI_PASSWORD + if [[ -z "$GUI_PASSWORD" ]]; then + GUI_PASSWORD="$(random_alnum 16)" + log_warn "Пустой пароль не допускается. Сгенерирован случайный пароль: $GUI_PASSWORD" + fi + fi + fi + GUI_SESSION_SECRET="$(random_alnum 32)" + fi + + validate_inputs +} + +install_packages() { + apt_install_if_missing \ + wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \ + qrencode docker.io docker-compose-plugin +} + +setup_sysctl() { + local f="/etc/sysctl.d/99-wireguard-forwarding.conf" + cat > "$f" </dev/null + log_success "IP forwarding включен" +} + +setup_keys() { + mkdir -p /etc/wireguard + chmod 700 /etc/wireguard + + local priv="/etc/wireguard/server_private.key" + local pub="/etc/wireguard/server_public.key" + + if [[ ! -f "$priv" ]]; then + umask 077 + wg genkey | tee "$priv" | wg pubkey > "$pub" + log_success "Сгенерированы ключи сервера" + else + if [[ ! -f "$pub" ]]; then + wg pubkey < "$priv" > "$pub" + fi + log_info "Ключи сервера уже существуют, переиспользую" + fi + + safe_chmod_600 "$priv" + safe_chmod_600 "$pub" +} + +setup_wg_config() { + local conf="/etc/wireguard/${WG_INTERFACE}.conf" + if [[ -f "$conf" ]]; then + log_warn "Конфиг ${conf} уже существует. Автосоздание пропущено." + return + fi + + local server_priv + server_priv="$(cat /etc/wireguard/server_private.key)" + + sed -e "s|__WG_ADDRESS__|${WG_ADDRESS}|g" \ + -e "s|__WG_PORT__|${WG_PORT}|g" \ + -e "s|__SERVER_PRIVATE_KEY__|${server_priv}|g" \ + -e "s|__WG_INTERFACE__|${WG_INTERFACE}|g" \ + -e "s|__WG_NETWORK__|${WG_NETWORK}|g" \ + -e "s|__DEFAULT_IFACE__|${DEFAULT_IFACE}|g" \ + "${PROJECT_ROOT}/templates/wg0.conf.template" > "$conf" + + safe_chmod_600 "$conf" + log_success "Создан базовый конфиг: $conf" +} + +setup_meta() { + local meta="/etc/wireguard/wg-meta.env" + touch "$meta" + safe_chmod_600 "$meta" + + set_kv_in_file "WG_INTERFACE" "$WG_INTERFACE" "$meta" + set_kv_in_file "WG_PORT" "$WG_PORT" "$meta" + set_kv_in_file "WG_NETWORK" "$WG_NETWORK" "$meta" + set_kv_in_file "WG_ADDRESS" "$WG_ADDRESS" "$meta" + set_kv_in_file "SERVER_PUBLIC_IP" "$SERVER_PUBLIC_IP" "$meta" + set_kv_in_file "SERVER_DNS" "$SERVER_DNS" "$meta" + set_kv_in_file "DEFAULT_IFACE" "$DEFAULT_IFACE" "$meta" +} + +setup_wg_service() { + systemd_enable_now "wg-quick@${WG_INTERFACE}.service" + log_success "WireGuard сервис запущен и включен в автозапуск" +} + +setup_ufw_if_active() { + if command -v ufw >/dev/null 2>&1; then + if ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow "${WG_PORT}/udp" || true + if [[ "$GUI_ENABLE" == "yes" ]]; then + ufw allow "${GUI_PORT}/tcp" || true + fi + log_info "UFW активен: правила для WireGuard/GUI добавлены" + fi + fi +} + +install_peer_helper() { + mkdir -p /usr/local/lib/wireguard-automation/{lib,server} + install -m 640 "${PROJECT_ROOT}/lib/common.sh" /usr/local/lib/wireguard-automation/lib/common.sh + install -m 750 "${PROJECT_ROOT}/server/wg-peerctl.sh" /usr/local/lib/wireguard-automation/server/wg-peerctl.sh + + cat > /usr/local/sbin/wg-peerctl <<'EOF_HELPER' +#!/usr/bin/env bash +exec /usr/local/lib/wireguard-automation/server/wg-peerctl.sh "$@" +EOF_HELPER + chmod 750 /usr/local/sbin/wg-peerctl + + log_success "Установлен helper: /usr/local/sbin/wg-peerctl" +} + +setup_gui() { + [[ "$GUI_ENABLE" == "yes" ]] || { log_warn "GUI отключен (GUI_ENABLE=no)"; return; } + + mkdir -p /opt/wireguard-ui/{db,data} + safe_chmod_700 /opt/wireguard-ui + + cat > /opt/wireguard-ui/docker-compose.yml </dev/null || true)" + gui_status="disabled" + + if [[ "$GUI_ENABLE" == "yes" ]]; then + gui_status="$(docker ps --filter name=wireguard-ui --format '{{.Status}}' || true)" + [[ -n "$gui_status" ]] || gui_status="not running" + fi + + cat <&2 + exit 1 +fi + +LOG_FILE="/var/log/wireguard-peerctl.log" +WG_META_FILE="/etc/wireguard/wg-meta.env" + +usage() { + cat <<'USAGE' +Использование: + wg-peerctl.sh add \ + --client-name \ + --client-public-key \ + [--client-address <10.66.66.X/32>] \ + [--client-preshared-key ] \ + [--persistent-keepalive 25] + +Описание: + Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно. + Если peer с таким public key уже существует, повторно не добавляет. +USAGE +} + +load_meta() { + [[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh" + # shellcheck disable=SC1090 + source "$WG_META_FILE" + + WG_INTERFACE="${WG_INTERFACE:-wg0}" + WG_NETWORK="${WG_NETWORK:-10.66.66.0/24}" + WG_PORT="${WG_PORT:-51820}" + SERVER_PUBLIC_IP="${SERVER_PUBLIC_IP:-}" + SERVER_DNS="${SERVER_DNS:-1.1.1.1}" + WG_CONF="/etc/wireguard/${WG_INTERFACE}.conf" +} + +next_client_ip() { + local network="$1" + local base + base="${network%.*}" + + local used + used="$(grep -E '^AllowedIPs\s*=\s*' "$WG_CONF" | awk -F'=' '{print $2}' | tr ',' '\n' | sed 's/ //g' | grep -E '^10\.[0-9]+\.[0-9]+\.[0-9]+/32$' | sed 's#/32##' || true)" + + local i candidate + for i in $(seq 2 254); do + candidate="${base}.${i}" + if ! grep -qx "$candidate" <<< "$used"; then + echo "${candidate}/32" + return 0 + fi + done + return 1 +} + +peer_exists_by_pubkey() { + local pubkey="$1" + grep -Fq "PublicKey = $pubkey" "$WG_CONF" +} + +extract_peer_address_by_pubkey() { + local pubkey="$1" + awk -v pk="$pubkey" ' + $0 ~ /^\[Peer\]/ {in_peer=1; key=""; addr=""} + in_peer && $0 ~ /^PublicKey[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, "", $0); key=$0 + } + in_peer && $0 ~ /^AllowedIPs[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, "", $0); addr=$0 + } + in_peer && key==pk && addr!="" {print addr; exit} + ' "$WG_CONF" | awk -F',' '{print $1}' | xargs +} + +apply_config() { + if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then + wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF") + else + systemctl restart "wg-quick@${WG_INTERFACE}" + fi +} + +cmd_add() { + local client_name="" + local client_pubkey="" + local client_address="" + local client_psk="" + local keepalive="25" + + while [[ $# -gt 0 ]]; do + case "$1" in + --client-name) + client_name="$2"; shift 2 ;; + --client-public-key) + client_pubkey="$2"; shift 2 ;; + --client-address) + client_address="$2"; shift 2 ;; + --client-preshared-key) + client_psk="$2"; shift 2 ;; + --persistent-keepalive) + keepalive="$2"; shift 2 ;; + *) + die "Неизвестный аргумент: $1" + ;; + esac + done + + [[ -n "$client_name" ]] || die "Не указан --client-name" + [[ -n "$client_pubkey" ]] || die "Не указан --client-public-key" + + client_name="$(sanitize_name "$client_name")" + [[ -n "$client_name" ]] || die "Некорректное имя клиента" + + load_meta + [[ -f "$WG_CONF" ]] || die "Не найден конфиг WireGuard: $WG_CONF" + + local server_pubkey + server_pubkey="$(cat /etc/wireguard/server_public.key)" + + if peer_exists_by_pubkey "$client_pubkey"; then + local existing_addr + existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")" + cat <> "$WG_CONF" + + apply_config + + cat <