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 @@ + + +
+ + +Интерфейс: {{ meta.get('WG_INTERFACE','wg0') }} | Сеть: {{ meta.get('WG_NETWORK','-') }} | Endpoint: {{ meta.get('SERVER_PUBLIC_IP','-') }}:{{ meta.get('WG_PORT','-') }}
+| Имя | Статус | IP | Роуты | AllowedIPs | Endpoint | Handshake | RX | TX | PubKey | +
|---|---|---|---|---|---|---|---|---|---|
| {{ p.name }} | +{{ p.status }} | +{{ p.client_address }} | +{{ p.routes }} | +{{ p.allowed_ips }} | +{{ p.endpoint }} | +{{ p.latest_handshake }} | +{{ p.rx }} | +{{ p.tx }} | +{{ p.public_key }} | +
PublicKey: {{ public_key }}
+{{ client_conf }}
+ {{ k }}
+{{ v }}
+{% endfor %}
+{{ p }}