diff --git a/itsm/tools/db_init.py b/itsm/tools/db_init.py
index 01b33bc5..ede879e2 100644
--- a/itsm/tools/db_init.py
+++ b/itsm/tools/db_init.py
@@ -112,5 +112,16 @@ async def main():
print("[OK] DB 초기화 완료")
+def _copy_offline_assets():
+ """폐쇄망 정적 파일 복사 (있는 경우)."""
+ import shutil
+ offline_chart = ITSM_DIR.parent / "setup" / "offline" / "common" / "chart.umd.min.js"
+ target_chart = ITSM_DIR / "static" / "chart.umd.min.js"
+ if offline_chart.exists() and not target_chart.exists():
+ shutil.copy2(offline_chart, target_chart)
+ print(f"[OK] Chart.js 오프라인 파일 복사: {target_chart}")
+
+
if __name__ == "__main__":
+ _copy_offline_assets()
asyncio.run(main())
diff --git a/setup/download_packages.sh b/setup/download_packages.sh
new file mode 100644
index 00000000..4be4db3f
--- /dev/null
+++ b/setup/download_packages.sh
@@ -0,0 +1,375 @@
+#!/bin/bash
+# ============================================================
+# GUARDiA 폐쇄망 설치 패키지 다운로드 스크립트
+# ============================================================
+# 인터넷이 연결된 Linux 서버에서 실행하세요.
+# 모든 필요 파일을 setup/offline/ 에 다운로드합니다.
+#
+# 사용법:
+# bash setup/download_packages.sh [OS유형]
+# bash setup/download_packages.sh ubuntu # Ubuntu 20/22/24
+# bash setup/download_packages.sh centos # CentOS 7/8/Stream
+# bash setup/download_packages.sh rhel # RHEL 8/9
+# bash setup/download_packages.sh all # 세 OS 모두
+#
+# 결과 디렉토리:
+# setup/offline/
+# ├── ubuntu/ deb 패키지 + pip 휠 + 바이너리
+# ├── centos/ rpm 패키지 + pip 휠 + 바이너리
+# ├── rhel/ rpm 패키지 + pip 휠 + 바이너리
+# ├── common/ 공통 바이너리 (Tomcat, Ollama, Chart.js 등)
+# └── python/ pip 휠 파일 (OS 공통)
+# ============================================================
+
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+GUARDIA_ROOT="$(dirname "$SCRIPT_DIR")"
+OFFLINE_DIR="$SCRIPT_DIR/offline"
+OS_TYPE="${1:-all}"
+
+TOMCAT_VER="${TOMCAT_VER:-9.0.98}"
+PYTHON_VER="${PYTHON_VER:-3.11}"
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
+ok() { echo -e "${GREEN}[OK]${NC} $*"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
+fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
+info() { echo -e " $*"; }
+
+echo "=================================================="
+echo " GUARDiA 폐쇄망 패키지 다운로드"
+echo " 대상 OS: $OS_TYPE"
+echo " 저장 위치: $OFFLINE_DIR"
+echo "=================================================="
+
+mkdir -p "$OFFLINE_DIR/common" "$OFFLINE_DIR/python"
+
+# ── 공통 바이너리 다운로드 ──────────────────────────────
+
+download_common() {
+ echo ""
+ echo "=== 공통 바이너리 다운로드 ==="
+
+ # Tomcat 9
+ TOMCAT_URL="https://archive.apache.org/dist/tomcat/tomcat-9/v${TOMCAT_VER}/bin/apache-tomcat-${TOMCAT_VER}.tar.gz"
+ if [[ ! -f "$OFFLINE_DIR/common/apache-tomcat-${TOMCAT_VER}.tar.gz" ]]; then
+ echo " Tomcat $TOMCAT_VER 다운로드..."
+ wget -q "$TOMCAT_URL" -O "$OFFLINE_DIR/common/apache-tomcat-${TOMCAT_VER}.tar.gz" \
+ && ok "Tomcat $TOMCAT_VER" || warn "Tomcat 다운로드 실패"
+ else
+ info "Tomcat $TOMCAT_VER 이미 존재"
+ fi
+
+ # Ollama Linux AMD64
+ OLLAMA_VER="${OLLAMA_VER:-latest}"
+ if [[ ! -f "$OFFLINE_DIR/common/ollama-linux-amd64" ]]; then
+ echo " Ollama 바이너리 다운로드..."
+ wget -q "https://ollama.com/download/ollama-linux-amd64" \
+ -O "$OFFLINE_DIR/common/ollama-linux-amd64" \
+ && chmod +x "$OFFLINE_DIR/common/ollama-linux-amd64" \
+ && ok "Ollama binary" \
+ || warn "Ollama 다운로드 실패"
+ else
+ info "Ollama 바이너리 이미 존재"
+ fi
+
+ # Chart.js (오프라인 대시보드용)
+ if [[ ! -f "$OFFLINE_DIR/common/chart.umd.min.js" ]]; then
+ echo " Chart.js 다운로드..."
+ wget -q "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" \
+ -O "$OFFLINE_DIR/common/chart.umd.min.js" \
+ && ok "Chart.js 4.4.0" || warn "Chart.js 다운로드 실패"
+ fi
+
+ # NSSM (Windows 서비스 관리자)
+ if [[ ! -f "$OFFLINE_DIR/common/nssm-2.24.zip" ]]; then
+ echo " NSSM 다운로드..."
+ wget -q "https://nssm.cc/release/nssm-2.24.zip" \
+ -O "$OFFLINE_DIR/common/nssm-2.24.zip" \
+ && ok "NSSM 2.24" || warn "NSSM 다운로드 실패"
+ fi
+}
+
+# ── Python 패키지 (pip wheel) ───────────────────────────
+
+download_python_packages() {
+ echo ""
+ echo "=== Python 패키지 다운로드 (pip wheel) ==="
+
+ if [[ ! -d "$OFFLINE_DIR/python/wheels" ]]; then
+ mkdir -p "$OFFLINE_DIR/python/wheels"
+ echo " requirements.txt 기반 wheel 다운로드..."
+ pip download \
+ -r "$GUARDIA_ROOT/itsm/requirements.txt" \
+ -d "$OFFLINE_DIR/python/wheels" \
+ --platform manylinux2014_x86_64 \
+ --python-version 311 \
+ --implementation cp \
+ --abi cp311 \
+ --only-binary=:all: \
+ 2>/dev/null || true
+
+ # 일부 패키지는 소스 빌드 필요 — 아무 플랫폼 없이 재시도
+ pip download \
+ -r "$GUARDIA_ROOT/itsm/requirements.txt" \
+ -d "$OFFLINE_DIR/python/wheels" \
+ 2>/dev/null || true
+
+ ok "Python 패키지 다운로드 완료 ($(ls "$OFFLINE_DIR/python/wheels" | wc -l)개)"
+ else
+ info "Python 패키지 이미 존재 ($(ls "$OFFLINE_DIR/python/wheels" | wc -l)개)"
+ fi
+}
+
+# ── Ubuntu/Debian 패키지 ────────────────────────────────
+
+download_ubuntu() {
+ echo ""
+ echo "=== Ubuntu .deb 패키지 다운로드 ==="
+
+ if ! command -v apt-get &>/dev/null; then
+ warn "apt-get 없음 — Ubuntu/Debian 환경에서 실행하거나 Docker로 다운로드하세요."
+ warn " docker run --rm -v $OFFLINE_DIR/ubuntu:/downloads ubuntu:22.04 bash -c \\"
+ warn " 'apt-get update && apt-get install -y --download-only -o Dir::Cache::Archives=/downloads [패키지목록]'"
+ return
+ fi
+
+ UBUNTU_DIR="$OFFLINE_DIR/ubuntu"
+ mkdir -p "$UBUNTU_DIR"
+
+ apt-get update -qq
+
+ PACKAGES=(
+ python3.11 python3.11-venv python3.11-dev python3-pip
+ openjdk-17-jdk
+ openjdk-11-jdk
+ openjdk-8-jdk-headless
+ postgresql postgresql-contrib
+ redis-server
+ nginx
+ fail2ban
+ chrony
+ curl wget git
+ lsof unzip jq
+ libpq-dev gcc
+ )
+
+ echo " 패키지 다운로드 중 (${#PACKAGES[@]}개)..."
+ apt-get install -y --download-only \
+ -o Dir::Cache::Archives="$UBUNTU_DIR" \
+ "${PACKAGES[@]}" 2>/dev/null \
+ && ok "Ubuntu .deb 다운로드 완료 ($(ls "$UBUNTU_DIR"/*.deb 2>/dev/null | wc -l)개)" \
+ || warn "일부 패키지 다운로드 실패 — 이미 설치된 패키지는 아래로 수동 복사:"
+
+ # 이미 설치된 패키지의 캐시 복사
+ find /var/cache/apt/archives -name "*.deb" 2>/dev/null | while read -r f; do
+ cp -n "$f" "$UBUNTU_DIR/" 2>/dev/null || true
+ done
+
+ # 오프라인 설치 스크립트 생성
+ cat > "$UBUNTU_DIR/install_offline.sh" << 'UBSCRIPT'
+#!/bin/bash
+# Ubuntu 오프라인 패키지 설치
+set -e
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+echo "Ubuntu 패키지 오프라인 설치 중..."
+dpkg -i "$SCRIPT_DIR"/*.deb 2>/dev/null || true
+apt-get install -f -y 2>/dev/null || true
+echo "완료"
+UBSCRIPT
+ chmod +x "$UBUNTU_DIR/install_offline.sh"
+ ok "Ubuntu 오프라인 설치 스크립트 생성"
+}
+
+# ── CentOS/RHEL rpm 패키지 ──────────────────────────────
+
+download_centos() {
+ echo ""
+ echo "=== CentOS/RHEL .rpm 패키지 다운로드 ==="
+
+ if ! command -v dnf &>/dev/null && ! command -v yum &>/dev/null; then
+ warn "dnf/yum 없음 — CentOS/RHEL 환경에서 실행하거나 Docker로 다운로드하세요."
+ warn " docker run --rm -v $OFFLINE_DIR/centos:/downloads centos:stream9 bash -c \\"
+ warn " 'dnf download --downloaddir=/downloads --resolve [패키지목록]'"
+ return
+ fi
+
+ CENTOS_DIR="$OFFLINE_DIR/centos"
+ mkdir -p "$CENTOS_DIR"
+
+ PACKAGES=(
+ python3.11 python3.11-devel python3-pip
+ java-17-openjdk java-17-openjdk-devel
+ java-11-openjdk
+ java-1.8.0-openjdk
+ postgresql-server postgresql-contrib
+ redis
+ nginx
+ fail2ban
+ chrony
+ curl wget git
+ lsof unzip jq
+ postgresql-devel gcc
+ )
+
+ PKG_MGR="dnf"
+ command -v dnf &>/dev/null || PKG_MGR="yum"
+
+ echo " 패키지 다운로드 중..."
+ $PKG_MGR install -y --downloadonly \
+ --downloaddir="$CENTOS_DIR" \
+ "${PACKAGES[@]}" 2>/dev/null \
+ && ok "CentOS/RHEL .rpm 다운로드 완료 ($(ls "$CENTOS_DIR"/*.rpm 2>/dev/null | wc -l)개)" \
+ || warn "일부 패키지 다운로드 실패"
+
+ cat > "$CENTOS_DIR/install_offline.sh" << 'RPMSCRIPT'
+#!/bin/bash
+# CentOS/RHEL 오프라인 패키지 설치
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+echo "RPM 패키지 오프라인 설치 중..."
+dnf install -y "$SCRIPT_DIR"/*.rpm --disablerepo='*' 2>/dev/null || \
+yum install -y "$SCRIPT_DIR"/*.rpm 2>/dev/null || \
+rpm -ivh --nodeps "$SCRIPT_DIR"/*.rpm 2>/dev/null || true
+echo "완료"
+RPMSCRIPT
+ chmod +x "$CENTOS_DIR/install_offline.sh"
+ ok "CentOS/RHEL 오프라인 설치 스크립트 생성"
+}
+
+# ── Ollama 모델 다운로드 ─────────────────────────────────
+
+download_ollama_models() {
+ echo ""
+ echo "=== Ollama 모델 다운로드 ==="
+
+ MODELS_DIR="$OFFLINE_DIR/common/ollama-models"
+ mkdir -p "$MODELS_DIR"
+
+ MODELS=( "llama3.1:8b" "codellama:7b" )
+
+ if command -v ollama &>/dev/null; then
+ for model in "${MODELS[@]}"; do
+ echo " 모델 다운로드: $model"
+ ollama pull "$model" 2>&1 | tail -3 || warn "$model 다운로드 실패"
+ done
+
+ # 모델 파일 압축
+ OLLAMA_HOME="${OLLAMA_MODELS:-$HOME/.ollama}"
+ if [[ -d "$OLLAMA_HOME/models" ]]; then
+ echo " 모델 파일 압축 중..."
+ tar -czf "$MODELS_DIR/models.tar.gz" \
+ -C "$OLLAMA_HOME" models manifests 2>/dev/null \
+ && ok "Ollama 모델 압축 완료 ($(du -sh "$MODELS_DIR/models.tar.gz" | cut -f1))" \
+ || warn "모델 압축 실패"
+ fi
+ else
+ warn "Ollama 미설치 — 모델 다운로드 건너뜀"
+ info "설치 후 수동 실행: OLLAMA_MODELS_PATH=$MODELS_DIR ollama pull llama3.1:8b"
+ fi
+}
+
+# ── Docker 이미지 패키지 ─────────────────────────────────
+
+download_docker_images() {
+ echo ""
+ echo "=== Docker 이미지 패키지 ==="
+
+ if command -v docker &>/dev/null; then
+ bash "$SCRIPT_DIR/docker_package.sh" "$OFFLINE_DIR/docker"
+ ok "Docker 이미지 패키지 완료"
+ else
+ warn "Docker 미설치 — docker_package.sh를 Docker가 설치된 서버에서 별도 실행하세요."
+ fi
+}
+
+# ── 설치 인덱스 생성 ─────────────────────────────────────
+
+create_index() {
+ echo ""
+ echo "=== 설치 인덱스 생성 ==="
+
+ cat > "$OFFLINE_DIR/README.md" << RDEOF
+# GUARDiA 폐쇄망 설치 패키지
+
+생성 일시: $(date)
+
+## 디렉토리 구조
+
+\`\`\`
+offline/
+├── common/ # 공통 바이너리
+│ ├── apache-tomcat-${TOMCAT_VER}.tar.gz
+│ ├── ollama-linux-amd64
+│ ├── chart.umd.min.js
+│ └── ollama-models/models.tar.gz
+├── ubuntu/ # Ubuntu .deb 패키지
+│ └── install_offline.sh
+├── centos/ # CentOS/RHEL .rpm 패키지
+│ └── install_offline.sh
+├── python/
+│ └── wheels/ # pip wheel 파일
+└── docker/ # Docker 이미지 tar (있는 경우)
+\`\`\`
+
+## 폐쇄망 설치 절차
+
+### Ubuntu
+\`\`\`bash
+# 1. OS 패키지
+bash offline/ubuntu/install_offline.sh
+
+# 2. Python 패키지
+pip install --no-index --find-links=offline/python/wheels -r itsm/requirements.txt
+
+# 3. Tomcat
+TOMCAT_MIRROR=file://\$(pwd)/offline/common bash setup/setup_ubuntu.sh
+\`\`\`
+
+### CentOS/RHEL
+\`\`\`bash
+bash offline/centos/install_offline.sh
+pip install --no-index --find-links=offline/python/wheels -r itsm/requirements.txt
+TOMCAT_MIRROR=file://\$(pwd)/offline/common bash setup/setup_centos.sh
+\`\`\`
+
+### Docker (Docker 설치된 서버)
+\`\`\`bash
+bash offline/docker/guardia-docker-*/docker_load.sh --start
+\`\`\`
+RDEOF
+
+ ok "설치 인덱스 생성: $OFFLINE_DIR/README.md"
+}
+
+# ── 메인 실행 ────────────────────────────────────────────
+
+download_common
+download_python_packages
+
+case "$OS_TYPE" in
+ ubuntu)
+ download_ubuntu ;;
+ centos|rhel)
+ download_centos ;;
+ all)
+ download_ubuntu
+ download_centos ;;
+ docker)
+ download_docker_images ;;
+ *)
+ warn "알 수 없는 OS 유형: $OS_TYPE. ubuntu|centos|rhel|all|docker 중 선택하세요." ;;
+esac
+
+download_ollama_models
+create_index
+
+echo ""
+echo "=================================================="
+ok "다운로드 완료!"
+echo ""
+info "저장 위치: $OFFLINE_DIR"
+info "전체 크기: $(du -sh "$OFFLINE_DIR" | cut -f1)"
+echo ""
+info "패키지 압축 (USB 복사용):"
+info " tar -czf guardia-offline-$(date +%Y%m%d).tar.gz -C setup offline/"
+echo "=================================================="
diff --git a/setup/setup_centos.sh b/setup/setup_centos.sh
index 326ba7ca..4eae5e9c 100644
--- a/setup/setup_centos.sh
+++ b/setup/setup_centos.sh
@@ -5,9 +5,12 @@
# 전제조건: 순수 CentOS OS (최소 설치)
# 실행 방법: sudo bash setup_centos.sh
# 설치 테스트: bash setup_centos.sh --test
-# 환경변수:
-# TOMCAT_VER=9.0.98 : Tomcat 버전
-# TOMCAT_MIRROR=http:// : 내부 미러 URL (오프라인 환경)
+# 환경변수 (오프라인/폐쇄망):
+# TOMCAT_VER=9.0.98 : Tomcat 버전
+# TOMCAT_MIRROR=file://$(pwd)/setup/offline/common : 로컬 파일
+# OFFLINE_PKG_DIR=./setup/offline/centos : .rpm 패키지 디렉토리
+# OLLAMA_INSTALL=offline
+# OLLAMA_BIN_PATH=./setup/offline/common/ollama-linux-amd64
# =============================================================
set -euo pipefail
@@ -74,6 +77,15 @@ fi
# ── 1. 시스템 패키지 ─────────────────────────────────────────
echo ""
echo "[1/10] 시스템 패키지 설치..."
+# 오프라인 RPM 패키지 디렉토리 사용 여부
+OFFLINE_PKG_DIR="${OFFLINE_PKG_DIR:-}"
+if [[ -n "$OFFLINE_PKG_DIR" && -d "$OFFLINE_PKG_DIR" ]]; then
+ info "오프라인 모드: $OFFLINE_PKG_DIR"
+ dnf install -y "$OFFLINE_PKG_DIR"/*.rpm --disablerepo='*' 2>/dev/null || \
+ rpm -ivh --nodeps "$OFFLINE_PKG_DIR"/*.rpm 2>/dev/null || true
+ ok "오프라인 RPM 설치 완료"
+fi
+
yum install -y epel-release 2>/dev/null || dnf install -y epel-release 2>/dev/null || true
if [[ "$OS_VER" -ge 8 ]]; then
diff --git a/setup/setup_ubuntu.sh b/setup/setup_ubuntu.sh
index c9adfd51..18010735 100644
--- a/setup/setup_ubuntu.sh
+++ b/setup/setup_ubuntu.sh
@@ -5,9 +5,13 @@
# 전제조건: 순수 Ubuntu OS (최소 설치)
# 실행 방법: sudo bash setup_ubuntu.sh
# 설치 테스트: bash setup_ubuntu.sh --test
-# 환경변수 (오프라인 환경):
-# TOMCAT_VER=9.0.98 : Tomcat 버전 (기본 9.0.98)
-# TOMCAT_MIRROR=http:// : 내부 미러 URL (기본 apache.org)
+# 환경변수 (오프라인/폐쇄망 환경):
+# TOMCAT_VER=9.0.98 : Tomcat 버전 (기본 9.0.98)
+# TOMCAT_MIRROR=http://... : 내부 미러 URL (기본 apache.org)
+# TOMCAT_MIRROR=file://$(pwd)/setup/offline/common : 로컬 파일 사용
+# OLLAMA_INSTALL=offline : 오프라인 바이너리 설치
+# OLLAMA_BIN_PATH=./setup/offline/common/ollama-linux-amd64
+# OFFLINE_PKG_DIR=./setup/offline/ubuntu : .deb 패키지 디렉토리
# =============================================================
set -euo pipefail
@@ -73,7 +77,18 @@ fi
# ── 1. 시스템 패키지 업데이트 ────────────────────────────────
echo ""
echo "[1/10] 시스템 패키지 업데이트..."
-apt-get update -qq
+
+# 오프라인 패키지 디렉토리 사용 여부 확인
+OFFLINE_PKG_DIR="${OFFLINE_PKG_DIR:-}"
+if [[ -n "$OFFLINE_PKG_DIR" && -d "$OFFLINE_PKG_DIR" ]]; then
+ info "오프라인 모드: $OFFLINE_PKG_DIR"
+ dpkg -i "$OFFLINE_PKG_DIR"/*.deb 2>/dev/null || true
+ apt-get install -f -y -qq 2>/dev/null || true
+ ok "오프라인 패키지 설치 완료"
+else
+ apt-get update -qq
+fi
+
apt-get install -y -qq \
curl wget git build-essential libssl-dev libffi-dev \
python3.11 python3.11-venv python3.11-dev python3-pip \