Add multi-host Proxmox support with SSL certificate handling

- Added support for multiple Proxmox hosts (pve7.0bin.in:443, Healthport PVE:8006)
- Enhanced VM management APIs to accept host parameter
- Fixed WebSocket URL generation bug (dynamic port handling)
- Added comprehensive SSL certificate trust help system
- Implemented host selection dropdown in UI
- Added VNC connection failure detection and automatic SSL help redirection
- Updated session management to store host_key information
- Enhanced error handling for different Proxmox configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-12 23:57:52 +09:00
parent ac620a0e15
commit fb00b0a5fd
14 changed files with 2029 additions and 32 deletions

View File

@@ -77,12 +77,34 @@
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-desktop"></i> Proxmox VM 관리</h2>
<div>
<div class="d-flex align-items-center gap-3">
<!-- 호스트 선택 드롭다운 -->
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="hostSelector" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-server"></i> {{ current_host_name or '호스트 선택' }}
</button>
<ul class="dropdown-menu" aria-labelledby="hostSelector">
{% for host_key, host_info in available_hosts.items() %}
<li>
<a class="dropdown-item {% if host_key == current_host_key %}active{% endif %}"
href="/vms?host={{ host_key }}"
onclick="changeHost('{{ host_key }}')">
<i class="fas fa-server me-2"></i>
<strong>{{ host_info.name }}</strong><br>
<small class="text-muted">{{ host_info.host }}{% if host_info.port != 443 %}:{{ host_info.port }}{% endif %}</small>
</a>
</li>
{% endfor %}
</ul>
</div>
<button id="refresh-btn" class="btn btn-outline-primary">
<i class="fas fa-sync-alt"></i> 새로고침
</button>
<span class="text-muted ms-3">
<i class="fas fa-server"></i> {{ host }}
<span class="text-muted">
<i class="fas fa-network-wired"></i>
<small>{{ current_host_info.host }}{% if current_host_info.port != 443 %}:{{ current_host_info.port }}{% endif %}</small>
</span>
</div>
</div>
@@ -271,7 +293,8 @@
body: JSON.stringify({
node: node,
vmid: vmid,
vm_name: vmName
vm_name: vmName,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
@@ -312,7 +335,8 @@
},
body: JSON.stringify({
node: node,
vmid: vmid
vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
@@ -345,7 +369,8 @@
},
body: JSON.stringify({
node: node,
vmid: vmid
vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
})
});
@@ -375,6 +400,13 @@
location.reload();
};
// 호스트 변경
function changeHost(hostKey) {
showSpinner();
showToast('호스트 변경', `${hostKey} 호스트로 연결 중...`, 'info');
// URL을 통해 페이지 이동 (이미 href에 설정되어 있음)
};
// 스피너 표시/숨김
function showSpinner() {
document.querySelector('.loading-spinner').style.display = 'block';

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ vm_name }} - VNC 콘솔 (프록시)</title>
<style>
body {
margin: 0;
background-color: dimgrey;
height: 100%;
display: flex;
flex-direction: column;
}
html {
height: 100%;
}
#top_bar {
background-color: #6e84a3;
color: white;
font: bold 12px Helvetica;
padding: 6px 5px 4px 5px;
border-bottom: 1px outset;
}
#status {
text-align: center;
}
#sendCtrlAltDelButton {
position: fixed;
top: 0px;
right: 120px;
border: 1px outset;
padding: 5px 5px 4px 5px;
cursor: pointer;
}
#connectButton {
position: fixed;
top: 0px;
right: 0px;
border: 1px outset;
padding: 5px 5px 4px 5px;
cursor: pointer;
background-color: #28a745;
color: white;
}
#screen {
flex: 1;
overflow: hidden;
}
.error {
color: #ff6b6b;
background-color: #ffe6e6;
padding: 10px;
margin: 10px;
border-radius: 4px;
border: 1px solid #ff6b6b;
}
.success {
color: #28a745;
background-color: #e6ffe6;
padding: 10px;
margin: 10px;
border-radius: 4px;
border: 1px solid #28a745;
}
</style>
</head>
<body>
<div id="top_bar">
<div id="status">로딩 중...</div>
<div id="sendCtrlAltDelButton">Ctrl+Alt+Del 전송</div>
<div id="connectButton">VNC 연결</div>
</div>
<div id="screen">
<div id="connectionMessages"></div>
</div>
<!-- Socket.IO 클라이언트 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
<!-- noVNC 라이브러리 -->
<script type="module" crossorigin="anonymous">
import RFB from '/static/novnc/core/rfb.js';
let rfb;
let desktopName;
let socket;
// 상태 표시 함수
function status(text) {
document.getElementById('status').textContent = text;
}
function showMessage(message, type = 'info') {
const messagesDiv = document.getElementById('connectionMessages');
const messageDiv = document.createElement('div');
messageDiv.className = type;
messageDiv.textContent = message;
messagesDiv.appendChild(messageDiv);
// 5초 후 메시지 제거
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 5000);
}
// noVNC 이벤트 핸들러
function connectedToServer(e) {
status("연결됨");
showMessage("VNC 서버에 성공적으로 연결되었습니다.", 'success');
}
function disconnectedFromServer(e) {
if (e.detail.clean) {
status("연결 종료");
showMessage("VNC 연결이 정상적으로 종료되었습니다.", 'info');
} else {
status("연결 실패");
showMessage("VNC 연결이 예기치 않게 종료되었습니다.", 'error');
}
}
function credentialsAreRequired(e) {
status("인증 필요");
showMessage("VNC 서버에서 추가 인증을 요구합니다.", 'error');
}
function updateDesktopName(e) {
desktopName = e.detail.name;
status("연결됨: " + desktopName);
}
function onSecurityFailure(e) {
status("보안 인증 실패");
showMessage("VNC 보안 인증에 실패했습니다: " + e.detail.reason, 'error');
}
// Socket.IO 연결 및 이벤트 핸들러
function initSocketIO() {
socket = io();
socket.on('connect', function() {
console.log('Socket.IO 연결됨');
status("서버 연결됨 - VNC 연결 대기 중");
});
socket.on('disconnect', function() {
console.log('Socket.IO 연결 종료');
status("서버 연결 종료");
});
socket.on('vnc_ready', function(data) {
console.log('VNC 연결 준비 완료:', data);
status("VNC 연결 중...");
// noVNC로 직접 연결 (테스트용)
try {
const websocketUrl = data.websocket_url;
const password = data.password;
console.log('WebSocket URL:', websocketUrl);
console.log('VNC Password:', password);
rfb = new RFB(document.getElementById('screen'), websocketUrl,
{ credentials: { password: password } });
// 이벤트 리스너 등록
rfb.addEventListener("connect", connectedToServer);
rfb.addEventListener("disconnect", disconnectedFromServer);
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
rfb.addEventListener("desktopname", updateDesktopName);
rfb.addEventListener("securityfailure", onSecurityFailure);
showMessage("VNC 연결을 시도 중입니다...", 'info');
} catch (error) {
console.error('VNC 연결 오류:', error);
showMessage("VNC 연결 중 오류가 발생했습니다: " + error.message, 'error');
}
});
socket.on('vnc_error', function(data) {
console.error('VNC 프록시 오류:', data);
status("VNC 연결 실패");
showMessage("VNC 연결 실패: " + data.error, 'error');
});
}
// VNC 연결 시작
function connectVNC() {
if (socket && socket.connected) {
showMessage("VNC 연결을 요청 중입니다...", 'info');
status("VNC 연결 요청 중...");
socket.emit('vnc_connect', {
vm_id: {{ vmid }},
node: '{{ node }}',
vm_name: '{{ vm_name }}'
});
} else {
showMessage("서버 연결이 필요합니다. 잠시 후 다시 시도해주세요.", 'error');
}
}
// Ctrl+Alt+Del 전송
function sendCtrlAltDel() {
if (rfb) {
rfb.sendCtrlAltDel();
showMessage("Ctrl+Alt+Del을 전송했습니다.", 'info');
}
}
// 이벤트 리스너 설정
document.getElementById('connectButton').onclick = connectVNC;
document.getElementById('sendCtrlAltDelButton').onclick = sendCtrlAltDel;
// 페이지 로드 시 Socket.IO 초기화
window.addEventListener('load', function() {
initSocketIO();
});
// 페이지 언로드 시 연결 정리
window.addEventListener('beforeunload', function() {
if (rfb) {
rfb.disconnect();
}
if (socket) {
socket.disconnect();
}
});
</script>
</body>
</html>

