diff --git a/UNIFIED_INSTALL_DESIGN.md b/UNIFIED_INSTALL_DESIGN.md new file mode 100644 index 0000000..cf4abab --- /dev/null +++ b/UNIFIED_INSTALL_DESIGN.md @@ -0,0 +1,341 @@ +# PharmQ PVE 원클릭 통합 설치 스크립트 설계 + +## 목표 + +**PVE host 1대에서 스크립트 1번 실행으로 모든 것을 완료:** +1. PVE 구독 리포 수정 (no-subscription) +2. PVE host에 Tailscale 설치 + Headscale 등록 +3. PBS 등록 + Windows VM(팜IT3000) 복원 +4. Ubuntu CT 자동 생성 (API 서버용) +5. CT 내부에 Tailscale 설치 + Headscale 등록 + API 환경 구축 +6. farmq.db에 약국 + 장비 2개 등록 +7. gateway.db에 관리자 계정 생성 +8. 로그인 정보 출력 + +## 실행 방법 + +```bash +# PVE host의 shell에서 실행 +curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pharmq-setup.sh | bash +``` + +## Phase 구조 (총 8단계) + +### Phase 1: PVE Repository Fix (자동, ~30초) +- proxmox-archive-keyring 확인 +- no-subscription 리포 설정 (deb822) +- enterprise 리포 비활성화 +- `apt-get update` + +### Phase 2: PVE Host Tailscale 등록 (자동, ~20초) +- Tailscale 패키지 설치 (이미 있으면 스킵) +- tailscaled 서비스 시작 +- `tailscale up --login-server=http://head.pharmq.kr --authkey=... --hostname=PXXXX-pve-pharmq` +- PVE host VPN IP 획득 → `$PVE_VPN_IP` +- **주의**: hostname은 Phase 3에서 약국 코드 확정 후 `tailscale set --hostname=...`으로 변경 + +### Phase 3: 약국 정보 수집 (대화형, 사용자 입력) +- 약국명 (필수) +- 요양기관부호 (선택) +- 주소, 약국장, 연락처 (선택) +- MSSQL 서버 IP (기본: `192.168.0.201\PM2014` — Windows VM 복원 후 변경될 수 있음) + +**참고**: `curl | bash` 파이프 실행 시 `read`는 `/dev/tty`에서 읽음 + +### Phase 4: PBS 등록 + Windows VM 복원 (반자동, ~5분) + +PBS에서 팜IT3000 Windows VM을 복원하는 단계. + +#### 4-1. PBS 스토리지 등록 (자동) +```bash +pvesm add pbs "PBS-Auto" \ + --server "100.64.0.10" \ + --port "8007" \ + --datastore "PBS-DVA" \ + --username "0bin@pbs" \ + --password "@Trajet6640" \ + --fingerprint "24:42:c6:..." \ + --namespace "PQ" +``` +- 이미 등록돼있으면 스킵 + +#### 4-2. PBS API 인증 + 백업 목록 조회 (자동) +```bash +# PBS API 로그인 → ticket 획득 +# GET /api2/json/admin/datastore/PBS-DVA/groups?ns=PQ +# VM 백업 목록 표시 +``` + +#### 4-3. 복원할 VM 선택 (사용자 입력) +``` +=== VM 백업 === + 1. VM 100 - 3개 백업 (최근: 2026-04-05 14:00) + └─ 팜IT3000 Windows Server 템플릿 + 2. VM 200 - 2개 백업 (최근: 2026-04-03 10:00) + └─ 기본 Windows 10 + +복원할 VM 번호: 1 +복원 VMID [기본: 200]: 200 +저장 스토리지 [기본: local-lvm]: local-lvm +``` + +#### 4-4. VM 복원 실행 (자동) +```bash +# 최신 스냅샷 자동 선택 +qmrestore "PBS-Auto:backup/vm/$TEMPLATE_VMID/$LATEST_SNAPSHOT" $TARGET_VMID \ + --storage local-lvm --unique 1 +``` + +#### 4-5. Windows VM 시작 (자동) +```bash +qm start $TARGET_VMID +sleep 30 # Windows 부팅 대기 +``` +- VM의 LAN IP → `$MSSQL_SERVER` 로 사용 (기본 `192.168.0.201\PM2014`) + +### Phase 5: Ubuntu CT 생성 (자동, ~2분) + +#### 5-1. 템플릿 준비 +```bash +pveam update +pveam download local ubuntu-24.04-standard_24.04-2_amd64.tar.zst +# 이미 있으면 스킵 +``` + +#### 5-2. VMID 자동 선택 +- 200~299 범위에서 사용 가능한 VMID 자동 탐색 (VM과 겹치지 않게) +- `pct list` + `qm list`로 기존 VM/CT 확인 + +#### 5-3. LAN IP 자동 선택 +- 192.168.0.100~199 범위에서 사용 가능한 IP 탐색 +- `ping -c1 -W1`로 충돌 확인 + +#### 5-4. CT 생성 +```bash +pct create $CT_VMID local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \ + --hostname ubuntu-api \ + --cores 4 --memory 8192 \ + --rootfs local-lvm:30 \ + --net0 name=eth0,bridge=vmbr0,ip=192.168.0.$X/24,gw=192.168.0.1 \ + --nameserver 8.8.8.8 \ + --password 'trajet6640' \ + --unprivileged 1 \ + --features nesting=1 +``` + +#### 5-5. TUN 디바이스 설정 (Tailscale 필수) +```bash +echo 'lxc.cgroup2.devices.allow: c 10:200 rwm' >> /etc/pve/lxc/$CT_VMID.conf +echo 'lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file' >> /etc/pve/lxc/$CT_VMID.conf +``` + +#### 5-6. CT 시작 +```bash +pct start $CT_VMID +sleep 5 # 부팅 대기 +``` + +### Phase 6: CT 내부 환경 구축 (자동, ~3분) + +모든 명령은 `pct exec $CT_VMID -- bash -c '...'`로 실행 + +#### 6-1. 기본 패키지 +```bash +apt-get update +apt-get install -y curl git python3-pip python3-dev python3-venv \ + pkg-config unixodbc unixodbc-dev build-essential +``` + +#### 6-2. Microsoft ODBC Driver 18 (FreeTDS 아님!) +```bash +curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg +echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" > /etc/apt/sources.list.d/mssql-release.list +apt-get update && ACCEPT_EULA=Y apt-get install -y msodbcsql18 +# tdsodbc 절대 설치하지 않음 — named instance 접속 불가 +``` + +#### 6-3. Tailscale 설치 + Headscale 등록 +```bash +curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null +curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list +apt-get update && apt-get install -y tailscale +systemctl enable --now tailscaled +sleep 3 +tailscale up --login-server=http://head.pharmq.kr \ + --authkey=$PREAUTH_KEY \ + --hostname=$PHARMACY_CODE-ubuntu-api \ + --accept-routes --accept-dns=false +``` +- CT VPN IP 획득 → `$CT_VPN_IP` + +#### 6-4. API 서버 코드 클론 + 설치 +```bash +git clone https://git.0bin.in/thug0bin/person-lookup-web.git /srv/person-lookup-web-local +cd /srv/person-lookup-web-local +pip3 install -r requirements.txt --break-system-packages +# 추가 누락 패키지 (P0019 freeze 기준) +pip3 install brother_ql pytz qrcode netifaces pydantic anthropic httpx PyYAML rich --break-system-packages +``` + +#### 6-5. .env 파일 생성 +```bash +cat > /srv/person-lookup-web-local/.env << EOF +MSSQL_SERVER=$MSSQL_SERVER +MSSQL_USER=sa +MSSQL_PASSWORD=tmddls214!%( +PHARMACY_CODE=$PHARMACY_CODE +EOF +``` + +#### 6-6. systemd 서비스 등록 +```bash +cat > /etc/systemd/system/pharmq-api.service << EOF +[Unit] +Description=PharmQ API Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=/srv/person-lookup-web-local +ExecStart=/usr/bin/python3 app.py +Restart=always +RestartSec=5 +Environment=FLASK_ENV=production + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now pharmq-api +``` + +### Phase 7: 약국 + 장비 등록 (자동, ~5초) + +#### 7-1. farmq.db — 약국 생성 (CT를 primary 장비로) +```bash +curl -s -X POST https://demo.pharmq.kr/api/pharmacy \ + -H "Content-Type: application/json" \ + -d '{ + "pharmacy_name": "$PHARMACY_NAME", + "vpn_ip": "$CT_VPN_IP", + "device_type": "linux_server", + "device_name": "$PHARMACY_CODE-ubuntu-api", + "hostname": "ubuntu-api", + "hira_code": "$HIRA_CODE", + "address": "$ADDRESS", + "owner_name": "$OWNER_NAME", + "phone": "$PHONE", + "api_port": 8082 + }' +# → pharmacy_code 응답 (P0022 등) +``` + +#### 7-2. farmq.db — PVE host 장비 추가 등록 ✅ NEW API +```bash +curl -s -X POST https://demo.pharmq.kr/api/pharmacy/$PHARMACY_CODE/device \ + -H "Content-Type: application/json" \ + -d '{ + "tailscale_ip": "$PVE_VPN_IP", + "device_name": "$PHARMACY_CODE-pve-pharmq", + "device_type": "proxmox_host", + "device_role": "main_server", + "hostname": "$PVE_HOSTNAME", + "is_primary": false + }' +``` + +#### 7-3. gateway.db — 관리자 계정 생성 +```bash +curl -s -X POST https://gateway.pharmq.kr/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "$PHARMACY_CODE_LOWER", + "email": "$PHARMACY_CODE_LOWER@pharmq.internal", + "password": "12341234", + "name": "$PHARMACY_NAME 관리자", + "phone": "$PHONE", + "primary_pharmacy_code": "$PHARMACY_CODE", + "role": "admin" + }' +``` + +### Phase 8: 검증 + 결과 출력 (자동, ~10초) + +1. CT API 서버 헬스체크: `curl http://$CT_VPN_IP:8082/api/status` +2. Windows VM 상태 확인: `qm status $VM_VMID` +3. PVE → CT 네트워크 확인: `ping -c3 $CT_VPN_IP` +4. 최종 결과 출력: + +``` +============================================ + PharmQ 원클릭 설치 완료! +============================================ + +약국 코드: P0022 +약국명: OO약국 + +[Windows VM - 팜IT3000] + VMID: 200 + MSSQL: 192.168.0.201\PM2014 + +[PVE Host] + VPN IP: 100.64.0.XX + hostname: P0022-pve-pharmq + +[Ubuntu API CT] + VPN IP: 100.64.0.YY + hostname: P0022-ubuntu-api + CT VMID: 204 + LAN IP: 192.168.0.103 + API: http://100.64.0.YY:8082 + +로그인 정보: + URL: https://pharmq.kr + ID: p0022 + PW: 12341234 + +⚠ 최초 로그인 후 비밀번호를 변경하세요! +============================================ +``` + +## 주의사항 + +1. **ODBC Driver**: FreeTDS(tdsodbc) 절대 설치 금지. MSSQL named instance 접속 불가 +2. **Preauth Key**: 현재 키 `b4692...` 만료일 2026-09-22 (충분) +3. **CT VMID**: 200~299 자동 탐색, VM과 겹치지 않게 +4. **DNS**: CT에 `--nameserver 8.8.8.8` 필수 (Headscale DNS만 쓰면 apt 불가) +5. **TUN 디바이스**: CT 생성 후 start 전에 설정 필수 +6. **중복 실행**: 같은 PVE에서 다시 돌리면 새 약국 코드 생성됨 (기존 것 덮어쓰지 않음) +7. **PBS 접근**: VPN 연결 후에만 PBS(100.64.0.10) 접근 가능 → Phase 2 이후 실행 + +## API 엔드포인트 (완료) + +| 엔드포인트 | 용도 | 상태 | +|-----------|------|------| +| `POST /api/pharmacy` | 약국 생성 + 1번째 장비 등록 | 기존 | +| `POST /api/pharmacy//device` | 추가 장비 등록 | ✅ 신규 추가 | +| `POST /api/auth/register` | 관리자 계정 생성 | 기존 (gateway) | + +## PBS 설정 (하드코딩) + +| 항목 | 값 | +|------|-----| +| PBS 서버 | 100.64.0.10:8007 | +| 사용자 | 0bin@pbs | +| 비밀번호 | @Trajet6640 | +| 데이터스토어 | PBS-DVA | +| 네임스페이스 | PQ | +| Fingerprint | 24:42:c6:0f:a8:... | + +## 파일 구조 + +``` +pve9-repo-fix/ +├── pharmq-setup.sh ← 통합 스크립트 (메인) +├── fix-pve9-repos.sh ← 기존 Phase 1 (참고용, 통합에 흡수) +├── headscale-auto-register.sh ← 기존 (참고용, 통합에 흡수) +├── pbs_allinone.sh ← 기존 PBS (참고용, Phase 4에 흡수) +├── UNIFIED_INSTALL_DESIGN.md ← 이 문서 +└── README.md +``` diff --git a/pharmq-setup.sh b/pharmq-setup.sh new file mode 100644 index 0000000..d839060 --- /dev/null +++ b/pharmq-setup.sh @@ -0,0 +1,848 @@ +#!/usr/bin/env bash +# +# PharmQ PVE 원클릭 통합 설치 스크립트 +# 사용법: curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pharmq-setup.sh | bash +# +# Phase 1: PVE Repository Fix (구독 제한 해제) +# Phase 2: PVE Host Tailscale → Headscale 등록 +# Phase 3: 약국 정보 수집 +# Phase 4: PBS 등록 + Windows VM 복원 +# Phase 5: Ubuntu CT 생성 +# Phase 6: CT 내부 환경 구축 +# Phase 7: 약국 + 장비 + 계정 등록 +# Phase 8: 검증 + 결과 출력 +# + +set -euo pipefail + +# ============================================================ +# 설정 +# ============================================================ +HEADSCALE_SERVER="http://head.pharmq.kr" +PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003" + +# PBS 설정 +PBS_SERVER="100.64.0.10" +PBS_PORT="8007" +PBS_USERNAME="0bin@pbs" +PBS_PASSWORD="@Trajet6640" +PBS_DATASTORE="PBS-DVA" +PBS_FINGERPRINT="24:42:c6:0f:a8:1b:93:32:32:44:84:be:6a:c5:71:97:e4:4d:61:fc:a4:48:12:0c:97:3b:9f:1f:cc:b2:54:e8" +PBS_STORAGE_NAME="PBS-Auto" + +# API 서버 +FARMQ_API="https://demo.pharmq.kr" +GATEWAY_API="https://gateway.pharmq.kr" + +# CT 기본값 +CT_CORES=4 +CT_MEMORY=8192 +CT_DISK=30 +CT_PASSWORD="trajet6640" +CT_TEMPLATE="ubuntu-24.04-standard_24.04-2_amd64.tar.zst" + +# 결과 변수 (Phase 간 공유) +PVE_VPN_IP="" +PVE_HOSTNAME="" +CT_VMID="" +CT_LAN_IP="" +CT_VPN_IP="" +VM_VMID="" +MSSQL_SERVER="" +PHARMACY_NAME="" +PHARMACY_CODE="" +HIRA_CODE="" +PHARMACY_ADDRESS="" +OWNER_NAME="" +PHARMACY_PHONE="" + +# ============================================================ +# 색상 + 유틸 +# ============================================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +WHITE='\033[1;37m' +NC='\033[0m' + +print_phase() { + echo "" + echo -e "${PURPLE}════════════════════════════════════════════${NC}" + echo -e "${WHITE} $1${NC}" + echo -e "${PURPLE}════════════════════════════════════════════${NC}" + echo "" +} + +print_step() { echo -e "${BLUE}▶ $1${NC}"; } +print_ok() { echo -e "${GREEN}✅ $1${NC}"; } +print_warn() { echo -e "${YELLOW}⚠️ $1${NC}"; } +print_err() { echo -e "${RED}❌ $1${NC}"; } +print_info() { echo -e "${CYAN} $1${NC}"; } + +# ============================================================ +# Phase 1: PVE Repository Fix +# ============================================================ +phase1_repo_fix() { + print_phase "Phase 1/8: PVE Repository Fix" + + # Proxmox 환경 확인 + if [ ! -f /etc/pve/storage.cfg ]; then + print_err "Proxmox VE가 설치되어 있지 않습니다. 이 스크립트는 PVE host에서 실행해야 합니다." + exit 1 + fi + + # Root 확인 + if [ "$EUID" -ne 0 ]; then + print_err "root 권한으로 실행해야 합니다." + exit 1 + fi + + print_ok "Proxmox VE 환경 확인" + + # Keyring 확인 + install -d -m 0755 /usr/share/keyrings + if [ ! -f /usr/share/keyrings/proxmox-archive-keyring.gpg ]; then + print_step "proxmox-archive-keyring 설치 중..." + apt-get update -qq + apt-get install -y proxmox-archive-keyring || true + fi + + CODENAME="trixie" # PVE 9 = Debian 13 + + # No-subscription 리포 설정 + print_step "No-subscription 리포 설정 중..." + cat >/etc/apt/sources.list.d/proxmox.sources </etc/apt/sources.list.d/ceph.sources < "$f.new" && mv "$f.new" "$f" + fi + done + + for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do + [ -f "$f" ] || continue + if grep -q "enterprise\.proxmox\.com" "$f" 2>/dev/null; then + sed -i -E 's|^(deb .*enterprise\.proxmox\.com.*)$|# \1|' "$f" + fi + done + rm -f /etc/apt/sources.list.d/pve-no-subscription.list 2>/dev/null || true + + print_step "apt-get update 실행 중..." + apt-get update -qq + + print_ok "Phase 1 완료: PVE 리포지토리 수정됨" +} + +# ============================================================ +# Phase 2: PVE Host Tailscale → Headscale 등록 +# ============================================================ +phase2_tailscale_pve() { + print_phase "Phase 2/8: PVE Host Tailscale 등록" + + # Tailscale 설치 + if ! command -v tailscale >/dev/null 2>&1; then + print_step "Tailscale 설치 중..." + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list >/dev/null + apt-get update -qq + apt-get install -y tailscale + else + print_ok "Tailscale 이미 설치됨: $(tailscale version | head -n1)" + fi + + # 서비스 시작 + systemctl enable tailscaled >/dev/null 2>&1 || true + systemctl start tailscaled >/dev/null 2>&1 || true + sleep 3 + + # 이미 연결돼있는지 확인 + if tailscale status >/dev/null 2>&1; then + PVE_VPN_IP=$(tailscale ip -4 2>/dev/null || echo "") + if [ -n "$PVE_VPN_IP" ]; then + print_ok "이미 Headscale에 연결됨: $PVE_VPN_IP" + PVE_HOSTNAME=$(hostname) + return 0 + fi + fi + + # Headscale 등록 + PVE_HOSTNAME=$(hostname) + print_step "Headscale 서버에 등록 중... (hostname: ${PVE_HOSTNAME})" + + if tailscale up \ + --login-server="$HEADSCALE_SERVER" \ + --authkey="$PREAUTH_KEY" \ + --accept-routes \ + --accept-dns=false 2>/dev/null; then + + sleep 5 + PVE_VPN_IP=$(tailscale ip -4 2>/dev/null || echo "") + + if [ -n "$PVE_VPN_IP" ]; then + print_ok "Headscale 등록 성공!" + print_info "VPN IP: $PVE_VPN_IP" + else + print_err "VPN IP를 가져올 수 없습니다." + exit 1 + fi + else + print_err "Headscale 등록 실패" + print_info "수동 등록: tailscale up --login-server=$HEADSCALE_SERVER --authkey=$PREAUTH_KEY --accept-routes --accept-dns=false" + exit 1 + fi + + print_ok "Phase 2 완료: PVE Host VPN 연결됨 ($PVE_VPN_IP)" +} + +# ============================================================ +# Phase 3: 약국 정보 수집 +# ============================================================ +phase3_collect_info() { + print_phase "Phase 3/8: 약국 정보 수집" + + # 약국명 (필수) + PHARMACY_NAME="" + while [ -z "$PHARMACY_NAME" ]; do + echo -ne "${CYAN}약국명 (필수): ${NC}" + read -r PHARMACY_NAME /dev/null | grep -q "^${PBS_STORAGE_NAME} "; then + print_ok "PBS 스토리지 이미 등록됨" + else + pvesm add pbs "${PBS_STORAGE_NAME}" \ + --server "${PBS_SERVER}" \ + --port "${PBS_PORT}" \ + --datastore "${PBS_DATASTORE}" \ + --username "${PBS_USERNAME}" \ + --password "${PBS_PASSWORD}" \ + --fingerprint "${PBS_FINGERPRINT}" \ + --namespace "PQ" 2>&1 + + if [ $? -eq 0 ]; then + print_ok "PBS 스토리지 등록 완료" + else + print_err "PBS 스토리지 등록 실패" + exit 1 + fi + fi + + # PBS API 인증 + print_step "PBS API 인증 중..." + curl -k -s -X POST "https://${PBS_SERVER}:${PBS_PORT}/api2/json/access/ticket" \ + -d "username=${PBS_USERNAME}" \ + -d "password=${PBS_PASSWORD}" > /tmp/pbs_auth.json + + local PBS_TICKET PBS_CSRF + PBS_TICKET=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["ticket"])' 2>/dev/null) + PBS_CSRF=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["CSRFPreventionToken"])' 2>/dev/null) + + if [ -z "$PBS_TICKET" ]; then + print_err "PBS API 인증 실패" + exit 1 + fi + print_ok "PBS API 인증 완료" + + # 백업 목록 조회 + print_step "VM 백업 목록 조회 중..." + curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/groups?ns=PQ" \ + -H "Cookie: PBSAuthCookie=${PBS_TICKET}" \ + -H "CSRFPreventionToken: ${PBS_CSRF}" > /tmp/pbs_groups.json + + export PBS_TICKET PBS_CSRF + + python3 << 'PYEOF' +import json, subprocess, os +from datetime import datetime + +PBS_TICKET = os.environ.get('PBS_TICKET', '') +PBS_CSRF = os.environ.get('PBS_CSRF', '') + +with open('/tmp/pbs_groups.json', 'r') as f: + data = json.load(f) + +groups = data.get("data", []) +vm_groups = [g for g in groups if g.get("backup-type") == "vm"] + +if vm_groups: + print("\033[1;36m=== VM 백업 목록 ===\033[0m") + for i, group in enumerate(vm_groups, 1): + bid = group.get("backup-id", "N/A") + count = group.get("backup-count", 0) + last = group.get("last-backup", 0) + comment = group.get("comment", "") + + if not comment: + try: + cmd = ['curl', '-k', '-s', '-X', 'GET', + f'https://100.64.0.10:8007/api2/json/admin/datastore/PBS-DVA/snapshots?backup-type=vm&backup-id={bid}&ns=PQ', + '-H', f'Cookie: PBSAuthCookie={PBS_TICKET}', + '-H', f'CSRFPreventionToken: {PBS_CSRF}'] + result = subprocess.run(cmd, capture_output=True, text=True) + sdata = json.loads(result.stdout) + snaps = sdata.get("data", []) + if snaps: + latest = max(snaps, key=lambda x: x.get("backup-time", 0)) + comment = latest.get("comment", "") + except: + pass + + last_str = datetime.fromtimestamp(last).strftime("%Y-%m-%d %H:%M") if last > 0 else "N/A" + print(f" {i}. VM {bid:<6} - {count}개 백업 (최근: {last_str})") + if comment: + print(f" \033[0;90m└─ {comment}\033[0m") +else: + print("VM 백업이 없습니다.") +PYEOF + + echo "" + echo -ne "${CYAN}복원할 VM 백업 ID (숫자, Enter로 건너뛰기): ${NC}" + read -r TEMPLATE_VMID /dev/null | tail -n +2 | while read -r line; do + sname=$(echo "$line" | awk '{print $1}') + stype=$(echo "$line" | awk '{print $2}') + echo -e " ${GREEN}●${NC} ${sname} (${stype})" + done + echo "" + + echo -ne "${CYAN}저장 스토리지 [기본: local-lvm]: ${NC}" + read -r TARGET_STORAGE /tmp/pbs_snapshots.json + + local LATEST_SNAPSHOT + LATEST_SNAPSHOT=$(python3 << 'PYEOF' +import json, sys +from datetime import datetime +with open('/tmp/pbs_snapshots.json', 'r') as f: + data = json.load(f) +snapshots = data.get("data", []) +if not snapshots: + sys.exit(1) +latest = max(snapshots, key=lambda x: x.get("backup-time", 0)) +backup_time = latest.get("backup-time", 0) +print(datetime.utcfromtimestamp(backup_time).strftime("%Y-%m-%dT%H:%M:%SZ")) +PYEOF + ) + + if [ -z "$LATEST_SNAPSHOT" ]; then + print_err "백업을 찾을 수 없습니다." + exit 1 + fi + print_ok "최신 백업: $LATEST_SNAPSHOT" + + # 기존 VM 확인 + if qm status "$VM_VMID" 2>/dev/null; then + print_warn "VMID $VM_VMID가 이미 존재합니다." + echo -ne "${YELLOW}삭제하고 복원하시겠습니까? (y/N): ${NC}" + read -r confirm /dev/null || true + sleep 2 + qm destroy "$VM_VMID" --purge --skiplock 2>/dev/null || true + sleep 2 + print_ok "기존 VM 삭제 완료" + else + print_err "복원 취소됨" + exit 1 + fi + fi + + # 복원 실행 + local BACKUP_PATH="${PBS_STORAGE_NAME}:backup/vm/${TEMPLATE_VMID}/${LATEST_SNAPSHOT}" + print_step "VM 복원 중... (시간이 걸릴 수 있습니다)" + print_info "경로: $BACKUP_PATH → VMID $VM_VMID" + + if qmrestore "$BACKUP_PATH" "$VM_VMID" --storage "$TARGET_STORAGE" --unique 1 2>&1; then + print_ok "VM 복원 완료!" + else + print_err "VM 복원 실패" + exit 1 + fi + + # VM 시작 + print_step "Windows VM 시작 중..." + qm start "$VM_VMID" + print_info "Windows 부팅 대기 (30초)..." + sleep 30 + + print_ok "Phase 4 완료: PBS 등록 + Windows VM($VM_VMID) 복원됨" +} + +# ============================================================ +# Phase 5: Ubuntu CT 생성 +# ============================================================ +phase5_create_ct() { + print_phase "Phase 5/8: Ubuntu CT 생성" + + # 템플릿 준비 + print_step "Ubuntu 템플릿 확인 중..." + if pveam list local 2>/dev/null | grep -q "$CT_TEMPLATE"; then + print_ok "템플릿 이미 다운로드됨" + else + print_step "템플릿 다운로드 중..." + pveam update >/dev/null 2>&1 + pveam download local "$CT_TEMPLATE" + fi + + # VMID 자동 선택 (200~299, VM/CT 모두 확인) + print_step "사용 가능한 VMID 탐색 중..." + CT_VMID="" + for vmid in $(seq 200 299); do + if ! qm status "$vmid" >/dev/null 2>&1 && ! pct status "$vmid" >/dev/null 2>&1; then + CT_VMID=$vmid + break + fi + done + + if [ -z "$CT_VMID" ]; then + print_err "200~299 범위에서 사용 가능한 VMID가 없습니다." + exit 1 + fi + print_ok "CT VMID: $CT_VMID" + + # LAN IP 자동 선택 (192.168.0.100~199) + print_step "사용 가능한 LAN IP 탐색 중..." + CT_LAN_IP="" + for octet in $(seq 100 199); do + local test_ip="192.168.0.${octet}" + if ! ping -c1 -W1 "$test_ip" >/dev/null 2>&1; then + CT_LAN_IP="$test_ip" + break + fi + done + + if [ -z "$CT_LAN_IP" ]; then + print_err "192.168.0.100~199 범위에서 사용 가능한 IP가 없습니다." + exit 1 + fi + print_ok "CT LAN IP: $CT_LAN_IP" + + # CT 생성 + print_step "CT 생성 중... (VMID: $CT_VMID, IP: $CT_LAN_IP)" + pct create "$CT_VMID" "local:vztmpl/${CT_TEMPLATE}" \ + --hostname ubuntu-api \ + --cores "$CT_CORES" --memory "$CT_MEMORY" \ + --rootfs "local-lvm:${CT_DISK}" \ + --net0 "name=eth0,bridge=vmbr0,ip=${CT_LAN_IP}/24,gw=192.168.0.1" \ + --nameserver "8.8.8.8" \ + --password "$CT_PASSWORD" \ + --unprivileged 1 \ + --features nesting=1 + + print_ok "CT $CT_VMID 생성 완료" + + # TUN 디바이스 설정 + print_step "TUN 디바이스 설정 중... (Tailscale 필수)" + echo 'lxc.cgroup2.devices.allow: c 10:200 rwm' >> "/etc/pve/lxc/${CT_VMID}.conf" + echo 'lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file' >> "/etc/pve/lxc/${CT_VMID}.conf" + print_ok "TUN 디바이스 설정 완료" + + # CT 시작 + print_step "CT 시작 중..." + pct start "$CT_VMID" + sleep 5 + print_ok "CT $CT_VMID 시작됨" + + print_ok "Phase 5 완료: Ubuntu CT($CT_VMID) 생성됨 ($CT_LAN_IP)" +} + +# ============================================================ +# Phase 6: CT 내부 환경 구축 +# ============================================================ +phase6_setup_ct() { + print_phase "Phase 6/8: CT 내부 환경 구축" + + # 헬퍼: CT 내부에서 명령 실행 + ct_exec() { + pct exec "$CT_VMID" -- bash -c "$1" + } + + # 6-1. 기본 패키지 + print_step "기본 패키지 설치 중..." + ct_exec "apt-get update -qq && apt-get install -y curl git python3-pip python3-dev python3-venv pkg-config unixodbc unixodbc-dev build-essential" >/dev/null 2>&1 + print_ok "기본 패키지 설치 완료" + + # 6-2. Microsoft ODBC Driver 18 + print_step "ODBC Driver 18 설치 중... (FreeTDS 아님!)" + ct_exec "curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg" + ct_exec 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" > /etc/apt/sources.list.d/mssql-release.list' + ct_exec "apt-get update -qq && ACCEPT_EULA=Y apt-get install -y msodbcsql18" >/dev/null 2>&1 + # FreeTDS/tdsodbc 제거 (혹시 설치돼있으면) + ct_exec "apt-get remove -y tdsodbc 2>/dev/null || true" + print_ok "ODBC Driver 18 설치 완료" + + # 6-3. Tailscale 설치 + Headscale 등록 + print_step "CT에 Tailscale 설치 중..." + ct_exec "curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null" + ct_exec "curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list >/dev/null" + ct_exec "apt-get update -qq && apt-get install -y tailscale" >/dev/null 2>&1 + ct_exec "systemctl enable --now tailscaled" >/dev/null 2>&1 + sleep 3 + + # hostname은 임시로 설정 (pharmacy_code는 Phase 7에서 확정) + print_step "CT를 Headscale에 등록 중..." + ct_exec "tailscale up --login-server=${HEADSCALE_SERVER} --authkey=${PREAUTH_KEY} --accept-routes --accept-dns=false" 2>/dev/null + sleep 5 + + CT_VPN_IP=$(ct_exec "tailscale ip -4 2>/dev/null" | tr -d '[:space:]') + if [ -n "$CT_VPN_IP" ]; then + print_ok "CT Headscale 등록 성공: $CT_VPN_IP" + else + print_err "CT VPN IP를 가져올 수 없습니다." + exit 1 + fi + + # 6-4. API 서버 코드 클론 + 설치 + print_step "API 서버 코드 설치 중..." + ct_exec "git clone https://git.0bin.in/thug0bin/person-lookup-web.git /srv/person-lookup-web-local 2>/dev/null || (cd /srv/person-lookup-web-local && git pull)" + ct_exec "cd /srv/person-lookup-web-local && pip3 install -r requirements.txt --break-system-packages -q" 2>/dev/null + ct_exec "pip3 install brother_ql pytz qrcode netifaces pydantic anthropic httpx PyYAML rich --break-system-packages -q" 2>/dev/null + print_ok "API 서버 코드 설치 완료" + + # 6-5. .env 파일 생성 + print_step ".env 파일 생성 중..." + ct_exec "cat > /srv/person-lookup-web-local/.env << 'ENVEOF' +MSSQL_SERVER=${MSSQL_SERVER} +MSSQL_USER=sa +MSSQL_PASSWORD=tmddls214!%( +ENVEOF" + print_ok ".env 파일 생성 완료" + + # 6-6. systemd 서비스 등록 + print_step "API 서비스 등록 중..." + ct_exec "cat > /etc/systemd/system/pharmq-api.service << 'SVCEOF' +[Unit] +Description=PharmQ API Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=/srv/person-lookup-web-local +ExecStart=/usr/bin/python3 app.py +Restart=always +RestartSec=5 +Environment=FLASK_ENV=production + +[Install] +WantedBy=multi-user.target +SVCEOF" + ct_exec "systemctl daemon-reload && systemctl enable --now pharmq-api" >/dev/null 2>&1 + print_ok "API 서비스 등록 완료" + + print_ok "Phase 6 완료: CT 환경 구축됨 (VPN: $CT_VPN_IP)" +} + +# ============================================================ +# Phase 7: 약국 + 장비 + 계정 등록 +# ============================================================ +phase7_register() { + print_phase "Phase 7/8: 약국 + 장비 + 계정 등록" + + # 7-1. 약국 생성 (CT를 primary 장비로) + print_step "약국 생성 중... (farmq.db)" + local PHARMACY_RESPONSE + PHARMACY_RESPONSE=$(curl -s -X POST "${FARMQ_API}/api/pharmacy" \ + -H "Content-Type: application/json" \ + -d "$(cat </dev/null || true + + # CT hostname도 변경 + ct_exec() { pct exec "$CT_VMID" -- bash -c "$1"; } + ct_exec "tailscale set --hostname=${PHARMACY_CODE}-ubuntu-api" 2>/dev/null || true + + # CT .env에 PHARMACY_CODE 추가 + ct_exec "echo 'PHARMACY_CODE=${PHARMACY_CODE}' >> /srv/person-lookup-web-local/.env" + + # 7-3. Gateway 계정 생성 + print_step "관리자 계정 생성 중... (gateway.db)" + local USERNAME + USERNAME=$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]') + + local REGISTER_RESPONSE + REGISTER_RESPONSE=$(curl -s -X POST "${GATEWAY_API}/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "$(cat </dev/null || echo "연결 실패") + if echo "$API_STATUS" | grep -qi "connected\|ok\|status"; then + print_ok "API 서버 정상" + else + print_warn "API 서버 응답 확인 필요" + print_info "수동 확인: curl http://${CT_VPN_IP}:8082/api/status" + fi + + # Windows VM 상태 + if [ -n "$VM_VMID" ]; then + print_step "Windows VM 상태 확인 중..." + qm status "$VM_VMID" 2>/dev/null || print_warn "VM $VM_VMID 상태 확인 실패" + fi + + # 네트워크 테스트 + print_step "VPN 네트워크 테스트 중..." + if ping -c2 -W3 "$CT_VPN_IP" >/dev/null 2>&1; then + print_ok "PVE → CT 네트워크 정상" + else + print_warn "PVE → CT ping 실패 (방화벽 확인)" + fi + + # 최종 결과 출력 + local USERNAME + USERNAME=$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]') + + echo "" + echo -e "${GREEN}════════════════════════════════════════════${NC}" + echo -e "${WHITE} PharmQ 원클릭 설치 완료!${NC}" + echo -e "${GREEN}════════════════════════════════════════════${NC}" + echo "" + echo -e "${WHITE}약국 코드: ${CYAN}${PHARMACY_CODE}${NC}" + echo -e "${WHITE}약국명: ${CYAN}${PHARMACY_NAME}${NC}" + echo "" + + if [ -n "$VM_VMID" ]; then + echo -e "${WHITE}[Windows VM - 팜IT3000]${NC}" + echo -e " VMID: ${VM_VMID}" + echo -e " MSSQL: ${MSSQL_SERVER}" + echo "" + fi + + echo -e "${WHITE}[PVE Host]${NC}" + echo -e " VPN IP: ${PVE_VPN_IP}" + echo -e " hostname: ${PHARMACY_CODE}-pve-pharmq" + echo "" + + echo -e "${WHITE}[Ubuntu API CT]${NC}" + echo -e " VPN IP: ${CT_VPN_IP}" + echo -e " hostname: ${PHARMACY_CODE}-ubuntu-api" + echo -e " CT VMID: ${CT_VMID}" + echo -e " LAN IP: ${CT_LAN_IP}" + echo -e " API: http://${CT_VPN_IP}:8082" + echo "" + + echo -e "${WHITE}[로그인 정보]${NC}" + echo -e " URL: ${CYAN}https://pharmq.kr${NC}" + echo -e " ID: ${CYAN}${USERNAME}${NC}" + echo -e " PW: ${CYAN}12341234${NC}" + echo "" + + echo -e "${YELLOW}⚠ 최초 로그인 후 비밀번호를 변경하세요!${NC}" + echo "" + echo -e "${GREEN}════════════════════════════════════════════${NC}" + echo "" + + # 유용한 명령어 + echo -e "${WHITE}유용한 명령어:${NC}" + echo " tailscale status # VPN 상태" + echo " pct enter ${CT_VMID} # CT 접속" + echo " pct exec ${CT_VMID} -- systemctl status pharmq-api # API 상태" + if [ -n "$VM_VMID" ]; then + echo " qm status ${VM_VMID} # Windows VM 상태" + fi + echo "" + + # 임시 파일 정리 + rm -f /tmp/pbs_auth.json /tmp/pbs_groups.json /tmp/pbs_snapshots.json 2>/dev/null || true +} + +# ============================================================ +# 에러 핸들링 +# ============================================================ +trap 'echo -e "\n${RED}❌ 설치 중 오류가 발생했습니다 (line $LINENO). 로그를 확인해주세요.${NC}"; exit 1' ERR + +# ============================================================ +# 메인 실행 +# ============================================================ +main() { + echo "" + echo -e "${PURPLE}╔════════════════════════════════════════════╗${NC}" + echo -e "${PURPLE}║ ║${NC}" + echo -e "${PURPLE}║${NC} ${WHITE}PharmQ PVE 원클릭 통합 설치${NC} ${PURPLE}║${NC}" + echo -e "${PURPLE}║${NC} ${CYAN}Repo Fix → VPN → PBS → CT → 등록${NC} ${PURPLE}║${NC}" + echo -e "${PURPLE}║ ║${NC}" + echo -e "${PURPLE}╚════════════════════════════════════════════╝${NC}" + echo "" + + phase1_repo_fix + phase2_tailscale_pve + phase3_collect_info + phase4_pbs_restore + phase5_create_ct + phase6_setup_ct + phase7_register + phase8_verify +} + +main "$@"