8 Commits

Author SHA1 Message Date
7aa08682b8 Implement smart Magic DNS copy with automatic port detection
### Magic DNS Smart Copy Features:
- **PBS servers**: Automatically append `:8007` port when copying
- **PVE servers**: Automatically append `:8006` port when copying
- **Other machines**: Copy Magic DNS address without port (existing behavior)

### UI Improvements:
- PBS servers: Blue button with `:8007` port hint
- PVE servers: Orange button with `:8006` port hint
- Enhanced tooltips with service-specific port information
- Visual distinction between different server types

### PBS Backup Server Monitoring:
- Complete PBS API integration with authentication
- Real-time backup/restore task monitoring with detailed logs
- Namespace-separated backup visualization with color coding
- Datastore usage monitoring and status tracking
- Task history with success/failure status and error details

### Technical Implementation:
- Smart port detection via JavaScript `addSmartPort()` function
- Jinja2 template logic for conditional button styling
- PBS API endpoints for comprehensive backup monitoring
- Enhanced clipboard functionality with user feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 10:48:47 +09:00
be3795c7bf Add Git repository link to sidebar navigation
- Added Git repository navigation item below Medivault link
- Links to working Headscale setup branch
- Opens in new tab for easy access to project source

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:54:12 +09:00
c68ed59946 Implement Headscale machine rename functionality in FarmQ Admin
Add machine name editing feature similar to Headplane:
- REST API endpoint POST /api/machines/{id}/rename with Headscale CLI integration
- Edit button next to each machine name in the machine list
- Modal dialog with real-time Magic DNS preview
- DNS-compatible name validation (lowercase, digits, hyphens only)
- Immediate UI updates after successful rename
- Loading states and error handling

Features:
- farmq-admin/app.py: New rename API endpoint with subprocess Headscale CLI calls
- farmq-admin/templates/machines/list.html: Edit buttons, rename modal, JavaScript functions
- Real-time validation and Magic DNS preview
- Bootstrap modal with form validation
- Error handling with toast notifications

Users can now rename machines and their Magic DNS addresses directly from the web interface,
matching the functionality available in Headplane.

Tested with machine ID 2: desktop-emjd1dc ↔ desktop-emjd1dc-test 

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:38:57 +09:00
a9aa31cc4a Implement FarmQ Admin machine name display fix for Magic DNS
Fix machine management page to display proper Magic DNS names:
- Use given_name instead of hostname for machine display
- Add Magic DNS address with copy-to-clipboard functionality
- Distinguish between machine name and OS hostname like Headplane
- Enhance UI with Magic DNS information (.headscale.local)

Changes:
- farmq-admin/utils/database_new.py: Use given_name for machine_name
- farmq-admin/models/farmq_models.py: Update sync logic for given_name
- farmq-admin/templates/machines/list.html: Add Magic DNS display with copy feature
- FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md: Complete analysis and implementation plan

Now displays:
- Machine Name: pbs-hp (Magic DNS name)
- Magic DNS: pbs-hp.headscale.local (with copy button)
- OS Hostname: proxmox-backup-server (system name)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:28:20 +09:00
b4ce883546 Fix Magic DNS issue: Enable DNS acceptance in Linux installation script
Changed --accept-dns=false to --accept-dns=true in quick-install.sh:
- Automatic registration command (line 347)
- Manual registration example command (line 357)

This ensures Linux clients also receive Headscale Magic DNS configuration
(100.64.0.1) automatically during installation, enabling proper name resolution
for *.headscale.local domains on all platforms.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 22:43:28 +09:00
4123babcea Fix Magic DNS issue: Enable DNS acceptance in Windows installation scripts
Changed --accept-dns=false to --accept-dns=true in both:
- farmq-install-en.ps1
- farmq-install.ps1

This allows Windows clients to receive Headscale Magic DNS configuration
(100.64.0.1) automatically during installation, enabling proper name resolution
for *.headscale.local domains.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 22:34:18 +09:00
fb00b0a5fd 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>
2025-09-13 00:03:25 +09:00
ac620a0e15 VNC WebSocket 연결 문제 - 브라우저 보안 정책으로 인한 미해결 상태
WebSocket 1006 오류로 인해 브라우저에서 VNC 연결 실패
- 서버 환경에서는 연결 가능하나 브라우저 보안 정책으로 차단
- 역방향 프록시 솔루션 문서화 완료
- 추후 nginx 프록시 구현 필요

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 01:44:47 +09:00
25 changed files with 5208 additions and 71 deletions

View File

@@ -0,0 +1,289 @@
# FarmQ Admin 머신 이름 표시 문제 해결 계획
## 📋 문제 상황
현재 FarmQ Admin의 머신 관리 페이지에서 **머신 이름이 hostname으로만 표시**되고 있어, Magic DNS에서 사용되는 실제 노드 이름(`given_name`)과 일치하지 않는 문제가 발생하고 있습니다.
## 🔍 문제 분석
### 1. 현재 상황
**Headscale CLI API 실제 데이터:**
```json
{
"id": 1,
"name": "0bin-Ubuntu-VM", // ❌ 실제 Magic DNS에서 사용되는 이름
"given_name": "0bin-ubuntu-vm", // ✅ Magic DNS 접두어 (pev.headscale.local)
"ip_addresses": ["100.64.0.1"],
"user": {"name": "myuser"},
"online": true
}
```
**FarmQ Admin 현재 표시:**
```html
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<div class="small text-muted">{{ machine_data.hostname }}</div>
```
### 2. 문제의 근본 원인
#### A. 데이터베이스 스키마 불일치
**Headscale 모델:**
```python
# models/headscale_models.py
class Node:
hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
given_name = Column(String) # 사용자 지정 이름 (0bin-ubuntu-vm) - Magic DNS용
```
**FarmQ 모델:**
```python
# models/farmq_models.py
class MachineProfile:
hostname = Column(String) # hostname으로 복사됨
machine_name = Column(String) # hostname으로 중복 저장됨 ❌
```
#### B. 동기화 로직 문제
```python
# utils/database_new.py (line 343)
machine_data = {
'hostname': node.hostname, # "0bin-Ubuntu-VM"
'machine_name': node.hostname, # ❌ hostname 중복! given_name이어야 함
'tailscale_ip': node.ipv4,
}
```
#### C. 템플릿 표시 문제
```html
<!-- templates/machines/list.html (line 115) -->
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<!-- 현재: "0bin-Ubuntu-VM" (hostname) -->
<!-- 원하는: "0bin-ubuntu-vm" (given_name) -->
```
## ✅ 해결 방안
### 1. 즉시 수정 (Quick Fix)
#### A. 데이터베이스 동기화 로직 수정
```python
# farmq-admin/utils/database_new.py
def get_all_machines_with_details():
machine_data = {
'hostname': node.hostname, # 시스템 호스트명
'machine_name': node.given_name, # ✅ Magic DNS 이름으로 변경
'display_name': node.given_name or node.hostname, # 표시용 이름
'tailscale_ip': node.ipv4,
}
```
#### B. FarmQ 모델 동기화 수정
```python
# models/farmq_models.py - sync_machine_from_headscale
machine = MachineProfile(
hostname=headscale_node_data.get('hostname'),
machine_name=headscale_node_data.get('given_name'), # ✅ given_name 사용
tailscale_ip=headscale_node_data.get('ipv4'),
)
```
#### C. 템플릿 표시 개선
```html
<!-- templates/machines/list.html -->
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<div class="small text-muted">
<i class="fas fa-network-wired"></i> {{ machine_data.machine_name }}.headscale.local
</div>
<div class="small text-muted">
<i class="fas fa-server"></i> {{ machine_data.hostname }}
</div>
```
### 2. 근본적 개선 (Long-term)
#### A. 필드 명칭 명확화
```python
class MachineProfile:
system_hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
headscale_name = Column(String) # Headscale given_name (0bin-ubuntu-vm)
magic_dns_name = Column(String) # Magic DNS 전체 이름 (0bin-ubuntu-vm.headscale.local)
display_name = Column(String) # 사용자 표시용 이름
```
#### B. Magic DNS 정보 표시 강화
```html
<div class="machine-info">
<h5>{{ machine.display_name }}</h5>
<div class="dns-info">
<code>{{ machine.magic_dns_name }}</code>
<button class="btn btn-sm" onclick="copyToClipboard('{{ machine.magic_dns_name }}')">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="system-info small text-muted">
시스템: {{ machine.system_hostname }}
</div>
</div>
```
## 🚀 구현 단계
### Phase 1: 긴급 수정 (1-2시간)
1. **데이터 동기화 로직 수정**
```bash
# 파일: farmq-admin/utils/database_new.py
# 라인: 343
'machine_name': node.given_name, # hostname → given_name
```
2. **템플릿 표시 개선**
```bash
# 파일: farmq-admin/templates/machines/list.html
# Magic DNS 정보 추가 표시
```
3. **동기화 함수 수정**
```bash
# 파일: farmq-admin/models/farmq_models.py
# sync_machine_from_headscale 함수 수정
```
### Phase 2: 표시 개선 (2-3시간)
1. **Magic DNS 정보 강화 표시**
- `.headscale.local` 접미사 자동 표시
- 클립보드 복사 기능
- 연결 테스트 기능
2. **필터링 기능 추가**
- Magic DNS 이름으로 검색
- 온라인/오프라인 필터
- 사용자별 필터
### Phase 3: 구조적 개선 (4-6시간)
1. **데이터베이스 스키마 개선**
- 필드명 명확화
- Magic DNS 전용 필드 추가
- 인덱스 최적화
2. **API 통합 개선**
- Headscale CLI 응답 캐싱
- 실시간 상태 업데이트
- WebSocket을 통한 실시간 알림
## 📊 예상 결과
### Before (현재)
```
머신 이름: 0bin-Ubuntu-VM # hostname (시스템명)
호스트명: 0bin-Ubuntu-VM # 중복 정보
Magic DNS: 사용불가 ❌ # given_name 정보 부족
```
### After (수정 후)
```
머신 이름: 0bin-ubuntu-vm # given_name (Magic DNS용)
Magic DNS: 0bin-ubuntu-vm.headscale.local ✅
시스템명: 0bin-Ubuntu-VM # hostname (참고 정보)
IP 주소: 100.64.0.1 # 현재와 동일
```
## 🧪 테스트 계획
### 1. 데이터 검증
```python
# 테스트 스크립트
def test_machine_name_mapping():
nodes = headscale_session.query(Node).all()
for node in nodes:
print(f"ID: {node.id}")
print(f"Hostname: {node.hostname}") # 0bin-Ubuntu-VM
print(f"Given Name: {node.given_name}") # 0bin-ubuntu-vm
print(f"Magic DNS: {node.given_name}.headscale.local")
print("---")
```
### 2. Magic DNS 연결 테스트
```bash
# 각 노드별 Magic DNS 테스트
ping 0bin-ubuntu-vm.headscale.local
ping pev.headscale.local
ping pqserver.headscale.local
```
### 3. UI 표시 확인
- 머신 목록에서 올바른 이름 표시
- Magic DNS 주소 복사 기능
- 연결 상태와 일치성 확인
## 📝 체크리스트
### 코드 수정
- [ ] `farmq-admin/utils/database_new.py` - 동기화 로직 수정
- [ ] `farmq-admin/models/farmq_models.py` - 모델 동기화 수정
- [ ] `farmq-admin/templates/machines/list.html` - 표시 개선
- [ ] `farmq-admin/templates/machines/detail.html` - 상세 페이지 수정
### 테스트
- [ ] 데이터베이스 동기화 테스트
- [ ] Magic DNS 이름 표시 확인
- [ ] UI 표시 정상성 확인
- [ ] 기존 기능 호환성 테스트
### 문서화
- [ ] 변경사항 README 업데이트
- [ ] API 문서 갱신
- [ ] 사용자 가이드 수정
## 🔧 구현 파일 목록
### 수정할 파일들
1. **`farmq-admin/utils/database_new.py`** (라인 343)
- `machine_name` 필드를 `given_name`으로 변경
2. **`farmq-admin/models/farmq_models.py`** (라인 445)
- 동기화 시 `given_name` 사용
3. **`farmq-admin/templates/machines/list.html`** (라인 115-119)
- Magic DNS 정보 추가 표시
4. **`farmq-admin/templates/machines/detail.html`**
- 상세 페이지 Magic DNS 정보 개선
### 새로 추가할 기능
- Magic DNS 주소 클립보드 복사
- 연결 테스트 버튼
- 실시간 온라인 상태 표시
## 💡 장기적 개선사항
### 1. Headscale API 직접 통합
현재 CLI 기반 → REST API 직접 호출로 전환하여 성능 개선
### 2. 실시간 모니터링
WebSocket을 통한 실시간 노드 상태 업데이트
### 3. Magic DNS 관리 기능
- 노드 이름 변경
- Magic DNS 도메인 설정
- DNS 해석 테스트 도구
## 📅 구현 일정
| 단계 | 작업 | 소요시간 | 완료일 |
|------|------|---------|---------|
| Phase 1 | 긴급 수정 | 2시간 | 당일 |
| Phase 2 | 표시 개선 | 3시간 | 1일 |
| Phase 3 | 구조 개선 | 6시간 | 2-3일 |
---
**작성일:** 2025년 9월 13일
**업데이트:** FarmQ Admin 머신 이름 표시 문제 분석 및 해결 계획 수립 완료