View File

@@ -80,7 +80,6 @@
status("연결이 정상적으로 종료되었습니다");
} else {
const reason = e.detail.reason || 'Unknown';
status(`연결 실패: ${reason} (Code: ${e.detail.code || 'Unknown'})`);
console.error('❌ VNC 연결 실패 상세:', {
code: e.detail.code,
reason: e.detail.reason,
@@ -89,7 +88,7 @@
// WebSocket 에러 코드별 메시지
const errorMessages = {
1006: 'WebSocket 서버에 연결할 수 없습니다. VM이 실행중인지 확인하세요.',
1006: 'WebSocket 서버에 연결할 수 없습니다. SSL 인증서를 확인하세요.',
1000: '정상적으로 연결이 종료되었습니다.',
1002: '프로토콜 오류가 발생했습니다.',
1003: '지원하지 않는 데이터를 받았습니다.',
@@ -99,6 +98,14 @@
const userFriendlyMessage = errorMessages[e.detail.code] || `알 수 없는 오류 (코드: ${e.detail.code})`;
status(`${userFriendlyMessage}`);
// SSL 인증서 문제일 가능성이 높은 경우 SSL 도움말 페이지로 이동
if (e.detail.code === 1006 || !e.detail.clean) {
setTimeout(() => {
const sessionId = window.location.pathname.split('/').pop();
window.location.href = `/vnc/${sessionId}/ssl-help`;
}, 5000); // 5초 후 이동하여 사용자가 메시지를 읽을 시간 제공
}
}
}

View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSL 인증서 문제 해결 - {{ vm_name }}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.ssl-help-container {
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
margin: 50px auto;
max-width: 800px;
overflow: hidden;
}
.ssl-help-header {
background: linear-gradient(135deg, #ff6b6b, #ffd93d);
color: white;
padding: 30px;
text-align: center;
}
.ssl-help-body {
padding: 30px;
}
.step-card {
border: 1px solid #e9ecef;
border-radius: 10px;
margin-bottom: 20px;
overflow: hidden;
}
.step-header {
background: #f8f9fa;
padding: 15px 20px;
font-weight: bold;
display: flex;
align-items: center;
}
.step-number {
background: #007bff;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 14px;
font-weight: bold;
}
.step-content {
padding: 20px;
}
.url-box {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 10px;
font-family: monospace;
word-break: break-all;
margin: 10px 0;
}
.btn-copy {
margin-left: 10px;
padding: 5px 10px;
font-size: 12px;
}
.warning-box {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
}
.success-box {
background: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="ssl-help-container">
<div class="ssl-help-header">
<i class="fas fa-shield-alt fa-3x mb-3"></i>
<h2>SSL 인증서 신뢰 설정이 필요합니다</h2>
<p class="mb-0">{{ vm_name }} VNC 연결을 위해 Proxmox 서버의 SSL 인증서를 신뢰해야 합니다.</p>
</div>
<div class="ssl-help-body">
<div class="warning-box">
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
<strong>연결 실패 원인:</strong> 브라우저가 Proxmox 서버의 자체 서명된 SSL 인증서를 신뢰하지 않아 WebSocket 연결이 차단되고 있습니다.
</div>
<div class="step-card">
<div class="step-header">
<div class="step-number">1</div>
SSL 인증서 신뢰 설정
</div>
<div class="step-content">
<p>아래 링크를 <strong>새 탭</strong>에서 열어 Proxmox 서버의 SSL 인증서를 신뢰하도록 설정하세요:</p>
<div class="url-box">
<a href="https://{{ proxmox_host }}:{{ proxmox_port }}" target="_blank" id="proxmoxUrl">
https://{{ proxmox_host }}:{{ proxmox_port }}
</a>
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyUrl()">
<i class="fas fa-copy"></i> 복사
</button>
</div>
<div class="mt-3">
<h6><i class="fas fa-info-circle text-info me-2"></i>브라우저별 설정 방법:</h6>
<ul class="list-unstyled ms-3">
<li><strong>Chrome/Edge:</strong> "고급" → "{{ proxmox_host }}(으)로 이동(안전하지 않음)" 클릭</li>
<li><strong>Firefox:</strong> "고급" → "위험을 감수하고 계속" 클릭</li>
<li><strong>Safari:</strong> "세부 정보 표시" → "웹 사이트 방문" 클릭</li>
</ul>
</div>
</div>
</div>
<div class="step-card">
<div class="step-header">
<div class="step-number">2</div>
Proxmox 웹 인터페이스 확인
</div>
<div class="step-content">
<p>링크를 클릭하면 Proxmox VE 웹 인터페이스가 표시됩니다. 로그인할 필요는 없으며, 페이지가 정상적으로 로드되면 SSL 인증서 신뢰 설정이 완료된 것입니다.</p>
<div class="success-box">
<i class="fas fa-check-circle text-success me-2"></i>
<strong>성공 확인:</strong> Proxmox 로그인 페이지가 보이면 인증서 신뢰 설정이 완료되었습니다.
</div>
</div>
</div>
<div class="step-card">
<div class="step-header">
<div class="step-number">3</div>
VNC 연결 재시도
</div>
<div class="step-content">
<p>SSL 인증서 신뢰 설정이 완료되면, 아래 버튼을 클릭하여 VNC 연결을 다시 시도하세요:</p>
<div class="text-center mt-4">
<button class="btn btn-primary btn-lg" onclick="retryVncConnection()">
<i class="fas fa-desktop me-2"></i>VNC 연결 재시도
</button>
<button class="btn btn-outline-secondary btn-lg ms-3" onclick="testWebSocket()">
<i class="fas fa-wifi me-2"></i>연결 테스트
</button>
</div>
<div id="connectionStatus" class="mt-3"></div>
</div>
</div>
<div class="step-card">
<div class="step-header">
<div class="step-number">4</div>
문제 해결
</div>
<div class="step-content">
<p>위 단계를 완료했는데도 연결이 되지 않는다면:</p>
<ul>
<li>브라우저를 완전히 닫고 다시 열어보세요</li>
<li>시크릿/인코그니토 모드에서 시도해보세요</li>
<li>다른 브라우저를 사용해보세요</li>
<li>방화벽이나 프록시 설정을 확인하세요</li>
</ul>
<div class="text-center mt-3">
<a href="/vms?host={{ host_key }}" class="btn btn-outline-dark">
<i class="fas fa-arrow-left me-2"></i>VM 목록으로 돌아가기
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// URL 복사 함수
function copyUrl() {
const url = document.getElementById('proxmoxUrl').href;
navigator.clipboard.writeText(url).then(() => {
alert('URL이 클립보드에 복사되었습니다.');
});
}
// VNC 연결 재시도
function retryVncConnection() {
const sessionId = '{{ session_id }}';
window.location.href = `/vnc/${sessionId}`;
}
// WebSocket 연결 테스트
function testWebSocket() {
const statusDiv = document.getElementById('connectionStatus');
statusDiv.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 연결 테스트 중...</div>';
try {
const websocketUrl = '{{ websocket_url }}';
const ws = new WebSocket(websocketUrl);
const timeout = setTimeout(() => {
ws.close();
statusDiv.innerHTML = `
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>연결 시간 초과</strong><br>
SSL 인증서 신뢰 설정을 먼저 완료해주세요.
</div>`;
}, 5000);
ws.onopen = function() {
clearTimeout(timeout);
ws.close();
statusDiv.innerHTML = `
<div class="alert alert-success">
<i class="fas fa-check-circle me-2"></i>
<strong>연결 성공!</strong><br>
VNC 연결 재시도 버튼을 클릭하세요.
</div>`;
};
ws.onerror = function() {
clearTimeout(timeout);
statusDiv.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-times-circle me-2"></i>
<strong>연결 실패</strong><br>
SSL 인증서 신뢰 설정이 필요합니다.
</div>`;
};
ws.onclose = function(event) {
if (event.code !== 1000) {
clearTimeout(timeout);
statusDiv.innerHTML = `
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>연결 종료</strong><br>
SSL 인증서를 신뢰한 후 다시 시도해주세요.
</div>`;
}
};
} catch (error) {
statusDiv.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-times-circle me-2"></i>
<strong>테스트 오류</strong><br>
${error.message}
</div>`;
}
}
</script>
</body>
</html>