# VM 원격 제어 가이드 (PVE + QEMU)

> Claude Code 환경에서 Proxmox 위의 Windows/Linux VM을 화면 캡처 + 명령 실행 + GUI 조작까지 원격으로 다루는 방법.
>
> 작성일: 2026-05-18 / 검증 환경: Windows 10 Pro VM on Proxmox VE 8.1, virtio-scsi-single + iothread + discard

## 표기 규약

- `<PVE_HOST>` `<PVE_HOST_2>`: Proxmox 노드 IP
- `<PVE_PASSWORD>` `<PVE_PASSWORD_2>`: 각 노드 root 비밀번호 (또는 SSH key 사용 권장)
- `<SMB_HOST>` `<SMB_USER>` `<SMB_PASSWORD>`: SMB/CIFS 파일 서버 접속 정보
- `<windows_user>`: Windows 게스트 로그인 사용자
- VMID 예시는 `101`, zvol 예시는 `vm-101-disk-N` — 본인 환경 값으로 치환

---

## 1. 인프라 개요

### Proxmox 호스트
| 호스트 | IP | SSH | 비고 |
|---|---|---|---|
| pve5 | `<PVE_HOST>` | `root` / `<PVE_PASSWORD>` | 메인 VM 호스트 (운영) |
| pve7 | `<PVE_HOST_2>` | `root` / `<PVE_PASSWORD_2>` | 보조 / 백업 호스트 |

### SMB 파일 서버
- 주소: `<SMB_HOST>`
- 인증: `<SMB_USER>` / `<SMB_PASSWORD>`
- 주요 공유: `vhd` (VHD 디스크 이미지 보관)

### 접속 패턴
```bash
# pve5 SSH (모든 VM 작업은 여기서)
sshpass -p "$PVE_PASSWORD" ssh -o StrictHostKeyChecking=no root@<PVE_HOST> '<명령>'

# SMB 마운트 (pve5 또는 작업 VM에서)
mkdir -p /mnt/vhd-src
mount -t cifs //<SMB_HOST>/vhd /mnt/vhd-src \
  -o username=<SMB_USER>,password=$SMB_PASSWORD,ro,vers=3.0,iocharset=utf8,file_mode=0444,dir_mode=0555
```

---

## 2. VM 화면 캡처 (보기)

PVE 8.x에는 `qm screendump` 명령이 **없음**. QEMU monitor에 직접 명령 보내야 함.

### 한 줄 캡처 (pve5에서)
```bash
echo "screendump /tmp/vm101.ppm" | qm monitor 101
```

결과는 PPM(Netpbm raw bitmap) 형식. 일반 뷰어가 못 읽으므로 PNG로 변환 필요.

### Claude 환경에서 보기까지 풀 흐름
```bash
# 1) pve5에서 screendump
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> \
  'echo "screendump /tmp/vm101.ppm" | qm monitor 101'

# 2) PPM 가져오기
sshpass -p "$PVE_PASSWORD" scp root@<PVE_HOST>:/tmp/vm101.ppm /tmp/vm101.ppm

# 3) PNG 변환 (ffmpeg는 작업 VM에 설치돼있음, pve5에는 없음)
ffmpeg -y -i /tmp/vm101.ppm /tmp/vm101.png

# 4) Read 툴로 열기 → 멀티모달 모델이 이미지 인식
```

### PVE API 방식 (대안)
```bash
pvesh get /nodes/pve5/qemu/101/screenshot > /tmp/vm101.png
```
*이 엔드포인트는 PNG 직접 반환. 단 PVE 권한 토큰 설정에 따라 동작 여부 다름.*

---

## 3. QGA (QEMU Guest Agent)로 명령 실행

### 전제 조건
- VM config에 `agent: 1`
- 게스트 OS에 qemu-ga 서비스 설치/실행 중
- 확인: `qm agent <vmid> ping` (응답 없으면 = OK)

