From ae3da04d4afad4ad499072ac1ab5578ed65ead51 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 14 Apr 2026 11:43:07 +0300 Subject: [PATCH] Server: replace wireguard-ui with built-in wg-admin-gui + PostgreSQL --- README.md | 46 ++--- gui/__pycache__/app.cpython-312.pyc | Bin 0 -> 13935 bytes gui/app.py | 303 ++++++++++++++++++++++++++++ gui/requirements.txt | 4 + gui/static/style.css | 27 +++ gui/templates/base.html | 29 +++ gui/templates/index.html | 28 +++ gui/templates/new_peer.html | 22 ++ gui/templates/peer_created.html | 15 ++ gui/templates/scripts.html | 15 ++ server/install_server.sh | 182 +++++++++-------- 11 files changed, 550 insertions(+), 121 deletions(-) create mode 100644 gui/__pycache__/app.cpython-312.pyc create mode 100644 gui/app.py create mode 100644 gui/requirements.txt create mode 100644 gui/static/style.css create mode 100644 gui/templates/base.html create mode 100644 gui/templates/index.html create mode 100644 gui/templates/new_peer.html create mode 100644 gui/templates/peer_created.html create mode 100644 gui/templates/scripts.html 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 0000000000000000000000000000000000000000..f0e1db398e28b413aaf3aab5e565fe61d7c0b642 GIT binary patch literal 13935 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�@0VFH2^w&Xfceh3u<5}5wxdV!-~RT4hrt+V zRubF&GhUCsUApWGpfwP(3UA0SzNhrPwKW6(S`iC|OLNi8<47xL8p9R&L@?r*@PL*_ zO%H;x3A`SImIqRVpvUh6<(KB*k+3CKCWHEB_u+8A2ZLPucJe1M=08sUZ0(J;8+M5N zEcs(d_S8a54rY8BK`=fHgBq&>`bX6;fStbd>%+Xkcb#XQKT#H-g&jWZ0iOILf{88RxU2(AkvfYP^hS6x5*r$9jbAb z6&(K`VZDmnghxKtXGOoyW(NS%FXzIb&&Qh_ zLy&E&y47;CW!>gjvpJSet~ye-BkQ)~YqsMl+sWv;hajQ6viQonY1f)*S7KsClQQjF zHyv0r9Y~oDMSK4a^o5KmX+FF<{(w34(6ZxQ{W6=X+?TSnB$<|9>ABdIZ(U1Q>{;YK zF;%83s&7r)oJd4g+^LF_G49vR&!?J?uhDy>=i=3gQF!w-G=iw0D3gt8W>=cAr5U^G z8#l^1P8aQdR7o&q&^9Ma>XwftEzN68GrX75w6+zSrxM-oFkti$qrt2JtX7(CHYIEJ ztqi4X`=jU5*794GH!G9Xtt(>6dN6tx4JGCm=aZGrm4hiuTl7qtH7_+SHpNdYSEbl} z(QfQhMVj4_;NlBOrZLTy#b1t}Pcn`)TLJ=_HU>Rm(@aU4t%!%?{v-%%Y*nH;u`|i+ zNwekgiTG#|hLEoSN;2$5mC2@yp554qJ#^l5CeEg8FbNM$&`!$K5bgbS#(It2YP|;0 zoUpl}(V9v&jD*=1?@0XiN^jD5=nD%2@|Z1e%T^8r9uwewQ3=9MnyE>ftm(4dX-j#! zbk`T04t4Z8t?nPbI8PGBigm7OjceKjp{Es>wB-1`&U=TF=C6IY;{nsRWo7?{Y589p zAltS+d3+dZ-@qn7TFM2bF4(+(p)Kpv5P#KdJ!_@@>iFKXTJ6UU2Esqrl%Ca4KQDLm zouq#L9EstRTJV3N)t)sQe!=R2<`-7&*=jxN%e`Q@7TPK=;T56CO9vqU_WESz9pqhV z0bhf)TVI3WH5b&nclCLd4!qEYe;Jw%o^&)cO;4d+vAvadI_!J)*ulckZ*Lvzw>u_6 zp`Zj-z+fQICeBKJPoQo7YPdZUY`>&h^SU!PK)XluP5`TIpZ53yZJ^C>8xK3|tuuB< z12ohw3c;CNDl`KG_SX6ESh4KZ?o6uEA9%Hz$P(oYh}9;uE&BqXr}6uAlx9jvYGCz=35!CRMi%C1;B+Mlww-rt7APO!%^XD{q* zy*X8z8fU=B!++I1Hx6bVzYm)$yyF>_i8jn_Y`@|`G&i0%?*^N4WC^X=8rvYVC|PDi zTgYmQ)Rx7DN;SspSy0&S4>dA__OWOPgQh3&00d7e$X3Pw0NxfmS$(#k_@5zXC%_$P zKLo(^L`T&0&>I@`(`6SP>FE+(^vou!H|e6TFG^57a%%O=hpq?Q*(9B@4wD(T8~3=| z9oA=2ugsuX{osWm&~hBve{6p*%riX!mh|v$J_DLKEiYW29e1{g*RXTHjUF~H?m-Wa zv3M1{j1m8oh}w+|22jR=pdo_>6mWn~ld?W2UG>fc#})H+*_e&Bp+Syf$BunOJmhwR zf-wlXL$I{>iSUkwNCSB`8vP@T%b337Ly2N+sXVMH4{#YAfr4BHdURmu6nctga|%QF z(3k-o-+_>9zAONm5!~7ToS?kDiA$KHRRFi5aLh1wTm{9-W834`6Xpa6CbNFwl=!zm z0_#Lb{|O-%`IM;pI~^B0^X-`^mA01N zs=ir$tM+E?vT?E);FhWvtCwmQYZFY;aVceZF{%T@8?+K!y0&;N5m*sY zmcvowmb7Hs*_7oRB$_Nsql=?UuPwfo=wI2JG98Sv1??p5`vEmrk3`LzI+8rF$(YHC zO$SLH-()zlWOJvUv}~4DlGe?mI?}djqsi(`Gfke@G;2xSMhWnnb+UAgEPbS5$&$}0 zLQ}SZp^fT-9Fi={=0FIPsT%EftKO)J9r||dBTXyWNn&Qnh7#QPE%-lGnd}qGjVl$) zyQ5Vp^1vg_5J~Eg?%;+J+<^DyEL}&6tazj`ljoFrc5Em?FjS(AvG9GW3ZL;$zi~QV tb*FTB=>5?$#r&D!jl7kncr~iij#s)>u)ygB~{{y@%5FY>l literal 0 HcmV?d00001 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 %} +

Важные пути

+
    +{% for p in paths %} +
  • {{ p }}
  • +{% 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