Compare commits

..

11 Commits

10 changed files with 607 additions and 90 deletions

View File

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

View File

@@ -24,6 +24,7 @@ SSH_PASSWORD=""
KEEPALIVE="25" KEEPALIVE="25"
CLIENT_ADDRESS_PREFIX="24" CLIENT_ADDRESS_PREFIX="24"
FORWARDING_MODE="disabled"
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
@@ -303,6 +304,12 @@ build_allowed_ips() {
build_route_hooks_if_needed() { build_route_hooks_if_needed() {
PRE_UP="" PRE_UP=""
POST_DOWN="" 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 if [[ "$TUNNEL_MODE" != "full" ]]; then
return return
@@ -321,6 +328,41 @@ build_route_hooks_if_needed() {
fi 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() { build_client_interface_address() {
local ip_only local ip_only
ip_only="${CLIENT_ADDRESS%%/*}" ip_only="${CLIENT_ADDRESS%%/*}"
@@ -360,6 +402,14 @@ write_client_config() {
if [[ -n "$POST_DOWN" ]]; then if [[ -n "$POST_DOWN" ]]; then
echo "$POST_DOWN" echo "$POST_DOWN"
fi 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
echo "[Peer]" echo "[Peer]"
echo "PublicKey = ${SERVER_PUBLIC_KEY}" echo "PublicKey = ${SERVER_PUBLIC_KEY}"
@@ -403,6 +453,7 @@ Endpoint сервера: ${SERVER_ENDPOINT}
SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT} SSH сервер: ${SERVER_USER}@${SERVER_HOST}:${SSH_PORT}
Статус регистрации: ${SERVER_STATUS} Статус регистрации: ${SERVER_STATUS}
Сети за клиентом: ${ADVERTISE_SUBNETS:-не объявлены} Сети за клиентом: ${ADVERTISE_SUBNETS:-не объявлены}
LAN forwarding/NAT: ${FORWARDING_MODE}
Лог: ${LOG_FILE} Лог: ${LOG_FILE}
================================================= =================================================
EOF_SUMMARY EOF_SUMMARY
@@ -434,6 +485,7 @@ main() {
build_allowed_ips build_allowed_ips
build_client_interface_address build_client_interface_address
build_route_hooks_if_needed build_route_hooks_if_needed
build_lan_nat_hooks_if_needed
write_client_config write_client_config
apply_client_config apply_client_config
print_summary print_summary

View File

@@ -2,42 +2,60 @@
import base64 import base64
import io import io
import os import os
import sqlite3
import subprocess import subprocess
import time
from datetime import datetime from datetime import datetime
import qrcode import qrcode
from flask import Flask, redirect, render_template, request, url_for, flash, Response 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 = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET", "dev-secret") 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_INTERFACE = os.environ.get("WG_INTERFACE", "wg0")
WG_META_FILE = os.environ.get("WG_META_FILE", "/etc/wireguard/wg-meta.env") WG_META_FILE = os.environ.get("WG_META_FILE", "/etc/wireguard/wg-meta.env")
ADMIN_USER = os.environ.get("ADMIN_USER", "admin") ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
ONLINE_WINDOW_SEC = int(os.environ.get("ONLINE_WINDOW_SEC", "120"))
def db_conn(): 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(): def ensure_schema():
with db_conn() as conn, conn.cursor() as cur: with db_conn() as conn:
cur.execute( cur = conn.cursor()
""" cur.execute("""
CREATE TABLE IF NOT EXISTS peers ( CREATE TABLE IF NOT EXISTS peers (
id BIGSERIAL PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
public_key TEXT UNIQUE NOT NULL, public_key TEXT UNIQUE NOT NULL,
client_address TEXT, client_address TEXT,
advertised_routes 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() conn.commit()
@@ -69,6 +87,61 @@ def parse_kv(text):
return out 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(): def wg_dump():
try: try:
out = run(["wg", "show", WG_INTERFACE, "dump"]) out = run(["wg", "show", WG_INTERFACE, "dump"])
@@ -80,15 +153,18 @@ def wg_dump():
parts = line.split("\t") parts = line.split("\t")
if len(parts) < 8: if len(parts) < 8:
continue continue
latest_ts = 0
latest = "never" latest = "never"
if parts[5].isdigit() and int(parts[5]) > 0: 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( peers.append(
{ {
"public_key": parts[0], "public_key": parts[0],
"endpoint": parts[2] or "-", "endpoint": parts[2] or "-",
"allowed_ips": parts[3], "allowed_ips": parts[3],
"latest_handshake": latest, "latest_handshake": latest,
"latest_handshake_ts": latest_ts,
"rx_bytes": int(parts[6] or 0), "rx_bytes": int(parts[6] or 0),
"tx_bytes": int(parts[7] or 0), "tx_bytes": int(parts[7] or 0),
} }
@@ -115,7 +191,7 @@ def gen_keypair_psk():
def to_png_b64(text): def to_png_b64(text):
img = qrcode.make(text) img = qrcode.make(text)
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="PNG") img.save(buf)
return base64.b64encode(buf.getvalue()).decode("ascii") return base64.b64encode(buf.getvalue()).decode("ascii")
@@ -150,17 +226,23 @@ def _schema():
def index(): def index():
meta = load_meta() meta = load_meta()
runtime = {p["public_key"]: p for p in wg_dump()} 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") cur.execute("SELECT * FROM peers ORDER BY id DESC")
db_peers = cur.fetchall() db_peers = [dict(r) for r in cur.fetchall()]
items = [] items = []
seen = set() seen = set()
now = int(time.time())
for row in db_peers: for row in db_peers:
rt = runtime.get(row["public_key"], {}) 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"]) seen.add(row["public_key"])
items.append( items.append(
{ {
"id": row["id"],
"name": row["name"], "name": row["name"],
"public_key": row["public_key"], "public_key": row["public_key"],
"client_address": row.get("client_address") or "-", "client_address": row.get("client_address") or "-",
@@ -170,25 +252,46 @@ def index():
"latest_handshake": rt.get("latest_handshake", "offline"), "latest_handshake": rt.get("latest_handshake", "offline"),
"rx": bytes_h(rt.get("rx_bytes", 0)), "rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_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(): for pk, rt in runtime.items():
if pk in seen: if pk in seen:
continue 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( items.append(
{ {
"name": "(external)", "id": ext_id,
"name": imported_name,
"public_key": pk, "public_key": pk,
"client_address": rt.get("allowed_ips", "-").split(",", 1)[0], "client_address": imported_addr,
"routes": "-", "routes": imported_routes,
"allowed_ips": rt.get("allowed_ips", "-"), "allowed_ips": rt.get("allowed_ips", "-"),
"endpoint": rt.get("endpoint", "-"), "endpoint": rt.get("endpoint", "-"),
"latest_handshake": rt.get("latest_handshake", "offline"), "latest_handshake": rt.get("latest_handshake", "offline"),
"rx": bytes_h(rt.get("rx_bytes", 0)), "rx": bytes_h(rt.get("rx_bytes", 0)),
"tx": bytes_h(rt.get("tx_bytes", 0)), "tx": bytes_h(rt.get("tx_bytes", 0)),
"status": "online", "status": "online" if is_online else "offline",
} }
) )
@@ -260,16 +363,17 @@ def new_peer():
client_conf = "\n".join(conf_lines) client_conf = "\n".join(conf_lines)
qr_b64 = to_png_b64(client_conf) 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( cur.execute(
""" "UPDATE peers SET name=?, client_address=?, advertised_routes=?, client_conf=?, peer_psk=?, peer_allowed_ips=?, enabled=1 WHERE public_key=?",
INSERT INTO peers(name, public_key, client_address, advertised_routes) (name, client_addr, routes, client_conf, client_psk, client_addr + (("," + routes) if routes else ""), client_pub),
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),
) )
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() conn.commit()
return render_template( return render_template(
@@ -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") @app.route("/scripts")
def scripts(): def scripts():
commands = { commands = {

View File

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

View File

@@ -14,6 +14,7 @@ main { padding: 22px; }
label { display: grid; gap: 6px; } label { display: grid; gap: 6px; }
input, select, button { padding: 10px; border-radius: 10px; border: 1px solid #bad2bf; font-size: 14px; } 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 { 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; } 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; } 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; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }

View File

@@ -5,7 +5,7 @@
<table> <table>
<thead> <thead>
<tr> <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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -21,6 +21,19 @@
<td>{{ p.rx }}</td> <td>{{ p.rx }}</td>
<td>{{ p.tx }}</td> <td>{{ p.tx }}</td>
<td class="mono">{{ p.public_key }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -6,17 +6,41 @@
<input name="name" required placeholder="astra" /> <input name="name" required placeholder="astra" />
</label> </label>
<label>Режим <label>Режим
<select name="mode"> <select name="mode" id="mode">
<option value="full">full (весь трафик через VPN)</option> <option value="full">full (весь трафик через VPN)</option>
<option value="split">split (только выбранные сети)</option> <option value="split">split (только выбранные сети)</option>
</select> </select>
</label> </label>
<label>AllowedIPs (для split) <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>
<label>Сети за клиентом (роуты) <label>Сети за клиентом (роуты)
<input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" /> <input name="routes" placeholder="192.168.33.0/24,10.10.0.0/16" />
</label> </label>
<button type="submit">Создать</button> <button type="submit">Создать</button>
</form> </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 %} {% endblock %}

View File

@@ -2,6 +2,7 @@
{% block content %} {% block content %}
<h2>Peer создан: {{ name }}</h2> <h2>Peer создан: {{ name }}</h2>
<p>PublicKey: <span class="mono">{{ public_key }}</span></p> <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 class="grid2">
<div> <div>
<h3>QR</h3> <h3>QR</h3>

View File

@@ -24,7 +24,6 @@ GUI_USER="admin"
GUI_PASSWORD="" GUI_PASSWORD=""
GUI_PASSWORD_GENERATED=0 GUI_PASSWORD_GENERATED=0
GUI_RESET_DB="no" GUI_RESET_DB="no"
GUI_DB_PASSWORD=""
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
@@ -116,16 +115,6 @@ validate_inputs() {
fi 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() { reset_existing_install() {
log_warn "Выполняю полный reset предыдущей инсталляции WireGuard/GUI" log_warn "Выполняю полный reset предыдущей инсталляции WireGuard/GUI"
@@ -149,14 +138,9 @@ reset_existing_install() {
rm -f /etc/systemd/system/wg-admin-gui.service rm -f /etc/systemd/system/wg-admin-gui.service
if command -v docker >/dev/null 2>&1; then 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 if [[ -f /opt/wireguard-ui/docker-compose.yml ]]; then
detect_compose_cmd (cd /opt/wireguard-ui && docker compose down --remove-orphans >/dev/null 2>&1) || true
(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
fi fi
docker rm -f wireguard-ui wg-admin-postgres >/dev/null 2>&1 || true docker rm -f wireguard-ui wg-admin-postgres >/dev/null 2>&1 || true
@@ -213,8 +197,6 @@ collect_inputs() {
fi fi
fi fi
fi fi
GUI_DB_PASSWORD="$(random_alnum 24)"
[[ "$GUI_RESET_DB" == "yes" || "$GUI_RESET_DB" == "no" ]] || die "--gui-reset-db должен быть yes или no" [[ "$GUI_RESET_DB" == "yes" || "$GUI_RESET_DB" == "no" ]] || die "--gui-reset-db должен быть yes или no"
fi fi
@@ -224,15 +206,7 @@ collect_inputs() {
install_packages() { install_packages() {
apt_install_if_missing \ apt_install_if_missing \
wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \ wireguard wireguard-tools iproute2 iptables curl ca-certificates openssl \
docker.io python3 python3-venv python3-pip 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
} }
setup_sysctl() { setup_sysctl() {
@@ -371,37 +345,17 @@ EOF_SYNC_PATH
setup_gui() { setup_gui() {
[[ "$GUI_ENABLE" == "yes" ]] || { log_warn "GUI отключен (GUI_ENABLE=no)"; return; } [[ "$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 safe_chmod_700 /opt/wg-admin-gui
cp -a "${PROJECT_ROOT}/gui/." /opt/wg-admin-gui/app/ 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 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 --upgrade pip >/dev/null
/opt/wg-admin-gui/venv/bin/pip install -r /opt/wg-admin-gui/app/requirements.txt >/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 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_INTERFACE=${WG_INTERFACE}
WG_META_FILE=/etc/wireguard/wg-meta.env WG_META_FILE=/etc/wireguard/wg-meta.env
ADMIN_USER=${GUI_USER} ADMIN_USER=${GUI_USER}
@@ -414,7 +368,7 @@ EOF_ENV
cat > /etc/systemd/system/wg-admin-gui.service <<EOF_SERVICE cat > /etc/systemd/system/wg-admin-gui.service <<EOF_SERVICE
[Unit] [Unit]
Description=WG Admin GUI Description=WG Admin GUI
After=network-online.target docker.service After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]

View File

@@ -15,6 +15,7 @@ fi
LOG_FILE="/var/log/wireguard-peerctl.log" LOG_FILE="/var/log/wireguard-peerctl.log"
WG_META_FILE="/etc/wireguard/wg-meta.env" WG_META_FILE="/etc/wireguard/wg-meta.env"
GUI_DB_FILE="/opt/wg-admin-gui/data/wgadmin.db"
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
@@ -27,12 +28,89 @@ usage() {
[--client-preshared-key <psk>] \ [--client-preshared-key <psk>] \
[--persistent-keepalive 25] [--persistent-keepalive 25]
wg-peerctl.sh remove \
--client-public-key <pubkey>
Описание: Описание:
Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно. Скрипт добавляет peer в конфигурацию WireGuard-сервера идемпотентно.
Если peer с таким public key уже существует, повторно не добавляет. Если peer с таким public key уже существует, повторно не добавляет.
USAGE 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() { load_meta() {
[[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh" [[ -f "$WG_META_FILE" ]] || die "Не найден $WG_META_FILE. Сначала выполните install_server.sh"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
@@ -129,6 +207,71 @@ extract_peer_address_by_pubkey() {
' "$WG_CONF" | awk -F',' '{print $1}' | xargs ' "$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() { apply_config() {
if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then if systemctl is-active --quiet "wg-quick@${WG_INTERFACE}"; then
wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF") wg syncconf "$WG_INTERFACE" <(wg-quick strip "$WG_CONF")
@@ -179,7 +322,14 @@ cmd_add() {
if peer_exists_by_pubkey "$client_pubkey"; then if peer_exists_by_pubkey "$client_pubkey"; then
local existing_addr local existing_addr
local existing_allowed
existing_addr="$(extract_peer_address_by_pubkey "$client_pubkey")" 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 cat <<EOF_OUT
STATUS=exists STATUS=exists
CLIENT_NAME=$client_name CLIENT_NAME=$client_name
@@ -219,6 +369,8 @@ EOF_OUT
} >> "$WG_CONF" } >> "$WG_CONF"
apply_config 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 cat <<EOF_OUT
STATUS=created STATUS=created
@@ -232,6 +384,65 @@ WG_NETWORK=${WG_NETWORK}
EOF_OUT 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() { main() {
local cmd="${1:-}" local cmd="${1:-}"
if [[ -z "$cmd" ]]; then if [[ -z "$cmd" ]]; then
@@ -246,6 +457,11 @@ main() {
check_os_supported check_os_supported
cmd_add "$@" cmd_add "$@"
;; ;;
remove)
require_root
check_os_supported
cmd_remove "$@"
;;
-h|--help|help) -h|--help|help)
usage usage
;; ;;