diff --git a/vnc-gateway-proxy-implementation.md b/vnc-gateway-proxy-implementation.md new file mode 100644 index 0000000..b26753c --- /dev/null +++ b/vnc-gateway-proxy-implementation.md @@ -0,0 +1,195 @@ +# VNC Gateway WebSocket 프록시 구현 기록 + +> 날짜: 2026-04-08 +> 환경: FastAPI(gateway), PVE 9, noVNC, Chromium + +## 요약 + +websockify 제거하고 gateway(FastAPI)에서 PVE VNC WebSocket을 직접 프록시. +핵심 난관은 **VNC Auth 핸드셰이크를 gateway가 대리 처리**하는 것이었음. + +## 이전 구조 (websockify) + +``` +브라우저 → Caddy → gateway HTML → CT:6085(websockify) → PVE:5988(VNC) +``` + +약국마다 CT에 3개 서비스 설치 필요: +- pharmq-websockify-vnc1.service +- pharmq-websockify-vnc2.service +- pharmq-vnc-app.service (Flask) + +## 현재 구조 (gateway 직접 프록시) + +``` +브라우저 → Caddy → gateway WebSocket 프록시 → PVE:8006(VNC WebSocket) +``` + +CT에 설치할 것 없음. gateway 코드만으로 모든 약국 VNC 제공. + +## 구현 과정 + +### 1단계: PVE API VNC 티켓 발급 확인 + +```bash +# PVE 인증 +curl -sk -d "username=root@pam&password=trajet6640" \ + https://192.168.0.7:8006/api2/json/access/ticket + +# VNC 프록시 티켓 발급 +curl -sk -X POST \ + "https://192.168.0.7:8006/api2/json/nodes/pvenest/qemu/201/vncproxy" \ + -H "Cookie: PVEAuthCookie=$PVE_TICKET" \ + -H "CSRFPreventionToken: $CSRF_TOKEN" \ + -d "websocket=1" +# → ticket: "PVEVNC:...", port: "5900" +``` + +### 2단계: PVE VNC WebSocket 직접 연결 확인 + +```python +from websockets.asyncio.client import connect +url = f"wss://{pve_ip}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}" +async with connect(url, ssl=ssl_ctx, additional_headers={"Cookie": f"PVEAuthCookie={pve_ticket}"}) as ws: + msg = await ws.recv() # b'RFB 003.008\n' ← 성공 +``` + +**주의**: websockets 15.x에서는 `from websockets.asyncio.client import connect` 사용. 이전 API(`websockets.connect`)와 파라미터명이 다름. + +### 3단계: gateway WebSocket 프록시 구현 + +`app/novnc/router.py`의 `websocket_proxy` 함수 수정: + +1. `pharmacy_code` → `pharmacy_devices` DB에서 PVE Tailscale IP 조회 +2. PVE API 인증 → VNC 프록시 티켓 발급 +3. PVE VNC WebSocket 연결 +4. **VNC Auth 핸드셰이크 대리 처리** (핵심) +5. 양방향 릴레이 + +### 4단계: VNC Auth 문제 발견 및 해결 (핵심) + +#### 증상 + +Playwright로 확인한 WebSocket 프레임: +``` +RECV #1: b'RFB 003.008\n' ← PVE 버전 +SENT #1: b'RFB 003.008\n' ← noVNC 버전 응답 +RECV #2: b'\x01\x02' ← 보안 타입: 1=None, 2=VNC Auth +SENT #2: b'\x02' ← noVNC가 VNC Auth 선택 +RECV #3: b'\xc3(\xd2\xe8...' ← 16바이트 DES 챌린지 +(여기서 멈춤 — noVNC가 비밀번호를 모름) +``` + +#### 원인 + +- PVE VNC는 **VNC Auth (SecurityType=2)** 를 요구 +- VNC Auth 비밀번호 = **PVE vncproxy 티켓** +- gateway가 티켓을 발급받지만 **브라우저(noVNC)에는 전달하지 않음** +- noVNC는 비밀번호가 없어서 DES 챌린지 응답을 못 보냄 → 핸드셰이크 멈춤 + +#### 해결: VNC Auth 대리 처리 + +gateway가 PVE와의 VNC Auth를 대신 수행하고, noVNC에는 **SecurityType=1 (None)**으로 응답: + +``` +[PVE 측] [Gateway] [브라우저/noVNC] +RFB 003.008\n ────→ 전달 ────→ RFB 003.008\n + ←──── 전달 ←──── RFB 003.008\n +\x01\x02 ────→ 변환 ────→ \x01\x01 (None으로 변환) + ←──── 변환 ←──── \x01 (None 선택) +\x02 ←──── (VNC Auth 선택 전송) +16byte챌린지 ────→ DES암호화 → 응답 전송 +\x00\x00\x00\x00 ──→ 전달 ────→ \x00\x00\x00\x00 (인증 성공) + ↕ (이후 순수 릴레이) ↕ +``` + +#### VNC DES 암호화 구현 + +```python +from Crypto.Cipher import DES + +# VNC DES는 비트 반전된 키 사용 +def vnc_des_key(key_bytes): + result = bytearray(8) + for i in range(8): + b = key_bytes[i] + result[i] = ((b >> 7) & 1) | ((b >> 5) & 2) | ((b >> 3) & 4) | ((b >> 1) & 8) | \ + ((b << 1) & 16) | ((b << 3) & 32) | ((b << 5) & 64) | ((b << 7) & 128) + return bytes(result) + +password = vnc_ticket[:8].encode('latin-1').ljust(8, b'\x00') +des_key = vnc_des_key(password) +cipher = DES.new(des_key, DES.MODE_ECB) +response = cipher.encrypt(challenge[:8]) + cipher.encrypt(challenge[8:16]) +``` + +**중요**: VNC DES는 표준 DES와 다르게 **각 바이트의 비트 순서가 반전**됨. 이걸 안 하면 인증 실패. + +필요 패키지: `pycryptodome` (`pip install pycryptodome`) + +## 트러블슈팅 과정 + +### 문제 1: websockets API 버전 차이 + +```python +# websockets 15.x +from websockets.asyncio.client import connect # 새 API +async with connect(url, ssl=ctx, additional_headers=headers) as ws: ... + +# websockets 구버전 +import websockets +async with websockets.connect(url, ssl=ctx, extra_headers=headers) as ws: ... +``` + +### 문제 2: VMID 하드코딩 + +처음에 vnc1=100, vnc2=201로 하드코딩했는데, 실제로는 약국마다 다름. +수정: vnc1=202(Client), vnc2=201(Server) — 추후 DB에서 동적 조회로 개선 필요. + +### 문제 3: PVE 인증 정보 하드코딩 + +`_get_pve_credentials()`에서 `root@pam / trajet6640` 하드코딩. +다른 약국은 비밀번호가 다를 수 있음 (`pharmq119` 등). +추후 DB 또는 config에서 약국별 PVE 인증 정보 관리 필요. + +### 문제 4: gateway 재시작 후 Caddy 연결 문제 + +gateway systemctl restart 후에도 Caddy가 이전 프로세스에 연결 유지. +**Caddy도 reload 필요** 또는 Caddy `flush_interval -1` 설정으로 해결. + +### 문제 5: pyDes import 오류 + +코드에 `from pyDes import des` 잔존. pycryptodome만 사용하므로 제거. + +### 문제 6: Playwright 디버깅 + +브라우저에서 안 되는데 curl/Python에서 되는 상황. +Playwright로 headless Chromium 실행하여 콘솔 로그, WebSocket 프레임, Canvas 상태 확인. + +```python +from playwright.async_api import async_playwright +# WebSocket 프레임 수, Canvas 크기로 VNC 동작 여부 판단 +# canvas=1280x800 → 성공! +``` + +## 변경된 파일 + +| 파일 | 변경 | +|------|------| +| `pharmq-gateway/app/novnc/router.py` | WebSocket 프록시를 websockify → PVE VNC 직접 연결로 변경 + VNC Auth 대리 처리 | +| `pharmq-gateway/requirements.txt` | `pycryptodome` 추가 | + +## PVE 접속 정보 + +| 약국 | PVE IP | user | password | VM(Server) | VM(Client) | +|------|--------|------|----------|------------|------------| +| P0022 힘들다365약국 | 100.64.0.61 | root@pam | trajet6640 | 201 | 202 | +| 기타 약국 | pharmacy_devices DB | root@pam | pharmq119 or trajet6640 | 201 | 202 | + +## 향후 개선 + +1. **VMID 동적 조회** — PVE API에서 VM 목록 자동 감지 +2. **PVE 인증 DB 관리** — 약국별 PVE 비밀번호 저장 +3. **CT websockify 제거** — 기존 약국에서 불필요한 서비스 정리 +4. **통합 스크립트 Phase 9 제거** — noVNC 설치 불필요 +5. **동시 세션 모니터링** — 접속 수, 대역폭 로깅