Skip to content

feat: add ability to users to manage their vpn connections via Telegram and web portal - #92

Open
vikbaranov wants to merge 10 commits into
PRVTPRO:mainfrom
vikbaranov:feature/self-service-upstream
Open

feat: add ability to users to manage their vpn connections via Telegram and web portal#92
vikbaranov wants to merge 10 commits into
PRVTPRO:mainfrom
vikbaranov:feature/self-service-upstream

Conversation

@vikbaranov

Copy link
Copy Markdown

Core service

  • ConnectionService — orchestrates connection creation/deletion with per-user locks, rate limiting (sliding window), and ownership validation
  • SelfServiceError / RateLimitError — typed exceptions with HTTP status codes
  • Default settings: max 5 connections/user, 3 requests per 60s, protocols awg/awg2

Telegram bot

  • /connect command — interactive flow: select server → protocol → confirm → deploy
  • /disconnect command — list owned connections → confirm → delete
  • Full i18n via TG_TRANSLATIONS dict (~50 keys) with _tt(lang, key) for every message, button, and keyboard
  • lang propagation through _dispatch and all handler/keyboard builder functions
  • Rate limiting per user, connection ownership checks, inline keyboard confirmations

API

  • GET /api/my/connections/options — available servers, protocols, remaining quota
  • POST /api/my/connections/add — create a connection (server_id, protocol, name)
  • POST /api/my/connections/{id}/delete — delete an owned connection
  • GET /api/my/connections — list user's connections (extended with self-service metadata)

Web UI

  • Add my_connections.html page

Settings

  • Self-service toggle in global settings (web + telegram channels)
  • Per-server self_service_enabled flag in server edit form
  • Settings model: enabled, web_enabled, telegram_enabled, max_connections_per_user, rate_limit_count, rate_limit_window_seconds, allowed_protocols

Translations

  • Added self-service keys to all 5 languages: EN, RU, FR, FA, ZH

@helldweller helldweller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review — self-service connections

Reviewed the whole branch against main with a focus on what a regular (non-admin) user can reach, and specifically on whether any of this can be escalated into control of a VPN server through the Telegram bot.

The self-service core itself holds up well. ConnectionService validates ownership, intersects the protocol against a hard-coded {awg, awg2} allow-list twice, re-validates inside the lock (so the quota/status TOCTOU is closed), rolls the remote peer back when save_data fails, restricts deletion to created_by == 'self_service', and moves the blocking SSH work into asyncio.to_thread — better than the existing admin path, which blocks the event loop. The feature is off by default both globally and per server. The connection name reaches the server as json.dumps over SFTP (managers/awg_manager.py:804), never through a shell, so there is no command injection there. Test coverage is genuinely good.

The findings below are ordered by severity; details are in the inline comments.

Blockers, in my view

  1. _find_user() now resolves users by Telegram @username (telegram_bot.py:409). This is the one item that is not really about self-service. A username is mutable, released on rename/deletion, and re-claimable by anyone. _require_admin() uses the same lookup, so an admin whose telegramId field holds a handle rather than a numeric ID can be taken over by whoever claims that handle — which yields /addserver, every client config on every server (PrivateKey included), and docker start/stop of protocol containers. Recommendation: match on the numeric ID only. Users can obtain their own ID in one message via @userinfobot, so the onboarding cost is negligible.

  2. Stored XSS via the connection name. templates/users.html:863 interpolates c.name into innerHTML unescaped. That file is untouched here, but this PR is what makes the field user-controlled, so it becomes a user → admin-session escalation path. The new client-side rendering in templates/my_connections.html:203-224 also regresses from Jinja auto-escaping to partial manual escaping.

  3. The panel-wide DATA_LOCK is held across the whole SSH provisioning run (connection_service.py:97). Any user can stall every write in the panel for minutes by targeting a slow or unreachable server.

Worth fixing, but could be a follow-up

  1. Rate limiting counts only successful creations, so failed attempts — the ones that actually hammer the VPN node over SSH — are unlimited; deletion is not limited at all.
  2. Raw exception text is returned to API clients and Telegram users.
  3. The bot never checks chat["type"], so /connect in a group posts a config containing PrivateKey into the group.
  4. Fail-open when expiration_date cannot be parsed.

