Files
infra-troubleshooting/vnc-gateway-proxy-implementation.md
thug0bin d73222c6c6 VNC Gateway WebSocket 프록시 구현 기록 — websockify 제거, VNC Auth 대리 처리
핵심:
- PVE VNC WebSocket 직접 프록시 (websockify 불필요)
- VNC Auth 핸드셰이크를 gateway가 대리 처리 (DES 암호화)
- noVNC에는 SecurityType=None으로 전달하여 비밀번호 없이 연결
- Playwright로 headless 디버깅하여 원인 발견

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:51:45 +00:00

6.9 KiB

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 티켓 발급 확인

# 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 직접 연결 확인

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.pywebsocket_proxy 함수 수정:

  1. pharmacy_codepharmacy_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 암호화 구현

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 버전 차이

# 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 상태 확인.

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. 동시 세션 모니터링 — 접속 수, 대역폭 로깅