### 핵심 명령
```bash
# 상태 확인
qm agent 101 ping
qm agent 101 get-osinfo
qm agent 101 get-host-name
qm agent 101 get-users

# 명령 실행 (동기 / 결과 회수)
qm guest exec 101 --timeout 30 -- cmd.exe /c whoami
qm guest exec 101 --timeout 30 -- powershell.exe -NoProfile -Command "<script>"
qm guest exec 101 --timeout 30 -- C:\Path\To\program.exe arg1 arg2

# 비동기 (PID만 받고 나중에 결과)
PID=$(qm guest exec 101 --no-wait -- powershell.exe -Command "Start-Sleep 60" | jq -r .pid)
qm guest exec-status 101 $PID    # 진행 상태/exit code/stdout 회수
```

### 파일 송수신
```bash
# 게스트로 파일 쓰기 (base64 또는 raw content)
qm guest file-write 101 'C:\temp\script.ps1' --content "$(cat local.ps1)"

# 게스트에서 파일 읽기
qm guest file-read 101 'C:\temp\output.txt'
```

### ⚠️ 권한과 세션
- QGA로 실행된 프로세스는 **NT AUTHORITY\SYSTEM (Session 0, 비대화형 서비스 세션)** 으로 동작
- GUI를 띄워도 **사용자 데스크톱에 안 보임** (Windows 보안상 분리)
- 시스템 권한이라 거의 모든 작업 가능 (레지스트리, 서비스, 파일)
- 단 사용자 프로필(`%USERPROFILE%`)이 SYSTEM 계정 기준임에 주의

---

## 4. qm sendkey로 GUI 조작

키보드 이벤트를 QEMU에 직접 주입. **현재 포커스 있는 곳(=로그인 사용자 데스크톱)** 에 입력됨.

### 기본 사용
```bash
qm sendkey 101 <key>
```

### 주요 키 이름
| 키 | 이름 |
|---|---|
| Enter | `ret` 또는 `kp_enter` |
| Esc | `esc` |
| 윈도우 키 | `meta_l` / `meta_r` |
| Ctrl+Alt+Del | `ctrl-alt-delete` |
| 대문자 A | `shift-a` |
| 화살표 | `up`, `down`, `left`, `right` |
| 특수기호 | `exclam`(!), `at`(@), `minus`(-), `slash`(/), `dot`(.) 등 |

### 패턴: Win+R → 프로그램 실행
```bash
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> '
qm sendkey 101 esc                # 떠있는 다이얼로그 닫기
sleep 0.3
qm sendkey 101 meta_l-r           # 실행창 열기
sleep 0.5
for c in n o t e p a d; do qm sendkey 101 $c; sleep 0.05; done
qm sendkey 101 ret
'
```

### 제약
- 키보드 입력만 가능 (마우스 ❌)
- 한글/IME 입력 매우 어려움 — 영문 명령어/경로 위주로 설계
- 화면 상태 모르고 보내면 의도와 다른 곳에 들어감 → screendump으로 확인 권장
- 결과 회수 = screendump 또는 QGA로 프로세스 확인

---

## 5. ★ 최강 조합: QGA + schtasks로 사용자 세션에서 실행

QGA의 결과 회수성 + 사용자 세션에서의 GUI 가시성을 모두 얻는 방법.

```bash
qm guest exec 101 --timeout 30 -- powershell.exe -NoProfile -Command "
  schtasks /create /tn RunInUser /tr 'C:\path\program.exe arg' /sc once /st 23:59 /it /ru '<windows_user>' /f
  schtasks /run /tn RunInUser
  Start-Sleep 1
  schtasks /delete /tn RunInUser /f
"
```

- `/it` = interactive (사용자 데스크톱에 GUI 보임)
- `/ru '<windows_user>'` = 지정 사용자의 세션 컨텍스트
- `schtasks /run`은 일정 시각과 무관하게 즉시 실행
- 결과 파일을 게스트에 쓰게 한 다음 `qm guest file-read`로 회수 → 비동기 + 가시성 + 회수성

---

## 6. VM 라이프사이클 명령 (자주 쓰는 것만)

```bash
qm status 101                    # 상태 (running/stopped/...)
qm start 101                     # 시작
qm stop 101                      # 강제종료 (전원 끊기)
qm shutdown 101                  # ACPI shutdown (OS에 종료 신호)
qm reboot 101                    # ACPI 재시작
qm reset 101                     # 하드 리셋
qm config 101                    # config 보기
qm list                          # 전체 VM 목록
qm set 101 --boot order=sata1;sata0;ide0   # 부팅 순서
qm set 101 --sata1 pve-zfs:vm-101-disk-1   # 디스크 attach
qm set 101 --delete unused0                 # 디스크 detach
```