Two notes that are not inline

  • Stale-write race. save_data() rewrites the entire data.json, and several admin handlers (api_delete_server at app.py:2366, api_edit_server) write without taking DATA_LOCK. create_user_connection reads data, spends up to a couple of minutes on SSH, then writes the whole blob back — clobbering anything an admin changed meanwhile, including "disable user" or "delete server" (which would resurrect the entry together with its stored SSH credentials). A long user-triggered read → SSH → write-whole-blob cycle did not exist before this PR. Worth at least re-reading and merging just before the write. Separately, save_data writes in place with no tmp+rename, so a crash mid-write truncates the file that holds every server's SSH password and private key.

  • Blast radius of the feature itself. A self-service peer gets network access into the VPN subnet. If the panel or its admin UI is reachable from there, any linked user can issue themselves a route to it. Probably worth stating explicitly in the docs and/or constraining via AllowedIPs/firewall.

Happy to look again once the identity question in (1) is settled — that one changes the threat model for everything else here.

Comment thread telegram_bot.py
Comment on lines +409 to +415
# Fallback: try username-based lookup
if username:
username_clean = str(username).lstrip("@").lower()
for u in data.get("users", []):
stored = str(u.get("telegramId", "") or "").lstrip("@").lower()
if stored and stored == username_clean:
return u

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — this turns telegramId into a spoofable identifier.

On main, _find_user() matched telegramId against the numeric Telegram ID only. This fallback also accepts an @username, and a Telegram username is not an identity: the owner can change it at any time, it is released when the account is renamed or deleted, and anyone can then claim it (Fragment even sells them).

_require_admin() (line 893) resolves through this same function. So if an admin's profile has @handle in the telegramId field — which is the natural thing to type into a field labelled "Telegram ID" — whoever holds that handle next gets full admin control of the bot: /addserver, the server list with user@host:port, client_cfg for every client on every server (those configs contain PrivateKey), toggle_proto (docker start/stop of the protocol container), remove_client. For a non-admin it is straightforward impersonation of another panel user.

Suggestion: drop the fallback and keep numeric-ID matching only. Getting your own ID costs one message — e.g. @userinfobot replies with it — so the onboarding friction is negligible compared to the exposure. Validating the field as digits-only in the user form would close this for good.

If username lookup really has to stay for first-time linking, scope it strictly to that: on first contact write msg["from"]["id"] into a separate numeric telegram_id field and match on that from then on. In any case _require_admin() must never resolve by username.

Comment thread connection_service.py
Comment on lines +97 to +110
async with lock:
async with self.data_lock:
data = self.load_data()
settings = self._settings(data)
user = self._validate_create_request(data, settings, user_id, server_id, protocol, clean_name, source)
self._check_rate_limit(user_id, source, settings)
server = data['servers'][server_id]
port = server.get('protocols', {}).get(protocol, {}).get('port', '55424')
ssh = self.get_ssh(server)
remote_client_id = None
manager = None
try:
await asyncio.to_thread(ssh.connect)
manager = self.get_protocol_manager(ssh, protocol)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 The global DATA_LOCK is held for the entire SSH provisioning run.

self.data_lock is app.DATA_LOCK, the panel-wide write lock. add_client() for AWG is a chain of SSH calls (_insert_peer_sorted with a backup, wg syncconf, docker cp), each with a 60 s timeout, on top of a 15 s connect. For that whole window every other writer in the panel (save_data_async, reorder, auto-backup) blocks.

That means any regular self-service user can stall panel-wide writes for minutes just by targeting a server that is slow or down. The admin path (api_add_connection, app.py:3169) takes no lock at all, so this is new behaviour introduced here.

Suggested shape: hold DATA_LOCK only around the read-modify-write of the JSON (validation, and later the append + save_data), and run the SSH work outside it, guarded by _provision_locks[(server_id, protocol)] alone.

Comment thread connection_service.py
await self._rollback_client(manager, protocol, remote_client_id)
remote_client_id = None
raise
self._record_rate_event(user_id, source)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Rate limiting only counts successes, so it does not limit load on the VPN server.

_record_rate_event() runs only after a connection was fully created. An attempt that dies in ssh.connect or add_client is never counted, so failed attempts are unlimited — a user can hammer /api/my/connections/add and open unbounded root SSH sessions against the VPN node. Same for anything that fails earlier: _check_rate_limit() is called after _validate_create_request() (line 94), so validation failures are free too.

