MAATRIX / Блог / Автоматическое добавление пользователей VPN через API

Автоматическое добавление пользователей VPN через API

MAATRIX

Если вы выдаёте доступ к VPN больше чем трём-пяти людям, ручное добавление пиров через SSH быстро превращается в рутину: зайти на сервер, сгенерировать пару ключей, вписать в конфиг, не забыть выделить свободный IP, отправить конфиг пользователю. На пятом человеке начинаются опечатки, на двадцатом — конфликты адресов. Ниже — рабочая схема, как обернуть выдачу доступов в HTTP API, чтобы новый VPN-доступ создавался одним запросом, а не сессией в терминале.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Зачем автоматизировать выдачу VPN-доступов

Ручной SSH-путь работает, пока пользователей немного и выдаёт их один и тот же человек, который помнит, какие IP уже заняты. Проблемы начинаются на масштабе:

  • Человеческий фактор. Скопировали не тот публичный ключ, забыли allowed-ips, выдали занятый адрес — тунель не поднимается или конфликтует с другим клиентом.
  • Нет единой точки правды. Список пользователей живёт в блокноте, а не в базе — сложно понять, кому и когда выдан доступ, кто просрочен.
  • Нельзя делегировать. Выдачу обычно делает один админ с root-доступом, потому что остальным опасно давать SSH.
  • Не встраивается в другие системы. Заявки из тикет-системы, Telegram-бота или личного кабинета руками не синхронизировать с VPN-сервером.

API решает все четыре пункта: логика создания пира описана один раз, SSH нужен только для обслуживания самого API-сервиса, а выдачу доступа можно доверить боту, скрипту CI или менеджеру без root-прав.

Архитектура автоматической выдачи

Схема простая и без магии:

  1. На VPN-сервере поднимается лёгкий HTTP-сервис — процесс, который умеет вызывать wg/wg-quick и писать конфиги.
  2. Сервис слушает localhost или закрытый порт за reverse-proxy, доступ к нему — по API-ключу или клиентскому TLS-сертификату.
  3. Клиент (бот, панель, скрипт) шлёт POST с именем пользователя — сервис генерирует ключевую пару, выделяет IP из пула, применяет конфиг «на лету» и возвращает готовый .conf.
  4. Все пиры и их IP пишутся в небольшую базу (SQLite достаточно), чтобы не пересчитывать занятые адреса каждый раз.

Если у вас уже есть отдельный сервер под VPN — держите API-сервис прямо на нём, это проще, чем городить отдельную машину-оркестратор ради 10-строчного HTTP-роута.

Арендуйте сервер под свои задачи!

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Пишем сервис на FastAPI для WireGuard

Ниже минимальный, но рабочий каркас на Python + FastAPI. Он не претендует на production-грейд из коробки (о доработках — в разделе про безопасность), но показывает суть: никакой магии, обычные вызовы wg через subprocess.

# на сервере: WireGuard уже поднят как интерфейс wg0
apt install -y python3-venv wireguard-tools
python3 -m venv /opt/vpn-api/venv
/opt/vpn-api/venv/bin/pip install fastapi uvicorn[standard]

/opt/vpn-api/main.py:

import os
import sqlite3
import subprocess
import ipaddress
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel

WG_IFACE = "wg0"
SUBNET = ipaddress.ip_network("10.8.0.0/24")
SERVER_ENDPOINT = "203.0.113.10:51820"
SERVER_PUBKEY = os.environ["WG_SERVER_PUBKEY"]
API_KEY = os.environ["VPN_API_KEY"]
DB_PATH = "/opt/vpn-api/peers.db"

app = FastAPI()

def db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""CREATE TABLE IF NOT EXISTS peers (
        id INTEGER PRIMARY KEY, name TEXT UNIQUE, ip TEXT UNIQUE,
        pubkey TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)""")
    return conn

def next_free_ip(conn):
    used = {row[0] for row in conn.execute("SELECT ip FROM peers")}
    for host in SUBNET.hosts():
        ip = str(host)
        if ip not in used and not ip.endswith(".1"):
            return ip
    raise HTTPException(409, "IP pool exhausted")

class NewPeer(BaseModel):
    name: str

def check_key(x_api_key: str):
    if x_api_key != API_KEY:
        raise HTTPException(403, "invalid api key")

@app.post("/peers")
def create_peer(peer: NewPeer, x_api_key: str = Header(...)):
    check_key(x_api_key)
    conn = db()
    if conn.execute("SELECT 1 FROM peers WHERE name=?", (peer.name,)).fetchone():
        raise HTTPException(409, "peer already exists")

    privkey = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True).stdout.strip()
    pubkey = subprocess.run(["wg", "pubkey"], input=privkey, capture_output=True, text=True, check=True).stdout.strip()
    ip = next_free_ip(conn)

    subprocess.run(["wg", "set", WG_IFACE, "peer", pubkey, "allowed-ips", f"{ip}/32"], check=True)
    subprocess.run(["/usr/bin/wg-save.sh", WG_IFACE], check=True)  # см. ниже

    conn.execute("INSERT INTO peers (name, ip, pubkey) VALUES (?,?,?)", (peer.name, ip, pubkey))
    conn.commit()

    client_conf = f"""[Interface]