---

## 7. VHD/디스크 임포트 패턴 (실전: SMB → ZFS raw zvol)

```bash
# 1) SMB 마운트 (pve5에서)
mkdir -p /mnt/vhd-src
mount -t cifs //<SMB_HOST>/vhd /mnt/vhd-src \
  -o username=<SMB_USER>,password=$SMB_PASSWORD,ro,vers=3.0,iocharset=utf8

# 2) vhd 메타데이터 확인
qemu-img info /mnt/vhd-src/vm100.vhd

# 3) qm importdisk 한방 (vhd → raw zvol on pve-zfs)
#    nohup으로 백그라운드 권장 (106GB 약 30-40분 소요)
nohup qm importdisk 101 /mnt/vhd-src/vm100.vhd pve-zfs --format raw \
  > /tmp/import.log 2>&1 &

# 4) 완료 후 unused0 → sata1 (또는 원하는 슬롯)
qm set 101 --sata1 pve-zfs:vm-101-disk-1

# 5) 부팅 순서 변경
qm set 101 --boot 'order=sata1;sata0;ide0'

# 6) SMB 정리
umount /mnt/vhd-src
```

### 디스크 검증 (부팅 전)
```bash
fdisk -l /dev/zvol/pve-zfs/vm-101-disk-1
# Windows UEFI 정상 = part1(Recovery) + part2(EFI System) + part3(MSR) + part4(NTFS)

mount -o ro /dev/zvol/pve-zfs/vm-101-disk-1-part2 /mnt/efi
ls /mnt/efi/EFI/Microsoft/Boot/bootmgfw.efi    # 있으면 부팅 가능
umount /mnt/efi
```

---

## 8. 흔한 트러블슈팅

### `WARN: no efidisk configured! Using temporary efivars disk.`
OVMF 부팅 VM에 NVRAM 저장 디스크가 없음. 매 부팅 시 EFI 부트 엔트리가 초기화됨. 영구 NVRAM 만들려면 VM 종료 후:
```bash
qm set 101 --efidisk0 pve-zfs:1,efitype=4m,pre-enrolled-keys=0
```
fallback boot path(`\EFI\Microsoft\Boot\bootmgfw.efi`, `\EFI\Boot\bootx64.efi`)가 있으면 efidisk 없어도 부팅은 됨.

### `GPT PMBR size mismatch` 경고
zvol 크기가 vhd 가상 크기보다 약간 큰 경우. Windows 부팅에는 영향 없음. 수정하려면 게스트에서 `diskpart`로 GPT 백업 위치 재기록.

### qm sendkey 입력이 안 먹힘
- 화면 잠겨있거나 다른 모달 다이얼로그 떠있는지 screendump으로 확인
- 키 사이 간격 너무 짧으면 일부 키 손실 → `sleep 0.05` 삽입
- IME가 한글모드라면 영문으로 전환 필요 (`shift` + 한/영 키는 표준 매핑 없음, OS GUI에서 미리 영문으로)

### qm guest exec가 "guest agent not running"
- VM config에 `agent: 1` 있는지 확인 (`qm config 101 | grep agent`)
- 게스트 안에서 서비스 확인: `Get-Service QEMU-GA` (Windows) / `systemctl status qemu-guest-agent` (Linux)
- VM이 막 부팅됐다면 agent 서비스 시작까지 30~60초 대기

### qemu-ga가 SYSTEM이라 사용자 환경변수가 다름
`%USERPROFILE%`, 매핑 드라이브, 사용자 설치 프로그램 등에 접근 못 함. 사용자 세션 컨텍스트 필요하면 § 5 schtasks 패턴 사용.

### Windows SATA → VirtIO-SCSI 디스크 컨트롤러 전환 (마스터화)

