Compare commits

...

11 Commits

10 changed files with 607 additions and 90 deletions

View File

@@ -8,7 +8,7 @@
- Быстро развернуть WireGuard-сервер (`wg-quick@wg0`) с автозапуском через `systemd`.
- Включить IP forwarding и NAT для выхода клиентов в интернет через сервер.
- Установить встроенный GUI для управления peer'ами и QR (`wg-admin-gui`) с хранением данных в PostgreSQL.
- Установить встроенный GUI для управления peer'ами и QR (`wg-admin-gui`) с хранением данных в SQLite.
- Автоматизировать добавление клиента с клиентской машины через SSH на сервер.
- Поддержать 2 режима маршрутизации клиента:
- полный туннель (весь трафик через VPN)
@@ -36,7 +36,7 @@
- Сервер разворачивается нативно на `wg-quick` + `systemd` (стабильность после reboot).
- GUI (`wg-admin-gui`) работает поверх того же `/etc/wireguard`, где лежит серверный конфиг.
- Метаданные GUI хранятся в PostgreSQL.
- Метаданные GUI хранятся в SQLite.
- На сервере ставится `wg-syncconf@wg0.path`: при изменении `/etc/wireguard/wg0.conf` конфиг автоматически применяется в живой интерфейс `wg0`.
- Клиентский скрипт:
1. генерирует ключи локально,
@@ -60,7 +60,6 @@
- `wireguard`, `wireguard-tools`
- `iproute2`, `iptables`
- `curl`, `ca-certificates`, `openssl`, `qrencode`
- `docker.io` и один из пакетов: `docker-compose-plugin` или `docker-compose` (для PostgreSQL контейнера GUI)
- `python3`, `python3-venv`, `python3-pip` (для `wg-admin-gui`)
### Клиент
@@ -79,7 +78,7 @@
sudo bash server/install_server.sh
```
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + данные GUI/PostgreSQL) и поднимает всё заново.
Важно: серверный установщик теперь всегда выполняет полный reset прошлого состояния (`/etc/wireguard` + данные GUI/SQLite) и поднимает всё заново.
### Запуск сервера одной командой (без `git clone`)
@@ -161,6 +160,7 @@ tmp="$(mktemp -d)" && curl -fL "https://git.ruslan.xyz/ruslan/Wireguard_server/a
Если за клиентом есть локальная сеть (например `192.168.33.0/24`), передайте `--advertise-subnets 192.168.33.0/24`, чтобы сервер маршрутизировал эту сеть через клиента.
Если `--advertise-subnets` не задан, скрипт автоматически пытается определить LAN-сети клиента и объявить их на сервере.
В режиме `split`, если `--allowed-ips` не задан, скрипт автоматически использует сеть WG сервера.
При объявлении сетей за клиентом скрипт автоматически включает `ip_forward` и добавляет правила `iptables` (forward + nat) через `PostUp/PostDown`.
### Non-interactive пример (SSH-ключ)
@@ -234,7 +234,7 @@ http://203.0.113.10:5000
1. Откройте GUI по ссылке из итоговой сводки установки.
2. Перейдите в раздел добавления peer.
3. Создайте клиента.
4. Используйте показанный QR.
4. Используйте показанный QR или скачайте готовый `.conf`.
5. На iPhone: WireGuard → `Add Tunnel``Create from QR code` и отсканируйте код.
## Взаимодействие клиента с сервером
@@ -309,7 +309,6 @@ ls -l /usr/local/sbin/wg-peerctl
4. GUI недоступен:
```bash
sudo systemctl status wg-admin-gui --no-pager
sudo docker ps | grep wg-admin-postgres
sudo ss -tulpn | grep 5000
```

View File

@@ -24,6 +24,7 @@ SSH_PASSWORD=""
KEEPALIVE="25"
CLIENT_ADDRESS_PREFIX="24"
FORWARDING_MODE="disabled"
usage() {
cat <<'USAGE'
@@ -303,6 +304,12 @@ build_allowed_ips() {
build_route_hooks_if_needed() {
PRE_UP=""
POST_DOWN=""
POST_UP_EXTRA_1=""
POST_UP_EXTRA_2=""
POST_UP_EXTRA_3=""
POST_DOWN_EXTRA_1=""
POST_DOWN_EXTRA_2=""
POST_DOWN_EXTRA_3=""
if [[ "$TUNNEL_MODE" != "full" ]]; then
return
@@ -321,6 +328,41 @@ build_route_hooks_if_needed() {
fi
}
detect_iface_for_cidr() {
local cidr="$1"
ip -4 route show "$cidr" 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="dev") {print $(i+1); exit}}'
}
build_lan_nat_hooks_if_needed() {
[[ -n "$ADVERTISE_SUBNETS" ]] || return
local first_cidr lan_iface wg_net
first_cidr="$(echo "$ADVERTISE_SUBNETS" | awk -F',' '{gsub(/ /,"",$1); print $1}')"
lan_iface="$(detect_iface_for_cidr "$first_cidr" || true)"
[[ -n "$lan_iface" ]] || lan_iface="$(detect_default_iface || true)"
[[ -n "$lan_iface" ]] || { log_warn "Не удалось определить LAN-интерфейс для NAT/forwarding."; return; }
wg_net="${SERVER_WG_NETWORK:-10.66.66.0/24}"
local fwd="/etc/sysctl.d/99-wireguard-client-forwarding.conf"
cat > "$fwd" <<EOF_FWD
net.ipv4.ip_forward=1
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
EOF_FWD
sysctl --system >/dev/null || true
POST_UP_EXTRA_1="PostUp = iptables -C FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT"
POST_UP_EXTRA_2="PostUp = iptables -C FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || iptables -A FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT"
POST_UP_EXTRA_3="PostUp = iptables -t nat -C POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE"
POST_DOWN_EXTRA_1="PostDown = iptables -D FORWARD -i ${WG_INTERFACE} -o ${lan_iface} -j ACCEPT 2>/dev/null || true"
POST_DOWN_EXTRA_2="PostDown = iptables -D FORWARD -i ${lan_iface} -o ${WG_INTERFACE} -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true"
POST_DOWN_EXTRA_3="PostDown = iptables -t nat -D POSTROUTING -s ${wg_net} -o ${lan_iface} -j MASQUERADE 2>/dev/null || true"
FORWARDING_MODE="enabled via ${lan_iface} (wg:${wg_net})"
}
build_client_interface_address() {
local ip_only
ip_only="${CLIENT_ADDRESS%%/*}"
@@ -360,6 +402,14 @@ write_client_config() {
if [[ -n "$POST_DOWN" ]]; then
echo "$POST_DOWN"
fi
if [[ -n "$POST_UP_EXTRA_1" ]]; then
echo "$POST_UP_EXTRA_1"
echo "$POST_UP_EXTRA_2"
echo "$POST_UP_EXTRA_3"
echo "$POST_DOWN_EXTRA_1"
echo "$POST_DOWN_EXTRA_2"
echo "$POST_DOWN_EXTRA_3"
fi
echo
echo "[Peer]"
echo "PublicKey = ${SERVER_PUBLIC_KEY}"
@@ -403,6 +453,7 @@ Endpoint сервера: ${SERVER_ENDPOINT}
SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT}
Статус регистрации: ${SERVER_STATUS}
Сети за клиентом: ${ADVERTISE_SUBNETS:-не объявлены}
LAN forwarding/NAT: ${FORWARDING_MODE}
Лог: ${LOG_FILE}
=================================================
EOF_SUMMARY
@@ -434,6 +485,7 @@ main() {
build_allowed_ips
build_client_interface_address
build_route_hooks_if_needed
build_lan_nat_hooks_if_needed
write_client_config
apply_client_config
print_summary

View File

@@ -2,42 +2,60 @@
import base64
import io
import os
import sqlite3
import subprocess
import time
from datetime import datetime
import qrcode
from flask import Flask, redirect, render_template, request, url_for, flash, Response
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")
DB_PATH = os.environ.get("DB_PATH", "/opt/wg-admin-gui/data/wgadmin.db")
WG_INTERFACE = os.environ.get("WG_INTERFACE", "wg0")
WG_META_FILE = os.environ.get("WG_META_FILE", "/etc/wireguard/wg-meta.env")
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
ONLINE_WINDOW_SEC = int(os.environ.get("ONLINE_WINDOW_SEC", "120"))
def db_conn():
return connect(DB_DSN, row_factory=dict_row)
db_dir = os.path.dirname(DB_PATH)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def ensure_schema():
with db_conn() as conn, conn.cursor() as cur:
cur.execute(
"""
with db_conn() as conn:
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS peers (
id BIGSERIAL PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
public_key TEXT UNIQUE NOT NULL,
client_address TEXT,
advertised_routes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
client_conf TEXT,
peer_psk TEXT,
peer_allowed_ips TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
""")
cols = {row[1] for row in cur.execute("PRAGMA table_info(peers)").fetchall()}
if "client_conf" not in cols:
cur.execute("ALTER TABLE peers ADD COLUMN client_conf TEXT")
if "peer_psk" not in cols:
cur.execute("ALTER TABLE peers ADD COLUMN peer_psk TEXT")
if "peer_allowed_ips" not in cols:
cur.execute("ALTER TABLE peers ADD COLUMN peer_allowed_ips TEXT")
if "enabled" not in cols:
cur.execute("ALTER TABLE peers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
conn.commit()
@@ -69,6 +87,61 @@ def parse_kv(text):
return out
def parse_wg_conf_peer_meta():
path = f"/etc/wireguard/{WG_INTERFACE}.conf"
if not os.path.exists(path):
return {}
by_pub = {}
pending_name = None
in_peer = False
current_pub = ""
current_allowed = ""
def flush():
nonlocal current_pub, current_allowed, pending_name
if not current_pub:
return
first = current_allowed.split(",", 1)[0].strip() if current_allowed else ""
routes = ""
if "," in current_allowed:
routes = current_allowed.split(",", 1)[1].strip()
by_pub[current_pub] = {
"name": pending_name or "(external)",
"client_address": first,
"routes": routes,
"allowed_ips": current_allowed,
}
pending_name = None
current_pub = ""
current_allowed = ""
with open(path, "r", encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line:
continue
if line.startswith("# managed-by=wg-peerctl"):
marker = "client="
if marker in line:
pending_name = line.split(marker, 1)[1].split()[0].strip()
continue
if line == "[Peer]":
if in_peer:
flush()
in_peer = True
continue
if in_peer and line.startswith("PublicKey"):
current_pub = line.split("=", 1)[1].strip()
continue
if in_peer and line.startswith("AllowedIPs"):
current_allowed = line.split("=", 1)[1].strip()
continue
if in_peer:
flush()
return by_pub
def wg_dump():
try:
out = run(["wg", "show", WG_INTERFACE, "dump"])
@@ -80,15 +153,18 @@ def wg_dump():
parts = line.split("\t")
if len(parts) < 8:
continue
latest_ts = 0
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")
latest_ts = int(parts[5])
latest = datetime.utcfromtimestamp(latest_ts).strftime("%Y-%m-%d %H:%M:%S UTC")
peers.append(
{
"public_key": parts[0],
"endpoint": parts[2] or "-",
"allowed_ips": parts[3],
"latest_handshake": latest,
"latest_handshake_ts": latest_ts,
"rx_bytes": int(parts[6] or 0),
"tx_bytes": int(parts[7] or 0),
}
@@ -115,7 +191,7 @@ def gen_keypair_psk():
def to_png_b64(text):
img = qrcode.make(text)
buf = io.BytesIO()
img.save(buf, format="PNG")
img.save(buf)
return base64.b64encode(buf.getvalue()).decode("ascii")
@@ -150,17 +226,23 @@ def _schema():
def index():
meta = load_meta()
runtime = {p["public_key"]: p for p in wg_dump()}
with db_conn() as conn, conn.cursor() as cur:
conf_meta = parse_wg_conf_peer_meta()
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers ORDER BY id DESC")
db_peers = cur.fetchall()
db_peers = [dict(r) for r in cur.fetchall()]
items = []
seen = set()
now = int(time.time())
for row in db_peers:
rt = runtime.get(row["public_key"], {})
ts = int(rt.get("latest_handshake_ts", 0) or 0)
is_online = ts > 0 and (now - ts) <= ONLINE_WINDOW_SEC
seen.add(row["public_key"])
items.append(
{
"id": row["id"],
"name": row["name"],
"public_key": row["public_key"],
"client_address": row.get("client_address") or "-",
@@ -170,25 +252,46 @@ def index():
"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",
"status": "online" if is_online else "offline",
}
)
for pk, rt in runtime.items():
if pk in seen:
continue
cm = conf_meta.get(pk, {})
imported_name = cm.get("name", "(external)")
imported_addr = cm.get("client_address", rt.get("allowed_ips", "-").split(",", 1)[0])
imported_routes = cm.get("routes", "-") or "-"
ext_id = None
with db_conn() as conn:
cur = conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO peers(name, public_key, client_address, advertised_routes, enabled) VALUES (?,?,?,?,1)",
(imported_name, pk, imported_addr, imported_routes if imported_routes != "-" else ""),
)
cur.execute("SELECT id FROM peers WHERE public_key = ?", (pk,))
got = cur.fetchone()
if got:
ext_id = got["id"]
conn.commit()
ts = int(rt.get("latest_handshake_ts", 0) or 0)
is_online = ts > 0 and (now - ts) <= ONLINE_WINDOW_SEC
items.append(
{
"name": "(external)",
"id": ext_id,
"name": imported_name,
"public_key": pk,
"client_address": rt.get("allowed_ips", "-").split(",", 1)[0],
"routes": "-",
"client_address": imported_addr,
"routes": imported_routes,
"allowed_ips": rt.get("allowed_ips", "-"),
"endpoint": rt.get("endpoint", "-"),
"latest_handshake": rt.get("latest_handshake", "offline"),
"rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online",
"status": "online" if is_online else "offline",
}
)
@@ -260,15 +363,16 @@ def new_peer():
client_conf = "\n".join(conf_lines)
qr_b64 = to_png_b64(client_conf)
with db_conn() as conn, conn.cursor() as cur:
with db_conn() as conn:
cur = conn.cursor()
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),
"UPDATE peers SET name=?, client_address=?, advertised_routes=?, client_conf=?, peer_psk=?, peer_allowed_ips=?, enabled=1 WHERE public_key=?",
(name, client_addr, routes, client_conf, client_psk, client_addr + (("," + routes) if routes else ""), client_pub),
)
if cur.rowcount == 0:
cur.execute(
"INSERT INTO peers(name, public_key, client_address, advertised_routes, client_conf, peer_psk, peer_allowed_ips, enabled) VALUES (?,?,?,?,?,?,?,1)",
(name, client_pub, client_addr, routes, client_conf, client_psk, client_addr + (("," + routes) if routes else "")),
)
conn.commit()
@@ -281,6 +385,160 @@ def new_peer():
)
@app.route("/peers/<int:peer_id>")
def peer_view(peer_id: int):
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
row = cur.fetchone()
if not row:
flash("Клиент не найден", "error")
return redirect(url_for("index"))
item = dict(row)
conf = item.get("client_conf") or ""
if not conf:
flash("Для этого клиента не найден сохраненный конфиг", "error")
return redirect(url_for("index"))
qr_b64 = to_png_b64(conf)
return render_template(
"peer_created.html",
name=item.get("name", "peer"),
client_conf=conf,
qr_b64=qr_b64,
public_key=item.get("public_key", ""),
)
@app.post("/peers/<int:peer_id>/disable")
def peer_disable(peer_id: int):
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
row = cur.fetchone()
if not row:
flash("Клиент не найден", "error")
return redirect(url_for("index"))
item = dict(row)
pk = item.get("public_key", "")
if not pk:
flash("Не найден public key", "error")
return redirect(url_for("index"))
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except subprocess.CalledProcessError as e:
flash(f"Не удалось отключить peer: {e}", "error")
return redirect(url_for("index"))
with db_conn() as conn:
cur = conn.cursor()
cur.execute("UPDATE peers SET enabled=0 WHERE id = ?", (peer_id,))
conn.commit()
flash("Peer отключен", "ok")
return redirect(url_for("index"))
@app.post("/peers/<int:peer_id>/enable")
def peer_enable(peer_id: int):
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
row = cur.fetchone()
if not row:
flash("Клиент не найден", "error")
return redirect(url_for("index"))
item = dict(row)
name = item.get("name", "")
pk = item.get("public_key", "")
addr = item.get("client_address", "")
routes = item.get("advertised_routes", "") or ""
psk = item.get("peer_psk", "") or ""
if not (name and pk and addr and psk):
flash("Недостаточно данных для включения peer (нужны name/public key/address/psk)", "error")
return redirect(url_for("index"))
cmd = [
"/usr/local/sbin/wg-peerctl",
"add",
"--client-name",
name,
"--client-public-key",
pk,
"--client-address",
addr,
"--client-preshared-key",
psk,
"--persistent-keepalive",
"25",
]
if routes:
cmd += ["--client-routes", routes]
try:
run(cmd)
except subprocess.CalledProcessError as e:
flash(f"Не удалось включить peer: {e}", "error")
return redirect(url_for("index"))
with db_conn() as conn:
cur = conn.cursor()
cur.execute("UPDATE peers SET enabled=1 WHERE id = ?", (peer_id,))
conn.commit()
flash("Peer включен", "ok")
return redirect(url_for("index"))
@app.post("/peers/<int:peer_id>/delete")
def peer_delete(peer_id: int):
with db_conn() as conn:
cur = conn.cursor()
cur.execute("SELECT * FROM peers WHERE id = ?", (peer_id,))
row = cur.fetchone()
if not row:
flash("Клиент не найден", "error")
return redirect(url_for("index"))
item = dict(row)
pk = item.get("public_key", "")
if pk:
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except Exception:
pass
with db_conn() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM peers WHERE id = ?", (peer_id,))
conn.commit()
flash("Peer удален", "ok")
return redirect(url_for("index"))
@app.post("/peers/delete-by-key")
def peer_delete_by_key():
pk = (request.form.get("public_key") or "").strip()
if not pk:
flash("Не найден public key", "error")
return redirect(url_for("index"))
try:
run(["/usr/local/sbin/wg-peerctl", "remove", "--client-public-key", pk])
except Exception:
pass
with db_conn() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM peers WHERE public_key = ?", (pk,))
conn.commit()
flash("Peer удален", "ok")
return redirect(url_for("index"))
@app.route("/scripts")
def scripts():
commands = {

View File

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

View File

@@ -14,6 +14,7 @@ main { padding: 22px; }
label { display: grid; gap: 6px; }
input, select, button { padding: 10px; border-radius: 10px; border: 1px solid #bad2bf; font-size: 14px; }
button { background: var(--brand); color: #fff; border: 0; font-weight: 700; cursor: pointer; }
button.danger { background: #b42318; }
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #d7e7da; }
th, td { border-bottom: 1px solid #e0ece2; padding: 8px; font-size: 13px; text-align: left; vertical-align: top; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }

View File

@@ -5,7 +5,7 @@
<table>
<thead>
<tr>
<th>Имя</th><th>Статус</th><th>IP</th><th>Роуты</th><th>AllowedIPs</th><th>Endpoint</th><th>Handshake</th><th>RX</th><th>TX</th><th>PubKey</th>
<th>Имя</th><th>Статус</th><th>IP</th><th>Роуты</th><th>AllowedIPs</th><th>Endpoint</th><th>Handshake</th><th>RX</th><th>TX</th><th>PubKey</th><th>Действие</th>
</tr>
</thead>
<tbody>
@@ -21,6 +21,19 @@
<td>{{ p.rx }}</td>
<td>{{ p.tx }}</td>
<td class="mono">{{ p.public_key }}</td>
<td>
{% if p.id %}
<a href="{{ url_for('peer_view', peer_id=p.id) }}">QR/Config</a>
<form method="post" action="{{ url_for('peer_delete', peer_id=p.id) }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<button type="submit" class="danger">Удалить</button>
</form>
{% else %}
<form method="post" action="{{ url_for('peer_delete_by_key') }}" style="display:inline" onsubmit="return confirm('Удалить peer?')">
<input type="hidden" name="public_key" value="{{ p.public_key }}" />
<button type="submit" class="danger">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -6,17 +6,41 @@
<input name="name" required placeholder="astra" />
</label>
<label>Режим
<select name="mode">
<select name="mode" id="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') }}" />
<input
id="allowed_ips"
name="allowed_ips"
placeholder="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
data-default="{{ meta.get('WG_NETWORK','10.66.66.0/24') }}"
/>
</label>
<label>Сети за клиентом (роуты)
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" />
</label>
<button type="submit">Создать</button>
</form>
<script>
(() => {
const mode = document.getElementById("mode");
const allowed = document.getElementById("allowed_ips");
const def = allowed.dataset.default || "10.66.66.0/24";
function syncAllowed() {
if (mode.value === "split") {
if (!allowed.value.trim()) allowed.value = def;
allowed.readOnly = false;
} else {
allowed.readOnly = true;
}
}
mode.addEventListener("change", syncAllowed);
syncAllowed();
})();
</script>
{% endblock %}

View File

@@ -2,6 +2,7 @@
{% block content %}
<h2>Peer создан: {{ name }}</h2>
<p>PublicKey: <span class="mono">{{ public_key }}</span></p>
<p><a href="data:text/plain;charset=utf-8,{{ client_conf | urlencode }}" download="{{ name }}.conf">Скачать {{ name }}.conf</a></p>
<div class="grid2">
<div>
<h3>QR</h3>

View File

@@ -24,7 +24,6 @@ GUI_USER="admin"
GUI_PASSWORD=""
GUI_PASSWORD_GENERATED=0
GUI_RESET_DB="no"
GUI_DB_PASSWORD=""
usage() {
cat <<'USAGE'
@@ -116,16 +115,6 @@ 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"
@@ -149,14 +138,9 @@ reset_existing_install() {
rm -f /etc/systemd/system/wg-admin-gui.service
if command -v docker >/dev/null 2>&1; then
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
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
(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 wg-admin-postgres >/dev/null 2>&1 || true
@@ -213,8 +197,6 @@ collect_inputs() {
fi
fi
fi
GUI_DB_PASSWORD="$(random_alnum 24)"
[[ "$GUI_RESET_DB" == "yes" || "$GUI_RESET_DB" == "no" ]] || die "--gui-reset-db должен быть yes или no"
fi
@@ -224,15 +206,7 @@ collect_inputs() {
install_packages() {
apt_install_if_missing \
wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \
docker.io python3 python3-venv python3-pip
if apt-cache show docker-compose-plugin >/dev/null 2>&1; then
apt_install_if_missing docker-compose-plugin
elif apt-cache show docker-compose >/dev/null 2>&1; then
apt_install_if_missing docker-compose
else
log_warn "Не найден пакет docker-compose-plugin/docker-compose. Проверьте репозитории APT."
fi
python3 python3-venv python3-pip
}
setup_sysctl() {
@@ -371,37 +345,17 @@ EOF_SYNC_PATH
setup_gui() {
[[ "$GUI_ENABLE" == "yes" ]] || { log_warn "GUI отключен (GUI_ENABLE=no)"; return; }
mkdir -p /opt/wg-admin-gui/{app,pgdata}
mkdir -p /opt/wg-admin-gui/{app,data}
safe_chmod_700 /opt/wg-admin-gui
cp -a "${PROJECT_ROOT}/gui/." /opt/wg-admin-gui/app/
cat > /opt/wg-admin-gui/docker-compose.yml <<EOF_COMPOSE
services:
postgres:
image: postgres:16-alpine
container_name: wg-admin-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=wgadmin
- POSTGRES_USER=wgadmin
- POSTGRES_PASSWORD=${GUI_DB_PASSWORD}
ports:
- "127.0.0.1:5432:5432"
volumes:
- /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)
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
cat > /opt/wg-admin-gui/wg-admin-gui.env <<EOF_ENV
DB_DSN=postgresql://wgadmin:${GUI_DB_PASSWORD}@127.0.0.1:5432/wgadmin
DB_PATH=/opt/wg-admin-gui/data/wgadmin.db
WG_INTERFACE=${WG_INTERFACE}
WG_META_FILE=/etc/wireguard/wg-meta.env
ADMIN_USER=${GUI_USER}
@@ -414,7 +368,7 @@ EOF_ENV
cat > /etc/systemd/system/wg-admin-gui.service <<EOF_SERVICE
[Unit]
Description=WG Admin GUI
After=network-online.target docker.service
After=network-online.target
Wants=network-online.target
[Service]

View File

@@ -15,6 +15,7 @@ fi
LOG_FILE="/var/log/wireguard-peerctl.log"
WG_META_FILE="/etc/wireguard/wg-meta.env"
GUI_DB_FILE="/opt/wg-admin-gui/data/wgadmin.db"
usage() {
cat <<'USAGE'
@@ -27,12 +28,89 @@ usage() {
[--client-preshared-key <psk>] \
[--persistent-keepalive 25]
wg-peerctl.sh remove \
--client-public-key <pubkey>
Описание:
Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно.
Если peer с таким public key уже существует, повторно не добавляет.
USAGE
}
sql_escape() {
local s="$1"
s="${s//\'/\'\'}"
printf "%s" "$s"
}
ensure_gui_db_schema() {
command -v sqlite3 >/dev/null 2>&1 || return 0
[[ -f "$GUI_DB_FILE" ]] || return 0
sqlite3 "$GUI_DB_FILE" <<'SQL' >/dev/null 2>&1 || true
CREATE TABLE IF NOT EXISTS peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
public_key TEXT UNIQUE NOT NULL,
client_address TEXT,
advertised_routes TEXT,
client_conf TEXT,
peer_psk TEXT,
peer_allowed_ips TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE peers ADD COLUMN client_conf TEXT;
ALTER TABLE peers ADD COLUMN peer_psk TEXT;
ALTER TABLE peers ADD COLUMN peer_allowed_ips TEXT;
ALTER TABLE peers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
SQL
}
sync_gui_db_upsert_peer() {
local name="$1"
local pubkey="$2"
local address="$3"
local routes="$4"
local psk="$5"
local peer_allowed_ips="$6"
local enabled="${7:-1}"
command -v sqlite3 >/dev/null 2>&1 || return 0
[[ -f "$GUI_DB_FILE" ]] || return 0
ensure_gui_db_schema
local e_name e_pub e_addr e_routes e_psk e_allowed
e_name="$(sql_escape "$name")"
e_pub="$(sql_escape "$pubkey")"
e_addr="$(sql_escape "$address")"
e_routes="$(sql_escape "$routes")"
e_psk="$(sql_escape "$psk")"
e_allowed="$(sql_escape "$peer_allowed_ips")"
sqlite3 "$GUI_DB_FILE" <<SQL >/dev/null 2>&1 || true
INSERT INTO peers(name, public_key, client_address, advertised_routes, peer_psk, peer_allowed_ips, enabled)
VALUES ('$e_name', '$e_pub', '$e_addr', '$e_routes', '$e_psk', '$e_allowed', $enabled)
ON CONFLICT(public_key)
DO UPDATE SET
name=excluded.name,
client_address=excluded.client_address,
advertised_routes=excluded.advertised_routes,
peer_psk=excluded.peer_psk,
peer_allowed_ips=excluded.peer_allowed_ips,
enabled=excluded.enabled;
SQL
}
sync_gui_db_set_enabled() {
local pubkey="$1"
local enabled="$2"
command -v sqlite3 >/dev/null 2>&1 || return 0
[[ -f "$GUI_DB_FILE" ]] || return 0
local e_pub
e_pub="$(sql_escape "$pubkey")"
sqlite3 "$GUI_DB_FILE" "UPDATE peers SET enabled=${enabled} WHERE public_key='${e_pub}';" >/dev/null 2>&1 || true
}
load_meta() {
[[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh"
# shellcheck disable=SC1090
@@ -129,6 +207,71 @@ extract_peer_address_by_pubkey() {
' "$WG_CONF" | awk -F',' '{print $1}' | xargs
}
extract_peer_allowed_ips_by_pubkey() {
local pubkey="$1"
awk -v pk="$pubkey" '
$0 ~ /^\[Peer\]/ {in_peer=1; key=""; allowed=""}
in_peer && $0 ~ /^PublicKey[[:space:]]*=/ {
sub(/^[^=]*=[[:space:]]*/, "", $0); key=$0
}
in_peer && $0 ~ /^AllowedIPs[[:space:]]*=/ {
sub(/^[^=]*=[[:space:]]*/, "", $0); allowed=$0
}
in_peer && key==pk && allowed!="" {print allowed; exit}
' "$WG_CONF" | xargs
}
routes_without_primary_address() {
local allowed_ips="$1"
local primary_addr="$2"
local out=""
local item
local norm_primary
norm_primary="$(echo "$primary_addr" | xargs)"
IFS=',' read -ra items <<< "$allowed_ips"
for item in "${items[@]}"; do
item="$(echo "$item" | xargs)"
[[ -z "$item" ]] && continue
if [[ -n "$norm_primary" && "$item" == "$norm_primary" ]]; then
continue
fi
if [[ -z "$out" ]]; then
out="$item"
else
out="${out},${item}"
fi
done
echo "$out"
}
apply_client_routes_now() {
local routes="${1:-}"
[[ -n "$routes" ]] || return 0
local cidr
IFS=',' read -ra cidrs <<< "$routes"
for cidr in "${cidrs[@]}"; do
cidr="$(echo "$cidr" | xargs)"
[[ -n "$cidr" ]] || continue
ip route replace "$cidr" dev "$WG_INTERFACE" proto static >/dev/null 2>&1 || true
done
}
remove_client_routes_now() {
local routes="${1:-}"
[[ -n "$routes" ]] || return 0
local cidr
IFS=',' read -ra cidrs <<< "$routes"
for cidr in "${cidrs[@]}"; do
cidr="$(echo "$cidr" | xargs)"
[[ -n "$cidr" ]] || continue
ip route del "$cidr" dev "$WG_INTERFACE" >/dev/null 2>&1 || true
done
}
apply_config() {
if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then
wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF")
@@ -179,7 +322,14 @@ cmd_add() {
if peer_exists_by_pubkey "$client_pubkey"; then
local existing_addr
local existing_allowed
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")"
existing_allowed="${existing_addr:-}"
if [[ -n "$client_routes" ]]; then
existing_allowed="${existing_allowed},${client_routes}"
apply_client_routes_now "$client_routes"
fi
sync_gui_db_upsert_peer "$client_name" "$client_pubkey" "${existing_addr:-}" "$client_routes" "$client_psk" "${existing_allowed}" 1
cat <<EOF_OUT
STATUS=exists
CLIENT_NAME=$client_name
@@ -219,6 +369,8 @@ EOF_OUT
} >> "$WG_CONF"
apply_config
apply_client_routes_now "$client_routes"
sync_gui_db_upsert_peer "$client_name" "$client_pubkey" "$client_address" "$client_routes" "$client_psk" "$peer_allowed_ips" 1
cat <<EOF_OUT
STATUS=created
@@ -232,6 +384,65 @@ WG_NETWORK=${WG_NETWORK}
EOF_OUT
}
cmd_remove() {
local client_pubkey=""
while [[ $# -gt 0 ]]; do
case "$1" in
--client-public-key)
client_pubkey="$2"; shift 2 ;;
*)
die "Неизвестный аргумент: $1"
;;
esac
done
[[ -n "$client_pubkey" ]] || die "Не указан --client-public-key"
load_meta
[[ -f "$WG_CONF" ]] || die "Не найден конфиг WireGuard: $WG_CONF"
local existing_allowed existing_addr existing_routes
existing_allowed="$(extract_peer_allowed_ips_by_pubkey "$client_pubkey")"
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")"
existing_routes="$(routes_without_primary_address "$existing_allowed" "$existing_addr")"
backup_file "$WG_CONF"
local tmp
tmp="$(mktemp)"
awk -v pk="$client_pubkey" '
BEGIN {in=0; block=""; keep=1}
/^\[Peer\]/ {
if (in && keep) printf "%s", block
in=1; block=$0 ORS; keep=1; next
}
{
if (in) {
block = block $0 ORS
if ($0 ~ /^PublicKey[[:space:]]*=/) {
line=$0
sub(/^[^=]*=[[:space:]]*/, "", line)
if (line == pk) keep=0
}
next
}
print
}
END {
if (in && keep) printf "%s", block
}
' "$WG_CONF" > "$tmp"
mv "$tmp" "$WG_CONF"
safe_chmod_600 "$WG_CONF"
apply_config
remove_client_routes_now "$existing_routes"
sync_gui_db_set_enabled "$client_pubkey" 0
cat <<EOF_OUT
STATUS=removed
PUBLIC_KEY=${client_pubkey}
WG_INTERFACE=${WG_INTERFACE}
EOF_OUT
}
main() {
local cmd="${1:-}"
if [[ -z "$cmd" ]]; then
@@ -246,6 +457,11 @@ main() {
check_os_supported
cmd_add "$@"
;;
remove)
require_root
check_os_supported
cmd_remove "$@"
;;
-h|--help|help)
usage
;;