Files
pve9-repo-fix/pharmq-setup.sh
Claude dd53e869d0 Fix: VM selection by list number instead of raw backup ID
Users were entering list number (e.g. 4) but script expected backup ID (e.g. 201).
Now maps selection number to actual VM ID via temp file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:36:43 +00:00

968 lines
35 KiB
Bash

#!/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 -eo pipefail
# ============================================================
# 명령행 인자 처리
# ============================================================
ARGS_NAME=""
ARGS_HIRA=""
ARGS_ADDR=""
ARGS_OWNER=""
ARGS_PHONE=""
ARGS_MSSQL=""
SKIP_PBS=false
ARGS_VM_ID=""
ARGS_VM_VMID=""
ARGS_VM_STORAGE=""
while [[ $# -gt 0 ]]; do
case $1 in
--name) ARGS_NAME="$2"; shift 2 ;;
--hira) ARGS_HIRA="$2"; shift 2 ;;
--addr) ARGS_ADDR="$2"; shift 2 ;;
--owner) ARGS_OWNER="$2"; shift 2 ;;
--phone) ARGS_PHONE="$2"; shift 2 ;;
--mssql) ARGS_MSSQL="$2"; shift 2 ;;
--skip-pbs) SKIP_PBS=true; shift ;;
--vm-id) ARGS_VM_ID="$2"; shift 2 ;;
--vm-vmid) ARGS_VM_VMID="$2"; shift 2 ;;
--vm-storage) ARGS_VM_STORAGE="$2"; shift 2 ;;
--help|-h)
echo "사용법: pharmq-setup.sh [옵션]"
echo " --name 약국명 (필수 또는 대화형 입력)"
echo " --hira 요양기관부호"
echo " --addr 약국 주소"
echo " --owner 약국장 이름"
echo " --phone 연락처"
echo " --mssql MSSQL 서버 주소 (기본: 192.168.0.201\\PM2014)"
exit 0 ;;
*) shift ;;
esac
done
# ============================================================
# 설정
# ============================================================
HEADSCALE_SERVER="" # 자동 감지 (LAN: 직접 IP, 외부: 도메인)
HEADSCALE_SERVER_LAN="http://192.168.0.100:8070"
HEADSCALE_SERVER_EXT="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 등록"
# Headscale 서버 주소 자동 감지 (LAN vs 외부)
# ping만으로는 부정확 (외부 약국에 192.168.0.100 장비가 있을 수 있음)
# → Headscale 8070 포트에 실제 응답하는지 확인
if [ -z "$HEADSCALE_SERVER" ]; then
print_step "Headscale 서버 접근 경로 감지 중..."
if curl -s --connect-timeout 3 http://192.168.0.100:8070/health 2>/dev/null | grep -qi "ok\|healthy" || \
curl -s --connect-timeout 3 -o /dev/null -w "%{http_code}" http://192.168.0.100:8070/ 2>/dev/null | grep -q "200\|404"; then
HEADSCALE_SERVER="$HEADSCALE_SERVER_LAN"
print_ok "LAN Headscale 감지 → 직접 연결 ($HEADSCALE_SERVER)"
else
HEADSCALE_SERVER="$HEADSCALE_SERVER_EXT"
print_ok "외부 네트워크 → 도메인 경유 ($HEADSCALE_SERVER)"
fi
fi
# 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: 약국 정보 수집"
# 명령행 인자가 있으면 사용, 없으면 대화형 입력
if [ -n "$ARGS_NAME" ]; then
PHARMACY_NAME="$ARGS_NAME"
HIRA_CODE="${ARGS_HIRA:-}"
PHARMACY_ADDRESS="${ARGS_ADDR:-}"
OWNER_NAME="${ARGS_OWNER:-}"
PHARMACY_PHONE="${ARGS_PHONE:-}"
MSSQL_SERVER="${ARGS_MSSQL:-192.168.0.201\\PM2014}"
print_ok "명령행 인자에서 약국 정보 로드됨"
else
# 약국명 (필수)
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'}
fi
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")
# VM ID 목록을 파일로 저장 (번호→ID 매핑)
id_list = []
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
id_list.append(str(bid))
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")
# 번호→ID 매핑 파일 저장
with open('/tmp/pbs_vm_ids.txt', 'w') as f:
f.write('\n'.join(id_list))
else:
print("VM 백업이 없습니다.")
with open('/tmp/pbs_vm_ids.txt', 'w') as f:
f.write('')
PYEOF
echo ""
# --skip-pbs: PBS 스킵 / --vm-id: 자동 선택 / 없으면 대화형
if [ "$SKIP_PBS" = true ]; then
TEMPLATE_VMID=""
elif [ -n "$ARGS_VM_ID" ]; then
TEMPLATE_VMID="$ARGS_VM_ID"
print_ok "VM 백업 자동 선택: $TEMPLATE_VMID"
else
echo -ne "${CYAN}복원할 번호 선택 (1~7, Enter로 건너뛰기): ${NC}"
read -r VM_CHOICE </dev/tty || true
if [ -n "${VM_CHOICE:-}" ]; then
# 번호 → 실제 VM ID 변환
TEMPLATE_VMID=$(sed -n "${VM_CHOICE}p" /tmp/pbs_vm_ids.txt 2>/dev/null || echo "")
if [ -z "$TEMPLATE_VMID" ]; then
print_err "잘못된 번호입니다: $VM_CHOICE"
TEMPLATE_VMID=""
else
print_ok "선택: VM $TEMPLATE_VMID"
fi
else
TEMPLATE_VMID=""
fi
fi
if [ -z "${TEMPLATE_VMID:-}" ]; then
print_warn "VM 복원을 건너뜁니다."
VM_VMID=""
print_ok "Phase 4 완료: PBS 등록됨 (VM 복원 스킵)"
return 0
fi
# 복원 VMID
if [ -n "$ARGS_VM_VMID" ]; then
VM_VMID="$ARGS_VM_VMID"
else
echo -ne "${CYAN}복원할 VMID [기본: 200]: ${NC}"
read -r VM_VMID </dev/tty
VM_VMID=${VM_VMID:-200}
fi
# 스토리지 선택
if [ -n "$ARGS_VM_STORAGE" ]; then
TARGET_STORAGE="$ARGS_VM_STORAGE"
elif [ -n "$ARGS_VM_ID" ]; then
# 자동 모드 — 기본 local-lvm
TARGET_STORAGE="local-lvm"
print_ok "스토리지 자동 선택: local-lvm"
else
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}
fi
# 최신 스냅샷 찾기
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' || true
import json, sys
from datetime import datetime
try:
with open('/tmp/pbs_snapshots.json', 'r') as f:
data = json.load(f)
snapshots = data.get("data", [])
if not snapshots:
sys.exit(0)
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"))
except Exception:
pass
PYEOF
)
if [ -z "${LATEST_SNAPSHOT:-}" ]; then
print_err "백업을 찾을 수 없습니다. (PBS 연결 또는 백업 ID 확인 필요)"
print_warn "VM 복원을 건너뛰고 계속 진행합니다."
VM_VMID=""
print_ok "Phase 4 완료: PBS 등록됨 (VM 복원 스킵)"
return 0
fi
print_ok "최신 백업: $LATEST_SNAPSHOT"
# 기존 VM 확인
if qm status "$VM_VMID" 2>/dev/null; then
print_warn "VMID $VM_VMID가 이미 존재합니다."
if [ -n "$ARGS_VM_ID" ]; then
# 자동 모드 — 기존 VM 자동 삭제
confirm="y"
else
echo -ne "${YELLOW}삭제하고 복원하시겠습니까? (y/N): ${NC}"
read -r confirm </dev/tty
fi
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 "$@"