가장 흔한 함정: `pnputil /add-driver vioscsi.inf /install`로 등록만 하면 BSOD `0xc0000017` / `0xc0000225` 발생. 이유는 vioscsi 서비스가 **DEMAND_START(=3)** 으로 등록되어 boot critical이 아니기 때문. winload가 vioscsi.sys를 부팅 시점에 로드하지 않아 OS 디스크 접근 실패.

**해결책**: 게스트 안에서 vioscsi를 BOOT_START(=0)으로 명시 승격.
```cmd
sc config vioscsi start= boot
reg query HKLM\SYSTEM\CurrentControlSet\Services\vioscsi /v Start
# 결과가 0x0 이어야 함
```

**검증된 마스터화 절차** (현재 SATA 부팅 → VirtIO-SCSI 단독 마스터):

1. virtio-win.iso 마운트 + `pnputil /add-driver` 로 vioscsi/viostor/netkvm INF 등록
2. **`sc config vioscsi start= boot`** ← 핵심
3. Windows ACPI shutdown (NTFS dirty + hive transaction log 정리)
4. ZFS 클론 (같은 풀 내):
   ```bash
   zfs snapshot pve-zfs/vm-<vmid>-disk-0@master
   zfs send pve-zfs/vm-<vmid>-disk-0@master | zfs recv pve-zfs/vm-<vmid>-disk-NEW
   zfs destroy pve-zfs/vm-<vmid>-disk-0@master
   zfs destroy pve-zfs/vm-<vmid>-disk-NEW@master
   ```
5. config 교체:
   ```bash
   qm set <vmid> --delete sata0
   qm set <vmid> --scsi0 pve-zfs:vm-<vmid>-disk-NEW,iothread=1,discard=on,ssd=1
   qm set <vmid> --scsihw virtio-scsi-single
   qm set <vmid> --boot 'order=scsi0'
   ```
6. 부팅 검증

**주의 — 동일 GPT signature 디스크 두 개 동시 attach 시 함정**: SATA 디스크와 그 raw 변환본/클론을 동시에 attach하면 NT namespace 매핑 이상으로 BSOD 발생. 한쪽 detach 후 단독으로 검증.

### vhd → raw 변환본이 부팅 안 될 때 (sata 원본은 부팅 OK)
원인 가능성: `qm importdisk`로 vhd → raw 변환 시 disk size에 1MB 패딩 추가됨 (`vmcfg sata1: ...size=122881M` 같이 +1 표기). 이 padding이 GPT backup header 위치를 미세하게 흔들 수 있음. 또는 우리 작업 경험상 SYSTEM hive 손상 가능성도 있음. 해결책:
- **부팅 가능한 sata 원본을 ZFS 클론**해서 그걸 마스터로 사용 (vhd 변환본 폐기)
- 또는 sata 원본 자체를 in-place로 virtio-scsi 인터페이스 변경 (drive number는 NT 동적이라 OK)

---

## 9. 참고 명령 치트시트

```bash
# 화면 한 번 보기 (pve5에서 + Claude 환경으로 회수)
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> 'echo "screendump /tmp/s.ppm" | qm monitor 101'
sshpass -p "$PVE_PASSWORD" scp root@<PVE_HOST>:/tmp/s.ppm /tmp/s.ppm && ffmpeg -y -i /tmp/s.ppm /tmp/s.png

# 게스트 정보 한 번에
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> '
  qm agent 101 get-osinfo
  qm agent 101 get-host-name
  qm agent 101 get-users
'

# 게스트에서 PowerShell 한 줄
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> \
  'qm guest exec 101 --timeout 30 -- powershell.exe -NoProfile -Command "Get-Process | Select Name,Id"'

# 게스트에 Python 스크립트 던지고 실행
LOCAL_PY=/tmp/probe.py
cat > $LOCAL_PY <<'EOF'
import os, json
print(json.dumps({"cwd": os.getcwd(), "env_user": os.environ.get("USERNAME")}))
EOF
sshpass -p "$PVE_PASSWORD" ssh root@<PVE_HOST> "
  qm guest file-write 101 'C:\\temp\\probe.py' --content \"\$(cat)\" <<< \"\$(cat $LOCAL_PY)\"
  qm guest exec 101 --timeout 30 -- python.exe 'C:\\temp\\probe.py'
"
```