View File

@@ -0,0 +1,343 @@
# 다중 Proxmox 호스트 관리 시스템 기획서
## 🎯 프로젝트 개요
약국별 독립적인 Proxmox 서버를 중앙에서 통합 관리하는 시스템 구축. Headscale 네트워크를 통해 여러 Proxmox 호스트에 접속하여 VM을 관리하고 VNC 원격 접속을 제공.
## 📋 현재 상황 분석
### 기존 구현 현황
- ✅ 단일 Proxmox 서버 (`pve7.0bin.in`) 연동 완료
- ✅ VNC WebSocket 직접 연결 구현
- ✅ VNC 인증 실패 자동 해결 시스템
- ✅ VM 목록 조회 및 상태 확인
- ✅ VM 시작/정지 기능
### 확장 요구사항
- 약국 수: 100개 (예상)
- Proxmox 호스트: 약국별 1개씩 (총 100대)
- 네트워크: Headscale VPN으로 연결된 사설 IP (예: 100.64.0.x)
- 인증: 모든 Proxmox에서 동일한 `root@pam` 계정 사용
## 🏗️ 시스템 아키텍처
```
┌─────────────────────────────────────────┐
│ Central Management Web UI │
│ (farmq-admin) │
└─────────────────┬───────────────────────┘
┌─────────┴─────────┐
│ Headscale VPN │
│ Network │
└─────────┬─────────┘
┌─────────────┼─────────────┐
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
│약국 A │ │약국 B │ │약국 C │
│Proxmox│ │Proxmox│ │Proxmox│
│100.64.│ │100.64.│ │100.64.│
│0.10 │ │0.11 │ │0.12 │
└───┬───┘ └───┬───┘ └───┬───┘
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
│VM들 │ │VM들 │ │VM들 │
│- 키오스│ │- POS │ │- 서버 │
│- POS │ │- 키오스│ │- 백업 │
└───────┘ └───────┘ └───────┘
```
## 📊 데이터베이스 설계
### 1. Proxmox 호스트 관리 테이블
```sql
CREATE TABLE proxmox_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
pharmacy_name VARCHAR(255) NOT NULL,
host_ip VARCHAR(15) NOT NULL, -- 100.64.0.x
host_port INTEGER DEFAULT 443,
username VARCHAR(50) DEFAULT 'root@pam',
password VARCHAR(255) NOT NULL,
status ENUM('online', 'offline', 'maintenance') DEFAULT 'offline',
last_check TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(pharmacy_id)
);
```
### 2. VM 인벤토리 테이블
```sql
CREATE TABLE vm_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxmox_host_id INTEGER NOT NULL,
vmid INTEGER NOT NULL,
vm_name VARCHAR(255) NOT NULL,
vm_type ENUM('kiosk', 'pos', 'server', 'backup', 'other') DEFAULT 'other',
node_name VARCHAR(100) NOT NULL,
status ENUM('running', 'stopped', 'suspended') DEFAULT 'stopped',
cpu_cores INTEGER,
memory_mb INTEGER,
disk_gb INTEGER,
ip_address VARCHAR(15),
description TEXT,
last_sync TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
);
```
### 3. VNC 세션 확장 테이블
```sql
CREATE TABLE vnc_sessions_extended (
session_id VARCHAR(36) PRIMARY KEY,
proxmox_host_id INTEGER NOT NULL,
vmid INTEGER NOT NULL,
vm_name VARCHAR(255) NOT NULL,
websocket_url TEXT NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
last_refresh TIMESTAMP,
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
);
```
## 🔧 주요 기능 구현 계획
### 1. Proxmox 호스트 관리
- **호스트 등록/수정/삭제**
- 약국 정보와 연동하여 Proxmox 호스트 정보 관리
- IP, 포트, 인증 정보 저장
- **호스트 상태 모니터링**
- 주기적 헬스체크 (ping, API 연결 확인)
- 오프라인 호스트 알림 및 상태 표시
- **보안 관리**
- 패스워드 암호화 저장
- API 토큰 관리 옵션 제공
### 2. 통합 VM 관리 대시보드
- **멀티 호스트 VM 목록**
```
약국A (100.64.0.10) - 온라인
├── VM 101: Kiosk-1 (실행중)
├── VM 102: POS-System (정지됨)
└── VM 103: Backup-Server (실행중)
약국B (100.64.0.11) - 오프라인
├── 연결 불가
약국C (100.64.0.12) - 온라인
├── VM 201: Main-Server (실행중)
└── VM 202: Kiosk-Terminal (실행중)
```
- **통합 검색 및 필터링**
- 약국별, VM 타입별, 상태별 필터링
- VM 이름 및 IP 주소 검색
- 상태별 대시보드 (전체 실행중/정지됨 VM 수)
- **배치 작업**
- 여러 VM 동시 시작/정지
- 약국별 전체 VM 관리
- 예약 작업 (특정 시간에 VM 시작/정지)
### 3. 동적 VNC 접속 시스템
- **호스트 자동 선택**
```python
def connect_to_vm(pharmacy_id, vmid):
# 1. pharmacy_id로 Proxmox 호스트 정보 조회
host_info = get_proxmox_host_by_pharmacy(pharmacy_id)
# 2. 해당 호스트에서 VM 정보 확인
vm_info = get_vm_info(host_info, vmid)
# 3. VNC 티켓 생성 및 연결
vnc_ticket = create_vnc_ticket(host_info, vm_info)
return vnc_ticket
```
- **다중 세션 관리**
- 서로 다른 Proxmox 호스트의 여러 VM에 동시 VNC 접속
- 세션별 독립적인 티켓 관리
- 탭 기반 다중 VNC 창 지원
### 4. 모니터링 및 알림
- **리소스 모니터링**
- 모든 Proxmox 호스트의 CPU, 메모리, 스토리지 사용량
- VM별 리소스 사용 현황
- 임계치 초과 시 알림
- **이벤트 로그**
- VM 시작/정지 이력
- VNC 접속 이력
- 시스템 오류 및 복구 이력
## 🛠️ API 설계
### Proxmox 호스트 관리 API
```http
GET /api/proxmox/hosts # 호스트 목록 조회
POST /api/proxmox/hosts # 호스트 등록
PUT /api/proxmox/hosts/{host_id} # 호스트 정보 수정
DELETE /api/proxmox/hosts/{host_id} # 호스트 삭제
GET /api/proxmox/hosts/{host_id}/health # 호스트 상태 확인
```
### 통합 VM 관리 API
```http
GET /api/vms # 모든 호스트의 VM 목록
GET /api/vms/pharmacy/{pharmacy_id} # 특정 약국의 VM 목록
GET /api/vms/sync # VM 정보 동기화
POST /api/vms/batch/start # 여러 VM 일괄 시작
POST /api/vms/batch/stop # 여러 VM 일괄 정지
```
### 동적 VNC 접속 API
```http
POST /api/vnc/connect # 동적 VNC 연결 생성
# Request Body:
{
"pharmacy_id": 1,
"vmid": 101,
"vm_type": "kiosk"
}
GET /api/vnc/sessions # 활성 VNC 세션 목록
POST /api/vnc/refresh/{session_id} # VNC 티켓 새로고침 (기존)
```
## 🎨 UI/UX 설계
### 1. 대시보드 개선
```
┌─────────────────────────────────────────────────────────────┐
│ 🏥 PharmQ - 다중 Proxmox 관리 시스템 │
├─────────────────────────────────────────────────────────────┤
│ 📊 전체 현황 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │호스트수 │ │총VM수 │ │실행중 │ │오프라인 │ │
│ │ 100 │ │ 450 │ │ 380 │ │ 15 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 🔍 검색 및 필터 │
│ [약국명/IP 검색____] [호스트상태▼] [VM타입▼] [VM상태▼] │
└─────────────────────────────────────────────────────────────┘
```
### 2. 호스트 목록 페이지
```
┌─────────────────────────────────────────────────────────────┐
│ 🖥️ Proxmox 호스트 관리 [+ 호스트 추가] │
├─────────────────────────────────────────────────────────────┤
│ 약국명 │ IP주소 │ 상태 │ VM수 │ 마지막확인 │
├─────────────────────────────────────────────────────────────┤
│ 🟢 약국A │ 100.64.0.10 │ 온라인 │ 5개 │ 1분전 │
│ 🔴 약국B │ 100.64.0.11 │ 오프라인│ - │ 30분전 │
│ 🟢 약국C │ 100.64.0.12 │ 온라인 │ 3개 │ 2분전 │
└─────────────────────────────────────────────────────────────┘
```
### 3. 통합 VM 관리 페이지
```
┌─────────────────────────────────────────────────────────────┐
│ 🔧 통합 VM 관리 │
├─────────────────────────────────────────────────────────────┤
│ 📍 약국A (100.64.0.10) - 온라인 │
│ ├── 🖥️ VM101: Kiosk-1 [실행중] [VNC] [정지] │
│ ├── 🖥️ VM102: POS-System [정지됨] [시작] [VNC] │
│ └── 🖥️ VM103: Backup [실행중] [VNC] [정지] │
├─────────────────────────────────────────────────────────────┤
│ 📍 약국C (100.64.0.12) - 온라인 │
│ ├── 🖥️ VM201: Server [실행중] [VNC] [정지] │
│ └── 🖥️ VM202: Kiosk [실행중] [VNC] [정지] │
└─────────────────────────────────────────────────────────────┘
```
## 🔧 구현 단계
### Phase 1: 기반 시스템 확장 (1-2주)
1. **데이터베이스 스키마 확장**
- 다중 호스트 테이블 생성
- 기존 데이터 마이그레이션
2. **Proxmox 클라이언트 다중화**
- 호스트별 클라이언트 풀 관리
- 연결 관리 및 로드밸런싱
### Phase 2: 호스트 관리 시스템 (2-3주)
1. **호스트 등록/관리 기능**
- CRUD API 구현
- 관리 UI 개발
2. **상태 모니터링**
- 헬스체크 시스템
- 알림 기능
### Phase 3: 통합 VM 관리 (2-3주)
1. **다중 호스트 VM 조회**
- 통합 대시보드
- 검색/필터링 기능
2. **배치 작업 시스템**
- 일괄 작업 API
- 스케줄링 기능
### Phase 4: 고도화 기능 (2-3주)
1. **성능 최적화**
- 캐싱 시스템
- 비동기 처리
2. **보안 강화**
- 접근 권한 관리
- 감사 로그
## 🚨 고려사항
### 기술적 도전과제
1. **네트워크 지연**
- Headscale VPN을 통한 다중 호스트 접근 시 지연 가능성
- 연결 타임아웃 및 재시도 로직 필요
2. **확장성**
- 100개 호스트 동시 관리 시 리소스 사용량
- 동시 VNC 세션 수 제한 고려
3. **장애 처리**
- 개별 호스트 오프라인 시 graceful degradation
- 부분 장애 상황에서의 서비스 연속성
### 보안 고려사항
1. **인증 정보 관리**
- 각 Proxmox 호스트별 패스워드 암호화 저장
- API 토큰 순환 정책
2. **네트워크 보안**
- Headscale 네트워크 내부 통신 암호화
- VNC 세션 보안 강화
## 📈 성공 지표
### 기능적 지표
- ✅ 100개 Proxmox 호스트 동시 관리
- ✅ 1000개 이상 VM 통합 관리
- ✅ 동시 VNC 세션 50개 이상 지원
- ✅ 호스트 상태 확인 응답 시간 < 5초
### 성능 지표
- ✅ 대시보드 로딩 시간 < 3초
- ✅ VM 목록 조회 시간 < 5초
- ✅ VNC 연결 설정 시간 < 10초
- ✅ 시스템 가용성 99.5% 이상
## 🎯 결론
이 다중 Proxmox 호스트 관리 시스템을 통해 약국별로 분산된 IT 인프라를 중앙에서 효율적으로 관리할 수 있게 됩니다. Headscale 네트워크를 활용한 안전한 연결과 통합 관리 인터페이스를 제공하여 운영 효율성을 크게 향상시킬 수 있을 것입니다.
---
*이 기획서는 현재 단일 Proxmox 호스트 관리 시스템을 다중 호스트 환경으로 확장하기 위한 상세 계획을 담고 있습니다. 단계적 구현을 통해 안정적이고 확장 가능한 시스템을 구축할 수 있을 것입니다.*

