Add invite links with admin-configurable config creation limits.

Admins can create shareable links that allow redeeming a VPN config a set number of times (or unlimited).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:23:16 +03:00
co-authored by Cursor
parent 5386c4d40e
commit 7b3d8aac34
11 changed files with 1197 additions and 0 deletions
+395
View File
@@ -1879,6 +1879,39 @@ class GuestCreateRequest(BaseModel):
name: str = 'Guest VPN'
class InviteCreateRequest(BaseModel):
name: str = 'Invite'
max_uses: int = 1 # 0 = unlimited
user_id: str = ''
protocol: str = 'xui'
server_id: int = 0
xui_inbound_id: int = 0
password: Optional[str] = None
expires_at: Optional[str] = None
note: str = ''
enabled: bool = True
class InviteUpdateRequest(BaseModel):
name: Optional[str] = None
max_uses: Optional[int] = None
user_id: Optional[str] = None
protocol: Optional[str] = None
server_id: Optional[int] = None
xui_inbound_id: Optional[int] = None
password: Optional[str] = None
clear_password: bool = False
expires_at: Optional[str] = None
clear_expires: bool = False
note: Optional[str] = None
enabled: Optional[bool] = None
reset_used: bool = False
class InviteRedeemRequest(BaseModel):
name: str = 'Invite VPN'
class TunnelStartRequest(BaseModel):
authtoken: Optional[str] = None
@@ -4207,6 +4240,363 @@ async def api_guest_regenerate_token(request: Request):
return {'status': 'success', 'token': guest['token']}
# ======================== Invite links (limited config creation) ========================
def _invite_public_view(link: dict) -> dict:
max_uses = int(link.get('max_uses') or 0)
used = int(link.get('used_count') or 0)
remaining = None if max_uses <= 0 else max(0, max_uses - used)
expired = False
if link.get('expires_at'):
try:
exp = datetime.fromisoformat(str(link['expires_at']).replace('Z', '+00:00'))
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
expired = now > exp
except Exception:
expired = False
exhausted = remaining is not None and remaining <= 0
return {
'id': link.get('id'),
'name': link.get('name') or 'Invite',
'token': link.get('token'),
'enabled': bool(link.get('enabled', True)),
'max_uses': max_uses,
'used_count': used,
'remaining': remaining,
'unlimited': max_uses <= 0,
'expired': expired,
'exhausted': exhausted,
'has_password': bool(link.get('password_hash')),
'protocol': link.get('protocol') or 'xui',
'server_id': int(link.get('server_id') or 0),
'xui_inbound_id': int(link.get('xui_inbound_id') or 0),
'user_id': link.get('user_id') or '',
'note': link.get('note') or '',
'expires_at': link.get('expires_at'),
'created_at': link.get('created_at'),
'available': bool(link.get('enabled', True)) and not expired and not exhausted,
}
def _find_invite(data: dict, token: str) -> Optional[dict]:
return next((x for x in data.get('invite_links', []) if x.get('token') == token), None)
def _invite_auth_ok(link: dict, request: Request) -> bool:
if not link.get('password_hash'):
return True
return bool(request.session.get(f"invite_auth_{link.get('token')}"))
async def _create_config_for_protocol(
data: dict,
*,
protocol: str,
name: str,
server_id: int = 0,
xui_inbound_id: Optional[int] = None,
) -> dict:
"""Create VPN client; returns {client_id, config, protocol, server_id}."""
protocol = protocol or 'xui'
if protocol_base(protocol) == 'xui':
from managers.xui_api import xui_create_vless_config
created = await xui_create_vless_config(
data.get('settings', {}),
name=name,
inbound_id=xui_inbound_id,
)
return {
'client_id': created['client_id'],
'config': created.get('config') or '',
'protocol': 'xui',
'server_id': 0,
}
sid = int(server_id or 0)
if sid >= len(data['servers']):
raise RuntimeError('Server not found')
server = data['servers'][sid]
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
await asyncio.to_thread(ssh.connect)
try:
manager = get_protocol_manager(ssh, protocol)
if protocol_base(protocol) == 'wireguard':
result = await asyncio.to_thread(manager.add_client, name, server['host'])
else:
result = await asyncio.to_thread(
_manager_call, manager, 'add_client',
protocol, name, server['host'], port,
)
finally:
await asyncio.to_thread(ssh.disconnect)
if not result.get('client_id'):
raise RuntimeError('Failed to create config')
return {
'client_id': result['client_id'],
'config': result.get('config') or '',
'protocol': protocol,
'server_id': sid,
}
@app.get('/invites', response_class=HTMLResponse, tags=["System Templates"])
async def invites_page(request: Request):
user = get_current_user(request)
if not user:
return RedirectResponse(url='/login', status_code=302)
if user['role'] not in ('admin', 'support'):
return RedirectResponse(url='/my', status_code=302)
data = load_data()
return tpl(
request,
'invites.html',
invites=[_invite_public_view(x) for x in data.get('invite_links', [])],
users=data.get('users', []),
servers=data.get('servers', []),
xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')),
)
@app.get('/api/invites', tags=["Invites"])
async def api_list_invites(request: Request):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
return {'invites': [_invite_public_view(x) for x in data.get('invite_links', [])]}
@app.post('/api/invites', tags=["Invites"])
async def api_create_invite(request: Request, req: InviteCreateRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if req.max_uses < 0:
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
data = load_data()
if req.user_id and not any(u['id'] == req.user_id for u in data['users']):
return JSONResponse({'error': 'User not found'}, status_code=400)
link = {
'id': str(uuid.uuid4()),
'name': (req.name or 'Invite').strip() or 'Invite',
'token': secrets.token_urlsafe(16),
'enabled': bool(req.enabled),
'max_uses': int(req.max_uses),
'used_count': 0,
'user_id': req.user_id or '',
'protocol': req.protocol or 'xui',
'server_id': int(req.server_id or 0),
'xui_inbound_id': int(req.xui_inbound_id or 0),
'password_hash': hash_password(req.password) if req.password else None,
'expires_at': req.expires_at or None,
'note': req.note or '',
'created_at': datetime.now().isoformat(),
}
data.setdefault('invite_links', []).append(link)
save_data(data)
view = _invite_public_view(link)
return {'status': 'success', 'invite': view, 'url': f"/invite/{link['token']}"}
@app.put('/api/invites/{invite_id}', tags=["Invites"])
async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
link = next((x for x in data.get('invite_links', []) if x.get('id') == invite_id), None)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
if req.name is not None:
link['name'] = req.name.strip() or link['name']
if req.max_uses is not None:
if req.max_uses < 0:
return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400)
link['max_uses'] = int(req.max_uses)
if req.user_id is not None:
if req.user_id and not any(u['id'] == req.user_id for u in data['users']):
return JSONResponse({'error': 'User not found'}, status_code=400)
link['user_id'] = req.user_id
if req.protocol is not None:
link['protocol'] = req.protocol
if req.server_id is not None:
link['server_id'] = int(req.server_id)
if req.xui_inbound_id is not None:
link['xui_inbound_id'] = int(req.xui_inbound_id)
if req.clear_password:
link['password_hash'] = None
elif req.password:
link['password_hash'] = hash_password(req.password)
if req.clear_expires:
link['expires_at'] = None
elif req.expires_at is not None:
link['expires_at'] = req.expires_at or None
if req.note is not None:
link['note'] = req.note
if req.enabled is not None:
link['enabled'] = bool(req.enabled)
if req.reset_used:
link['used_count'] = 0
save_data(data)
return {'status': 'success', 'invite': _invite_public_view(link)}
@app.delete('/api/invites/{invite_id}', tags=["Invites"])
async def api_delete_invite(request: Request, invite_id: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
before = len(data.get('invite_links', []))
data['invite_links'] = [x for x in data.get('invite_links', []) if x.get('id') != invite_id]
if len(data['invite_links']) == before:
return JSONResponse({'error': 'Not found'}, status_code=404)
save_data(data)
return {'status': 'success'}
@app.get('/invite/{token}', response_class=HTMLResponse, tags=["System Templates"])
async def invite_public_page(token: str, request: Request):
data = load_data()
link = _find_invite(data, token)
lang = request.cookies.get('lang', 'ru')
if not link:
return HTMLResponse(
f"<h1>{_t('invite_not_found', lang)}</h1><p>{_t('invite_not_found_desc', lang)}</p>",
status_code=404,
)
view = _invite_public_view(link)
need_password = bool(link.get('password_hash')) and not _invite_auth_ok(link, request)
return tpl(
request,
'invite.html',
invite=view,
need_password=need_password,
token=token,
)
@app.post('/api/invite/{token}/auth', tags=["Invites"])
async def api_invite_auth(token: str, req: ShareAuthRequest, request: Request):
data = load_data()
link = _find_invite(data, token)
if not link or not link.get('enabled'):
return JSONResponse({'error': 'Link expired or disabled'}, status_code=404)
if not link.get('password_hash'):
request.session[f'invite_auth_{token}'] = True
return {'status': 'success'}
if verify_password(req.password, link.get('password_hash') or ''):
request.session[f'invite_auth_{token}'] = True
return {'status': 'success'}
lang = request.cookies.get('lang', 'ru')
return JSONResponse({'error': _t('wrong_invite_password', lang)}, status_code=401)
@app.get('/api/invite/{token}/info', tags=["Invites"])
async def api_invite_info(token: str, request: Request):
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
view = _invite_public_view(link)
# Don't leak admin note / ids beyond what's needed
return {
'name': view['name'],
'available': view['available'],
'enabled': view['enabled'],
'expired': view['expired'],
'exhausted': view['exhausted'],
'unlimited': view['unlimited'],
'remaining': view['remaining'],
'max_uses': view['max_uses'],
'used_count': view['used_count'],
'protocol': view['protocol'],
}
@app.post('/api/invite/{token}/create', tags=["Invites"])
async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request):
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
if not _invite_auth_ok(link, request):
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
view = _invite_public_view(link)
if not view['available']:
if view['expired']:
return JSONResponse({'error': 'Invite link expired'}, status_code=403)
if view['exhausted']:
return JSONResponse({'error': 'Invite link has no remaining uses'}, status_code=403)
return JSONResponse({'error': 'Invite link is disabled'}, status_code=403)
holder_id = link.get('user_id') or ''
if not holder_id or not any(u['id'] == holder_id for u in data['users']):
return JSONResponse({'error': 'Invite holder user is not configured'}, status_code=400)
# Reserve a use slot under lock
async with DATA_LOCK:
data = load_data()
link = _find_invite(data, token)
if not link:
return JSONResponse({'error': 'Not found'}, status_code=404)
view = _invite_public_view(link)
if not view['available']:
return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403)
link['used_count'] = int(link.get('used_count') or 0) + 1
save_data(data)
name = (req.name or 'Invite VPN').strip() or 'Invite VPN'
name = f"{name}_{secrets.token_hex(3)}"
try:
data = load_data()
created = await _create_config_for_protocol(
data,
protocol=link.get('protocol') or 'xui',
name=name,
server_id=int(link.get('server_id') or 0),
xui_inbound_id=link.get('xui_inbound_id') or None,
)
conn = {
'id': str(uuid.uuid4()),
'user_id': holder_id,
'server_id': created['server_id'],
'protocol': created['protocol'],
'client_id': created['client_id'],
'name': name,
'created_at': datetime.now().isoformat(),
}
async with DATA_LOCK:
data = load_data()
data.setdefault('user_connections', []).append(conn)
save_data(data)
config = created.get('config') or ''
vpn_link = generate_vpn_link(config) if config else ''
data = load_data()
link = _find_invite(data, token) or link
return {
'status': 'success',
'config': config,
'vpn_link': vpn_link,
'connection': conn,
'invite': _invite_public_view(link),
}
except Exception as e:
# Roll back reserved use
try:
async with DATA_LOCK:
data = load_data()
link = _find_invite(data, token)
if link and int(link.get('used_count') or 0) > 0:
link['used_count'] = int(link['used_count']) - 1
save_data(data)
except Exception:
logger.exception('Failed to roll back invite use count')
logger.exception('Error creating invite config')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"])
async def api_my_connection_config(request: Request, connection_id: str):
user = get_current_user(request)
@@ -4703,6 +5093,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)):
if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list):
return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400)
backup_data.setdefault('user_connections', [])
backup_data.setdefault('api_tokens', [])
backup_data.setdefault('invite_links', [])
backup_data.setdefault('settings', {})
# Save the new data
async with DATA_LOCK:
save_data(backup_data)