1248 lines
47 KiB
Bash
1248 lines
47 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/10: 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/10: 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/10: 약국 정보 수집"
|
|
|
|
# 명령행 인자가 있으면 사용
|
|
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
|
|
# PVE의 Tailscale IP로 이미 등록된 약국인지 조회
|
|
local EXISTING_PHARMACY=""
|
|
if [ -n "$PVE_VPN_IP" ]; then
|
|
print_step "등록된 약국 조회 중... (VPN IP: $PVE_VPN_IP)"
|
|
EXISTING_PHARMACY=$(curl -s --connect-timeout 5 "${FARMQ_API}/api/pharmacy/search?vpn_ip=${PVE_VPN_IP}" 2>/dev/null || true)
|
|
fi
|
|
|
|
# success:true 인지 확인
|
|
if echo "$EXISTING_PHARMACY" | grep -q '"success": true\|"success":true' 2>/dev/null; then
|
|
# 기존 약국 정보 로드
|
|
local EXIST_NAME EXIST_CODE
|
|
EXIST_NAME=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy']['pharmacy_name'])" 2>/dev/null || true)
|
|
EXIST_CODE=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy']['pharmacy_code'])" 2>/dev/null || true)
|
|
|
|
echo ""
|
|
echo -e "${GREEN}이 PVE에 등록된 약국을 찾았습니다:${NC}"
|
|
echo -e " 약국명: ${WHITE}${EXIST_NAME}${NC}"
|
|
echo -e " 코드: ${WHITE}${EXIST_CODE}${NC}"
|
|
echo ""
|
|
echo -ne "${CYAN}이 약국 정보를 사용하시겠습니까? (Y/n): ${NC}"
|
|
read -r USE_EXISTING </dev/tty || true
|
|
|
|
if [[ -z "${USE_EXISTING:-}" ]] || [[ "${USE_EXISTING}" =~ ^[Yy]$ ]]; then
|
|
PHARMACY_NAME="$EXIST_NAME"
|
|
PHARMACY_CODE="$EXIST_CODE"
|
|
HIRA_CODE=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy'].get('institution_code','') or '')" 2>/dev/null || true)
|
|
PHARMACY_ADDRESS=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy'].get('address','') or '')" 2>/dev/null || true)
|
|
OWNER_NAME=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy'].get('owner_name','') or '')" 2>/dev/null || true)
|
|
PHARMACY_PHONE=$(echo "$EXISTING_PHARMACY" | python3 -c "import json,sys; print(json.load(sys.stdin)['pharmacy'].get('phone','') or '')" 2>/dev/null || true)
|
|
MSSQL_SERVER="${ARGS_MSSQL:-192.168.0.201\\PM2014}"
|
|
print_ok "기존 약국 정보 로드됨: $PHARMACY_CODE ($PHARMACY_NAME)"
|
|
|
|
echo ""
|
|
print_info "약국명: $PHARMACY_NAME"
|
|
print_info "코드: $PHARMACY_CODE"
|
|
print_info "MSSQL: $MSSQL_SERVER"
|
|
print_ok "Phase 3 완료: 약국 정보 수집됨"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# 신규 약국 — 대화형 입력
|
|
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/10: PBS 등록 + Windows VM 복원"
|
|
|
|
# 이미 VM이 존재하면 스킵
|
|
local TARGET_CHECK="${ARGS_VM_VMID:-201}"
|
|
if qm status "$TARGET_CHECK" 2>/dev/null | grep -q "running\|stopped"; then
|
|
VM_VMID="$TARGET_CHECK"
|
|
print_ok "VM $VM_VMID 이미 존재 — PBS 복원 스킵"
|
|
return 0
|
|
fi
|
|
|
|
# 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 인증 실패 (PBS 서버 연결 확인 필요)"
|
|
print_warn "VM 복원을 건너뛰고 계속 진행합니다."
|
|
VM_VMID=""
|
|
return 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/10: Ubuntu CT 생성"
|
|
|
|
# 이미 ubuntu-api CT가 존재하면 스킵
|
|
for vmid in $(seq 200 299); do
|
|
if pct status "$vmid" 2>/dev/null | grep -q "running\|stopped"; then
|
|
local ct_hostname
|
|
ct_hostname=$(pct config "$vmid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
|
|
if [ "$ct_hostname" = "ubuntu-api" ]; then
|
|
CT_VMID="$vmid"
|
|
CT_LAN_IP=$(pct config "$vmid" 2>/dev/null | grep "^net0:" | sed 's/.*ip=\([^/]*\).*/\1/' || true)
|
|
print_ok "CT $CT_VMID (ubuntu-api) 이미 존재 — 생성 스킵"
|
|
# CT가 꺼져있으면 시작
|
|
if pct status "$vmid" 2>/dev/null | grep -q "stopped"; then
|
|
print_step "CT 시작 중..."
|
|
pct start "$vmid"
|
|
sleep 5
|
|
fi
|
|
return 0
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# 템플릿 준비
|
|
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/10: CT 내부 환경 구축"
|
|
|
|
# 헬퍼: CT 내부에서 명령 실행
|
|
ct_exec() {
|
|
pct exec "$CT_VMID" -- bash -c "$1"
|
|
}
|
|
|
|
# 이미 CT에 Tailscale이 등록되어있고 API 코드가 있으면 스킵
|
|
local existing_vpn=""
|
|
existing_vpn=$(ct_exec "tailscale ip -4 2>/dev/null" 2>/dev/null | tr -d '[:space:]' || true)
|
|
if [ -n "$existing_vpn" ] && ct_exec "test -d /srv/person-lookup-web-local" 2>/dev/null; then
|
|
CT_VPN_IP="$existing_vpn"
|
|
print_ok "CT 환경 이미 구축됨 (VPN: $CT_VPN_IP) — 스킵"
|
|
return 0
|
|
fi
|
|
|
|
# 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/10: 약국 + 장비 + 계정 등록"
|
|
|
|
# 이미 약국 코드가 있으면 (Phase 3에서 기존 약국 로드됨) 등록 스킵
|
|
if [ -n "${PHARMACY_CODE:-}" ]; then
|
|
print_ok "약국 이미 등록됨: $PHARMACY_CODE ($PHARMACY_NAME) — 등록 스킵"
|
|
# Tailscale hostname만 업데이트
|
|
tailscale set --hostname="${PHARMACY_CODE}-pve-pharmq" 2>/dev/null || true
|
|
if [ -n "${CT_VMID:-}" ]; then
|
|
pct exec "$CT_VMID" -- bash -c "tailscale set --hostname=${PHARMACY_CODE}-ubuntu-api" 2>/dev/null || true
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# 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"
|
|
print_warn "Phase 7을 건너뛰고 계속 진행합니다."
|
|
return 1
|
|
fi
|
|
else
|
|
print_err "약국 생성 실패"
|
|
print_info "응답: $PHARMACY_RESPONSE"
|
|
print_warn "Phase 7을 건너뛰고 계속 진행합니다."
|
|
return 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: RDP 자동 연결 설정
|
|
# ============================================================
|
|
phase8_rdp_setup() {
|
|
print_phase "Phase 8/10: RDP 자동 연결 설정"
|
|
|
|
# 이미 설치되어있으면 스킵
|
|
if systemctl is-active --quiet rdp-toggle-api.service 2>/dev/null && \
|
|
[ -f "/opt/rdp-toggle-api/rdp-toggle-api.py" ]; then
|
|
print_ok "RDP 자동 연결 이미 설치됨 — 스킵"
|
|
return 0
|
|
fi
|
|
|
|
# RDP 서버 주소 (Phase 4의 VM에서 자동 설정)
|
|
local RDP_SERVER="${MSSQL_SERVER%%\\*}" # 192.168.0.201\PM2014 → 192.168.0.201
|
|
local RDP_USERNAME="pqserver"
|
|
local RDP_PASSWORD="pharmq119"
|
|
local LOCAL_USER="rdpuser"
|
|
|
|
print_info "RDP 서버: $RDP_SERVER"
|
|
print_info "RDP 사용자: $RDP_USERNAME"
|
|
|
|
# 패키지 설치
|
|
print_step "RDP 패키지 설치 중 (xorg, openbox, freerdp)..."
|
|
apt-get update -qq
|
|
apt-get install -y xorg openbox unclutter freerdp3-x11 python3 python3-venv >/dev/null 2>&1 || \
|
|
apt-get install -y xorg openbox unclutter freerdp2-x11 python3 python3-venv >/dev/null 2>&1
|
|
print_ok "RDP 패키지 설치 완료"
|
|
|
|
# 사용자 생성
|
|
if ! id "$LOCAL_USER" &>/dev/null; then
|
|
useradd -m -s /bin/bash "$LOCAL_USER"
|
|
fi
|
|
chown -R "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER"
|
|
|
|
# 자동 로그인 설정
|
|
print_step "자동 로그인 + X 자동 시작 설정 중..."
|
|
mkdir -p /etc/systemd/system/getty@tty1.service.d
|
|
cat > /etc/systemd/system/getty@tty1.service.d/override.conf << EOF
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=-/sbin/agetty --autologin $LOCAL_USER --noclear %I \$TERM
|
|
Type=idle
|
|
EOF
|
|
|
|
# .bash_profile — tty1에서 X 자동 시작
|
|
cat > "/home/$LOCAL_USER/.bash_profile" << 'BPEOF'
|
|
if [[ -z $DISPLAY ]] && [[ $(tty) == /dev/tty1 ]]; then
|
|
startx
|
|
logout
|
|
fi
|
|
BPEOF
|
|
chown "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.bash_profile"
|
|
|
|
# .xinitrc — RDP 자동 연결
|
|
local XFREERDP_CMD="xfreerdp3"
|
|
command -v xfreerdp3 >/dev/null 2>&1 || XFREERDP_CMD="xfreerdp"
|
|
|
|
cat > "/home/$LOCAL_USER/.xinitrc" << EOF
|
|
#!/bin/bash
|
|
xset -dpms && xset s off && xset s noblank
|
|
unclutter -idle 0.1 -root &
|
|
openbox-session &
|
|
sleep 2
|
|
$XFREERDP_CMD /v:$RDP_SERVER /u:$RDP_USERNAME /p:"$RDP_PASSWORD" +f /cert:ignore +dynamic-resolution /sound:sys:alsa +clipboard
|
|
pkill -SIGTERM Xorg
|
|
EOF
|
|
chmod +x "/home/$LOCAL_USER/.xinitrc"
|
|
chown "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.xinitrc"
|
|
|
|
# Openbox 설정
|
|
mkdir -p "/home/$LOCAL_USER/.config/openbox"
|
|
cat > "/home/$LOCAL_USER/.config/openbox/rc.xml" << 'OBEOF'
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<openbox_config xmlns="http://openbox.org/3.4/rc">
|
|
<applications>
|
|
<application class="*">
|
|
<decor>no</decor>
|
|
<maximized>yes</maximized>
|
|
</application>
|
|
</applications>
|
|
</openbox_config>
|
|
OBEOF
|
|
chown -R "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.config"
|
|
|
|
# RDP Toggle API 설치
|
|
print_step "RDP Toggle API 설치 중..."
|
|
local INSTALL_DIR="/opt/rdp-toggle-api"
|
|
local VENV_DIR="$INSTALL_DIR/venv"
|
|
local GITEA_BASE_URL="https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP"
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
python3 -m venv "$VENV_DIR"
|
|
"$VENV_DIR/bin/pip" install --upgrade pip -q >/dev/null 2>&1
|
|
"$VENV_DIR/bin/pip" install fastapi==0.115.5 uvicorn==0.32.1 python-multipart==0.0.20 pydantic==2.10.3 -q >/dev/null 2>&1
|
|
curl -fsSL "$GITEA_BASE_URL/rdp-toggle-api.py" -o "$INSTALL_DIR/rdp-toggle-api.py"
|
|
chmod +x "$INSTALL_DIR/rdp-toggle-api.py"
|
|
|
|
cat > /etc/systemd/system/rdp-toggle-api.service << EOF
|
|
[Unit]
|
|
Description=RDP Toggle API Service
|
|
After=network.target
|
|
[Service]
|
|
Type=simple
|
|
User=root
|
|
WorkingDirectory=$INSTALL_DIR
|
|
ExecStart=$VENV_DIR/bin/python $INSTALL_DIR/rdp-toggle-api.py
|
|
Restart=always
|
|
RestartSec=5
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
mkdir -p /var/lib/rdp-toggle
|
|
cat > /var/lib/rdp-toggle/config.json << EOF
|
|
{
|
|
"rdp_server": "$RDP_SERVER",
|
|
"rdp_username": "$RDP_USERNAME",
|
|
"rdp_password": "$RDP_PASSWORD",
|
|
"local_user": "$LOCAL_USER"
|
|
}
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now rdp-toggle-api.service >/dev/null 2>&1
|
|
|
|
print_ok "Phase 8 완료: RDP 자동 연결 설정됨 (API: 포트 8090)"
|
|
}
|
|
|
|
# ============================================================
|
|
# Phase 9: noVNC 웹 서비스 설치
|
|
# ============================================================
|
|
phase9_novnc_setup() {
|
|
print_phase "Phase 9/10: noVNC 웹 서비스 설치 (CT 내부)"
|
|
|
|
if [ -z "${CT_VMID:-}" ]; then
|
|
print_warn "CT VMID가 없어서 noVNC 설치를 건너뜁니다."
|
|
return 1
|
|
fi
|
|
|
|
# CT 내부에서 이미 설치되어있는지 확인
|
|
if pct exec "$CT_VMID" -- test -d /srv/pharmq-novnc 2>/dev/null && \
|
|
pct exec "$CT_VMID" -- systemctl is-active --quiet pharmq-vnc-app.service 2>/dev/null; then
|
|
print_ok "noVNC 이미 설치됨 (CT $CT_VMID) — 스킵"
|
|
return 0
|
|
fi
|
|
|
|
# PVE host IP 감지 (CT에서 PVE API에 접근할 LAN IP)
|
|
local PVE_LAN_IP
|
|
PVE_LAN_IP=$(hostname -I | awk '{print $1}')
|
|
|
|
# CT 안에서 noVNC 설치 스크립트 실행 (PVE 접속 정보 + 약국 정보 자동 전달)
|
|
print_step "CT $CT_VMID 내부에 noVNC 설치 중..."
|
|
print_info "PVE API: ${PVE_LAN_IP}:8006, 약국: ${PHARMACY_CODE:-미정} (${PHARMACY_NAME:-미정})"
|
|
pct exec "$CT_VMID" -- bash -c "curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh -o /tmp/pharmq-novnc-setup.sh && chmod +x /tmp/pharmq-novnc-setup.sh && bash /tmp/pharmq-novnc-setup.sh --pve-host ${PVE_LAN_IP} --pve-password trajet6640 --pharmacy-code ${PHARMACY_CODE:-P0000} --pharmacy-name '${PHARMACY_NAME:-미정}'; rm -f /tmp/pharmq-novnc-setup.sh" || true
|
|
|
|
print_ok "Phase 9 완료: noVNC 설치 (CT $CT_VMID)"
|
|
}
|
|
|
|
# ============================================================
|
|
# Phase 10: 검증 + 결과 출력
|
|
# ============================================================
|
|
phase10_verify() {
|
|
print_phase "Phase 10/10: 검증 + 결과 출력"
|
|
|
|
# API 헬스체크
|
|
print_step "API 서버 헬스체크 중..."
|
|
sleep 5
|
|
local API_STATUS
|
|
local API_STATUS=""
|
|
API_STATUS=$(curl -s --connect-timeout 10 "http://${CT_VPN_IP:-localhost}: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
|
|
|
|
# 네트워크 테스트 (tailscale ping 사용 — 일반 ping은 iptables에서 DROP될 수 있음)
|
|
print_step "VPN 네트워크 테스트 중..."
|
|
if tailscale ping -c 1 --timeout 5s "$CT_VPN_IP" >/dev/null 2>&1; then
|
|
print_ok "PVE → CT 네트워크 정상"
|
|
else
|
|
print_warn "PVE → CT tailscale 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 ""
|
|
|
|
if systemctl is-active --quiet rdp-toggle-api.service 2>/dev/null; then
|
|
echo -e "${WHITE}[RDP 자동 연결]${NC}"
|
|
echo -e " RDP API: http://${PVE_VPN_IP:-localhost}:8090"
|
|
echo -e " RDP 서버: ${MSSQL_SERVER%%\\*}"
|
|
echo ""
|
|
fi
|
|
|
|
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:-200} # CT 접속"
|
|
echo " pct exec ${CT_VMID:-200} -- 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
|
|
}
|
|
|
|
# ============================================================
|
|
# 에러 핸들링
|
|
# ============================================================
|
|
error_handler() {
|
|
local exit_code=$?
|
|
local line_no=$1
|
|
echo ""
|
|
echo -e "${RED}════════════════════════════════════════════${NC}"
|
|
echo -e "${RED}❌ 오류 발생 (line ${line_no}, exit code ${exit_code})${NC}"
|
|
echo -e "${RED}════════════════════════════════════════════${NC}"
|
|
echo -e "${YELLOW}현재까지 진행된 정보:${NC}"
|
|
[ -n "${PHARMACY_NAME:-}" ] && echo -e " 약국명: ${PHARMACY_NAME}"
|
|
[ -n "${PVE_VPN_IP:-}" ] && echo -e " PVE VPN IP: ${PVE_VPN_IP}"
|
|
[ -n "${CT_VMID:-}" ] && echo -e " CT VMID: ${CT_VMID}"
|
|
[ -n "${CT_VPN_IP:-}" ] && echo -e " CT VPN IP: ${CT_VPN_IP}"
|
|
[ -n "${VM_VMID:-}" ] && echo -e " VM VMID: ${VM_VMID}"
|
|
[ -n "${PHARMACY_CODE:-}" ] && echo -e " 약국 코드: ${PHARMACY_CODE}"
|
|
echo ""
|
|
echo -e "${YELLOW}이 정보를 개발자에게 전달해주세요.${NC}"
|
|
exit 1
|
|
}
|
|
trap 'error_handler $LINENO' 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→VPN→PBS→CT→등록→RDP→VNC${NC} ${PURPLE}║${NC}"
|
|
echo -e "${PURPLE}║ ║${NC}"
|
|
echo -e "${PURPLE}╚════════════════════════════════════════════╝${NC}"
|
|
echo ""
|
|
|
|
phase1_repo_fix
|
|
phase2_tailscale_pve
|
|
phase3_collect_info
|
|
phase4_pbs_restore || print_warn "Phase 4 실패 — 계속 진행"
|
|
phase5_create_ct
|
|
phase6_setup_ct
|
|
phase7_register || print_warn "Phase 7 실패 — 수동 등록 필요"
|
|
phase8_rdp_setup || print_warn "Phase 8 실패 — RDP 수동 설정 필요"
|
|
phase9_novnc_setup || print_warn "Phase 9 실패 — noVNC 수동 설치 필요"
|
|
phase10_verify
|
|
}
|
|
|
|
main "$@"
|