View File

@@ -0,0 +1,230 @@
# VNC WebSocket 연결 문제 및 프록시 솔루션
## 🚨 현재 문제 상황
### WebSocket 1006 연결 실패 원인
```
WebSocket connection to 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket...' failed:
Failed when connecting: Connection closed (code: 1006)
```
**WebSocket Error Code 1006**는 "Abnormal Closure"를 의미하며, 연결이 설정되기도 전에 종료되었음을 나타냅니다.
## 🔍 근본 원인 분석
### 1. 브라우저 보안 정책 (Mixed Content)
- **현재 상황**: HTTPS 웹사이트 (`pqadmin.0bin.in`)에서 자체 서명 인증서를 사용하는 WebSocket 연결 시도
- **브라우저 제한**: Chrome, Firefox 등 모던 브라우저는 HTTPS 페이지에서 신뢰할 수 없는 SSL 인증서로의 WebSocket 연결을 자동 차단
- **에러 발생**: 브라우저가 연결 시도 자체를 거부하여 1006 에러 발생
### 2. SSL/TLS 인증서 신뢰 문제
```bash
# Proxmox 서버의 자체 서명 인증서
Subject: CN=Proxmox Virtual Environment
Issuer: PVE Cluster Manager CA
```
- **자체 서명 인증서**: `pve7.0bin.in`이 공인 CA가 아닌 자체 서명 인증서 사용
- **브라우저 불신**: 사용자가 명시적으로 인증서를 신뢰하지 않으면 WebSocket 연결 거부
### 3. CORS (Cross-Origin Resource Sharing) 정책
- **Origin 불일치**: `pqadmin.0bin.in`에서 `pve7.0bin.in`으로의 크로스 오리진 WebSocket 연결
- **Preflight 검사**: 브라우저가 연결 전 OPTIONS 요청을 보내지만 Proxmox가 적절한 CORS 헤더를 반환하지 않을 가능성
## 💡 해결책 1: 역방향 프록시 (Reverse Proxy)
### 구현 방식: nginx를 통한 WebSocket 프록시
```nginx
# /etc/nginx/sites-available/pqadmin-vnc
server {
listen 443 ssl;
server_name pqadmin.0bin.in;
ssl_certificate /path/to/ssl/cert.pem;
ssl_certificate_key /path/to/ssl/key.pem;
# VNC WebSocket 프록시
location /vnc-proxy/ {
# WebSocket 업그레이드 헤더
proxy_pass https://pve7.0bin.in/api2/json/nodes/pve7/qemu/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSL 검증 비활성화 (자체 서명 인증서 때문)
proxy_ssl_verify off;
proxy_ssl_server_name on;
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
```
### 장점
-**같은 도메인**: `pqladmin.0bin.in/vnc-proxy/`로 WebSocket 연결하여 CORS 문제 해결
-**신뢰할 수 있는 SSL**: nginx가 공인 인증서를 사용하여 브라우저 신뢰 확보
-**투명한 프록시**: 클라이언트는 Proxmox 직접 연결과 동일하게 작동
### 구현 단계
```bash
# 1. nginx 설정 파일 생성
sudo nano /etc/nginx/sites-available/pqadmin-vnc
# 2. 심볼릭 링크 생성
sudo ln -s /etc/nginx/sites-available/pqadmin-vnc /etc/nginx/sites-enabled/
# 3. nginx 설정 테스트
sudo nginx -t
# 4. nginx 재시작
sudo systemctl reload nginx
```
### 클라이언트 코드 수정
```javascript
// 기존: 직접 Proxmox 연결
// const websocketUrl = 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
// 변경: nginx 프록시를 통한 연결
const websocketUrl = 'wss://pqladmin.0bin.in/vnc-proxy/102/vncwebsocket?...'
```
## 💡 해결책 2: WebSocket 프록시 서버
### 구현 방식: Node.js/Python WebSocket 중계 서버
```python
# vnc_websocket_proxy.py
import asyncio
import websockets
import ssl
import json
from urllib.parse import parse_qs
class VNCWebSocketProxy:
def __init__(self):
self.proxmox_host = "pve7.0bin.in"
self.proxmox_port = 443
async def proxy_websocket(self, websocket, path):
# 클라이언트 요청 파싱
query_params = parse_qs(path.split('?', 1)[1] if '?' in path else '')
vmid = query_params.get('vmid', [''])[0]
ticket = query_params.get('vncticket', [''])[0]
# Proxmox WebSocket URL 생성
proxmox_url = f"wss://{self.proxmox_host}:{self.proxmox_port}/api2/json/nodes/pve7/qemu/{vmid}/vncwebsocket?port=5900&vncticket={ticket}"
# SSL 컨텍스트 (자체 서명 인증서 허용)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
# Proxmox WebSocket 연결
async with websockets.connect(proxmox_url, ssl=ssl_context) as proxmox_ws:
# 양방향 데이터 중계
await asyncio.gather(
self.forward_messages(websocket, proxmox_ws),
self.forward_messages(proxmox_ws, websocket)
)
except Exception as e:
await websocket.send(json.dumps({"error": f"Proxmox connection failed: {str(e)}"}))
async def forward_messages(self, source, destination):
try:
async for message in source:
await destination.send(message)
except websockets.exceptions.ConnectionClosed:
pass
# 프록시 서버 시작
if __name__ == "__main__":
proxy = VNCWebSocketProxy()
start_server = websockets.serve(proxy.proxy_websocket, "0.0.0.0", 8765)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
```
### 장점
-**완전한 제어**: WebSocket 연결 과정을 완전히 제어 가능
-**에러 처리**: 연결 실패 시 상세한 에러 메시지 제공
-**로깅**: 모든 WebSocket 트래픽 로깅 및 디버깅 가능
-**인증 확장**: 추가적인 인증 로직 구현 가능
### 구현 단계
```bash
# 1. 의존성 설치
pip install websockets asyncio
# 2. 프록시 서버 실행
python vnc_websocket_proxy.py
# 3. systemd 서비스 등록
sudo systemctl enable vnc-websocket-proxy.service
```
### 클라이언트 코드 수정
```javascript
// WebSocket 프록시 서버로 연결
const websocketUrl = 'ws://localhost:8765/vnc-proxy?vmid=102&vncticket=' + encodeURIComponent(ticket);
```
## 📊 솔루션 비교
| 기준 | 역방향 프록시 (nginx) | WebSocket 프록시 서버 |
|------|----------------------|---------------------|
| **구현 복잡도** | 🟡 중간 | 🔴 높음 |
| **성능** | 🟢 우수 | 🟡 양호 |
| **유지보수** | 🟢 쉬움 | 🟡 보통 |
| **확장성** | 🟡 제한적 | 🟢 우수 |
| **디버깅** | 🟡 제한적 | 🟢 우수 |
| **보안** | 🟢 안전 | 🟡 주의 필요 |
| **리소스 사용량** | 🟢 낮음 | 🟡 보통 |
## 🎯 추천 솔루션
### 단기 해결책: 역방향 프록시 (nginx)
1. **빠른 구현**: 기존 nginx 설정에 location 블록만 추가
2. **안정성**: nginx의 검증된 WebSocket 프록시 기능 활용
3. **보안**: 공인 SSL 인증서로 브라우저 신뢰 확보
### 장기 해결책: 하이브리드 접근
1. **nginx 프록시**: 기본적인 WebSocket 중계
2. **커스텀 미들웨어**: 인증, 로깅, 모니터링 기능 추가
3. **로드 밸런싱**: 다중 Proxmox 호스트 지원
## 🚀 구현 우선순위
### Phase 1: nginx 역방향 프록시 구현
```bash
# 1. nginx 설정 파일 작성
# 2. SSL 인증서 설정
# 3. WebSocket 프록시 규칙 추가
# 4. 클라이언트 URL 변경
```
### Phase 2: 에러 처리 및 모니터링 강화
```bash
# 1. 연결 실패 시 재시도 로직
# 2. WebSocket 상태 모니터링
# 3. 로그 수집 및 분석
```
### Phase 3: 다중 호스트 지원
```bash
# 1. 동적 백엔드 라우팅
# 2. 헬스체크 구현
# 3. 로드 밸런싱 설정
```
---
**결론**: WebSocket 1006 에러는 브라우저 보안 정책과 인증서 신뢰 문제가 주요 원인입니다. nginx 역방향 프록시를 통해 이 문제를 근본적으로 해결할 수 있으며, 이는 가장 실용적이고 안정적인 솔루션입니다.

View File

