Add optional expiration that starts on first config use.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 05:07:50 +03:00
co-authored by Cursor
parent 16d14a7256
commit 745b5e5d8c
9 changed files with 245 additions and 35 deletions
+69 -5
View File
@@ -1488,6 +1488,8 @@ class AddUserRequest(BaseModel):
protocol: Optional[str] = None
connection_name: Optional[str] = None
expiration_date: Optional[str] = None
expire_after_first_use: bool = False
expiration_days: int = 0
telemt_quota: Optional[str] = None
telemt_max_ips: Optional[int] = None
telemt_expiry: Optional[str] = None
@@ -1564,6 +1566,8 @@ class UpdateUserRequest(BaseModel):
traffic_limit: Optional[float] = 0
traffic_reset_strategy: Optional[str] = None
expiration_date: Optional[str] = None
expire_after_first_use: Optional[bool] = None
expiration_days: Optional[int] = None
password: Optional[str] = None
@@ -1703,6 +1707,10 @@ async def startup():
migrated = True
if 'expiration_date' not in u:
u['expiration_date'] = None
if 'expire_after_first_use' not in u:
u['expire_after_first_use'] = False
if 'expiration_days' not in u:
u['expiration_days'] = 0
migrated = True
if 'xui_email' not in u:
u['xui_email'] = None
@@ -3346,6 +3354,8 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size
'traffic_reset_strategy': u.get('traffic_reset_strategy', 'never'),
'last_reset_at': u.get('last_reset_at'),
"expiration_date": u.get("expiration_date"),
"expire_after_first_use": bool(u.get("expire_after_first_use")),
"expiration_days": int(u.get("expiration_days") or 0),
'share_enabled': u.get('share_enabled', False),
'share_token': u.get('share_token'),
'has_share_password': bool(u.get('share_password_hash')),
@@ -3389,7 +3399,9 @@ async def api_add_user(request: Request, req: AddUserRequest):
'traffic_used': 0,
'traffic_total': 0,
'last_reset_at': datetime.now().isoformat(),
'expiration_date': req.expiration_date,
'expiration_date': None if req.expire_after_first_use else req.expiration_date,
'expire_after_first_use': bool(req.expire_after_first_use),
'expiration_days': int(req.expiration_days or 0) if req.expire_after_first_use else 0,
'enabled': True,
'created_at': datetime.now().isoformat(),
'remnawave_uuid': None,
@@ -3398,6 +3410,8 @@ async def api_add_user(request: Request, req: AddUserRequest):
'share_token': secrets.token_urlsafe(16),
'share_password_hash': None,
}
if new_user['expire_after_first_use'] and new_user['expiration_days'] <= 0:
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
data['users'].append(new_user)
save_data(data)
@@ -3472,8 +3486,29 @@ async def api_update_user(request: Request, user_id: str, req: UpdateUserRequest
user['last_reset_at'] = datetime.now().isoformat()
req_fields = getattr(req, 'model_fields_set', getattr(req, '__fields_set__', set()))
if 'expiration_date' in req_fields:
if 'expire_after_first_use' in req_fields or 'expiration_days' in req_fields or 'expiration_date' in req_fields:
after_first = bool(req.expire_after_first_use) if req.expire_after_first_use is not None else bool(user.get('expire_after_first_use'))
days = int(req.expiration_days) if req.expiration_days is not None else int(user.get('expiration_days') or 0)
if after_first:
if days <= 0:
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
user['expire_after_first_use'] = True
user['expiration_days'] = days
# Keep existing absolute date if countdown already started; otherwise wait for first use
if not user.get('expiration_date'):
user['expiration_date'] = None
elif 'expiration_date' in req_fields and req.expiration_date:
# Admin can still override absolute end date after start
user['expiration_date'] = req.expiration_date or None
else:
user['expire_after_first_use'] = False
user['expiration_days'] = 0
if 'expiration_date' in req_fields:
user['expiration_date'] = req.expiration_date or None
elif 'expiration_date' in req_fields:
user['expiration_date'] = req.expiration_date or None
user['expire_after_first_use'] = False
user['expiration_days'] = 0
if req.password:
user['password_hash'] = hash_password(req.password)
@@ -3735,6 +3770,12 @@ async def api_share_config(token: str, connection_id: str, request: Request):
return JSONResponse({'error': 'Not found'}, status_code=404)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(user):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if maybe_start_user_expiration(data, user['id']):
save_data(data)
if protocol_base(conn.get('protocol', '')) == 'xui':
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
@@ -3749,7 +3790,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
except Exception as e:
logger.exception("Error getting shared config")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -3873,6 +3914,12 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
if not conn:
return JSONResponse({'error': 'Not found'}, status_code=404)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if maybe_start_user_expiration(data, holder['id']):
save_data(data)
if protocol_base(conn.get('protocol', '')) == 'xui':
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
@@ -3886,7 +3933,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
except Exception as e:
logger.exception("Error getting guest config")
return JSONResponse({'error': str(e)}, status_code=500)
@@ -3909,6 +3956,10 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
name = f"{name}_{secrets.token_hex(3)}"
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
if user_is_expired(holder):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
sid = int(guest.get('create_server_id') or 0)
if sid >= len(data['servers']):
return JSONResponse({'error': 'Guest server not found'}, status_code=400)
@@ -3944,6 +3995,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
async with DATA_LOCK:
data = load_data()
data['user_connections'].append(conn)
maybe_start_user_expiration(data, holder['id'])
save_data(data)
config = result.get('config') or ''
@@ -3955,6 +4007,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
'config': config,
'subscription_url': subscription_url,
'vpn_link': vpn_link,
'expires_at': next((u.get('expiration_date') for u in data.get('users', []) if u.get('id') == holder['id']), None),
}
except Exception as e:
logger.exception("Error creating guest config")
@@ -4323,7 +4376,14 @@ async def api_my_connection_config(request: Request, connection_id: str):
if not user:
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
data = load_data()
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
if panel_user and user_is_expired(panel_user):
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
if panel_user and maybe_start_user_expiration(data, user['id']):
save_data(data)
conn = next(
(c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']),
None
@@ -4347,7 +4407,11 @@ async def api_my_connection_config(request: Request, connection_id: str):
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
ssh.disconnect()
vpn_link = generate_vpn_link(config) if config else ''
return {'config': config, 'vpn_link': vpn_link}
expires_at = None
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
if panel_user:
expires_at = panel_user.get('expiration_date')
return {'config': config, 'vpn_link': vpn_link, 'expires_at': expires_at}
except Exception as e:
logger.exception("Error getting my connection config")
return JSONResponse({'error': str(e)}, status_code=500)