Add unified PharmQ PVE setup script + design doc

Single script that handles: PVE repo fix, Tailscale/Headscale VPN registration,
PBS Windows VM restore, Ubuntu CT creation with API environment,
pharmacy + device registration in farmq.db, and gateway account creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-06 14:31:05 +00:00
parent 93a2313d37
commit d0cd2b1137
2 changed files with 1189 additions and 0 deletions

341
UNIFIED_INSTALL_DESIGN.md Normal file
View File

@@ -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/<code>/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
```

848
pharmq-setup.sh Normal file
View File

@@ -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 <<EOF
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: ${CODENAME}
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
Enabled: yes
EOF
# Ceph 리포 (비활성)
cat >/etc/apt/sources.list.d/ceph.sources <<EOF
Types: deb
URIs: http://download.proxmox.com/debian/ceph-squid
Suites: ${CODENAME}
Components: no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
Enabled: no
EOF
# Enterprise 리포 비활성화
print_step "Enterprise 리포 비활성화 중..."
for f in /etc/apt/sources.list.d/*.sources; do
[ -f "$f" ] || continue
if grep -q "enterprise\.proxmox\.com" "$f"; then
awk -v RS= -v ORS="\n\n" '{
if ($0 ~ /enterprise\.proxmox\.com/) {
if ($0 ~ /Enabled:/) sub(/Enabled:.*/, "Enabled: no");
else $0 = $0 "\nEnabled: no";
}
print
}' "$f" > "$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/tty
done
echo -ne "${CYAN}요양기관부호 (선택, Enter로 건너뛰기): ${NC}"
read -r HIRA_CODE </dev/tty
echo -ne "${CYAN}약국 주소 (선택): ${NC}"
read -r PHARMACY_ADDRESS </dev/tty
echo -ne "${CYAN}약국장 이름 (선택): ${NC}"
read -r OWNER_NAME </dev/tty
echo -ne "${CYAN}약국 연락처 (선택): ${NC}"
read -r PHARMACY_PHONE </dev/tty
echo -ne "${CYAN}MSSQL 서버 주소 [기본: 192.168.0.201\\PM2014]: ${NC}"
read -r MSSQL_SERVER </dev/tty
MSSQL_SERVER=${MSSQL_SERVER:-'192.168.0.201\PM2014'}
echo ""
print_info "약국명: $PHARMACY_NAME"
[ -n "$HIRA_CODE" ] && print_info "요양기관부호: $HIRA_CODE"
[ -n "$PHARMACY_ADDRESS" ] && print_info "주소: $PHARMACY_ADDRESS"
[ -n "$OWNER_NAME" ] && print_info "약국장: $OWNER_NAME"
[ -n "$PHARMACY_PHONE" ] && print_info "연락처: $PHARMACY_PHONE"
print_info "MSSQL: $MSSQL_SERVER"
print_ok "Phase 3 완료: 약국 정보 수집됨"
}
# ============================================================
# Phase 4: PBS 등록 + Windows VM 복원
# ============================================================
phase4_pbs_restore() {
print_phase "Phase 4/8: PBS 등록 + Windows VM 복원"
# PBS 스토리지 등록
print_step "PBS 스토리지 등록 중..."
if pvesm status 2>/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/tty
if [ -z "$TEMPLATE_VMID" ]; then
print_warn "VM 복원을 건너뜁니다."
VM_VMID=""
print_ok "Phase 4 완료: PBS 등록됨 (VM 복원 스킵)"
return 0
fi
# 복원 VMID
echo -ne "${CYAN}복원할 VMID [기본: 200]: ${NC}"
read -r VM_VMID </dev/tty
VM_VMID=${VM_VMID:-200}
# 스토리지 선택
echo ""
print_step "사용 가능한 스토리지:"
pvesm status -content images 2>/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 </dev/tty
TARGET_STORAGE=${TARGET_STORAGE:-local-lvm}
# 최신 스냅샷 찾기
print_step "최신 백업 조회 중..."
curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/snapshots?backup-type=vm&backup-id=${TEMPLATE_VMID}&ns=PQ" \
-H "Cookie: PBSAuthCookie=${PBS_TICKET}" \
-H "CSRFPreventionToken: ${PBS_CSRF}" > /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/tty
if [[ "$confirm" =~ ^[Yy]$ ]]; then
qm stop "$VM_VMID" --skiplock 2>/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 <<JSONEOF
{
"pharmacy_name": "${PHARMACY_NAME}",
"vpn_ip": "${CT_VPN_IP}",
"device_type": "linux_server",
"device_name": "ubuntu-api",
"hostname": "ubuntu-api",
"hira_code": "${HIRA_CODE}",
"address": "${PHARMACY_ADDRESS}",
"owner_name": "${OWNER_NAME}",
"phone": "${PHARMACY_PHONE}",
"api_port": 8082
}
JSONEOF
)")
# pharmacy_code 추출
if echo "$PHARMACY_RESPONSE" | grep -q '"success"' && echo "$PHARMACY_RESPONSE" | grep -q 'true'; then
PHARMACY_CODE=$(echo "$PHARMACY_RESPONSE" | grep '"pharmacy_code"' | sed 's/.*"pharmacy_code":[[:space:]]*"\([^"]*\)".*/\1/' | head -1)
if [ -n "$PHARMACY_CODE" ]; then
print_ok "약국 생성 완료: $PHARMACY_CODE ($PHARMACY_NAME)"
else
print_err "pharmacy_code 추출 실패"
print_info "응답: $PHARMACY_RESPONSE"
exit 1
fi
else
print_err "약국 생성 실패"
print_info "응답: $PHARMACY_RESPONSE"
exit 1
fi
# 7-2. PVE host 장비 추가
print_step "PVE host 장비 등록 중..."
local DEVICE_RESPONSE
DEVICE_RESPONSE=$(curl -s -X POST "${FARMQ_API}/api/pharmacy/${PHARMACY_CODE}/device" \
-H "Content-Type: application/json" \
-d "$(cat <<JSONEOF
{
"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
}
JSONEOF
)")
if echo "$DEVICE_RESPONSE" | grep -q '"success":true' || echo "$DEVICE_RESPONSE" | grep -q '"success": true'; then
print_ok "PVE host 장비 등록 완료"
else
print_warn "PVE host 장비 등록 실패 (약국은 정상 생성됨)"
print_info "응답: $DEVICE_RESPONSE"
fi
# Tailscale hostname 업데이트 (pharmacy_code 확정됨)
print_step "Tailscale hostname 업데이트 중..."
tailscale set --hostname="${PHARMACY_CODE}-pve-pharmq" 2>/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 <<JSONEOF
{
"username": "${USERNAME}",
"email": "${USERNAME}@pharmq.internal",
"password": "12341234",
"name": "${PHARMACY_NAME} 관리자",
"phone": "${PHARMACY_PHONE}",
"primary_pharmacy_code": "${PHARMACY_CODE}",
"role": "admin"
}
JSONEOF
)")
if echo "$REGISTER_RESPONSE" | grep -q '"success":true' || echo "$REGISTER_RESPONSE" | grep -q '"success": true'; then
print_ok "관리자 계정 생성 완료: $USERNAME"
else
print_warn "관리자 계정 생성 실패"
print_info "응답: $REGISTER_RESPONSE"
fi
print_ok "Phase 7 완료: 약국($PHARMACY_CODE) + 장비 2개 + 계정 등록됨"
}
# ============================================================
# Phase 8: 검증 + 결과 출력
# ============================================================
phase8_verify() {
print_phase "Phase 8/8: 검증 + 결과 출력"
# API 헬스체크
print_step "API 서버 헬스체크 중..."
sleep 5
local API_STATUS
API_STATUS=$(curl -s --connect-timeout 10 "http://${CT_VPN_IP}:8082/api/status" 2>/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 "$@"