Related gaps in the same mechanism:

  • the key is (source, user_id), so web and telegram get independent quotas — effectively 2× the configured limit;
  • delete_user_connection() has no rate limit at all, and each call is an SSH round trip plus a config rewrite;
  • state is per-process and in-memory, so it resets on restart, and _rate_events / _provision_locks are defaultdicts that grow without bound.

Minimal fix: record the attempt before provisioning (or in a finally), not only on success.

Comment thread connection_service.py
def _check_rate_limit(self, user_id, source, settings):
count = int(settings.get('rate_limit_count', 3))
window = int(settings.get('rate_limit_window_seconds', 60))
if count <= 0 or window <= 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 rate_limit_count = 0 silently means "unlimited".

The settings form (templates/settings.html) presents this as a request count, so 0 reads as "no requests allowed" — here it disables the limiter entirely. Same for rate_limit_window_seconds. Either treat 0 as "deny" or reject it at the model level so the UI cannot produce it.

Comment thread connection_service.py
except SelfServiceError:
raise
except Exception as e:
logger.warning("Failed to parse expiration_date '%s': %s", expiration, e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fail-open on an unparsable expiration_date.

If the date does not parse we log a warning and fall through, treating the user as active. For an expiry check the safe default is the other way round — refuse and let the admin fix the record.

Comment thread app.py
Comment on lines +3753 to +3755
except Exception as e:
logger.exception("Error getting self-service options")
return JSONResponse({'error': str(e)}, status_code=500)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Internal exception text is returned to the client.

str(e) here carries whatever paramiko or the protocol manager raised — hostnames, remote paths, stderr from remote commands. Same pattern at lines 3771 and 3787. logger.exception is already doing the right thing; the response should be a generic message.

Comment thread telegram_bot.py
await api.edit_message(chat_id, loading_msg_id, f"✅ {_tt(lang, 'connection_created_success')}")
except Exception as e:
logger.exception("Bot: self-service create failed")
await api.send_message(chat_id, f"❌ {_tt(lang, 'error')}: {_e(e)}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Same as the API handlers — raw exception text is sent to the user.

_e(e) only HTML-escapes it; the content is still the internal error (host, path, remote stderr). Also at line 1547 in _user_delete_confirm. Keep the detail in logger.exception and send a generic failure message.

Comment thread telegram_bot.py

await api.answer_callback(callback_id)

_pending_inputs[str(chat_id)] = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 _pending_inputs is keyed by chat_id alone.

In a group chat the wizard state is shared by everyone in that chat, so a second linked user can complete the flow another user started. Ownership is re-derived from msg["from"]["id"] in _handle_pending_input, so no connection is created under the wrong account — but the state should still be keyed by (chat_id, from_id). Same at line 1134.

Comment thread telegram_bot.py
Comment on lines +329 to +338
async def _set_default_commands(api: TelegramAPI):
await api.call(
"setMyCommands",
commands=[
{"command": "start", "description": "Open bot menu"},
{"command": "connections", "description": "Show my connections"},
{"command": "connect", "description": "Create a new connection"},
{"command": "disconnect", "description": "Delete a connection"},
],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nothing in the bot checks chat["type"], and these commands are now advertised to everyone.

/connect, /connections and the cfg: callback all work in any chat the bot has been added to, and the resulting config — which contains PrivateKey — is delivered with sendMessage/sendDocument to that same chat. In a group every member sees it.

Some of this predates the PR (cfg:), but registering /connect and /disconnect in the command menu makes the scenario much more likely. Suggest gating the self-service flows (and ideally config delivery in general) on chat["type"] == "private".

Comment thread app.py
Comment on lines +1645 to +1652
class SelfServiceSettings(BaseModel):
enabled: bool = False
web_enabled: bool = True
telegram_enabled: bool = True
max_connections_per_user: int = 5
rate_limit_count: int = 3
rate_limit_window_seconds: int = 60
allowed_protocols: List[str] = ['awg', 'awg2']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor: no bounds on these fields.

templates/settings.html enforces min/max on the inputs, but the API accepts anything — max_connections_per_user: 100000 or a negative rate_limit_count go straight through. Admin-only, so low impact, but Field(ge=..., le=...) here would keep the model and the form in agreement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants