feat: автоматизация установки и настройки WireGuard сервера и клиента

This commit is contained in:
Ruslan
2026-04-14 00:04:06 +03:00
commit a31f1a1090
8 changed files with 1416 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.log
*.tmp
*.swp
.env
.DS_Store

271
README.md Normal file
View File

@@ -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://<GUI_HOST>:<GUI_PORT>
```
Пример:
```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 '<client_pub_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
```

350
client/install_client.sh Executable file
View File

@@ -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 <name> Интерфейс клиента (по умолчанию: wg0)
--client-name <name> Имя клиента (по умолчанию: hostname)
--mode <full|split> full: весь трафик, split: только выбранные сети
--allowed-ips <cidr,cidr> Для режима split
--dns <ip> DNS для клиента (если не задан, берется с сервера)
--server-host <host> IP/домен WireGuard-сервера
--server-user <user> SSH-пользователь (по умолчанию: root)
--ssh-port <port> SSH-порт (по умолчанию: 22)
--ssh-auth <key|password> Тип SSH-аутентификации (по умолчанию: key)
--ssh-password <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 <<EOF_SUMMARY
================ ИТОГОВАЯ СВОДКА ================
Клиент: ${CLIENT_NAME}
Интерфейс: ${WG_INTERFACE}
Сервис: ${st}
Конфиг клиента: /etc/wireguard/${WG_INTERFACE}.conf
Endpoint сервера: ${SERVER_ENDPOINT}
Маршруты (AllowedIPs):${ALLOWED_IPS}
Режим туннеля: ${TUNNEL_MODE}
SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT}
Статус регистрации: ${SERVER_STATUS}
Лог: ${LOG_FILE}
=================================================
EOF_SUMMARY
}
main() {
parse_args "$@"
require_root
check_os_supported
require_cmd awk
require_cmd sed
require_cmd ip
require_cmd ssh
collect_inputs
print_routes_info
install_packages
generate_keys
register_peer_on_server
build_allowed_ips
build_route_hooks_if_needed
write_client_config
apply_client_config
print_summary
# Не держим пароль в переменной дольше необходимого.
unset SSH_PASSWORD
log_success "Настройка клиента завершена"
}
main "$@"

231
lib/common.sh Executable file
View File

@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# Общие функции для скриптов проекта WireGuard.
if [[ -t 1 ]]; then
C_RESET='\033[0m'
C_RED='\033[0;31m'
C_GREEN='\033[0;32m'
C_YELLOW='\033[1;33m'
C_BLUE='\033[0;34m'
else
C_RESET=''
C_RED=''
C_GREEN=''
C_YELLOW=''
C_BLUE=''
fi
LOG_FILE="${LOG_FILE:-}"
timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
_write_log() {
local level="$1"
local msg="$2"
local line="[$(timestamp)] [$level] $msg"
if [[ -n "${LOG_FILE}" ]]; then
mkdir -p "$(dirname "$LOG_FILE")"
printf '%s\n' "$line" >> "$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' </dev/urandom | head -c "$len"
}

0
read.me Normal file
View File

352
server/install_server.sh Executable file
View File

@@ -0,0 +1,352 @@
#!/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-server-install.log"
NON_INTERACTIVE=0
WG_INTERFACE="wg0"
WG_PORT="51820"
WG_NETWORK="10.66.66.0/24"
WG_ADDRESS="10.66.66.1/24"
SERVER_PUBLIC_IP=""
SERVER_DNS="1.1.1.1"
DEFAULT_IFACE=""
GUI_ENABLE="yes"
GUI_HOST=""
GUI_PORT="5000"
GUI_USER="admin"
GUI_PASSWORD=""
GUI_SESSION_SECRET=""
usage() {
cat <<'USAGE'
Установка WireGuard-сервера и GUI (Debian/Ubuntu).
Использование:
install_server.sh [опции]
Опции:
--non-interactive Режим без вопросов (используются аргументы/дефолты)
--wg-interface <name> Имя интерфейса WireGuard (по умолчанию: wg0)
--wg-port <port> Порт WireGuard (по умолчанию: 51820)
--wg-network <cidr> Подсеть VPN (по умолчанию: 10.66.66.0/24)
--wg-address <cidr> Адрес сервера в VPN (по умолчанию: 10.66.66.1/24)
--server-public-ip <ip> Публичный IP сервера
--server-dns <ip> DNS для клиентов (по умолчанию: 1.1.1.1)
--default-iface <iface> Внешний интерфейс для NAT
--gui-enable <yes|no> Включить GUI wireguard-ui (по умолчанию: yes)
--gui-host <host> Домен/IP для открытия GUI
--gui-port <port> Порт GUI (по умолчанию: 5000)
--gui-user <user> Логин GUI (по умолчанию: admin)
--gui-password <pass> Пароль 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" <<EOF_SYSCTL
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF_SYSCTL
sysctl --system >/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 <<EOF_COMPOSE
services:
wireguard-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wireguard-ui
restart: unless-stopped
ports:
- "${GUI_PORT}:5000"
environment:
- WGUI_USERNAME=${GUI_USER}
- WGUI_PASSWORD=${GUI_PASSWORD}
- SESSION_SECRET=${GUI_SESSION_SECRET}
- WGUI_MANAGE_START=true
- WGUI_MANAGE_RESTART=true
volumes:
- /etc/wireguard:/etc/wireguard
- /opt/wireguard-ui/db:/app/db
- /opt/wireguard-ui/data:/app/data
cap_add:
- NET_ADMIN
EOF_COMPOSE
systemd_enable_now docker.service
(cd /opt/wireguard-ui && docker compose up -d)
log_success "GUI wireguard-ui запущен"
}
print_summary() {
local service_status gui_status
service_status="$(systemctl is-active "wg-quick@${WG_INTERFACE}" 2>/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 <<EOF_SUMMARY
================ ИТОГОВАЯ СВОДКА ================
WireGuard сервис: ${service_status}
Интерфейс: ${WG_INTERFACE}
Порт WireGuard: ${WG_PORT}/udp
Конфиг сервера: /etc/wireguard/${WG_INTERFACE}.conf
Ключи сервера: /etc/wireguard/server_private.key, /etc/wireguard/server_public.key
Подсеть VPN: ${WG_NETWORK}
Маршрутизация NAT: через интерфейс ${DEFAULT_IFACE}
GUI: ${GUI_ENABLE}
GUI адрес: http://${GUI_HOST}:${GUI_PORT}
GUI логин: ${GUI_USER}
GUI статус: ${gui_status}
Helper для peer: /usr/local/sbin/wg-peerctl
Лог установки: ${LOG_FILE}
=================================================
EOF_SUMMARY
}
main() {
parse_args "$@"
require_root
check_os_supported
require_cmd ip
require_cmd awk
require_cmd sed
collect_inputs
log_info "Начинаю установку WireGuard-сервера"
install_packages
setup_sysctl
setup_keys
setup_wg_config
setup_meta
setup_wg_service
setup_ufw_if_active
install_peer_helper
setup_gui
print_summary
log_success "Установка завершена"
}
main "$@"

200
server/wg-peerctl.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${SCRIPT_DIR}/../lib/common.sh" ]]; then
# shellcheck source=../lib/common.sh
source "${SCRIPT_DIR}/../lib/common.sh"
elif [[ -f "/usr/local/lib/wireguard-automation/lib/common.sh" ]]; then
# shellcheck source=/usr/local/lib/wireguard-automation/lib/common.sh
source "/usr/local/lib/wireguard-automation/lib/common.sh"
else
echo "Не найден common.sh" >&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 <name> \
--client-public-key <pubkey> \
[--client-address <10.66.66.X/32>] \
[--client-preshared-key <psk>] \
[--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 <<EOF_OUT
STATUS=exists
CLIENT_NAME=$client_name
CLIENT_ADDRESS=${existing_addr:-unknown}
SERVER_PUBLIC_KEY=$server_pubkey
SERVER_ENDPOINT=${SERVER_PUBLIC_IP}:${WG_PORT}
SERVER_DNS=${SERVER_DNS}
WG_INTERFACE=${WG_INTERFACE}
EOF_OUT
return 0
fi
if [[ -z "$client_address" ]]; then
client_address="$(next_client_ip "$WG_NETWORK")" || die "Не удалось выделить IP клиенту в сети $WG_NETWORK"
fi
backup_file "$WG_CONF"
{
echo
echo "# managed-by=wg-peerctl client=${client_name} created_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "[Peer]"
echo "PublicKey = ${client_pubkey}"
if [[ -n "$client_psk" ]]; then
echo "PresharedKey = ${client_psk}"
fi
echo "AllowedIPs = ${client_address}"
echo "PersistentKeepalive = ${keepalive}"
} >> "$WG_CONF"
apply_config
cat <<EOF_OUT
STATUS=created
CLIENT_NAME=$client_name
CLIENT_ADDRESS=$client_address
SERVER_PUBLIC_KEY=$server_pubkey
SERVER_ENDPOINT=${SERVER_PUBLIC_IP}:${WG_PORT}
SERVER_DNS=${SERVER_DNS}
WG_INTERFACE=${WG_INTERFACE}
EOF_OUT
}
main() {
local cmd="${1:-}"
if [[ -z "$cmd" ]]; then
usage
exit 0
fi
shift || true
case "$cmd" in
add)
require_root
check_os_supported
cmd_add "$@"
;;
-h|--help|help)
usage
;;
*)
die "Неизвестная команда: $cmd"
;;
esac
}
main "$@"

View File

@@ -0,0 +1,7 @@
[Interface]
Address = __WG_ADDRESS__
ListenPort = __WG_PORT__
PrivateKey = __SERVER_PRIVATE_KEY__
SaveConfig = true
PostUp = iptables -A FORWARD -i __WG_INTERFACE__ -j ACCEPT; iptables -A FORWARD -o __WG_INTERFACE__ -j ACCEPT; iptables -t nat -A POSTROUTING -s __WG_NETWORK__ -o __DEFAULT_IFACE__ -j MASQUERADE
PostDown = iptables -D FORWARD -i __WG_INTERFACE__ -j ACCEPT; iptables -D FORWARD -o __WG_INTERFACE__ -j ACCEPT; iptables -t nat -D POSTROUTING -s __WG_NETWORK__ -o __DEFAULT_IFACE__ -j MASQUERADE