Compare commits
13 Commits
e2d3993fb3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530e93c1df | ||
|
|
02ccad1805 | ||
|
|
22680a0df5 | ||
|
|
8e124be1f0 | ||
|
|
54868b99cd | ||
|
|
69f51bd5d7 | ||
|
|
bb6cdb58ed | ||
|
|
bdfc648bba | ||
|
|
9b31c5d5c5 | ||
|
|
8de590c5d0 | ||
|
|
cd5ba53802 | ||
|
|
39d17534e0 | ||
|
|
ae3da04d4a |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
*.swp
|
*.swp
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- Быстро развернуть WireGuard-сервер (`wg-quick@wg0`) с автозапуском через `systemd`.
|
- Быстро развернуть WireGuard-сервер (`wg-quick@wg0`) с автозапуском через `systemd`.
|
||||||
- Включить IP forwarding и NAT для выхода клиентов в интернет через сервер.
|
- Включить IP forwarding и NAT для выхода клиентов в интернет через сервер.
|
||||||
- Установить легкий GUI для управления (`wireguard-ui` в Docker).
|
- Установить встроенный GUI для управления peer'ами и QR (`wg-admin-gui`) с хранением данных в SQLite.
|
||||||
- Автоматизировать добавление клиента с клиентской машины через SSH на сервер.
|
- Автоматизировать добавление клиента с клиентской машины через SSH на сервер.
|
||||||
- Поддержать 2 режима маршрутизации клиента:
|
- Поддержать 2 режима маршрутизации клиента:
|
||||||
- полный туннель (весь трафик через VPN)
|
- полный туннель (весь трафик через VPN)
|
||||||
@@ -28,15 +28,16 @@
|
|||||||
- `lib/common.sh` — общие функции
|
- `lib/common.sh` — общие функции
|
||||||
- `bootstrap/install_wg_install.sh` — установка короткой команды `wg-install`
|
- `bootstrap/install_wg_install.sh` — установка короткой команды `wg-install`
|
||||||
- `templates/wg0.conf.template` — шаблон базового `wg0.conf`
|
- `templates/wg0.conf.template` — шаблон базового `wg0.conf`
|
||||||
- `server/install_server.sh` — установка сервера + GUI
|
- `server/install_server.sh` — единый установщик сервера + GUI
|
||||||
- `server/wg-peerctl.sh` — helper для регистрации peer на сервере
|
- `server/wg-peerctl.sh` — helper для регистрации peer на сервере
|
||||||
- `client/install_client.sh` — установка и автонастройка клиента
|
- `client/install_client.sh` — установка и автонастройка клиента
|
||||||
|
|
||||||
## Архитектура решения
|
## Архитектура решения
|
||||||
|
|
||||||
- Сервер разворачивается нативно на `wg-quick` + `systemd` (стабильность после reboot).
|
- Сервер разворачивается нативно на `wg-quick` + `systemd` (стабильность после reboot).
|
||||||
- GUI (`wireguard-ui`) запускается в Docker, но работает с тем же `/etc/wireguard`, где лежит серверный конфиг.
|
- GUI (`wg-admin-gui`) работает поверх того же `/etc/wireguard`, где лежит серверный конфиг.
|
||||||
- На сервере ставится `wg-syncconf@wg0.path`: при изменении `/etc/wireguard/wg0.conf` (в том числе после `Apply` в GUI) конфиг автоматически применяется в живой интерфейс `wg0`.
|
- Метаданные GUI хранятся в SQLite.
|
||||||
|
- На сервере ставится `wg-syncconf@wg0.path`: при изменении `/etc/wireguard/wg0.conf` конфиг автоматически применяется в живой интерфейс `wg0`.
|
||||||
- Клиентский скрипт:
|
- Клиентский скрипт:
|
||||||
1. генерирует ключи локально,
|
1. генерирует ключи локально,
|
||||||
2. подключается к серверу по SSH,
|
2. подключается к серверу по SSH,
|
||||||
@@ -46,13 +47,11 @@
|
|||||||
6. запускает и включает `wg-quick@wg0`.
|
6. запускает и включает `wg-quick@wg0`.
|
||||||
Каждый запуск клиентского установщика сначала очищает старые клиентские ключи/конфиг выбранного интерфейса и поднимает клиента заново.
|
Каждый запуск клиентского установщика сначала очищает старые клиентские ключи/конфиг выбранного интерфейса и поднимает клиента заново.
|
||||||
|
|
||||||
## Почему выбран GUI `wireguard-ui`
|
## Про GUI
|
||||||
|
|
||||||
- Легкий для VPS (один контейнер).
|
- `wg-admin-gui` показывает клиентов, статус, трафик, роуты и важные скрипты.
|
||||||
- Понятный веб-интерфейс.
|
- Поддерживает добавление peer и генерацию QR.
|
||||||
- Не требует переносить основной WireGuard в Docker: VPN остается в нативном `systemd`.
|
- Основной WireGuard остается нативным в `systemd` (`wg-quick@wg0`).
|
||||||
- Проще обслуживание: серверная сеть и NAT остаются под полным контролем Bash-скрипта.
|
|
||||||
- GUI не разрабатывается в этом проекте с нуля: используется готовый `wireguard-ui`, а проект автоматизирует его установку и настройку.
|
|
||||||
|
|
||||||
## Какие пакеты устанавливаются
|
## Какие пакеты устанавливаются
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
- `wireguard`, `wireguard-tools`
|
- `wireguard`, `wireguard-tools`
|
||||||
- `iproute2`, `iptables`
|
- `iproute2`, `iptables`
|
||||||
- `curl`, `ca-certificates`, `openssl`, `qrencode`
|
- `curl`, `ca-certificates`, `openssl`, `qrencode`
|
||||||
- `docker.io` и один из пакетов: `docker-compose-plugin` или `docker-compose` (в зависимости от версии ОС/репозиториев)
|
- `python3`, `python3-venv`, `python3-pip` (для `wg-admin-gui`)
|
||||||
|
|
||||||
### Клиент
|
### Клиент
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
sudo bash server/install_server.sh
|
sudo bash server/install_server.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + `wireguard-ui` data/db) и поднимает всё заново.
|
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + данные GUI/SQLite) и поднимает всё заново.
|
||||||
|
|
||||||
### Запуск сервера одной командой (без `git clone`)
|
### Запуск сервера одной командой (без `git clone`)
|
||||||
|
|
||||||
@@ -161,6 +160,7 @@ tmp="$(mktemp -d)" && curl -fL "https://git.ruslan.xyz/ruslan/Wireguard_server/a
|
|||||||
Если за клиентом есть локальная сеть (например `192.168.33.0/24`), передайте `--advertise-subnets 192.168.33.0/24`, чтобы сервер маршрутизировал эту сеть через клиента.
|
Если за клиентом есть локальная сеть (например `192.168.33.0/24`), передайте `--advertise-subnets 192.168.33.0/24`, чтобы сервер маршрутизировал эту сеть через клиента.
|
||||||
Если `--advertise-subnets` не задан, скрипт автоматически пытается определить LAN-сети клиента и объявить их на сервере.
|
Если `--advertise-subnets` не задан, скрипт автоматически пытается определить LAN-сети клиента и объявить их на сервере.
|
||||||
В режиме `split`, если `--allowed-ips` не задан, скрипт автоматически использует сеть WG сервера.
|
В режиме `split`, если `--allowed-ips` не задан, скрипт автоматически использует сеть WG сервера.
|
||||||
|
При объявлении сетей за клиентом скрипт автоматически включает `ip_forward` и добавляет правила `iptables` (forward + nat) через `PostUp/PostDown`.
|
||||||
|
|
||||||
### Non-interactive пример (SSH-ключ)
|
### Non-interactive пример (SSH-ключ)
|
||||||
|
|
||||||
@@ -232,13 +232,11 @@ http://203.0.113.10:5000
|
|||||||
### Как получить QR для iPhone в GUI
|
### Как получить QR для iPhone в GUI
|
||||||
|
|
||||||
1. Откройте GUI по ссылке из итоговой сводки установки.
|
1. Откройте GUI по ссылке из итоговой сводки установки.
|
||||||
2. Перейдите в раздел клиентов (`Clients`).
|
2. Перейдите в раздел добавления peer.
|
||||||
3. Создайте клиента (`New Client`) или выберите существующего.
|
3. Создайте клиента.
|
||||||
4. Нажмите кнопку показа QR (или `Show QR`) у клиента.
|
4. Используйте показанный QR или скачайте готовый `.conf`.
|
||||||
5. На iPhone: WireGuard → `Add Tunnel` → `Create from QR code` и отсканируйте код.
|
5. На iPhone: WireGuard → `Add Tunnel` → `Create from QR code` и отсканируйте код.
|
||||||
|
|
||||||
В установщике уже задаются дефолты GUI для корректной генерации клиентских конфигов: endpoint, DNS, порт и путь к `wg0.conf`.
|
|
||||||
|
|
||||||
## Взаимодействие клиента с сервером
|
## Взаимодействие клиента с сервером
|
||||||
|
|
||||||
- Клиент генерирует локальные ключи.
|
- Клиент генерирует локальные ключи.
|
||||||
@@ -310,21 +308,10 @@ ls -l /usr/local/sbin/wg-peerctl
|
|||||||
|
|
||||||
4. GUI недоступен:
|
4. GUI недоступен:
|
||||||
```bash
|
```bash
|
||||||
sudo docker ps
|
sudo systemctl status wg-admin-gui --no-pager
|
||||||
sudo docker logs wireguard-ui --tail=100
|
|
||||||
sudo ss -tulpn | grep 5000
|
sudo ss -tulpn | grep 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
Если при запуске встречается ошибка `KeyError: 'ContainerConfig'` (обычно на legacy `docker-compose` v1), перезапустите установщик из актуальной версии репозитория: в нем добавлена автоматическая очистка старого контейнера `wireguard-ui` перед запуском.
|
|
||||||
|
|
||||||
Для уже сломанного состояния можно вручную очистить старые контейнеры и запустить установщик снова:
|
|
||||||
```bash
|
|
||||||
docker ps -aq --filter 'label=com.docker.compose.service=wireguard-ui' | xargs -r docker rm -f
|
|
||||||
docker ps -a --format '{{.Names}}' | grep -E '(^|[_-])wireguard-ui($|[_-])' | xargs -r docker rm -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Если клиенты из GUI создаются в неправильной подсети, просто перезапустите серверный установщик: теперь он автоматически очищает БД GUI и поднимает всё с нуля.
|
|
||||||
|
|
||||||
## Важные пути
|
## Важные пути
|
||||||
|
|
||||||
- Серверный конфиг: `/etc/wireguard/wg0.conf`
|
- Серверный конфиг: `/etc/wireguard/wg0.conf`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SSH_PASSWORD=""
|
|||||||
|
|
||||||
KEEPALIVE="25"
|
KEEPALIVE="25"
|
||||||
CLIENT_ADDRESS_PREFIX="24"
|
CLIENT_ADDRESS_PREFIX="24"
|
||||||
|
FORWARDING_MODE="disabled"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
@@ -303,6 +304,12 @@ build_allowed_ips() {
|
|||||||
build_route_hooks_if_needed() {
|
build_route_hooks_if_needed() {
|
||||||
PRE_UP=""
|
PRE_UP=""
|
||||||
POST_DOWN=""
|
POST_DOWN=""
|
||||||
|
POST_UP_EXTRA_1=""
|
||||||
|
POST_UP_EXTRA_2=""
|
||||||
|
POST_UP_EXTRA_3=""
|
||||||
|
POST_DOWN_EXTRA_1=""
|
||||||
|
POST_DOWN_EXTRA_2=""
|
||||||
|
POST_DOWN_EXTRA_3=""
|
||||||
|
|
||||||
if [[ "$TUNNEL_MODE" != "full" ]]; then
|
if [[ "$TUNNEL_MODE" != "full" ]]; then
|
||||||
return
|
return
|
||||||
@@ -321,6 +328,41 @@ build_route_hooks_if_needed() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detect_iface_for_cidr() {
|
||||||
|
local cidr="$1"
|
||||||
|
ip -4 route show "$cidr" 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="dev") {print $(i+1); exit}}'
|
||||||
|
}
|
||||||
|
|
||||||
|
build_lan_nat_hooks_if_needed() {
|
||||||
|
[[ -n "$ADVERTISE_SUBNETS" ]] || return
|
||||||
|
|
||||||
|
local first_cidr lan_iface wg_net
|
||||||
|
first_cidr="$(echo "$ADVERTISE_SUBNETS" | awk -F',' '{gsub(/ /,"",$1); print $1}')"
|
||||||
|
lan_iface="$(detect_iface_for_cidr "$first_cidr" || true)"
|
||||||
|
[[ -n "$lan_iface" ]] || lan_iface="$(detect_default_iface || true)"
|
||||||
|
[[ -n "$lan_iface" ]] || { log_warn "Не удалось определить LAN-интерфейс для NAT/forwarding."; return; }
|
||||||
|
|
||||||
|
wg_net="${SERVER_WG_NETWORK:-10.66.66.0/24}"
|
||||||
|
|
||||||
|
local fwd="/etc/sysctl.d/99-wireguard-client-forwarding.conf"
|
||||||
|
cat > "$fwd" <<EOF_FWD
|
||||||
|
net.ipv4.ip_forward=1
|
||||||
|
net.ipv4.conf.all.rp_filter=2
|
||||||
|
net.ipv4.conf.default.rp_filter=2
|
||||||
|
EOF_FWD
|
||||||
|
sysctl --system >/dev/null || true
|
||||||
|
|
||||||
|
POST_UP_EXTRA_1="PostUp = iptables -C FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT"
|
||||||
|
POST_UP_EXTRA_2="PostUp = iptables -C FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT"
|
||||||
|
POST_UP_EXTRA_3="PostUp = iptables -t nat -C POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE"
|
||||||
|
|
||||||
|
POST_DOWN_EXTRA_1="PostDown = iptables -D FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT 2>/dev/null || true"
|
||||||
|
POST_DOWN_EXTRA_2="PostDown = iptables -D FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true"
|
||||||
|
POST_DOWN_EXTRA_3="PostDown = iptables -t nat -D POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE 2>/dev/null || true"
|
||||||
|
|
||||||
|
FORWARDING_MODE="enabled via ${lan_iface} (wg:${wg_net})"
|
||||||
|
}
|
||||||
|
|
||||||
build_client_interface_address() {
|
build_client_interface_address() {
|
||||||
local ip_only
|
local ip_only
|
||||||
ip_only="${CLIENT_ADDRESS%%/*}"
|
ip_only="${CLIENT_ADDRESS%%/*}"
|
||||||
@@ -360,6 +402,14 @@ write_client_config() {
|
|||||||
if [[ -n "$POST_DOWN" ]]; then
|
if [[ -n "$POST_DOWN" ]]; then
|
||||||
echo "$POST_DOWN"
|
echo "$POST_DOWN"
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$POST_UP_EXTRA_1" ]]; then
|
||||||
|
echo "$POST_UP_EXTRA_1"
|
||||||
|
echo "$POST_UP_EXTRA_2"
|
||||||
|
echo "$POST_UP_EXTRA_3"
|
||||||
|
echo "$POST_DOWN_EXTRA_1"
|
||||||
|
echo "$POST_DOWN_EXTRA_2"
|
||||||
|
echo "$POST_DOWN_EXTRA_3"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
echo "[Peer]"
|
echo "[Peer]"
|
||||||
echo "PublicKey = ${SERVER_PUBLIC_KEY}"
|
echo "PublicKey = ${SERVER_PUBLIC_KEY}"
|
||||||
@@ -403,6 +453,7 @@ Endpoint сервера: ${SERVER_ENDPOINT}
|
|||||||
SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT}
|
SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT}
|
||||||
Статус регистрации: ${SERVER_STATUS}
|
Статус регистрации: ${SERVER_STATUS}
|
||||||
Сети за клиентом: ${ADVERTISE_SUBNETS:-не объявлены}
|
Сети за клиентом: ${ADVERTISE_SUBNETS:-не объявлены}
|
||||||
|
LAN forwarding/NAT: ${FORWARDING_MODE}
|
||||||
Лог: ${LOG_FILE}
|
Лог: ${LOG_FILE}
|
||||||
=================================================
|
=================================================
|
||||||
EOF_SUMMARY
|
EOF_SUMMARY
|
||||||
@@ -434,6 +485,7 @@ main() {
|
|||||||
build_allowed_ips
|
build_allowed_ips
|
||||||
build_client_interface_address
|
build_client_interface_address
|
||||||
build_route_hooks_if_needed
|
build_route_hooks_if_needed
|
||||||
|
build_lan_nat_hooks_if_needed
|
||||||
write_client_config
|
write_client_config
|
||||||
apply_client_config
|
apply_client_config
|
||||||
print_summary
|
print_summary
|
||||||
|
|||||||
561
gui/app.py
Normal file
561
gui/app.py
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from flask import Flask, redirect, render_template, request, url_for, flash, Response
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("APP_SECRET", "dev-secret")
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/opt/wg-admin-gui/data/wgadmin.db")
|
||||||
|
WG_INTERFACE = os.environ.get("WG_INTERFACE", "wg0")
|
||||||
|
WG_META_FILE = os.environ.get("WG_META_FILE", "/etc/wireguard/wg-meta.env")
|
||||||
|
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
|
||||||
|
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||||
|
ONLINE_WINDOW_SEC = int(os.environ.get("ONLINE_WINDOW_SEC", "120"))
|
||||||
|
|
||||||
|
|
||||||
|
def db_conn():
|
||||||
|
db_dir = os.path.dirname(DB_PATH)
|
||||||
|
if db_dir:
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema():
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS peers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
public_key TEXT UNIQUE NOT NULL,
|
||||||
|
client_address TEXT,
|
||||||
|
advertised_routes TEXT,
|
||||||
|
client_conf TEXT,
|
||||||
|
peer_psk TEXT,
|
||||||
|
peer_allowed_ips TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cols = {row[1] for row in cur.execute("PRAGMA table_info(peers)").fetchall()}
|
||||||
|
if "client_conf" not in cols:
|
||||||
|
cur.execute("ALTER TABLE peers ADD COLUMN client_conf TEXT")
|
||||||
|
if "peer_psk" not in cols:
|
||||||
|
cur.execute("ALTER TABLE peers ADD COLUMN peer_psk TEXT")
|
||||||
|
if "peer_allowed_ips" not in cols:
|
||||||
|
cur.execute("ALTER TABLE peers ADD COLUMN peer_allowed_ips TEXT")
|
||||||
|
if "enabled" not in cols:
|
||||||
|
cur.execute("ALTER TABLE peers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
return subprocess.check_output(cmd, text=True).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def load_meta():
|
||||||
|
meta = {}
|
||||||
|
if not os.path.exists(WG_META_FILE):
|
||||||
|
return meta
|
||||||
|
with open(WG_META_FILE, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
meta[k] = v
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kv(text):
|
||||||
|
out = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wg_conf_peer_meta():
|
||||||
|
path = f"/etc/wireguard/{WG_INTERFACE}.conf"
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
by_pub = {}
|
||||||
|
pending_name = None
|
||||||
|
in_peer = False
|
||||||
|
current_pub = ""
|
||||||
|
current_allowed = ""
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
nonlocal current_pub, current_allowed, pending_name
|
||||||
|
if not current_pub:
|
||||||
|
return
|
||||||
|
first = current_allowed.split(",", 1)[0].strip() if current_allowed else ""
|
||||||
|
routes = ""
|
||||||
|
if "," in current_allowed:
|
||||||
|
routes = current_allowed.split(",", 1)[1].strip()
|
||||||
|
by_pub[current_pub] = {
|
||||||
|
"name": pending_name or "(external)",
|
||||||
|
"client_address": first,
|
||||||
|
"routes": routes,
|
||||||
|
"allowed_ips": current_allowed,
|
||||||
|
}
|
||||||
|
pending_name = None
|
||||||
|
current_pub = ""
|
||||||
|
current_allowed = ""
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for raw in f:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("# managed-by=wg-peerctl"):
|
||||||
|
marker = "client="
|
||||||
|
if marker in line:
|
||||||
|
pending_name = line.split(marker, 1)[1].split()[0].strip()
|
||||||
|
continue
|
||||||
|
if line == "[Peer]":
|
||||||
|
if in_peer:
|
||||||
|
flush()
|
||||||
|
in_peer = True
|
||||||
|
continue
|
||||||
|
if in_peer and line.startswith("PublicKey"):
|
||||||
|
current_pub = line.split("=", 1)[1].strip()
|
||||||
|
continue
|
||||||
|
if in_peer and line.startswith("AllowedIPs"):
|
||||||
|
current_allowed = line.split("=", 1)[1].strip()
|
||||||
|
continue
|
||||||
|
if in_peer:
|
||||||
|
flush()
|
||||||
|
return by_pub
|
||||||
|
|
||||||
|
|
||||||
|
def wg_dump():
|
||||||
|
try:
|
||||||
|
out = run(["wg", "show", WG_INTERFACE, "dump"])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
rows = out.splitlines()
|
||||||
|
peers = []
|
||||||
|
for line in rows[1:]:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 8:
|
||||||
|
continue
|
||||||
|
latest_ts = 0
|
||||||
|
latest = "never"
|
||||||
|
if parts[5].isdigit() and int(parts[5]) > 0:
|
||||||
|
latest_ts = int(parts[5])
|
||||||
|
latest = datetime.utcfromtimestamp(latest_ts).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
peers.append(
|
||||||
|
{
|
||||||
|
"public_key": parts[0],
|
||||||
|
"endpoint": parts[2] or "-",
|
||||||
|
"allowed_ips": parts[3],
|
||||||
|
"latest_handshake": latest,
|
||||||
|
"latest_handshake_ts": latest_ts,
|
||||||
|
"rx_bytes": int(parts[6] or 0),
|
||||||
|
"tx_bytes": int(parts[7] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return peers
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_h(n):
|
||||||
|
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
||||||
|
x = float(n)
|
||||||
|
for u in units:
|
||||||
|
if x < 1024 or u == units[-1]:
|
||||||
|
return f"{x:.1f} {u}" if u != "B" else f"{int(x)} B"
|
||||||
|
x /= 1024
|
||||||
|
|
||||||
|
|
||||||
|
def gen_keypair_psk():
|
||||||
|
priv = run(["wg", "genkey"])
|
||||||
|
pub = subprocess.check_output(["wg", "pubkey"], input=priv, text=True).strip()
|
||||||
|
psk = run(["wg", "genpsk"])
|
||||||
|
return priv, pub, psk
|
||||||
|
|
||||||
|
|
||||||
|
def to_png_b64(text):
|
||||||
|
img = qrcode.make(text)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized():
|
||||||
|
return Response(
|
||||||
|
"Auth required",
|
||||||
|
401,
|
||||||
|
{"WWW-Authenticate": 'Basic realm="WG Admin"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _auth():
|
||||||
|
if request.path.startswith("/static/"):
|
||||||
|
return None
|
||||||
|
if not ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
auth = request.authorization
|
||||||
|
if not auth:
|
||||||
|
return _unauthorized()
|
||||||
|
if auth.username != ADMIN_USER or auth.password != ADMIN_PASSWORD:
|
||||||
|
return _unauthorized()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _schema():
|
||||||
|
ensure_schema()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
meta = load_meta()
|
||||||
|
runtime = {p["public_key"]: p for p in wg_dump()}
|
||||||
|
conf_meta = parse_wg_conf_peer_meta()
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM peers ORDER BY id DESC")
|
||||||
|
db_peers = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
seen = set()
|
||||||
|
now = int(time.time())
|
||||||
|
for row in db_peers:
|
||||||
|
rt = runtime.get(row["public_key"], {})
|
||||||
|
ts = int(rt.get("latest_handshake_ts", 0) or 0)
|
||||||
|
is_online = ts > 0 and (now - ts) <= ONLINE_WINDOW_SEC
|
||||||
|
seen.add(row["public_key"])
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"public_key": row["public_key"],
|
||||||
|
"client_address": row.get("client_address") or "-",
|
||||||
|
"routes": row.get("advertised_routes") or "-",
|
||||||
|
"allowed_ips": rt.get("allowed_ips", "-"),
|
||||||
|
"endpoint": rt.get("endpoint", "-"),
|
||||||
|
"latest_handshake": rt.get("latest_handshake", "offline"),
|
||||||
|
"rx": bytes_h(rt.get("rx_bytes", 0)),
|
||||||
|
"tx": bytes_h(rt.get("tx_bytes", 0)),
|
||||||
|
"status": "online" if is_online else "offline",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for pk, rt in runtime.items():
|
||||||
|
if pk in seen:
|
||||||
|
continue
|
||||||
|
cm = conf_meta.get(pk, {})
|
||||||
|
imported_name = cm.get("name", "(external)")
|
||||||
|
imported_addr = cm.get("client_address", rt.get("allowed_ips", "-").split(",", 1)[0])
|
||||||
|
imported_routes = cm.get("routes", "-") or "-"
|
||||||
|
|
||||||
|
ext_id = None
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT OR IGNORE INTO peers(name, public_key, client_address, advertised_routes, enabled) VALUES (?,?,?,?,1)",
|
||||||
|
(imported_name, pk, imported_addr, imported_routes if imported_routes != "-" else ""),
|
||||||
|
)
|
||||||
|
cur.execute("SELECT id FROM peers WHERE public_key = ?", (pk,))
|
||||||
|
got = cur.fetchone()
|
||||||
|
if got:
|
||||||
|
ext_id = got["id"]
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
ts = int(rt.get("latest_handshake_ts", 0) or 0)
|
||||||
|
is_online = ts > 0 and (now - ts) <= ONLINE_WINDOW_SEC
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": ext_id,
|
||||||
|
"name": imported_name,
|
||||||
|
"public_key": pk,
|
||||||
|
"client_address": imported_addr,
|
||||||
|
"routes": imported_routes,
|
||||||
|
"allowed_ips": rt.get("allowed_ips", "-"),
|
||||||
|
"endpoint": rt.get("endpoint", "-"),
|
||||||
|
"latest_handshake": rt.get("latest_handshake", "offline"),
|
||||||
|
"rx": bytes_h(rt.get("rx_bytes", 0)),
|
||||||
|
"tx": bytes_h(rt.get("tx_bytes", 0)),
|
||||||
|
"status": "online" if is_online else "offline",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("index.html", peers=items, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/peers/new", methods=["GET", "POST"])
|
||||||
|
def new_peer():
|
||||||
|
meta = load_meta()
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("new_peer.html", meta=meta)
|
||||||
|
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
mode = request.form.get("mode", "full").strip()
|
||||||
|
allowed_ips = request.form.get("allowed_ips", "").strip()
|
||||||
|
routes = request.form.get("routes", "").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash("Укажите имя клиента", "error")
|
||||||
|
return redirect(url_for("new_peer"))
|
||||||
|
|
||||||
|
if mode == "full":
|
||||||
|
allowed_ips = "0.0.0.0/0,::/0"
|
||||||
|
elif not allowed_ips:
|
||||||
|
allowed_ips = meta.get("WG_NETWORK", "10.66.66.0/24")
|
||||||
|
|
||||||
|
client_priv, client_pub, client_psk = gen_keypair_psk()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"/usr/local/sbin/wg-peerctl",
|
||||||
|
"add",
|
||||||
|
"--client-name",
|
||||||
|
name,
|
||||||
|
"--client-public-key",
|
||||||
|
client_pub,
|
||||||
|
"--client-preshared-key",
|
||||||
|
client_psk,
|
||||||
|
"--persistent-keepalive",
|
||||||
|
"25",
|
||||||
|
]
|
||||||
|
if routes:
|
||||||
|
cmd += ["--client-routes", routes]
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = parse_kv(run(cmd))
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
flash(f"Не удалось добавить peer: {e}", "error")
|
||||||
|
return redirect(url_for("new_peer"))
|
||||||
|
|
||||||
|
client_addr = resp.get("CLIENT_ADDRESS", "")
|
||||||
|
server_pub = resp.get("SERVER_PUBLIC_KEY", "")
|
||||||
|
endpoint = resp.get("SERVER_ENDPOINT", "")
|
||||||
|
dns = resp.get("SERVER_DNS", "1.1.1.1")
|
||||||
|
|
||||||
|
conf_lines = [
|
||||||
|
"[Interface]",
|
||||||
|
f"PrivateKey = {client_priv}",
|
||||||
|
f"Address = {client_addr}",
|
||||||
|
f"DNS = {dns}",
|
||||||
|
"",
|
||||||
|
"[Peer]",
|
||||||
|
f"PublicKey = {server_pub}",
|
||||||
|
f"PresharedKey = {client_psk}",
|
||||||
|
f"Endpoint = {endpoint}",
|
||||||
|
f"AllowedIPs = {allowed_ips}",
|
||||||
|
"PersistentKeepalive = 25",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
client_conf = "\n".join(conf_lines)
|
||||||
|
qr_b64 = to_png_b64(client_conf)
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE peers SET name=?, client_address=?, advertised_routes=?, client_conf=?, peer_psk=?, peer_allowed_ips=?, enabled=1 WHERE public_key=?",
|
||||||
|
(name, client_addr, routes, client_conf, client_psk, client_addr + (("," + routes) if routes else ""), client_pub),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO peers(name, public_key, client_address, advertised_routes, client_conf, peer_psk, peer_allowed_ips, enabled) VALUES (?,?,?,?,?,?,?,1)",
|
||||||
|
(name, client_pub, client_addr, routes, client_conf, client_psk, client_addr + (("," + routes) if routes else "")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"peer_created.html",
|
||||||
|
name=name,
|
||||||
|
client_conf=client_conf,
|
||||||
|
qr_b64=qr_b64,
|
||||||
|
public_key=client_pub,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/peers/<int:peer_id>")
|
||||||
|
def peer_view(peer_id: int):
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
flash("Клиент не найден", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
item = dict(row)
|
||||||
|
|
||||||
|
conf = item.get("client_conf") or ""
|
||||||
|
if not conf:
|
||||||
|
flash("Для этого клиента не найден сохраненный конфиг", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
qr_b64 = to_png_b64(conf)
|
||||||
|
return render_template(
|
||||||
|
"peer_created.html",
|
||||||
|
name=item.get("name", "peer"),
|
||||||
|
client_conf=conf,
|
||||||
|
qr_b64=qr_b64,
|
||||||
|
public_key=item.get("public_key", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/peers/<int:peer_id>/disable")
|
||||||
|
def peer_disable(peer_id: int):
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
flash("Клиент не найден", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
item = dict(row)
|
||||||
|
|
||||||
|
pk = item.get("public_key", "")
|
||||||
|
if not pk:
|
||||||
|
flash("Не найден public key", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
flash(f"Не удалось отключить peer: {e}", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE peers SET enabled=0 WHERE id = ?", (peer_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Peer отключен", "ok")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/peers/<int:peer_id>/enable")
|
||||||
|
def peer_enable(peer_id: int):
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
flash("Клиент не найден", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
item = dict(row)
|
||||||
|
|
||||||
|
name = item.get("name", "")
|
||||||
|
pk = item.get("public_key", "")
|
||||||
|
addr = item.get("client_address", "")
|
||||||
|
routes = item.get("advertised_routes", "") or ""
|
||||||
|
psk = item.get("peer_psk", "") or ""
|
||||||
|
if not (name and pk and addr and psk):
|
||||||
|
flash("Недостаточно данных для включения peer (нужны name/public key/address/psk)", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"/usr/local/sbin/wg-peerctl",
|
||||||
|
"add",
|
||||||
|
"--client-name",
|
||||||
|
name,
|
||||||
|
"--client-public-key",
|
||||||
|
pk,
|
||||||
|
"--client-address",
|
||||||
|
addr,
|
||||||
|
"--client-preshared-key",
|
||||||
|
psk,
|
||||||
|
"--persistent-keepalive",
|
||||||
|
"25",
|
||||||
|
]
|
||||||
|
if routes:
|
||||||
|
cmd += ["--client-routes", routes]
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(cmd)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
flash(f"Не удалось включить peer: {e}", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE peers SET enabled=1 WHERE id = ?", (peer_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Peer включен", "ok")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/peers/<int:peer_id>/delete")
|
||||||
|
def peer_delete(peer_id: int):
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
flash("Клиент не найден", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
item = dict(row)
|
||||||
|
|
||||||
|
pk = item.get("public_key", "")
|
||||||
|
if pk:
|
||||||
|
try:
|
||||||
|
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM peers WHERE id = ?", (peer_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Peer удален", "ok")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/peers/delete-by-key")
|
||||||
|
def peer_delete_by_key():
|
||||||
|
pk = (request.form.get("public_key") or "").strip()
|
||||||
|
if not pk:
|
||||||
|
flash("Не найден public key", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with db_conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM peers WHERE public_key = ?", (pk,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
flash("Peer удален", "ok")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/scripts")
|
||||||
|
def scripts():
|
||||||
|
commands = {
|
||||||
|
"server_install": "tmp=\"$(mktemp -d)\" && curl -fL \"https://git.ruslan.xyz/ruslan/Wireguard_server/archive/main.tar.gz\" -o \"$tmp/repo.tar.gz\" && tar -xzf \"$tmp/repo.tar.gz\" -C \"$tmp\" && bash \"$tmp/wireguard_server/server/install_server.sh\"",
|
||||||
|
"client_install": "tmp=\"$(mktemp -d)\" && curl -fL \"https://git.ruslan.xyz/ruslan/Wireguard_server/archive/main.tar.gz\" -o \"$tmp/repo.tar.gz\" && tar -xzf \"$tmp/repo.tar.gz\" -C \"$tmp\" && bash \"$tmp/wireguard_server/client/install_client.sh\"",
|
||||||
|
"apply_wg": "wg syncconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)",
|
||||||
|
}
|
||||||
|
paths = [
|
||||||
|
"/usr/local/sbin/wg-peerctl",
|
||||||
|
"/etc/wireguard/wg0.conf",
|
||||||
|
"/etc/wireguard/wg-meta.env",
|
||||||
|
"/var/log/wireguard-server-install.log",
|
||||||
|
"/var/log/wireguard-client-install.log",
|
||||||
|
"/var/log/wireguard-peerctl.log",
|
||||||
|
]
|
||||||
|
return render_template("scripts.html", commands=commands, paths=paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=int(os.environ.get("APP_PORT", "5080")), debug=False)
|
||||||
3
gui/requirements.txt
Normal file
3
gui/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
qrcode==7.4.2
|
||||||
|
gunicorn==23.0.0
|
||||||
28
gui/static/style.css
Normal file
28
gui/static/style.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f3f8f4;
|
||||||
|
--fg: #12261a;
|
||||||
|
--brand: #145a32;
|
||||||
|
--muted: #5c7463;
|
||||||
|
--card: #ffffff;
|
||||||
|
}
|
||||||
|
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: radial-gradient(circle at 10% 10%, #d8ead9, var(--bg)); color: var(--fg); }
|
||||||
|
.top { display: flex; justify-content: space-between; align-items: center; padding: 16px 22px; background: #e7f4e9; border-bottom: 1px solid #c7decb; }
|
||||||
|
.top h1 { margin: 0; font-size: 24px; }
|
||||||
|
.top nav a { margin-right: 14px; color: var(--brand); text-decoration: none; font-weight: 600; }
|
||||||
|
main { padding: 22px; }
|
||||||
|
.card { background: var(--card); padding: 16px; border-radius: 12px; border: 1px solid #d7e7da; display: grid; gap: 10px; max-width: 640px; }
|
||||||
|
label { display: grid; gap: 6px; }
|
||||||
|
input, select, button { padding: 10px; border-radius: 10px; border: 1px solid #bad2bf; font-size: 14px; }
|
||||||
|
button { background: var(--brand); color: #fff; border: 0; font-weight: 700; cursor: pointer; }
|
||||||
|
button.danger { background: #b42318; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #d7e7da; }
|
||||||
|
th, td { border-bottom: 1px solid #e0ece2; padding: 8px; font-size: 13px; text-align: left; vertical-align: top; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
|
||||||
|
.badge.online { background: #d8f0df; color: #115f33; }
|
||||||
|
.badge.offline { background: #f2e7e7; color: #8a2e2e; }
|
||||||
|
pre { background: #0e1b12; color: #c8f6d8; padding: 10px; border-radius: 10px; overflow: auto; }
|
||||||
|
.alert { padding: 10px; border-radius: 8px; margin-bottom: 10px; }
|
||||||
|
.alert.error { background: #ffe0e0; color: #8a2020; }
|
||||||
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
|
||||||
29
gui/templates/base.html
Normal file
29
gui/templates/base.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WG Admin</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<h1>WG Admin</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Клиенты</a>
|
||||||
|
<a href="{{ url_for('new_peer') }}">Добавить peer</a>
|
||||||
|
<a href="{{ url_for('scripts') }}">Скрипты</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert {{category}}">{{message}}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
gui/templates/index.html
Normal file
41
gui/templates/index.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Клиенты</h2>
|
||||||
|
<p>Интерфейс: <b>{{ meta.get('WG_INTERFACE','wg0') }}</b> | Сеть: <b>{{ meta.get('WG_NETWORK','-') }}</b> | Endpoint: <b>{{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}</b></p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th><th>Статус</th><th>IP</th><th>Роуты</th><th>AllowedIPs</th><th>Endpoint</th><th>Handshake</th><th>RX</th><th>TX</th><th>PubKey</th><th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in peers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td><span class="badge {{ p.status }}">{{ p.status }}</span></td>
|
||||||
|
<td>{{ p.client_address }}</td>
|
||||||
|
<td>{{ p.routes }}</td>
|
||||||
|
<td>{{ p.allowed_ips }}</td>
|
||||||
|
<td>{{ p.endpoint }}</td>
|
||||||
|
<td>{{ p.latest_handshake }}</td>
|
||||||
|
<td>{{ p.rx }}</td>
|
||||||
|
<td>{{ p.tx }}</td>
|
||||||
|
<td class="mono">{{ p.public_key }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.id %}
|
||||||
|
<a href="{{ url_for('peer_view', peer_id=p.id) }}">QR/Config</a>
|
||||||
|
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
|
||||||
|
<button type="submit" class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
|
||||||
|
<input type="hidden" name="public_key" value="{{ p.public_key }}" />
|
||||||
|
<button type="submit" class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
46
gui/templates/new_peer.html
Normal file
46
gui/templates/new_peer.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Новый peer</h2>
|
||||||
|
<form method="post" class="card">
|
||||||
|
<label>Имя клиента
|
||||||
|
<input name="name" required placeholder="astra" />
|
||||||
|
</label>
|
||||||
|
<label>Режим
|
||||||
|
<select name="mode" id="mode">
|
||||||
|
<option value="full">full (весь трафик через VPN)</option>
|
||||||
|
<option value="split">split (только выбранные сети)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>AllowedIPs (для split)
|
||||||
|
<input
|
||||||
|
id="allowed_ips"
|
||||||
|
name="allowed_ips"
|
||||||
|
placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
|
||||||
|
data-default="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>Сети за клиентом (роуты)
|
||||||
|
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Создать</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const mode = document.getElementById("mode");
|
||||||
|
const allowed = document.getElementById("allowed_ips");
|
||||||
|
const def = allowed.dataset.default || "10.66.66.0/24";
|
||||||
|
|
||||||
|
function syncAllowed() {
|
||||||
|
if (mode.value === "split") {
|
||||||
|
if (!allowed.value.trim()) allowed.value = def;
|
||||||
|
allowed.readOnly = false;
|
||||||
|
} else {
|
||||||
|
allowed.readOnly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mode.addEventListener("change", syncAllowed);
|
||||||
|
syncAllowed();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
16
gui/templates/peer_created.html
Normal file
16
gui/templates/peer_created.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Peer создан: {{ name }}</h2>
|
||||||
|
<p>PublicKey: <span class="mono">{{ public_key }}</span></p>
|
||||||
|
<p><a href="data:text/plain;charset=utf-8,{{ client_conf | urlencode }}" download="{{ name }}.conf">Скачать {{ name }}.conf</a></p>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<h3>QR</h3>
|
||||||
|
<img alt="QR" src="data:image/png;base64,{{ qr_b64 }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Client config</h3>
|
||||||
|
<pre>{{ client_conf }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
15
gui/templates/scripts.html
Normal file
15
gui/templates/scripts.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Скрипты и команды</h2>
|
||||||
|
<h3>Команды</h3>
|
||||||
|
{% for k, v in commands.items() %}
|
||||||
|
<p><b>{{ k }}</b></p>
|
||||||
|
<pre>{{ v }}</pre>
|
||||||
|
{% endfor %}
|
||||||
|
<h3>Важные пути</h3>
|
||||||
|
<ul>
|
||||||
|
{% for p in paths %}
|
||||||
|
<li><code>{{ p }}</code></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -23,12 +23,11 @@ GUI_PORT="5000"
|
|||||||
GUI_USER="admin"
|
GUI_USER="admin"
|
||||||
GUI_PASSWORD=""
|
GUI_PASSWORD=""
|
||||||
GUI_PASSWORD_GENERATED=0
|
GUI_PASSWORD_GENERATED=0
|
||||||
GUI_SESSION_SECRET=""
|
|
||||||
GUI_RESET_DB="no"
|
GUI_RESET_DB="no"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Установка WireGuard-сервера и GUI (Debian/Ubuntu).
|
Установка WireGuard-сервера и встроенного WG Admin GUI (Debian/Ubuntu).
|
||||||
Каждый запуск выполняет полный reset прошлой инсталляции и поднимает все с нуля.
|
Каждый запуск выполняет полный reset прошлой инсталляции и поднимает все с нуля.
|
||||||
|
|
||||||
Использование:
|
Использование:
|
||||||
@@ -44,12 +43,12 @@ usage() {
|
|||||||
--server-dns <ip> DNS для клиентов (по умолчанию: 1.1.1.1)
|
--server-dns <ip> DNS для клиентов (по умолчанию: 1.1.1.1)
|
||||||
--default-iface <iface> Внешний интерфейс для NAT
|
--default-iface <iface> Внешний интерфейс для NAT
|
||||||
|
|
||||||
--gui-enable <yes|no> Включить GUI wireguard-ui (по умолчанию: yes)
|
--gui-enable <yes|no> Включить WG Admin GUI (по умолчанию: yes)
|
||||||
--gui-host <host> Домен/IP для открытия GUI
|
--gui-host <host> Домен/IP для открытия GUI
|
||||||
--gui-port <port> Порт GUI (по умолчанию: 5000)
|
--gui-port <port> Порт GUI (по умолчанию: 5000)
|
||||||
--gui-user <user> Логин GUI (по умолчанию: admin)
|
--gui-user <user> Логин GUI (по умолчанию: admin)
|
||||||
--gui-password <pass> Пароль GUI (если не указан, будет запрос)
|
--gui-password <pass> Пароль GUI (если не указан, будет сгенерирован)
|
||||||
--gui-reset-db <yes|no> Устарело: теперь reset GUI выполняется автоматически
|
--gui-reset-db <yes|no> Устарело: reset БД теперь выполняется автоматически
|
||||||
|
|
||||||
-h, --help Показать помощь
|
-h, --help Показать помощь
|
||||||
USAGE
|
USAGE
|
||||||
@@ -135,16 +134,20 @@ reset_existing_install() {
|
|||||||
log_info "Очищены конфиги/ключи WireGuard в /etc/wireguard"
|
log_info "Очищены конфиги/ключи WireGuard в /etc/wireguard"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
systemctl disable --now wg-admin-gui.service >/dev/null 2>&1 || true
|
||||||
|
rm -f /etc/systemd/system/wg-admin-gui.service
|
||||||
|
|
||||||
if command -v docker >/dev/null 2>&1; then
|
if command -v docker >/dev/null 2>&1; then
|
||||||
if [[ -f /opt/wireguard-ui/docker-compose.yml ]]; then
|
if [[ -f /opt/wireguard-ui/docker-compose.yml ]]; then
|
||||||
(cd /opt/wireguard-ui && docker compose down --remove-orphans >/dev/null 2>&1) || true
|
(cd /opt/wireguard-ui && docker compose down --remove-orphans >/dev/null 2>&1) || true
|
||||||
(cd /opt/wireguard-ui && docker-compose down --remove-orphans >/dev/null 2>&1) || true
|
(cd /opt/wireguard-ui && docker-compose down --remove-orphans >/dev/null 2>&1) || true
|
||||||
fi
|
fi
|
||||||
docker rm -f wireguard-ui >/dev/null 2>&1 || true
|
|
||||||
|
docker rm -f wireguard-ui wg-admin-postgres >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -rf /opt/wireguard-ui/db/* /opt/wireguard-ui/data/* /opt/wireguard-ui/docker-compose.yml
|
rm -rf /opt/wireguard-ui /opt/wg-admin-gui
|
||||||
log_info "Очищено состояние GUI в /opt/wireguard-ui"
|
log_info "Очищено состояние GUI"
|
||||||
}
|
}
|
||||||
|
|
||||||
collect_inputs() {
|
collect_inputs() {
|
||||||
@@ -174,9 +177,9 @@ collect_inputs() {
|
|||||||
|
|
||||||
if [[ "$GUI_ENABLE" == "yes" ]]; then
|
if [[ "$GUI_ENABLE" == "yes" ]]; then
|
||||||
if [[ -z "$GUI_PASSWORD" ]]; then
|
if [[ -z "$GUI_PASSWORD" ]]; then
|
||||||
GUI_PASSWORD="$(random_alnum 8)"
|
GUI_PASSWORD="$(random_alnum 10)"
|
||||||
GUI_PASSWORD_GENERATED=1
|
GUI_PASSWORD_GENERATED=1
|
||||||
log_warn "Пароль GUI не задан. Сгенерирован пароль (8 символов): ${GUI_PASSWORD}"
|
log_warn "Пароль GUI не задан. Сгенерирован пароль: ${GUI_PASSWORD}"
|
||||||
|
|
||||||
if (( ! NON_INTERACTIVE )); then
|
if (( ! NON_INTERACTIVE )); then
|
||||||
local replace_or_password=""
|
local replace_or_password=""
|
||||||
@@ -187,8 +190,6 @@ collect_inputs() {
|
|||||||
if [[ -n "$custom_gui_password" ]]; then
|
if [[ -n "$custom_gui_password" ]]; then
|
||||||
GUI_PASSWORD="$custom_gui_password"
|
GUI_PASSWORD="$custom_gui_password"
|
||||||
GUI_PASSWORD_GENERATED=0
|
GUI_PASSWORD_GENERATED=0
|
||||||
else
|
|
||||||
log_warn "Пустой пароль не принят. Остается сгенерированный пароль."
|
|
||||||
fi
|
fi
|
||||||
elif [[ -n "$replace_or_password" && ! "$replace_or_password" =~ ^([nN][oO]?|[nN])$ ]]; then
|
elif [[ -n "$replace_or_password" && ! "$replace_or_password" =~ ^([nN][oO]?|[nN])$ ]]; then
|
||||||
GUI_PASSWORD="$replace_or_password"
|
GUI_PASSWORD="$replace_or_password"
|
||||||
@@ -196,11 +197,7 @@ collect_inputs() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
GUI_SESSION_SECRET="$(random_alnum 32)"
|
|
||||||
[[ "$GUI_RESET_DB" == "yes" || "$GUI_RESET_DB" == "no" ]] || die "--gui-reset-db должен быть yes или no"
|
[[ "$GUI_RESET_DB" == "yes" || "$GUI_RESET_DB" == "no" ]] || die "--gui-reset-db должен быть yes или no"
|
||||||
if [[ "$GUI_RESET_DB" == "yes" ]]; then
|
|
||||||
log_warn "--gui-reset-db устарел: очистка GUI теперь выполняется автоматически на каждом запуске."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
validate_inputs
|
validate_inputs
|
||||||
@@ -209,15 +206,7 @@ collect_inputs() {
|
|||||||
install_packages() {
|
install_packages() {
|
||||||
apt_install_if_missing \
|
apt_install_if_missing \
|
||||||
wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \
|
wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \
|
||||||
qrencode docker.io
|
python3 python3-venv python3-pip
|
||||||
|
|
||||||
if apt-cache show docker-compose-plugin >/dev/null 2>&1; then
|
|
||||||
apt_install_if_missing docker-compose-plugin
|
|
||||||
elif apt-cache show docker-compose >/dev/null 2>&1; then
|
|
||||||
apt_install_if_missing docker-compose
|
|
||||||
else
|
|
||||||
log_warn "Не найден пакет docker-compose-plugin/docker-compose. Проверьте репозитории APT."
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_sysctl() {
|
setup_sysctl() {
|
||||||
@@ -237,19 +226,12 @@ setup_keys() {
|
|||||||
local priv="/etc/wireguard/server_private.key"
|
local priv="/etc/wireguard/server_private.key"
|
||||||
local pub="/etc/wireguard/server_public.key"
|
local pub="/etc/wireguard/server_public.key"
|
||||||
|
|
||||||
if [[ ! -f "$priv" ]]; then
|
umask 077
|
||||||
umask 077
|
wg genkey | tee "$priv" | wg pubkey > "$pub"
|
||||||
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 "$priv"
|
||||||
safe_chmod_600 "$pub"
|
safe_chmod_600 "$pub"
|
||||||
|
log_success "Сгенерированы ключи сервера"
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_wg_config() {
|
setup_wg_config() {
|
||||||
@@ -363,75 +345,49 @@ EOF_SYNC_PATH
|
|||||||
setup_gui() {
|
setup_gui() {
|
||||||
[[ "$GUI_ENABLE" == "yes" ]] || { log_warn "GUI отключен (GUI_ENABLE=no)"; return; }
|
[[ "$GUI_ENABLE" == "yes" ]] || { log_warn "GUI отключен (GUI_ENABLE=no)"; return; }
|
||||||
|
|
||||||
mkdir -p /opt/wireguard-ui/{db,data}
|
mkdir -p /opt/wg-admin-gui/{app,data}
|
||||||
safe_chmod_700 /opt/wireguard-ui
|
safe_chmod_700 /opt/wg-admin-gui
|
||||||
|
|
||||||
cat > /opt/wireguard-ui/docker-compose.yml <<EOF_COMPOSE
|
cp -a "${PROJECT_ROOT}/gui/." /opt/wg-admin-gui/app/
|
||||||
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_ENDPOINT_ADDRESS=${SERVER_PUBLIC_IP}:${WG_PORT}
|
|
||||||
- WGUI_DNS=${SERVER_DNS}
|
|
||||||
- WGUI_CONFIG_FILE_PATH=/etc/wireguard/${WG_INTERFACE}.conf
|
|
||||||
- WGUI_SERVER_INTERFACE_ADDRESSES=${WG_ADDRESS}
|
|
||||||
- WGUI_SERVER_LISTEN_PORT=${WG_PORT}
|
|
||||||
- WGUI_DEFAULT_CLIENT_ALLOWED_IPS=0.0.0.0/0
|
|
||||||
- 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
|
python3 -m venv /opt/wg-admin-gui/venv
|
||||||
|
/opt/wg-admin-gui/venv/bin/pip install --upgrade pip >/dev/null
|
||||||
|
/opt/wg-admin-gui/venv/bin/pip install -r /opt/wg-admin-gui/app/requirements.txt >/dev/null
|
||||||
|
|
||||||
local compose_cmd=()
|
cat > /opt/wg-admin-gui/wg-admin-gui.env <<EOF_ENV
|
||||||
local compose_mode=""
|
DB_PATH=/opt/wg-admin-gui/data/wgadmin.db
|
||||||
if docker compose version >/dev/null 2>&1; then
|
WG_INTERFACE=${WG_INTERFACE}
|
||||||
compose_cmd=(docker compose)
|
WG_META_FILE=/etc/wireguard/wg-meta.env
|
||||||
compose_mode="plugin"
|
ADMIN_USER=${GUI_USER}
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
ADMIN_PASSWORD=${GUI_PASSWORD}
|
||||||
compose_cmd=(docker-compose)
|
APP_SECRET=$(random_alnum 32)
|
||||||
compose_mode="legacy"
|
APP_PORT=${GUI_PORT}
|
||||||
else
|
EOF_ENV
|
||||||
die "Не найден docker compose. Установите docker-compose-plugin или docker-compose."
|
chmod 600 /opt/wg-admin-gui/wg-admin-gui.env
|
||||||
fi
|
|
||||||
|
|
||||||
# На некоторых системах с legacy docker-compose (v1) при recreate может возникать
|
cat > /etc/systemd/system/wg-admin-gui.service <<EOF_SERVICE
|
||||||
# KeyError: 'ContainerConfig'. Предварительно удаляем старый контейнер по имени.
|
[Unit]
|
||||||
if [[ "$compose_mode" == "legacy" ]]; then
|
Description=WG Admin GUI
|
||||||
docker rm -f wireguard-ui >/dev/null 2>&1 || true
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
# Удаляем возможные старые контейнеры вида <project>_wireguard-ui
|
[Service]
|
||||||
# и контейнеры сервиса wireguard-ui по compose-label.
|
Type=simple
|
||||||
local legacy_ids legacy_names
|
WorkingDirectory=/opt/wg-admin-gui/app
|
||||||
legacy_ids="$(docker ps -aq --filter 'label=com.docker.compose.service=wireguard-ui' || true)"
|
EnvironmentFile=/opt/wg-admin-gui/wg-admin-gui.env
|
||||||
if [[ -n "$legacy_ids" ]]; then
|
ExecStart=/opt/wg-admin-gui/venv/bin/gunicorn -w 2 -b 0.0.0.0:${GUI_PORT} app:app
|
||||||
docker rm -f $legacy_ids >/dev/null 2>&1 || true
|
Restart=always
|
||||||
fi
|
RestartSec=2
|
||||||
|
User=root
|
||||||
|
|
||||||
legacy_names="$(docker ps -a --format '{{.Names}}' | grep -E '(^|[_-])wireguard-ui($|[_-])' || true)"
|
[Install]
|
||||||
if [[ -n "$legacy_names" ]]; then
|
WantedBy=multi-user.target
|
||||||
while IFS= read -r cname; do
|
EOF_SERVICE
|
||||||
[[ -n "$cname" ]] || continue
|
|
||||||
docker rm -f "$cname" >/dev/null 2>&1 || true
|
|
||||||
done <<< "$legacy_names"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
(cd /opt/wireguard-ui && "${compose_cmd[@]}" up -d --remove-orphans)
|
systemctl daemon-reload
|
||||||
log_success "GUI wireguard-ui запущен"
|
systemctl enable --now wg-admin-gui.service
|
||||||
|
|
||||||
|
log_success "WG Admin GUI запущен"
|
||||||
}
|
}
|
||||||
|
|
||||||
print_summary() {
|
print_summary() {
|
||||||
@@ -440,8 +396,7 @@ print_summary() {
|
|||||||
gui_status="disabled"
|
gui_status="disabled"
|
||||||
|
|
||||||
if [[ "$GUI_ENABLE" == "yes" ]]; then
|
if [[ "$GUI_ENABLE" == "yes" ]]; then
|
||||||
gui_status="$(docker ps --filter name=wireguard-ui --format '{{.Status}}' || true)"
|
gui_status="$(systemctl is-active wg-admin-gui.service 2>/dev/null || true)"
|
||||||
[[ -n "$gui_status" ]] || gui_status="not running"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<EOF_SUMMARY
|
cat <<EOF_SUMMARY
|
||||||
@@ -466,10 +421,6 @@ Auto-apply GUI->WG: enabled (wg-syncconf@${WG_INTERFACE}.path)
|
|||||||
Лог установки: ${LOG_FILE}
|
Лог установки: ${LOG_FILE}
|
||||||
=================================================
|
=================================================
|
||||||
EOF_SUMMARY
|
EOF_SUMMARY
|
||||||
|
|
||||||
if [[ "$GUI_ENABLE" == "yes" ]]; then
|
|
||||||
echo "Ссылка для входа в GUI: http://${GUI_HOST}:${GUI_PORT}"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
@@ -480,6 +431,7 @@ main() {
|
|||||||
require_cmd ip
|
require_cmd ip
|
||||||
require_cmd awk
|
require_cmd awk
|
||||||
require_cmd sed
|
require_cmd sed
|
||||||
|
require_cmd python3
|
||||||
|
|
||||||
collect_inputs
|
collect_inputs
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ fi
|
|||||||
|
|
||||||
LOG_FILE="/var/log/wireguard-peerctl.log"
|
LOG_FILE="/var/log/wireguard-peerctl.log"
|
||||||
WG_META_FILE="/etc/wireguard/wg-meta.env"
|
WG_META_FILE="/etc/wireguard/wg-meta.env"
|
||||||
|
GUI_DB_FILE="/opt/wg-admin-gui/data/wgadmin.db"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
@@ -27,12 +28,89 @@ usage() {
|
|||||||
[--client-preshared-key <psk>] \
|
[--client-preshared-key <psk>] \
|
||||||
[--persistent-keepalive 25]
|
[--persistent-keepalive 25]
|
||||||
|
|
||||||
|
wg-peerctl.sh remove \
|
||||||
|
--client-public-key <pubkey>
|
||||||
|
|
||||||
Описание:
|
Описание:
|
||||||
Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно.
|
Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно.
|
||||||
Если peer с таким public key уже существует, повторно не добавляет.
|
Если peer с таким public key уже существует, повторно не добавляет.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sql_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\'/\'\'}"
|
||||||
|
printf "%s" "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_gui_db_schema() {
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 || return 0
|
||||||
|
[[ -f "$GUI_DB_FILE" ]] || return 0
|
||||||
|
sqlite3 "$GUI_DB_FILE" <<'SQL' >/dev/null 2>&1 || true
|
||||||
|
CREATE TABLE IF NOT EXISTS peers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
public_key TEXT UNIQUE NOT NULL,
|
||||||
|
client_address TEXT,
|
||||||
|
advertised_routes TEXT,
|
||||||
|
client_conf TEXT,
|
||||||
|
peer_psk TEXT,
|
||||||
|
peer_allowed_ips TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
ALTER TABLE peers ADD COLUMN client_conf TEXT;
|
||||||
|
ALTER TABLE peers ADD COLUMN peer_psk TEXT;
|
||||||
|
ALTER TABLE peers ADD COLUMN peer_allowed_ips TEXT;
|
||||||
|
ALTER TABLE peers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_gui_db_upsert_peer() {
|
||||||
|
local name="$1"
|
||||||
|
local pubkey="$2"
|
||||||
|
local address="$3"
|
||||||
|
local routes="$4"
|
||||||
|
local psk="$5"
|
||||||
|
local peer_allowed_ips="$6"
|
||||||
|
local enabled="${7:-1}"
|
||||||
|
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 || return 0
|
||||||
|
[[ -f "$GUI_DB_FILE" ]] || return 0
|
||||||
|
ensure_gui_db_schema
|
||||||
|
|
||||||
|
local e_name e_pub e_addr e_routes e_psk e_allowed
|
||||||
|
e_name="$(sql_escape "$name")"
|
||||||
|
e_pub="$(sql_escape "$pubkey")"
|
||||||
|
e_addr="$(sql_escape "$address")"
|
||||||
|
e_routes="$(sql_escape "$routes")"
|
||||||
|
e_psk="$(sql_escape "$psk")"
|
||||||
|
e_allowed="$(sql_escape "$peer_allowed_ips")"
|
||||||
|
|
||||||
|
sqlite3 "$GUI_DB_FILE" <<SQL >/dev/null 2>&1 || true
|
||||||
|
INSERT INTO peers(name, public_key, client_address, advertised_routes, peer_psk, peer_allowed_ips, enabled)
|
||||||
|
VALUES ('$e_name', '$e_pub', '$e_addr', '$e_routes', '$e_psk', '$e_allowed', $enabled)
|
||||||
|
ON CONFLICT(public_key)
|
||||||
|
DO UPDATE SET
|
||||||
|
name=excluded.name,
|
||||||
|
client_address=excluded.client_address,
|
||||||
|
advertised_routes=excluded.advertised_routes,
|
||||||
|
peer_psk=excluded.peer_psk,
|
||||||
|
peer_allowed_ips=excluded.peer_allowed_ips,
|
||||||
|
enabled=excluded.enabled;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_gui_db_set_enabled() {
|
||||||
|
local pubkey="$1"
|
||||||
|
local enabled="$2"
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 || return 0
|
||||||
|
[[ -f "$GUI_DB_FILE" ]] || return 0
|
||||||
|
local e_pub
|
||||||
|
e_pub="$(sql_escape "$pubkey")"
|
||||||
|
sqlite3 "$GUI_DB_FILE" "UPDATE peers SET enabled=${enabled} WHERE public_key='${e_pub}';" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
load_meta() {
|
load_meta() {
|
||||||
[[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh"
|
[[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh"
|
||||||
# shellcheck disable=SC1090
|
# shellcheck disable=SC1090
|
||||||
@@ -129,6 +207,71 @@ extract_peer_address_by_pubkey() {
|
|||||||
' "$WG_CONF" | awk -F',' '{print $1}' | xargs
|
' "$WG_CONF" | awk -F',' '{print $1}' | xargs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extract_peer_allowed_ips_by_pubkey() {
|
||||||
|
local pubkey="$1"
|
||||||
|
awk -v pk="$pubkey" '
|
||||||
|
$0 ~ /^\[Peer\]/ {in_peer=1; key=""; allowed=""}
|
||||||
|
in_peer && $0 ~ /^PublicKey[[:space:]]*=/ {
|
||||||
|
sub(/^[^=]*=[[:space:]]*/, "", $0); key=$0
|
||||||
|
}
|
||||||
|
in_peer && $0 ~ /^AllowedIPs[[:space:]]*=/ {
|
||||||
|
sub(/^[^=]*=[[:space:]]*/, "", $0); allowed=$0
|
||||||
|
}
|
||||||
|
in_peer && key==pk && allowed!="" {print allowed; exit}
|
||||||
|
' "$WG_CONF" | xargs
|
||||||
|
}
|
||||||
|
|
||||||
|
routes_without_primary_address() {
|
||||||
|
local allowed_ips="$1"
|
||||||
|
local primary_addr="$2"
|
||||||
|
local out=""
|
||||||
|
local item
|
||||||
|
local norm_primary
|
||||||
|
norm_primary="$(echo "$primary_addr" | xargs)"
|
||||||
|
|
||||||
|
IFS=',' read -ra items <<< "$allowed_ips"
|
||||||
|
for item in "${items[@]}"; do
|
||||||
|
item="$(echo "$item" | xargs)"
|
||||||
|
[[ -z "$item" ]] && continue
|
||||||
|
if [[ -n "$norm_primary" && "$item" == "$norm_primary" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -z "$out" ]]; then
|
||||||
|
out="$item"
|
||||||
|
else
|
||||||
|
out="${out},${item}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_client_routes_now() {
|
||||||
|
local routes="${1:-}"
|
||||||
|
[[ -n "$routes" ]] || return 0
|
||||||
|
|
||||||
|
local cidr
|
||||||
|
IFS=',' read -ra cidrs <<< "$routes"
|
||||||
|
for cidr in "${cidrs[@]}"; do
|
||||||
|
cidr="$(echo "$cidr" | xargs)"
|
||||||
|
[[ -n "$cidr" ]] || continue
|
||||||
|
ip route replace "$cidr" dev "$WG_INTERFACE" proto static >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_client_routes_now() {
|
||||||
|
local routes="${1:-}"
|
||||||
|
[[ -n "$routes" ]] || return 0
|
||||||
|
|
||||||
|
local cidr
|
||||||
|
IFS=',' read -ra cidrs <<< "$routes"
|
||||||
|
for cidr in "${cidrs[@]}"; do
|
||||||
|
cidr="$(echo "$cidr" | xargs)"
|
||||||
|
[[ -n "$cidr" ]] || continue
|
||||||
|
ip route del "$cidr" dev "$WG_INTERFACE" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
apply_config() {
|
apply_config() {
|
||||||
if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then
|
if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then
|
||||||
wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF")
|
wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF")
|
||||||
@@ -179,7 +322,14 @@ cmd_add() {
|
|||||||
|
|
||||||
if peer_exists_by_pubkey "$client_pubkey"; then
|
if peer_exists_by_pubkey "$client_pubkey"; then
|
||||||
local existing_addr
|
local existing_addr
|
||||||
|
local existing_allowed
|
||||||
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")"
|
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")"
|
||||||
|
existing_allowed="${existing_addr:-}"
|
||||||
|
if [[ -n "$client_routes" ]]; then
|
||||||
|
existing_allowed="${existing_allowed},${client_routes}"
|
||||||
|
apply_client_routes_now "$client_routes"
|
||||||
|
fi
|
||||||
|
sync_gui_db_upsert_peer "$client_name" "$client_pubkey" "${existing_addr:-}" "$client_routes" "$client_psk" "${existing_allowed}" 1
|
||||||
cat <<EOF_OUT
|
cat <<EOF_OUT
|
||||||
STATUS=exists
|
STATUS=exists
|
||||||
CLIENT_NAME=$client_name
|
CLIENT_NAME=$client_name
|
||||||
@@ -219,6 +369,8 @@ EOF_OUT
|
|||||||
} >> "$WG_CONF"
|
} >> "$WG_CONF"
|
||||||
|
|
||||||
apply_config
|
apply_config
|
||||||
|
apply_client_routes_now "$client_routes"
|
||||||
|
sync_gui_db_upsert_peer "$client_name" "$client_pubkey" "$client_address" "$client_routes" "$client_psk" "$peer_allowed_ips" 1
|
||||||
|
|
||||||
cat <<EOF_OUT
|
cat <<EOF_OUT
|
||||||
STATUS=created
|
STATUS=created
|
||||||
@@ -232,6 +384,65 @@ WG_NETWORK=${WG_NETWORK}
|
|||||||
EOF_OUT
|
EOF_OUT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd_remove() {
|
||||||
|
local client_pubkey=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--client-public-key)
|
||||||
|
client_pubkey="$2"; shift 2 ;;
|
||||||
|
*)
|
||||||
|
die "Неизвестный аргумент: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ -n "$client_pubkey" ]] || die "Не указан --client-public-key"
|
||||||
|
|
||||||
|
load_meta
|
||||||
|
[[ -f "$WG_CONF" ]] || die "Не найден конфиг WireGuard: $WG_CONF"
|
||||||
|
local existing_allowed existing_addr existing_routes
|
||||||
|
existing_allowed="$(extract_peer_allowed_ips_by_pubkey "$client_pubkey")"
|
||||||
|
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")"
|
||||||
|
existing_routes="$(routes_without_primary_address "$existing_allowed" "$existing_addr")"
|
||||||
|
backup_file "$WG_CONF"
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v pk="$client_pubkey" '
|
||||||
|
BEGIN {in=0; block=""; keep=1}
|
||||||
|
/^\[Peer\]/ {
|
||||||
|
if (in && keep) printf "%s", block
|
||||||
|
in=1; block=$0 ORS; keep=1; next
|
||||||
|
}
|
||||||
|
{
|
||||||
|
if (in) {
|
||||||
|
block = block $0 ORS
|
||||||
|
if ($0 ~ /^PublicKey[[:space:]]*=/) {
|
||||||
|
line=$0
|
||||||
|
sub(/^[^=]*=[[:space:]]*/, "", line)
|
||||||
|
if (line == pk) keep=0
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
print
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (in && keep) printf "%s", block
|
||||||
|
}
|
||||||
|
' "$WG_CONF" > "$tmp"
|
||||||
|
mv "$tmp" "$WG_CONF"
|
||||||
|
safe_chmod_600 "$WG_CONF"
|
||||||
|
|
||||||
|
apply_config
|
||||||
|
remove_client_routes_now "$existing_routes"
|
||||||
|
sync_gui_db_set_enabled "$client_pubkey" 0
|
||||||
|
|
||||||
|
cat <<EOF_OUT
|
||||||
|
STATUS=removed
|
||||||
|
PUBLIC_KEY=${client_pubkey}
|
||||||
|
WG_INTERFACE=${WG_INTERFACE}
|
||||||
|
EOF_OUT
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local cmd="${1:-}"
|
local cmd="${1:-}"
|
||||||
if [[ -z "$cmd" ]]; then
|
if [[ -z "$cmd" ]]; then
|
||||||
@@ -246,6 +457,11 @@ main() {
|
|||||||
check_os_supported
|
check_os_supported
|
||||||
cmd_add "$@"
|
cmd_add "$@"
|
||||||
;;
|
;;
|
||||||
|
remove)
|
||||||
|
require_root
|
||||||
|
check_os_supported
|
||||||
|
cmd_remove "$@"
|
||||||
|
;;
|
||||||
-h|--help|help)
|
-h|--help|help)
|
||||||
usage
|
usage
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in New Issue
Block a user