Compare commits

..

2 Commits

Author SHA1 Message Date
Ruslan
39d17534e0 Chore: ignore Python cache artifacts 2026-04-14 11:43:16 +03:00
Ruslan
ae3da04d4a Server: replace wireguard-ui with built-in wg-admin-gui + PostgreSQL 2026-04-14 11:43:07 +03:00
11 changed files with 554 additions and 121 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
*.swp
.env
.DS_Store
# Python cache
__pycache__/
*.pyc

View File

@@ -8,7 +8,7 @@
- Быстро развернуть WireGuard-сервер (`wg-quick@wg0`) с автозапуском через `systemd`.
- Включить IP forwarding и NAT для выхода клиентов в интернет через сервер.
- Установить легкий GUI для управления (`wireguard-ui` в Docker).
- Установить встроенный GUI для управления peer'ами и QR (`wg-admin-gui`) с хранением данных в PostgreSQL.
- Автоматизировать добавление клиента с клиентской машины через SSH на сервер.
- Поддержать 2 режима маршрутизации клиента:
- полный туннель (весь трафик через VPN)
@@ -28,15 +28,16 @@
- `lib/common.sh` — общие функции
- `bootstrap/install_wg_install.sh` — установка короткой команды `wg-install`
- `templates/wg0.conf.template` — шаблон базового `wg0.conf`
- `server/install_server.sh` — установка сервера + GUI
- `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`, где лежит серверный конфиг.
- На сервере ставится `wg-syncconf@wg0.path`: при изменении `/etc/wireguard/wg0.conf` (в том числе после `Apply` в GUI) конфиг автоматически применяется в живой интерфейс `wg0`.
- GUI (`wg-admin-gui`) работает поверх того же `/etc/wireguard`, где лежит серверный конфиг.
- Метаданные GUI хранятся в PostgreSQL.
- На сервере ставится `wg-syncconf@wg0.path`: при изменении `/etc/wireguard/wg0.conf` конфиг автоматически применяется в живой интерфейс `wg0`.
- Клиентский скрипт:
1. генерирует ключи локально,
2. подключается к серверу по SSH,
@@ -46,13 +47,11 @@
6. запускает и включает `wg-quick@wg0`.
Каждый запуск клиентского установщика сначала очищает старые клиентские ключи/конфиг выбранного интерфейса и поднимает клиента заново.
## Почему выбран GUI `wireguard-ui`
## Про GUI
- Легкий для VPS (один контейнер).
- Понятный веб-интерфейс.
- Не требует переносить основной WireGuard в Docker: VPN остается в нативном `systemd`.
- Проще обслуживание: серверная сеть и NAT остаются под полным контролем Bash-скрипта.
- GUI не разрабатывается в этом проекте с нуля: используется готовый `wireguard-ui`, а проект автоматизирует его установку и настройку.
- `wg-admin-gui` показывает клиентов, статус, трафик, роуты и важные скрипты.
- Поддерживает добавление peer и генерацию QR.
- Основной WireGuard остается нативным в `systemd` (`wg-quick@wg0`).
## Какие пакеты устанавливаются
@@ -61,7 +60,8 @@
- `wireguard`, `wireguard-tools`
- `iproute2`, `iptables`
- `curl`, `ca-certificates`, `openssl`, `qrencode`
- `docker.io` и один из пакетов: `docker-compose-plugin` или `docker-compose` (в зависимости от версии ОС/репозиториев)
- `docker.io` и один из пакетов: `docker-compose-plugin` или `docker-compose` (для PostgreSQL контейнера GUI)
- `python3`, `python3-venv`, `python3-pip` (для `wg-admin-gui`)
### Клиент
@@ -79,7 +79,7 @@
sudo bash server/install_server.sh
```
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + `wireguard-ui` data/db) и поднимает всё заново.
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + данные GUI/PostgreSQL) и поднимает всё заново.
### Запуск сервера одной командой (без `git clone`)
@@ -232,13 +232,11 @@ http://203.0.113.10:5000
### Как получить QR для iPhone в GUI
1. Откройте GUI по ссылке из итоговой сводки установки.
2. Перейдите в раздел клиентов (`Clients`).
3. Создайте клиента (`New Client`) или выберите существующего.
4. Нажмите кнопку показа QR (или `Show QR`) у клиента.
2. Перейдите в раздел добавления peer.
3. Создайте клиента.
4. Используйте показанный QR.
5. На iPhone: WireGuard → `Add Tunnel``Create from QR code` и отсканируйте код.
В установщике уже задаются дефолты GUI для корректной генерации клиентских конфигов: endpoint, DNS, порт и путь к `wg0.conf`.
## Взаимодействие клиента с сервером
- Клиент генерирует локальные ключи.
@@ -310,21 +308,11 @@ ls -l /usr/local/sbin/wg-peerctl
4. GUI недоступен:
```bash
sudo docker ps
sudo docker logs wireguard-ui --tail=100
sudo systemctl status wg-admin-gui --no-pager
sudo docker ps | grep wg-admin-postgres
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`

303
gui/app.py Normal file
View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
import base64
import io
import os
import subprocess
from datetime import datetime
import qrcode
from flask import Flask, redirect, render_template, request, url_for, flash, Response
from psycopg import connect
from psycopg.rows import dict_row
app = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET", "dev-secret")
DB_DSN = os.environ.get("DB_DSN", "postgresql://wgadmin:wgadmin@127.0.0.1:5432/wgadmin")
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", "")
def db_conn():
return connect(DB_DSN, row_factory=dict_row)
def ensure_schema():
with db_conn() as conn, conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS peers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
public_key TEXT UNIQUE NOT NULL,
client_address TEXT,
advertised_routes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
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 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 = "never"
if parts[5].isdigit() and int(parts[5]) > 0:
latest = datetime.utcfromtimestamp(int(parts[5])).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,
"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, format="PNG")
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()}
with db_conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM peers ORDER BY id DESC")
db_peers = cur.fetchall()
items = []
seen = set()
for row in db_peers:
rt = runtime.get(row["public_key"], {})
seen.add(row["public_key"])
items.append(
{
"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 rt else "offline",
}
)
for pk, rt in runtime.items():
if pk in seen:
continue
items.append(
{
"name": "(external)",
"public_key": pk,
"client_address": rt.get("allowed_ips", "-").split(",", 1)[0],
"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",
}
)
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, conn.cursor() as cur:
cur.execute(
"""
INSERT INTO peers(name, public_key, client_address, advertised_routes)
VALUES (%s,%s,%s,%s)
ON CONFLICT(public_key)
DO UPDATE SET name=excluded.name, client_address=excluded.client_address, advertised_routes=excluded.advertised_routes
""",
(name, client_pub, client_addr, routes),
)
conn.commit()
return render_template(
"peer_created.html",
name=name,
client_conf=client_conf,
qr_b64=qr_b64,
public_key=client_pub,
)
@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)

4
gui/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask==3.0.3
psycopg[binary]==3.2.1
qrcode==7.4.2
gunicorn==23.0.0

27
gui/static/style.css Normal file
View File

@@ -0,0 +1,27 @@
: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; }
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
View 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>

28
gui/templates/index.html Normal file
View File

@@ -0,0 +1,28 @@
{% 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>
</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>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% 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">
<option value="full">full (весь трафик через VPN)</option>
<option value="split">split (только выбранные сети)</option>
</select>
</label>
<label>AllowedIPs (для split)
<input name="allowed_ips" placeholder="{{ 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>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<h2>Peer создан: {{ name }}</h2>
<p>PublicKey: <span class="mono">{{ public_key }}</span></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 %}

View 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 %}

View File

@@ -23,12 +23,12 @@ GUI_PORT="5000"
GUI_USER="admin"
GUI_PASSWORD=""
GUI_PASSWORD_GENERATED=0
GUI_SESSION_SECRET=""
GUI_RESET_DB="no"
GUI_DB_PASSWORD=""
usage() {
cat <<'USAGE'
Установка WireGuard-сервера и GUI (Debian/Ubuntu).
Установка WireGuard-сервера и встроенного WG Admin GUI (Debian/Ubuntu).
Каждый запуск выполняет полный reset прошлой инсталляции и поднимает все с нуля.
Использование:
@@ -44,12 +44,12 @@ usage() {
--server-dns <ip> DNS для клиентов (по умолчанию: 1.1.1.1)
--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-port <port> Порт GUI (по умолчанию: 5000)
--gui-user <user> Логин GUI (по умолчанию: admin)
--gui-password <pass> Пароль GUI (если не указан, будет запрос)
--gui-reset-db <yes|no> Устарело: теперь reset GUI выполняется автоматически
--gui-password <pass> Пароль GUI (если не указан, будет сгенерирован)
--gui-reset-db <yes|no> Устарело: reset БД теперь выполняется автоматически
-h, --help Показать помощь
USAGE
@@ -116,6 +116,16 @@ validate_inputs() {
fi
}
detect_compose_cmd() {
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
die "Не найден docker compose. Установите docker-compose-plugin или docker-compose."
fi
}
reset_existing_install() {
log_warn "Выполняю полный reset предыдущей инсталляции WireGuard/GUI"
@@ -135,16 +145,25 @@ reset_existing_install() {
log_info "Очищены конфиги/ключи WireGuard в /etc/wireguard"
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 [[ -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
fi
docker rm -f wireguard-ui >/dev/null 2>&1 || true
if [[ -f /opt/wg-admin-gui/docker-compose.yml ]]; then
detect_compose_cmd
(cd /opt/wg-admin-gui && "${COMPOSE_CMD[@]}" down --remove-orphans >/dev/null 2>&1) || true
fi
rm -rf /opt/wireguard-ui/db/* /opt/wireguard-ui/data/* /opt/wireguard-ui/docker-compose.yml
log_info "Очищено состояние GUI в /opt/wireguard-ui"
if [[ -f /opt/wireguard-ui/docker-compose.yml ]]; then
detect_compose_cmd
(cd /opt/wireguard-ui && "${COMPOSE_CMD[@]}" down --remove-orphans >/dev/null 2>&1) || true
fi
docker rm -f wireguard-ui wg-admin-postgres >/dev/null 2>&1 || true
fi
rm -rf /opt/wireguard-ui /opt/wg-admin-gui
log_info "Очищено состояние GUI"
}
collect_inputs() {
@@ -174,9 +193,9 @@ collect_inputs() {
if [[ "$GUI_ENABLE" == "yes" ]]; then
if [[ -z "$GUI_PASSWORD" ]]; then
GUI_PASSWORD="$(random_alnum 8)"
GUI_PASSWORD="$(random_alnum 10)"
GUI_PASSWORD_GENERATED=1
log_warn "Пароль GUI не задан. Сгенерирован пароль (8 символов): ${GUI_PASSWORD}"
log_warn "Пароль GUI не задан. Сгенерирован пароль: ${GUI_PASSWORD}"
if (( ! NON_INTERACTIVE )); then
local replace_or_password=""
@@ -187,8 +206,6 @@ collect_inputs() {
if [[ -n "$custom_gui_password" ]]; then
GUI_PASSWORD="$custom_gui_password"
GUI_PASSWORD_GENERATED=0
else
log_warn "Пустой пароль не принят. Остается сгенерированный пароль."
fi
elif [[ -n "$replace_or_password" && ! "$replace_or_password" =~ ^([nN][oO]?|[nN])$ ]]; then
GUI_PASSWORD="$replace_or_password"
@@ -196,11 +213,9 @@ collect_inputs() {
fi
fi
fi
GUI_SESSION_SECRET="$(random_alnum 32)"
GUI_DB_PASSWORD="$(random_alnum 24)"
[[ "$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
validate_inputs
@@ -209,7 +224,7 @@ collect_inputs() {
install_packages() {
apt_install_if_missing \
wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \
qrencode docker.io
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
@@ -237,19 +252,12 @@ setup_keys() {
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"
log_success "Сгенерированы ключи сервера"
}
setup_wg_config() {
@@ -363,75 +371,69 @@ EOF_SYNC_PATH
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
mkdir -p /opt/wg-admin-gui/{app,pgdata}
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/
cat > /opt/wg-admin-gui/docker-compose.yml <<EOF_COMPOSE
services:
wireguard-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wireguard-ui
postgres:
image: postgres:16-alpine
container_name: wg-admin-postgres
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
- POSTGRES_DB=wgadmin
- POSTGRES_USER=wgadmin
- POSTGRES_PASSWORD=${GUI_DB_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- /etc/wireguard:/etc/wireguard
- /opt/wireguard-ui/db:/app/db
- /opt/wireguard-ui/data:/app/data
cap_add:
- NET_ADMIN
- /opt/wg-admin-gui/pgdata:/var/lib/postgresql/data
EOF_COMPOSE
systemd_enable_now docker.service
detect_compose_cmd
(cd /opt/wg-admin-gui && "${COMPOSE_CMD[@]}" up -d --remove-orphans)
local compose_cmd=()
local compose_mode=""
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
compose_mode="plugin"
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose)
compose_mode="legacy"
else
die "Не найден docker compose. Установите docker-compose-plugin или docker-compose."
fi
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
# На некоторых системах с legacy docker-compose (v1) при recreate может возникать
# KeyError: 'ContainerConfig'. Предварительно удаляем старый контейнер по имени.
if [[ "$compose_mode" == "legacy" ]]; then
docker rm -f wireguard-ui >/dev/null 2>&1 || true
cat > /opt/wg-admin-gui/wg-admin-gui.env <<EOF_ENV
DB_DSN=postgresql://wgadmin:${GUI_DB_PASSWORD}@127.0.0.1:5432/wgadmin
WG_INTERFACE=${WG_INTERFACE}
WG_META_FILE=/etc/wireguard/wg-meta.env
ADMIN_USER=${GUI_USER}
ADMIN_PASSWORD=${GUI_PASSWORD}
APP_SECRET=$(random_alnum 32)
APP_PORT=${GUI_PORT}
EOF_ENV
chmod 600 /opt/wg-admin-gui/wg-admin-gui.env
# Удаляем возможные старые контейнеры вида <project>_wireguard-ui
# и контейнеры сервиса wireguard-ui по compose-label.
local legacy_ids legacy_names
legacy_ids="$(docker ps -aq --filter 'label=com.docker.compose.service=wireguard-ui' || true)"
if [[ -n "$legacy_ids" ]]; then
docker rm -f $legacy_ids >/dev/null 2>&1 || true
fi
cat > /etc/systemd/system/wg-admin-gui.service <<EOF_SERVICE
[Unit]
Description=WG Admin GUI
After=network-online.target docker.service
Wants=network-online.target
legacy_names="$(docker ps -a --format '{{.Names}}' | grep -E '(^|[_-])wireguard-ui($|[_-])' || true)"
if [[ -n "$legacy_names" ]]; then
while IFS= read -r cname; do
[[ -n "$cname" ]] || continue
docker rm -f "$cname" >/dev/null 2>&1 || true
done <<< "$legacy_names"
fi
fi
[Service]
Type=simple
WorkingDirectory=/opt/wg-admin-gui/app
EnvironmentFile=/opt/wg-admin-gui/wg-admin-gui.env
ExecStart=/opt/wg-admin-gui/venv/bin/gunicorn -w 2 -b 0.0.0.0:${GUI_PORT} app:app
Restart=always
RestartSec=2
User=root
(cd /opt/wireguard-ui && "${compose_cmd[@]}" up -d --remove-orphans)
log_success "GUI wireguard-ui запущен"
[Install]
WantedBy=multi-user.target
EOF_SERVICE
systemctl daemon-reload
systemctl enable --now wg-admin-gui.service
log_success "WG Admin GUI запущен"
}
print_summary() {
@@ -440,8 +442,7 @@ print_summary() {
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"
gui_status="$(systemctl is-active wg-admin-gui.service 2>/dev/null || true)"
fi
cat <<EOF_SUMMARY
@@ -466,10 +467,6 @@ Auto-apply GUI->WG: enabled (wg-syncconf@${WG_INTERFACE}.path)
Лог установки: ${LOG_FILE}
=================================================
EOF_SUMMARY
if [[ "$GUI_ENABLE" == "yes" ]]; then
echo "Ссылка для входа в GUI: http://${GUI_HOST}:${GUI_PORT}"
fi
}
main() {
@@ -480,6 +477,7 @@ main() {
require_cmd ip
require_cmd awk
require_cmd sed
require_cmd python3
collect_inputs