diff --git a/farmq-admin/app.py b/farmq-admin/app.py index 5e6b3d5..0fdd576 100644 --- a/farmq-admin/app.py +++ b/farmq-admin/app.py @@ -24,6 +24,8 @@ from sqlalchemy import or_ import subprocess from utils.proxmox_client import ProxmoxClient from utils.vnc_proxy import init_vnc_proxy, get_vnc_proxy +from utils.vnc_websocket_proxy import vnc_proxy +import websockets def create_app(config_name=None): """Flask 애플리케이션 팩토리""" @@ -88,6 +90,11 @@ def create_app(config_name=None): init_vnc_proxy(default_host_config['host'], default_host_config['username'], default_host_config['password']) # 메인 대시보드 + @app.route('/wstest') + def websocket_test(): + """WebSocket 연결 테스트 페이지""" + return render_template('websocket_test.html') + @app.route('/') def dashboard(): """메인 대시보드""" @@ -241,6 +248,56 @@ def create_app(config_name=None): return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 + + @app.route('/api/machines//rename', methods=['POST']) + def api_rename_machine(machine_id): + """머신 이름 변경 API""" + try: + data = request.get_json() + if not data or 'new_name' not in data: + return jsonify({'error': '새 이름이 필요합니다.'}), 400 + + new_name = data['new_name'].strip() + if not new_name: + return jsonify({'error': '유효한 이름을 입력해주세요.'}), 400 + + # 이름 유효성 검사 (DNS 호환) + import re + if not re.match(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', new_name): + return jsonify({ + 'error': '이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.' + }), 400 + + print(f"🔄 머신 이름 변경 요청: ID={machine_id}, 새 이름={new_name}") + + # Headscale CLI 명령 실행 + result = subprocess.run([ + 'docker', 'exec', 'headscale', + 'headscale', 'nodes', 'rename', new_name, + '--identifier', str(machine_id), + '--output', 'json' + ], capture_output=True, text=True, timeout=30) + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + print(f"❌ 머신 이름 변경 실패: {error_msg}") + return jsonify({'error': f'이름 변경 실패: {error_msg}'}), 500 + + print(f"✅ 머신 이름 변경 성공: {new_name}") + + # 성공 응답 + return jsonify({ + 'success': True, + 'message': f'머신 이름이 "{new_name}"로 변경되었습니다.', + 'new_name': new_name, + 'new_magic_dns': f'{new_name}.headscale.local' + }) + + except subprocess.TimeoutExpired: + return jsonify({'error': '요청 시간이 초과되었습니다. 다시 시도해주세요.'}), 500 + except Exception as e: + print(f"❌ 머신 이름 변경 오류: {e}") + return jsonify({'error': f'서버 오류: {str(e)}'}), 500 @app.route('/api/pharmacy/', methods=['GET']) def api_get_pharmacy(pharmacy_id): @@ -607,7 +664,7 @@ def create_app(config_name=None): session_id = str(uuid.uuid4()) # VNC 세션 저장 - vnc_sessions[session_id] = { + session_data = { 'node': node, 'vmid': vmid, 'vm_name': vm_name, @@ -617,6 +674,16 @@ def create_app(config_name=None): 'host_key': current_host_key, # 호스트 키 저장 'created_at': datetime.now() } + vnc_sessions[session_id] = session_data + + # 세션을 파일로 저장 (WebSocket 프록시 서버용) + import json + session_file = f"/tmp/vnc_session_{session_id}.json" + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, ensure_ascii=False, default=str) + + # WebSocket 프록시 세션 생성 + vnc_proxy.create_session(session_id, vnc_data['websocket_url'], session_data) return jsonify({ 'session_id': session_id, @@ -639,11 +706,20 @@ def create_app(config_name=None): session_data = vnc_sessions[session_id] node = session_data['node'] vmid = session_data['vmid'] + host_key = session_data.get('host_key') - print(f"🔄 VNC 티켓 새로고침 요청: {node}/{vmid}") + print(f"🔄 VNC 티켓 새로고침 요청: {node}/{vmid} (host: {host_key})") + + # 호스트 설정 가져오기 + current_host_key, current_host_config = get_host_config(host_key) # Proxmox 클라이언트 생성 - client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + client = ProxmoxClient( + current_host_config['host'], + current_host_config['username'], + current_host_config['password'], + port=current_host_config['port'] + ) if not client.login(): return jsonify({'error': 'Proxmox 로그인 실패'}), 500 @@ -660,6 +736,12 @@ def create_app(config_name=None): 'updated_at': datetime.now() }) + # 세션 파일도 업데이트 (WebSocket 프록시 서버용) + import json + session_file = f"/tmp/vnc_session_{session_id}.json" + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(vnc_sessions[session_id], f, ensure_ascii=False, default=str) + print(f"✅ VNC 티켓 새로고침 완료: {session_id}") return jsonify({ @@ -684,13 +766,14 @@ def create_app(config_name=None): session_data = vnc_sessions[session_id] - # 직접 WebSocket VNC 연결 (noVNC) - 간단한 버전으로 테스트 - return render_template('vnc_simple.html', + # WebSocket 프록시를 통한 VNC 연결 + return render_template('vnc_proxy_websocket.html', vm_name=session_data['vm_name'], vmid=session_data['vmid'], node=session_data['node'], websocket_url=session_data['websocket_url'], password=session_data.get('password', ''), + session_id=session_id, vm_status=session_data.get('vm_status', 'unknown')) except Exception as e: @@ -719,6 +802,10 @@ def create_app(config_name=None): print(f"❌ VNC 프록시 콘솔 오류: {e}") return render_template('error.html', error=str(e)), 500 + # WebSocket 라우터는 Flask-SocketIO를 통해 처리됩니다. + # 클라이언트는 ws://domain/socket.io/를 통해 연결하고, + # 'vnc_proxy_connect' 이벤트를 발생시켜야 합니다. + @app.route('/vnc//ssl-help') def vnc_ssl_help(session_id): """VNC SSL 인증서 도움말 페이지""" @@ -1562,11 +1649,47 @@ def create_app(config_name=None): error_code=500), 500 # VNC WebSocket 프록시 핸들러 - @socketio.on('vnc_connect') - def handle_vnc_connect(data): - """VNC WebSocket 프록시 연결 핸들러""" + @socketio.on('vnc_proxy_connect') + def handle_vnc_proxy_connect(data): + """새로운 VNC WebSocket 프록시 연결 핸들러""" print(f"🔌 VNC 프록시 연결 요청: {data}") + try: + session_id = data.get('session_id') + + if not session_id: + emit('vnc_error', {'error': '세션 ID가 필요합니다.'}) + return + + # 세션 확인 + if session_id not in vnc_sessions: + emit('vnc_error', {'error': '유효하지 않은 세션입니다.'}) + return + + session_data = vnc_sessions[session_id] + proxy_session = vnc_proxy.get_session(session_id) + + if not proxy_session: + emit('vnc_error', {'error': '프록시 세션을 찾을 수 없습니다.'}) + return + + # WebSocket 프록시 연결 설정 완료 알림 + emit('vnc_proxy_ready', { + 'session_id': session_id, + 'vm_name': session_data['vm_name'], + 'password': session_data['password'], + 'websocket_url': f"wss://pqadmin.0bin.in/vnc-ws/{session_id}" # 외부 URL로 변경 + }) + + except Exception as e: + print(f"❌ VNC 프록시 연결 오류: {e}") + emit('vnc_error', {'error': str(e)}) + + @socketio.on('vnc_connect') + def handle_vnc_connect_legacy(data): + """레거시 VNC 연결 핸들러 (호환성 유지)""" + print(f"🔌 레거시 VNC 연결 요청: {data}") + try: vm_id = data.get('vm_id') node = data.get('node', 'pve7') @@ -1576,8 +1699,8 @@ def create_app(config_name=None): return # VNC 프록시 가져오기 - vnc_proxy = get_vnc_proxy() - if not vnc_proxy: + legacy_vnc_proxy = get_vnc_proxy() + if not legacy_vnc_proxy: emit('vnc_error', {'error': 'VNC 프록시가 초기화되지 않았습니다.'}) return @@ -1585,8 +1708,14 @@ def create_app(config_name=None): def run_vnc_proxy(): # 간단한 동기 버전으로 시작 - 실제 WebSocket 중계는 나중에 구현 try: - # VNC 연결 정보 생성 테스트 - client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + # 기본 호스트 설정 사용 + current_host_key, current_host_config = get_host_config(None) + client = ProxmoxClient( + current_host_config['host'], + current_host_config['username'], + current_host_config['password'], + port=current_host_config['port'] + ) if not client.login(): socketio.emit('vnc_error', {'error': 'Proxmox 로그인 실패'}) return diff --git a/farmq-admin/templates/machines/list.html b/farmq-admin/templates/machines/list.html index 41f5d7a..1d08e0f 100644 --- a/farmq-admin/templates/machines/list.html +++ b/farmq-admin/templates/machines/list.html @@ -112,9 +112,16 @@ {% endif %}
- {{ machine_data.machine_name or machine_data.hostname }} +
+ {{ machine_data.machine_name or machine_data.hostname }} + +
- {{ machine_data.machine_name or machine_data.hostname }}.headscale.local + {{ machine_data.machine_name or machine_data.hostname }}.headscale.local @@ -460,9 +467,137 @@ function copyToClipboard(text) { }); } +// 머신 이름 변경 모달 표시 +function showRenameModal(machineId, currentName) { + document.getElementById('renameModal').dataset.machineId = machineId; + document.getElementById('currentMachineName').textContent = currentName; + document.getElementById('newMachineName').value = currentName; + + // 모달 표시 + const modal = new bootstrap.Modal(document.getElementById('renameModal')); + modal.show(); +} + +// 머신 이름 변경 실행 +function renameMachine() { + const modal = document.getElementById('renameModal'); + const machineId = modal.dataset.machineId; + const newName = document.getElementById('newMachineName').value.trim(); + + if (!newName) { + showToast('새로운 이름을 입력해주세요.', 'warning'); + return; + } + + // 이름 유효성 검사 + const namePattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!namePattern.test(newName)) { + showToast('이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.', 'warning'); + return; + } + + // 로딩 상태 표시 + const submitBtn = document.getElementById('renameSubmitBtn'); + const originalText = submitBtn.innerHTML; + submitBtn.innerHTML = ' 변경 중...'; + submitBtn.disabled = true; + + // API 호출 + fetch(`/api/machines/${machineId}/rename`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ new_name: newName }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showToast(data.message, 'success'); + + // UI 업데이트 + document.getElementById(`machineName-${machineId}`).textContent = data.new_name; + document.getElementById(`magicDns-${machineId}`).textContent = data.new_magic_dns; + + // 모달 닫기 + bootstrap.Modal.getInstance(document.getElementById('renameModal')).hide(); + } else { + showToast(data.error || '이름 변경에 실패했습니다.', 'danger'); + } + }) + .catch(error => { + console.error('머신 이름 변경 오류:', error); + showToast('서버 오류가 발생했습니다.', 'danger'); + }) + .finally(() => { + // 로딩 상태 해제 + submitBtn.innerHTML = originalText; + submitBtn.disabled = false; + }); +} + // 초기 카운터 설정 document.addEventListener('DOMContentLoaded', function() { filterMachines(); }); + + + + + + {% endblock %} \ No newline at end of file