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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user