diff --git a/README.md b/README.md index ba323ca..52cdeb7 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/gui/__pycache__/app.cpython-312.pyc b/gui/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..f0e1db3 Binary files /dev/null and b/gui/__pycache__/app.cpython-312.pyc differ diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..20fd8bd --- /dev/null +++ b/gui/app.py @@ -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) diff --git a/gui/requirements.txt b/gui/requirements.txt new file mode 100644 index 0000000..d053fba --- /dev/null +++ b/gui/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +psycopg[binary]==3.2.1 +qrcode==7.4.2 +gunicorn==23.0.0 diff --git a/gui/static/style.css b/gui/static/style.css new file mode 100644 index 0000000..2fe9b77 --- /dev/null +++ b/gui/static/style.css @@ -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; } } diff --git a/gui/templates/base.html b/gui/templates/base.html new file mode 100644 index 0000000..7ef3884 --- /dev/null +++ b/gui/templates/base.html @@ -0,0 +1,29 @@ + + + + + + WG Admin + + + +
+

WG Admin

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{message}}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/gui/templates/index.html b/gui/templates/index.html new file mode 100644 index 0000000..795f099 --- /dev/null +++ b/gui/templates/index.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block content %} +

Клиенты

+

Интерфейс: {{ meta.get('WG_INTERFACE','wg0') }} | Сеть: {{ meta.get('WG_NETWORK','-') }} | Endpoint: {{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}

+ + + + + + + + {% for p in peers %} + + + + + + + + + + + + + {% endfor %} + +
ИмяСтатусIPРоутыAllowedIPsEndpointHandshakeRXTXPubKey
{{ p.name }}{{ p.status }}{{ p.client_address }}{{ p.routes }}{{ p.allowed_ips }}{{ p.endpoint }}{{ p.latest_handshake }}{{ p.rx }}{{ p.tx }}{{ p.public_key }}
+{% endblock %} diff --git a/gui/templates/new_peer.html b/gui/templates/new_peer.html new file mode 100644 index 0000000..c15cdc6 --- /dev/null +++ b/gui/templates/new_peer.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% block content %} +

Новый peer

+
+ + + + + +
+{% endblock %} diff --git a/gui/templates/peer_created.html b/gui/templates/peer_created.html new file mode 100644 index 0000000..df562f2 --- /dev/null +++ b/gui/templates/peer_created.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block content %} +

Peer создан: {{ name }}

+

PublicKey: {{ public_key }}

+
+
+

QR

+ QR +
+
+

Client config

+
{{ client_conf }}
+
+
+{% endblock %} diff --git a/gui/templates/scripts.html b/gui/templates/scripts.html new file mode 100644 index 0000000..99d5374 --- /dev/null +++ b/gui/templates/scripts.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block content %} +

Скрипты и команды

+

Команды

+{% for k, v in commands.items() %} +

{{ k }}

+
{{ v }}
+{% endfor %} +

Важные пути

+ +{% endblock %} diff --git a/server/install_server.sh b/server/install_server.sh index 6abbc0a..aa3ffc5 100755 --- a/server/install_server.sh +++ b/server/install_server.sh @@ -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 DNS для клиентов (по умолчанию: 1.1.1.1) --default-iface Внешний интерфейс для NAT - --gui-enable Включить GUI wireguard-ui (по умолчанию: yes) + --gui-enable Включить WG Admin GUI (по умолчанию: yes) --gui-host Домен/IP для открытия GUI --gui-port Порт GUI (по умолчанию: 5000) --gui-user Логин GUI (по умолчанию: admin) - --gui-password Пароль GUI (если не указан, будет запрос) - --gui-reset-db Устарело: теперь reset GUI выполняется автоматически + --gui-password Пароль GUI (если не указан, будет сгенерирован) + --gui-reset-db Устарело: 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 + 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 - docker rm -f wireguard-ui >/dev/null 2>&1 || true + + 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/db/* /opt/wireguard-ui/data/* /opt/wireguard-ui/docker-compose.yml - log_info "Очищено состояние GUI в /opt/wireguard-ui" + 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 + umask 077 + wg genkey | tee "$priv" | wg pubkey > "$pub" 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 < /opt/wg-admin-gui/docker-compose.yml </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 <_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 </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 <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