@@ -0,0 +1,190 @@
# 안정적인 PVE 인증 정보 전달 전략
## 브라우저 WebSocket VNC 연결 개선 방안
### 🚨 현재 문제 상황
**간헐적 연결 실패 원인 분석:**
- 브라우저에서 PVE 인증 쿠키가 WebSocket 연결 시 불안정하게 전달됨
- NPM 리버스 프록시에서 인증 헤더 전달 불일치
- Proxmox 세션 만료 및 브라우저 쿠키 상태 변화
- CORS/보안 정책으로 인한 쿠키 차단
**Claude Code 환경 성공 요인:**
```python
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(websocket_url, ssl=ssl_context, additional_headers=headers)
```
**명시적 인증 헤더 전달로 100% 안정적 연결**
### 🎯 안정적인 인증 전략 옵션
#### 방법 1: Flask 백엔드 인증 프록시 (권장 ⭐⭐⭐⭐⭐)
**개념:**
- Flask가 Proxmox 인증을 대신 처리
- 브라우저는 Flask 세션만 관리
- Flask가 WebSocket을 Proxmox로 프록시
**장점:**
- ✅ 브라우저 보안 정책 우회
- ✅ 인증 상태 중앙 관리
- ✅ 세션 만료 자동 갱신 가능
- ✅ NPM 설정 변경 불필요
**구현 구조:**
```
브라우저 WebSocket → Flask WebSocket 프록시 → Proxmox VNC WebSocket
↑ (PVE 인증 헤더 자동 추가)
```
#### 방법 2: Flask API를 통한 인증 토큰 전달 (보통 ⭐⭐⭐)
**개념:**
- Flask API로 PVE 인증 정보 조회
- JavaScript에서 받아서 WebSocket 연결 시 사용
**장점:**
- ✅ 기존 코드 수정 최소화
- ✅ 명시적 인증 제어
**단점:**
- ❌ 브라우저 JavaScript에 인증 정보 노출
- ❌ noVNC 라이브러리 제약 (커스텀 헤더 지원 제한)
#### 방법 3: NPM 설정 개선 (복잡함 ⭐⭐)
**개념:**
- Nginx 설정으로 WebSocket 인증 헤더 자동 추가
**단점:**
- ❌ NPM 설정 복잡도 증가
- ❌ 여전히 브라우저 보안 정책 제약
- ❌ 디버깅 어려움
### 🏆 권장 솔루션: Flask WebSocket 프록시
#### 아키텍처 설계
```
┌─────────────┐ WebSocket ┌─────────────┐ WebSocket+Auth ┌─────────────┐
│ 브라우저 │ ────────────────→ │ Flask │ ───────────────────→ │ Proxmox VE │
│ (noVNC) │ │ Proxy │ │ VNC Server │
└─────────────┘ └─────────────┘ └─────────────┘
자동 PVE 인증
헤더 추가 처리
```
#### 핵심 컴포넌트
**1. Flask WebSocket 프록시 엔드포인트**
```python
# 새로운 엔드포인트: /vnc/<vm_id>/proxy
@app.websocket('/vnc/<int:vm_id>/proxy')
async def vnc_websocket_proxy(vm_id):
# 1. Flask 세션 검증
# 2. Proxmox 인증 정보 준비
# 3. Proxmox VNC WebSocket 연결 (PVE 쿠키 포함)
# 4. 브라우저 ↔ Proxmox 간 데이터 양방향 중계
```
**2. 브라우저 WebSocket URL 변경**
```javascript
// 기존 (직접 Proxmox 연결)
const websocketUrl = 'wss://pve7.0bin.in:443/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
// 새로운 (Flask 프록시 경유)
const websocketUrl = 'wss://farmq.0bin.in/vnc/102/proxy'
```
### 🔧 현재 코드 개선 계획
#### Phase 1: Flask WebSocket 프록시 구현
```python
# app.py에 추가할 함수들
async def create_proxmox_vnc_connection(vm_id):
"""Proxmox VNC WebSocket 연결 생성 (인증 헤더 포함)"""
async def proxy_websocket_data(browser_ws, proxmox_ws):
"""브라우저와 Proxmox 간 WebSocket 데이터 중계"""
@app.websocket('/vnc/<int:vm_id>/proxy')
async def vnc_websocket_proxy(vm_id):
"""VNC WebSocket 프록시 메인 핸들러"""
```
#### Phase 2: 브라우저 클라이언트 수정
```javascript
// vnc_simple.html 수정 계획
// 1. WebSocket URL을 Flask 프록시로 변경
// 2. VNC 티켓 생성 로직 제거 (Flask에서 처리)
// 3. 연결 상태 관리 개선
```
#### Phase 3: 세션 관리 강화
```python
# 세션 만료 감지 및 자동 갱신
# Proxmox 인증 상태 모니터링
# 오류 상황 처리 및 복구
```
### 🎯 구현 우선순위
**즉시 구현 (High Priority):**
- [ ] Flask WebSocket 라이브러리 추가 (flask-socketio 또는 quart)
- [ ] VNC WebSocket 프록시 기본 구조 구현
- [ ] 브라우저 WebSocket URL 변경
**단계적 개선 (Medium Priority):**
- [ ] 세션 만료 자동 처리
- [ ] 연결 상태 모니터링
- [ ] 오류 복구 메커니즘
**최적화 (Low Priority):**
- [ ] 성능 튜닝 (연결 풀링, 캐싱)
- [ ] 로깅 및 모니터링 강화
- [ ] 다중 VM 동시 연결 지원
### 🔒 보안 고려사항
**인증 보안:**
- Flask 세션으로 사용자 권한 검증
- Proxmox 인증 정보는 서버 메모리에만 보관
- 브라우저에 민감 정보 노출 방지
**네트워크 보안:**
- Flask ↔ Proxmox 통신은 내부 네트워크
- SSL/TLS 종단간 암호화 유지
- CORS 정책 적절한 설정
### 📊 예상 효과
**안정성 개선:**
- ✅ 인증 실패로 인한 연결 오류 완전 제거
- ✅ 세션 만료 자동 처리
- ✅ 네트워크 오류 복구 능력 향상
**사용자 경험:**
- ✅ 일관된 연결 성공률
- ✅ 빠른 연결 속도 (중간 단계 제거)
- ✅ 오류 상황 사용자 친화적 처리
**유지보수성:**
- ✅ 중앙집중식 인증 관리
- ✅ 디버깅 용이성 향상
- ✅ 코드 복잡도 감소
### 🚀 다음 단계
1. **Flask WebSocket 라이브러리 선택 및 설치**
2. **간단한 프록시 프로토타입 구현**
3. **기본 연결 테스트**
4. **브라우저 클라이언트 수정**
5. **통합 테스트 및 검증**
**⚠️ 주의사항:**
이 변경은 아키텍처 수준의 개선이므로 충분한 테스트와 백업 계획이 필요합니다.
---
**💡 핵심 메시지:**
Claude Code 환경에서 증명된 "명시적 PVE 인증 헤더 전달" 방식을 Flask 프록시를 통해 브라우저 환경에서도 안정적으로 구현하자는 것이 이 전략의 핵심입니다.

View File

