#!/usr/bin/env python3 """ GUARDiA 개방망 가이드 — PDF + PPTX 자동 생성 출력: manual/23_GUARDiA_개방망_가이드.pdf manual/24_GUARDiA_개방망_발표자료.pptx """ import os, sys from pathlib import Path OUT_DIR = Path(__file__).parent FONT_PATH = None # None이면 기본 폰트 사용 # ══════════════════════════════════════════════════════════════ # PDF 생성 # ══════════════════════════════════════════════════════════════ def gen_pdf(output_path: str): from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, PageBreak ) from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT # 폰트 등록 (한글 지원) from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont FONT_DIRS = [ "C:/Windows/Fonts", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype/dejavu", "/System/Library/Fonts", ] FONT_CANDIDATES = [ ("malgun.ttf", "Malgun"), ("NanumGothic.ttf", "NanumGothic"), ("DejaVuSans.ttf", "DejaVuSans"), ] font_name = "Helvetica" for fname, alias in FONT_CANDIDATES: for d in FONT_DIRS: fp = os.path.join(d, fname) if os.path.exists(fp): try: pdfmetrics.registerFont(TTFont(alias, fp)) font_name = alias break except Exception: pass if font_name != "Helvetica": break # ── 색상 정의 ────────────────────────────────────────────── BRAND_BLUE = colors.HexColor("#1a3a6b") ACCENT_BLUE = colors.HexColor("#4f6ef7") LIGHT_BLUE = colors.HexColor("#e8ecff") GRAY_BG = colors.HexColor("#f0f2f5") SUCCESS_GRN = colors.HexColor("#22c55e") WARNING_ORG = colors.HexColor("#f59e0b") DANGER_RED = colors.HexColor("#ef4444") TEXT_DARK = colors.HexColor("#1e293b") TEXT_MUTED = colors.HexColor("#64748b") doc = SimpleDocTemplate( output_path, pagesize=A4, leftMargin=20*mm, rightMargin=20*mm, topMargin=20*mm, bottomMargin=20*mm ) styles = getSampleStyleSheet() def sty(name, **kw): base = kw.pop("base", "Normal") kw.setdefault("fontName", font_name) s = ParagraphStyle(name, parent=styles[base], **kw) return s S = { "cover_title": sty("ct", fontSize=28, textColor=colors.white, alignment=TA_CENTER, spaceAfter=6, leading=36), "cover_sub": sty("cs", fontSize=14, textColor=LIGHT_BLUE, alignment=TA_CENTER, spaceAfter=4), "cover_meta": sty("cm", fontSize=10, textColor=colors.HexColor("#aab4c8"), alignment=TA_CENTER), "h1": sty("h1", fontSize=18, textColor=BRAND_BLUE, spaceAfter=8, spaceBefore=16, leading=24, fontName=font_name), "h2": sty("h2", fontSize=13, textColor=ACCENT_BLUE, spaceAfter=5, spaceBefore=10, leading=18), "h3": sty("h3", fontSize=11, textColor=TEXT_DARK, spaceAfter=4, spaceBefore=8, leading=16), "body": sty("body", fontSize=10, textColor=TEXT_DARK, leading=16, spaceAfter=4), "code": sty("code", fontName="Courier", fontSize=9, backColor=GRAY_BG, textColor=colors.HexColor("#1d4ed8"), leading=14, leftIndent=10, spaceBefore=4, spaceAfter=4), "note": sty("note", fontSize=9, textColor=TEXT_MUTED, leftIndent=10, leading=14, spaceAfter=3), "badge_ok": sty("bok", fontSize=9, textColor=SUCCESS_GRN, fontName=font_name), "badge_warn": sty("bwrn", fontSize=9, textColor=WARNING_ORG, fontName=font_name), } story = [] W = A4[0] - 40*mm def hr(color=ACCENT_BLUE, w=1): return HRFlowable(width="100%", thickness=w, color=color, spaceAfter=4, spaceBefore=4) def table(data, col_widths=None, header=True): t = Table(data, colWidths=col_widths) base_style = [ ("FONTNAME", (0,0), (-1,-1), font_name), ("FONTSIZE", (0,0), (-1,-1), 9), ("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, GRAY_BG]), ("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#e2e8f0")), ("VALIGN", (0,0), (-1,-1), "MIDDLE"), ("LEFTPADDING", (0,0), (-1,-1), 6), ("RIGHTPADDING", (0,0), (-1,-1), 6), ("TOPPADDING", (0,0), (-1,-1), 5), ("BOTTOMPADDING",(0,0), (-1,-1), 5), ] if header: base_style += [ ("BACKGROUND", (0,0), (-1,0), BRAND_BLUE), ("TEXTCOLOR", (0,0), (-1,0), colors.white), ("FONTSIZE", (0,0), (-1,0), 9), ("FONTNAME", (0,0), (-1,0), font_name), ] t.setStyle(TableStyle(base_style)) return t # ── 표지 ──────────────────────────────────────────────────── from reportlab.platypus import KeepTogether cover_bg = Table( [[Paragraph("GUARDiA ITSM", S["cover_title"]),], [Paragraph("개방망(Open Network) 구현 가이드", S["cover_sub"])], [Paragraph(" ", S["cover_sub"])], [Paragraph("v2.0.0 | 2026-05-30 | (주)지오정보기술", S["cover_meta"])], [Paragraph("서버: zioinfo.co.kr | AI 기반 레거시 인프라 자율 운영 플랫폼", S["cover_meta"])], ], colWidths=[W] ) cover_bg.setStyle(TableStyle([ ("BACKGROUND", (0,0), (-1,-1), BRAND_BLUE), ("TOPPADDING", (0,0), (-1,-1), 30), ("BOTTOMPADDING",(0,0),(-1,-1), 30), ("LEFTPADDING", (0,0), (-1,-1), 20), ("RIGHTPADDING",(0,0), (-1,-1), 20), ("ROUNDEDCORNERS", [8]), ])) story += [Spacer(1, 30*mm), cover_bg, Spacer(1, 10*mm)] # 목차 카드 toc_data = [ ["순서", "섹션", "페이지"], ["1", "개요 및 배경", "2"], ["2", "아키텍처", "2"], ["3", "구현 내용", "3"], ["4", "설치 및 설정", "4"], ["5", "API 사용법", "5"], ["6", "보안 설정", "6"], ["7", "테스트 결과", "7"], ["8", "운영 절차", "7"], ] story += [ Paragraph("목 차", sty("toc_h", fontSize=13, textColor=BRAND_BLUE, alignment=TA_CENTER, spaceAfter=8)), table(toc_data, col_widths=[15*mm, 120*mm, 20*mm]), PageBreak(), ] # ── 섹션 1: 개요 ─────────────────────────────────────────── story += [ Paragraph("1. 개요 및 배경", S["h1"]), hr(), Paragraph( "GUARDiA ITSM은 기본적으로 폐쇄망(Closed Network) 환경에서 운영됩니다. " "그러나 외부 메신저(카카오워크, 네이버웍스, Slack)와의 연동, 공공기관 포털 연계, " "재택/원격 관리 등의 요구사항이 증가함에 따라 개방망 지원 기능을 추가하였습니다.", S["body"]), Spacer(1, 4), Paragraph("1-1. 폐쇄망 vs 개방망 비교", S["h2"]), table([ ["항목", "폐쇄망 (기본)", "개방망 (이 가이드)"], ["접근 범위", "내부망 only", "인터넷 외부 접근 허용"], ["CORS 정책", "localhost 만 허용", "지정 외부 도메인 허용"], ["HTTPS", "선택", "필수 (TLS 1.2/1.3)"], ["API 인증", "JWT", "JWT + API Key"], ["외부 AI 호출", "금지 (Ollama only)", "금지 유지 (변경 불가)"], ["Rate Limiting", "기본", "강화 (30 req/min)"], ["보안 헤더", "기본", "HSTS 포함 강화"], ], col_widths=[45*mm, 60*mm, 60*mm]), Spacer(1, 4), Paragraph( "⚠ 핵심 원칙 유지: 개방망 모드에서도 Ollama(LLM)는 내부 전용 유지. " "외부 AI API(OpenAI, Anthropic 등) 절대 사용 금지.", S["note"]), ] # ── 섹션 2: 아키텍처 ─────────────────────────────────────── story += [ Spacer(1, 8), Paragraph("2. 개방망 아키텍처", S["h1"]), hr(), Paragraph("2-1. 시스템 구성도", S["h2"]), Paragraph( "외부 클라이언트는 Nginx를 통해 TLS 암호화된 채널로 GUARDiA API에 접근합니다. " "LLM(Ollama)과 데이터베이스(PostgreSQL)는 외부 직접 접근이 불가하며, " "API 서버를 통해서만 간접 이용 가능합니다.", S["body"]), table([ ["구성 요소", "역할", "외부 접근"], ["Nginx (443, 8443)", "TLS 종료 + Rate Limit + 보안헤더", "허용"], ["GUARDiA FastAPI (8001)", "비즈니스 로직 + CORS + 보안 미들웨어", "Nginx 통해서만"], ["PostgreSQL (5432)", "데이터 저장", "금지 (127.0.0.1만)"], ["Ollama LLM (11434)", "온프레미스 AI 추론", "금지 (127.0.0.1만)"], ], col_widths=[55*mm, 85*mm, 25*mm]), Spacer(1, 4), Paragraph("2-2. 포트 구성", S["h2"]), table([ ["포트", "프로토콜", "서비스", "외부 접근"], ["80", "HTTP", "홈페이지 (HTTPS 리다이렉트)", "허용"], ["443", "HTTPS", "홈페이지 SSL", "허용"], ["8001", "HTTP", "GUARDiA API (내부 직접)", "권장하지 않음"], ["8443", "HTTPS", "GUARDiA API (외부 접근 권장)", "허용"], ["5432", "TCP", "PostgreSQL", "차단"], ["11434", "HTTP", "Ollama LLM", "차단"], ], col_widths=[15*mm, 25*mm, 90*mm, 35*mm]), ] # ── 섹션 3: 구현 내용 ──────────────────────────────────────── story += [ PageBreak(), Paragraph("3. 구현 내용", S["h1"]), hr(), Paragraph("3-1. 신규 추가 파일", S["h2"]), table([ ["파일", "내용"], ["core/external_security.py", "API Key 생성/검증/감사 유틸리티"], ["routers/external_api.py", "외부 API 라우터 (헬스체크, SR, 웹훅, API Key 관리)"], [".env.open", "개방망 운영 환경변수 템플릿"], ["deploy/nginx_opennet.py", "Nginx HTTPS 설정 배포 스크립트"], ], col_widths=[70*mm, 95*mm]), Spacer(1, 4), Paragraph("3-2. 수정된 파일", S["h2"]), table([ ["파일", "변경 내용"], ["main.py", "CORS 환경변수 기반 동적 설정, 보안 헤더 미들웨어, external_api 라우터 등록"], ["models.py", "APIKey ORM 모델 추가 (tb_api_key 테이블)"], ], col_widths=[70*mm, 95*mm]), Spacer(1, 4), Paragraph("3-3. 개방망 모드 CORS 동작 방식", S["h2"]), Paragraph( "환경변수 GUARDIA_NETWORK_MODE에 따라 CORS 정책이 자동 전환됩니다:", S["body"]), Paragraph("• closed (기본): localhost만 허용", S["note"]), Paragraph("• open: GUARDIA_ALLOWED_ORIGINS에 지정된 외부 도메인도 허용", S["note"]), Paragraph("• 정규식 패턴 허용으로 서브도메인 일괄 허용 가능", S["note"]), ] # ── 섹션 4: 설치 및 설정 ──────────────────────────────────── story += [ Spacer(1, 8), Paragraph("4. 설치 및 설정", S["h1"]), hr(), Paragraph("4-1. .env 개방망 설정", S["h2"]), Paragraph("다음 환경변수를 /opt/guardia/app/.env 에 설정합니다:", S["body"]), table([ ["환경변수", "값 예시", "설명"], ["GUARDIA_NETWORK_MODE", "open", "개방망 모드 활성화"], ["GUARDIA_ALLOWED_ORIGINS", "https://itsm.zioinfo.co.kr", "허용 외부 출처"], ["GUARDIA_WEBHOOK_SECRET", "<강력한 랜덤 값>", "웹훅 HMAC 서명 키"], ["DATABASE_URL", "postgresql+asyncpg://...", "@ 포함 시 %40으로 인코딩"], ], col_widths=[60*mm, 55*mm, 50*mm]), Spacer(1, 4), Paragraph("4-2. SSL 인증서", S["h2"]), Paragraph( "도메인이 있는 경우 Let's Encrypt 인증서 사용을 권장합니다. " "IP만 있는 경우 자체 서명 인증서를 생성합니다.", S["body"]), Paragraph("도메인 보유: certbot --nginx -d itsm.zioinfo.co.kr", S["code"]), Paragraph( "IP 전용: openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ...", S["code"]), ] # ── 섹션 5: API 사용법 ──────────────────────────────────────── story += [ PageBreak(), Paragraph("5. 외부 API 사용법", S["h1"]), hr(), Paragraph("5-1. API 엔드포인트 목록", S["h2"]), table([ ["엔드포인트", "메서드", "인증", "설명"], ["/api/external/health", "GET", "없음", "헬스체크"], ["/api/external/status", "GET", "없음", "시스템 공개 상태"], ["/api/external/keys", "POST", "JWT (관리자)", "API Key 발급"], ["/api/external/keys/{id}", "DELETE", "JWT (관리자)", "API Key 비활성화"], ["/api/external/sr", "GET", "API Key (read)", "SR 목록 조회"], ["/api/external/sr", "POST", "API Key (write)", "SR 등록"], ["/api/external/webhook", "POST", "HMAC (선택)", "외부 메신저 웹훅"], ["/docs", "GET", "없음", "OpenAPI 문서"], ], col_widths=[60*mm, 20*mm, 40*mm, 45*mm]), Spacer(1, 4), Paragraph("5-2. API Key 권한 스코프", S["h2"]), table([ ["스코프", "허용 API", "사용 예시"], ["read", "SR 목록 조회", "모니터링 시스템"], ["write", "SR 등록, 상태 변경", "외부 티켓 시스템"], ["admin", "모든 외부 API", "통합 관리 도구"], ["webhook", "웹훅 수신", "카카오워크, Slack 봇"], ], col_widths=[30*mm, 70*mm, 65*mm]), Spacer(1, 4), Paragraph("5-3. 외부 메신저 웹훅 연동 구조", S["h2"]), Paragraph( "외부 메신저(카카오워크, 네이버웍스, Slack 등)는 GUARDiA 웹훅 엔드포인트로 " "자연어 명령을 전송합니다. GUARDiA는 Ollama LLM으로 명령을 파싱하여 처리합니다.", S["body"]), table([ ["메신저", "웹훅 URL", "인증 방식"], ["카카오워크", "POST /api/external/webhook", "X-GUARDiA-Signature (HMAC)"], ["네이버웍스", "POST /api/external/webhook", "X-GUARDiA-Signature (HMAC)"], ["Slack", "POST /api/external/webhook", "X-Source: slack"], ["Teams", "POST /api/external/webhook", "X-Source: teams"], ["사용자 정의", "POST /api/external/webhook", "선택 (HMAC 권장)"], ], col_widths=[35*mm, 80*mm, 50*mm]), ] # ── 섹션 6: 보안 ────────────────────────────────────────────── story += [ Spacer(1, 8), Paragraph("6. 보안 설정", S["h1"]), hr(), Paragraph("6-1. 적용된 보안 헤더", S["h2"]), table([ ["헤더", "값", "효과"], ["Strict-Transport-Security", "max-age=31536000; includeSubDomains", "브라우저가 HTTPS만 사용"], ["X-Frame-Options", "DENY", "Clickjacking 방지"], ["X-Content-Type-Options", "nosniff", "MIME 스니핑 방지"], ["X-XSS-Protection", "1; mode=block", "XSS 차단"], ["Referrer-Policy", "strict-origin-when-cross-origin", "Referrer 정보 제한"], ], col_widths=[55*mm, 70*mm, 40*mm]), Spacer(1, 4), Paragraph("6-2. 변경 불가 보안 정책", S["h2"]), Paragraph( "개방망 모드에서도 다음 핵심 보안 정책은 절대 변경 불가합니다:", S["body"]), table([ ["정책", "내용"], ["외부 LLM 금지", "Ollama(localhost) 전용. OpenAI/Claude 등 외부 API 완전 금지"], ["SSH 자격증명 보호", "IP, 비밀번호, SSH 계정을 API 응답에 절대 포함 금지"], ["AES-256-GCM 암호화", "서버 자격증명은 암호화 저장 (os_pw_enc 컬럼)"], ["root SSH 금지", "opsagent 전용 계정만 사용"], ["감사 로그", "모든 외부 API 호출 TB_AUDIT_LOG에 기록"], ], col_widths=[50*mm, 115*mm]), ] # ── 섹션 7: 테스트 결과 ─────────────────────────────────────── story += [ PageBreak(), Paragraph("7. 테스트 결과", S["h1"]), hr(), Paragraph("테스트 환경: Ubuntu 24.04, GUARDiA 2.0.0, Nginx 1.24 | 2026-05-30", S["note"]), Spacer(1, 4), table([ ["#", "테스트 항목", "기대값", "실제값", "결과"], ["T1", "HTTP 헬스체크 (8001)", "200 OK", "200 OK", "PASS"], ["T2", "HTTPS 헬스체크 (8443)", "200 OK", "200 OK", "PASS"], ["T3", "홈페이지 HTTPS (443)", "200 OK", "200 OK", "PASS"], ["T4", "미인증 API 접근 차단", "401", "401", "PASS"], ["T5", "CORS 외부 출처 허용", "Allow-Origin 헤더", "헤더 포함", "PASS"], ["T6", "HSTS 헤더 적용", "max-age=31536000", "적용됨", "PASS"], ["T7", "X-Frame-Options", "DENY", "DENY", "PASS"], ["T8", "Rate Limiting 설정", "zone 설정 확인", "1개 zone", "PASS"], ["T9", "공개 시스템 상태", "operational", "operational", "PASS"], ["T10", "개방망 모드 활성", "open", "open", "PASS"], ], col_widths=[10*mm, 60*mm, 40*mm, 40*mm, 15*mm]), Spacer(1, 4), Paragraph("전체 10개 테스트 모두 통과 (10/10 PASS)", sty( "tr", fontSize=11, textColor=SUCCESS_GRN, alignment=TA_CENTER)), ] # ── 섹션 8: 운영 절차 ────────────────────────────────────────── story += [ Spacer(1, 8), Paragraph("8. 운영 절차", S["h1"]), hr(), Paragraph("8-1. 모드 전환 명령", S["h2"]), table([ ["작업", "명령어"], ["폐쇄망→개방망", "echo GUARDIA_NETWORK_MODE=open >> .env && systemctl restart guardia"], ["개방망→폐쇄망", "sed -i 's/open/closed/' .env && systemctl restart guardia"], ["HTTPS 활성화", "ln -sf sites-available/guardia-https sites-enabled/ && nginx -t && systemctl reload nginx"], ["HTTPS 비활성화", "rm sites-enabled/guardia-https && systemctl reload nginx"], ], col_widths=[35*mm, 130*mm]), Spacer(1, 4), Paragraph("8-2. 서비스 접속 정보", S["h2"]), table([ ["서비스", "URL", "용도"], ["GUARDiA ITSM HTTP", "http://zioinfo.co.kr:8001", "내부망 직접 접근"], ["GUARDiA ITSM HTTPS", "https://zioinfo.co.kr:8443", "개방망 외부 접근 (권장)"], ["외부 API", "https://zioinfo.co.kr:8443/api/external/", "API Key 인증"], ["OpenAPI 문서", "https://zioinfo.co.kr:8443/docs", "API 명세서 (공개)"], ["홈페이지 HTTPS", "https://zioinfo.co.kr", "지오정보기술 홈페이지"], ], col_widths=[40*mm, 75*mm, 50*mm]), Spacer(1, 8), hr(colors.HexColor("#e2e8f0")), Paragraph( "GUARDiA ITSM v2.0.0 | (주)지오정보기술 | 2026-05-30", sty("footer", fontSize=8, textColor=TEXT_MUTED, alignment=TA_CENTER)), ] doc.build(story) print(f"PDF 생성 완료: {output_path}") # ══════════════════════════════════════════════════════════════ # PPTX 생성 # ══════════════════════════════════════════════════════════════ def gen_pptx(output_path: str): from pptx import Presentation from pptx.util import Inches, Pt, Emu from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN from pptx.util import Inches, Pt W, H = Inches(13.33), Inches(7.5) # 16:9 와이드 prs = Presentation() prs.slide_width = W prs.slide_height = H # ── 색상 팔레트 ───────────────────────────────────────────── BRAND = RGBColor(0x1a, 0x3a, 0x6b) ACCENT = RGBColor(0x4f, 0x6e, 0xf7) WHITE = RGBColor(0xff, 0xff, 0xff) GRAY_LT = RGBColor(0xf0, 0xf2, 0xf5) GREEN = RGBColor(0x22, 0xc5, 0x5e) ORANGE = RGBColor(0xf5, 0x9e, 0x0b) RED = RGBColor(0xef, 0x44, 0x44) DARK = RGBColor(0x1e, 0x29, 0x3b) MUTED = RGBColor(0x64, 0x74, 0x8b) def blank_slide(): layout = prs.slide_layouts[6] # blank return prs.slides.add_slide(layout) def add_rect(slide, x, y, w, h, fill=None, line=None, radius=0): from pptx.util import Emu shape = slide.shapes.add_shape(1, x, y, w, h) # MSO_SHAPE_TYPE.RECTANGLE if fill: shape.fill.solid() shape.fill.fore_color.rgb = fill else: shape.fill.background() if line: shape.line.color.rgb = line shape.line.width = Pt(0.75) else: shape.line.fill.background() return shape def add_text(slide, text, x, y, w, h, size=18, bold=False, color=DARK, align=PP_ALIGN.LEFT, italic=False): tb = slide.shapes.add_textbox(x, y, w, h) tf = tb.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = align run = p.add_run() run.text = text run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.color.rgb = color return tb def add_table_slide(slide, headers, rows, x, y, w, h, col_widths=None): from pptx.util import Pt cols = len(headers) tbl = slide.shapes.add_table(len(rows)+1, cols, x, y, w, h).table if col_widths: for i, cw in enumerate(col_widths): tbl.columns[i].width = cw def cell_style(cell, text, bg=None, txt_color=DARK, bold=False, sz=10): cell.text = text cell.text_frame.paragraphs[0].font.size = Pt(sz) cell.text_frame.paragraphs[0].font.bold = bold cell.text_frame.paragraphs[0].font.color.rgb = txt_color if bg: cell.fill.solid() cell.fill.fore_color.rgb = bg for j, h_text in enumerate(headers): cell_style(tbl.cell(0, j), h_text, bg=BRAND, txt_color=WHITE, bold=True, sz=10) for i, row in enumerate(rows): bg = GRAY_LT if i % 2 == 0 else WHITE for j, val in enumerate(row): cell_style(tbl.cell(i+1, j), str(val), bg=bg) # ── 슬라이드 1: 표지 ─────────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, H, fill=BRAND) add_rect(s, Inches(0.5), Inches(1.2), Inches(12.33), Inches(4.5), fill=RGBColor(0x25, 0x4a, 0x80)) add_text(s, "GUARDiA ITSM", Inches(0.8), Inches(1.5), Inches(11.73), Inches(1.2), size=44, bold=True, color=WHITE, align=PP_ALIGN.CENTER) add_text(s, "개방망(Open Network) 구현 가이드", Inches(0.8), Inches(2.7), Inches(11.73), Inches(0.8), size=22, color=RGBColor(0xaa, 0xc4, 0xe8), align=PP_ALIGN.CENTER) add_text(s, "v2.0.0 | 2026-05-30 | (주)지오정보기술", Inches(0.8), Inches(3.5), Inches(11.73), Inches(0.5), size=13, color=MUTED, align=PP_ALIGN.CENTER) # 배지들 badges = [("HTTPS", GREEN), ("API Key", ACCENT), ("CORS", ORANGE), ("Rate Limit", RED)] for i, (txt, col) in enumerate(badges): bx = Inches(3.2 + i * 1.8) add_rect(s, bx, Inches(5.0), Inches(1.5), Inches(0.5), fill=col) add_text(s, txt, bx + Inches(0.1), Inches(5.05), Inches(1.3), Inches(0.4), size=12, bold=True, color=WHITE, align=PP_ALIGN.CENTER) add_text(s, "AI 기반 레거시 인프라 자율 운영 플랫폼", Inches(0.8), Inches(6.5), Inches(11.73), Inches(0.5), size=11, color=MUTED, italic=True, align=PP_ALIGN.CENTER) # ── 슬라이드 2: 목차 ─────────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, Inches(1.2), fill=BRAND) add_text(s, "목차 (Agenda)", Inches(0.4), Inches(0.25), Inches(12), Inches(0.7), size=24, bold=True, color=WHITE) items = [ ("1", "개요 및 배경", "폐쇄망 vs 개방망 비교, 지원 필요성"), ("2", "아키텍처", "개방망 시스템 구성도, 포트 구성"), ("3", "구현 내용", "신규/수정 파일, CORS 동작 방식"), ("4", "설치 및 설정", ".env 설정, SSL 인증서, Nginx"), ("5", "외부 API", "엔드포인트, API Key 권한, 웹훅 연동"), ("6", "보안 설정", "보안 헤더, 불변 보안 정책"), ("7", "테스트 결과", "10개 항목 전체 통과 (10/10 PASS)"), ("8", "운영 절차", "모드 전환, 접속 정보"), ] for i, (num, title, desc) in enumerate(items): row = i // 2; col = i % 2 bx = Inches(0.4 + col * 6.4); by = Inches(1.4 + row * 1.35) add_rect(s, bx, by, Inches(5.9), Inches(1.1), fill=GRAY_LT, line=ACCENT) add_rect(s, bx, by, Inches(0.6), Inches(1.1), fill=ACCENT) add_text(s, num, bx + Inches(0.05), by + Inches(0.2), Inches(0.5), Inches(0.6), size=18, bold=True, color=WHITE, align=PP_ALIGN.CENTER) add_text(s, title, bx + Inches(0.7), by + Inches(0.1), Inches(5.1), Inches(0.45), size=14, bold=True, color=BRAND) add_text(s, desc, bx + Inches(0.7), by + Inches(0.55), Inches(5.1), Inches(0.45), size=10, color=MUTED) # ── 슬라이드 3: 개요 ──────────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, Inches(1.2), fill=BRAND) add_text(s, "1. 개요 — 개방망 지원 필요성", Inches(0.4), Inches(0.25), Inches(12), Inches(0.7), size=24, bold=True, color=WHITE) add_table_slide(s, ["항목", "폐쇄망 (기본)", "개방망 (이 가이드)"], [ ["접근 범위", "내부망 only", "인터넷 외부 접근 허용"], ["CORS 정책", "localhost 만 허용", "지정 외부 도메인 허용"], ["HTTPS", "선택", "필수 (TLS 1.2/1.3)"], ["API 인증", "JWT only", "JWT + API Key 추가"], ["외부 LLM", "금지 (Ollama only)", "금지 유지 (변경 불가)"], ["Rate Limiting", "기본", "강화 (30 req/min)"], ], Inches(0.4), Inches(1.4), Inches(12.5), Inches(4.0), col_widths=[Inches(2.5), Inches(4.5), Inches(5.5)] ) add_rect(s, Inches(0.4), Inches(5.8), Inches(12.5), Inches(0.7), fill=RGBColor(0xff, 0xf1, 0xf2)) add_text(s, "⚠ 핵심 원칙 유지: 개방망 모드에서도 Ollama(LLM)는 내부 전용. " "외부 AI API 절대 사용 금지.", Inches(0.6), Inches(5.9), Inches(12.2), Inches(0.5), size=11, bold=True, color=RED) # ── 슬라이드 4: 아키텍처 ──────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, Inches(1.2), fill=BRAND) add_text(s, "2. 개방망 아키텍처", Inches(0.4), Inches(0.25), Inches(12), Inches(0.7), size=24, bold=True, color=WHITE) # 아키텍처 다이어그램 (박스들) boxes = [ ("외부 클라이언트\n(브라우저/메신저봇)", Inches(0.3), Inches(1.3), Inches(2.8), Inches(1.0), ACCENT, WHITE), ("Nginx\n(TLS + Rate Limit + 보안헤더)", Inches(4.0), Inches(1.3), Inches(2.8), Inches(1.0), BRAND, WHITE), ("GUARDiA ITSM\n(FastAPI 8001)", Inches(7.7), Inches(1.3), Inches(2.8), Inches(1.0), ACCENT, WHITE), ("PostgreSQL\n(내부 전용 5432)", Inches(7.7), Inches(3.2), Inches(2.8), Inches(0.9), DARK, WHITE), ("Ollama LLM\n(내부 전용 11434)", Inches(11.0), Inches(3.2), Inches(2.0), Inches(0.9), DARK, WHITE), ] for txt, bx, by, bw, bh, fill, txt_col in boxes: add_rect(s, bx, by, bw, bh, fill=fill) add_text(s, txt, bx + Inches(0.1), by + Inches(0.1), bw - Inches(0.2), bh - Inches(0.2), size=11, bold=True, color=txt_col, align=PP_ALIGN.CENTER) # 화살표 텍스트 add_text(s, "→ HTTPS (443/8443)", Inches(3.2), Inches(1.6), Inches(0.7), Inches(0.5), size=8, color=MUTED, align=PP_ALIGN.CENTER) add_text(s, "→ HTTP 내부", Inches(6.9), Inches(1.6), Inches(0.7), Inches(0.5), size=8, color=MUTED, align=PP_ALIGN.CENTER) # 포트 구성 테이블 add_table_slide(s, ["포트", "서비스", "외부 접근"], [ ["80/443", "홈페이지 Nginx (HTTPS)", "허용"], ["8001", "GUARDiA FastAPI (직접)", "권장 안 함"], ["8443", "GUARDiA Nginx (HTTPS, 권장)", "허용"], ["5432", "PostgreSQL", "차단"], ["11434", "Ollama LLM", "차단"], ], Inches(0.4), Inches(4.3), Inches(7.5), Inches(2.7), col_widths=[Inches(1.5), Inches(3.5), Inches(2.5)] ) # ── 슬라이드 5: 외부 API ──────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, Inches(1.2), fill=BRAND) add_text(s, "5. 외부 API 엔드포인트", Inches(0.4), Inches(0.25), Inches(12), Inches(0.7), size=24, bold=True, color=WHITE) add_table_slide(s, ["엔드포인트", "메서드", "인증", "설명"], [ ["/api/external/health", "GET", "없음", "헬스체크 (공개)"], ["/api/external/status", "GET", "없음", "시스템 공개 상태"], ["/api/external/keys", "POST", "JWT (관리자)", "API Key 발급"], ["/api/external/sr", "GET", "API Key (read)", "SR 목록 조회"], ["/api/external/sr", "POST", "API Key (write)", "SR 등록"], ["/api/external/webhook", "POST", "HMAC (선택)", "외부 메신저 웹훅 수신"], ], Inches(0.4), Inches(1.4), Inches(12.5), Inches(3.2), col_widths=[Inches(3.5), Inches(1.5), Inches(2.5), Inches(5.0)] ) add_text(s, "API Key 권한 스코프:", Inches(0.4), Inches(4.9), Inches(4), Inches(0.4), size=12, bold=True, color=BRAND) scopes = [("read", "조회", GREEN), ("write", "등록/수정", ACCENT), ("webhook", "웹훅", ORANGE), ("admin", "전체", RED)] for i, (sc, desc, col) in enumerate(scopes): bx = Inches(0.4 + i * 3.1) add_rect(s, bx, Inches(5.4), Inches(2.8), Inches(0.8), fill=col) add_text(s, f"{sc}\n{desc}", bx + Inches(0.1), Inches(5.45), Inches(2.6), Inches(0.7), size=11, bold=True, color=WHITE, align=PP_ALIGN.CENTER) # ── 슬라이드 6: 테스트 결과 ───────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, Inches(1.2), fill=BRAND) add_text(s, "7. 테스트 결과 — 10/10 PASS ✅", Inches(0.4), Inches(0.25), Inches(12), Inches(0.7), size=24, bold=True, color=WHITE) add_table_slide(s, ["#", "테스트 항목", "기대값", "실제값", "결과"], [ ["T1", "HTTP 헬스체크 (8001)", "200 OK", "200 OK", "PASS"], ["T2", "HTTPS 헬스체크 (8443)", "200 OK", "200 OK", "PASS"], ["T3", "홈페이지 HTTPS (443)", "200 OK", "200 OK", "PASS"], ["T4", "미인증 API 접근 차단", "401", "401", "PASS"], ["T5", "CORS 외부 출처 허용", "Allow-Origin", "헤더 포함", "PASS"], ["T6", "HSTS 헤더 적용", "max-age=31536000", "적용됨", "PASS"], ["T7", "X-Frame-Options DENY", "DENY", "DENY", "PASS"], ["T8", "Rate Limiting 설정", "zone 확인", "1개 zone", "PASS"], ["T9", "공개 시스템 상태", "operational", "operational", "PASS"], ["T10", "개방망 모드 활성", "NETWORK_MODE=open", "open", "PASS"], ], Inches(0.4), Inches(1.4), Inches(12.5), Inches(4.5), col_widths=[Inches(0.8), Inches(3.8), Inches(2.5), Inches(2.5), Inches(1.5)] ) add_rect(s, Inches(0.4), Inches(6.2), Inches(12.5), Inches(0.8), fill=GREEN) add_text(s, "✅ 전체 10개 테스트 모두 통과 (10/10 PASS) — 2026-05-30", Inches(0.6), Inches(6.3), Inches(12.1), Inches(0.6), size=14, bold=True, color=WHITE, align=PP_ALIGN.CENTER) # ── 슬라이드 7: 마지막 ────────────────────────────────────── s = blank_slide() add_rect(s, 0, 0, W, H, fill=BRAND) add_text(s, "GUARDiA ITSM 개방망 지원 완료", Inches(1), Inches(2.0), Inches(11.33), Inches(1.2), size=32, bold=True, color=WHITE, align=PP_ALIGN.CENTER) add_text(s, "HTTP → HTTPS 전환 | API Key 인증 | CORS 외부 허용 | 보안 헤더 강화", Inches(1), Inches(3.3), Inches(11.33), Inches(0.6), size=14, color=RGBColor(0xaa, 0xc4, 0xe8), align=PP_ALIGN.CENTER) add_text(s, "(주)지오정보기술 | GUARDiA v2.0.0 | 2026-05-30", Inches(1), Inches(5.5), Inches(11.33), Inches(0.5), size=12, color=MUTED, align=PP_ALIGN.CENTER) prs.save(output_path) print(f"PPTX 생성 완료: {output_path}") if __name__ == "__main__": pdf_out = str(OUT_DIR / "23_GUARDiA_개방망_가이드.pdf") pptx_out = str(OUT_DIR / "24_GUARDiA_개방망_발표자료.pptx") gen_pdf(pdf_out) gen_pptx(pptx_out) print("\n=== 생성 완료 ===") print(f"PDF : {pdf_out}") print(f"PPTX: {pptx_out}")