PrivateKey = {privkey}
Address = {ip}/32
DNS = 1.1.1.1

[Peer]
PublicKey = {SERVER_PUBKEY}
Endpoint = {SERVER_ENDPOINT}
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
"""
    return {"name": peer.name, "ip": ip, "config": client_conf}

@app.delete("/peers/{name}")
def delete_peer(name: str, x_api_key: str = Header(...)):
    check_key(x_api_key)
    conn = db()
    row = conn.execute("SELECT pubkey FROM peers WHERE name=?", (name,)).fetchone()
    if not row:
        raise HTTPException(404, "not found")
    subprocess.run(["wg", "set", WG_IFACE, "peer", row[0], "remove"], check=True)
    subprocess.run(["/usr/bin/wg-save.sh", WG_IFACE], check=True)
    conn.execute("DELETE FROM peers WHERE name=?", (name,))
    conn.commit()
    return {"status": "removed"}

Приватный ключ отдаётся клиенту один раз в ответе и на сервере не сохраняется — сервер вообще не должен хранить приватные ключи клиентов. Но если ответ потеряется, придётся выпускать пир заново.

Команда wg set меняет конфигурацию интерфейса на лету, без разрыва существующих туннелей — это ключевое преимущество WireGuard перед OpenVPN, где добавление пользователя обычно требует перевыпуска сертификата и не всегда применяется без перезапуска процесса. Про разницу в этом контексте — в статье WireGuard или OpenVPN: что выбрать для сервера.

Файл /usr/bin/wg-save.sh, который вызывает код выше, — простая обёртка над wg-quick save, переносящая рантайм-изменения в постоянный конфиг, чтобы они пережили перезагрузку сервера.

Запускаем как systemd-юнит с минимальными правами вместо root:

# /etc/systemd/system/vpn-api.service
[Unit]
Description=VPN peer provisioning API
After=network.target wg-quick@wg0.service

[Service]
EnvironmentFile=/opt/vpn-api/env
ExecStart=/opt/vpn-api/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
WorkingDirectory=/opt/vpn-api
User=vpnapi
AmbientCapabilities=CAP_NET_ADMIN
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/vpn-api /etc/wireguard
Restart=on-failure

[Install]
WantedBy=multi-user.target

Учёт пользователей и IP-адресов

В примере выше SQLite используется как источник правды по занятым адресам — это снимает главную причину ручных ошибок (выдача одного IP двум клиентам). Для реального использования стоит добавить в таблицу peers ещё пару полей:

ПолеЗачем
expires_atВременный доступ (подрядчику, гостю) — cron раз в час вызывает DELETE /peers/{name} для просроченных
ownerКто выдал доступ — полезно при аудите
traffic_bytesСнимок из wg show wg0 transfer, чтобы видеть, кто реально пользуется VPN
tagОтдел/проект — если один сервер обслуживает несколько команд

Скрипт отзыва просроченных доступов по крону:

#!/bin/bash
sqlite3 /opt/vpn-api/peers.db "SELECT name FROM peers WHERE expires_at < datetime('now')" | \
while read -r name; do
  curl -s -X DELETE -H "X-API-Key: $VPN_API_KEY" "http://127.0.0.1:8000/peers/$name"
done

Если нужен более тонкий контроль трафика на пользователя (не просто учёт, а лимиты по скорости), это уже отдельная задача на уровне tc/iptables поверх интерфейса wg0.

Безопасность API-сервиса

Сервис, который умеет добавлять и удалять сетевые пиры, — это по сути ключ от VPN-сервера, и относиться к нему нужно соответственно:

  • Не выставляйте API в интернет напрямую. Слушайте 127.0.0.1, пускайте наружу только через nginx с TLS и, желательно, mTLS или жёсткий allowlist по IP.
  • API-ключ — не единственная защита. Заголовок X-API-Key легко перехватить без TLS. HTTPS обязателен, даже если сервис используется только вашим ботом.
  • Не давайте процессу root. AmbientCapabilities=CAP_NET_ADMIN в systemd-юните — рабочая альтернатива: процесс меняет интерфейсы, но не читает чужие файлы и не ставит пакеты.
  • Логируйте каждый вызов — кто, когда и под каким именем создал или удалил пир. access.log от uvicorn плюс запись в БД обычно достаточно для разбора инцидентов.
  • Отдельный пользователь для юнита (User=vpnapi) — если сервис скомпрометируют через баг в коде, ущерб ограничен его правами, а не всей системой.
  • Rate limiting на уровне nginx (limit_req) защитит от перебора API-ключа или шторма запросов от сломавшегося бота.

Двухфакторная защита самого доступа к панели/боту, из которого дергается API, тоже не лишняя — общие принципы описаны в статье двухфакторная аутентификация для VPN.

Готовые инструменты вместо самописного API

Прежде чем писать своё, стоит понять, закрывает ли задачу готовое решение — самопис оправдан только при нестандартной интеграции.

РешениеПротоколAPI из коробкиКогда выбрать
wg-easyWireGuardДа, REST + веб-интерфейсБыстрый старт, панель для команды из 5-50 человек без кастомизации
Outline ServerShadowsocksДа, Management API с TLS и учётом трафикаКлиенты не технические, нужны простые ссылки-ключи вместо конфигов
Свой сервисWireGuard/OpenVPNПишете самиИнтеграция с ботом, тикет-системой, биллингом, кастомная логика

wg-easy разворачивается одним docker compose up и уже умеет генерировать пиры и QR-коды через веб-интерфейс и REST-эндпоинты — подробности установки и структура API в статье wg-easy: веб-панель для WireGuard. Если ваш кейс — просто «дать команде кнопку для самостоятельной выдачи себе доступа», wg-easy закроет вопрос без единой строчки кода. Самописный сервис имеет смысл, когда выдача — часть более крупного процесса: например, доступ создаётся автоматически при оплате подписки или приёме сотрудника в HR-системе.

Интеграция с ботом или личным кабинетом

Как только API есть, подключить к нему фронтенд — вопрос десяти строк. Пример вызова из Telegram-бота на python-telegram-bot:

import requests

def issue_vpn_access(username: str) -> str:
    resp = requests.post(
        "https://vpn-api.example.com/peers",
        headers={"X-API-Key": VPN_API_KEY},
        json={"name": username},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()["config"]

# в хендлере команды /getvpn
async def cmd_getvpn(update, context):
    conf = issue_vpn_access(update.effective_user.username)
    await update.message.reply_document(
        document=conf.encode(),
        filename=f"{update.effective_user.username}.conf",
    )

Так же API дёргается из CI/CD, из Zapier/n8n через HTTP-узел или из формы на внутреннем сайте. Важно: сам API не знает, кто его вызывает и почему — авторизация и бизнес-логика («этому пользователю положен доступ») остаются на стороне вызывающего сервиса, API отвечает только за техническую часть.

Для контроля за тем, что сервер и API-сервис в принципе живы, стоит добавить внешний мониторинг — как настроить проверку доступности разобрано в статье мониторинг доступности VPN-сервера через Uptime Kuma.

Арендуйте сервер под свои задачи!

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли так же автоматизировать OpenVPN, а не только WireGuard?

Да, но сложнее: вместо wg set придётся вызывать easy-rsa для выпуска сертификата, что медленнее и требует отзыва через CRL при удалении пользователя. WireGuard для API-подхода удобнее именно из-за возможности менять пиров на лету без даунтайма.

Что если сервис упадёт во время запроса, IP выделится, а пир не создастся?

Такое возможно при сбое между next_free_ip и INSERT — для продакшена стоит обернуть блок в транзакцию БД и делать wg set внутри try/except с откатом записи при ошибке.

Нужно ли хранить приватные ключи клиентов на сервере?

Нет — приватный ключ генерируется разово, отдаётся в ответе API и не сохраняется в базе. Сервер хранит только публичный ключ, которого достаточно для управления пиром.

Как ограничить, кто может дергать API?

Закрытым портом (только localhost), TLS на reverse-proxy, API-ключом в заголовке и, если нужно строже, — mTLS с клиентскими сертификатами.

Что делать при большом числе пользователей — сотни и больше?

SQLite ещё справится, но стоит разнести пул на несколько подсетей и добавить индекс по expires_at для быстрого поиска просроченных доступов.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →