A Telegram bot and Django admin panel that manage a fleet of self-hosted VPN servers — issuing, revoking and renewing client configs for VLESS (Xray) and AmneziaWG without anyone touching the servers by hand.
The bot is the user-facing half: request access, get a config, see your configs. The Django admin is the operator half: approve users, manage servers and protocols, set quotas, deactivate configs. Both act on the same models, and every model change is pushed to the actual server over SSH.
Access is gated. A new user can only press "request access". That creates an
AccessRequest and sends the admin an inline ✅/❌ keyboard in Telegram.
Until approval the user is inactive and gets nothing. Each user has a config
quota; admins are exempt.
Configs are placed automatically. On creation, VPNServer.get_least_loaded()
picks the active server with the most free slots, honouring each server's
max_configs limit. If every server is full, creation fails loudly rather than
silently overloading one.
Model state is the source of truth. Saving a config generates it on the
chosen server; flipping is_active enables or disables the client remotely;
deleting the row removes the peer from the server. Deactivating a user
cascades to all of their configs. Configs expire after a year by default and can
be renewed.
Two protocols, one interface. XRayManager and WGManager both extend
BaseConfigManager, which handles the transport: pull the server's config file,
mutate it, push it back, restart the container — all as a context manager, so
the connection and temp files are always cleaned up.
Every server operation goes over SSH with Paramiko, and the config file is reached one of two ways depending on how that server is set up:
- Mounted volumes — SFTP straight to
config_pathon the host. - No volumes —
docker cpin and out of the running container.
For VLESS, adding a client does not go through the config file at all. It
calls Xray's gRPC HandlerService.AlterInbound with an AddUserOperation
(flow: xtls-rprx-vision), so a new client is live immediately with no container
restart. gRPC failures are translated into typed exceptions —
EmailExistsError, InboundTagNotFoundError, XrayError — instead of raw
RpcError.
For AmneziaWG, WGManager generates the keypair, allocates the next free IP,
appends the peer, and builds the client .conf file to hand back to the user.
- Django 5.2 — models, admin panel, migrations
- aiogram 3 — async Telegram bot, routers, middleware
- Paramiko — SSH and SFTP to the VPN servers
- grpcio — Xray Handler API client (generated stubs in
xray_api/grpc_generated_files) - PostgreSQL (SQLite optionally, via
SQLITE_DB=true) - Sentry — error reporting
- wgconfig / wireguard-tools — WireGuard config handling
- Docker · uv · Python 3.12
The bot bridges async aiogram handlers to sync Django ORM calls with
asgiref.sync_to_async.
uv syncCreate a .env file:
TELEGRAM_BOT_TOKEN=your-bot-token
MAIN_ADMIN_ID=your-telegram-id
SSH_KEY_PATH=/path/to/private/key
WG_PRESHARED_KEY=your-wireguard-preshared-key
SENTRY_DSN_DJANGO=your-sentry-dsn
POSTGRES_DB=vpn_manager
POSTGRES_USER=vpn_manager_user
POSTGRES_PASSWORD=
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# SQLITE_DB=true # use SQLite instead of PostgreSQLThen:
cd vpn_manager
uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py runserver # admin panel
uv run python telegram_bot/main.py # botOr with Docker:
docker compose up -dAdd a VPNServer and its ServerProtocol in the admin panel before issuing the
first config.
| Path | Role |
|---|---|
django_vpn/models/ |
VPNUser, AccessRequest, VPNServer, ServerProtocol, VLESSConfig, AmneziaWGConfig |
django_vpn/managers/ |
BaseConfigManager and the VLESS / AmneziaWG implementations |
django_vpn/xray_api/ |
Xray gRPC client, generated protobuf stubs, typed exceptions |
django_vpn/utils/ |
SSH transport, WireGuard and VLESS config helpers |
django_vpn/admin.py |
the operator console — users, configs, servers, inlines |
telegram_bot/ |
aiogram routers, authorization middleware, config generation |
django_vpn/management/commands/ |
fix_config, migrate_to_3xui (bulk-migrate VLESS users into a 3x-ui panel, supports --dry-run) |
Working and in use, but this is a personal-scale tool, not a product:
- No automated tests. The riskiest gap, given that a bad save mutates a live server's config.
- Config expiry is not enforced.
expires_atis stored andis_expired()exists, but nothing sweeps expired configs yet. - Config generation is synchronous. A slow SSH round-trip blocks the bot handler; moving it to a task queue is the main planned change.
celeryandqrcodeare declared as dependencies but not yet used.
Planned, roughly in order of value: expiry enforcement and a periodic reconciliation check between the database and what is actually on each server; async config generation; per-config user comments; payments through the bot.
Superseded for my own use by the Remnawave ecosystem, which I now contribute to instead — this project is where I worked out the problem first.
MIT