POSA_LEAKSMS/docs/INSTALL_GUIDE.html
cw2007 fb3630d868 docs: 문서 현행화(leak_ 접두사·경로 스크럽) + 코드 투어 + settings deny 수정
- 전 운영 문서 테이블명 leak_ 접두사 반영, 삭제된 마이그레이션 참조 정리,
  구경로 raspi_leck_detecter→<WEB_DIR> 스크럽(DEPLOYMENT_PLAN).
- docs/DEPLOYMENT_PLAN_RPI4x3.md(신규): 3대 Pi + 신규 서버 배포 로드맵.
- .tours/architect-full-system.tour(신규): 19스텝 전체 아키텍처 투어.
- README.md(루트, 0바이트였음): 시스템 인덱스.
- .claude/settings.local.json: /doctor 지적 deny 규칙 5건 수정
  (파이프 4건 와일드카드형, 포크밤 삭제) + 죽은 allow 항목 3건 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:52:15 +09:00

657 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32)</title>
<style>
/* 폐쇄망 적합: 외부 CDN/폰트/스크립트 0. 인라인 스타일만 사용. */
:root{
--bg:#f1f5f9; --card:#fff; --ink:#0f172a; --muted:#64748b; --line:#e2e8f0;
--brand:#1a56db; --brandd:#1347b8; --ok:#166534; --okbg:#f0fdf4; --okln:#bbf7d0;
--warn:#92400e; --warnbg:#fffbeb; --warnln:#fde68a; --crit:#991b1b; --critbg:#fef2f2; --critln:#fecaca;
--mono:'D2Coding','Consolas','Courier New',monospace;
}
*{box-sizing:border-box;}
body{margin:0;font-family:'Noto Sans KR','Malgun Gothic',system-ui,sans-serif;background:var(--bg);color:var(--ink);line-height:1.65;}
a{color:var(--brand);text-decoration:none;} a:hover{text-decoration:underline;}
header.top{background:linear-gradient(135deg,#1a56db,#0f766e);color:#fff;padding:34px 24px;}
header.top .wrap{max-width:980px;margin:0 auto;}
header.top h1{margin:0 0 6px;font-size:1.7rem;}
header.top p{margin:2px 0;color:rgba(255,255,255,.92);font-size:.95rem;}
.badges{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;}
.badge{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.32);border-radius:999px;padding:3px 12px;font-size:.8rem;font-weight:600;}
.layout{max-width:980px;margin:0 auto;padding:24px 18px 80px;}
nav.toc{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 20px;margin-bottom:24px;}
nav.toc h2{margin:0 0 10px;font-size:1rem;color:var(--brand);}
nav.toc ol{margin:0;padding-left:20px;columns:2;column-gap:28px;font-size:.9rem;}
@media(max-width:640px){nav.toc ol{columns:1;}}
nav.toc li{margin:3px 0;break-inside:avoid;}
section{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:24px 26px;margin-bottom:22px;box-shadow:0 1px 4px rgba(15,23,42,.05);}
section>h2{margin:0 0 4px;font-size:1.35rem;color:var(--brand);border-bottom:2px solid var(--line);padding-bottom:10px;}
.part-tag{display:inline-block;font-size:.78rem;font-weight:700;color:#fff;background:var(--brandd);border-radius:6px;padding:2px 10px;margin-bottom:8px;}
h3{margin:26px 0 8px;font-size:1.08rem;}
h4{margin:18px 0 6px;font-size:.98rem;color:#334155;}
p{margin:8px 0;}
code{font-family:var(--mono);background:#f1f5f9;border:1px solid var(--line);border-radius:4px;padding:1px 6px;font-size:.88em;color:#0b3b8c;}
pre{background:#0f172a;color:#e2e8f0;border-radius:10px;padding:16px 18px;overflow:auto;font-family:var(--mono);font-size:.85rem;line-height:1.55;}
pre code{background:none;border:none;color:inherit;padding:0;}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:.9rem;}
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top;}
th{background:#f8fafc;font-weight:700;}
tbody tr:nth-child(even){background:#fafcff;}
.step{counter-increment:step;position:relative;padding-left:46px;margin:22px 0;}
.step::before{content:counter(step);position:absolute;left:0;top:-2px;width:32px;height:32px;border-radius:50%;background:var(--brand);color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:.95rem;}
.steps{counter-reset:step;}
.step h3{margin-top:0;}
/* 콜아웃 */
.note,.warn,.crit,.ok{border-radius:10px;padding:12px 16px;margin:14px 0;font-size:.9rem;border:1px solid;}
.note{background:#eff6ff;border-color:#bfdbfe;color:#1e3a8a;}
.warn{background:var(--warnbg);border-color:var(--warnln);color:var(--warn);}
.crit{background:var(--critbg);border-color:var(--critln);color:var(--crit);}
.ok{background:var(--okbg);border-color:var(--okln);color:var(--ok);}
.note b,.warn b,.crit b,.ok b{font-weight:800;}
/* 캡처 자리표시 */
figure{margin:18px 0;}
.shot{border:2px dashed #94a3b8;border-radius:12px;background:repeating-linear-gradient(45deg,#f8fafc,#f8fafc 12px,#f1f5f9 12px,#f1f5f9 24px);padding:26px 20px;text-align:center;}
.shot .cam{font-size:1.6rem;}
.shot .ph-title{font-weight:800;color:#334155;margin:6px 0 4px;font-size:.95rem;}
.shot .ph-what{color:#475569;font-size:.86rem;}
.shot .ph-what b{color:var(--brand);}
figure>figcaption{margin-top:8px;font-size:.82rem;color:var(--muted);text-align:center;font-style:italic;}
figure svg{max-width:100%;height:auto;display:block;margin:0 auto;background:#fff;border:1px solid var(--line);border-radius:12px;}
.kbd{font-family:var(--mono);background:#fff;border:1px solid #cbd5e1;border-bottom-width:2px;border-radius:5px;padding:1px 6px;font-size:.82em;}
hr{border:none;border-top:1px solid var(--line);margin:26px 0;}
.pin{font-family:var(--mono);font-weight:700;color:#0b3b8c;}
.small{font-size:.84rem;color:var(--muted);}
@media print{
body{background:#fff;} section{box-shadow:none;border-color:#cbd5e1;break-inside:avoid;}
header.top{background:#1a56db !important;-webkit-print-color-adjust:exact;print-color-adjust:exact;}
nav.toc{display:none;}
}
.cap{display:block;max-width:100%;height:auto;margin:6px auto 0;border:1px solid var(--line);border-radius:12px;box-shadow:0 1px 6px rgba(15,23,42,.08);}
.fname{font-family:var(--mono);font-size:.78rem;color:#475569;font-weight:600;}
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px;margin:14px 0;}
.gallery figure{margin:0;}
.gallery img{width:100%;height:auto;border:1px solid var(--line);border-radius:8px;background:#fff;}
.gallery figcaption{font-size:.78rem;color:var(--muted);margin-top:4px;text-align:center;}
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<h1>서버실 온습도(SHT30) 모니터링 시스템 v2606 — 설치 가이드</h1>
<p>Cafe24 PHP/MySQL 서버부터 STM32F407 MCU 펌웨어까지, 폐쇄망(air-gapped) 배포 절차</p>
<div class="badges">
<span class="badge">서버: Cafe24 PHP / MySQL</span>
<span class="badge">MCU: STM32F407VGT6 (sht30_fw)</span>
<span class="badge">센서: SHT30 I²C 0x44</span>
<span class="badge">전송: HMAC-SHA256 + TLS 1.2</span>
<span class="badge">폐쇄망 · 인터넷 없음</span>
</div>
</div>
</header>
<div class="layout">
<div class="note">
<b>이 가이드의 이미지에 대하여.</b> 본 문서의 <b>아키텍처·배선 다이어그램은 코드/<code>HARDWARE.md</code> 기준으로 정확히 생성</b>한 SVG입니다.
Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼 <b>운영 환경에서만 캡처할 수 있는 화면은
“📷 캡처 위치” 자리표시</b>로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다.
(저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 — <a href="#appendix">부록</a> 참조.)
</div>
<nav class="toc">
<h2>목차</h2>
<ol>
<li><a href="#overview">시스템 개요 &amp; 아키텍처</a></li>
<li><a href="#prep">설치 전 준비물</a></li>
<li><a href="#A-files">A. Cafe24 — 파일 업로드</a></li>
<li><a href="#A-config">A. Cafe24 — config.local.php</a></li>
<li><a href="#A-db">A. Cafe24 — DB 설치(phpMyAdmin)</a></li>
<li><a href="#A-admin">A. Cafe24 — 권한 &amp; 관리자/MFA</a></li>
<li><a href="#A-cron">A. Cafe24 — cron(오프라인 감지)</a></li>
<li><a href="#B-wiring">B. STM32 — 하드웨어 배선</a></li>
<li><a href="#B-build">B. STM32 — 폐쇄망 빌드</a></li>
<li><a href="#B-flash">B. STM32 — 플래시 &amp; 콘솔 확인</a></li>
<li><a href="#verify">최종 검증(종단)</a></li>
<li><a href="#trouble">문제 해결</a></li>
<li><a href="#secrets">운영자 보관 값</a></li>
<li><a href="#appendix">부록 — 레거시 자산</a></li>
</ol>
</nav>
<!-- ============================== 개요 ============================== -->
<section id="overview">
<h2>1. 시스템 개요 &amp; 아키텍처</h2>
<p>단일 STM32F407 보드(<code>sht30_fw</code>, <code>sensor_id=2</code>)가 SHT30 센서를 I²C로 5분마다 읽어,
본문을 <b>HMAC-SHA256으로 서명</b>해 <b>TLS 1.2</b>로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 <b>재판정</b>하여
고온/저온/고습/저습·결로·오프라인 상황을 <b>SMS</b>로 알리고 <b>웹 대시보드/보고서</b>를 제공합니다.</p>
<figure>
<svg viewBox="0 0 940 250" role="img" aria-label="시스템 아키텍처 다이어그램">
<defs>
<marker id="ah" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" fill="#334155"/>
</marker>
<style>
.b{fill:#fff;stroke:#1a56db;stroke-width:2;rx:10;}
.t{font-family:'Noto Sans KR',sans-serif;font-size:14px;fill:#0f172a;font-weight:700;text-anchor:middle;}
.s{font-family:'Noto Sans KR',sans-serif;font-size:11px;fill:#64748b;text-anchor:middle;}
.ln{stroke:#334155;stroke-width:2;marker-end:url(#ah);fill:none;}
.lbl{font-family:'Noto Sans KR',sans-serif;font-size:10.5px;fill:#0f766e;text-anchor:middle;font-weight:700;}
</style>
</defs>
<rect x="20" y="92" width="120" height="66" rx="10" fill="#ecfeff" stroke="#0f766e" stroke-width="2"/>
<text class="t" x="80" y="120">SHT30</text><text class="s" x="80" y="140">온·습도 센서</text>
<rect x="200" y="80" width="170" height="90" rx="10" fill="#eff6ff" stroke="#1a56db" stroke-width="2"/>
<text class="t" x="285" y="112">STM32F407</text><text class="s" x="285" y="132">sht30_fw</text><text class="s" x="285" y="150">FreeRTOS+mbedTLS</text>
<rect x="430" y="80" width="180" height="90" rx="10" fill="#eff6ff" stroke="#1a56db" stroke-width="2"/>
<text class="t" x="520" y="108">Cafe24 PHP</text><text class="s" x="520" y="128">api/sensor_data.php</text><text class="s" x="520" y="146">임계 재판정 + 저장</text>
<rect x="670" y="40" width="160" height="60" rx="10" fill="#f0fdf4" stroke="#166534" stroke-width="2"/>
<text class="t" x="750" y="66">MySQL</text><text class="s" x="750" y="84">4 테이블</text>
<rect x="670" y="150" width="160" height="62" rx="10" fill="#fffbeb" stroke="#92400e" stroke-width="2"/>
<text class="t" x="750" y="176">SMS · 대시보드</text><text class="s" x="750" y="194">경보/보고서</text>
<line class="ln" x1="142" y1="125" x2="196" y2="125"/><text class="lbl" x="170" y="116">I²C 0x44</text>
<line class="ln" x1="372" y1="125" x2="426" y2="125"/><text class="lbl" x="399" y="112">HMAC</text><text class="lbl" x="399" y="146">TLS 1.2</text>
<line class="ln" x1="612" y1="110" x2="666" y2="80"/>
<line class="ln" x1="612" y1="140" x2="666" y2="172"/>
</svg>
<figcaption>그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)</figcaption>
</figure>
<table>
<thead><tr><th>구분</th><th>설치 위치</th><th>역할</th></tr></thead>
<tbody>
<tr><td>Cafe24 서버</td><td><code>public_html/raspi_leck_detecter/</code></td><td>센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서</td></tr>
<tr><td>Cafe24 MySQL</td><td>Cafe24 DB</td><td>센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태</td></tr>
<tr><td>STM32F407VGT6</td><td>펌웨어 플래시 (<code>sht30_fw</code>)</td><td>SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명</td></tr>
<tr><td>서버 cron</td><td>Cafe24 cron 또는 외부 스케줄러</td><td><code>cron_heartbeat.php</code> 주기 실행(오프라인 감지·헬스·에스컬레이션)</td></tr>
</tbody>
</table>
<div class="warn"><b>웹 루트 경로 고정.</b> 폴더명 <code>raspi_leck_detecter</code> 는 레거시 이름이지만 펌웨어
<code>app_config.h</code> 의 <code>APP_API_PATH</code> 와 <b>반드시 일치</b>해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.</div>
</section>
<!-- ============================== 준비물 ============================== -->
<section id="prep">
<h2>2. 설치 전 준비물</h2>
<table>
<thead><tr><th>서버측(Cafe24)</th><th>장치측(STM32)</th></tr></thead>
<tbody>
<tr>
<td>
• Cafe24 PHP 호스팅<br>
• Cafe24 MySQL DB(이름/계정/비밀번호)<br>
• Cafe24 SMS 서비스 계정 + secure key + 발신번호<br>
• 운영 담당자 SMS 수신번호<br>
• HTTPS 도메인/경로 + Cafe24 루트 CA
</td>
<td>
• STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)<br>
• SHT30 모듈(한진데이터 P4422-3, I²C)<br>
• LAN8720 Ethernet PHY(RMII) + LAN 케이블<br>
• I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)<br>
• 외부 3.3V USB-UART 어댑터(USART3 콘솔)<br>
• 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴)
</td>
</tr>
</tbody>
</table>
<div class="note"><b>핵심 비밀값 한 쌍.</b> 서버 <code>config.local.php</code> 의 <code>API_KEY</code> 와 펌웨어
<code>secrets.h</code> 의 <code>APP_API_KEY</code> 는 <b>바이트 단위로 동일</b>해야 합니다. 서명
<code>X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문))</code> 검증의 기준입니다.</div>
</section>
<!-- ====================================================================
PART A — CAFE24 서버
===================================================================== -->
<section id="A-files">
<span class="part-tag">PART A · Cafe24 서버</span>
<h2>3. 서버 파일 업로드</h2>
<p>Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.</p>
<pre><code>public_html/raspi_leck_detecter/
.htaccess config.php config.local.php ← 서버에서만 생성
config.local.example.php
login.php dashboard.php setup_wizard.php
setup_mfa.php admin_security.php ops_checks.php
sms_send.php cron_heartbeat.php monthly_report.php
security_evidence.php retention_cleanup.php setup_hash.php
blocked.php test_mobile.php
api/
sensor_data.php
var/
.gitkeep ← PHP가 쓰기 가능해야 함(상태/감사/카운터 파일)</code></pre>
<div class="crit"><b>업로드 금지.</b> <code>config.local.php</code> 와 펌웨어 <code>secrets.h</code> 는 비밀값이므로
저장소/공유에 올리지 않습니다. <code>php/var/</code> 는 <code>.htaccess</code> 로 외부 접근이 차단됩니다(확인 필수).</div>
<figure>
<img class="cap" src="screenshots/02-server-files-upload.png" alt="그림 2 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 2 <span class="fname">→ screenshots/02-server-files-upload.png</span></div>
<div class="ph-what">Cafe24 <b>FTP/파일 관리자</b>에서 <b><code>public_html/raspi_leck_detecter/</code> 업로드 완료된 파일 목록</b> 화면</div>
</div>
<figcaption>그림 2. 서버 파일 업로드 결과(웹 루트 트리)</figcaption>
</figure>
</section>
<section id="A-config">
<span class="part-tag">PART A · Cafe24 서버</span>
<h2>4. 운영 설정 — <code>config.local.php</code></h2>
<p><code>config.local.example.php</code> 를 복사해 <code>config.local.php</code> 를 만들고 실제 운영 값으로 교체합니다.</p>
<pre><code>&lt;?php
return [
'DB_HOST' =&gt; 'localhost',
'DB_PORT' =&gt; 3306,
'DB_NAME' =&gt; 'your_db_name',
'DB_USER' =&gt; 'your_db_user',
'DB_PASS' =&gt; 'your_db_password',
'API_KEY' =&gt; 'replace-with-long-random-secret', // 펌웨어 APP_API_KEY 와 동일!
'ALLOW_LEGACY_BODY_SIGNATURE' =&gt; false, // HMAC 전용(레거시 RPi 없으면 false)
'SMS_USER_ID' =&gt; 'your-cafe24-sms-user-id',
'SMS_SECURE' =&gt; 'your-cafe24-sms-secure-key',
'SMS_SENDER' =&gt; '01000000000',
'SMS_RECIPIENTS' =&gt; ['01000000000'],
'SMS_RECIPIENTS_TIER2' =&gt; [], // 미복귀 30분 시 2차(야간) 통지(opt-in)
'ADMIN_USER' =&gt; 'admin',
'ADMIN_PASSWORD_HASH' =&gt; '', // setup_hash.php 출력으로 채움(빈값=로그인 차단)
'ADMIN_TOTP_SECRET' =&gt; '', // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
'MFA_SETUP_TOKEN' =&gt; 'replace-with-temporary-random-token', // 등록 후 비울 것
// 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
'METRIC_TEMP_HIGH_C' =&gt; 30, 'METRIC_TEMP_LOW_C' =&gt; 10,
'METRIC_RH_HIGH' =&gt; 70, 'METRIC_RH_LOW' =&gt; 20,
];</code></pre>
<div class="warn"><b>fail-closed 기본값.</b> <code>ADMIN_PASSWORD_HASH</code> 또는 <code>ADMIN_TOTP_SECRET</code> 가
비어 있으면 관리자 로그인이 <b>차단</b>됩니다(보안). 4·6단계에서 채웁니다.</div>
</section>
<section id="A-db">
<span class="part-tag">PART A · Cafe24 서버</span>
<h2>5. 데이터베이스 설치 (phpMyAdmin)</h2>
<div class="steps">
<div class="step">
<h3>Cafe24 phpMyAdmin 접속 → 대상 DB 선택</h3>
<p>Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(<code>DB_NAME</code>)를 클릭합니다.</p>
</div>
<div class="step">
<h3>스키마 실행</h3>
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
(+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
<table>
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
<tbody>
<tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr>
</tbody>
</table>
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
기존 설치는 <code>setup_wizard.php</code> 점검 결과를 먼저 확인하세요.</div>
</div>
</div>
<figure>
<img class="cap" src="screenshots/03-db-phpmyadmin.png" alt="그림 3 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div>
<div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div>
</div>
<figcaption>그림 3. DB 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
</figure>
</section>
<section id="A-admin">
<span class="part-tag">PART A · Cafe24 서버</span>
<h2>6. 권한 · 관리자 비밀번호 · MFA 등록</h2>
<div class="steps">
<div class="step">
<h3>상태 파일 폴더 쓰기 권한</h3>
<p><code>raspi_leck_detecter/var/</code> 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).</p>
</div>
<div class="step">
<h3>관리자 비밀번호 해시 생성(서버 CLI)</h3>
<pre><code>php setup_hash.php "새관리자비밀번호"</code></pre>
<p>출력된 bcrypt 해시를 <code>config.local.php</code> 의 <code>ADMIN_PASSWORD_HASH</code> 에 넣습니다.</p>
</div>
<div class="step">
<h3>MFA(TOTP) 등록</h3>
<p><code>MFA_SETUP_TOKEN</code> 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.</p>
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰</code></pre>
<p>화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 <code>ADMIN_TOTP_SECRET</code> 줄을
<code>config.local.php</code> 에 반영 → <b>등록 후 <code>MFA_SETUP_TOKEN</code> 을 빈 값으로</b> 바꿉니다.</p>
<figure>
<img class="cap" src="screenshots/04-mfa-setup.png" alt="그림 4 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 4 <span class="fname">→ screenshots/04-mfa-setup.png</span></div>
<div class="ph-what"><b><code>setup_mfa.php</code> 등록 화면</b>(Base32 수동 입력 키 + 6자리 코드 “확인됨” 상태). 비밀키 노출 주의 — 캡처 시 키 일부 가림 권장</div>
</div>
<figcaption>그림 4. 관리자 MFA(TOTP) 등록 화면</figcaption>
</figure>
</div>
<div class="step">
<h3>설치 점검 화면 확인</h3>
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_wizard.php</code></pre>
<p>확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 ·
<code>var/</code> 쓰기 권한 · <b>테스트 SMS 발송</b>.</p>
<figure>
<img class="cap" src="screenshots/05-setup-wizard.png" alt="그림 5 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 5 <span class="fname">→ screenshots/05-setup-wizard.png</span></div>
<div class="ph-what"><b><code>setup_wizard.php</code> 점검 결과</b>(모든 항목 ✅ + 테스트 SMS “발송 성공”). 수신번호는 마스킹되어 표시됨</div>
</div>
<figcaption>그림 5. 설치 점검(setup_wizard) 통과 화면</figcaption>
</figure>
</div>
</div>
</section>
<section id="A-cron">
<span class="part-tag">PART A · Cafe24 서버</span>
<h2>7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션</h2>
<p><code>cron_heartbeat.php</code> 를 <b>1분 간격</b>으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).</p>
<h4>방법 A — 서버 CLI cron</h4>
<pre><code>* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php</code></pre>
<h4>방법 B — HTTP 호출(외부 스케줄러)</h4>
<pre><code>https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=&lt;CRON_SECRET&gt;</code></pre>
<p class="small"><code>CRON_SECRET = HMAC-SHA256("cron", API_KEY)</code> 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/<code>phpinfo()</code>로 확인.
마지막 수신 후 <code>HEARTBEAT_TIMEOUT_SEC</code>(기본 1200초=20분) 초과 시 오프라인 판정.</p>
<div class="note"><b>cron이 없으면?</b> 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단,
완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 <b>cron 등록을 권장</b>합니다.</div>
<figure>
<img class="cap" src="screenshots/06-cron-register.png" alt="그림 6 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 6 <span class="fname">→ screenshots/06-cron-register.png</span></div>
<div class="ph-what">Cafe24 <b>크론탭(예약작업) 등록 화면</b> — <code>cron_heartbeat.php</code> 가 1분 주기로 등록된 목록</div>
</div>
<figcaption>그림 6. Cafe24 cron(예약작업) 등록</figcaption>
</figure>
</section>
<!-- ====================================================================
PART B — STM32 MCU
===================================================================== -->
<section id="B-wiring">
<span class="part-tag">PART B · STM32 MCU</span>
<h2>8. 하드웨어 배선 (STM32F407VGT6)</h2>
<p>LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.</p>
<figure>
<svg viewBox="0 0 920 430" role="img" aria-label="STM32F407 SHT30 보드 배선도">
<defs><style>
.mcu{fill:#0f172a;}
.blk{fill:#fff;stroke-width:2;rx:10;}
.tt{font-family:'Noto Sans KR',sans-serif;font-size:13px;font-weight:700;fill:#0f172a;text-anchor:middle;}
.ss{font-family:'Noto Sans KR',sans-serif;font-size:10.5px;fill:#475569;text-anchor:middle;}
.pn{font-family:'Consolas',monospace;font-size:11px;font-weight:700;fill:#0b3b8c;}
.wt{font-family:'Consolas',monospace;font-size:10px;fill:#334155;}
.w{stroke-width:2.4;fill:none;}
</style></defs>
<!-- MCU -->
<rect x="360" y="120" width="200" height="190" rx="14" class="mcu"/>
<text x="460" y="150" text-anchor="middle" font-family="Noto Sans KR" font-size="15" font-weight="700" fill="#fff">STM32F407VGT6</text>
<text x="460" y="170" text-anchor="middle" font-family="Noto Sans KR" font-size="11" fill="#cbd5e1">168MHz · sht30_fw</text>
<text x="460" y="300" text-anchor="middle" font-family="Consolas" font-size="10" fill="#94a3b8">HSE 8MHz→PLL · IWDG 20s · LSE RTC</text>
<!-- SHT30 (left) -->
<rect x="40" y="150" width="150" height="120" class="blk" stroke="#0f766e"/>
<text class="tt" x="115" y="178">SHT30</text><text class="ss" x="115" y="196">P4422-3 · I²C 0x44</text>
<text class="pn" x="58" y="222">VDD</text><text class="pn" x="58" y="240">SCL</text><text class="pn" x="58" y="258">SDA</text><text class="pn" x="150" y="258">GND</text>
<!-- wires SHT30 -> MCU (PB6/PB7) -->
<path class="w" stroke="#f97316" d="M150,218 H 300 V 200 H 360"/><text class="wt" x="250" y="194">3.3V</text>
<path class="w" stroke="#22c55e" d="M150,236 H 320 V 225 H 360"/><text class="wt" x="300" y="219">PB6 SCL</text>
<path class="w" stroke="#3b82f6" d="M150,256 H 335 V 250 H 360"/><text class="wt" x="305" y="266">PB7 SDA</text>
<path class="w" stroke="#111827" d="M150,272 H 300 V 280 H 360"/><text class="wt" x="250" y="296">GND</text>
<!-- pullups -->
<text x="250" y="150" text-anchor="middle" font-family="Consolas" font-size="10" fill="#b45309">4.7kΩ↑3.3V (SCL/SDA)</text>
<!-- LAN8720 (top) -->
<rect x="610" y="40" width="270" height="92" class="blk" stroke="#1a56db"/>
<text class="tt" x="745" y="66">LAN8720 PHY (RMII · AF11)</text>
<text class="wt" x="745" y="86" text-anchor="middle">PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV</text>
<text class="wt" x="745" y="102" text-anchor="middle">PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1</text>
<text class="ss" x="745" y="122">MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK</text>
<path class="w" stroke="#1a56db" d="M610,110 H 540 V 120"/>
<!-- USART3 + LED (bottom) -->
<rect x="610" y="330" width="270" height="80" class="blk" stroke="#92400e"/>
<text class="tt" x="745" y="356">콘솔 &amp; 상태 LED</text>
<text class="wt" x="745" y="376" text-anchor="middle">USART3: PD8 TX / PD9 RX (AF7, 115200 8N1)</text>
<text class="wt" x="745" y="392" text-anchor="middle">외부 3.3V USB-UART · 상태 LED = PD12</text>
<path class="w" stroke="#92400e" d="M610,360 H 540 V 300 H 560"/>
<!-- 범례 -->
<text x="40" y="300" font-family="Noto Sans KR" font-size="10.5" fill="#64748b">※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음.</text>
<text x="40" y="316" font-family="Noto Sans KR" font-size="10.5" fill="#991b1b">※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9).</text>
</svg>
<figcaption>그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)</figcaption>
</figure>
<h3>8.1 SHT30 I²C1</h3>
<table>
<thead><tr><th>SHT30</th><th>STM32 핀</th><th>비고</th></tr></thead>
<tbody>
<tr><td>VDD</td><td>3.3V</td><td rowspan="4">SCL/SDA 각각 <b>4.7kΩ → 3.3V</b> 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 <code>0x2C06</code> → ~20ms → 6바이트 read</td></tr>
<tr><td>SCL</td><td class="pin">PB6</td></tr>
<tr><td>SDA</td><td class="pin">PB7</td></tr>
<tr><td>GND / ADDR</td><td>GND</td></tr>
</tbody>
</table>
<h3>8.2 LAN8720 RMII (AF11)</h3>
<table>
<thead><tr><th>STM32 핀</th><th>RMII 신호</th><th>STM32 핀</th><th>RMII 신호</th></tr></thead>
<tbody>
<tr><td class="pin">PA1</td><td>ETH_REF_CLK (50MHz 입력)</td><td class="pin">PC4</td><td>ETH_RXD0</td></tr>
<tr><td class="pin">PA2</td><td>ETH_MDIO ⚠️ (USART2 금지)</td><td class="pin">PC5</td><td>ETH_RXD1</td></tr>
<tr><td class="pin">PC1</td><td>ETH_MDC</td><td class="pin">PB11</td><td>ETH_TX_EN</td></tr>
<tr><td class="pin">PA7</td><td>ETH_CRS_DV</td><td class="pin">PB12/PB13</td><td>ETH_TXD0 / TXD1</td></tr>
</tbody>
</table>
<h3>8.3 콘솔 · 상태 LED · 클럭</h3>
<ul>
<li>콘솔 UART: <span class="pin">PD8</span>(TX)/<span class="pin">PD9</span>(RX), USART3 AF7, <b>115200 8N1</b>, 외부 3.3V USB-UART 어댑터.</li>
<li>상태 LED: <span class="pin">PD12</span> — 부팅=점등 / 정상보고=느린 토글 / 망단절·TLS실패=빠른 점멸.</li>
<li>클럭: HSE 8MHz→168MHz, RMII REF_CLK 50MHz, RTC <b>LSE 32.768kHz</b>(권장; 정전 시 VBAT 시간 보존), IWDG=LSI 20s.</li>
</ul>
</section>
<section id="B-build">
<span class="part-tag">PART B · STM32 MCU</span>
<h2>9. 폐쇄망 펌웨어 빌드</h2>
<div class="crit"><b>폐쇄망 원칙.</b> 다운로드는 <b>인터넷 가능한 빌드 머신에서만</b> 일어나고, 결과물 <code>.bin</code> 이 에어갭을
넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.</div>
<div class="steps">
<div class="step">
<h3>의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)</h3>
<pre><code>bash firmware/scripts/vendor.sh # 또는 vendor.ps1
# 릴리스 빌드는 핀(SHA-256) 강제:
VENDOR_STRICT=1 bash firmware/scripts/vendor.sh</code></pre>
<p class="small">STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 <code>firmware/third_party/</code> 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.</p>
</div>
<div class="step">
<h3>비밀값 — <code>secrets.h</code></h3>
<pre><code>cp firmware/common/secrets.h.example firmware/common/secrets.h
# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.</code></pre>
<div class="warn"><code>secrets.h</code> 는 커밋 금지. 산출 <code>.bin</code> 에 키가 임베드되므로 <code>.bin</code> 자체를 비밀로 취급합니다.</div>
</div>
<div class="step">
<h3>서버 루트 CA 임베드 — <code>certs/server_ca.c</code> <span style="color:var(--crit)">(필수)</span></h3>
<pre><code>openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts &lt;/dev/null
# → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
# (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)</code></pre>
<div class="crit"><b>자리표시자 CA로는 부팅 불가.</b> 기본 <code>server_ca.c</code> 는 PLACEHOLDER라 <code>tls_init()</code>가
실패해 부팅이 정지(fail-closed)합니다. <code>build.ps1</code> 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.</div>
</div>
<div class="step">
<h3>설정값 확인 — <code>app_config.h</code></h3>
<table>
<thead><tr><th>설정</th><th>값</th></tr></thead>
<tbody>
<tr><td><code>APP_API_HOST</code> / <code>APP_API_PATH</code></td><td>운영 도메인 / <code>/raspi_leck_detecter/api/sensor_data.php</code></td></tr>
<tr><td><code>APP_SNTP_SERVER</code> ⚠️</td><td><b>폐쇄망 LAN NTP 서버 IP</b>(기본 <code>192.0.2.123</code> 자리표시자 — 반드시 교체)</td></tr>
<tr><td><code>APP_SHT30_I2C_ADDR</code> / <code>APP_SHT30_REPORT_INTERVAL_SEC</code></td><td>0x44 / 300(5분)</td></tr>
</tbody>
</table>
<div class="crit"><b>SNTP는 폐쇄망 전면장애 지점.</b> 인터넷 NTP(<code>pool.ntp.org</code>)는 폐쇄망에서 도달 불가 →
시간 미동기 → <code>timestamp=0</code> → TLS 차단 + 서버 전량 거부. <b>반드시 사내 LAN NTP IP</b>로 설정하세요.</div>
</div>
<div class="step">
<h3>빌드 (cmake + ninja)</h3>
<pre><code>cmake -S firmware -B firmware/build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex</code></pre>
<p class="small">(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: <code>python3 firmware/test/host/parity_test.py</code></p>
</div>
</div>
<figure>
<img class="cap" src="screenshots/08-firmware-build.png" alt="그림 8 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 8 <span class="fname">→ screenshots/08-firmware-build.png</span></div>
<div class="ph-what">빌드 머신 터미널의 <b><code>cmake --build … --target sht30_fw</code> 성공 로그</b>(산출물 <code>sht30_fw.bin/.hex/.elf</code> + <code>arm-none-eabi-size</code> 출력)</div>
</div>
<figcaption>그림 8. 펌웨어 빌드 성공 로그</figcaption>
</figure>
</section>
<section id="B-flash">
<span class="part-tag">PART B · STM32 MCU</span>
<h2>10. 에어갭 전달 · 플래시 · 콘솔 확인</h2>
<div class="steps">
<div class="step">
<h3>무결성 해시 + 신뢰 매체 전달</h3>
<pre><code>sha256sum firmware/build/sht30_fw.bin &gt; FW_HASHES.txt # 빌드 머신
# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조</code></pre>
</div>
<div class="step">
<h3>ST-Link 플래시 (격리 호스트)</h3>
<pre><code>pwsh firmware/scripts/flash.ps1 # 권장(자동 탐지)
# 대안:
st-flash write sht30_fw.bin 0x08000000
STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst</code></pre>
<figure>
<img class="cap" src="screenshots/09-flash.png" alt="그림 9 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 9 <span class="fname">→ screenshots/09-flash.png</span></div>
<div class="ph-what"><b>플래싱 툴 화면</b>(STM32CubeProgrammer 또는 <code>st-flash</code>의 “Flash written / verified” 결과)</div>
</div>
<figcaption>그림 9. STM32 펌웨어 플래시 완료</figcaption>
</figure>
</div>
<div class="step">
<h3>콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)</h3>
<p>외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:</p>
<pre><code>bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
→ TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200</code></pre>
<p class="small">I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면
<code>API_KEY</code>↔<code>APP_API_KEY</code>, TLS 실패면 <code>server_ca.c</code>/SNTP를 재점검.</p>
<figure>
<img class="cap" src="screenshots/10-serial-console.png" alt="그림 10 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 10 <span class="fname">→ screenshots/10-serial-console.png</span></div>
<div class="ph-what"><b>시리얼 터미널(USART3) 부팅 로그</b> — SNTP 동기 → TLS OK → 서버 200 응답 라인이 보이는 화면</div>
</div>
<figcaption>그림 10. STM32 콘솔 부팅 로그(정상 보고)</figcaption>
</figure>
</div>
</div>
</section>
<!-- ============================== 검증 ============================== -->
<section id="verify">
<h2>11. 최종 검증 (종단)</h2>
<p>서버에서 아래 화면을 확인합니다.</p>
<pre><code>https://your-domain.example/raspi_leck_detecter/setup_wizard.php
https://your-domain.example/raspi_leck_detecter/dashboard.php
https://your-domain.example/raspi_leck_detecter/monthly_report.php
https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pre>
<div class="ok">
<b>정상 설치 기준</b>
<ul style="margin:6px 0 0;">
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
<li><code>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
</ul>
</div>
<figure>
<img class="cap" src="screenshots/11-dashboard.png" alt="그림 11 캡처" onerror="this.style.display=&#39;none&#39;;this.nextElementSibling.hidden=false;">
<div class="shot" hidden>
<div class="cam">📷</div>
<div class="ph-title">캡처 위치 — 그림 11 <span class="fname">→ screenshots/11-dashboard.png</span></div>
<div class="ph-what"><b>운영 <code>dashboard.php</code> 화면</b>(상단 “정상 감시 중” + 2번 센서 SHT30 온/습도 카드 + 30일 요약). 데스크톱·모바일 2장 권장</div>
</div>
<figcaption>그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일</figcaption>
</figure>
</section>
<!-- ============================== 문제해결 ============================== -->
<section id="trouble">
<h2>12. 문제 해결</h2>
<table>
<thead><tr><th>증상</th><th>확인 지점</th></tr></thead>
<tbody>
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
<tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr>
<tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr>
<tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</code>, 펌웨어 측정 로그</td></tr>
<tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr>
<tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>
</tbody>
</table>
</section>
<!-- ============================== 보관값 ============================== -->
<section id="secrets">
<h2>13. 운영자가 별도 보관할 값</h2>
<div class="warn">아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.</div>
<ul>
<li>Cafe24 DB 이름 / 계정 / 비밀번호</li>
<li><code>API_KEY</code> = 펌웨어 <code>APP_API_KEY</code></li>
<li>Cafe24 SMS user id / secure key / 발신번호 / 수신자 번호</li>
<li>관리자 계정·원문 비밀번호·<code>ADMIN_TOTP_SECRET</code></li>
<li>서버 업로드 경로, LAN NTP 서버 IP, 장비 ID(<code>stm32-sht30-01</code>)와 설치 위치</li>
</ul>
</section>
<!-- ============================== 부록 ============================== -->
<section id="appendix">
<h2>부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)</h2>
<div class="warn"><b>주의:</b> 아래 캡처는 <b>구버전 v2605 누수감지(카메라/사진) UI</b> 또는 <b>Raspberry Pi</b> 기준이라
현재 v2606 STM32/온습도 시스템 화면과 <b>다릅니다</b>. 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는
<b>현재 운영 시스템에서 직접 캡처한 이미지</b>를 넣으세요.</div>
<p>본 HTML 파일은 <code>docs/</code> 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):</p>
<table>
<thead><tr><th>경로</th><th>내용</th><th>상태</th></tr></thead>
<tbody>
<tr><td><code>examples/captures/admin_dashboard_overview.png</code></td><td>관리자 대시보드 개요</td><td>⚠️ v2605 누수 UI</td></tr>
<tr><td><code>examples/captures/admin_dashboard_mobile.png</code></td><td>대시보드 모바일</td><td>⚠️ v2605</td></tr>
<tr><td><code>examples/captures/admin_dashboard_incident.png</code></td><td>경보/사고 카드</td><td>⚠️ v2605(사고/사진)</td></tr>
<tr><td><code>evidence/mfa_setup_page_capture.png</code></td><td>MFA 등록 페이지</td><td>참고(개념 동일)</td></tr>
<tr><td><code>examples/assets/sht30_sensor2_wiring.svg</code></td><td>배선도</td><td>⚠️ Raspberry Pi 기준</td></tr>
<tr><td><code>assets/security_plan/generated/security_plan_target_architecture.png</code></td><td>목표 아키텍처</td><td>보안계획 다이어그램</td></tr>
</tbody>
</table>
<div class="gallery">
<figure><img src="examples/captures/admin_dashboard_overview.png" alt="v2605 대시보드 개요"><figcaption>⚠️ v2605 대시보드 개요(누수 UI)</figcaption></figure>
<figure><img src="examples/captures/admin_dashboard_mobile.png" alt="v2605 모바일"><figcaption>⚠️ v2605 모바일</figcaption></figure>
<figure><img src="examples/captures/admin_dashboard_incident.png" alt="v2605 사고 카드"><figcaption>⚠️ v2605 사고/사진 카드</figcaption></figure>
<figure><img src="evidence/mfa_setup_page_capture.png" alt="MFA 등록 페이지"><figcaption>MFA 등록 페이지(참고·개념 동일)</figcaption></figure>
<figure><img src="examples/assets/sht30_sensor2_wiring.svg" alt="RPi 배선도"><figcaption>⚠️ 배선도(Raspberry Pi 기준)</figcaption></figure>
<figure><img src="assets/security_plan/generated/security_plan_target_architecture.png" alt="목표 아키텍처"><figcaption>목표 아키텍처(보안계획 다이어그램)</figcaption></figure>
</div>
<p class="small">현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.</p>
</section>
<p class="small" style="text-align:center;margin-top:30px;">
서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(<span class="kbd">Ctrl/Cmd</span>+<span class="kbd">P</span>) 지원
</p>
</div>
</body>
</html>