---

## 10. DR 이전 (다른 PVE 노드로 옮기기)

원본 정지 후 다른 노드에서 켜는 시나리오. pve5 ↔ pve7 모두 같은 PBS(`<PBS_STORAGE_A>` / `<PBS_STORAGE_B>`)에 연결돼있어 PBS 경로가 가장 깔끔.

### 사전 점검 (이전하기 좋은 상태 만들기)
- [ ] `efidisk0` 설정됨 (없으면 매 부팅 NVRAM 초기화 — `qm set <vmid> --efidisk0 <stor>:1,efitype=4m,pre-enrolled-keys=0`)
- [ ] VirtIO 드라이버 설치 상태 확인 (`Get-PnpDevice | Where Manufacturer -like "*Red Hat*"`)
- [ ] 현재 인터페이스(sata/e1000 등)를 대상 PVE도 지원하는지 — 동일 인터페이스 유지가 가장 안전
- [ ] `cpu: host`면 대상 노드 CPU 세대가 같은지 확인 (다르면 `cpu: x86-64-v2-AES` 등 호환 모델로 사전 변경)
- [ ] 불필요한 디스크 detach (사용 안 하는 zvol/qcow2 정리)

### 절차 (PBS 백업 → 다른 PVE에서 restore)
```bash
# 1) 원본 PVE에서 백업 (실행 중에도 가능, snapshot 모드)
ssh root@pve5 'vzdump 101 --storage <PBS_STORAGE_A> --mode snapshot --compress zstd'

# 2) 백업 ID 확인
ssh root@pve5 'pvesm list <PBS_STORAGE_A> --vmid 101 | tail -5'

# 3) 대상 PVE에서 restore (새 VMID 또는 동일 VMID)
ssh root@pve7 '
  pvesm list <PBS_STORAGE_B> --vmid 101 | tail -5    # 같은 PBS면 보임
  qmrestore <BACKUP_VOLID> <NEW_VMID> --storage local-lvm
  # 예: qmrestore <PBS_STORAGE_B>:backup/vm/101/2026-05-19T... 201 --storage local-lvm
'

# 4) 원본 정지 + 대상 시작
ssh root@pve5 'qm shutdown 101'
ssh root@pve7 'qm start <NEW_VMID>'
```

### 절차 (vzdump → scp → qmrestore, PBS 없을 때)
```bash
ssh root@pve5 'vzdump 101 --dumpdir /var/lib/vz/dump --mode snapshot --compress zstd'
scp root@pve5:/var/lib/vz/dump/vzdump-qemu-101-*.vma.zst root@pve7:/var/lib/vz/dump/
ssh root@pve7 'qmrestore /var/lib/vz/dump/vzdump-qemu-101-*.vma.zst 201 --storage local-lvm'
```

### 스토리지 차이 주의
- pve5 = `pve-zfs` (ZFS zvol, raw)
- pve7 = `local-lvm` (LVM-thin, raw)
- 둘 다 raw 기반이라 restore 자동 변환됨. qcow2 dir로 가면 qcow2로 변환.

### MAC/UUID 충돌 (동시 운영 시에만)
원본을 *정지*하고 옮기면 충돌 없음. 동시 운영이면:
```bash
qm set <NEW_VMID> --net0 e1000=AUTO,bridge=vmbr0   # MAC 자동 재생성
qm set <NEW_VMID> --vmgenid 1                       # vmgenid 새로 (Windows AD/DC 인식 변경)
qm set <NEW_VMID> --smbios1 uuid=auto              # SMBIOS uuid 새로
```

---

## 11. 보안 메모

- QGA는 **SYSTEM 권한**이라 게스트 OS 안에서 사실상 할 수 있는 게 무제한. 신뢰되는 게스트에만 사용.
- pve5 SSH 키 또는 적어도 ssh-agent 사용을 권장 (현재는 sshpass + 평문 패스워드)
- SMB 마운트는 `ro` 기본, 쓰기 작업 필요할 때만 `rw`
- qm sendkey로 비밀번호 같은 민감 정보 입력 시 screendump에 남을 수 있음 — 캡처 파일 즉시 삭제