@@ -0,0 +1,249 @@
# VNC 웹소켓 구현 기술 문서
## Proxmox API를 이용한 noVNC 통합 가이드
### 📋 개요
이 문서는 Proxmox VE API를 활용하여 웹 브라우저에서 직접 가상머신에 VNC 접속할 수 있는 시스템의 기술적 구현 내용을 설명합니다. Flask 백엔드와 noVNC 클라이언트를 통해 브라우저에서 직접 VM 콘솔에 접근할 수 있도록 구현되었습니다.
### 🏗️ 시스템 아키텍처
```
[웹 브라우저]
↓ HTTPS
[NPM 리버스 프록시]
↓ HTTP
[Flask 애플리케이션]
↓ HTTPS API
[Proxmox VE 서버]
↓ WebSocket (WSS)
[VM VNC 서버]
```
### 🔧 핵심 구성요소
#### 1. Proxmox API 클라이언트 (`utils/proxmox_client.py`)
**주요 기능:**
- Proxmox VE API 인증 및 세션 관리
- VNC 티켓 생성 및 WebSocket URL 생성
- VM 상태 관리 (시작/정지/상태확인)
**핵심 구현:**
```python
def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]:
"""VNC 접속 티켓 생성"""
data = {
'websocket': '1',
'generate-password': '1' # 자동 패스워드 생성
}
response = self.session.post(
f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy",
data=data,
timeout=10
)
if response.status_code == 200:
vnc_data = response.json()['data']
encoded_ticket = quote_plus(vnc_data['ticket'])
vnc_data['websocket_url'] = f"wss://{self.host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
return vnc_data
```
**인증 방식:**
- 세션 쿠키 방식 (PVEAuthCookie)
- CSRF 토큰 헤더 (CSRFPreventionToken)
- API 토큰 방식 지원
#### 2. Flask 웹 애플리케이션 (`app.py`)
**주요 엔드포인트:**
- `/vnc/<int:vm_id>`: VNC 클라이언트 페이지 렌더링
- API 엔드포인트들을 통한 VM 관리
**로깅 시스템:**
```python
def setup_logging():
if not os.path.exists('logs'):
os.makedirs('logs')
file_handler = RotatingFileHandler(
'logs/farmq-admin.log',
maxBytes=10*1024*1024,
backupCount=5
)
```
#### 3. noVNC 클라이언트 (`templates/vnc_simple.html`)
**핵심 기능:**
- WebSocket을 통한 VNC 프로토콜 처리
- 자동 리사이징 및 스케일링
- HTML 엔티티 디코딩 (패스워드 처리)
**WebSocket 연결 코드:**
```javascript
function connectVNC() {
const rfb = new RFB(document.getElementById('screen'), websocketUrl, {
credentials: {
password: decodeHtmlEntities('{{ vnc_data.password }}')
}
});
rfb.addEventListener("connect", () => {
console.log("VNC 연결 성공");
resizeScreen();
});
rfb.addEventListener("disconnect", (e) => {
console.log(`VNC 연결 종료: ${e.detail.clean ? '정상' : '비정상'}`);
});
}
```
**HTML 엔티티 디코딩:**
```javascript
function decodeHtmlEntities(text) {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
```
### 🔐 보안 및 인증
#### VNC 인증 플로우
1. **티켓 요청**: Flask → Proxmox API (`/vncproxy`)
2. **티켓 생성**: Proxmox가 일회용 VNC 티켓 및 패스워드 생성
3. **WebSocket 연결**: 브라우저 → Proxmox VNC WebSocket
4. **VNC 인증**: 생성된 패스워드로 VNC 서버 인증
#### 보안 설정
```python
# SSL 검증 무시 (내부 네트워크)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.session.verify = False
# WebSocket URL에 티켓 인코딩
encoded_ticket = quote_plus(vnc_data['ticket'])
```
### 🌐 네트워크 구성
#### NPM (Nginx Proxy Manager) 설정
- **외부 도메인**: `https://pve7.0bin.in`
- **내부 주소**: `https://192.168.0.5:8006`
- **WebSocket 지원**: Upgrade 헤더 프록시 필요
#### WebSocket 프록시 요구사항
```nginx
# WebSocket 업그레이드 헤더
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
```
### ⚡ 성능 최적화
#### Canvas 자동 리사이징
```javascript
function resizeScreen() {
const canvas = document.querySelector('#screen canvas');
if (canvas) {
const container = document.getElementById('screen');
const scaleX = container.clientWidth / canvas.width;
const scaleY = container.clientHeight / canvas.height;
const scale = Math.min(scaleX, scaleY, 1);
canvas.style.transform = `scale(${scale})`;
canvas.style.transformOrigin = 'top left';
}
}
```
#### 연결 상태 관리
- 자동 재연결 로직
- 연결 품질 모니터링
- 에러 처리 및 사용자 피드백
### 🐛 문제 해결 가이드
#### 일반적인 문제들
**1. WebSocket 1006 에러 (비정상 연결 종료)**
- **원인**: NPM 프록시 WebSocket 설정 부족
- **해결**: WebSocket 업그레이드 헤더 설정 확인
**2. VNC 인증 실패 (HTTP 401)**
- **원인**: 잘못된 티켓 또는 패스워드
- **해결**: `generate-password: 1` 설정 확인
**3. 빈 화면 또는 검은 화면**
- **원인**: Canvas 리사이징 문제
- **해결**: `resizeScreen()` 함수 호출 확인
**4. HTML 엔티티 문제**
- **원인**: 패스워드의 특수문자 인코딩
- **해결**: `decodeHtmlEntities()` 함수 적용
#### 디버깅 도구
**1. 서버 사이드 테스트**
```bash
cd /srv/headscale-setup/farmq-admin
python test_vnc_websocket.py
```
**2. 로그 확인**
```bash
tail -f logs/farmq-admin.log
```
**3. 브라우저 개발자 도구**
- Network 탭: WebSocket 연결 상태 확인
- Console 탭: JavaScript 에러 확인
### 📊 모니터링 및 로깅
#### 로그 레벨
- **INFO**: 정상 동작 로그
- **WARNING**: 경고사항
- **ERROR**: 오류 발생
- **DEBUG**: 상세 디버깅 정보
#### 중요 로그 포인트
- VNC 티켓 생성 성공/실패
- WebSocket 연결 시도
- VNC 인증 결과
- 연결 종료 사유
### 🔄 배포 및 유지보수
#### 배포 체크리스트
- [ ] Proxmox API 연결 테스트
- [ ] Flask 애플리케이션 시작 확인
- [ ] NPM 프록시 설정 검증
- [ ] WebSocket 연결 테스트
- [ ] VNC 클라이언트 동작 확인
#### 백업 및 복구
```bash
# Git 커밋 상태 확인
git log --oneline -10
# 안정된 버전으로 롤백
git reset --hard 1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f
```
### 📚 참고 자료
- [Proxmox VE API 문서](https://pve.proxmox.com/pve-docs/api-viewer/)
- [noVNC 프로젝트](https://github.com/novnc/noVNC)
- [WebSocket RFC 6455](https://tools.ietf.org/html/rfc6455)
- [VNC 프로토콜 스펙](https://tools.ietf.org/html/rfc6143)
### 🏷️ 버전 정보
- **프로젝트**: FarmQ Admin VNC Integration
- **마지막 업데이트**: 2024년
- **안정 버전**: commit `1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f`
- **Python**: 3.x
- **Flask**: 최신 안정 버전
- **noVNC**: 최신 버전

View File

@@ -0,0 +1,161 @@
# VNC 네트워크 아키텍처 문제 분석 및 해결방안
## 🔍 현재 상황 분석
### 네트워크 구조
```
[외부 사용자 PC]
↓ (인터넷)
[pqadmin.0bin.in] (Let's Encrypt SSL + 리버스 프록시)
↓ (로컬/VPN)
[Headscale 서버 - Flask Admin]
↓ (Headscale VPN: 100.64.x.x)
[Proxmox Hosts]
- pve7.0bin.in (443)
- 100.64.0.6:8006 (Healthport PVE)
```
### 문제 상황
1. **Flask 서버**: Headscale VPN 내부에서 실행 중
2. **외부 사용자**: `pqadmin.0bin.in`을 통해 Flask Admin에 접속
3. **VNC WebSocket URL**: `wss://100.64.0.6:8006/...` (Headscale VPN 내부 IP)
4. **접속 실패**: 외부 사용자는 100.64.x.x 대역에 접근 불가
## ❌ 핵심 문제
### 네트워크 분리 문제
- **Flask Admin**: 인터넷에서 접근 가능 (`pqadmin.0bin.in`)
- **Proxmox VNC**: Headscale VPN 내부에서만 접근 가능 (`100.64.x.x`)
- **사용자**: 두 네트워크를 동시에 접근할 수 없음
### 현재 WebSocket 연결 방식
```javascript
// 문제가 되는 현재 방식
WebSocket URL: wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
```
## 💡 해결방안
### 1. WebSocket 프록시 구현 (권장)
#### 구조
```
[외부 사용자] → [pqadmin.0bin.in] → [Flask 서버] → [Proxmox VNC]
↓ ↓ ↓ ↓
wss://pqadmin.0bin.in/vnc-proxy/{session_id} → wss://100.64.0.6:8006/...
```
#### 구현 방법
1. **Flask에서 WebSocket 프록시 서버 구현**
- Socket.IO 또는 웹소켓 라이브러리 사용
- 외부 → Flask → Proxmox 중계 역할
2. **URL 변경**
```javascript
// 변경 전
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
// 변경 후
wss://pqadmin.0bin.in/vnc-proxy/session_id_here
```
### 2. Nginx 리버스 프록시 확장
#### Nginx 설정 추가
```nginx
# VNC WebSocket 프록시
location /vnc-ws/ {
proxy_pass https://100.64.0.6:8006/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_ssl_verify off;
}
```
#### URL 변경
```javascript
// 변경 전
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
// 변경 후
wss://pqadmin.0bin.in/vnc-ws/api2/json/nodes/pev/qemu/103/vncwebsocket
```
### 3. VPN 클라이언트 요구 (비권장)
#### 방법
- 모든 사용자가 Headscale VPN 클라이언트 설치
- 100.64.x.x 대역 직접 접근 가능
#### 단점
- 사용자 부담 증가
- 관리 복잡성
- 보안 위험
## 🎯 권장 솔루션: WebSocket 프록시
### 장점
1. **사용자 친화적**: 별도 설치 없이 웹브라우저로 접근
2. **보안**: Headscale VPN은 내부 트래픽만 처리
3. **확장성**: 다수의 Proxmox 호스트 지원
4. **SSL 인증서**: Let's Encrypt로 안전한 연결
### 구현 우선순위
1. **1단계**: Flask Socket.IO WebSocket 프록시 구현
2. **2단계**: 세션 관리 및 인증 강화
3. **3단계**: 다중 Proxmox 호스트 지원 완성
4. **4단계**: 성능 최적화 및 모니터링
## 🔧 기술 스택
### 현재 사용 중
- **Flask**: Web 서버
- **noVNC**: 브라우저 VNC 클라이언트
- **Headscale**: VPN 서버
- **Nginx**: 리버스 프록시
- **Let's Encrypt**: SSL 인증서
### 추가 필요
- **Flask-SocketIO**: WebSocket 프록시 구현
- **python-websockets**: WebSocket 클라이언트 (Proxmox 연결용)
## 📋 구현 단계
### Phase 1: WebSocket 프록시 서버
1. Flask-SocketIO 설치 및 설정
2. VNC WebSocket 프록시 핸들러 구현
3. 세션 관리 및 인증 연동
### Phase 2: URL 라우팅 변경
1. VNC 연결 URL 생성 로직 수정
2. 프록시 경로로 WebSocket URL 변경
3. noVNC 클라이언트 연결 테스트
### Phase 3: 다중 호스트 지원
1. 호스트별 프록시 라우팅
2. 동적 Proxmox 호스트 추가
3. 로드 밸런싱 고려
## ⚠️ 고려사항
### 성능
- WebSocket 프록시로 인한 지연 시간 증가
- 동시 연결 수 제한
- 서버 리소스 사용량 증가
### 보안
- VNC 트래픽 암호화 상태 유지
- 세션 만료 및 권한 관리
- DDoS 방어 메커니즘
### 확장성
- 다수 사용자 동시 접속
- Proxmox 호스트 동적 추가
- 클러스터 환경 지원
---
**결론**: WebSocket 프록시를 통해 외부 사용자가 안전하게 내부 Proxmox VNC에 접근할 수 있도록 하는 것이 가장 현실적인 해결책입니다.

View File

@@ -0,0 +1,207 @@
# VNC WebSocket 연결 문제 해결 문서
## Proxmox VE API 기반 VNC 접속 문제 진단 및 해결
### 🚨 발생한 문제
**증상:**
- 브라우저에서 VNC 접속 시 WebSocket 1006 에러 (비정상 연결 종료)
- Proxmox 로그에 `TASK ERROR: connection timed out` 발생
- noVNC 클라이언트에서 검은 화면만 표시
- HTTP 401 Unauthorized 에러 발생
**영향:**
- 웹 브라우저를 통한 VM 콘솔 접속 불가능
- 사용자가 VM에 직접 접근할 수 없는 상황
### 🔍 문제 진단 과정
#### 1단계: 초기 상황 파악
```bash
# VM 상태 확인
VM 102 상태: running
VM 실행시간: 1463275초 (정상 실행 중)
VM PID: 3482
VNC 포트: N/A ← 문제 발견
```
#### 2단계: VM 설정 상세 조회
```bash
# Proxmox API로 VM 설정 확인
GET /api2/json/nodes/pve7/qemu/102/config
결과:
args: -vnc 0.0.0.0:77 ← 문제의 근본 원인 발견
```
**💡 핵심 발견:**
VM에 커스텀 VNC 설정 `-vnc 0.0.0.0:77`이 설정되어 있어서:
- VM은 포트 5977 (5900 + 77)에서 VNC 서비스 제공
- Proxmox VNC 프록시는 표준 포트 5900 기대
- 포트 불일치로 인한 연결 실패
### 🛠️ 해결 과정
#### 1단계: VM 설정 수정 (실제 Proxmox 서버 설정 변경)
```python
# Python 코드로 실제 Proxmox VM 설정 수정
PUT /api2/json/nodes/pve7/qemu/102/config
data = {'args': ''} # VNC 커스텀 설정 제거
결과: VNC args 제거 성공
```
**⚠️ 중요:** 이는 Python 설정이 아닌 **실제 Proxmox 서버의 VM 102 설정을 API를 통해 수정**한 것입니다.
#### 2단계: VM 재시작 (설정 적용)
```python
# VM 정지
POST /api2/json/nodes/pve7/qemu/102/status/stop
결과: VM 상태가 'stopped' 변경 확인
# VM 시작
POST /api2/json/nodes/pve7/qemu/102/status/start
결과: VM 상태가 'running'으로 변경 확인
```
#### 3단계: VNC 프록시 연결 테스트
```python
# VNC 티켓 생성
POST /api2/json/nodes/pve7/qemu/102/vncproxy
data = {
'websocket': '1',
'generate-password': '1'
}
결과:
포트: 5900 (표준 포트로 정상화)
패스워드: 자동 생성됨
WebSocket URL: 정상 생성
```
#### 4단계: 인증 문제 해결
**문제:** WebSocket 연결 시 HTTP 401 Unauthorized
**근본 원인:** Proxmox VNC WebSocket은 인증이 필요하지만 브라우저와 달리 수동으로 인증 헤더를 전달해야 함
**해결:** PVE 인증 쿠키를 WebSocket 헤더에 추가
```python
# 기존 (실패) - 인증 정보 없음
async with websockets.connect(websocket_url, ssl=ssl_context)
# 결과: HTTP 401 Unauthorized
# 수정 (성공) - PVE 인증 쿠키 추가
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(
websocket_url,
ssl=ssl_context,
additional_headers=headers # 핵심: 인증 헤더 추가
)
# 결과: ✅ WebSocket 연결 성공
```
**🔑 핵심 포인트:**
- `client.ticket`은 Proxmox 로그인 시 받은 인증 티켓
- VNC 티켓(`vncticket`)과는 별개의 **세션 인증 쿠키**
- 브라우저는 자동으로 쿠키를 전송하지만, Python WebSocket은 수동으로 헤더에 추가해야 함
### ✅ 해결 결과
**최종 테스트 성공:**
```
🎉 VNC WebSocket 연결 및 프로토콜 핸드셰이크 성공!
- WebSocket 연결: ✅ 성공
- VNC 프로토콜 버전: RFB 003.008
- 클라이언트 응답: ✅ 완료
```
### 📋 변경된 설정 요약
| 구분 | 변경 전 | 변경 후 |
|------|---------|---------|
| **VM args 설정** | `-vnc 0.0.0.0:77` | `''` (빈 값) |
| **VNC 포트** | 5977 (5900+77) | 5900 (표준) |
| **Proxmox 프록시** | 포트 불일치 오류 | 정상 연결 |
| **WebSocket 인증** | 인증 헤더 누락 | PVE 쿠키 추가 |
### 🔧 적용된 수정 사항
#### 1. Proxmox 서버 VM 설정 (실제 서버 변경)
```bash
# 변경 전
VM 102 설정: args = "-vnc 0.0.0.0:77"
# 변경 후
VM 102 설정: args = ""
```
#### 2. WebSocket 테스트 코드 (`test_vnc_websocket.py`)
```python
# 추가된 인증 헤더
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(
websocket_url,
ssl=ssl_context,
additional_headers=headers
)
```
### 🚀 브라우저 클라이언트 적용 방안
이제 서버 사이드 연결이 성공했으므로, 브라우저의 noVNC 클라이언트도 같은 방식으로 수정 가능:
**💡 핵심 인사이트:** Claude Code 환경에서 성공한 이유는 **PVE 인증 쿠키를 WebSocket 헤더에 명시적으로 추가**했기 때문
#### 브라우저에서 해결해야 할 사항:
1. **WebSocket 연결 시 PVE 인증 정보 전달**
```javascript
// 현재 브라우저 코드 (인증 정보 없음)
rfb = new RFB(document.getElementById('screen'), websocketUrl,
{ credentials: { password: vncPassword } });
// 필요한 수정: PVE 세션 쿠키 또는 인증 헤더 추가 방법 구현
```
2. **NPM 리버스 프록시 WebSocket 업그레이드 헤더 설정**
- WebSocket 연결을 위한 Upgrade 헤더 전달
- Cookie 헤더의 올바른 프록시 설정
3. **브라우저 보안 정책 (CORS, Mixed Content) 검토**
- HTTPS → WSS 프로토콜 일관성
- Same-Origin Policy 또는 적절한 CORS 설정
**🎯 가장 중요한 발견:**
서버 환경에서는 `additional_headers={'Cookie': f'PVEAuthCookie={client.ticket}'}`로 해결되었으므로, 브라우저에서도 동일한 인증 정보 전달 방식이 필요합니다.
### 📊 문제 해결 체크리스트
- [x] VM 커스텀 VNC 설정 제거
- [x] VM 재시작으로 설정 적용
- [x] VNC 티켓 생성 확인 (포트 5900)
- [x] WebSocket 인증 헤더 추가
- [x] VNC 프로토콜 핸드셰이크 성공
- [x] 서버 환경에서 완전한 연결 확인
- [ ] 브라우저 클라이언트 적용 (향후 작업)
- [ ] NPM 프록시 WebSocket 설정 개선 (향후 작업)
### 🎯 핵심 교훈
1. **VM 설정 충돌 주의**: 커스텀 VNC 설정이 Proxmox 표준 프록시와 충돌할 수 있음
2. **포트 일관성 중요**: VM VNC 포트와 프록시 기대 포트가 일치해야 함
3. **인증 정보 전달**: WebSocket 연결 시 적절한 인증 헤더 필수
4. **API 기반 설정 변경**: Proxmox API로 실시간 VM 설정 수정 가능
### 📅 해결 완료 시점
- **문제 발견**: Proxmox 로그 타임아웃 에러
- **원인 분석**: VM 커스텀 VNC 설정 충돌
- **해결 완료**: 2024년 (Claude Code 환경에서 완전한 VNC WebSocket 연결 성공)
- **테스트 결과**: VNC 프로토콜 핸드셰이크까지 완료
---
**💡 참고**: 이 문서는 실제 Proxmox 서버의 VM 설정을 변경한 내용을 기록한 것입니다. Python 코드는 Proxmox API 호출을 위한 클라이언트 역할만 수행했으며, 실제 변경은 Proxmox 서버에서 발생했습니다.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -432,6 +432,7 @@ class FarmqDatabaseManager:
if machine: if machine:
# 기존 머신 업데이트 # 기존 머신 업데이트
machine.hostname = headscale_node_data.get('hostname') machine.hostname = headscale_node_data.get('hostname')
machine.machine_name = headscale_node_data.get('given_name') or headscale_node_data.get('hostname')
machine.tailscale_ip = headscale_node_data.get('ipv4') machine.tailscale_ip = headscale_node_data.get('ipv4')
machine.tailscale_status = 'online' if headscale_node_data.get('is_online') else 'offline' machine.tailscale_status = 'online' if headscale_node_data.get('is_online') else 'offline'
machine.last_seen = datetime.now() machine.last_seen = datetime.now()
@@ -442,7 +443,7 @@ class FarmqDatabaseManager:
headscale_node_id=headscale_node_data.get('id'), headscale_node_id=headscale_node_data.get('id'),
headscale_machine_key=headscale_node_data.get('machine_key'), headscale_machine_key=headscale_node_data.get('machine_key'),
hostname=headscale_node_data.get('hostname'), hostname=headscale_node_data.get('hostname'),
machine_name=headscale_node_data.get('hostname'), machine_name=headscale_node_data.get('given_name') or headscale_node_data.get('hostname'),
tailscale_ip=headscale_node_data.get('ipv4'), tailscale_ip=headscale_node_data.get('ipv4'),
tailscale_status='online' if headscale_node_data.get('is_online') else 'offline', tailscale_status='online' if headscale_node_data.get('is_online') else 'offline',
last_seen=datetime.now() last_seen=datetime.now()

View File

@@ -212,6 +212,11 @@
<i class="fas fa-chart-pie text-warning"></i> 매출 대시보드 <i class="fas fa-chart-pie text-warning"></i> 매출 대시보드
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'pbs' in request.endpoint %}active{% endif %}" href="{{ url_for('pbs_monitoring') }}">
<i class="fas fa-server text-info"></i> PBS 백업 서버
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#"> <a class="nav-link" href="#">
<i class="fas fa-chart-line"></i> 모니터링 <i class="fas fa-chart-line"></i> 모니터링
@@ -233,6 +238,11 @@
<i class="fas fa-external-link-alt"></i> Medivault <i class="fas fa-external-link-alt"></i> Medivault
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="https://git.0bin.in/thug0bin/headscale-tailscale-replacement/src/branch/feature/working-headscale-setup" target="_blank">
<i class="fab fa-git-alt"></i> Git Repository
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#" onclick="refreshData()"> <a class="nav-link" href="#" onclick="refreshData()">
<i class="fas fa-sync-alt"></i> 데이터 새로고침 <i class="fas fa-sync-alt"></i> 데이터 새로고침

View File

@@ -112,8 +112,36 @@
{% endif %} {% endif %}
</div> </div>
<div> <div>
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong> <div class="d-flex align-items-center">
<div class="small text-muted">{{ machine_data.hostname }}</div> <strong id="machineName-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}</strong>
<button class="btn btn-sm btn-outline-primary ms-2"
onclick="showRenameModal({{ machine_data.id }}, '{{ machine_data.machine_name or machine_data.hostname }}')"
title="머신 이름 변경">
<i class="fas fa-edit"></i>
</button>
</div>
<div class="small text-success">
<i class="fas fa-link"></i> <code id="magicDns-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}.headscale.local</code>
{% set machine_name = (machine_data.machine_name or machine_data.hostname).lower() %}
{% if 'pbs' in machine_name %}
<button class="btn btn-sm btn-outline-info ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (PBS 서버 - 포트 8007 자동 추가)">
<i class="fas fa-copy"></i><small class="ms-1">:8007</small>
</button>
{% elif 'pve' in machine_name %}
<button class="btn btn-sm btn-outline-warning ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (Proxmox VE - 포트 8006 자동 추가)">
<i class="fas fa-copy"></i><small class="ms-1">:8006</small>
</button>
{% else %}
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사">
<i class="fas fa-copy"></i>
</button>
{% endif %}
</div>
{% if machine_data.hostname != (machine_data.machine_name or machine_data.hostname) %}
<div class="small text-muted">
<i class="fas fa-server"></i> OS: {{ machine_data.hostname }}
</div>
{% endif %}
<div class="small"> <div class="small">
<i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }} <i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }}
</div> </div>
@@ -440,9 +468,174 @@ function deleteNode(nodeId, nodeName) {
}); });
} }
// 스마트 포트 추가 기능
function addSmartPort(address) {
const lowerAddress = address.toLowerCase();
// PBS가 포함된 경우 :8007 포트 추가
if (lowerAddress.includes('pbs')) {
return address + ':8007';
}
// PVE가 포함된 경우 :8006 포트 추가
if (lowerAddress.includes('pve')) {
return address + ':8006';
}
// 기타 경우는 그대로 반환
return address;
}
// Magic DNS 주소 클립보드 복사 기능 (스마트 포트 지원)
function copyToClipboard(text) {
// 스마트 포트 추가 로직 적용
const enhancedText = addSmartPort(text);
navigator.clipboard.writeText(enhancedText).then(() => {
// 포트가 추가되었는지 확인하여 메시지 표시
if (enhancedText !== text) {
const port = enhancedText.split(':')[1];
showToast(`Magic DNS 주소가 복사되었습니다 (포트 ${port} 자동 추가): ${enhancedText}`, 'success');
} else {
showToast(`Magic DNS 주소가 복사되었습니다: ${enhancedText}`, 'success');
}
}).catch(err => {
console.error('복사 실패:', err);
showToast('복사에 실패했습니다.', 'danger');
});
}
// 머신 이름 변경 모달 표시
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 = '<i class="fas fa-spinner fa-spin"></i> 변경 중...';
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() { document.addEventListener('DOMContentLoaded', function() {
filterMachines(); filterMachines();
}); });
</script> </script>
<!-- 머신 이름 변경 모달 -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">
<i class="fas fa-edit"></i> 머신 이름 변경
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">현재 이름</label>
<div class="form-control-plaintext">
<strong id="currentMachineName"></strong>
</div>
</div>
<div class="mb-3">
<label for="newMachineName" class="form-label">새로운 이름</label>
<input type="text" class="form-control" id="newMachineName"
placeholder="새로운 머신 이름을 입력하세요"
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
oninput="updatePreview()">
<div class="form-text">
<i class="fas fa-info-circle"></i>
소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-magic"></i>
<strong>Magic DNS 주소:</strong>
<span id="previewMagicDns">새이름.headscale.local</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="renameSubmitBtn" onclick="renameMachine()">
<i class="fas fa-save"></i> 변경
</button>
</div>
</div>
</div>
</div>
<script>
// Magic DNS 미리보기 업데이트
function updatePreview() {
const newName = document.getElementById('newMachineName').value.trim();
const preview = document.getElementById('previewMagicDns');
if (newName) {
preview.textContent = `${newName}.headscale.local`;
} else {
preview.textContent = '새이름.headscale.local';
}
}
</script>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -77,12 +77,34 @@
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-desktop"></i> Proxmox VM 관리</h2> <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"> <button id="refresh-btn" class="btn btn-outline-primary">
<i class="fas fa-sync-alt"></i> 새로고침 <i class="fas fa-sync-alt"></i> 새로고침
</button> </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> </span>
</div> </div>
</div> </div>
@@ -271,7 +293,8 @@
body: JSON.stringify({ body: JSON.stringify({
node: node, node: node,
vmid: vmid, 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({ body: JSON.stringify({
node: node, node: node,
vmid: vmid vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
}) })
}); });
@@ -345,7 +369,8 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
node: node, node: node,
vmid: vmid vmid: vmid,
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
}) })
}); });
@@ -375,6 +400,13 @@
location.reload(); location.reload();
}; };
// 호스트 변경
function changeHost(hostKey) {
showSpinner();
showToast('호스트 변경', `${hostKey} 호스트로 연결 중...`, 'info');
// URL을 통해 페이지 이동 (이미 href에 설정되어 있음)
};
// 스피너 표시/숨김 // 스피너 표시/숨김
function showSpinner() { function showSpinner() {
document.querySelector('.loading-spinner').style.display = 'block'; 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

@@ -74,10 +74,38 @@
// This function is called when we are disconnected // This function is called when we are disconnected
function disconnectedFromServer(e) { function disconnectedFromServer(e) {
console.log('🔌 VNC 연결 해제 상세 정보:', e.detail);
if (e.detail.clean) { if (e.detail.clean) {
status("Disconnected"); status("연결이 정상적으로 종료되었습니다");
} else { } else {
status("Something went wrong, connection is closed"); const reason = e.detail.reason || 'Unknown';
console.error('❌ VNC 연결 실패 상세:', {
code: e.detail.code,
reason: e.detail.reason,
wasClean: e.detail.clean
});
// WebSocket 에러 코드별 메시지
const errorMessages = {
1006: 'WebSocket 서버에 연결할 수 없습니다. SSL 인증서를 확인하세요.',
1000: '정상적으로 연결이 종료되었습니다.',
1002: '프로토콜 오류가 발생했습니다.',
1003: '지원하지 않는 데이터를 받았습니다.',
1009: '메시지가 너무 큽니다.',
1011: '서버에서 예상치 못한 오류가 발생했습니다.'
};
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초 후 이동하여 사용자가 메시지를 읽을 시간 제공
}
} }
} }
@@ -190,22 +218,103 @@
console.log('WebSocket URL:', websocketUrl); console.log('WebSocket URL:', websocketUrl);
console.log('VNC Password:', vncPassword); console.log('VNC Password:', vncPassword);
status("Connecting"); // WebSocket URL 유효성 검사
function validateWebSocketUrl(url) {
if (!url || typeof url !== 'string') {
console.error('❌ WebSocket URL이 비어있습니다');
return false;
}
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
console.error('❌ 올바르지 않은 WebSocket URL 프로토콜:', url);
return false;
}
console.log('✅ WebSocket URL 유효성 검사 통과');
return true;
}
// Creating a new RFB object will start a new connection // VNC 연결 함수
rfb = new RFB(document.getElementById('screen'), websocketUrl, function connectToVNC() {
{ credentials: { password: vncPassword } }); if (!validateWebSocketUrl(websocketUrl)) {
status("❌ 잘못된 WebSocket URL입니다");
return;
}
if (!vncPassword || vncPassword.trim() === '') {
status("❌ VNC 패스워드가 없습니다");
return;
}
status("Connecting to VM...");
console.log('🔄 VNC 연결 시도 시작...');
// Add listeners to important events from the RFB module try {
rfb.addEventListener("connect", connectedToServer); // WebSocket 연결 직접 테스트
rfb.addEventListener("disconnect", disconnectedFromServer); console.log('🧪 WebSocket 연결 직접 테스트...');
rfb.addEventListener("credentialsrequired", credentialsAreRequired); const testWS = new WebSocket(websocketUrl);
rfb.addEventListener("desktopname", updateDesktopName);
rfb.addEventListener("securityfailure", onSecurityFailure); testWS.onopen = function(event) {
console.log('✅ WebSocket 연결 테스트 성공');
testWS.close();
// WebSocket이 연결되면 RFB 객체 생성
createRFBConnection();
};
testWS.onerror = function(error) {
console.error('❌ WebSocket 연결 테스트 실패:', error);
status('❌ WebSocket 서버에 연결할 수 없습니다');
// 대안: 티켓 새로고침 시도
setTimeout(() => {
status('🔄 새 티켓으로 재시도 중...');
refreshVNCTicket();
}, 2000);
};
testWS.onclose = function(event) {
console.log('🔌 WebSocket 테스트 연결 종료:', event.code, event.reason);
};
} catch (error) {
console.error('❌ WebSocket 테스트 실패:', error);
status(`❌ 연결 초기화 실패: ${error.message}`);
return;
}
}
// RFB 연결 생성 함수
function createRFBConnection() {
try {
console.log('🔄 RFB 객체 생성 시작...');
// Creating a new RFB object will start a new connection
rfb = new RFB(document.getElementById('screen'), websocketUrl,
{ credentials: { password: vncPassword } });
console.log('✅ RFB 객체 생성 완료');
// Add listeners to important events from the RFB module
rfb.addEventListener("connect", connectedToServer);
rfb.addEventListener("disconnect", disconnectedFromServer);
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
rfb.addEventListener("desktopname", updateDesktopName);
rfb.addEventListener("securityfailure", onSecurityFailure);
// Set parameters that can be changed on an active connection // Set parameters that can be changed on an active connection
rfb.viewOnly = false; rfb.viewOnly = false;
rfb.scaleViewport = true; rfb.scaleViewport = true;
} catch (error) {
console.error('❌ RFB 객체 생성 실패:', error);
status(`❌ VNC 클라이언트 초기화 실패: ${error.message}`);
return;
}
}
// 연결 시작
connectToVNC();
</script> </script>
</head> </head>

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>

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
다중 Proxmox 서버 접속 테스트
"""
from utils.proxmox_client import ProxmoxClient
import json
# Proxmox 서버 설정
PROXMOX_HOSTS = {
'pve7.0bin.in': {
'host': 'pve7.0bin.in',
'username': 'root@pam',
'password': 'trajet6640',
'port': 443,
'name': 'PVE 7.0 (Main)'
},
'healthport_pve': {
'host': '100.64.0.6',
'username': 'root@pam',
'password': 'healthport',
'port': 8006,
'name': 'Healthport PVE'
}
}
def test_proxmox_connection(host_key, host_config):
"""Proxmox 서버 연결 테스트"""
print(f"\n{'='*50}")
print(f"🔍 Testing connection to: {host_config['name']}")
print(f"📡 Host: {host_config['host']}")
print(f"👤 Username: {host_config['username']}")
print(f"{'='*50}")
try:
# ProxmoxClient 생성 및 로그인 시도
client = ProxmoxClient(
host_config['host'],
host_config['username'],
host_config['password'],
port=host_config['port']
)
print("🔐 Attempting login...")
if client.login():
print("✅ Login successful!")
# VM 목록 가져오기 시도
print("📋 Fetching VM list...")
vms = client.get_vm_list()
if vms:
print(f"✅ Found {len(vms)} VMs:")
for vm in vms[:5]: # 처음 5개만 표시
print(f" • VM {vm.get('vmid', 'N/A')}: {vm.get('name', 'Unknown')} ({vm.get('status', 'unknown')})")
if len(vms) > 5:
print(f" ... and {len(vms) - 5} more VMs")
else:
print("⚠️ No VMs found or unable to fetch VM list")
return True
else:
print("❌ Login failed!")
return False
except Exception as e:
print(f"❌ Connection error: {e}")
return False
def main():
"""메인 테스트 함수"""
print("🚀 Multiple Proxmox Server Connection Test")
print("=" * 60)
results = {}
for host_key, host_config in PROXMOX_HOSTS.items():
success = test_proxmox_connection(host_key, host_config)
results[host_key] = {
'success': success,
'config': host_config
}
# 결과 요약
print(f"\n{'='*60}")
print("📊 TEST RESULTS SUMMARY")
print(f"{'='*60}")
for host_key, result in results.items():
status = "✅ SUCCESS" if result['success'] else "❌ FAILED"
print(f"{result['config']['name']}: {status}")
successful_hosts = [k for k, v in results.items() if v['success']]
print(f"\n🎯 {len(successful_hosts)}/{len(PROXMOX_HOSTS)} hosts connected successfully")
if successful_hosts:
print("\n✅ Ready for multi-host implementation!")
else:
print("\n⚠️ No hosts connected. Check network and credentials.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
VNC WebSocket 연결 테스트 스크립트
Claude Code 환경에서 직접 테스트
"""
import asyncio
import websockets
import ssl
import json
import sys
import os
# Flask app.py와 동일한 경로에서 import
sys.path.append('/srv/headscale-setup/farmq-admin')
from utils.proxmox_client import ProxmoxClient
# 설정
PROXMOX_HOST = "pve7.0bin.in"
PROXMOX_USERNAME = "root@pam"
PROXMOX_PASSWORD = "trajet6640"
VM_ID = 102
NODE_NAME = 'pve7'
async def test_vnc_websocket():
"""VNC WebSocket 연결 테스트"""
print("=" * 60)
print("🧪 Claude Code에서 VNC WebSocket 연결 테스트")
print("=" * 60)
# 1. Proxmox 클라이언트 생성 및 로그인
print("1⃣ Proxmox 로그인 중...")
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
if not client.login():
print("❌ Proxmox 로그인 실패")
return
print("✅ Proxmox 로그인 성공")
# 2. VM 상태 확인
print("2⃣ VM 상태 확인 중...")
vm_status = client.get_vm_status(NODE_NAME, VM_ID)
print(f"🔍 VM {VM_ID} 상태: {vm_status.get('status', 'unknown')}")
if vm_status.get('status') != 'running':
print(f"❌ VM이 실행 중이 아닙니다: {vm_status.get('status')}")
return
# 3. VNC 티켓 생성
print("3⃣ VNC 티켓 생성 중...")
vnc_data = client.get_vnc_ticket(NODE_NAME, VM_ID)
if not vnc_data:
print("❌ VNC 티켓 생성 실패")
return
print("✅ VNC 티켓 생성 성공!")
print(f" - WebSocket URL: {vnc_data['websocket_url']}")
print(f" - VNC 패스워드: {vnc_data.get('password', 'N/A')}")
print(f" - 포트: {vnc_data.get('port', 'N/A')}")
# 4. WebSocket 연결 테스트
print("4⃣ WebSocket 연결 테스트...")
websocket_url = vnc_data['websocket_url']
# SSL 컨텍스트 설정 (자체 서명 인증서 허용)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
# WebSocket 연결 시도
print(f"🔌 연결 시도: {websocket_url}")
# Proxmox 인증 쿠키를 헤더로 추가
headers = {
'Cookie': f'PVEAuthCookie={client.ticket}'
}
print(f"🔐 인증 쿠키 추가: PVEAuthCookie={client.ticket[:50]}...")
# WebSocket 연결 시 인증 헤더 추가 (다른 방식)
async with websockets.connect(
websocket_url,
ssl=ssl_context,
additional_headers=headers
) as websocket:
print("✅ WebSocket 연결 성공!")
# VNC 프로토콜 초기 메시지 받기
try:
initial_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
print(f"📨 초기 메시지 수신 ({len(initial_message)} bytes)")
# VNC 프로토콜 버전 확인
if isinstance(initial_message, bytes) and initial_message.startswith(b'RFB'):
version = initial_message.decode('ascii').strip()
print(f"🔗 VNC 프로토콜 버전: {version}")
# 클라이언트 버전 응답
await websocket.send(b"RFB 003.008\n")
print("📤 클라이언트 버전 응답 완료")
print("🎉 VNC WebSocket 연결 및 프로토콜 핸드셰이크 성공!")
return True
else:
print(f"❓ 예상과 다른 초기 메시지: {initial_message[:50]}...")
except asyncio.TimeoutError:
print("⏰ 초기 메시지 수신 타임아웃 - 연결은 성공했지만 VNC 서버 응답 없음")
return True # 연결 자체는 성공
except websockets.exceptions.ConnectionClosed as e:
print(f"❌ WebSocket 연결 종료: 코드={e.code}, 이유={e.reason}")
return False
except websockets.exceptions.WebSocketException as e:
print(f"❌ WebSocket 예외: {e}")
return False
except asyncio.TimeoutError:
print("❌ WebSocket 연결 타임아웃")
return False
except Exception as e:
print(f"❌ 예상치 못한 오류: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
result = asyncio.run(test_vnc_websocket())
print("=" * 60)
if result:
print("🎉 테스트 성공! WebSocket 연결이 Claude Code 환경에서 작동합니다.")
print("💡 브라우저에서 문제가 있다면 브라우저 보안 정책 문제일 가능성이 높습니다.")
else:
print("❌ 테스트 실패! WebSocket 연결에 문제가 있습니다.")
print("🔍 Proxmox 서버나 네트워크 설정을 확인해야 합니다.")
print("=" * 60)

View File

@@ -340,7 +340,7 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
machine_data = { machine_data = {
'id': node.id, 'id': node.id,
'hostname': node.hostname, 'hostname': node.hostname,
'machine_name': node.hostname, # 표시용 이름 'machine_name': node.given_name or node.hostname, # Magic DNS용 이름 (given_name 우선)
'tailscale_ip': node.ipv4, 'tailscale_ip': node.ipv4,
'ipv6': node.ipv6, 'ipv6': node.ipv6,
'headscale_user_name': node.user.name if node.user else '미지정', 'headscale_user_name': node.user.name if node.user else '미지정',

View File

@@ -13,12 +13,19 @@ from typing import Dict, List, Optional, Tuple
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class ProxmoxClient: class ProxmoxClient:
def __init__(self, host: str, username: str = "root@pam", password: str = "", api_token: str = ""): def __init__(self, host: str, username: str = "root@pam", password: str = "", api_token: str = "", port: int = 8006):
self.host = host # 호스트에서 포트가 포함된 경우 분리
if ':' in host:
self.host, port_str = host.split(':')
self.port = int(port_str)
else:
self.host = host
self.port = port
self.username = username self.username = username
self.password = password self.password = password
self.api_token = api_token self.api_token = api_token
self.base_url = f"https://{host}:443/api2/json" self.base_url = f"https://{self.host}:{self.port}/api2/json"
self.session = requests.Session() self.session = requests.Session()
self.session.verify = False self.session.verify = False
self.ticket = None self.ticket = None
@@ -106,26 +113,46 @@ class ProxmoxClient:
def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]: def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]:
"""VNC 접속 티켓 생성""" """VNC 접속 티켓 생성"""
try: try:
# 먼저 VM 상태 확인
vm_status = self.get_vm_status(node, vmid)
print(f"🔍 VM {vmid} 상태: {vm_status}")
if vm_status.get('status') != 'running':
print(f"❌ VM {vmid}이 실행중이 아닙니다. 현재 상태: {vm_status.get('status', 'unknown')}")
return None
data = { data = {
'websocket': '1', 'websocket': '1',
'generate-password': '1' # 패스워드 생성 활성화 'generate-password': '1' # 패스워드 생성 활성화
} }
print(f"🔄 VNC 티켓 요청: {self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy")
response = self.session.post( response = self.session.post(
f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy", f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy",
data=data, data=data,
timeout=10 timeout=10
) )
print(f"📡 VNC 티켓 응답 상태: {response.status_code}")
print(f"📄 VNC 티켓 응답 내용: {response.text}")
if response.status_code == 200: if response.status_code == 200:
vnc_data = response.json()['data'] vnc_data = response.json()['data']
print(f"✅ VNC 티켓 생성 성공: {vnc_data}") print(f"✅ VNC 티켓 생성 성공: {vnc_data}")
# WebSocket URL 생성 # WebSocket URL 생성 (동적 포트 및 CSRF 토큰 포함)
encoded_ticket = quote_plus(vnc_data['ticket']) encoded_ticket = quote_plus(vnc_data['ticket'])
vnc_data['websocket_url'] = f"wss://{self.host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}" # Proxmox 세션 쿠키도 함께 포함 (CSRFPreventionToken도 필요할 수 있음)
csrf_token = getattr(self, 'csrf_token', None)
if csrf_token:
vnc_data['websocket_url'] = f"wss://{self.host}:{self.port}/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}&CSRFPreventionToken={csrf_token}"
else:
vnc_data['websocket_url'] = f"wss://{self.host}:{self.port}/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
# 디버깅 정보 추가 # 디버깅 정보 추가
print(f"🔗 WebSocket URL: {vnc_data['websocket_url']}") print(f"🔗 WebSocket URL: {vnc_data['websocket_url']}")
print(f"🔑 VNC Password: {vnc_data.get('password', 'N/A')}")
return vnc_data return vnc_data
else: else:
print(f"❌ VNC 티켓 생성 HTTP 오류: {response.status_code}") print(f"❌ VNC 티켓 생성 HTTP 오류: {response.status_code}")

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
VNC WebSocket 프록시
브라우저와 Proxmox VNC 서버 간 WebSocket 연결을 중계하며
PVE 인증을 자동으로 처리합니다.
"""
import asyncio
import websockets
import ssl
import logging
from utils.proxmox_client import ProxmoxClient
# 로깅 설정
logger = logging.getLogger(__name__)
class VNCWebSocketProxy:
def __init__(self, proxmox_host, proxmox_username, proxmox_password):
self.proxmox_host = proxmox_host
self.proxmox_username = proxmox_username
self.proxmox_password = proxmox_password
self.proxmox_client = None
async def create_proxmox_client(self):
"""Proxmox 클라이언트 생성 및 로그인"""
if not self.proxmox_client:
self.proxmox_client = ProxmoxClient(
self.proxmox_host,
self.proxmox_username,
self.proxmox_password
)
if not self.proxmox_client.login():
logger.error("Proxmox 로그인 실패")
return False
logger.info("Proxmox 로그인 성공")
return True
async def get_vnc_connection_info(self, node, vm_id):
"""VNC 연결 정보 생성"""
if not await self.create_proxmox_client():
return None
# VM 상태 확인
vm_status = self.proxmox_client.get_vm_status(node, vm_id)
if vm_status.get('status') != 'running':
logger.error(f"VM {vm_id}가 실행 중이 아님: {vm_status.get('status')}")
return None
# VNC 티켓 생성
vnc_data = self.proxmox_client.get_vnc_ticket(node, vm_id)
if not vnc_data:
logger.error(f"VM {vm_id} VNC 티켓 생성 실패")
return None
# WebSocket 연결 정보 준비
connection_info = {
'websocket_url': vnc_data['websocket_url'],
'password': vnc_data['password'],
'auth_headers': {
'Cookie': f'PVEAuthCookie={self.proxmox_client.ticket}'
}
}
logger.info(f"VM {vm_id} VNC 연결 정보 생성 완료")
return connection_info
async def create_proxmox_websocket(self, connection_info):
"""Proxmox VNC WebSocket 연결 생성"""
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
websocket = await websockets.connect(
connection_info['websocket_url'],
ssl=ssl_context,
additional_headers=connection_info['auth_headers']
)
logger.info("Proxmox VNC WebSocket 연결 성공")
return websocket
except Exception as e:
logger.error(f"Proxmox VNC WebSocket 연결 실패: {e}")
return None
async def proxy_data(self, browser_ws, proxmox_ws):
"""브라우저와 Proxmox 간 WebSocket 데이터 양방향 중계"""
async def forward_browser_to_proxmox():
"""브라우저 → Proxmox 데이터 전달"""
try:
async for message in browser_ws:
await proxmox_ws.send(message)
logger.debug(f"브라우저 → Proxmox: {len(message)} bytes")
except websockets.exceptions.ConnectionClosed:
logger.info("브라우저 연결 종료")
except Exception as e:
logger.error(f"브라우저 → Proxmox 전달 오류: {e}")
async def forward_proxmox_to_browser():
"""Proxmox → 브라우저 데이터 전달"""
try:
async for message in proxmox_ws:
await browser_ws.send(message)
logger.debug(f"Proxmox → 브라우저: {len(message)} bytes")
except websockets.exceptions.ConnectionClosed:
logger.info("Proxmox 연결 종료")
except Exception as e:
logger.error(f"Proxmox → 브라우저 전달 오류: {e}")
# 양방향 데이터 전달을 병렬로 실행
await asyncio.gather(
forward_browser_to_proxmox(),
forward_proxmox_to_browser(),
return_exceptions=True
)
async def handle_vnc_proxy(self, browser_websocket, node, vm_id):
"""VNC 프록시 메인 핸들러"""
logger.info(f"VNC 프록시 시작: VM {vm_id}")
try:
# 1. Proxmox VNC 연결 정보 생성
connection_info = await self.get_vnc_connection_info(node, vm_id)
if not connection_info:
await browser_websocket.send("ERROR: VNC 연결 정보 생성 실패")
return False
# 2. Proxmox VNC WebSocket 연결
proxmox_websocket = await self.create_proxmox_websocket(connection_info)
if not proxmox_websocket:
await browser_websocket.send("ERROR: Proxmox VNC 연결 실패")
return False
# 3. 연결 성공 알림
logger.info(f"VM {vm_id} VNC 프록시 연결 완료")
# 4. 데이터 중계 시작
await self.proxy_data(browser_websocket, proxmox_websocket)
return True
except Exception as e:
logger.error(f"VNC 프록시 처리 오류: {e}")
try:
await browser_websocket.send(f"ERROR: {str(e)}")
except:
pass
return False
finally:
logger.info(f"VNC 프록시 종료: VM {vm_id}")
# 전역 VNC 프록시 인스턴스 (설정값은 app.py에서 주입)
vnc_proxy_instance = None
def init_vnc_proxy(proxmox_host, proxmox_username, proxmox_password):
"""VNC 프록시 인스턴스 초기화"""
global vnc_proxy_instance
vnc_proxy_instance = VNCWebSocketProxy(proxmox_host, proxmox_username, proxmox_password)
logger.info("VNC WebSocket 프록시 초기화 완료")
def get_vnc_proxy():
"""VNC 프록시 인스턴스 반환"""
global vnc_proxy_instance
return vnc_proxy_instance

View File

@@ -258,7 +258,7 @@ function Register-Headscale {
"--login-server=$HeadscaleServer", "--login-server=$HeadscaleServer",
"--authkey=$PreAuthKey", "--authkey=$PreAuthKey",
"--accept-routes", "--accept-routes",
"--accept-dns=false" "--accept-dns=true"
) )
& $tailscalePath $arguments & $tailscalePath $arguments

View File

@@ -264,7 +264,7 @@ function Register-Headscale {
"--login-server=$HeadscaleServer", "--login-server=$HeadscaleServer",
"--authkey=$PreAuthKey", "--authkey=$PreAuthKey",
"--accept-routes", "--accept-routes",
"--accept-dns=false" "--accept-dns=true"
) )
& $tailscalePath $arguments & $tailscalePath $arguments

View File

@@ -344,7 +344,7 @@ register_headscale() {
--login-server="$HEADSCALE_SERVER" \ --login-server="$HEADSCALE_SERVER" \
--authkey="$PREAUTH_KEY" \ --authkey="$PREAUTH_KEY" \
--accept-routes \ --accept-routes \
--accept-dns=false >/dev/null 2>&1; then --accept-dns=true >/dev/null 2>&1; then
print_success "Headscale 등록 성공!" print_success "Headscale 등록 성공!"
@@ -354,7 +354,7 @@ register_headscale() {
# 수동 등록 모드 # 수동 등록 모드
print_info "다음 명령을 실행하여 수동 등록하세요:" print_info "다음 명령을 실행하여 수동 등록하세요:"
echo "" echo ""
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\"" echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\" --accept-routes --accept-dns=true"
echo "" echo ""
# 등록 URL 시도 # 등록 URL 시도