diff --git a/README.md b/README.md index 87a2727..a1884c2 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.5.4 +* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links). + ### v2.5.3 * **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable. diff --git a/app.py b/app.py index 30b1d02..9025954 100644 --- a/app.py +++ b/app.py @@ -102,7 +102,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.5.3" +CURRENT_VERSION = "v2.5.4" RELEASES_REPO_URL = repo_url() RELEASES_API_LATEST = api_latest_url() BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -1311,6 +1311,186 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool: return True +def resolve_protocol_on_server(server: dict, protocol: str) -> Optional[str]: + """Pick an installed protocol key on server matching the requested protocol/base.""" + protocols = server.get('protocols') or {} + if protocol in protocols and protocols[protocol].get('installed'): + return protocol + base = protocol_base(protocol) + candidates = [ + key for key, info in protocols.items() + if protocol_base(key) == base and info.get('installed') + ] + if not candidates: + return None + return sorted(candidates, key=protocol_instance)[0] + + +def _client_display_name(client: dict) -> str: + user_data = client.get('userData') or {} + return ( + user_data.get('clientName') + or client.get('clientName') + or client.get('clientId') + or 'Connection' + ) + + +def _create_remote_client(manager, protocol: str, server: dict, name: str, source_client: Optional[dict] = None): + proto_info = server.get('protocols', {}).get(protocol, {}) + port = proto_info.get('port', '55424') + base = protocol_base(protocol) + if base == 'telemt': + user_data = (source_client or {}).get('userData') or {} + return manager.add_client( + protocol, name, server['host'], port, + telemt_quota=user_data.get('quota'), + telemt_expiry=user_data.get('expiry'), + secret=user_data.get('token'), + user_ad_tag=user_data.get('user_ad_tag'), + max_tcp_conns=user_data.get('max_tcp_conns'), + ) + if base == 'wireguard': + return manager.add_client(name, server['host']) + return manager.add_client(protocol, name, server['host'], port) + + +def _move_connections_sync( + source_server_id: int, + target_server_id: int, + protocol: str, + client_ids: List[str], + target_protocol: Optional[str] = None, + delete_source: bool = True, +) -> dict: + data = load_data() + if source_server_id >= len(data['servers']) or target_server_id >= len(data['servers']): + raise ValueError('Server not found') + if source_server_id == target_server_id: + raise ValueError('Source and target server must be different') + if protocol_base(protocol) == 'xui': + raise ValueError('Moving 3x-ui connections between servers is not supported') + + source_server = data['servers'][source_server_id] + target_server = data['servers'][target_server_id] + resolved_target_protocol = target_protocol or resolve_protocol_on_server(target_server, protocol) + if not resolved_target_protocol: + raise ValueError( + f'Target server does not have {protocol_display_name(protocol)} installed' + ) + + source_ssh = get_ssh(source_server) + target_ssh = get_ssh(target_server) + source_ssh.connect() + target_ssh.connect() + + source_manager = get_protocol_manager(source_ssh, protocol) + source_clients = _manager_call(source_manager, 'get_clients', protocol) or [] + clients_map = {c.get('clientId'): c for c in source_clients if c.get('clientId')} + + target_manager = get_protocol_manager(target_ssh, resolved_target_protocol) + moved = [] + failed = [] + + try: + for client_id in client_ids: + client = clients_map.get(client_id) + if not client: + failed.append({'client_id': client_id, 'error': 'Client not found on source server'}) + continue + + user_data = client.get('userData') or {} + base = protocol_base(protocol) + if user_data.get('externalClient') and not user_data.get('clientPrivateKey') and base in ( + 'awg', 'awg2', 'awg_legacy', 'wireguard', + ): + failed.append({ + 'client_id': client_id, + 'error': 'External/native client cannot be moved (no private key)', + }) + continue + + name = _client_display_name(client) + enabled = client.get('enabled', True) + if user_data.get('enabled') is False: + enabled = False + + try: + created = _create_remote_client( + target_manager, resolved_target_protocol, target_server, name, client, + ) + new_client_id = created.get('client_id') + if not new_client_id: + failed.append({'client_id': client_id, 'error': 'Failed to create client on target server'}) + continue + + if not enabled: + _manager_call( + target_manager, 'toggle_client', + resolved_target_protocol, new_client_id, False, + ) + + user_conn = next( + ( + uc for uc in data.get('user_connections', []) + if uc.get('client_id') == client_id + and uc.get('server_id') == source_server_id + and uc.get('protocol') == protocol + ), + None, + ) + if user_conn: + user_conn['server_id'] = target_server_id + user_conn['client_id'] = new_client_id + user_conn['protocol'] = resolved_target_protocol + user_conn['name'] = name + elif client.get('assigned_user_id'): + data.setdefault('user_connections', []).append({ + 'id': str(uuid.uuid4()), + 'user_id': client['assigned_user_id'], + 'server_id': target_server_id, + 'protocol': resolved_target_protocol, + 'client_id': new_client_id, + 'name': name, + 'created_at': datetime.now().isoformat(), + }) + + if delete_source: + _manager_call(source_manager, 'remove_client', protocol, client_id) + data['user_connections'] = [ + uc for uc in data.get('user_connections', []) + if not ( + uc.get('client_id') == client_id + and uc.get('server_id') == source_server_id + and uc.get('protocol') == protocol + ) + ] + + moved.append({ + 'client_id': client_id, + 'new_client_id': new_client_id, + 'name': name, + }) + except Exception as exc: + logger.exception('Failed to move client %s', client_id) + failed.append({'client_id': client_id, 'error': str(exc)}) + finally: + source_ssh.disconnect() + target_ssh.disconnect() + + if moved: + save_data(data) + + return { + 'status': 'success' if moved else 'error', + 'moved': moved, + 'failed': failed, + 'target_server_id': target_server_id, + 'target_protocol': resolved_target_protocol, + 'message': f'Moved {len(moved)} connection(s)' + (f', {len(failed)} failed' if failed else ''), + } + + 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. @@ -1929,10 +2109,12 @@ class ConnectionActionRequest(BaseModel): client_id: str = '' -class ToggleConnectionRequest(BaseModel): +class MoveConnectionsRequest(BaseModel): protocol: str = 'awg' - client_id: str = '' - enable: bool = True + target_server_id: int + client_ids: List[str] + target_protocol: Optional[str] = None + delete_source: bool = True class AddUserRequest(BaseModel): @@ -2457,7 +2639,22 @@ async def server_detail(request: Request, server_id: int): 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) + servers_for_move = [ + { + 'id': idx, + 'name': srv.get('name') or srv.get('host') or f'Server {idx + 1}', + 'host': srv.get('host') or '', + } + for idx, srv in enumerate(data['servers']) + ] + return tpl( + request, + 'server.html', + server=server, + server_id=server_id, + users=users_list, + servers_for_move=servers_for_move, + ) @app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) @@ -4272,6 +4469,35 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne return JSONResponse({'error': str(e)}, status_code=500) +@app.post('/api/servers/{server_id}/connections/move', tags=["Connections"]) +async def api_move_connections(request: Request, server_id: int, req: MoveConnectionsRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not req.client_ids: + return JSONResponse({'error': 'No connections selected'}, status_code=400) + try: + result = await asyncio.to_thread( + _move_connections_sync, + server_id, + req.target_server_id, + req.protocol, + req.client_ids, + req.target_protocol, + bool(req.delete_source), + ) + if not result.get('moved'): + return JSONResponse( + {'error': result.get('message') or 'Move failed', **result}, + status_code=400, + ) + return result + except ValueError as e: + return JSONResponse({'error': str(e)}, status_code=400) + except Exception as e: + logger.exception('Error moving connections') + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"]) async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): if not _check_admin(request): diff --git a/static/css/style.css b/static/css/style.css index b9bd746..65aa2d2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1282,6 +1282,7 @@ a:hover { display: flex; align-items: center; justify-content: space-between; + gap: var(--space-sm); padding: var(--space-md) var(--space-lg); background: var(--bg-card); border: 1px solid var(--border-color); @@ -1294,6 +1295,45 @@ a:hover { background: var(--bg-card-hover); } +.conn-select-wrap { + flex-shrink: 0; + display: flex; + align-items: center; + cursor: pointer; +} + +.conn-select { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.connections-bulk-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1px dashed var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.connections-select-all { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: 0.85rem; + color: var(--text-secondary); + cursor: pointer; +} + +.connections-selected-count { + font-size: 0.8rem; + color: var(--text-muted); +} + .client-info { display: flex; align-items: center; diff --git a/templates/server.html b/templates/server.html index e931444..feb8833 100644 --- a/templates/server.html +++ b/templates/server.html @@ -544,12 +544,23 @@ + +
+{{ _('move_connections_hint') }}
+