mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
5b40445442
* Added support for AmneziaDNS * Added traffic reset functionality * Fixed hideConnectionsSection is not defined error and false “Connection Error” when clearing the server * Added basic management features (restart, delete, poll, clear) * Fixed AWG 2.0 connection generation * Optimized Check Server polling speed (now queries all protocols and services in parallel over a single SSH connection; ~5–6x faster)
2125 lines
83 KiB
Python
2125 lines
83 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import logging
|
|
import base64
|
|
import hashlib
|
|
import secrets
|
|
import uuid
|
|
import asyncio
|
|
from datetime import datetime
|
|
import io
|
|
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi import FastAPI, Request, Query, UploadFile, File
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List, Dict
|
|
import uvicorn
|
|
import httpx
|
|
|
|
try:
|
|
from multicolorcaptcha import CaptchaGenerator
|
|
except ImportError:
|
|
CaptchaGenerator = None
|
|
|
|
from ssh_manager import SSHManager
|
|
from awg_manager import AWGManager
|
|
from xray_manager import XrayManager
|
|
import telegram_bot as tg_bot
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
app = FastAPI(title="Amnezia Web Panel")
|
|
app.add_middleware(SessionMiddleware, secret_key=os.environ.get('SECRET_KEY', secrets.token_hex(32)))
|
|
|
|
# Mount static files & templates
|
|
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
|
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
|
|
|
|
if getattr(sys, 'frozen', False):
|
|
application_path = os.path.dirname(sys.executable)
|
|
else:
|
|
application_path = os.path.dirname(__file__)
|
|
|
|
DATA_FILE = os.path.join(application_path, 'data.json')
|
|
|
|
|
|
# ======================== Translations ========================
|
|
TRANSLATIONS = {}
|
|
|
|
def load_translations():
|
|
global TRANSLATIONS
|
|
trans_dir = os.path.join(os.path.dirname(__file__), 'translations')
|
|
if os.path.exists(trans_dir):
|
|
for f in os.listdir(trans_dir):
|
|
if f.endswith('.json'):
|
|
lang = f.split('.')[0]
|
|
try:
|
|
with open(os.path.join(trans_dir, f), 'r', encoding='utf-8') as tf:
|
|
TRANSLATIONS[lang] = json.load(tf)
|
|
except Exception as e:
|
|
logger.error(f"Error loading translation {f}: {e}")
|
|
logger.info(f"Loaded translations: {list(TRANSLATIONS.keys())}")
|
|
|
|
def _t(text_id, lang='en'):
|
|
lang_batch = TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))
|
|
return lang_batch.get(text_id, text_id)
|
|
|
|
load_translations()
|
|
|
|
|
|
# ======================== Helpers ========================
|
|
|
|
# Global lock for data.json access to prevent race conditions during async operations
|
|
DATA_LOCK = asyncio.Lock()
|
|
|
|
|
|
def load_data():
|
|
if os.path.exists(DATA_FILE):
|
|
with open(DATA_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
else:
|
|
data = {}
|
|
data.setdefault('servers', [])
|
|
data.setdefault('users', [])
|
|
data.setdefault('user_connections', [])
|
|
data.setdefault('settings', {
|
|
'appearance': {
|
|
'title': 'Amnezia',
|
|
'logo': '❤️',
|
|
'subtitle': 'Web Panel'
|
|
},
|
|
'sync': {
|
|
'remnawave_url': '',
|
|
'remnawave_api_key': '',
|
|
'remnawave_sync': False,
|
|
'remnawave_sync_users': False,
|
|
'remnawave_create_conns': False,
|
|
'remnawave_server_id': 0,
|
|
'remnawave_protocol': 'awg'
|
|
}
|
|
})
|
|
return data
|
|
|
|
|
|
def save_data(data):
|
|
with open(DATA_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
async def save_data_async(data):
|
|
"""Saves data to file in a thread-safe way."""
|
|
async with DATA_LOCK:
|
|
await asyncio.to_thread(save_data, data)
|
|
|
|
|
|
def get_ssh(server):
|
|
return SSHManager(
|
|
host=server['host'],
|
|
port=server.get('ssh_port', 22),
|
|
username=server['username'],
|
|
password=server.get('password'),
|
|
private_key=server.get('private_key'),
|
|
)
|
|
|
|
|
|
def get_protocol_manager(ssh, protocol: str):
|
|
if protocol == 'xray':
|
|
from xray_manager import XrayManager
|
|
return XrayManager(ssh)
|
|
elif protocol == 'telemt':
|
|
from telemt_manager import TelemtManager
|
|
return TelemtManager(ssh)
|
|
elif protocol == 'dns':
|
|
from dns_manager import DNSManager
|
|
return DNSManager(ssh)
|
|
from awg_manager import AWGManager
|
|
return AWGManager(ssh)
|
|
|
|
|
|
def generate_vpn_link(config_text):
|
|
b64 = base64.b64encode(config_text.strip().encode('utf-8')).decode('utf-8')
|
|
return f"vpn://{b64}"
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
salt = secrets.token_hex(16)
|
|
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
|
return f"{salt}${h.hex()}"
|
|
|
|
|
|
def verify_password(password: str, password_hash: str) -> bool:
|
|
try:
|
|
salt, h = password_hash.split('$', 1)
|
|
new_h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
|
return new_h.hex() == h
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def perform_delete_user(data: dict, user_id: str):
|
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
|
if not user:
|
|
return False
|
|
# Remove user's connections from servers
|
|
user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
|
for uc in user_conns:
|
|
try:
|
|
sid = uc['server_id']
|
|
if sid < len(data['servers']):
|
|
server = data['servers'][sid]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, uc['protocol'])
|
|
manager.remove_client(uc['protocol'], uc['client_id'])
|
|
ssh.disconnect()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to remove connection {uc['client_id']} during user delete: {e}")
|
|
data['user_connections'] = [c for c in data.get('user_connections', []) if c['user_id'] != user_id]
|
|
data['users'] = [u for u in data['users'] if u['id'] != user_id]
|
|
return True
|
|
|
|
|
|
return True
|
|
|
|
|
|
async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None):
|
|
"""
|
|
Executes multiple SSH operations efficiently.
|
|
Reloads data inside to ensure we don't overwrite other changes.
|
|
"""
|
|
data = load_data()
|
|
server_ops = {}
|
|
|
|
def get_ops(sid):
|
|
if sid not in server_ops:
|
|
server_ops[sid] = {'delete': [], 'toggle': [], 'create': []}
|
|
return server_ops[sid]
|
|
|
|
if delete_uids:
|
|
for uid in delete_uids:
|
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == uid]
|
|
for c in conns: get_ops(c['server_id'])['delete'].append(c)
|
|
|
|
if toggle_uids:
|
|
for uid, enabled in toggle_uids:
|
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == uid]
|
|
for c in conns: get_ops(c['server_id'])['toggle'].append((c, enabled))
|
|
|
|
if create_conns:
|
|
for req in create_conns: get_ops(req['server_id'])['create'].append(req)
|
|
|
|
async def run_server_ops(srv_id, ops):
|
|
# We re-load data inside to be absolutely sure about current state
|
|
# but for performance we'll use the passed srv_id
|
|
current_data = load_data()
|
|
if srv_id >= len(current_data['servers']): return
|
|
srv = current_data['servers'][srv_id]
|
|
|
|
try:
|
|
ssh = get_ssh(srv)
|
|
await asyncio.to_thread(ssh.connect)
|
|
|
|
# 1. Deletes
|
|
for c in ops['delete']:
|
|
manager = get_protocol_manager(ssh, c['protocol'])
|
|
await asyncio.to_thread(manager.remove_client, c['protocol'], c['client_id'])
|
|
# Incremental delete from data
|
|
async with DATA_LOCK:
|
|
current_data = load_data()
|
|
current_data['user_connections'] = [conn for conn in current_data['user_connections'] if conn['id'] != c['id']]
|
|
save_data(current_data)
|
|
|
|
# 2. Toggles
|
|
for c, enabled in ops['toggle']:
|
|
manager = get_protocol_manager(ssh, c['protocol'])
|
|
await asyncio.to_thread(manager.toggle_client, c['protocol'], c['client_id'], enabled)
|
|
# Incremental toggle in data
|
|
async with DATA_LOCK:
|
|
current_data = load_data()
|
|
# We also need to update user status if it was a user toggle
|
|
# Wait, mass ops caller usually handles user enabled status.
|
|
# Here we just toggle the actual wireguard peer.
|
|
save_data(current_data)
|
|
|
|
# 3. Creates
|
|
for c_req in ops['create']:
|
|
proto_info = srv.get('protocols', {}).get(c_req['protocol'], {})
|
|
port = proto_info.get('port', '55424')
|
|
manager = get_protocol_manager(ssh, c_req['protocol'])
|
|
res = await asyncio.to_thread(manager.add_client, c_req['protocol'], c_req['name'], srv['host'], port)
|
|
|
|
if res.get('client_id'):
|
|
new_conn = {
|
|
'id': str(uuid.uuid4()),
|
|
'user_id': c_req['user_id'],
|
|
'server_id': srv_id,
|
|
'protocol': c_req['protocol'],
|
|
'client_id': res['client_id'],
|
|
'name': c_req['name'],
|
|
'created_at': datetime.now().isoformat(),
|
|
}
|
|
async with DATA_LOCK:
|
|
current_data = load_data()
|
|
current_data['user_connections'].append(new_conn)
|
|
save_data(current_data)
|
|
|
|
await asyncio.to_thread(ssh.disconnect)
|
|
except Exception as e:
|
|
logger.error(f"Mass ops failed for server {srv_id}: {e}")
|
|
|
|
# Run all servers in parallel
|
|
tasks = [run_server_ops(sid, ops) for sid, ops in server_ops.items()]
|
|
if tasks:
|
|
await asyncio.gather(*tasks)
|
|
|
|
# 4. Final user-level cleanup (delete/toggle users metadata)
|
|
async with DATA_LOCK:
|
|
current_data = load_data()
|
|
if delete_uids:
|
|
current_data['users'] = [u for u in current_data['users'] if u['id'] not in delete_uids]
|
|
current_data['user_connections'] = [c for c in current_data.get('user_connections', []) if c['user_id'] not in delete_uids]
|
|
if toggle_uids:
|
|
for uid, enabled in toggle_uids:
|
|
user = next((u for u in current_data['users'] if u['id'] == uid), None)
|
|
if user: user['enabled'] = enabled
|
|
save_data(current_data)
|
|
|
|
return True
|
|
|
|
|
|
async def sync_users_with_remnawave(data: dict):
|
|
settings = data.get('settings', {}).get('sync', {})
|
|
if not settings.get('remnawave_sync_users'):
|
|
return 0, "Synchronization is disabled in settings"
|
|
|
|
url = settings.get('remnawave_url')
|
|
api_key = settings.get('remnawave_api_key')
|
|
if not url or not api_key:
|
|
return 0, "Remnawave URL or API Key not configured"
|
|
|
|
api_url = url.rstrip('/') + '/api/users'
|
|
headers = {"Authorization": f"Bearer {api_key}"}
|
|
|
|
try:
|
|
rw_users = []
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
page_size = 50 # Use a smaller page size that is more likely to be accepted
|
|
current_start = 0
|
|
while True:
|
|
resp = await client.get(f"{api_url}?size={page_size}&start={current_start}", headers=headers)
|
|
if resp.status_code != 200:
|
|
return 0, f"Remnawave API error: {resp.status_code} {resp.text}"
|
|
|
|
page_data = resp.json()
|
|
response_obj = page_data.get('response', {})
|
|
page_users = response_obj.get('users', [])
|
|
total_count = response_obj.get('total', 0)
|
|
|
|
if not page_users:
|
|
break
|
|
|
|
rw_users.extend(page_users)
|
|
logger.info(f"Fetched {len(rw_users)} / {total_count} users from Remnawave...")
|
|
|
|
if len(rw_users) >= total_count or len(page_users) == 0:
|
|
break
|
|
|
|
current_start += len(page_users)
|
|
|
|
rw_uuids = {u['uuid'] for u in rw_users}
|
|
|
|
# 1. Handle deletion (users that have remnawave_uuid but are no longer in Remnawave)
|
|
to_delete_ids = []
|
|
for u in data['users']:
|
|
if u.get('remnawave_uuid') and u['remnawave_uuid'] not in rw_uuids:
|
|
to_delete_ids.append(u['id'])
|
|
|
|
if to_delete_ids:
|
|
logger.info(f"Removing {len(to_delete_ids)} users deleted in Remnawave")
|
|
await perform_mass_operations(delete_uids=to_delete_ids)
|
|
|
|
# 2. Sync / Create users
|
|
synced_count = 0
|
|
to_toggle = [] # list of (user_id, enabled)
|
|
to_create_conns = [] # list of dicts
|
|
|
|
for rw_u in rw_users:
|
|
# We reload data in each loop step to handle concurrency
|
|
data = load_data()
|
|
local_u = next((u for u in data['users'] if u.get('remnawave_uuid') == rw_u['uuid']), None)
|
|
if not local_u:
|
|
local_u = next((u for u in data['users'] if u['username'] == rw_u['username']), None)
|
|
|
|
is_active = (rw_u.get('status') == 'ACTIVE')
|
|
|
|
if local_u:
|
|
local_u['username'] = rw_u['username']
|
|
local_u['telegramId'] = rw_u.get('telegramId')
|
|
local_u['email'] = rw_u.get('email')
|
|
local_u['description'] = rw_u.get('description')
|
|
local_u['remnawave_uuid'] = rw_u['uuid']
|
|
|
|
if local_u.get('enabled', True) != is_active:
|
|
to_toggle.append((local_u['id'], is_active))
|
|
|
|
# Save metadata immediately
|
|
async with DATA_LOCK:
|
|
current = load_data()
|
|
# Update index
|
|
idx = next((i for i, u in enumerate(current['users']) if u['id'] == local_u['id']), -1)
|
|
if idx != -1:
|
|
current['users'][idx] = local_u
|
|
save_data(current)
|
|
|
|
synced_count += 1
|
|
else:
|
|
new_id = str(uuid.uuid4())
|
|
new_user = {
|
|
'id': new_id,
|
|
'username': rw_u['username'],
|
|
'password_hash': '',
|
|
'role': 'user',
|
|
'telegramId': rw_u.get('telegramId'),
|
|
'email': rw_u.get('email'),
|
|
'description': rw_u.get('description'),
|
|
'enabled': is_active,
|
|
'created_at': datetime.now().isoformat(),
|
|
'remnawave_uuid': rw_u['uuid'],
|
|
'share_enabled': False,
|
|
'share_token': secrets.token_urlsafe(16),
|
|
'share_password_hash': None,
|
|
}
|
|
async with DATA_LOCK:
|
|
current = load_data()
|
|
current['users'].append(new_user)
|
|
save_data(current)
|
|
|
|
if settings.get('remnawave_create_conns'):
|
|
sid = settings.get('remnawave_server_id')
|
|
if sid is not None:
|
|
to_create_conns.append({
|
|
'user_id': new_id,
|
|
'server_id': sid,
|
|
'protocol': settings.get('remnawave_protocol', 'awg'),
|
|
'name': f"{rw_u['username']}_vpn"
|
|
})
|
|
synced_count += 1
|
|
|
|
# Execute all collected mass operations
|
|
if to_toggle or to_create_conns:
|
|
logger.info(f"Executing mass ops for Remnawave sync: toggle={len(to_toggle)}, create={len(to_create_conns)}")
|
|
await perform_mass_operations(toggle_uids=to_toggle, create_conns=to_create_conns)
|
|
|
|
return synced_count, "Successfully synchronized with Remnawave"
|
|
|
|
except Exception as e:
|
|
logger.exception("Synchronization error")
|
|
return 0, f"Error: {str(e)}"
|
|
|
|
|
|
def get_current_user(request: Request):
|
|
user_id = request.session.get('user_id')
|
|
if not user_id:
|
|
return None
|
|
data = load_data()
|
|
for u in data.get('users', []):
|
|
if u['id'] == user_id:
|
|
return u
|
|
return None
|
|
|
|
|
|
def tpl(request, template, **kwargs):
|
|
data = load_data()
|
|
lang = request.cookies.get('lang', 'en')
|
|
ctx = {
|
|
'request': request,
|
|
'current_user': get_current_user(request),
|
|
'site_settings': data.get('settings', {}).get('appearance', {}),
|
|
'captcha_settings': data.get('settings', {}).get('captcha', {}),
|
|
'telegram_settings': data.get('settings', {}).get('telegram', {}),
|
|
'bot_running': tg_bot.is_running(),
|
|
'lang': lang,
|
|
'_': lambda text_id: _t(text_id, lang),
|
|
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
|
|
'all_translations_json': json.dumps(TRANSLATIONS)
|
|
}
|
|
ctx.update(kwargs)
|
|
return templates.TemplateResponse(template, ctx)
|
|
|
|
|
|
# ======================== Pydantic Models ========================
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
captcha: Optional[str] = None
|
|
|
|
|
|
class AddServerRequest(BaseModel):
|
|
host: str = ''
|
|
ssh_port: int = 22
|
|
username: str = ''
|
|
password: str = ''
|
|
private_key: str = ''
|
|
name: str = ''
|
|
|
|
|
|
class InstallProtocolRequest(BaseModel):
|
|
protocol: str = 'awg'
|
|
port: str = '55424'
|
|
tls_emulation: Optional[bool] = None
|
|
tls_domain: Optional[str] = None
|
|
max_connections: Optional[int] = None
|
|
|
|
|
|
class ProtocolRequest(BaseModel):
|
|
protocol: str = 'awg'
|
|
|
|
|
|
class AddConnectionRequest(BaseModel):
|
|
protocol: str = 'awg'
|
|
name: str = 'Connection'
|
|
user_id: Optional[str] = None
|
|
telemt_quota: Optional[str] = None
|
|
telemt_max_ips: Optional[int] = None
|
|
telemt_expiry: Optional[str] = None
|
|
|
|
|
|
class EditConnectionRequest(BaseModel):
|
|
protocol: str = 'telemt'
|
|
client_id: str = ''
|
|
telemt_quota: Optional[str] = None
|
|
telemt_max_ips: Optional[int] = None
|
|
telemt_expiry: Optional[str] = None
|
|
|
|
|
|
class ConnectionActionRequest(BaseModel):
|
|
protocol: str = 'awg'
|
|
client_id: str = ''
|
|
|
|
|
|
class ToggleConnectionRequest(BaseModel):
|
|
protocol: str = 'awg'
|
|
client_id: str = ''
|
|
enable: bool = True
|
|
|
|
|
|
class AddUserRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
role: str = 'user'
|
|
telegramId: Optional[str] = None
|
|
email: Optional[str] = None
|
|
description: Optional[str] = None
|
|
traffic_limit: Optional[float] = 0
|
|
traffic_reset_strategy: Optional[str] = 'never'
|
|
server_id: Optional[int] = None
|
|
protocol: Optional[str] = None
|
|
connection_name: Optional[str] = None
|
|
|
|
|
|
class ServerConfigSaveRequest(BaseModel):
|
|
protocol: str
|
|
config: str
|
|
|
|
|
|
class AppearanceSettings(BaseModel):
|
|
title: str = 'Amnezia'
|
|
logo: str = '🛡'
|
|
subtitle: str = 'Web Panel'
|
|
|
|
|
|
class SyncSettings(BaseModel):
|
|
remnawave_url: str = ''
|
|
remnawave_api_key: str = ''
|
|
remnawave_sync: bool = False
|
|
remnawave_sync_users: bool = False
|
|
remnawave_create_conns: bool = False
|
|
remnawave_server_id: int = 0
|
|
remnawave_protocol: str = 'awg'
|
|
|
|
class CaptchaSettings(BaseModel):
|
|
enabled: bool = False
|
|
|
|
|
|
class TelegramSettings(BaseModel):
|
|
token: str = ''
|
|
enabled: bool = False
|
|
|
|
|
|
|
|
|
|
class UpdateUserRequest(BaseModel):
|
|
telegramId: Optional[str] = None
|
|
email: Optional[str] = None
|
|
description: Optional[str] = None
|
|
traffic_limit: Optional[float] = 0
|
|
traffic_reset_strategy: Optional[str] = None
|
|
password: Optional[str] = None
|
|
|
|
|
|
class SaveSettingsRequest(BaseModel):
|
|
appearance: AppearanceSettings
|
|
sync: SyncSettings
|
|
captcha: CaptchaSettings
|
|
telegram: TelegramSettings
|
|
|
|
|
|
class ToggleUserRequest(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class AddUserConnectionRequest(BaseModel):
|
|
server_id: int
|
|
protocol: str = 'awg'
|
|
name: str = 'VPN Connection'
|
|
client_id: Optional[str] = None
|
|
telemt_quota: Optional[str] = None
|
|
telemt_max_ips: Optional[int] = None
|
|
telemt_expiry: Optional[str] = None
|
|
|
|
|
|
class ShareSetupRequest(BaseModel):
|
|
enabled: bool
|
|
password: Optional[str] = None
|
|
|
|
|
|
class ShareAuthRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
# ======================== Startup ========================
|
|
|
|
@app.on_event("startup")
|
|
async def startup():
|
|
data = load_data()
|
|
changed = False
|
|
if not data.get('users'):
|
|
data['users'] = [{
|
|
'id': str(uuid.uuid4()),
|
|
'username': 'admin',
|
|
'password_hash': hash_password('admin'),
|
|
'role': 'admin',
|
|
'enabled': True,
|
|
'created_at': datetime.now().isoformat(),
|
|
}]
|
|
changed = True
|
|
logger.info("Default admin created (admin / admin)")
|
|
|
|
# Migration for sharing fields and traffic reset strategy
|
|
for u in data['users']:
|
|
migrated = False
|
|
if 'share_enabled' not in u:
|
|
u['share_enabled'] = False
|
|
migrated = True
|
|
if not u.get('share_token'):
|
|
u['share_token'] = secrets.token_urlsafe(16)
|
|
migrated = True
|
|
if 'share_password_hash' not in u:
|
|
u['share_password_hash'] = None
|
|
migrated = True
|
|
|
|
# Traffic reset strategy and total traffic
|
|
if 'traffic_reset_strategy' not in u:
|
|
u['traffic_reset_strategy'] = 'never'
|
|
migrated = True
|
|
if 'traffic_total' not in u:
|
|
u['traffic_total'] = u.get('traffic_used', 0)
|
|
migrated = True
|
|
if 'last_reset_at' not in u:
|
|
u['last_reset_at'] = datetime.now().isoformat()
|
|
migrated = True
|
|
|
|
if migrated:
|
|
changed = True
|
|
logger.info(f"Migrated user {u['username']} to new traffic/sharing fields")
|
|
|
|
if changed:
|
|
save_data(data)
|
|
|
|
# Start periodic background tasks
|
|
asyncio.create_task(periodic_background_tasks())
|
|
|
|
# Start Telegram bot if enabled
|
|
tg_cfg = data.get('settings', {}).get('telegram', {})
|
|
if tg_cfg.get('enabled') and tg_cfg.get('token'):
|
|
logger.info("Starting Telegram bot from saved settings...")
|
|
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link)
|
|
|
|
|
|
async def periodic_background_tasks():
|
|
"""Background task to sync traffic limits and Remnawave every 10 minutes"""
|
|
while True:
|
|
try:
|
|
# We wait before the first sync to let the app settle
|
|
await asyncio.sleep(60)
|
|
|
|
# --- 1. TRAFFIC SYNC & LIMITS ---
|
|
logger.info("Starting background traffic sync...")
|
|
data = load_data()
|
|
|
|
conns_by_server = {}
|
|
for uc in data.get('user_connections', []):
|
|
sid = uc['server_id']
|
|
conns_by_server.setdefault(sid, []).append(uc)
|
|
|
|
updates = []
|
|
|
|
for sid, server in enumerate(data.get('servers', [])):
|
|
if sid not in conns_by_server: continue
|
|
try:
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
for proto in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt']:
|
|
if proto in server.get('protocols', {}):
|
|
manager = get_protocol_manager(ssh, proto)
|
|
clients = manager.get_clients(proto)
|
|
client_bytes = {}
|
|
for c in clients:
|
|
rx = c.get('userData', {}).get('dataReceivedBytes', 0)
|
|
tx = c.get('userData', {}).get('dataSentBytes', 0)
|
|
client_bytes[c.get('clientId')] = rx + tx
|
|
|
|
for uc in conns_by_server[sid]:
|
|
if uc['protocol'] == proto and uc['client_id'] in client_bytes:
|
|
curr_bytes = client_bytes[uc['client_id']]
|
|
last_bytes = uc.get('last_bytes', 0)
|
|
delta = curr_bytes - last_bytes if curr_bytes >= last_bytes else curr_bytes
|
|
updates.append((uc['id'], delta, curr_bytes))
|
|
ssh.disconnect()
|
|
except Exception as e:
|
|
logger.error(f"Traffic sync err server {sid}: {e}")
|
|
|
|
to_disable_uids = []
|
|
if updates:
|
|
async with DATA_LOCK:
|
|
curr_data = load_data()
|
|
users_map = {u['id']: u for u in curr_data.get('users', [])}
|
|
uc_list = curr_data.get('user_connections', [])
|
|
uc_map = {uc['id']: uc for uc in uc_list}
|
|
|
|
# Current date/time for reset checking
|
|
now = datetime.now()
|
|
|
|
for uc_id, delta, curr_bytes in updates:
|
|
if uc_id in uc_map:
|
|
uc_map[uc_id]['last_bytes'] = curr_bytes
|
|
uid = uc_map[uc_id]['user_id']
|
|
if uid in users_map:
|
|
u = users_map[uid]
|
|
# Check if reset is needed BEFORE adding new consumption
|
|
strategy = u.get('traffic_reset_strategy', 'never')
|
|
last_reset_iso = u.get('last_reset_at')
|
|
|
|
reset_needed = False
|
|
if strategy != 'never' and last_reset_iso:
|
|
try:
|
|
last = datetime.fromisoformat(last_reset_iso)
|
|
if strategy == 'daily':
|
|
reset_needed = now.date() > last.date()
|
|
elif strategy == 'weekly':
|
|
reset_needed = now.isocalendar()[1] != last.isocalendar()[1] or now.year != last.year
|
|
elif strategy == 'monthly':
|
|
reset_needed = now.month != last.month or now.year != last.year
|
|
except:
|
|
pass
|
|
|
|
if reset_needed:
|
|
logger.info(f"Resetting traffic for user {u['username']} (strategy: {strategy})")
|
|
u['traffic_used'] = 0
|
|
u['last_reset_at'] = now.isoformat()
|
|
|
|
# Update both resettable and total traffic
|
|
u['traffic_used'] = u.get('traffic_used', 0) + delta
|
|
u['traffic_total'] = u.get('traffic_total', 0) + delta
|
|
|
|
limit = u.get('traffic_limit', 0)
|
|
if limit > 0 and u['traffic_used'] >= limit and u.get('enabled', True):
|
|
if uid not in to_disable_uids:
|
|
to_disable_uids.append(uid)
|
|
save_data(curr_data)
|
|
|
|
if to_disable_uids:
|
|
logger.info(f"Traffic limit reached, disabling users: {to_disable_uids}")
|
|
await perform_mass_operations(toggle_uids=[(uid, False) for uid in to_disable_uids])
|
|
|
|
# --- 2. REMNAWAVE SYNC ---
|
|
logger.info("Starting background Remnawave sync...")
|
|
data = load_data()
|
|
if data.get('settings', {}).get('sync', {}).get('remnawave_sync_users'):
|
|
count, msg = await sync_users_with_remnawave(data)
|
|
logger.info(f"Background Remnawave sync finished: {count} users updated. {msg}")
|
|
else:
|
|
logger.info("Background Remnawave sync skipped (disabled in settings)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in periodic_background_tasks: {e}")
|
|
|
|
# Wait 10 minutes before next sync
|
|
await asyncio.sleep(600)
|
|
|
|
|
|
# ======================== PAGE ROUTES ========================
|
|
|
|
@app.get('/login', response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
if get_current_user(request):
|
|
return RedirectResponse(url='/', status_code=302)
|
|
return tpl(request, 'login.html')
|
|
|
|
|
|
@app.get("/")
|
|
async def index(request: Request):
|
|
if not get_current_user(request):
|
|
return RedirectResponse(url="/login")
|
|
data = load_data()
|
|
return tpl(request, "index.html", servers=data['servers'])
|
|
|
|
|
|
@app.get("/set_lang/{lang}")
|
|
async def set_lang(lang: str, request: Request):
|
|
ref = request.headers.get("referer", "/")
|
|
response = RedirectResponse(url=ref)
|
|
response.set_cookie(key="lang", value=lang, max_age=31536000)
|
|
return response
|
|
|
|
|
|
@app.get('/logout')
|
|
async def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse(url='/login', status_code=302)
|
|
|
|
|
|
@app.get('/', response_class=HTMLResponse)
|
|
async def index(request: Request):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=302)
|
|
if user['role'] == 'user':
|
|
return RedirectResponse(url='/my', status_code=302)
|
|
data = load_data()
|
|
return tpl(request, 'index.html', servers=data['servers'])
|
|
|
|
|
|
@app.get('/server/{server_id}', response_class=HTMLResponse)
|
|
async def server_detail(request: Request, server_id: int):
|
|
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()
|
|
if server_id >= len(data['servers']):
|
|
return RedirectResponse(url='/')
|
|
server = data['servers'][server_id]
|
|
users_list = data.get('users', [])
|
|
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list)
|
|
|
|
|
|
@app.get('/users', response_class=HTMLResponse)
|
|
async def users_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()
|
|
users_list = data.get('users', [])
|
|
# Count connections per user
|
|
conns = data.get('user_connections', [])
|
|
for u in users_list:
|
|
u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id'])
|
|
servers = data['servers']
|
|
return tpl(request, 'users.html', users=users_list, servers=servers)
|
|
|
|
|
|
@app.get('/my', response_class=HTMLResponse)
|
|
async def my_connections_page(request: Request):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url='/login', status_code=302)
|
|
data = load_data()
|
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
|
# Enrich with server names
|
|
for c in conns:
|
|
sid = c.get('server_id', 0)
|
|
if sid < len(data['servers']):
|
|
c['server_name'] = data['servers'][sid].get('name', data['servers'][sid].get('host', ''))
|
|
else:
|
|
c['server_name'] = 'Unknown'
|
|
return tpl(request, 'my_connections.html', connections=conns)
|
|
|
|
|
|
# ======================== AUTH API ========================
|
|
|
|
@app.get('/api/auth/captcha')
|
|
async def api_captcha(request: Request):
|
|
if not CaptchaGenerator:
|
|
return JSONResponse({"error": "multicolorcaptcha is not installed"}, status_code=500)
|
|
|
|
# 2 is a multiplier for the image resolution size
|
|
generator = CaptchaGenerator(2)
|
|
captcha = generator.gen_captcha_image(difficult_level=2)
|
|
request.session['captcha_answer'] = captcha.characters
|
|
|
|
img_bytes = io.BytesIO()
|
|
captcha.image.save(img_bytes, format='PNG')
|
|
img_bytes.seek(0)
|
|
|
|
return StreamingResponse(img_bytes, media_type="image/png")
|
|
|
|
|
|
@app.post('/api/auth/login')
|
|
async def api_login(request: Request, req: LoginRequest):
|
|
data = load_data()
|
|
captcha_settings = data.get('settings', {}).get('captcha', {})
|
|
if captcha_settings.get('enabled') is True:
|
|
answer = request.session.get('captcha_answer')
|
|
lang = request.cookies.get('lang', 'ru')
|
|
if not answer or not req.captcha or answer.lower() != req.captcha.lower():
|
|
request.session.pop('captcha_answer', None)
|
|
return JSONResponse({'error': _t('invalid_captcha', lang)}, status_code=400)
|
|
request.session.pop('captcha_answer', None)
|
|
|
|
for u in data.get('users', []):
|
|
if u['username'] == req.username and verify_password(req.password, u['password_hash']):
|
|
lang = request.cookies.get('lang', 'ru')
|
|
if not u.get('enabled', True):
|
|
return JSONResponse({'error': _t('account_disabled', lang)}, status_code=403)
|
|
request.session['user_id'] = u['id']
|
|
return {'status': 'success', 'role': u['role']}
|
|
lang = request.cookies.get('lang', 'ru')
|
|
return JSONResponse({'error': _t('invalid_login', lang)}, status_code=401)
|
|
|
|
|
|
# ======================== SERVER API (admin/support) ========================
|
|
|
|
def _check_admin(request):
|
|
user = get_current_user(request)
|
|
if not user or user['role'] not in ('admin', 'support'):
|
|
return None
|
|
return user
|
|
|
|
|
|
@app.post('/api/servers/add')
|
|
async def api_add_server(request: Request, req: AddServerRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
host = req.host.strip()
|
|
username = req.username.strip()
|
|
name = req.name.strip() or host
|
|
if not host or not username:
|
|
return JSONResponse({'error': 'Host and username are required'}, status_code=400)
|
|
if not req.password and not req.private_key:
|
|
return JSONResponse({'error': 'Password or SSH key is required'}, status_code=400)
|
|
|
|
ssh = SSHManager(host, req.ssh_port, username, req.password, req.private_key)
|
|
try:
|
|
ssh.connect()
|
|
server_info = ssh.test_connection()
|
|
ssh.disconnect()
|
|
except Exception as e:
|
|
return JSONResponse({'error': f'Connection failed: {str(e)}'}, status_code=400)
|
|
|
|
server = {
|
|
'name': name, 'host': host, 'ssh_port': req.ssh_port,
|
|
'username': username, 'password': req.password,
|
|
'private_key': req.private_key, 'server_info': server_info,
|
|
'protocols': {},
|
|
}
|
|
data = load_data()
|
|
data['servers'].append(server)
|
|
save_data(data)
|
|
return {'status': 'success', 'server_id': len(data['servers']) - 1, 'server_info': server_info}
|
|
except Exception as e:
|
|
logger.exception("Error adding server")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/delete')
|
|
async def api_delete_server(request: Request, server_id: int):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
data['servers'].pop(server_id)
|
|
# Clean up connections for this server
|
|
data['user_connections'] = [c for c in data.get('user_connections', []) if c.get('server_id') != server_id]
|
|
# Adjust server_ids for connections pointing to higher indices
|
|
for c in data.get('user_connections', []):
|
|
if c.get('server_id', 0) > server_id:
|
|
c['server_id'] -= 1
|
|
save_data(data)
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/reboot')
|
|
async def api_reboot_server(request: Request, server_id: int):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
try:
|
|
ssh.run_sudo_command("nohup reboot > /dev/null 2>&1 &")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
ssh.disconnect()
|
|
except:
|
|
pass
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error rebooting server")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/clear')
|
|
async def api_clear_server(request: Request, server_id: int):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt', 'amnezia-dns']
|
|
for c in containers:
|
|
ssh.run_sudo_command(f"docker stop {c} || true")
|
|
ssh.run_sudo_command(f"docker rm {c} || true")
|
|
ssh.run_sudo_command("docker network rm amnezia-dns-net || true")
|
|
ssh.run_sudo_command("rm -rf /opt/amnezia")
|
|
|
|
server['protocols'] = {}
|
|
save_data(data)
|
|
ssh.disconnect()
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error clearing server")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/stats')
|
|
async def api_server_stats(request: Request, server_id: int):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
stats = {}
|
|
out, _, _ = ssh.run_command(
|
|
"top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1 2>/dev/null || "
|
|
"awk '{u=$2+$4; t=$2+$4+$5; if(NR==1){pu=u;pt=t} else printf \"%.1f\", (u-pu)/(t-pt)*100}' "
|
|
"<(grep 'cpu ' /proc/stat) <(sleep 0.5 && grep 'cpu ' /proc/stat) 2>/dev/null"
|
|
)
|
|
try:
|
|
stats['cpu'] = round(float(out.strip().split('\n')[0]), 1)
|
|
except (ValueError, IndexError):
|
|
stats['cpu'] = 0
|
|
out, _, _ = ssh.run_command("free -b | awk 'NR==2{printf \"%d %d\", $3, $2}'")
|
|
try:
|
|
parts = out.strip().split()
|
|
used, total = int(parts[0]), int(parts[1])
|
|
stats.update(ram_used=used, ram_total=total, ram_percent=round(used / total * 100, 1) if total > 0 else 0)
|
|
except (ValueError, IndexError):
|
|
stats.update(ram_used=0, ram_total=0, ram_percent=0)
|
|
out, _, _ = ssh.run_command("df -B1 / | awk 'NR==2{printf \"%d %d\", $3, $2}'")
|
|
try:
|
|
parts = out.strip().split()
|
|
used, total = int(parts[0]), int(parts[1])
|
|
stats.update(disk_used=used, disk_total=total, disk_percent=round(used / total * 100, 1) if total > 0 else 0)
|
|
except (ValueError, IndexError):
|
|
stats.update(disk_used=0, disk_total=0, disk_percent=0)
|
|
out, _, _ = ssh.run_command(
|
|
"DEV=$(ip route | awk '/default/ {print $5}' | head -1); "
|
|
"cat /proc/net/dev | awk -v dev=\"$DEV:\" '$1==dev{printf \"%d %d\", $2, $10}'"
|
|
)
|
|
try:
|
|
parts = out.strip().split()
|
|
stats['net_rx'], stats['net_tx'] = int(parts[0]), int(parts[1])
|
|
except (ValueError, IndexError):
|
|
stats['net_rx'] = stats['net_tx'] = 0
|
|
out, _, _ = ssh.run_command("uptime -p 2>/dev/null || uptime")
|
|
stats['uptime'] = out.strip()
|
|
ssh.disconnect()
|
|
return stats
|
|
except Exception as e:
|
|
logger.exception("Error getting server stats")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/check')
|
|
async def api_check_server(request: Request, server_id: int):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
# Just use awg's docker checker since it uses the same command
|
|
manager = get_protocol_manager(ssh, 'awg')
|
|
status = {'connection': 'ok', 'docker_installed': manager.check_docker_installed(), 'protocols': {}}
|
|
|
|
changed = False
|
|
if 'protocols' not in server:
|
|
server['protocols'] = {}
|
|
|
|
import concurrent.futures
|
|
|
|
def check_proto(proto):
|
|
try:
|
|
p_manager = get_protocol_manager(ssh, proto)
|
|
result = p_manager.get_server_status(proto)
|
|
db_proto = server.get('protocols', {}).get(proto, {})
|
|
if not result.get('port') and db_proto.get('port'):
|
|
result['port'] = db_proto['port']
|
|
return proto, result, None
|
|
except Exception as e:
|
|
return proto, None, str(e)
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
|
futures = [executor.submit(check_proto, p) for p in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns']]
|
|
for future in concurrent.futures.as_completed(futures):
|
|
proto, result, err = future.result()
|
|
if err:
|
|
status['protocols'][proto] = {'error': err}
|
|
else:
|
|
status['protocols'][proto] = result
|
|
if result.get('container_exists'):
|
|
if proto not in server['protocols']:
|
|
server['protocols'][proto] = {
|
|
'installed': True,
|
|
'port': result.get('port', '55424'),
|
|
'awg_params': result.get('awg_params', {})
|
|
}
|
|
changed = True
|
|
else:
|
|
if proto in server['protocols']:
|
|
del server['protocols'][proto]
|
|
changed = True
|
|
|
|
if changed:
|
|
save_data(data)
|
|
|
|
ssh.disconnect()
|
|
return status
|
|
except Exception as e:
|
|
logger.exception("Error checking server")
|
|
return JSONResponse({'error': str(e), 'connection': 'failed'}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/install')
|
|
async def api_install_protocol(request: Request, server_id: int, req: InstallProtocolRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
if req.protocol not in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns']:
|
|
return JSONResponse({'error': 'Invalid protocol type'}, status_code=400)
|
|
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
|
|
# Pass parameters to installer
|
|
if req.protocol == 'telemt':
|
|
result = manager.install_protocol(
|
|
protocol_type=req.protocol,
|
|
port=req.port,
|
|
tls_emulation=req.tls_emulation if req.tls_emulation is not None else True,
|
|
tls_domain=req.tls_domain,
|
|
max_connections=req.max_connections if req.max_connections is not None else 0
|
|
)
|
|
elif req.protocol == 'xray':
|
|
result = manager.install_protocol(port=req.port)
|
|
else:
|
|
result = manager.install_protocol(req.protocol, port=req.port)
|
|
|
|
server['protocols'][req.protocol] = {
|
|
'installed': True, 'port': req.port,
|
|
'awg_params': result.get('awg_params', {}),
|
|
}
|
|
save_data(data)
|
|
ssh.disconnect()
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("Error installing protocol")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/uninstall')
|
|
async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
if req.protocol == 'xray':
|
|
manager.remove_container()
|
|
else:
|
|
manager.remove_container(req.protocol)
|
|
if req.protocol in server.get('protocols', {}):
|
|
del server['protocols'][req.protocol]
|
|
save_data(data)
|
|
ssh.disconnect()
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error uninstalling protocol")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
CONTAINER_NAMES = {
|
|
'awg': 'amnezia-awg',
|
|
'awg2': 'amnezia-awg2',
|
|
'awg_legacy': 'amnezia-awg-legacy',
|
|
'xray': 'amnezia-xray',
|
|
'telemt': 'telemt',
|
|
'dns': 'amnezia-dns',
|
|
}
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/container/toggle')
|
|
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
|
|
"""Start or stop a protocol Docker container."""
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
container = CONTAINER_NAMES.get(req.protocol)
|
|
if not container:
|
|
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
# Check current state
|
|
out, _, _ = ssh.run_sudo_command(
|
|
f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null"
|
|
)
|
|
is_running = out.strip().lower() == 'true'
|
|
if is_running:
|
|
ssh.run_sudo_command(f"docker stop {container}")
|
|
action = 'stopped'
|
|
else:
|
|
ssh.run_sudo_command(f"docker start {container}")
|
|
action = 'started'
|
|
ssh.disconnect()
|
|
return {'status': 'success', 'action': action, 'container': container}
|
|
except Exception as e:
|
|
logger.exception("Error toggling container")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/server_config')
|
|
async def api_server_config(request: Request, server_id: int, req: ProtocolRequest):
|
|
"""Get the raw server-side WireGuard/Xray configuration."""
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
if req.protocol == 'xray':
|
|
from xray_manager import XrayManager
|
|
mgr = XrayManager(ssh)
|
|
data_json = mgr._get_server_json()
|
|
import json as _json
|
|
config = _json.dumps(data_json, indent=2, ensure_ascii=False) if data_json else ''
|
|
elif req.protocol == 'telemt':
|
|
from telemt_manager import TelemtManager
|
|
mgr = TelemtManager(ssh)
|
|
config = mgr._get_server_config()
|
|
else:
|
|
mgr = AWGManager(ssh)
|
|
config = mgr._get_server_config(req.protocol)
|
|
ssh.disconnect()
|
|
return {'config': config}
|
|
except Exception as e:
|
|
logger.exception("Error getting server config")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/server_config/save')
|
|
async def api_server_config_save(request: Request, server_id: int, req: ServerConfigSaveRequest):
|
|
"""Save the raw server-side WireGuard/Xray configuration and apply changes."""
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
if req.protocol == 'xray':
|
|
from xray_manager import XrayManager
|
|
mgr = XrayManager(ssh)
|
|
import json as _json
|
|
try:
|
|
data_json = _json.loads(req.config)
|
|
except Exception as e:
|
|
ssh.disconnect()
|
|
return JSONResponse({'error': f'Invalid JSON format: {str(e)}'}, status_code=400)
|
|
mgr._save_server_json(data_json)
|
|
elif req.protocol == 'telemt':
|
|
from telemt_manager import TelemtManager
|
|
mgr = TelemtManager(ssh)
|
|
mgr.save_server_config(req.protocol, req.config)
|
|
else:
|
|
mgr = AWGManager(ssh)
|
|
mgr.save_server_config(req.protocol, req.config)
|
|
ssh.disconnect()
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error saving server config")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
|
|
|
|
@app.get('/api/servers/{server_id}/connections')
|
|
async def api_get_connections(request: Request, server_id: int, protocol: str = Query(default='awg')):
|
|
if not protocol:
|
|
protocol = 'awg'
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, protocol)
|
|
clients = manager.get_clients(protocol)
|
|
ssh.disconnect()
|
|
|
|
# Enrich with user info from user_connections
|
|
user_conns = data.get('user_connections', [])
|
|
users = data.get('users', [])
|
|
users_map = {u['id']: u for u in users}
|
|
for client in clients:
|
|
cid = client.get('clientId', '')
|
|
for uc in user_conns:
|
|
if uc.get('client_id') == cid and uc.get('server_id') == server_id and uc.get('protocol') == protocol:
|
|
uid = uc.get('user_id')
|
|
u = users_map.get(uid)
|
|
if u:
|
|
client['assigned_user'] = u['username']
|
|
client['assigned_user_id'] = uid
|
|
break
|
|
return {'clients': clients}
|
|
except Exception as e:
|
|
logger.exception("Error getting connections")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/connections/add')
|
|
async def api_add_connection(request: Request, server_id: int, req: AddConnectionRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
|
port = proto_info.get('port', '55424')
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
|
|
if req.protocol == 'telemt':
|
|
result = manager.add_client(
|
|
req.protocol, req.name, server['host'], port,
|
|
telemt_quota=req.telemt_quota,
|
|
telemt_max_ips=req.telemt_max_ips,
|
|
telemt_expiry=req.telemt_expiry
|
|
)
|
|
else:
|
|
result = manager.add_client(req.protocol, req.name, server['host'], port)
|
|
ssh.disconnect()
|
|
|
|
if result.get('config'):
|
|
result['vpn_link'] = generate_vpn_link(result['config'])
|
|
|
|
# Link connection to user if specified
|
|
if req.user_id and result.get('client_id'):
|
|
conn = {
|
|
'id': str(uuid.uuid4()),
|
|
'user_id': req.user_id,
|
|
'server_id': server_id,
|
|
'protocol': req.protocol,
|
|
'client_id': result['client_id'],
|
|
'name': req.name,
|
|
'created_at': datetime.now().isoformat(),
|
|
}
|
|
data['user_connections'].append(conn)
|
|
save_data(data)
|
|
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("Error adding connection")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/connections/remove')
|
|
async def api_remove_connection(request: Request, server_id: int, req: ConnectionActionRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
if not req.client_id:
|
|
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
manager.remove_client(req.protocol, req.client_id)
|
|
ssh.disconnect()
|
|
# Remove from user_connections
|
|
data['user_connections'] = [
|
|
c for c in data.get('user_connections', [])
|
|
if not (c.get('client_id') == req.client_id and c.get('server_id') == server_id)
|
|
]
|
|
save_data(data)
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error removing connection")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/connections/edit')
|
|
async def api_edit_connection(request: Request, server_id: int, req: EditConnectionRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
|
|
edit_params = {}
|
|
if req.protocol == 'telemt':
|
|
edit_params['telemt_quota'] = req.telemt_quota
|
|
edit_params['telemt_max_ips'] = req.telemt_max_ips
|
|
edit_params['telemt_expiry'] = req.telemt_expiry
|
|
|
|
result = manager.edit_client(req.protocol, req.client_id, edit_params)
|
|
ssh.disconnect()
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("Error editing connection")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/connections/config')
|
|
async def api_get_connection_config(request: Request, server_id: int, req: ConnectionActionRequest):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
# Users can only view their own connections
|
|
if user['role'] == 'user':
|
|
owned = any(
|
|
c for c in data.get('user_connections', [])
|
|
if c.get('client_id') == req.client_id and c.get('server_id') == server_id and c.get('user_id') == user['id']
|
|
)
|
|
if not owned:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
server = data['servers'][server_id]
|
|
if not req.client_id:
|
|
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
|
port = proto_info.get('port', '55424')
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
config = manager.get_client_config(req.protocol, req.client_id, server['host'], port)
|
|
ssh.disconnect()
|
|
vpn_link = generate_vpn_link(config) if config else ''
|
|
return {'config': config, 'vpn_link': vpn_link}
|
|
except Exception as e:
|
|
logger.exception("Error getting connection config")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/servers/{server_id}/connections/toggle')
|
|
async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
if not req.client_id:
|
|
return JSONResponse({'error': 'Client ID is required'}, status_code=400)
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
manager.toggle_client(req.protocol, req.client_id, req.enable)
|
|
ssh.disconnect()
|
|
status = 'enabled' if req.enable else 'disabled'
|
|
return {'status': 'success', 'enabled': req.enable, 'message': f'Connection {status}'}
|
|
except Exception as e:
|
|
logger.exception("Error toggling connection")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
# ======================== USER API (admin only) ========================
|
|
|
|
@app.get('/api/users')
|
|
async def api_list_users(request: Request, search: str = '', page: int = 1, size: int = 10):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
all_users = data.get('users', [])
|
|
conns = data.get('user_connections', [])
|
|
|
|
# Filter
|
|
filtered = []
|
|
search = search.lower()
|
|
for u in all_users:
|
|
if search:
|
|
match = (search in u['username'].lower() or
|
|
(u.get('email') and search in u['email'].lower()) or
|
|
(u.get('telegramId') and search in str(u['telegramId']).lower()))
|
|
if not match:
|
|
continue
|
|
filtered.append(u)
|
|
|
|
total = len(filtered)
|
|
start = (page - 1) * size
|
|
end = start + size
|
|
page_items = filtered[start:end]
|
|
|
|
users = []
|
|
for u in page_items:
|
|
users.append({
|
|
'id': u['id'], 'username': u['username'], 'role': u['role'],
|
|
'enabled': u.get('enabled', True),
|
|
'created_at': u.get('created_at', ''),
|
|
'telegramId': u.get('telegramId'),
|
|
'email': u.get('email'),
|
|
'description': u.get('description'),
|
|
'connections_count': sum(1 for c in conns if c['user_id'] == u['id']),
|
|
'traffic_used': u.get('traffic_used', 0),
|
|
'traffic_total': u.get('traffic_total', 0),
|
|
'traffic_limit': u.get('traffic_limit', 0),
|
|
'traffic_reset_strategy': u.get('traffic_reset_strategy', 'never'),
|
|
'last_reset_at': u.get('last_reset_at'),
|
|
'share_enabled': u.get('share_enabled', False),
|
|
'share_token': u.get('share_token'),
|
|
'has_share_password': bool(u.get('share_password_hash')),
|
|
'source': 'Remnawave' if u.get('remnawave_uuid') else 'Local'
|
|
})
|
|
return {
|
|
'users': users,
|
|
'total': total,
|
|
'page': page,
|
|
'size': size,
|
|
'pages': (total + size - 1) // size
|
|
}
|
|
|
|
|
|
@app.post('/api/users/add')
|
|
async def api_add_user(request: Request, req: AddUserRequest):
|
|
cur = get_current_user(request)
|
|
if not cur or cur['role'] != 'admin':
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
lang = request.cookies.get('lang', 'ru')
|
|
# Check duplicate
|
|
if any(u['username'] == req.username for u in data.get('users', [])):
|
|
return JSONResponse({'error': _t('user_exists', lang)}, status_code=400)
|
|
if req.role not in ('admin', 'support', 'user'):
|
|
return JSONResponse({'error': 'Invalid role'}, status_code=400)
|
|
new_user = {
|
|
'id': str(uuid.uuid4()),
|
|
'username': req.username,
|
|
'password_hash': hash_password(req.password),
|
|
'role': req.role,
|
|
'telegramId': req.telegramId,
|
|
'email': req.email,
|
|
'description': req.description,
|
|
'traffic_limit': int(req.traffic_limit * 1024**3) if req.traffic_limit else 0,
|
|
'traffic_reset_strategy': req.traffic_reset_strategy or 'never',
|
|
'traffic_used': 0,
|
|
'traffic_total': 0,
|
|
'last_reset_at': datetime.now().isoformat(),
|
|
'enabled': True,
|
|
'created_at': datetime.now().isoformat(),
|
|
'remnawave_uuid': None,
|
|
'share_enabled': False,
|
|
'share_token': secrets.token_urlsafe(16),
|
|
'share_password_hash': None,
|
|
}
|
|
data['users'].append(new_user)
|
|
save_data(data)
|
|
|
|
result = {'status': 'success', 'user_id': new_user['id']}
|
|
|
|
# Auto-create connection if server & protocol specified
|
|
if req.server_id is not None and req.protocol:
|
|
if req.server_id < len(data['servers']):
|
|
server = data['servers'][req.server_id]
|
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
|
port = proto_info.get('port', '55424')
|
|
conn_name = req.connection_name or f"{req.username}_vpn"
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
conn_result = manager.add_client(req.protocol, conn_name, server['host'], port)
|
|
ssh.disconnect()
|
|
|
|
if conn_result.get('client_id'):
|
|
conn = {
|
|
'id': str(uuid.uuid4()),
|
|
'user_id': new_user['id'],
|
|
'server_id': req.server_id,
|
|
'protocol': req.protocol,
|
|
'client_id': conn_result['client_id'],
|
|
'name': conn_name,
|
|
'created_at': datetime.now().isoformat(),
|
|
}
|
|
data = load_data() # reload
|
|
data['user_connections'].append(conn)
|
|
save_data(data)
|
|
result['connection_created'] = True
|
|
if conn_result.get('config'):
|
|
result['config'] = conn_result['config']
|
|
result['vpn_link'] = generate_vpn_link(conn_result['config'])
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("Error adding user")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/users/{user_id}/update')
|
|
async def api_update_user(request: Request, user_id: str, req: UpdateUserRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
|
if not user:
|
|
return JSONResponse({'error': 'User not found'}, status_code=404)
|
|
|
|
if req.telegramId is not None: user['telegramId'] = req.telegramId
|
|
if req.email is not None: user['email'] = req.email
|
|
if req.description is not None: user['description'] = req.description
|
|
if req.traffic_limit is not None:
|
|
new_limit = int(req.traffic_limit * 1024**3)
|
|
user['traffic_limit'] = new_limit
|
|
|
|
if req.traffic_reset_strategy is not None:
|
|
user['traffic_reset_strategy'] = req.traffic_reset_strategy
|
|
user['last_reset_at'] = datetime.now().isoformat()
|
|
|
|
if req.password:
|
|
user['password_hash'] = hash_password(req.password)
|
|
|
|
save_data(data)
|
|
|
|
# Auto re-enable if traffic limit increased beyond usage
|
|
if req.traffic_limit is not None:
|
|
if new_limit > 0 and user.get('traffic_used', 0) < new_limit and not user.get('enabled', True):
|
|
await perform_toggle_user(data, user_id, True)
|
|
save_data(data)
|
|
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error updating user")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/users/{user_id}/delete')
|
|
async def api_delete_user(request: Request, user_id: str):
|
|
cur = get_current_user(request)
|
|
if not cur or cur['role'] != 'admin':
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
lang = request.cookies.get('lang', 'ru')
|
|
if cur['id'] == user_id:
|
|
return JSONResponse({'error': _t('cannot_delete_self', lang)}, status_code=400)
|
|
try:
|
|
data = load_data()
|
|
success = await perform_delete_user(data, user_id)
|
|
if not success:
|
|
return JSONResponse({'error': 'User not found'}, status_code=404)
|
|
save_data(data)
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error deleting user")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/users/{user_id}/toggle')
|
|
async def api_toggle_user(request: Request, user_id: str, req: ToggleUserRequest):
|
|
cur = get_current_user(request)
|
|
if not cur or cur['role'] != 'admin':
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
success = await perform_toggle_user(data, user_id, req.enabled)
|
|
if not success:
|
|
return JSONResponse({'error': 'User not found'}, status_code=404)
|
|
save_data(data)
|
|
return {'status': 'success', 'enabled': req.enabled}
|
|
except Exception as e:
|
|
logger.exception("Error toggling user")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/users/{user_id}/connections/add')
|
|
async def api_add_user_connection(request: Request, user_id: str, req: AddUserConnectionRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
|
if not user:
|
|
return JSONResponse({'error': 'User not found'}, status_code=404)
|
|
if req.server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][req.server_id]
|
|
proto_info = server.get('protocols', {}).get(req.protocol, {})
|
|
port = proto_info.get('port', '55424')
|
|
ssh = get_ssh(server)
|
|
await asyncio.to_thread(ssh.connect)
|
|
manager = get_protocol_manager(ssh, req.protocol)
|
|
|
|
if req.client_id:
|
|
# Use existing client
|
|
target_client_id = req.client_id
|
|
# Retrieve config for existing client
|
|
config = await asyncio.to_thread(manager.get_client_config, req.protocol, req.client_id, server['host'], port)
|
|
result = {'client_id': target_client_id, 'config': config}
|
|
else:
|
|
# Create new client
|
|
result = await asyncio.to_thread(manager.add_client, req.protocol, req.name, server['host'], port)
|
|
|
|
await asyncio.to_thread(ssh.disconnect)
|
|
|
|
if result.get('client_id'):
|
|
conn = {
|
|
'id': str(uuid.uuid4()),
|
|
'user_id': user_id,
|
|
'server_id': req.server_id,
|
|
'protocol': req.protocol,
|
|
'client_id': result['client_id'],
|
|
'name': req.name,
|
|
'created_at': datetime.now().isoformat(),
|
|
}
|
|
data = load_data()
|
|
data['user_connections'].append(conn)
|
|
save_data(data)
|
|
|
|
resp = {'status': 'success'}
|
|
if result.get('config'):
|
|
resp['config'] = result['config']
|
|
resp['vpn_link'] = generate_vpn_link(result['config'])
|
|
return resp
|
|
except Exception as e:
|
|
logger.exception("Error adding user connection")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.get('/api/users/{user_id}/connections')
|
|
async def api_get_user_connections(request: Request, user_id: str):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
# Users can only see their own, admin/support can see all
|
|
if user['role'] == 'user' and user['id'] != user_id:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id]
|
|
for c in conns:
|
|
sid = c.get('server_id', 0)
|
|
if sid < len(data['servers']):
|
|
c['server_name'] = data['servers'][sid].get('name', '')
|
|
return {'connections': conns}
|
|
|
|
|
|
# ======================== MY CONNECTIONS API (for user role) ========================
|
|
|
|
@app.get('/api/my/connections')
|
|
async def api_my_connections(request: Request):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
|
for c in conns:
|
|
sid = c.get('server_id', 0)
|
|
if sid < len(data['servers']):
|
|
c['server_name'] = data['servers'][sid].get('name', '')
|
|
else:
|
|
c['server_name'] = 'Unknown'
|
|
return {'connections': conns}
|
|
|
|
|
|
@app.post('/api/users/{user_id}/share/setup')
|
|
async def api_user_share_setup(user_id: str, req: ShareSetupRequest, request: Request):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u['id'] == user_id), None)
|
|
if not user:
|
|
return JSONResponse({'error': 'User not found'}, status_code=404)
|
|
|
|
user['share_enabled'] = req.enabled
|
|
if not user.get('share_token'):
|
|
user['share_token'] = secrets.token_urlsafe(16)
|
|
if req.password:
|
|
user['share_password_hash'] = hash_password(req.password)
|
|
elif req.password == "": # Clear
|
|
user['share_password_hash'] = None
|
|
|
|
save_data(data)
|
|
return {'status': 'success', 'share_token': user.get('share_token')}
|
|
|
|
|
|
@app.get('/share/{token}', response_class=HTMLResponse)
|
|
async def share_page(token: str, request: Request):
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u.get('share_token') == token), None)
|
|
if not user or not user.get('share_enabled'):
|
|
lang = request.cookies.get('lang', 'ru')
|
|
return HTMLResponse(f"<h1>{_t('share_not_found', lang)}</h1><p>{_t('share_not_found_desc', lang)}</p>", status_code=404)
|
|
|
|
auth_session_key = f'share_auth_{token}'
|
|
need_password = bool(user.get('share_password_hash')) and not request.session.get(auth_session_key)
|
|
|
|
return tpl(request, 'user_share.html',
|
|
share_user=user,
|
|
need_password=need_password,
|
|
token=token)
|
|
|
|
|
|
@app.post('/api/share/{token}/auth')
|
|
async def api_share_auth(token: str, req: ShareAuthRequest, request: Request):
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u.get('share_token') == token), None)
|
|
if not user or not user.get('share_enabled'):
|
|
return JSONResponse({'error': 'Link expired or disabled'}, status_code=404)
|
|
|
|
if verify_password(req.password, user.get('share_password_hash', '')):
|
|
request.session[f'share_auth_{token}'] = True
|
|
return {'status': 'success'}
|
|
else:
|
|
lang = request.cookies.get('lang', 'ru')
|
|
return JSONResponse({'error': _t('wrong_share_password', lang)}, status_code=401)
|
|
|
|
|
|
@app.get('/api/share/{token}/connections')
|
|
async def api_share_connections(token: str, request: Request):
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u.get('share_token') == token), None)
|
|
if not user or not user.get('share_enabled'):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
|
|
if user.get('share_password_hash'):
|
|
if not request.session.get(f'share_auth_{token}'):
|
|
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
|
|
|
conns = [dict(c) for c in data.get('user_connections', []) if c['user_id'] == user['id']]
|
|
for c in conns:
|
|
sid = c['server_id']
|
|
if sid < len(data['servers']):
|
|
c['server_name'] = data['servers'][sid].get('name') or data['servers'][sid]['host']
|
|
else:
|
|
c['server_name'] = 'Unknown'
|
|
|
|
return {'connections': conns, 'username': user['username']}
|
|
|
|
|
|
@app.post('/api/share/{token}/config/{connection_id}')
|
|
async def api_share_config(token: str, connection_id: str, request: Request):
|
|
data = load_data()
|
|
user = next((u for u in data['users'] if u.get('share_token') == token), None)
|
|
if not user or not user.get('share_enabled'):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
|
|
if user.get('share_password_hash'):
|
|
if not request.session.get(f'share_auth_{token}'):
|
|
return JSONResponse({'error': 'Unauthorized'}, status_code=401)
|
|
|
|
conn = next((c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']), None)
|
|
if not conn:
|
|
return JSONResponse({'error': 'Not found'}, status_code=404)
|
|
|
|
try:
|
|
sid = conn['server_id']
|
|
server = data['servers'][sid]
|
|
proto_info = server.get('protocols', {}).get(conn['protocol'], {})
|
|
port = proto_info.get('port', '55424')
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
awg = AWGManager(ssh)
|
|
config = awg.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}
|
|
except Exception as e:
|
|
logger.exception("Error getting shared config")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.post('/api/my/connections/{connection_id}/config')
|
|
async def api_my_connection_config(request: Request, connection_id: str):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
conn = next(
|
|
(c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']),
|
|
None
|
|
)
|
|
if not conn:
|
|
return JSONResponse({'error': 'Connection not found'}, status_code=404)
|
|
sid = conn['server_id']
|
|
if sid >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][sid]
|
|
proto_info = server.get('protocols', {}).get(conn['protocol'], {})
|
|
port = proto_info.get('port', '55424')
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
awg = AWGManager(ssh)
|
|
config = awg.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}
|
|
except Exception as e:
|
|
logger.exception("Error getting my connection config")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.get('/settings')
|
|
async def settings_page(request: Request):
|
|
user = _check_admin(request)
|
|
if not user:
|
|
return RedirectResponse('/login')
|
|
data = load_data()
|
|
return tpl(request, 'settings.html', settings=data.get('settings', {}), servers=data.get('servers', []))
|
|
|
|
|
|
@app.get('/api/settings')
|
|
async def api_get_settings(request: Request):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
return data.get('settings', {})
|
|
|
|
|
|
# @app.post('/api/settings/save')
|
|
# async def api_save_settings(request: Request, body: SaveSettingsRequest):
|
|
# _check_admin(request)
|
|
# data = load_data()
|
|
# data['settings'] = body.dict()
|
|
# save_data(data)
|
|
|
|
# # Trigger sync if enabled
|
|
# if body.sync.remnawave_sync_users:
|
|
# await sync_users_with_remnawave(data)
|
|
# save_data(data)
|
|
|
|
# return {'status': 'success'}
|
|
|
|
@app.post('/api/settings/save')
|
|
async def save_settings(request: Request, payload: SaveSettingsRequest):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
data['settings']['appearance'] = payload.appearance.dict()
|
|
data['settings']['sync'] = payload.sync.dict()
|
|
data['settings']['captcha'] = payload.captcha.dict()
|
|
data['settings']['telegram'] = payload.telegram.dict()
|
|
save_data(data)
|
|
logger.info("Settings saved (including captcha and telegram)")
|
|
|
|
# Handle bot start/stop based on new telegram settings
|
|
tg_cfg = payload.telegram
|
|
if tg_cfg.enabled and tg_cfg.token:
|
|
if not tg_bot.is_running():
|
|
logger.info("Starting Telegram bot (settings save)...")
|
|
tg_bot.launch_bot(tg_cfg.token, load_data, generate_vpn_link)
|
|
else:
|
|
if tg_bot.is_running():
|
|
logger.info("Stopping Telegram bot (settings save)...")
|
|
asyncio.create_task(tg_bot.stop_bot())
|
|
|
|
return {"status": "success", "bot_running": tg_bot.is_running()}
|
|
|
|
|
|
@app.post('/api/settings/telegram/toggle')
|
|
async def api_telegram_toggle(request: Request):
|
|
"""Quick enable/disable of the bot without a full settings save."""
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
tg_cfg = data.get('settings', {}).get('telegram', {})
|
|
token = tg_cfg.get('token', '')
|
|
if not token:
|
|
return JSONResponse({'error': 'Telegram token not set in settings'}, status_code=400)
|
|
|
|
if tg_bot.is_running():
|
|
await tg_bot.stop_bot()
|
|
tg_cfg['enabled'] = False
|
|
data['settings']['telegram'] = tg_cfg
|
|
save_data(data)
|
|
return {'status': 'stopped', 'bot_running': False}
|
|
else:
|
|
tg_bot.launch_bot(token, load_data, generate_vpn_link)
|
|
tg_cfg['enabled'] = True
|
|
data['settings']['telegram'] = tg_cfg
|
|
save_data(data)
|
|
return {'status': 'started', 'bot_running': True}
|
|
|
|
@app.post('/api/settings/sync_now')
|
|
async def api_sync_now(request: Request):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
count, msg = await sync_users_with_remnawave(data)
|
|
return {'status': 'success', 'count': count, 'message': msg}
|
|
|
|
|
|
@app.post('/api/settings/sync_delete')
|
|
async def api_sync_delete(request: Request):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
data = load_data()
|
|
to_delete_ids = [u['id'] for u in data['users'] if u.get('remnawave_uuid')]
|
|
if to_delete_ids:
|
|
await perform_mass_operations(delete_uids=to_delete_ids)
|
|
return {'status': 'success', 'count': len(to_delete_ids)}
|
|
|
|
|
|
@app.get('/api/servers/{server_id}/{protocol}/clients')
|
|
async def api_get_server_clients(request: Request, server_id: int, protocol: str):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
data = load_data()
|
|
if server_id >= len(data['servers']):
|
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
|
server = data['servers'][server_id]
|
|
ssh = get_ssh(server)
|
|
ssh.connect()
|
|
manager = get_protocol_manager(ssh, protocol)
|
|
clients = manager.get_clients(protocol)
|
|
ssh.disconnect()
|
|
|
|
# Filter: only show clients that are not assigned to anyone in the panel
|
|
assigned_ids = {c['client_id'] for c in data.get('user_connections', []) if c['server_id'] == server_id and c['protocol'] == protocol}
|
|
|
|
filtered = []
|
|
for c in clients:
|
|
if c['clientId'] not in assigned_ids:
|
|
filtered.append({
|
|
'id': c['clientId'],
|
|
'name': c.get('userData', {}).get('clientName', 'Unnamed')
|
|
})
|
|
|
|
return {'clients': filtered}
|
|
except Exception as e:
|
|
logger.exception("Error getting server clients")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
@app.get('/api/settings/backup/download')
|
|
async def api_backup_download(request: Request):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
if not os.path.exists(DATA_FILE):
|
|
return JSONResponse({'error': 'Data file not found'}, status_code=404)
|
|
return FileResponse(DATA_FILE, media_type='application/json', filename='data.json')
|
|
|
|
|
|
@app.post('/api/settings/backup/restore')
|
|
async def api_backup_restore(request: Request, file: UploadFile = File(...)):
|
|
if not _check_admin(request):
|
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
|
try:
|
|
content = await file.read()
|
|
if not content:
|
|
return JSONResponse({'error': 'Empty file'}, status_code=400)
|
|
|
|
try:
|
|
backup_data = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
return JSONResponse({'error': 'Invalid JSON format'}, status_code=400)
|
|
|
|
# Basic structure validation
|
|
required_keys = ['servers', 'users']
|
|
missing = [k for k in required_keys if k not in backup_data]
|
|
if missing:
|
|
return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400)
|
|
|
|
# Ensure types are correct
|
|
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)
|
|
|
|
# Save the new data
|
|
async with DATA_LOCK:
|
|
save_data(backup_data)
|
|
|
|
# In a real app we might want to restart or re-init background tasks
|
|
return {'status': 'success'}
|
|
except Exception as e:
|
|
logger.exception("Error during restore")
|
|
return JSONResponse({'error': str(e)}, status_code=500)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
uvicorn.run(app, host='0.0.0.0', port=5000)
|