diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..1f77a59
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,36 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(tail:*)",
+ "Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)",
+ "Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)",
+ "Bash(sort -k2)"
+ ],
+ "deny": [
+ "Bash(rm -rf:*)",
+ "Bash(rm -fr:*)",
+ "Bash(sudo rm:*)",
+ "Bash(chmod -R 777:*)",
+ "Bash(chmod 777:*)",
+ "Bash(dd:*)",
+ "Bash(mkfs:*)",
+ "Bash(:(){ :|:& };:)",
+ "Bash(git push --force:*)",
+ "Bash(git push -f:*)",
+ "Bash(curl:* | sh)",
+ "Bash(curl:* | bash)",
+ "Bash(wget:* | sh)",
+ "Bash(wget:* | bash)",
+ "Bash(ssh:*)",
+ "Bash(scp:*)",
+ "Read(./**/config.local.php)",
+ "Read(./**/config_local.py)",
+ "Read(./**/secrets.h)",
+ "Read(./**/.env)",
+ "Read(./**/.env.*)",
+ "Read(./**/*.pem)",
+ "Read(./**/id_rsa)",
+ "Read(./**/id_ed25519)"
+ ]
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c1efc7..1a5545c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,183 @@
# 변경 이력
+## Unreleased - 기능 추가 (고가치 M: 신뢰성·관측성)
+
+경보가 조용히 죽지 않도록 보장 + 그것을 검증하는 신뢰성·관측성 묶음. 모두 cron/서버/
+CLI 기반(새 상호작용 UI 없음), graceful degrade. (#5 임계·수신자 화면편집은 core config
+로드를 DB 의존으로 바꿔 신뢰성 회귀 위험이 있어 별도 설계로 보류.)
+
+### M#1 수집 거부 사유 카운터 (관측성)
+- `config.php:record_ingest_rejection()/read_ingest_rejections()`(파일 flock 기반, DB 무관
+ → 위조요청 폭주에도 안전). `api/sensor_data.php` 의 모든 거부 분기(403 서명/400 시각/
+ 413 본문/503 키/필드/sensor_id)를 사유별 일자 집계하고, 대시보드 상단에 사유별
+ 건수(서명불일치→키/펌웨어, 시각오차→SNTP/RTC 등 한국어 라벨)로 노출한다. "왜 데이터가
+ 안 들어오는가"를 서버 로그 없이 분별.
+
+### M#2 발송 실패 가시화 + 오프라인 재시도 (신뢰성)
+- `dashboard.php`: 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패하면 '경보 SMS
+ 발송 실패 — 미수신 가능' 배너.
+- `cron_heartbeat.php`: 일회성 오프라인 SMS 발송이 실패하면 `offline_alerted` 를 0 으로
+ 되돌려 다음 주기에 재시도한다(`SMS_RETRY_OFFLINE`). 게이트웨이 일시오류로 장비 다운을
+ 영영 못 알리던 갭을 닫음.
+
+### M#3 미복귀 경보 에스컬레이션 (신뢰성, opt-in)
+- `sms_send.php:maybe_escalate_alerts()`: 임계 경보가 `ESCALATE_AFTER_SEC`(기본 30분) 이상
+ 미복귀로 지속되면 2차(야간) 수신자 `SMS_RECIPIENTS_TIER2` 에게 `[에스컬레이션]` 1회
+ 통지(에피소드당 1회 — 최신 경보-상태가 경보일 때만). TIER2 가 비면 비활성. cron 호출.
+- 대화형 ack 버튼 UI 는 보류(별도 POST 표면 — 후속).
+
+### M#4 센서 고착(live-but-stuck) 검출 (자가신뢰성)
+- `sms_send.php:maybe_detect_stuck_sensors()`: 최근 `STUCK_DETECT_SAMPLES`(6) 측정의 온도·
+ 습도 표준편차가 둘 다 `STUCK_DETECT_EPSILON`(0.01, 분해능 이하) 미만이면 소자 고장(같은
+ 값 반복)을 의심해 `[센서고착]` 1회(쿨다운). 하트비트(수신 끊김)로는 못 잡는 무성실패를
+ 보완 — 단일 센서가 거짓말하면 감시 전체가 무력화되므로 핵심.
+
+### M#6 카오스 드릴 (검증)
+- `SMS_FORCE_FAIL` 게이트(`send_sms_cafe24` 가 실제 전송 없이 실패 반환)와
+ `tests/chaos_drill.php`(CLI) — 위 실패-의존 기능이 동작하는지 손시연 없이 검증.
+ 라이브 종단 드릴 절차는 파일 헤더에 문서화.
+
+### 테스트
+- `tests/threshold_sms_test.php`: `stddev_pop` 단위 테스트(고착 판정).
+- `tests/security_hardening_test.php`: M#1~#6 소스 회귀 고정.
+- `run_tests.sh`: 드릴 포함.
+
+### 보류(별도 설계 필요)
+- 경보 ack 버튼 UI(대화형 POST), 임계·수신자 화면편집(#5, core config DB-override —
+ DB 장애 시 config 로드 실패 → 사이트 다운 위험), 풀 카오스 하네스(prod DB 주입).
+
+## Unreleased - 기능 추가 (①시계동기 ②경보 생존성/결로/개인정보)
+
+전수 아이디에이션에서 도출한 우선순위 1~2(①+②)를 구현했다. 외부 의존 0, 대부분
+기존 파이프라인 재사용.
+
+### ① 폐쇄망 시계동기 — 인터넷 NTP 의존 제거 (잠재 전면장애 해소)
+- **`firmware/common/app_config.h`: `APP_SNTP_SERVER` 기본값을 `pool.ntp.org`(인터넷)에서
+ LAN NTP 자리표시자(`192.0.2.123`, RFC5737)로 교체**하고 폐쇄망 필수 설정임을 명시했다.
+ 진짜 폐쇄망에서 인터넷 NTP 는 도달 불가 → SNTP 실패 → RTC 미설정 → `timestamp=0` →
+ TLS 차단 + 서버 신선도 위반 전량거부 = **보고 전면장애**였다(제약 위반 상태 해소).
+- `api/sensor_data.php`: 200 응답에 `server_now`(서버 Unix 시각)를 추가했다. 펌웨어가
+ RTC 드리프트를 서버시각으로 미세보정(LSI 드리프트/간헐 NTP 대비)하는 **후속 펌웨어
+ 작업의 입력**이다(서버측 선반영; 보안 영향 없음).
+ - TODO(운영): 배포 전 `APP_SNTP_SERVER` 를 실제 LAN NTP IP 로 설정. 최초 시각 확보는
+ LAN NTP(또는 배터리 RTC)가 필요하며, `server_now` 소비(RTC 보정)는 펌웨어 후속 과제.
+
+### ②a SMS 잔액 사전경보
+- `sms_send.php`: Cafe24 응답의 잔여 건수를 파싱(`send_sms_cafe24` 가 `remaining` 반환)하고,
+ 임계(`SMS_BALANCE_WARN_THRESHOLD`, 기본 30) 이하이면 쿨다운(`SMS_BALANCE_WARN_COOLDOWN_SEC`)
+ 적용 `[SMS 잔액부족]` 경고를 1회 발송한다(`maybe_warn_sms_balance`). 추가 외부호출 0.
+- `config.php:latest_sms_balance()` + `ops_checks.php` 에 'SMS 잔여 건수' 점검 추가.
+
+### ②b 일일 헬스(생존신호) SMS — 데드맨
+- `sms_send.php:maybe_send_daily_health_sms()`: 하루 1통 `[정기점검]` 요약(정상 센서 수/
+ 최근 수신/24h 경보/SMS 잔여)을 발송한다. `HEALTH_SMS_HOUR`(기본 9시) 이후 첫 호출에서,
+ 날짜 dedup + 네임드 락으로 중복 없이 1회. `cron_heartbeat.php` 가 호출. '무소식=정상'
+ 과 '무소식=경보경로 전체고장'의 모호성을 제거(관리자는 "안 오면 점검"만 기억).
+
+### ②c 이슬점/결로 경보
+- `sms_send.php`: Magnus 이슬점(`dew_point_c`)·결로마진(`condensation_margin_c`)을 추가하고,
+ `(현재온도−이슬점) ≤ METRIC_DEWPOINT_MARGIN_C`(기본 3℃)이면 새 종류 `condensation_risk`/
+ `[결로주의]` 경보를 발화한다. `evaluate_metric_thresholds`(말미 추가)·`metric_is_recovered`
+ (결로 해소 히스테리시스 포함)·`send_metric_alert_sms`(라벨)·`sensor_data.php`(표시값=결로마진)
+ 에 통합. 온도·습도가 각각 정상이어도 잡히는 결합 위험(차가운 랙면 결로)을 단일 SHT30
+ 산술만으로 포착 — 누수감지기 후신인 이 시스템의 본래 목적에 부합.
+
+### ②d 수신번호 개인정보 마스킹
+- `config.php:mask_phone()`(010-****-1234)을 `dashboard.php`·`setup_wizard.php` 의 수신번호
+ 표시에 적용했다. 발송 기록(`sms_log`)은 보존하되 화면 표시만 최소화(개인정보 최소표시).
+
+### 테스트
+- `tests/threshold_sms_test.php`: 이슬점/결로마진/해소·`mask_phone` 경계 테스트(기존
+ 임계 벡터는 결로 미발화 확인 — 회귀 없음).
+- `tests/security_hardening_test.php`: ①+② 소스 회귀 고정(SNTP 비-인터넷, server_now,
+ 잔액/헬스/결로/마스킹 상수·함수·호출).
+
+## Unreleased - 개선 구현 (다중 에이전트 전수 점검 반영)
+
+전체 서브시스템(펌웨어/PHP/DB/문서/빌드)을 다중 에이전트로 매핑·교차검증하여 도출한
+개선 목록을 구현했다. **호환성 유지(가산/멱등)** 를 원칙으로 했으며, 새 DB 테이블은
+없어도 동작하도록 graceful degrade 한다.
+
+### 보안 / 신뢰성 (서버 PHP)
+- **재전송(replay) 멱등 방어 추가.** `api/sensor_data.php` 가 인증·신선도를 통과한 요청
+ 서명(X-Signature)을 `ingest_nonce` 테이블에 원자적으로 기록한다. 시계오차 창
+ (`INGEST_MAX_CLOCK_SKEW_SEC`) 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처
+ 재생) 중복 저장·중복 SMS 없이 **멱등 200** 으로 응답한다. 저장 실패 시 nonce 를
+ 되돌려 재시도가 재처리되게 한다(유실 방지). 테이블이 없으면 자동 비활성
+ (`INGEST_NONCE_ENABLED`).
+- **임계 경보 SMS 중복 발송 경합 제거.** `sms_send.php:send_metric_alert_sms()` 의
+ 쿨다운 검사→발송→기록 구간을 `(센서,종류)` 단위 MySQL 네임드 락(`GET_LOCK`)으로
+ 직렬화한다. 락 미지원 환경에서는 종전 동작으로 폴백한다(가용성 우선).
+- **SMS 길이 캡.** Cafe24 단문('S', EUC-KR ≤90B) 한도를 넘는 메시지를 발송 전
+ 멀티바이트 경계 보존하며 자른다(`sms_truncate_euckr`, `SMS_MAX_EUCKR_BYTES`). 위치/
+ 센서명이 길 때 게이트웨이의 무성 절단/거부를 막는다.
+- **경보 태그 단일 출처화.** `[고온경보]` 등 상태 태그를 `config.php` 의 `SMS_TAG_*`
+ 상수와 `sms_summary_select_sql()` 헬퍼로 일원화하고, `sms_send/dashboard/monthly_report`
+ 의 하드코딩 LIKE 리터럴을 제거했다. 태그를 한 곳만 바꿔도 쿨다운/복구/대시보드/월간
+ 보고서가 일관 유지된다(문자열 어긋남 무성 장애 방지).
+- **세션 절대 수명 캡 추가 + 모든 인증 페이지로 확대.** 유휴 타임아웃(`SESSION_TIMEOUT`)
+ 만으로 연속 활동 세션이 무한정 유지되던 문제를 로그인 시각 기준 절대 수명
+ (`SESSION_ABSOLUTE_TIMEOUT`, 기본 12시간)으로 보완했다. 타임아웃 검사를
+ `admin_security.php:admin_enforce_session_timeout()` 공통 헬퍼로 일원화하고
+ `dashboard.php`·`monthly_report.php`·`security_evidence.php`·`setup_wizard.php` 전부에서
+ 호출한다(이전엔 대시보드에서만 검사돼 다른 인증 페이지로 우회 가능했던 갭을 닫음).
+- **반사형 XSS 차단.** `test_mobile.php` 가 User-Agent 를 그대로 반사하던 것을
+ `text/plain` + `htmlspecialchars` 로 막았다.
+- **입력 검증.** `sensor_data.php` 가 `sensor_id` 범위(TINYINT 1..255)를 경계에서 검사해
+ 500 대신 깔끔한 400 을 반환한다.
+- **운영 자가진단 보강.** `ops_checks.php` 가 잔존 `MFA_SETUP_TOKEN` 과 활성화된 레거시
+ 서명 폴백(`ALLOW_LEGACY_BODY_SIGNATURE`)을 경고한다. `config.local.example.php` 에
+ `ALLOW_LEGACY_BODY_SIGNATURE => false` 를 명시했다.
+
+### DB
+- `sql/schema_sht30.sql` · `sql/migration_drop_leak.sql`: 재전송 멱등용 `ingest_nonce`
+ 테이블을 가산·멱등(`CREATE TABLE IF NOT EXISTS`)으로 추가했다.
+
+### 펌웨어 / 빌드 (폐쇄망)
+- **호스트 C 패리티 추가(미검증 갭 해소).** `firmware/test/host/sig_cli.c` 와
+ `parity_test.py` 가 펌웨어 실제 서명 구현(`common/sig.c`)을 호스트 컴파일·실행하여
+ Python 레퍼런스(`reference.py`)·서버(`verify_signature_raw`)와 **바이트 단위 일치**를
+ 검증한다(C↔Python↔PHP 3자). 이전엔 C 가 어떤 테스트에서도 컴파일되지 않았다.
+- **빌드 시 자리표시자 CA 차단.** `build.ps1` 이 `server_ca.c` 의 PLACEHOLDER 를
+ 검출하면 빌드를 중단한다(부팅 시 `bsp_fatal` 로 정지하던 것을 빌드 단계에서 선차단;
+ `-AllowPlaceholderCA` 로 강행 가능).
+- **벤더 핀 엄격 모드.** `vendor.sh`(`VENDOR_STRICT=1`)·`vendor.ps1`(`-Strict`)가 SHA-256
+ 핀 미설정(TODO) 의존성이 있으면 실패한다(릴리스 재현성 강제).
+- **stale 주석 정정.** `net.c`/`lwipopts.h` 의 RNG `TODO(hw)` 주석을 정정했다 —
+ HW RNG `bsp_rand32()` 는 `bsp.c` 에 이미 구현되어 weak 폴백을 덮어쓴다(예측성 완화는
+ 이미 적용됨). `sig.h`/`secrets.h.example` 의 `sha256(key||body)` 표기를 HMAC 로 정정.
+
+### 문서
+- **서명 방식 문서 드리프트 일괄 정정(16개 파일).** 운영/보안 문서의
+ `sha256(API_KEY+body)`(키-접두) 표기를 실제 구현인
+ `HMAC-SHA256(키=API_KEY, 메시지=요청본문)` 으로 정정했다.
+- `SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md` 의 **거짓 일치 인증(false all-clear)** 을
+ 바로잡았다(서명 행을 HMAC 로 갱신 + §2.1 추가).
+- `out_of_range` 가 펌웨어 plausibility 전용이며 DB(`sensor_metric.metric_status`)에는
+ `normal/high_temp/low_temp/high_humidity/low_humidity` 만 저장됨을 명시
+ (`wiring_diagram.md`, `HARDWARE.md`, `PORTING_NOTES.md`, 펌웨어 `README.md`).
+- `BUILD_OFFLINE.md` 의 비실존 스크립트/툴체인 이름 정정(`vendor_deps.* → vendor.*`,
+ `arm-none-eabi.cmake → arm-none-eabi-toolchain.cmake`). 웹루트 경로가 펌웨어
+ `app_config.h:APP_API_PATH` 와 일치해야 함을 교차참조.
+- HWP 보안대책서 **과대 스코프** 경고를 명문화(온습도 전용; 화재/누수/정전/카메라
+ 미구현). 원본 `.hwp` 는 운영자가 동일하게 정정 필요(`hwp_extracted_text.txt` 배너 +
+ `SECURITY_PLAN_HWP_REVIEW`/`PASS_READINESS`).
+
+### 테스트
+- `run_tests.sh`(전체 러너; php/python3/gcc 부재 시 SKIP 명시) 추가.
+- `tests/threshold_sms_test.php`: `sms_truncate_euckr()` 경계 테스트(EUC-KR 멀티바이트).
+- `tests/security_hardening_test.php`: 위 개선(nonce/락/SMS캡/태그상수/세션캡/XSS/ops)
+ 소스 회귀 고정.
+
+### 운영자 필수 조치 (코드로 끝낼 수 없는 항목)
+- **(CRITICAL)** `firmware/certs/server_ca.c` 를 실제 Cafe24 루트 CA 로 교체(자리표시자
+ 상태로는 부팅 불가; 이제 빌드 단계에서도 차단됨).
+- **(HIGH)** 원본 `.hwp` 보안대책서를 온습도 전용 스코프로 정정 후 제출.
+- `vendor.{sh,ps1}` 의 SHA-256 핀을 네트워크 빌드 머신에서 채우기(이후 `VENDOR_STRICT`
+ 로 강제). 신규/전환 DB 에 `ingest_nonce` 생성(스키마/마이그레이션 재실행). 운영
+ `config.local.php` 에서 `MFA_SETUP_TOKEN` 비우기, `ALLOW_LEGACY_BODY_SIGNATURE=false`
+ 확인.
+
## Unreleased - 보안 하드닝 (전체 코드리뷰 반영)
### ⚠️ 호환성 주의 (BREAKING)
diff --git a/PR_BODY.md b/PR_BODY.md
new file mode 100644
index 0000000..b25734d
--- /dev/null
+++ b/PR_BODY.md
@@ -0,0 +1,149 @@
+# RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
+
+## 1. 배경 / 목적
+
+서버실 모니터링을 **하나의 시스템**으로 통합한다. Raspberry Pi 4 센서 모듈 2개가 각각
+cafe24 서버로 데이터를 전송하고, 서버가 저장·임계판정·SMS·**단일 대시보드 표시**를 담당한다.
+
+- **1번 모듈 — 누수 + 카메라** (`raspberry_pi4/`, `sensor_id=1`)
+- **2번 모듈 — 온습도 SHT30** (`raspberry_pi4_sht30/`, `sensor_id=2`)
+
+기존에는 온습도(이 repo의 `php/` 서버)와 누수(레거시 누수 서버)가 **분리**되어 있었고
+서명 방식도 달랐다. 본 PR로 **두 모듈을 동일 서버·동일 서명(X-Signature HMAC)으로 통합**하여
+대시보드 한 화면에서 **온습도 + 누수 상태 + 현장 사진**을 함께 본다.
+
+```
+┌──────────────────────────┐
+│ 1번 모듈 (누수+카메라) │ leak_alert/recovery + 사진
+│ raspberry_pi4/ │──┐
+└──────────────────────────┘ │ HTTPS POST
+ ▼ X-Signature = HMAC-SHA256(API_KEY, body)
+┌──────────────────────────┐ ┌─────────────────────────────┐
+│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │
+│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │
+└──────────────────────────┘ │ api/photo_upload.php (사진) │
+ │ → MySQL / 임계·누수 SMS │
+ │ → dashboard.php (통합 대시보드)│
+ └─────────────────────────────┘
+```
+
+---
+
+## 2. 2번 모듈 신규 — `raspberry_pi4_sht30/` (온습도)
+
+STM32 펌웨어 `sht30_fw` 동작을 **Raspberry Pi 4 + Python**으로 이식.
+
+| 파일 | 역할 |
+|---|---|
+| `sht30_sensor.py` | 메인 — 5분 주기 측정 → 서명 POST → `startup`→`periodic`, SIGTERM 정상종료 |
+| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 변환 (smbus2, 미가용 시 시뮬레이션 폴백) |
+| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 **바이트 동일**) |
+| `config.py` / `config_local.example.py` | 비밀 아닌 기본값 / 운영·비밀값 템플릿(fail-closed) |
+| `install.sh` / `vendor.sh` | 폐쇄망 오프라인 + 온라인 설치 / 의존성 사전 다운로드, I2C 자동 활성화 |
+| `test_sensor.py` / `test_connection.py` | CRC·포맷 자가점검 / 서버 서명 POST 점검 |
+| `systemd/sht30-sensor.service` | 부팅 자동 실행 |
+
+- **I2C 배선:** SDA=GPIO2(물리3), SCL=GPIO3(물리5), 3.3V/GND, ADDR=GND→`0x44`, 4.7kΩ 풀업
+- **측정식:** `temp=-45+175·raw_t/65535`, `rh=100·raw_rh/65535`, CRC-8(poly 0x31, init 0xFF)
+- **보고 필드 순서(서명 호환 고정):** `device_id, device_location, sensor_id, sensor_name,
+ event_type, timestamp, metric_type="sht30", temperature_c, humidity_percent, metric_status, app_version`
+- 임계 판정(고온/저온/고습/저습)·SMS는 **전부 서버**가 수행. 모듈은 측정만 보고.
+
+---
+
+## 3. 1번 모듈 통합 — `raspberry_pi4/` (누수+카메라)
+
+- **서명 전환:** 레거시 `sha256(API_KEY + 정렬 JSON)` body-field 서명 → **raw-body
+ `X-Signature` HMAC**(2번 모듈과 동일 스킴). 본문을 고정 순서 문자열로 만들어 그 바이트에
+ 서명하고 그대로 POST(`data=body_bytes`).
+- **누수 이벤트 본문:** `{device_id, device_location, sensor_id, sensor_name, event_type,
+ is_leak, timestamp}`
+- **사진 업로드 서명:** canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음),
+ `X-Signature` 헤더. 서버 `photo_upload.php` 재구성 canonical과 바이트 동일.
+- GPIO 인터럽트 누수감지 / 카메라 촬영 / 재시도 / 스레딩 동작은 유지.
+- `config_local.example.py`: `API_URL`/`PHOTO_UPLOAD_URL`을 통합 서버로, `API_KEY`는 서버와 동일.
+
+---
+
+## 4. 서버 통합 (`php/`)
+
+| 파일 | 변경 |
+|---|---|
+| `api/sensor_data.php` | `leak_alert`/`leak_recovery` 수신, `is_leak` 처리, `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. **SHT30 측정 경로 100% 유지** |
+| `api/photo_upload.php` *(신규)* | 멀티파트 JPEG 업로드. HMAC 서명·timestamp 신선도·`finfo` MIME·크기상한·**서버생성 무작위 파일명**·nonce 멱등·`log_id↔sensor_id` 결속 |
+| `photo.php` *(신규)* | **관리자 인증** 사진 서빙. 파일명 정규식 + `realpath` 봉쇄(경로순회 방어), `readfile` image/jpeg |
+| `dashboard.php` | 누수 패널(센서1 누수/정상 + `leak_incident` 이력 + `photo.php?id=` 썸네일), 이벤트 라벨 `누수감지/누수복구`, 제목 "온습도·누수 통합" |
+| `sms_send.php` | `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만) |
+| `config.php` | `[누수감지]`/`[누수복구]` 태그, 쿨다운 상수, SMS 요약에 누수 카운트 |
+| `var/.htaccess`, `var/photos/.htaccess` *(신규)* | mod_rewrite 무관 `Require all denied` + PHP 실행차단 |
+
+### DB
+- `sql/migration_add_leak.sql` *(신규, 가산·멱등)* — 기존 온습도 서버에 누수 복원
+ (`sensor_log.is_leak`/`sensor_status.is_leak`, `event_type` ENUM에 leak 값,
+ `leak_incident`+`leak_photo` 테이블). `sensor_metric`(SHT30) 보존. 재실행 안전.
+- `sql/schema_sht30.sql` — 신규(통합) 설치용. 누수 요소 포함.
+
+---
+
+## 5. 보안 하드닝 (외부 펜테스트 대비)
+
+**2중 독립 리뷰 → 확정 CRITICAL/HIGH = 0.**
+- 광범위 감사(6도메인 → 적대 반증검증): 고위험 후보 8건 **전부 거짓양성 반증**
+ (TOTP 재생·CRON_SECRET 유추·setup_mfa 재등록 등 — 비번/키 선보유 필요거나 시크릿 미영속화)
+- 바운티 헌터(pre-auth 원격 익스플로잇): **바운티급 0건**. SQLi/경로순회/XSS/SSRF/업로드RCE/
+ `var/` 노출 전부 방어 확인.
+
+적용한 하드닝(저위험, 무회귀):
+1. `cron_heartbeat.php` — `api_key_is_default()` fail-closed (신규설치 예측가능 CRON_SECRET 무력화)
+2. `setup_mfa.php` — MFA 설정완료 후 부트스트랩 토큰 경로 차단
+3. `var/`·`var/photos/` — PHP 실행차단(`engine off` + FilesMatch + RemoveHandler) → 업로드→RCE 폴리글랏 봉쇄
+4. `php/.htaccess` — HSTS 헤더 추가
+
+미반영(문서화): CSP `unsafe-inline` 제거는 대시보드 인라인 스크립트/스타일 nonce 리팩터
+필요 → 회귀위험으로 별도단계. (현 XSS 실위험 없음 — 출력 전부 `htmlspecialchars`)
+
+---
+
+## 6. 검증 내역
+
+- **wire byte-parity:** `wire.py`(2번)·`leak_sensor.py`(1번) 모두 본문/서명이 펌웨어
+ `reference.py` 및 독립 HMAC과 바이트 일치(한글 UTF-8, `"`/`\` 이스케이프, round-half-up,
+ max uint32 ts 엣지케이스 포함). 사진 canonical 클라↔서버 동일.
+- Python: 전 모듈 `py_compile` + import 그래프 + 자가점검(CRC `0xBEEF→0x92` 등) 통과.
+- 적대 검증 2라운드(통합/보안) blocking 0.
+
+---
+
+## 7. 배포 절차
+
+**각 라즈베리파이4** (모듈당 1대 또는 한 대에 둘 다 — 핀 충돌 없음)
+1. 해당 폴더 업로드
+2. `cp config_local.example.py config_local.py` → `API_URL`/`API_KEY`/`DEVICE_ID`(+1번 `PHOTO_UPLOAD_URL`)
+3. `./install.sh` → systemd enable
+
+**cafe24 서버 (1회)**
+1. DB: 기존 온습도 서버 → `sql/migration_add_leak.sql`; 신규 → `sql/schema_sht30.sql`
+2. `php/` 업로드 (신규 `photo.php`, `api/photo_upload.php`, `var/.htaccess` 포함)
+3. `php/var/photos/` 웹서버 쓰기권한
+4. `config.local.php`의 `API_KEY` = 라즈베리파이와 동일
+
+---
+
+## 8. 배포 전 체크리스트
+
+- [ ] cafe24(PHP 있음)에서 문법/테스트:
+ `php -l php/cron_heartbeat.php php/setup_mfa.php php/dashboard.php php/photo.php php/api/photo_upload.php`
+ / `php tests/security_hardening_test.php` *(작업 환경엔 PHP 없어 미실행)*
+- [ ] `migration_add_leak.sql` 1회 적용
+- [ ] `php/var/photos/` 쓰기권한
+- [ ] 운영 위생: `API_KEY` 실값 설정 + MFA 등록 후 `MFA_SETUP_TOKEN` 비우기
+
+---
+
+## 9. 주의 / 리스크
+
+- ⚠️ 본 PR은 **세션 외 기존 미커밋 변경**(docs/firmware/기타 php 등)도 함께 포함(사용자 요청).
+ 순수 이 작업분만 보려면 핵심 경로는 `raspberry_pi4*/`, `php/api/`, `php/photo.php`,
+ `sql/migration_add_leak.sql`, `SYSTEM_OVERVIEW.md`.
+- PHP 정적 실행 검증은 작업 환경 제약으로 미수행 → 위 `php -l` 게이트 필수.
+- 폐쇄망: `requests`/`smbus2` 등 의존성은 `vendor.sh`로 사전 번들(aarch64).
diff --git a/SYSTEM_OVERVIEW.md b/SYSTEM_OVERVIEW.md
new file mode 100644
index 0000000..d732c86
--- /dev/null
+++ b/SYSTEM_OVERVIEW.md
@@ -0,0 +1,74 @@
+# 시스템 전체 구성 — 하나의 모니터링 시스템 (Module 1 + Module 2 → cafe24 서버 → 대시보드)
+
+서버실 모니터링은 **두 개의 Raspberry Pi 4 센서 모듈**이 각각 cafe24 서버로 측정/이벤트를
+보고하고, 서버가 이를 저장·판정(SMS)·**대시보드 표시**하는 하나의 시스템입니다.
+
+```
+┌─────────────────────────────┐
+│ 1번 모듈 (누수 + 카메라) │ GPIO 누수감지 + Pi Camera 촬영
+│ raspberry_pi4/ │──┐ leak_alert / leak_recovery + 사진 업로드
+│ Raspberry Pi 4 + Python │ │ X-Signature(HMAC-SHA256) + photo_upload.php
+└─────────────────────────────┘ │
+ ▼ HTTPS POST
+┌─────────────────────────────┐ ┌──────────────────────────────┐
+│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │
+│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │
+│ Raspberry Pi 4 + Python │ │ api/photo_upload.php (사진) │
+└─────────────────────────────┘ │ → MySQL 저장 / 임계·누수 SMS │
+ HTTPS POST │ → dashboard.php (통합 대시보드)│
+ X-Signature(HMAC-SHA256) └──────────────────────────────┘
+```
+두 모듈 모두 동일한 raw-body `X-Signature`(HMAC-SHA256) 서명으로 **같은 서버**에 보고합니다(통합 완료).
+
+## 모듈 매핑
+
+| 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 |
+|---|---|---|---|---|---|
+| **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` |
+| **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`sensor_metric`) |
+
+두 모듈 모두:
+- 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움.
+- 폐쇄망(air-gapped) 설치 지원 — `vendor.sh`(빌드 머신) → 번들 전송 → `install.sh`(오프라인).
+- `systemd` 유닛으로 부팅 자동 실행.
+- "업로드 + config_local 채우기 + install.sh + systemd enable" 만 하면 운영 가능.
+
+## 서버 (cafe24 `php/`)
+
+- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고.
+- DB 스키마: `sql/schema_sht30.sql` (신규) 또는 `sql/migration_drop_leak.sql` (전환).
+- 대시보드: `dashboard.php` — `sensor_status`/`sensor_metric`/`sms_log` 표시, 기본 차트 sensor 2.
+- 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`.
+
+---
+
+## ✅ 1번 모듈(누수) 서버 통합 상태 — 단일 서버로 통합 완료 (Option 2 구현됨)
+
+이전에 "운영 전 결정 필요"로 남겨 두었던 두 선택지 중 **2번(단일 서버로 통합)** 을 구현했습니다.
+이제 **두 모듈 모두** 이 repo 의 `php/` 서버로 동일한 `X-Signature`(HMAC-SHA256) 서명으로 보고하고,
+하나의 대시보드(`dashboard.php`)에서 **누수 상태/사진 + 온습도**를 함께 봅니다.
+
+구현 내용:
+
+- **서명 통일:** 1번 모듈을 raw-body `X-Signature` HMAC 으로 전환했습니다(2번 모듈과 동일 스킴).
+ 본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다.
+ (구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.)
+ 사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음).
+- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의
+ `leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을
+ 다시 추가했습니다. `sensor_metric`(SHT30)은 그대로 보존합니다.
+- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `sensor_log`/`sensor_status` 갱신,
+ `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지.
+- **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명).
+- **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만).
+
+### 운영 적용 절차 (기존 SHT30 서버에 누수 통합)
+
+1. 기존 서버에서 **`sql/migration_add_leak.sql` 1회 실행**(가산·멱등 — 운영 데이터 보존, 재실행 안전).
+ 신규(통합) 설치는 `sql/schema_sht30.sql` 하나만 실행하면 됩니다(이미 누수 요소 포함).
+2. 아래 서버 파일을 (재)배포:
+ `api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`,
+ `sql/migration_add_leak.sql`.
+3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도).
+4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로,
+ `API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정.
diff --git a/docs/INSTALL_GUIDE.html b/docs/INSTALL_GUIDE.html
new file mode 100644
index 0000000..d071eeb
--- /dev/null
+++ b/docs/INSTALL_GUIDE.html
@@ -0,0 +1,658 @@
+
+
+
+
+
+서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32)
+
+
+
+
+
+
+
+
+
+
이 가이드의 이미지에 대하여. 본 문서의
아키텍처·배선 다이어그램은 코드/HARDWARE.md 기준으로 정확히 생성 한 SVG입니다.
+ Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼
운영 환경에서만 캡처할 수 있는 화면은
+ “📷 캡처 위치” 자리표시 로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다.
+ (저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 —
부록 참조.)
+
+
+
+ 목차
+
+ 시스템 개요 & 아키텍처
+ 설치 전 준비물
+ A. Cafe24 — 파일 업로드
+ A. Cafe24 — config.local.php
+ A. Cafe24 — DB 설치(phpMyAdmin)
+ A. Cafe24 — 권한 & 관리자/MFA
+ A. Cafe24 — cron(오프라인 감지)
+ B. STM32 — 하드웨어 배선
+ B. STM32 — 폐쇄망 빌드
+ B. STM32 — 플래시 & 콘솔 확인
+ 최종 검증(종단)
+ 문제 해결
+ 운영자 보관 값
+ 부록 — 레거시 자산
+
+
+
+
+
+ 1. 시스템 개요 & 아키텍처
+ 단일 STM32F407 보드(sht30_fw, sensor_id=2)가 SHT30 센서를 I²C로 5분마다 읽어,
+ 본문을 HMAC-SHA256으로 서명 해 TLS 1.2 로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 재판정 하여
+ 고온/저온/고습/저습·결로·오프라인 상황을 SMS 로 알리고 웹 대시보드/보고서 를 제공합니다.
+
+
+
+
+
+
+
+
+
+
+ SHT30 온·습도 센서
+
+ STM32F407 sht30_fw FreeRTOS+mbedTLS
+
+ Cafe24 PHP api/sensor_data.php 임계 재판정 + 저장
+
+ MySQL 4 테이블
+
+ SMS · 대시보드 경보/보고서
+ I²C 0x44
+ HMAC TLS 1.2
+
+
+
+ 그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)
+
+
+
+ 구분 설치 위치 역할
+
+ Cafe24 서버 public_html/raspi_leck_detecter/센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서
+ Cafe24 MySQL Cafe24 DB 센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태
+ STM32F407VGT6 펌웨어 플래시 (sht30_fw) SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명
+ 서버 cron Cafe24 cron 또는 외부 스케줄러 cron_heartbeat.php 주기 실행(오프라인 감지·헬스·에스컬레이션)
+
+
+ 웹 루트 경로 고정. 폴더명 raspi_leck_detecter 는 레거시 이름이지만 펌웨어
+ app_config.h 의 APP_API_PATH 와 반드시 일치 해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.
+
+
+
+
+ 2. 설치 전 준비물
+
+ 서버측(Cafe24) 장치측(STM32)
+
+
+
+ • Cafe24 PHP 호스팅
+ • Cafe24 MySQL DB(이름/계정/비밀번호)
+ • Cafe24 SMS 서비스 계정 + secure key + 발신번호
+ • 운영 담당자 SMS 수신번호
+ • HTTPS 도메인/경로 + Cafe24 루트 CA
+
+
+ • STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)
+ • SHT30 모듈(한진데이터 P4422-3, I²C)
+ • LAN8720 Ethernet PHY(RMII) + LAN 케이블
+ • I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)
+ • 외부 3.3V USB-UART 어댑터(USART3 콘솔)
+ • 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴)
+
+
+
+
+ 핵심 비밀값 한 쌍. 서버 config.local.php 의 API_KEY 와 펌웨어
+ secrets.h 의 APP_API_KEY 는 바이트 단위로 동일 해야 합니다. 서명
+ X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문)) 검증의 기준입니다.
+
+
+
+
+ PART A · Cafe24 서버
+ 3. 서버 파일 업로드
+ Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.
+ 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가 쓰기 가능해야 함(상태/감사/카운터 파일)
+ 업로드 금지. config.local.php 와 펌웨어 secrets.h 는 비밀값이므로
+ 저장소/공유에 올리지 않습니다. php/var/ 는 .htaccess 로 외부 접근이 차단됩니다(확인 필수).
+
+
+
+
📷
+
캡처 위치 — 그림 2 → screenshots/02-server-files-upload.png
+
Cafe24 FTP/파일 관리자 에서 public_html/raspi_leck_detecter/ 업로드 완료된 파일 목록 화면
+
+ 그림 2. 서버 파일 업로드 결과(웹 루트 트리)
+
+
+
+
+ PART A · Cafe24 서버
+ 4. 운영 설정 — config.local.php
+ config.local.example.php 를 복사해 config.local.php 를 만들고 실제 운영 값으로 교체합니다.
+ <?php
+return [
+ 'DB_HOST' => 'localhost',
+ 'DB_PORT' => 3306,
+ 'DB_NAME' => 'your_db_name',
+ 'DB_USER' => 'your_db_user',
+ 'DB_PASS' => 'your_db_password',
+
+ 'API_KEY' => 'replace-with-long-random-secret', // 펌웨어 APP_API_KEY 와 동일!
+ 'ALLOW_LEGACY_BODY_SIGNATURE' => false, // HMAC 전용(레거시 RPi 없으면 false)
+
+ 'SMS_USER_ID' => 'your-cafe24-sms-user-id',
+ 'SMS_SECURE' => 'your-cafe24-sms-secure-key',
+ 'SMS_SENDER' => '01000000000',
+ 'SMS_RECIPIENTS' => ['01000000000'],
+ 'SMS_RECIPIENTS_TIER2' => [], // 미복귀 30분 시 2차(야간) 통지(opt-in)
+
+ 'ADMIN_USER' => 'admin',
+ 'ADMIN_PASSWORD_HASH' => '', // setup_hash.php 출력으로 채움(빈값=로그인 차단)
+ 'ADMIN_TOTP_SECRET' => '', // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
+ 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', // 등록 후 비울 것
+
+ // 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
+ 'METRIC_TEMP_HIGH_C' => 30, 'METRIC_TEMP_LOW_C' => 10,
+ 'METRIC_RH_HIGH' => 70, 'METRIC_RH_LOW' => 20,
+];
+ fail-closed 기본값. ADMIN_PASSWORD_HASH 또는 ADMIN_TOTP_SECRET 가
+ 비어 있으면 관리자 로그인이 차단 됩니다(보안). 4·6단계에서 채웁니다.
+
+
+
+ PART A · Cafe24 서버
+ 5. 데이터베이스 설치 (phpMyAdmin)
+
+
+
Cafe24 phpMyAdmin 접속 → 대상 DB 선택
+
Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(DB_NAME)를 클릭합니다.
+
+
+
스키마 실행
+
신규 설치 는 sql/schema_sht30.sql 전체를 복사해 SQL 탭에 붙여넣고 실행 .
+ 생성 테이블: sensor_log, sensor_status, sensor_metric, sms_log
+ (+ 재전송 방어용 ingest_nonce).
+
+ SQL 파일 적용 상황
+
+ sql/schema_sht30.sql신규 설치(통합 스키마)
+ sql/migration_drop_leak.sql기존 누수(v2605) 설치 → 온습도 전용 전환. 비가역 — 실행 전 DB 전체 백업
+
+
+
중복 실행 주의. 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
+ 기존 설치는 setup_wizard.php 점검 결과를 먼저 확인하세요.
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 3 → screenshots/03-db-phpmyadmin.png
+
phpMyAdmin SQL 탭에 schema_sht30.sql 붙여넣고 [실행]한 결과 (“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)
+
+ 그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)
+
+
+
+
+ PART A · Cafe24 서버
+ 6. 권한 · 관리자 비밀번호 · MFA 등록
+
+
+
상태 파일 폴더 쓰기 권한
+
raspi_leck_detecter/var/ 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).
+
+
+
관리자 비밀번호 해시 생성(서버 CLI)
+
php setup_hash.php "새관리자비밀번호"
+
출력된 bcrypt 해시를 config.local.php 의 ADMIN_PASSWORD_HASH 에 넣습니다.
+
+
+
MFA(TOTP) 등록
+
MFA_SETUP_TOKEN 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.
+
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
+
화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 ADMIN_TOTP_SECRET 줄을
+ config.local.php 에 반영 → 등록 후 MFA_SETUP_TOKEN 을 빈 값으로 바꿉니다.
+
+
+
+
📷
+
캡처 위치 — 그림 4 → screenshots/04-mfa-setup.png
+
setup_mfa.php 등록 화면 (Base32 수동 입력 키 + 6자리 코드 “확인됨” 상태). 비밀키 노출 주의 — 캡처 시 키 일부 가림 권장
+
+ 그림 4. 관리자 MFA(TOTP) 등록 화면
+
+
+
+
설치 점검 화면 확인
+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+
확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 ·
+ var/ 쓰기 권한 · 테스트 SMS 발송 .
+
+
+
+
📷
+
캡처 위치 — 그림 5 → screenshots/05-setup-wizard.png
+
setup_wizard.php 점검 결과 (모든 항목 ✅ + 테스트 SMS “발송 성공”). 수신번호는 마스킹되어 표시됨
+
+ 그림 5. 설치 점검(setup_wizard) 통과 화면
+
+
+
+
+
+
+ PART A · Cafe24 서버
+ 7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션
+ cron_heartbeat.php 를 1분 간격 으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).
+ 방법 A — 서버 CLI cron
+ * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
+ 방법 B — HTTP 호출(외부 스케줄러)
+ https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
+ CRON_SECRET = HMAC-SHA256("cron", API_KEY) 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/phpinfo()로 확인.
+ 마지막 수신 후 HEARTBEAT_TIMEOUT_SEC(기본 1200초=20분) 초과 시 오프라인 판정.
+ cron이 없으면? 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단,
+ 완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 cron 등록을 권장 합니다.
+
+
+
+
📷
+
캡처 위치 — 그림 6 → screenshots/06-cron-register.png
+
Cafe24 크론탭(예약작업) 등록 화면 — cron_heartbeat.php 가 1분 주기로 등록된 목록
+
+ 그림 6. Cafe24 cron(예약작업) 등록
+
+
+
+
+
+ PART B · STM32 MCU
+ 8. 하드웨어 배선 (STM32F407VGT6)
+ LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.
+
+
+
+
+
+
+ STM32F407VGT6
+ 168MHz · sht30_fw
+ HSE 8MHz→PLL · IWDG 20s · LSE RTC
+
+
+ SHT30 P4422-3 · I²C 0x44
+ VDD SCL SDA GND
+
+ 3.3V
+ PB6 SCL
+ PB7 SDA
+ GND
+
+ 4.7kΩ↑3.3V (SCL/SDA)
+
+
+ LAN8720 PHY (RMII · AF11)
+ PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV
+ PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1
+ MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK
+
+
+
+ 콘솔 & 상태 LED
+ USART3: PD8 TX / PD9 RX (AF7, 115200 8N1)
+ 외부 3.3V USB-UART · 상태 LED = PD12
+
+
+ ※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음.
+ ※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9).
+
+ 그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)
+
+
+ 8.1 SHT30 I²C1
+
+ SHT30 STM32 핀 비고
+
+ VDD 3.3V SCL/SDA 각각 4.7kΩ → 3.3V 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 0x2C06 → ~20ms → 6바이트 read
+ SCL PB6
+ SDA PB7
+ GND / ADDR GND
+
+
+ 8.2 LAN8720 RMII (AF11)
+
+ STM32 핀 RMII 신호 STM32 핀 RMII 신호
+
+ PA1 ETH_REF_CLK (50MHz 입력) PC4 ETH_RXD0
+ PA2 ETH_MDIO ⚠️ (USART2 금지) PC5 ETH_RXD1
+ PC1 ETH_MDC PB11 ETH_TX_EN
+ PA7 ETH_CRS_DV PB12/PB13 ETH_TXD0 / TXD1
+
+
+ 8.3 콘솔 · 상태 LED · 클럭
+
+ 콘솔 UART: PD8 (TX)/PD9 (RX), USART3 AF7, 115200 8N1 , 외부 3.3V USB-UART 어댑터.
+ 상태 LED: PD12 — 부팅=점등 / 정상보고=느린 토글 / 망단절·TLS실패=빠른 점멸.
+ 클럭: HSE 8MHz→168MHz, RMII REF_CLK 50MHz, RTC LSE 32.768kHz (권장; 정전 시 VBAT 시간 보존), IWDG=LSI 20s.
+
+
+
+
+ PART B · STM32 MCU
+ 9. 폐쇄망 펌웨어 빌드
+ 폐쇄망 원칙. 다운로드는 인터넷 가능한 빌드 머신에서만 일어나고, 결과물 .bin 이 에어갭을
+ 넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.
+
+
+
의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)
+
bash firmware/scripts/vendor.sh # 또는 vendor.ps1
+# 릴리스 빌드는 핀(SHA-256) 강제:
+VENDOR_STRICT=1 bash firmware/scripts/vendor.sh
+
STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 firmware/third_party/ 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.
+
+
+
비밀값 — secrets.h
+
cp firmware/common/secrets.h.example firmware/common/secrets.h
+# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.
+
secrets.h 는 커밋 금지. 산출 .bin 에 키가 임베드되므로 .bin 자체를 비밀로 취급합니다.
+
+
+
서버 루트 CA 임베드 — certs/server_ca.c (필수)
+
openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
+# → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
+# (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)
+
자리표시자 CA로는 부팅 불가. 기본 server_ca.c 는 PLACEHOLDER라 tls_init()가
+ 실패해 부팅이 정지(fail-closed)합니다. build.ps1 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.
+
+
+
설정값 확인 — app_config.h
+
+ 설정 값
+
+ APP_API_HOST / APP_API_PATH운영 도메인 / /raspi_leck_detecter/api/sensor_data.php
+ APP_SNTP_SERVER ⚠️폐쇄망 LAN NTP 서버 IP (기본 192.0.2.123 자리표시자 — 반드시 교체)
+ APP_SHT30_I2C_ADDR / APP_SHT30_REPORT_INTERVAL_SEC0x44 / 300(5분)
+
+
+
SNTP는 폐쇄망 전면장애 지점. 인터넷 NTP(pool.ntp.org)는 폐쇄망에서 도달 불가 →
+ 시간 미동기 → timestamp=0 → TLS 차단 + 서버 전량 거부. 반드시 사내 LAN NTP IP 로 설정하세요.
+
+
+
빌드 (cmake + ninja)
+
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
+
(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: python3 firmware/test/host/parity_test.py
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 8 → screenshots/08-firmware-build.png
+
빌드 머신 터미널의 cmake --build … --target sht30_fw 성공 로그 (산출물 sht30_fw.bin/.hex/.elf + arm-none-eabi-size 출력)
+
+ 그림 8. 펌웨어 빌드 성공 로그
+
+
+
+
+ PART B · STM32 MCU
+ 10. 에어갭 전달 · 플래시 · 콘솔 확인
+
+
+
무결성 해시 + 신뢰 매체 전달
+
sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt # 빌드 머신
+# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조
+
+
+
ST-Link 플래시 (격리 호스트)
+
pwsh firmware/scripts/flash.ps1 # 권장(자동 탐지)
+# 대안:
+st-flash write sht30_fw.bin 0x08000000
+STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst
+
+
+
+
📷
+
캡처 위치 — 그림 9 → screenshots/09-flash.png
+
플래싱 툴 화면 (STM32CubeProgrammer 또는 st-flash의 “Flash written / verified” 결과)
+
+ 그림 9. STM32 펌웨어 플래시 완료
+
+
+
+
콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)
+
외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:
+
bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
+ → TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200
+
I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면
+ API_KEY↔APP_API_KEY, TLS 실패면 server_ca.c/SNTP를 재점검.
+
+
+
+
📷
+
캡처 위치 — 그림 10 → screenshots/10-serial-console.png
+
시리얼 터미널(USART3) 부팅 로그 — SNTP 동기 → TLS OK → 서버 200 응답 라인이 보이는 화면
+
+ 그림 10. STM32 콘솔 부팅 로그(정상 보고)
+
+
+
+
+
+
+
+ 11. 최종 검증 (종단)
+ 서버에서 아래 화면을 확인합니다.
+ 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
+
+
정상 설치 기준
+
+ setup_wizard.php 필수 점검 항목 통과 + 테스트 SMS 수신
+ dashboard.php 에 최신 온/습도와 센서 상태(sensor_id=2) 표시
+ sensor_metric 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록
+ 임계 초과 테스트 시 metric_status 경보 + SMS 수신 / 월간 보고서에 내역 조회
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 11 → screenshots/11-dashboard.png
+
운영 dashboard.php 화면 (상단 “정상 감시 중” + 2번 센서 SHT30 온/습도 카드 + 30일 요약). 데스크톱·모바일 2장 권장
+
+ 그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일
+
+
+
+
+
+ 12. 문제 해결
+
+ 증상 확인 지점
+
+ API 403 서버 API_KEY ↔ 펌웨어 APP_API_KEY 일치, raw-body 서명
+ API 500 Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부
+ 로그인 불가 ADMIN_PASSWORD_HASH, ADMIN_TOTP_SECRET, 인증 앱 시간 동기
+ SMS 미발송 Cafe24 SMS 계정·secure key·발신번호 등록·잔액 , sms_log. 대시보드 ‘수집 거부/발송 실패’ 배너 확인
+ 온습도 미표시 I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, sensor_metric, 펌웨어 측정 로그
+ TLS 실패 server_ca.c 실제 CA 반영, SNTP 시간 동기 (APP_SNTP_SERVER=LAN NTP)
+ 데이터 전혀 안 들어옴 대시보드 수집 거부 사유 카운터 (서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별
+ 오프라인 알림 과다/지연 HEARTBEAT_TIMEOUT_SEC(1200초), cron 주기
+
+
+
+
+
+
+ 13. 운영자가 별도 보관할 값
+ 아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.
+
+ Cafe24 DB 이름 / 계정 / 비밀번호
+ API_KEY = 펌웨어 APP_API_KEY
+ Cafe24 SMS user id / secure key / 발신번호 / 수신자 번호
+ 관리자 계정·원문 비밀번호·ADMIN_TOTP_SECRET
+ 서버 업로드 경로, LAN NTP 서버 IP, 장비 ID(stm32-sht30-01)와 설치 위치
+
+
+
+
+
+ 부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)
+ 주의: 아래 캡처는 구버전 v2605 누수감지(카메라/사진) UI 또는 Raspberry Pi 기준이라
+ 현재 v2606 STM32/온습도 시스템 화면과 다릅니다 . 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는
+ 현재 운영 시스템에서 직접 캡처한 이미지 를 넣으세요.
+ 본 HTML 파일은 docs/ 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):
+
+ 경로 내용 상태
+
+ examples/captures/admin_dashboard_overview.png관리자 대시보드 개요 ⚠️ v2605 누수 UI
+ examples/captures/admin_dashboard_mobile.png대시보드 모바일 ⚠️ v2605
+ examples/captures/admin_dashboard_incident.png경보/사고 카드 ⚠️ v2605(사고/사진)
+ evidence/mfa_setup_page_capture.pngMFA 등록 페이지 참고(개념 동일)
+ examples/assets/sht30_sensor2_wiring.svg배선도 ⚠️ Raspberry Pi 기준
+ assets/security_plan/generated/security_plan_target_architecture.png목표 아키텍처 보안계획 다이어그램
+
+
+
+
⚠️ v2605 대시보드 개요(누수 UI)
+
⚠️ v2605 모바일
+
⚠️ v2605 사고/사진 카드
+
MFA 등록 페이지(참고·개념 동일)
+
⚠️ 배선도(Raspberry Pi 기준)
+
목표 아키텍처(보안계획 다이어그램)
+
+ 현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.
+
+
+
+ 서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(Ctrl/Cmd +P ) 지원
+
+
+
+
diff --git a/docs/INSTALL_GUIDE.standalone.html b/docs/INSTALL_GUIDE.standalone.html
new file mode 100644
index 0000000..6c5d941
--- /dev/null
+++ b/docs/INSTALL_GUIDE.standalone.html
@@ -0,0 +1,658 @@
+
+
+
+
+
+서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32)
+
+
+
+
+
+
+
+
+
+
이 가이드의 이미지에 대하여. 본 문서의
아키텍처·배선 다이어그램은 코드/HARDWARE.md 기준으로 정확히 생성 한 SVG입니다.
+ Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼
운영 환경에서만 캡처할 수 있는 화면은
+ “📷 캡처 위치” 자리표시 로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다.
+ (저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 —
부록 참조.)
+
+
+
+ 목차
+
+ 시스템 개요 & 아키텍처
+ 설치 전 준비물
+ A. Cafe24 — 파일 업로드
+ A. Cafe24 — config.local.php
+ A. Cafe24 — DB 설치(phpMyAdmin)
+ A. Cafe24 — 권한 & 관리자/MFA
+ A. Cafe24 — cron(오프라인 감지)
+ B. STM32 — 하드웨어 배선
+ B. STM32 — 폐쇄망 빌드
+ B. STM32 — 플래시 & 콘솔 확인
+ 최종 검증(종단)
+ 문제 해결
+ 운영자 보관 값
+ 부록 — 레거시 자산
+
+
+
+
+
+ 1. 시스템 개요 & 아키텍처
+ 단일 STM32F407 보드(sht30_fw, sensor_id=2)가 SHT30 센서를 I²C로 5분마다 읽어,
+ 본문을 HMAC-SHA256으로 서명 해 TLS 1.2 로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 재판정 하여
+ 고온/저온/고습/저습·결로·오프라인 상황을 SMS 로 알리고 웹 대시보드/보고서 를 제공합니다.
+
+
+
+
+
+
+
+
+
+
+ SHT30 온·습도 센서
+
+ STM32F407 sht30_fw FreeRTOS+mbedTLS
+
+ Cafe24 PHP api/sensor_data.php 임계 재판정 + 저장
+
+ MySQL 4 테이블
+
+ SMS · 대시보드 경보/보고서
+ I²C 0x44
+ HMAC TLS 1.2
+
+
+
+ 그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)
+
+
+
+ 구분 설치 위치 역할
+
+ Cafe24 서버 public_html/raspi_leck_detecter/센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서
+ Cafe24 MySQL Cafe24 DB 센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태
+ STM32F407VGT6 펌웨어 플래시 (sht30_fw) SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명
+ 서버 cron Cafe24 cron 또는 외부 스케줄러 cron_heartbeat.php 주기 실행(오프라인 감지·헬스·에스컬레이션)
+
+
+ 웹 루트 경로 고정. 폴더명 raspi_leck_detecter 는 레거시 이름이지만 펌웨어
+ app_config.h 의 APP_API_PATH 와 반드시 일치 해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.
+
+
+
+
+ 2. 설치 전 준비물
+
+ 서버측(Cafe24) 장치측(STM32)
+
+
+
+ • Cafe24 PHP 호스팅
+ • Cafe24 MySQL DB(이름/계정/비밀번호)
+ • Cafe24 SMS 서비스 계정 + secure key + 발신번호
+ • 운영 담당자 SMS 수신번호
+ • HTTPS 도메인/경로 + Cafe24 루트 CA
+
+
+ • STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)
+ • SHT30 모듈(한진데이터 P4422-3, I²C)
+ • LAN8720 Ethernet PHY(RMII) + LAN 케이블
+ • I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)
+ • 외부 3.3V USB-UART 어댑터(USART3 콘솔)
+ • 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴)
+
+
+
+
+ 핵심 비밀값 한 쌍. 서버 config.local.php 의 API_KEY 와 펌웨어
+ secrets.h 의 APP_API_KEY 는 바이트 단위로 동일 해야 합니다. 서명
+ X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문)) 검증의 기준입니다.
+
+
+
+
+ PART A · Cafe24 서버
+ 3. 서버 파일 업로드
+ Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.
+ 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가 쓰기 가능해야 함(상태/감사/카운터 파일)
+ 업로드 금지. config.local.php 와 펌웨어 secrets.h 는 비밀값이므로
+ 저장소/공유에 올리지 않습니다. php/var/ 는 .htaccess 로 외부 접근이 차단됩니다(확인 필수).
+
+
+
+
📷
+
캡처 위치 — 그림 2 → screenshots/02-server-files-upload.png
+
Cafe24 FTP/파일 관리자 에서 public_html/raspi_leck_detecter/ 업로드 완료된 파일 목록 화면
+
+ 그림 2. 서버 파일 업로드 결과(웹 루트 트리)
+
+
+
+
+ PART A · Cafe24 서버
+ 4. 운영 설정 — config.local.php
+ config.local.example.php 를 복사해 config.local.php 를 만들고 실제 운영 값으로 교체합니다.
+ <?php
+return [
+ 'DB_HOST' => 'localhost',
+ 'DB_PORT' => 3306,
+ 'DB_NAME' => 'your_db_name',
+ 'DB_USER' => 'your_db_user',
+ 'DB_PASS' => 'your_db_password',
+
+ 'API_KEY' => 'replace-with-long-random-secret', // 펌웨어 APP_API_KEY 와 동일!
+ 'ALLOW_LEGACY_BODY_SIGNATURE' => false, // HMAC 전용(레거시 RPi 없으면 false)
+
+ 'SMS_USER_ID' => 'your-cafe24-sms-user-id',
+ 'SMS_SECURE' => 'your-cafe24-sms-secure-key',
+ 'SMS_SENDER' => '01000000000',
+ 'SMS_RECIPIENTS' => ['01000000000'],
+ 'SMS_RECIPIENTS_TIER2' => [], // 미복귀 30분 시 2차(야간) 통지(opt-in)
+
+ 'ADMIN_USER' => 'admin',
+ 'ADMIN_PASSWORD_HASH' => '', // setup_hash.php 출력으로 채움(빈값=로그인 차단)
+ 'ADMIN_TOTP_SECRET' => '', // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
+ 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', // 등록 후 비울 것
+
+ // 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
+ 'METRIC_TEMP_HIGH_C' => 30, 'METRIC_TEMP_LOW_C' => 10,
+ 'METRIC_RH_HIGH' => 70, 'METRIC_RH_LOW' => 20,
+];
+ fail-closed 기본값. ADMIN_PASSWORD_HASH 또는 ADMIN_TOTP_SECRET 가
+ 비어 있으면 관리자 로그인이 차단 됩니다(보안). 4·6단계에서 채웁니다.
+
+
+
+ PART A · Cafe24 서버
+ 5. 데이터베이스 설치 (phpMyAdmin)
+
+
+
Cafe24 phpMyAdmin 접속 → 대상 DB 선택
+
Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(DB_NAME)를 클릭합니다.
+
+
+
스키마 실행
+
신규 설치 는 sql/schema_sht30.sql 전체를 복사해 SQL 탭에 붙여넣고 실행 .
+ 생성 테이블: sensor_log, sensor_status, sensor_metric, sms_log
+ (+ 재전송 방어용 ingest_nonce).
+
+ SQL 파일 적용 상황
+
+ sql/schema_sht30.sql신규 설치(통합 스키마)
+ sql/migration_drop_leak.sql기존 누수(v2605) 설치 → 온습도 전용 전환. 비가역 — 실행 전 DB 전체 백업
+
+
+
중복 실행 주의. 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
+ 기존 설치는 setup_wizard.php 점검 결과를 먼저 확인하세요.
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 3 → screenshots/03-db-phpmyadmin.png
+
phpMyAdmin SQL 탭에 schema_sht30.sql 붙여넣고 [실행]한 결과 (“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)
+
+ 그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)
+
+
+
+
+ PART A · Cafe24 서버
+ 6. 권한 · 관리자 비밀번호 · MFA 등록
+
+
+
상태 파일 폴더 쓰기 권한
+
raspi_leck_detecter/var/ 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).
+
+
+
관리자 비밀번호 해시 생성(서버 CLI)
+
php setup_hash.php "새관리자비밀번호"
+
출력된 bcrypt 해시를 config.local.php 의 ADMIN_PASSWORD_HASH 에 넣습니다.
+
+
+
MFA(TOTP) 등록
+
MFA_SETUP_TOKEN 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.
+
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
+
화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 ADMIN_TOTP_SECRET 줄을
+ config.local.php 에 반영 → 등록 후 MFA_SETUP_TOKEN 을 빈 값으로 바꿉니다.
+
+
+
+
📷
+
캡처 위치 — 그림 4 → screenshots/04-mfa-setup.png
+
setup_mfa.php 등록 화면 (Base32 수동 입력 키 + 6자리 코드 “확인됨” 상태). 비밀키 노출 주의 — 캡처 시 키 일부 가림 권장
+
+ 그림 4. 관리자 MFA(TOTP) 등록 화면
+
+
+
+
설치 점검 화면 확인
+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+
확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 ·
+ var/ 쓰기 권한 · 테스트 SMS 발송 .
+
+
+
+
📷
+
캡처 위치 — 그림 5 → screenshots/05-setup-wizard.png
+
setup_wizard.php 점검 결과 (모든 항목 ✅ + 테스트 SMS “발송 성공”). 수신번호는 마스킹되어 표시됨
+
+ 그림 5. 설치 점검(setup_wizard) 통과 화면
+
+
+
+
+
+
+ PART A · Cafe24 서버
+ 7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션
+ cron_heartbeat.php 를 1분 간격 으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).
+ 방법 A — 서버 CLI cron
+ * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
+ 방법 B — HTTP 호출(외부 스케줄러)
+ https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
+ CRON_SECRET = HMAC-SHA256("cron", API_KEY) 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/phpinfo()로 확인.
+ 마지막 수신 후 HEARTBEAT_TIMEOUT_SEC(기본 1200초=20분) 초과 시 오프라인 판정.
+ cron이 없으면? 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단,
+ 완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 cron 등록을 권장 합니다.
+
+
+
+
📷
+
캡처 위치 — 그림 6 → screenshots/06-cron-register.png
+
Cafe24 크론탭(예약작업) 등록 화면 — cron_heartbeat.php 가 1분 주기로 등록된 목록
+
+ 그림 6. Cafe24 cron(예약작업) 등록
+
+
+
+
+
+ PART B · STM32 MCU
+ 8. 하드웨어 배선 (STM32F407VGT6)
+ LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.
+
+
+
+
+
+
+ STM32F407VGT6
+ 168MHz · sht30_fw
+ HSE 8MHz→PLL · IWDG 20s · LSE RTC
+
+
+ SHT30 P4422-3 · I²C 0x44
+ VDD SCL SDA GND
+
+ 3.3V
+ PB6 SCL
+ PB7 SDA
+ GND
+
+ 4.7kΩ↑3.3V (SCL/SDA)
+
+
+ LAN8720 PHY (RMII · AF11)
+ PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV
+ PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1
+ MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK
+
+
+
+ 콘솔 & 상태 LED
+ USART3: PD8 TX / PD9 RX (AF7, 115200 8N1)
+ 외부 3.3V USB-UART · 상태 LED = PD12
+
+
+ ※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음.
+ ※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9).
+
+ 그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)
+
+
+ 8.1 SHT30 I²C1
+
+ SHT30 STM32 핀 비고
+
+ VDD 3.3V SCL/SDA 각각 4.7kΩ → 3.3V 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 0x2C06 → ~20ms → 6바이트 read
+ SCL PB6
+ SDA PB7
+ GND / ADDR GND
+
+
+ 8.2 LAN8720 RMII (AF11)
+
+ STM32 핀 RMII 신호 STM32 핀 RMII 신호
+
+ PA1 ETH_REF_CLK (50MHz 입력) PC4 ETH_RXD0
+ PA2 ETH_MDIO ⚠️ (USART2 금지) PC5 ETH_RXD1
+ PC1 ETH_MDC PB11 ETH_TX_EN
+ PA7 ETH_CRS_DV PB12/PB13 ETH_TXD0 / TXD1
+
+
+ 8.3 콘솔 · 상태 LED · 클럭
+
+ 콘솔 UART: PD8 (TX)/PD9 (RX), USART3 AF7, 115200 8N1 , 외부 3.3V USB-UART 어댑터.
+ 상태 LED: PD12 — 부팅=점등 / 정상보고=느린 토글 / 망단절·TLS실패=빠른 점멸.
+ 클럭: HSE 8MHz→168MHz, RMII REF_CLK 50MHz, RTC LSE 32.768kHz (권장; 정전 시 VBAT 시간 보존), IWDG=LSI 20s.
+
+
+
+
+ PART B · STM32 MCU
+ 9. 폐쇄망 펌웨어 빌드
+ 폐쇄망 원칙. 다운로드는 인터넷 가능한 빌드 머신에서만 일어나고, 결과물 .bin 이 에어갭을
+ 넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.
+
+
+
의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)
+
bash firmware/scripts/vendor.sh # 또는 vendor.ps1
+# 릴리스 빌드는 핀(SHA-256) 강제:
+VENDOR_STRICT=1 bash firmware/scripts/vendor.sh
+
STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 firmware/third_party/ 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.
+
+
+
비밀값 — secrets.h
+
cp firmware/common/secrets.h.example firmware/common/secrets.h
+# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.
+
secrets.h 는 커밋 금지. 산출 .bin 에 키가 임베드되므로 .bin 자체를 비밀로 취급합니다.
+
+
+
서버 루트 CA 임베드 — certs/server_ca.c (필수)
+
openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
+# → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
+# (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)
+
자리표시자 CA로는 부팅 불가. 기본 server_ca.c 는 PLACEHOLDER라 tls_init()가
+ 실패해 부팅이 정지(fail-closed)합니다. build.ps1 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.
+
+
+
설정값 확인 — app_config.h
+
+ 설정 값
+
+ APP_API_HOST / APP_API_PATH운영 도메인 / /raspi_leck_detecter/api/sensor_data.php
+ APP_SNTP_SERVER ⚠️폐쇄망 LAN NTP 서버 IP (기본 192.0.2.123 자리표시자 — 반드시 교체)
+ APP_SHT30_I2C_ADDR / APP_SHT30_REPORT_INTERVAL_SEC0x44 / 300(5분)
+
+
+
SNTP는 폐쇄망 전면장애 지점. 인터넷 NTP(pool.ntp.org)는 폐쇄망에서 도달 불가 →
+ 시간 미동기 → timestamp=0 → TLS 차단 + 서버 전량 거부. 반드시 사내 LAN NTP IP 로 설정하세요.
+
+
+
빌드 (cmake + ninja)
+
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
+
(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: python3 firmware/test/host/parity_test.py
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 8 → screenshots/08-firmware-build.png
+
빌드 머신 터미널의 cmake --build … --target sht30_fw 성공 로그 (산출물 sht30_fw.bin/.hex/.elf + arm-none-eabi-size 출력)
+
+ 그림 8. 펌웨어 빌드 성공 로그
+
+
+
+
+ PART B · STM32 MCU
+ 10. 에어갭 전달 · 플래시 · 콘솔 확인
+
+
+
무결성 해시 + 신뢰 매체 전달
+
sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt # 빌드 머신
+# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조
+
+
+
ST-Link 플래시 (격리 호스트)
+
pwsh firmware/scripts/flash.ps1 # 권장(자동 탐지)
+# 대안:
+st-flash write sht30_fw.bin 0x08000000
+STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst
+
+
+
+
📷
+
캡처 위치 — 그림 9 → screenshots/09-flash.png
+
플래싱 툴 화면 (STM32CubeProgrammer 또는 st-flash의 “Flash written / verified” 결과)
+
+ 그림 9. STM32 펌웨어 플래시 완료
+
+
+
+
콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)
+
외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:
+
bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
+ → TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200
+
I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면
+ API_KEY↔APP_API_KEY, TLS 실패면 server_ca.c/SNTP를 재점검.
+
+
+
+
📷
+
캡처 위치 — 그림 10 → screenshots/10-serial-console.png
+
시리얼 터미널(USART3) 부팅 로그 — SNTP 동기 → TLS OK → 서버 200 응답 라인이 보이는 화면
+
+ 그림 10. STM32 콘솔 부팅 로그(정상 보고)
+
+
+
+
+
+
+
+ 11. 최종 검증 (종단)
+ 서버에서 아래 화면을 확인합니다.
+ 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
+
+
정상 설치 기준
+
+ setup_wizard.php 필수 점검 항목 통과 + 테스트 SMS 수신
+ dashboard.php 에 최신 온/습도와 센서 상태(sensor_id=2) 표시
+ sensor_metric 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록
+ 임계 초과 테스트 시 metric_status 경보 + SMS 수신 / 월간 보고서에 내역 조회
+
+
+
+
+
+
📷
+
캡처 위치 — 그림 11 → screenshots/11-dashboard.png
+
운영 dashboard.php 화면 (상단 “정상 감시 중” + 2번 센서 SHT30 온/습도 카드 + 30일 요약). 데스크톱·모바일 2장 권장
+
+ 그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일
+
+
+
+
+
+ 12. 문제 해결
+
+ 증상 확인 지점
+
+ API 403 서버 API_KEY ↔ 펌웨어 APP_API_KEY 일치, raw-body 서명
+ API 500 Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부
+ 로그인 불가 ADMIN_PASSWORD_HASH, ADMIN_TOTP_SECRET, 인증 앱 시간 동기
+ SMS 미발송 Cafe24 SMS 계정·secure key·발신번호 등록·잔액 , sms_log. 대시보드 ‘수집 거부/발송 실패’ 배너 확인
+ 온습도 미표시 I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, sensor_metric, 펌웨어 측정 로그
+ TLS 실패 server_ca.c 실제 CA 반영, SNTP 시간 동기 (APP_SNTP_SERVER=LAN NTP)
+ 데이터 전혀 안 들어옴 대시보드 수집 거부 사유 카운터 (서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별
+ 오프라인 알림 과다/지연 HEARTBEAT_TIMEOUT_SEC(1200초), cron 주기
+
+
+
+
+
+
+ 13. 운영자가 별도 보관할 값
+ 아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.
+
+ Cafe24 DB 이름 / 계정 / 비밀번호
+ API_KEY = 펌웨어 APP_API_KEY
+ Cafe24 SMS user id / secure key / 발신번호 / 수신자 번호
+ 관리자 계정·원문 비밀번호·ADMIN_TOTP_SECRET
+ 서버 업로드 경로, LAN NTP 서버 IP, 장비 ID(stm32-sht30-01)와 설치 위치
+
+
+
+
+
+ 부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)
+ 주의: 아래 캡처는 구버전 v2605 누수감지(카메라/사진) UI 또는 Raspberry Pi 기준이라
+ 현재 v2606 STM32/온습도 시스템 화면과 다릅니다 . 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는
+ 현재 운영 시스템에서 직접 캡처한 이미지 를 넣으세요.
+ 본 HTML 파일은 docs/ 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):
+
+ 경로 내용 상태
+
+ examples/captures/admin_dashboard_overview.png관리자 대시보드 개요 ⚠️ v2605 누수 UI
+ examples/captures/admin_dashboard_mobile.png대시보드 모바일 ⚠️ v2605
+ examples/captures/admin_dashboard_incident.png경보/사고 카드 ⚠️ v2605(사고/사진)
+ evidence/mfa_setup_page_capture.pngMFA 등록 페이지 참고(개념 동일)
+ examples/assets/sht30_sensor2_wiring.svg배선도 ⚠️ Raspberry Pi 기준
+ assets/security_plan/generated/security_plan_target_architecture.png목표 아키텍처 보안계획 다이어그램
+
+
+
+
⚠️ v2605 대시보드 개요(누수 UI)
+
⚠️ v2605 모바일
+
⚠️ v2605 사고/사진 카드
+
MFA 등록 페이지(참고·개념 동일)
+
⚠️ 배선도(Raspberry Pi 기준)
+
목표 아키텍처(보안계획 다이어그램)
+
+ 현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.
+
+
+
+ 서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(Ctrl/Cmd +P ) 지원
+
+
+
+
diff --git a/docs/INSTALL_PI_SERVER.md b/docs/INSTALL_PI_SERVER.md
index 1708aae..2aacc83 100644
--- a/docs/INSTALL_PI_SERVER.md
+++ b/docs/INSTALL_PI_SERVER.md
@@ -93,7 +93,7 @@ return [
];
```
-`API_KEY`는 STM32 펌웨어 `firmware/common/secrets.h`의 `APP_API_KEY`와 반드시 바이트 단위로 같아야 합니다(raw-body 서명 `X-Signature = sha256(API_KEY + 요청본문)`).
+`API_KEY`는 STM32 펌웨어 `firmware/common/secrets.h`의 `APP_API_KEY`와 반드시 바이트 단위로 같아야 합니다(raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`).
`ADMIN_TOTP_SECRET`은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 등록한 Base32 비밀키입니다. 이 값이 없으면 관리자 로그인을 허용하지 않습니다. 담당자 변경 시 관리자 비밀번호와 TOTP 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다.
diff --git a/docs/README.md b/docs/README.md
index d5c38b7..27382c2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,7 +15,7 @@ Sensirion SHT30 온습도 센서, STM32F407VGT6(FreeRTOS) 펌웨어, Cafe24 PHP/
- 단일 보드 `sht30_fw` (`sensor_id=2`, `device_id=stm32-sht30-01`).
- SHT30 → STM32 I2C1(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업). 상세 배선은 [wiring_diagram.md](./wiring_diagram.md) 및 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3 참조.
- 측정 주기: 5분(펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC=300`). 최초 1회 startup, 이후 periodic.
-- 인증: 펌웨어가 `X-Signature: sha256(API_KEY + 요청본문)`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
+- 인증: 펌웨어가 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하고 서버가 검증합니다. 관리자 로그인은 비밀번호 해시 + TOTP MFA + 감사로그로 보호합니다.
## 폴더 구조
diff --git a/docs/SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md b/docs/SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md
index cc8aea7..dc2dc32 100644
--- a/docs/SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md
+++ b/docs/SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md
@@ -11,7 +11,7 @@
| 관리자 TOTP MFA | `login.php`, `setup_mfa.php`, `admin_security.php`, `ADMIN_TOTP_SECRET` | 반영 |
| MFA 최초 등록 토큰 | `setup_mfa.php`, `MFA_SETUP_TOKEN` | 반영 |
| 관리자 감사로그 | `admin_security.php`, `php/var/admin_audit.log` | 반영 |
-| raw-body API 서명 검증 | `config.php` `verify_signature_raw()`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 |
+| raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) |
| 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 |
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 |
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
@@ -21,6 +21,18 @@
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |
+### 2.1 서명 방식 정정 (이전 all-clear 정정)
+
+실제 구현의 요청 서명은 **HMAC-SHA256** 이다.
+
+- 서버: `config.php` `verify_signature_raw()` = `hash_hmac('sha256', $raw_body, API_KEY)`, `hash_equals()` 로 상수시간 비교.
+- 펌웨어: `sig.c` `sig_raw_body()` = `HMAC-SHA256(key=API_KEY, msg=raw_body)`(소문자 hex), 호스트 레퍼런스 `reference.py` 동일.
+- 헤더 표기: `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body))`.
+
+HMAC 구조를 채택한 이유는 키-접두 sha256(`sha256(API_KEY || body)`)의 **길이확장(length-extension) 공격**을 차단하기 위함이다.
+
+이전 일부 문서(`README.md`, `INSTALL_PI_SERVER.md`, `SHT30_SENSOR2_SETUP.md`, `SOURCE_SECURITY_EVIDENCE.md`, `stm32f407_migration_plan.md`, `firmware/README.md`, `firmware/docs/PORTING_NOTES.md`, `firmware/docs/BUILD_OFFLINE.md`, `report_leak_detection_system.md`)는 서명을 키-접두 `sha256(API_KEY + body)` / `sha256(API_KEY || body)` 로 잘못 기술하고 있었다. **이 문서들은 본 정정으로 모두 HMAC-SHA256 표기로 수정되었으며, 코드와 일치한다.** (과거의 키-접두 sha256 기술은 실제 구현과 불일치하는 stale 문구였다.)
+
## 2. 수정한 기존 문서
| 문서 | 반영 내용 |
diff --git a/docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md b/docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md
index 82d5466..d0dc930 100644
--- a/docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md
+++ b/docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md
@@ -90,6 +90,8 @@ HTTPS 적용 여부: 적용 / 미적용 / 확인 필요
DB 종류: Cafe24 MySQL
```
+> 참고: 위 `sht30_monitor/` 는 예시 폴더명입니다. 실제 서버 웹루트 경로는 펌웨어 `firmware/common/app_config.h` 의 `APP_API_PATH`(예: `/raspi_leck_detecter/api/sensor_data.php`)와 **반드시 일치**해야 합니다. 운영 경로를 정한 뒤 위 URL과 `APP_API_PATH` 를 같은 폴더명으로 맞추세요.
+
첨부하면 좋은 증적:
- Cafe24 파일 관리자에서 `sht30_monitor` 폴더가 보이는 화면
@@ -258,6 +260,7 @@ DB 백업 주기: 매일 / 매주 / 매월
3. 서버 설치 경로:
예) public_html/sht30_monitor/
+ ※ 실제 폴더명은 펌웨어 `app_config.h` 의 `APP_API_PATH` 와 동일해야 함
4. 단말 설치 위치:
예) 서버실 서버랙 하단
diff --git a/docs/SECURITY_PLAN_HWP_REVIEW.md b/docs/SECURITY_PLAN_HWP_REVIEW.md
index d83af3a..0c4a5f9 100644
--- a/docs/SECURITY_PLAN_HWP_REVIEW.md
+++ b/docs/SECURITY_PLAN_HWP_REVIEW.md
@@ -5,6 +5,8 @@
> ⚠️ HWP 바이너리는 직접 편집할 수 없습니다. 이 문서는 운영자가 HWP 본문에 반영하는 것을 전제로 한 **before → after 갱신 가이드**입니다. 인용한 라인 번호는 `docs/assets/security_plan/hwp_extracted_text.txt`(현재 HWP 추출본, 편집 금지) 기준이며 참고용입니다.
+> 🔴 **제출 전 필수:** HWP 본문은 **SHT30 온습도 모니터링 전용**으로 범위를 좁힌 뒤 제출해야 합니다. 화재·누수·정전·카메라/현장 사진은 **미구현**이므로 사업개요·구축범위·구성도·물리/기술 보안대책에서 해당 서술을 모두 제거하거나 향후 확장으로 분리해야 합니다(아래 §2~§5 교체안 적용).
+
심사 대응 수준의 상세 보완안은 [SECURITY_PLAN_PASS_READINESS.md](SECURITY_PLAN_PASS_READINESS.md)를 함께 적용합니다.
## 1. 요약 판단
diff --git a/docs/SECURITY_PLAN_PASS_READINESS.md b/docs/SECURITY_PLAN_PASS_READINESS.md
index ce26e17..a229b36 100644
--- a/docs/SECURITY_PLAN_PASS_READINESS.md
+++ b/docs/SECURITY_PLAN_PASS_READINESS.md
@@ -4,6 +4,8 @@
작성일: 2026-05-19
목적: 현재 HWP 보안대책서가 기관 보안성 검토 또는 국정원 검토 관점에서 설득력을 갖추도록, 수정해야 할 내용과 구체적 해결책을 제시한다.
+> 🔴 **제출 전 차단 항목:** 사업 범위를 **SHT30 온습도 모니터링 전용**(화재/누수/정전/카메라 미구현)으로 좁히기 전에는 제출하지 않는다. 상세는 §3.1 사업 범위 과대 기재 참조.
+
## 1. 결론
현재 HWP는 **완벽하지 않습니다.**
diff --git a/docs/SHT30_SENSOR2_SETUP.md b/docs/SHT30_SENSOR2_SETUP.md
index 2ea4c6d..f5e1063 100644
--- a/docs/SHT30_SENSOR2_SETUP.md
+++ b/docs/SHT30_SENSOR2_SETUP.md
@@ -106,7 +106,7 @@ SHT30_SENSOR_ID=2
SHT30_I2C_ADDR=0x44
```
-> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = sha256(API_KEY + body)`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
+> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
## 4. 대시보드 결과
diff --git a/docs/SOURCE_SECURITY_EVIDENCE.md b/docs/SOURCE_SECURITY_EVIDENCE.md
index 0fed459..25e4f83 100644
--- a/docs/SOURCE_SECURITY_EVIDENCE.md
+++ b/docs/SOURCE_SECURITY_EVIDENCE.md
@@ -59,13 +59,13 @@ HWP 반영 문구:
HWP 반영 문구:
-> API 요청은 서버와 STM32 단말이 공유하는 API 키를 기반으로 SHA-256 서명을 생성·검증한다. 단말은 요청 본문 바이트 전체에 대해 `sha256(API_KEY || raw_body)`를 계산하여 `X-Signature` 헤더로 전송하고, 서버는 동일하게 계산한 값과 상수시간 비교한다. 서명이 일치하지 않으면 `403 인증 실패`로 거부하며 DB에 저장하지 않는다. 이를 통해 임의의 외부 요청이 허위 측정값을 위조해 등록하는 위험을 낮춘다.
+> API 요청은 서버와 STM32 단말이 공유하는 API 키를 기반으로 HMAC-SHA256 서명을 생성·검증한다. 단말은 요청 본문 바이트 전체에 대해 `HMAC-SHA256(키=API_KEY, 메시지=raw_body)`를 계산하여 소문자 hex로 `X-Signature` 헤더로 전송하고, 서버는 동일하게 계산한 값과 상수시간 비교한다. HMAC을 사용하므로 길이확장(length-extension) 공격에 견고하다. 서명이 일치하지 않으면 `403 인증 실패`로 거부하며 DB에 저장하지 않는다. 이를 통해 임의의 외부 요청이 허위 측정값을 위조해 등록하는 위험을 낮춘다.
코드 근거:
- `php/config.php`
- `verify_signature_raw(string $raw_body): bool`
- - `hash('sha256', API_KEY . $raw_body)` 계산 후 `X-Signature`(`HTTP_X_SIGNATURE`)와 비교
+ - `hash_hmac('sha256', $raw_body, API_KEY)` 계산 후 `X-Signature`(`HTTP_X_SIGNATURE`)와 비교
- `hash_equals()`로 상수시간 서명 비교
- 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h`
- `APP_API_KEY` 기반 raw-body 서명 생성
@@ -237,11 +237,13 @@ HWP 반영 문구:
curl -I https://example.com/sht30_monitor/login.php
```
+> 위 `sht30_monitor` 는 예시 웹루트 폴더명이며, 실제 경로는 펌웨어 `firmware/common/app_config.h` 의 `APP_API_PATH`(예: `/raspi_leck_detecter/...`)와 동일하게 맞춰야 한다.
+
## 3. 내가 만들어줄 수 있는 HWP 첨부 문구
아래 문구는 HWP의 “기술적 보안대책”에 그대로 넣을 수 있습니다.
-> 본 시스템은 STM32 단말에서 Cafe24 PHP API로 HTTPS 기반 아웃바운드 요청만 수행하도록 구성한다. 센서 데이터 API는 공유 API 키 기반 raw-body SHA-256 서명(`X-Signature`)을 검증하며, 서명이 일치하지 않는 요청은 `403 인증 실패`로 거부한다. 센서 데이터 API는 POST JSON 요청과 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 온습도 임계 판정은 서버에서 운영 임계(`METRIC_*`)로 수행하고, 동일 종류 경보 30분 쿨다운과 복구 히스테리시스로 SMS 오발송을 방지한다. DB 저장은 PDO prepared statement를 사용하며, 운영 비밀값은 `config.local.php`와 펌웨어 `secrets.h`로 분리하여 소스코드에 직접 저장하지 않는다. 관리자 페이지는 비밀번호 해시, CSRF 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
+> 본 시스템은 STM32 단말에서 Cafe24 PHP API로 HTTPS 기반 아웃바운드 요청만 수행하도록 구성한다. 센서 데이터 API는 공유 API 키 기반 raw-body HMAC-SHA256 서명(`X-Signature`)을 검증하며, 서명이 일치하지 않는 요청은 `403 인증 실패`로 거부한다. 센서 데이터 API는 POST JSON 요청과 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 온습도 임계 판정은 서버에서 운영 임계(`METRIC_*`)로 수행하고, 동일 종류 경보 30분 쿨다운과 복구 히스테리시스로 SMS 오발송을 방지한다. DB 저장은 PDO prepared statement를 사용하며, 운영 비밀값은 `config.local.php`와 펌웨어 `secrets.h`로 분리하여 소스코드에 직접 저장하지 않는다. 관리자 페이지는 비밀번호 해시, CSRF 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
추가 반영 문구:
diff --git a/docs/assets/security_plan/hwp_extracted_text.txt b/docs/assets/security_plan/hwp_extracted_text.txt
index f880271..e398c35 100644
--- a/docs/assets/security_plan/hwp_extracted_text.txt
+++ b/docs/assets/security_plan/hwp_extracted_text.txt
@@ -1,156 +1,158 @@
-捤獥 汤捯 慤桥 漠杳 漠杳
-『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
-2026. 5.
-氠瑢 湰灧 湯湷
-사업개요
-□ 사업명
-ㅇ IoT 기반 상시 모니터링 체계 구축
-□ 사업목적
-ㅇ 전산실 내 화재, 누수, 장비 전원 이상 상태를 실시간으로 감지할 수 있는 IoT 기반 모니터링 체계를 구축하여 정보시스템 운영의 안정성을 확보하고, 장애·재난 발생 시 신속한 대응체계를 마련
-□ 사업내용
-ㅇ (구축범위) 전산실 내 화재 및 누수, 장비 오프라인 등 이상상태를 감지하기 위한 IoT 기반 실시간 모니터링 체계 구축
-<주요 구축 범위>
-구분
-구축내용
-감지 센서 설치
-전산실 내 화재 및 누수 감지를 위한 센서 설치
-IoT 보드
-센서 신호 수집, 이상상태 판단, 서버 전송 기능 구성
-카메라 연계
-이상상태 발생 또는 필요 시 현장 사진 캡처 기능 구성
-웹호스팅 서버 연계
-센서 감지정보 및 현장 사진을 웹 서버로 전송
-웹 대시보드 구축
-전산실 상태, 센서 감지 내역, 사진, 알림 이력 실시간 모니터링
-SMS 알림 기능
-이상상태 발생 시 담당자에게 SMS 자동 발송
-보안설정 적용
-계정관리, 접근통제, 전송구간 보호, 로그관리 등 보안조치 적용
-ㅇ (구축방식) 전산실 내 설치된 화재·누수 감지센서가 이상상태를 감지하면, 해당 신호를 IoT 보드로 전달하고 IoT 보드가 감지정보를 수집·처리하여 웹호스팅 서버로 전송하는 방식으로 구축
-<모니터링 시스템 구성>
-감지 센서
-(화재/누수)
-IoT 보드
-웹호스팅 서버
-SMS 발송
-카메라
-(현장 사진 캡처)
-웹 대시보드 실시간 모니터링
-보안성검토 사유
-국가 정보보안 기본지침 제16조(검토 생략)
-① 각급기관의 장은 다음 각 호에 해당하는 정보화사업에 대하여는 보안성 검토 절차의 이행을 생략할 수 있다. 이 경우 각급기관의 장은 관련 매뉴얼ㆍ가이드라인 등을 준수하는 등 자체 보안대책을 수립ㆍ시행하여야 한다.
-2. 제15조에 따른 보안성 검토를 거쳐 완료한 정보화사업에 대하여 정보통신망 구성을 변경하지 아니하는 범위 내에서 다음 각 목의 사항을 포함한 후속운영ㆍ유지보수ㆍ컨설팅(단일 회선의 이중화는 본 호를 적용함에 있어 정보통신망 구성의 변경이 아닌 것으로 본다)
-시스템 구성도
-네트워크 구성도
-본 과업의 네트워크 구성은 인터넷망을 통해 IoT 보드에서 웹호스팅 서버로 감지 정보 및 현장 사진을 전송하는 구조로 구성함. 다만, 기관 내부 업무망과는 분리하여 운영하며, 기관 경계 방화벽에서 승인된 목적지 IP 및 서비스 포트만 허용하고 통신 구간에는 HTTPS/TLS 등 암호화 통신을 적용
-보안대책
-보안관리 수행체계
-□ 추진체계도
-사업총괄
-혁신전략실장
-현업지원
-사업관리
-사업지원
-각 사업부서
-정보기반팀
-정보보안센터
-사업수행
-우편혁신AI연구팀
-□ 체계별 역할
-담당자
-역할
-비고
-사업총괄
-- 프로젝트 총괄관리
-- 주요 이슈사황 관리, 의사결정
-혁신전략실장
-사업관리
-- 프로젝트 진행상황, 업무 총괄관리
-- 실무부서와 수행사 간 협의 사항 추적 관리
-- 단계별 결과 검증 및 산출물 검토
-정보기반팀
-팀장 및 담당자
-사업수행
-- 요구사항 분석 및 컨설팅 등
-- 단계별 사업수행 보고 및 품질보증 활동
-- 결과 보고 및 이행 지원 등
-우편혁신AI연구팀
-사업지원
-- 사업 보안대책의 관리적·물리적·기술적 보안대책 적절성 검토
-- 사업 수행 중 보안대책 이행 여부 점검 및 미흡 사항 시정 요구
-정보보안센터
-관리적 보안대책
-□ 관련 법률 및 지침 준수
-ㅇ 정보통신망 이용촉진 및 정보보호 등에 관한 법률(방송통신위원회)
-ㅇ 개인정보 보호법(개인정보보호위원회)
-ㅇ 국가정보보안기본지침(국가정보원)
-ㅇ 주요정보통신기반시설 취약점 분석·평가 기준(과학기술정보통신부)
-ㅇ 행정기관 및 공공기관 정보시스템 구축·운영 지침(행정안전부)
-ㅇ 소프트웨어 개발 보안 가이드(행정안전부)
-□ 운영 단계 보안관리 대책
-ㅇ 시스템 도입 후 단말기, 연계구간, 서버, 관리자 페이지를 대상으로 주기적인 취약점 점검 수행
-ㅇ 암호 정책, 불필요 포트 차단 등 보안설정을 주기적으로 점검
-ㅇ 각 영역별 침해사고 징후를 주기적으로 모니터링 수행
-ㅇ 사용자 접속정보, 응용 실행 기록, 네트워크 및 CPU 부하, 리소스 사용량, 소프트웨어 버전 등 주요 로그를 안전하게 저장·관리
-ㅇ 로그 보관기간을 정의하고 위·변조 방지대책 적용
-ㅇ 유지보수 시 사용되는 USB 등 매체는 사전 승인된 장치만 사용하고, 사용 전 악성코드 검사 수행
-□ 구축·운영 원칙
-ㅇ IoT 시스템은 업무망/인터넷망과 분리 운영을 원칙으로 함
-ㅇ 보안기능 구축 시 일회성 반영이 아닌 운영 단계까지 지속 점검
-ㅇ 정보보호 시스템은 보안적합성이 검증된 제품을 우선 적용
-ㅇ 기능 추가, 구조 변경, 외부 연계 확대 시에는 보안성 검토를 다시 수행
-□ 사진정보 및 개인정보 보호대책
-ㅇ 현장 사진은 화재·누수 등 이벤트 발생 확인 목적으로만 수집
-ㅇ 사진정보는 웹호스팅 서버에 암호화 저장하거나 접근권한을 제한하여 관리, 보관기간은 최소한으로 설정
-ㅇ SMS 발송을 위해 처리되는 개인정보는 알림 발송 목적에 한하여 최소한으로 수집·이용하며 담당자 변경 즉시 현행화 실시
-□ 권한 및 계정관리
-ㅇ 웹 대시보드 관리자 계정은 업무상 필요한 인원에게만 부여
-ㅇ 담당자 전보, 퇴직, 업무변경 시 계정을 즉시 회수 또는 권한 변경
-ㅇ 비밀번호는 영문 대·소문자, 숫자, 특수문자를 조합하여 설정
-ㅇ 관리자 계정, SMS 발송 대상자 목록은 정기적으로 현행화
-물리적 보안대책
-□ 출입통제
-ㅇ 전산실은 통제구역으로 지정하여 인가된 인원에 한해 출입을 허용하고, 출입카드 등을 통해 출입권한을 관리
-ㅇ 전산실 내 IoT 센서, IoT 보드, 카메라 등 장비 설치·점검·교체 작업은 사전 승인된 작업계획에 따라 수행하고, 작업 완료 후 담당자가 설치 상태 및 보안조치 이행 여부 확인
-ㅇ 사업자가 사용하는 노트북(PC)의 인터넷 연결 금지 및 정보시스템 외부 반출 시 진흥원에 사전 승인
-ㅇ 사업수행 장소의 자료 및 장비보안을 위해 시건 장치와 통제가 가능한 공간을 활용하며, 정기적(월 1회 이상) 보안점검 수행
-□ 장비 설치 및 보호대책
-ㅇ 화재·누수 감지센서, IoT 보드 및 카메라는 전산실 내 지정된 위치에 설치하고, 임의 이동·탈거·조작이 어렵도록 고정 설치
-ㅇ IoT 보드 및 카메라 등 주요 장비는 잠금장치가 있는 함체 또는 보호 케이스에 설치하여 비인가자의 물리적 접근을 제한
-ㅇ 장비 설치 위치, 모델명, 일련번호, MAC 주소, IP 주소, 설치일자, 관리담당자 등을 자산관리대장에 등록하여 관리
-□ 장비 반입·반출 관리
-ㅇ 사업 수행을 위해 반입되는 노트북, 작업용 PC, 계측기, 설치 장비, 저장매체 등은 반입 전 보안점검을 실시
-ㅇ 반입 장비는 사업 수행 목적 외 사용을 금지하며, 전산실 내부에서 임의로 인터넷 연결, 테더링, 무선 공유기 연결 등을 통제
-ㅇ 부득이한 사유로 장비를 외부 반출하는 경우, 정보보안담당자 또는 사업담당자의 사전 승인을 받고 필요한 보안조치를 완료한 후 반출
-기술적 보안대책
-□ 공통 보안대책
-ㅇ USB, 이더넷, 시리얼 통신, SD카드 슬롯 등 불필요한 외부 인터페이스는 비활성화하고, 유지보수 목적 등 필요한 인터페이스는 승인된 사용자만 접근 가능하도록 통제
-ㅇ JTAG, UART, SPI, I2C 등 디버깅·정비용 내부 포트는 비인가자가 접근할 수 없도록 제거·봉인·비활성화
-ㅇ 자체 개발 소프트웨어 및 연계 모듈에는 시큐어코딩을 적용하고, 정적 분석 또는 취약점 점검을 수행
-ㅇ Telnet, FTP, 불필요한 SNMP, 테스트 계정, 디버그 기능 등 목적 외 서비스는 제거 또는 비활성화
-ㅇ 원격관리 접속 IP를 제한하고 관리자 계정은 별도 관리
-ㅇ 중요 정보와 인증 정보는 평문 저장을 금지하고 암호화 저장
-ㅇ IoT 환경을 고려한 침입탐지 및 이상징후 모니터링 체계를 운영
-ㅇ 서버·운영단말에는 백신을 설치하고 최신 상태를 유지
-□ 단말기 보안대책
-ㅇ 모든 IoT 단말기는 설치 즉시 초기 계정 및 비밀번호를 변경하고 제조사 기본 비밀번호, 공통 비밀번호 사용을 금지
-ㅇ 일반 사용자 접근을 제한하고 관리 기능 접근 시 권한을 부여받은 관리자만 접근할 수 있도록 별도 인증 수행
-ㅇ 로그인 실패 횟수 제한, 계정 잠금, 지연 응답 등 brute-force 방지 기능 적용
-ㅇ 인증정보의 하드코딩 및 평문 저장을 금지하고 * 등의 기호를 사용하여 마스킹 처리, 인증 실패 시 상세 오류 정보를 노출 제한
-ㅇ 일정 시간 미사용 시 자동 로그아웃 또는 세션 잠금 적용
-ㅇ 제조사 펌웨어 및 보안 업데이트를 검증 후 적용
-ㅇ 로그인, 설정변경, 펌웨어 변경 등 주요 행위는 로그로 기록
-ㅇ 단말기 도입·교체·폐기는 자산관리 대장에 등록하고, 교체·폐기 시 저장 정보 삭제 및 초기화 절차 수행
-□ 네트워크 보안대책
-ㅇ IoT시스템과 네트워크에는 방화벽, 침입탐지시스템 등 기존 ICT 환경 수준의 정보보호시스템 적용
-ㅇ IoT 시스템은 인터넷망·업무망과 분리 운영을 원칙으로 하며, 단말기로부터 정보를 수집하는 서버와 분석 서버는 분리 운영
-ㅇ 단말기–게이트웨이–서버 간 전송정보는 중요도에 따라 암호화하고 무결성 검증 기능을 적용
-ㅇ 세션키·인증키는 안전하게 관리
-ㅇ Wi-Fi·페어링·브로드캐스트 등 무선통신 기능 제한
-□ 플랫폼/서비스 보안대책
-ㅇ 서버, 관리자 페이지, API, 연계시스템 간에는 사용자 인증 적용
-ㅇ 관리자 접속은 계정별 권한통제와 접속기록 저장 적용
-ㅇ OS, DBMS, 응용프로그램, 에이전트 등의 최신 보안패치 정기 적용
-ㅇ 기본 계정, 시험용 서비스, 불필요 포트 및 기능 제거
-ㅇ 설정파일, DB 접근권한, API 인증키는 최소권한 원칙으로 관리
+※ [구현 범위 정정] 본 시스템 구현 범위는 SHT30 온습도 모니터링 전용입니다(화재/누수/정전/카메라 미구현). 원본 .hwp 도 동일하게 정정 필요.
+※ 아래 추출 본문은 원본 .hwp 그대로이며, 화재/누수/정전/현장 사진(카메라) 관련 서술은 실제 구현 범위가 아닙니다. 정정 지침은 docs/SECURITY_PLAN_HWP_REVIEW.md / docs/SECURITY_PLAN_PASS_READINESS.md 참조.
+捤獥 汤捯 慤桥 漠杳 漠杳
+『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
+2026. 5.
+氠瑢 湰灧 湯湷
+사업개요
+□ 사업명
+ㅇ IoT 기반 상시 모니터링 체계 구축
+□ 사업목적
+ㅇ 전산실 내 화재, 누수, 장비 전원 이상 상태를 실시간으로 감지할 수 있는 IoT 기반 모니터링 체계를 구축하여 정보시스템 운영의 안정성을 확보하고, 장애·재난 발생 시 신속한 대응체계를 마련
+□ 사업내용
+ㅇ (구축범위) 전산실 내 화재 및 누수, 장비 오프라인 등 이상상태를 감지하기 위한 IoT 기반 실시간 모니터링 체계 구축
+<주요 구축 범위>
+구분
+구축내용
+감지 센서 설치
+전산실 내 화재 및 누수 감지를 위한 센서 설치
+IoT 보드
+센서 신호 수집, 이상상태 판단, 서버 전송 기능 구성
+카메라 연계
+이상상태 발생 또는 필요 시 현장 사진 캡처 기능 구성
+웹호스팅 서버 연계
+센서 감지정보 및 현장 사진을 웹 서버로 전송
+웹 대시보드 구축
+전산실 상태, 센서 감지 내역, 사진, 알림 이력 실시간 모니터링
+SMS 알림 기능
+이상상태 발생 시 담당자에게 SMS 자동 발송
+보안설정 적용
+계정관리, 접근통제, 전송구간 보호, 로그관리 등 보안조치 적용
+ㅇ (구축방식) 전산실 내 설치된 화재·누수 감지센서가 이상상태를 감지하면, 해당 신호를 IoT 보드로 전달하고 IoT 보드가 감지정보를 수집·처리하여 웹호스팅 서버로 전송하는 방식으로 구축
+<모니터링 시스템 구성>
+감지 센서
+(화재/누수)
+IoT 보드
+웹호스팅 서버
+SMS 발송
+카메라
+(현장 사진 캡처)
+웹 대시보드 실시간 모니터링
+보안성검토 사유
+국가 정보보안 기본지침 제16조(검토 생략)
+① 각급기관의 장은 다음 각 호에 해당하는 정보화사업에 대하여는 보안성 검토 절차의 이행을 생략할 수 있다. 이 경우 각급기관의 장은 관련 매뉴얼ㆍ가이드라인 등을 준수하는 등 자체 보안대책을 수립ㆍ시행하여야 한다.
+2. 제15조에 따른 보안성 검토를 거쳐 완료한 정보화사업에 대하여 정보통신망 구성을 변경하지 아니하는 범위 내에서 다음 각 목의 사항을 포함한 후속운영ㆍ유지보수ㆍ컨설팅(단일 회선의 이중화는 본 호를 적용함에 있어 정보통신망 구성의 변경이 아닌 것으로 본다)
+시스템 구성도
+네트워크 구성도
+본 과업의 네트워크 구성은 인터넷망을 통해 IoT 보드에서 웹호스팅 서버로 감지 정보 및 현장 사진을 전송하는 구조로 구성함. 다만, 기관 내부 업무망과는 분리하여 운영하며, 기관 경계 방화벽에서 승인된 목적지 IP 및 서비스 포트만 허용하고 통신 구간에는 HTTPS/TLS 등 암호화 통신을 적용
+보안대책
+보안관리 수행체계
+□ 추진체계도
+사업총괄
+혁신전략실장
+현업지원
+사업관리
+사업지원
+각 사업부서
+정보기반팀
+정보보안센터
+사업수행
+우편혁신AI연구팀
+□ 체계별 역할
+담당자
+역할
+비고
+사업총괄
+- 프로젝트 총괄관리
+- 주요 이슈사황 관리, 의사결정
+혁신전략실장
+사업관리
+- 프로젝트 진행상황, 업무 총괄관리
+- 실무부서와 수행사 간 협의 사항 추적 관리
+- 단계별 결과 검증 및 산출물 검토
+정보기반팀
+팀장 및 담당자
+사업수행
+- 요구사항 분석 및 컨설팅 등
+- 단계별 사업수행 보고 및 품질보증 활동
+- 결과 보고 및 이행 지원 등
+우편혁신AI연구팀
+사업지원
+- 사업 보안대책의 관리적·물리적·기술적 보안대책 적절성 검토
+- 사업 수행 중 보안대책 이행 여부 점검 및 미흡 사항 시정 요구
+정보보안센터
+관리적 보안대책
+□ 관련 법률 및 지침 준수
+ㅇ 정보통신망 이용촉진 및 정보보호 등에 관한 법률(방송통신위원회)
+ㅇ 개인정보 보호법(개인정보보호위원회)
+ㅇ 국가정보보안기본지침(국가정보원)
+ㅇ 주요정보통신기반시설 취약점 분석·평가 기준(과학기술정보통신부)
+ㅇ 행정기관 및 공공기관 정보시스템 구축·운영 지침(행정안전부)
+ㅇ 소프트웨어 개발 보안 가이드(행정안전부)
+□ 운영 단계 보안관리 대책
+ㅇ 시스템 도입 후 단말기, 연계구간, 서버, 관리자 페이지를 대상으로 주기적인 취약점 점검 수행
+ㅇ 암호 정책, 불필요 포트 차단 등 보안설정을 주기적으로 점검
+ㅇ 각 영역별 침해사고 징후를 주기적으로 모니터링 수행
+ㅇ 사용자 접속정보, 응용 실행 기록, 네트워크 및 CPU 부하, 리소스 사용량, 소프트웨어 버전 등 주요 로그를 안전하게 저장·관리
+ㅇ 로그 보관기간을 정의하고 위·변조 방지대책 적용
+ㅇ 유지보수 시 사용되는 USB 등 매체는 사전 승인된 장치만 사용하고, 사용 전 악성코드 검사 수행
+□ 구축·운영 원칙
+ㅇ IoT 시스템은 업무망/인터넷망과 분리 운영을 원칙으로 함
+ㅇ 보안기능 구축 시 일회성 반영이 아닌 운영 단계까지 지속 점검
+ㅇ 정보보호 시스템은 보안적합성이 검증된 제품을 우선 적용
+ㅇ 기능 추가, 구조 변경, 외부 연계 확대 시에는 보안성 검토를 다시 수행
+□ 사진정보 및 개인정보 보호대책
+ㅇ 현장 사진은 화재·누수 등 이벤트 발생 확인 목적으로만 수집
+ㅇ 사진정보는 웹호스팅 서버에 암호화 저장하거나 접근권한을 제한하여 관리, 보관기간은 최소한으로 설정
+ㅇ SMS 발송을 위해 처리되는 개인정보는 알림 발송 목적에 한하여 최소한으로 수집·이용하며 담당자 변경 즉시 현행화 실시
+□ 권한 및 계정관리
+ㅇ 웹 대시보드 관리자 계정은 업무상 필요한 인원에게만 부여
+ㅇ 담당자 전보, 퇴직, 업무변경 시 계정을 즉시 회수 또는 권한 변경
+ㅇ 비밀번호는 영문 대·소문자, 숫자, 특수문자를 조합하여 설정
+ㅇ 관리자 계정, SMS 발송 대상자 목록은 정기적으로 현행화
+물리적 보안대책
+□ 출입통제
+ㅇ 전산실은 통제구역으로 지정하여 인가된 인원에 한해 출입을 허용하고, 출입카드 등을 통해 출입권한을 관리
+ㅇ 전산실 내 IoT 센서, IoT 보드, 카메라 등 장비 설치·점검·교체 작업은 사전 승인된 작업계획에 따라 수행하고, 작업 완료 후 담당자가 설치 상태 및 보안조치 이행 여부 확인
+ㅇ 사업자가 사용하는 노트북(PC)의 인터넷 연결 금지 및 정보시스템 외부 반출 시 진흥원에 사전 승인
+ㅇ 사업수행 장소의 자료 및 장비보안을 위해 시건 장치와 통제가 가능한 공간을 활용하며, 정기적(월 1회 이상) 보안점검 수행
+□ 장비 설치 및 보호대책
+ㅇ 화재·누수 감지센서, IoT 보드 및 카메라는 전산실 내 지정된 위치에 설치하고, 임의 이동·탈거·조작이 어렵도록 고정 설치
+ㅇ IoT 보드 및 카메라 등 주요 장비는 잠금장치가 있는 함체 또는 보호 케이스에 설치하여 비인가자의 물리적 접근을 제한
+ㅇ 장비 설치 위치, 모델명, 일련번호, MAC 주소, IP 주소, 설치일자, 관리담당자 등을 자산관리대장에 등록하여 관리
+□ 장비 반입·반출 관리
+ㅇ 사업 수행을 위해 반입되는 노트북, 작업용 PC, 계측기, 설치 장비, 저장매체 등은 반입 전 보안점검을 실시
+ㅇ 반입 장비는 사업 수행 목적 외 사용을 금지하며, 전산실 내부에서 임의로 인터넷 연결, 테더링, 무선 공유기 연결 등을 통제
+ㅇ 부득이한 사유로 장비를 외부 반출하는 경우, 정보보안담당자 또는 사업담당자의 사전 승인을 받고 필요한 보안조치를 완료한 후 반출
+기술적 보안대책
+□ 공통 보안대책
+ㅇ USB, 이더넷, 시리얼 통신, SD카드 슬롯 등 불필요한 외부 인터페이스는 비활성화하고, 유지보수 목적 등 필요한 인터페이스는 승인된 사용자만 접근 가능하도록 통제
+ㅇ JTAG, UART, SPI, I2C 등 디버깅·정비용 내부 포트는 비인가자가 접근할 수 없도록 제거·봉인·비활성화
+ㅇ 자체 개발 소프트웨어 및 연계 모듈에는 시큐어코딩을 적용하고, 정적 분석 또는 취약점 점검을 수행
+ㅇ Telnet, FTP, 불필요한 SNMP, 테스트 계정, 디버그 기능 등 목적 외 서비스는 제거 또는 비활성화
+ㅇ 원격관리 접속 IP를 제한하고 관리자 계정은 별도 관리
+ㅇ 중요 정보와 인증 정보는 평문 저장을 금지하고 암호화 저장
+ㅇ IoT 환경을 고려한 침입탐지 및 이상징후 모니터링 체계를 운영
+ㅇ 서버·운영단말에는 백신을 설치하고 최신 상태를 유지
+□ 단말기 보안대책
+ㅇ 모든 IoT 단말기는 설치 즉시 초기 계정 및 비밀번호를 변경하고 제조사 기본 비밀번호, 공통 비밀번호 사용을 금지
+ㅇ 일반 사용자 접근을 제한하고 관리 기능 접근 시 권한을 부여받은 관리자만 접근할 수 있도록 별도 인증 수행
+ㅇ 로그인 실패 횟수 제한, 계정 잠금, 지연 응답 등 brute-force 방지 기능 적용
+ㅇ 인증정보의 하드코딩 및 평문 저장을 금지하고 * 등의 기호를 사용하여 마스킹 처리, 인증 실패 시 상세 오류 정보를 노출 제한
+ㅇ 일정 시간 미사용 시 자동 로그아웃 또는 세션 잠금 적용
+ㅇ 제조사 펌웨어 및 보안 업데이트를 검증 후 적용
+ㅇ 로그인, 설정변경, 펌웨어 변경 등 주요 행위는 로그로 기록
+ㅇ 단말기 도입·교체·폐기는 자산관리 대장에 등록하고, 교체·폐기 시 저장 정보 삭제 및 초기화 절차 수행
+□ 네트워크 보안대책
+ㅇ IoT시스템과 네트워크에는 방화벽, 침입탐지시스템 등 기존 ICT 환경 수준의 정보보호시스템 적용
+ㅇ IoT 시스템은 인터넷망·업무망과 분리 운영을 원칙으로 하며, 단말기로부터 정보를 수집하는 서버와 분석 서버는 분리 운영
+ㅇ 단말기–게이트웨이–서버 간 전송정보는 중요도에 따라 암호화하고 무결성 검증 기능을 적용
+ㅇ 세션키·인증키는 안전하게 관리
+ㅇ Wi-Fi·페어링·브로드캐스트 등 무선통신 기능 제한
+□ 플랫폼/서비스 보안대책
+ㅇ 서버, 관리자 페이지, API, 연계시스템 간에는 사용자 인증 적용
+ㅇ 관리자 접속은 계정별 권한통제와 접속기록 저장 적용
+ㅇ OS, DBMS, 응용프로그램, 에이전트 등의 최신 보안패치 정기 적용
+ㅇ 기본 계정, 시험용 서비스, 불필요 포트 및 기능 제거
+ㅇ 설정파일, DB 접근권한, API 인증키는 최소권한 원칙으로 관리
ㅇ 중요 데이터는 주기적으로 백업하고 복구 가능 여부 점검
\ No newline at end of file
diff --git a/docs/build_standalone.py b/docs/build_standalone.py
new file mode 100644
index 0000000..757d269
--- /dev/null
+++ b/docs/build_standalone.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# =============================================================================
+# build_standalone.py — INSTALL_GUIDE.html 의 모든 로컬 이미지를 base64 로 인라인하여
+# 단일 자체완결 파일(INSTALL_GUIDE.standalone.html)을 만든다.
+#
+# 사용:
+# 1) docs/screenshots/ 에 캡처를 README 의 파일명대로 저장
+# 2) python3 docs/build_standalone.py
+# 3) 산출물: docs/INSTALL_GUIDE.standalone.html (이미지까지 한 파일에 내장 — 폴더 없이 전달 가능)
+#
+# 파일이 아직 없는 캡처는 상대경로(src="screenshots/..")로 남겨 두므로, 본문의
+# onerror 자리표시가 그대로 동작한다. 캡처를 채운 뒤 다시 실행하면 모두 내장된다.
+# 외부(http/https)·이미 data: 인 src 는 건드리지 않는다(폐쇄망 유지).
+# =============================================================================
+import base64, os, re, sys
+
+BASE = os.path.dirname(os.path.abspath(__file__)) # docs/
+SRC = os.path.join(BASE, 'INSTALL_GUIDE.html')
+OUT = os.path.join(BASE, 'INSTALL_GUIDE.standalone.html')
+
+MIME = {'.png':'image/png', '.svg':'image/svg+xml', '.jpg':'image/jpeg',
+ '.jpeg':'image/jpeg', '.gif':'image/gif', '.bmp':'image/bmp', '.webp':'image/webp'}
+
+def to_data_uri(path: str) -> str:
+ ext = os.path.splitext(path)[1].lower()
+ mime = MIME.get(ext, 'application/octet-stream')
+ with open(path, 'rb') as f:
+ b64 = base64.b64encode(f.read()).decode('ascii')
+ return f'data:{mime};base64,{b64}'
+
+def main() -> int:
+ if not os.path.isfile(SRC):
+ print(f'ERROR: {SRC} 없음', file=sys.stderr); return 1
+ html = open(SRC, encoding='utf-8').read()
+
+ embedded, missing, skipped = [], [], []
+ for src in sorted(set(re.findall(r'src="([^"]+)"', html))):
+ if src.startswith(('data:', 'http://', 'https://')):
+ skipped.append(src); continue
+ fp = os.path.normpath(os.path.join(BASE, src))
+ if os.path.isfile(fp):
+ html = html.replace(f'src="{src}"', f'src="{to_data_uri(fp)}"')
+ embedded.append(src)
+ else:
+ missing.append(src)
+
+ open(OUT, 'w', encoding='utf-8').write(html)
+
+ print(f'내장(embedded) {len(embedded)}개:')
+ for e in embedded: print(' +', e)
+ if missing:
+ print(f'미존재 — 상대경로 유지(캡처 추가 후 재실행) {len(missing)}개:')
+ for m in missing: print(' -', m)
+ if skipped:
+ print(f'건너뜀(외부/이미 data:) {len(skipped)}개')
+ size = os.path.getsize(OUT)
+ print(f'\n산출물: {OUT} ({size/1024:.0f} KB)')
+ if not missing:
+ print('→ 모든 이미지 내장 완료. 이 파일 하나만 전달하면 됩니다(폴더 불필요).')
+ else:
+ print('→ 일부 캡처 미제공: 해당 자리는 자리표시로 표시됩니다. 캡처 추가 후 다시 실행하세요.')
+ return 0
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/docs/screenshots/.gitkeep b/docs/screenshots/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
new file mode 100644
index 0000000..b7e8449
--- /dev/null
+++ b/docs/screenshots/README.md
@@ -0,0 +1,22 @@
+# 설치 가이드 캡처 이미지 폴더
+
+`docs/INSTALL_GUIDE.html` 본문의 각 "📷 캡처 위치" 는 아래 파일명을 가진 ` ` 로 연결되어 있습니다.
+**운영 시스템에서 해당 화면을 캡처해 아래 파일명 그대로 이 폴더에 저장**하면, 가이드를 열었을 때
+자리표시 대신 실제 이미지가 표시됩니다. (파일이 없으면 "여기에 넣으세요" 자리표시가 자동으로 보입니다.)
+
+| 파일명 | 그림 | 캡처할 화면 |
+|---|---|---|
+| `02-server-files-upload.png` | 그림 2 | Cafe24 FTP/파일 관리자 — `public_html/raspi_leck_detecter/` 업로드된 파일 목록 |
+| `03-db-phpmyadmin.png` | 그림 3 | phpMyAdmin SQL 탭에 `schema_sht30.sql` 실행 → 성공 + 테이블 생성 |
+| `04-mfa-setup.png` | 그림 4 | `setup_mfa.php` 등록 화면(Base32 키 + 6자리 코드 확인). 비밀키 일부 가림 권장 |
+| `05-setup-wizard.png` | 그림 5 | `setup_wizard.php` 점검 결과(모든 항목 ✅ + 테스트 SMS 성공) |
+| `06-cron-register.png` | 그림 6 | Cafe24 크론탭(예약작업)에 `cron_heartbeat.php` 1분 주기 등록 |
+| `08-firmware-build.png` | 그림 8 | 빌드 머신 터미널 — `cmake --build … --target sht30_fw` 성공 + size 출력 |
+| `09-flash.png` | 그림 9 | 플래싱 툴(STM32CubeProgrammer/`st-flash`) "Flash written/verified" |
+| `10-serial-console.png` | 그림 10 | 시리얼 터미널(USART3) 부팅 로그 — SNTP → TLS OK → 서버 200 |
+| `11-dashboard.png` | 그림 11 | 운영 `dashboard.php`(상단 "정상 감시 중" + 2번 센서 SHT30 온/습도 카드) |
+
+## 권장 사항
+- 형식: PNG 권장(고정 파일명). 캡처에 **수신번호·TOTP 비밀키 등 비밀값이 보이면 가린 뒤** 저장하세요.
+- 폭: 가로 1000~1400px 정도면 인쇄/화면 모두 적절합니다.
+- 모바일 대시보드를 추가로 넣고 싶으면 본문 그림 11 옆에 ` ` 를 직접 추가하면 됩니다.
diff --git a/docs/stm32f407_migration_plan.md b/docs/stm32f407_migration_plan.md
index fd5d241..0144ac1 100644
--- a/docs/stm32f407_migration_plan.md
+++ b/docs/stm32f407_migration_plan.md
@@ -135,11 +135,11 @@ main()
### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
-- 서버 검증식: `sha256(API_KEY . 요청_본문_바이트_그대로)`, 값은 HTTP 헤더 **`X-Signature`**로 전송.
-- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 SHA-256으로 서명만 하면 된다.
+- 서버 검증식: `HMAC-SHA256(키=API_KEY, 메시지=요청_본문_바이트_그대로)`(소문자 hex), 값은 HTTP 헤더 **`X-Signature`**로 전송. HMAC을 사용하므로 길이확장 공격에 견고하다.
+- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 API_KEY 로 HMAC-SHA256 서명만 하면 된다.
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거).
- 클라이언트 절차:
- 1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = sha256_hex(API_KEY || body)`.
+ 1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = hmac_sha256_hex(key=API_KEY, msg=body)`.
3. `POST` 시 `Content-Type: application/json` + `X-Signature: ` 헤더, 본문=`body`.
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
@@ -147,7 +147,7 @@ main()
```c
// 펌웨어: 전송 바이트 그대로 서명
-sig_sha256_hex(body, sig_hex); // sha256(API_KEY || body)
+sig_raw_body(API_KEY, body, sig_hex); // HMAC-SHA256(key=API_KEY, msg=body)
// HTTP 헤더에 "X-Signature: " 추가, 본문은 body 그대로 전송
```
@@ -199,13 +199,13 @@ int build_body_sht30(char *out, size_t n, const Sht30Msg *m) {
m->metric_status, m->app_version, (long)m->timestamp);
}
-// sha256(API_KEY || body) → hex. body는 전송 바이트와 100% 동일해야 한다.
-void sig_sha256_hex(const char *body, char hex[65]) {
- uint8_t d[32]; mbedtls_sha256_context c;
- mbedtls_sha256_init(&c); mbedtls_sha256_starts(&c, 0);
- mbedtls_sha256_update(&c, (const uint8_t*)API_KEY, strlen(API_KEY));
- mbedtls_sha256_update(&c, (const uint8_t*)body, strlen(body));
- mbedtls_sha256_finish(&c, d); mbedtls_sha256_free(&c);
+// HMAC-SHA256(key=API_KEY, msg=body) → hex. body는 전송 바이트와 100% 동일해야 한다.
+// HMAC 구조이므로 키-접두 sha256과 달리 길이확장 공격에 견고하다.
+void sig_raw_body(const char *key, const char *body, char hex[65]) {
+ uint8_t d[32];
+ const mbedtls_md_info_t *md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
+ mbedtls_md_hmac(md, (const uint8_t*)key, strlen(key),
+ (const uint8_t*)body, strlen(body), d);
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
}
// POST 시 헤더에 "X-Signature: " 추가, 본문은 위 body 그대로 전송.
diff --git a/docs/wiring_diagram.md b/docs/wiring_diagram.md
index d709174..41f738d 100644
--- a/docs/wiring_diagram.md
+++ b/docs/wiring_diagram.md
@@ -67,7 +67,9 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
- 첫 측정값(온도/습도) 및 startup 보고
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답
-정상 측정 범위(벗어나면 `metric_status="out_of_range"`): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
+정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
+
+> 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다.
---
diff --git a/firmware/README.md b/firmware/README.md
index 2c28a42..94b2102 100644
--- a/firmware/README.md
+++ b/firmware/README.md
@@ -29,7 +29,7 @@
└──────────────┘ │ Task_Watchdog: IWDG refresh │ │ 스위치/공유기 │
└────────────────────────────┘ └──────┬───────┘
│
- lowercase_hex(sha256(API_KEY || raw_body)) X-Signature 헤더 │ HTTPS/TLS1.2
+ lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) X-Signature 헤더 │ HTTPS/TLS1.2
▼
┌────────────────────────────────────────────────────────┐
│ Cafe24 호스팅 (변경 없음) │
@@ -40,7 +40,7 @@
```
- 펌웨어는 매 보고마다 `connect → TLS 핸드셰이크 → POST → close` 를 수행한다(메모리 절약, 보고 주기가 길어 성능 무관).
-- 서명은 본문 바이트 그대로 `sha256(API_KEY || body)` 를 계산해 **`X-Signature`** 헤더로 보낸다. 본문의 키 정렬/float 포맷 재현이 불필요하다(서버 `verify_signature_raw`).
+- 서명은 본문 바이트 그대로 `HMAC-SHA256(key=API_KEY, msg=body)`(소문자 hex) 를 계산해 **`X-Signature`** 헤더로 보낸다. 본문의 키 정렬/float 포맷 재현이 불필요하다(서버 `verify_signature_raw`). HMAC 구조라 길이확장 공격에 견고하다.
- **임계(고온/저온/고습/저습) 판정과 SMS 발송은 서버(PHP)에서 수행**한다. 펌웨어는 온습도 원값과 plausibility 상태(`metric_status`)만 보고하고, 경보 임계는 서버 `config.php` 의 `METRIC_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함).
---
@@ -74,7 +74,7 @@
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
-> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다.
+> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음).
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.
@@ -95,7 +95,7 @@ cp firmware/common/secrets.h.example firmware/common/secrets.h
# 2) 빌드 (sht30_fw 단일 타깃)
cmake -S firmware -B firmware/build -G Ninja \
- -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
+ -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
cmake --build firmware/build --target sht30_fw # → sht30_fw.bin/.hex/.elf
# 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정
@@ -119,7 +119,7 @@ firmware/
├─ README.md ← (이 문서)
├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합
├─ cmake/
-│ └─ arm-none-eabi.cmake 크로스 툴체인 파일 (gcc 15.2)
+│ └─ arm-none-eabi-toolchain.cmake 크로스 툴체인 파일 (gcc 15.2)
├─ scripts/
│ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비)
│ ├─ build.ps1 cmake configure + sht30_fw 빌드
diff --git a/firmware/common/app_config.h b/firmware/common/app_config.h
index 73f6bd3..beb496a 100644
--- a/firmware/common/app_config.h
+++ b/firmware/common/app_config.h
@@ -39,7 +39,15 @@
#define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */
/* ── 시간 동기 (SNTP) — TLS 인증서 유효기간/timestamp 필드에 필수 ────────── */
-#define APP_SNTP_SERVER "pool.ntp.org"
+/* !! 폐쇄망(air-gapped) 필수 설정 !!
+ * APP_SNTP_SERVER 는 *폐쇄망 내부 LAN NTP 서버*의 IP/호스트로 반드시 교체할 것.
+ * 인터넷 NTP(예: pool.ntp.org)는 폐쇄망에서 도달 불가 → SNTP 실패 → RTC 미설정 →
+ * timestamp=0 → TLS 연결 차단 + 서버가 신선도 위반으로 전량 거부 = 보고 전면 장애.
+ * (DNS 가 없을 수 있으므로 호스트명보다 LAN IP 권장. 아래는 RFC5737 TEST-NET 자리표시자
+ * 로, 그대로 두면 의도적으로 동기 실패하여 미설정을 드러낸다.)
+ * 보조: 서버 ingest 200 응답의 server_now 로 RTC 드리프트를 미세보정하는 후속 작업
+ * 예정(LSI 드리프트/간헐 NTP 대비). 최초 시각 확보는 LAN NTP(또는 배터리 RTC)가 필요. */
+#define APP_SNTP_SERVER "192.0.2.123" /* TODO(deploy): 폐쇄망 LAN NTP IP 로 교체 */
#define APP_SNTP_RETRY_DELAY_MS 15000u
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */
diff --git a/firmware/common/net.c b/firmware/common/net.c
index c3704b2..000511c 100644
--- a/firmware/common/net.c
+++ b/firmware/common/net.c
@@ -236,15 +236,15 @@ void net_ip_str(char *buf, uint32_t buflen)
}
}
-/* ── LWIP_RAND() 백엔드 ─────────────────────────────────────────────────────
- * lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 하드웨어 RNG 가
- * 정식 구현(rng.c/bsp.c)될 때까지의 약한 대체 구현. RNG 페리페럴이 준비되면
- * 그쪽 strong 심볼이 우선하도록 weak 로 둔다.
- * TODO(hw): bsp/rng 모듈에서 HW RNG(RNG->DR) 기반 강한 bsp_rand32() 제공. */
+/* ── LWIP_RAND() 백엔드 (약한 폴백) ─────────────────────────────────────────
+ * lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 운영 펌웨어(sht30_fw)는
+ * bsp.c 의 *강한* 하드웨어 RNG 구현(bsp_rand32, RNG->DR)이 이 weak 심볼을 덮어쓰므로
+ * 실제로는 HW RNG 가 사용된다(TCP ISN / DHCP·DNS 트랜잭션 ID 예측성 완화). 이 weak
+ * 구현은 bsp.c 를 링크하지 않는 빌드(예: 호스트 패리티)에서만 쓰이는 폴백이다. */
__attribute__((weak)) uint32_t bsp_rand32(void)
{
/* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합).
- * 운영에서는 반드시 HW RNG 구현으로 대체. */
+ * 운영 빌드에서는 bsp.c 의 강한 HW RNG 구현이 이 폴백을 대체한다. */
static uint32_t s = 0x1234abcdU;
s ^= (uint32_t)xTaskGetTickCount();
s ^= s << 13;
diff --git a/firmware/common/secrets.h.example b/firmware/common/secrets.h.example
index a14339e..0ffcb62 100644
--- a/firmware/common/secrets.h.example
+++ b/firmware/common/secrets.h.example
@@ -6,7 +6,7 @@
* secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록).
*
* APP_API_KEY 는 서버 php/config.php 의 API_KEY 와 반드시 동일해야 한다.
- * (raw-body 서명: X-Signature = sha256(APP_API_KEY || 본문) )
+ * (raw-body 서명: X-Signature = lowercase_hex(HMAC-SHA256(키=APP_API_KEY, 메시지=본문 바이트)) )
* ===========================================================================*/
#ifndef SECRETS_H
#define SECRETS_H
diff --git a/firmware/common/sig.h b/firmware/common/sig.h
index 661a279..22b9b27 100644
--- a/firmware/common/sig.h
+++ b/firmware/common/sig.h
@@ -16,7 +16,7 @@
#define SIG_HEX_LEN 64 /* SHA-256 hex 길이 (NUL 제외) */
#define SIG_HEX_BUFSZ (SIG_HEX_LEN + 1)
-/* out_hex 에 sha256(key || body) 의 소문자 hex(+NUL)를 기록한다.
+/* out_hex 에 HMAC-SHA256(key, body) 의 소문자 hex(+NUL)를 기록한다.
* out_hex 는 최소 SIG_HEX_BUFSZ 바이트.
* key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */
void sig_raw_body(const char *key, const char *body, size_t body_len,
diff --git a/firmware/config/lwipopts.h b/firmware/config/lwipopts.h
index 9db11ef..54e9feb 100644
--- a/firmware/config/lwipopts.h
+++ b/firmware/config/lwipopts.h
@@ -198,9 +198,9 @@ extern void timesync_apply_epoch(uint32_t unix_seconds);
#define LWIP_NETIF_TX_SINGLE_PBUF 1 /* 송신 시 단일 pbuf 선호(HAL_ETH 단순화) */
#define LWIP_RAND() ((u32_t)bsp_rand32())
-/* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수(bsp/별도 모듈 제공).
- * TODO(hw): bsp.c 또는 rng.c 에서 bsp_rand32() 를 RNG 페리페럴로 구현.
- * (현재 net.c 에 약한 대체 구현이 있을 수 있음) */
+/* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수.
+ * 운영 펌웨어(sht30_fw)는 bsp.c 가 RNG 페리페럴(RNG->DR) 기반 강한 bsp_rand32() 를
+ * 제공하며, net.c 의 weak 폴백을 덮어쓴다(bsp.c 미링크 빌드에서만 weak 폴백 사용). */
uint32_t bsp_rand32(void);
#endif /* LWIPOPTS_H */
diff --git a/firmware/docs/BUILD_OFFLINE.md b/firmware/docs/BUILD_OFFLINE.md
index 54d4f89..f586fc4 100644
--- a/firmware/docs/BUILD_OFFLINE.md
+++ b/firmware/docs/BUILD_OFFLINE.md
@@ -51,10 +51,10 @@ firmware/third_party/
```bash
# 빌드 머신(인터넷 가능)에서 1회 실행. 고정 버전을 받아 third_party/ 로 정리한다.
-bash firmware/scripts/vendor_deps.sh # (또는 vendor_deps.ps1)
+bash firmware/scripts/vendor.sh # (또는 vendor.ps1)
```
-> `TODO(hw):` `firmware/scripts/vendor_deps.{sh,ps1}` 는 별도 작업으로 작성/제공된다. 권장 고정 버전(예시):
+> `TODO(hw):` `firmware/scripts/vendor.{sh,ps1}` 는 별도 작업으로 작성/제공된다. 권장 고정 버전(예시):
> STM32CubeF4 `v1.28.x`, FreeRTOS-Kernel `V11.x`, lwip `STABLE-2.2.x`, mbedtls `v3.6.x` (LTS).
> 스크립트 미제공 시: 각 릴리스 tarball 을 수동으로 받아 위 디렉터리 구조로 풀고, **버전을 기록**한다.
@@ -76,7 +76,7 @@ cp firmware/common/secrets.h.example firmware/common/secrets.h
```
`secrets.h` 의 `APP_API_KEY` 를 **서버 `php/config.php` 의 `API_KEY` 와 바이트 단위로 동일**하게 채운다.
-(raw-body 서명: `X-Signature = sha256(APP_API_KEY || body)`.)
+(raw-body 서명: `X-Signature = lowercase_hex(HMAC-SHA256(key=APP_API_KEY, msg=body))`.)
> - `secrets.h` 는 **저장소에 커밋하지 않는다**(`.gitignore` 등록). 산출 `.bin` 안에는 키가 임베드되므로
> `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제).
@@ -126,7 +126,7 @@ const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
```bash
# 구성 (크로스 툴체인 파일 지정)
cmake -S firmware -B firmware/build -G Ninja \
- -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
+ -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
# SHT30 보드 산출물 빌드 (보드 매크로 -DBOARD_SHT30 은 타깃이 자동 주입)
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex
@@ -148,14 +148,14 @@ arm-none-eabi-size firmware/build/sht30_fw.elf # Flash(1MB)/RAM(192KB) 예
```
> 빌드 중 인터넷 접근이 발생하면 **벤더링 누락**이다 — `third_party/` 를 보강하고 외부 fetch 0건을 보장한다.
-> `TODO(hw):` `firmware/cmake/arm-none-eabi.cmake` / `*.ld`(CCM 스택 배치) 는 별도 작업으로 제공된다.
+> `TODO(hw):` `firmware/cmake/arm-none-eabi-toolchain.cmake` / `*.ld`(CCM 스택 배치) 는 별도 작업으로 제공된다.
### 4.1 (선택) 호스트 패리티 게이트 — 서버 기대값 대조
`.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다.
```bash
-# firmware/test/host : sha256(API_KEY||body) 와 PHP verify_signature_raw 결과 일치 확인
+# firmware/test/host : HMAC-SHA256(key=API_KEY, msg=body) 와 PHP verify_signature_raw 결과 일치 확인
python3 firmware/test/host/parity_test.py
php firmware/test/host/php_verify.php # 동일 본문에 대한 서버측 서명 기대값
```
diff --git a/firmware/docs/HARDWARE.md b/firmware/docs/HARDWARE.md
index b8b7087..10b8ead 100644
--- a/firmware/docs/HARDWARE.md
+++ b/firmware/docs/HARDWARE.md
@@ -98,8 +98,9 @@ Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py`
- 7-bit 주소: **0x44** (`APP_SHT30_I2C_ADDR`). HAL 호출 시 `0x44<<1`.
- 명령: `0x2C06` (high-repeatability, clock-stretch off) → ~20ms 대기 → 6바이트 read.
- 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장.
-- plausibility 범위(벗어나면 `metric_status="out_of_range"`): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)).
+- plausibility 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)).
운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고).
+ `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음).
```
SHT30 모듈 STM32
diff --git a/firmware/docs/PORTING_NOTES.md b/firmware/docs/PORTING_NOTES.md
index 41fa41c..2a99203 100644
--- a/firmware/docs/PORTING_NOTES.md
+++ b/firmware/docs/PORTING_NOTES.md
@@ -84,7 +84,7 @@ SHT30 시나리오: `startup` → (300s 마다) `periodic` 측정 보고. 서버
| **TLS 해시/암호** | OS OpenSSL | **소프트웨어**(SHA-256/AES) | F407 에 HW 해시 가속기 없음. RSA-2048 핸드셰이크 ~1–2s 허용 |
| **동시성** | Python threads + Lock | FreeRTOS tasks/queue/timer (네이티브 API) | RTOS. 태스크 스택은 CCM(64KB) 배치 |
| **시간원** | OS NTP | SNTP→RTC (TLS 전에 수행) | UDP SNTP 는 TLS 불필요 → 닭-달걀 회피 |
-| **서명 방식** | `sha256(API_KEY + json.dumps(sort_keys, ensure_ascii=False, separators=(',',':')))` (본문에 `signature` 필드) | `sha256(API_KEY ‖ raw_body)` → **`X-Signature` 헤더** | **(중요)** 키 정렬/float 포맷/슬래시 이스케이프 재현 불필요. SHT30 float 서명 패리티 문제 원천 제거(R1). 서버는 `verify_signature_raw` + 레거시 폴백 이중 모드 |
+| **서명 방식** | `sha256(API_KEY + json.dumps(sort_keys, ensure_ascii=False, separators=(',',':')))` (본문에 `signature` 필드) | `HMAC-SHA256(key=API_KEY, msg=raw_body)`(소문자 hex) → **`X-Signature` 헤더** | **(중요)** 키 정렬/float 포맷/슬래시 이스케이프 재현 불필요. SHT30 float 서명 패리티 문제 원천 제거(R1). HMAC 구조라 길이확장 공격에 견고. 서버는 `verify_signature_raw` + 레거시 폴백 이중 모드 |
| **본문 필드(wire)** | `is_leak` 포함(누수 시스템 잔재) | **`is_leak` 필드 제거** | 단일 SHT30 보드에 무의미. `jb_sht30_event` 가 더 이상 직렬화하지 않으며, 서버 `sensor_data.php` 도 `is_leak` 처리 제거 |
| **임계 판정/SMS** | (서버측) | **서버 `config.php` METRIC_* 에서 판정** | 폐쇄망 재플래시 비용 회피. 펌웨어는 원값 + `metric_status` 만 보고(§4) |
| **설정** | env / `config.py` | 컴파일 타임 `app_config.h`/`board_config.h` + `secrets.h` | 런타임 env 없음(옵션: flash 설정/UART 명령 미구현) |
@@ -99,7 +99,7 @@ RPi 의 `make_signature()` 는 본문을 **정규화(sort_keys, no-space, non-as
```c
/* 펌웨어 절차 (httpapi.c::api_post_once 가 내부 수행) */
-sig_raw_body(APP_API_KEY, body, strlen(body), sig_hex); /* sha256(key || body) */
+sig_raw_body(APP_API_KEY, body, strlen(body), sig_hex); /* HMAC-SHA256(key=APP_API_KEY, msg=body) */
/* HTTP 헤더에 "X-Signature: ", 본문은 body 바이트 그대로 전송 */
```
@@ -119,6 +119,8 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
1. **펌웨어(측정·보고)**: 300s 마다 SHT30 을 읽어 `temperature_c`/`humidity_percent` **원값**(소수 2자리)을 계산하고,
센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해
`sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.**
+ 여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로
+ DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `out_of_range` 는 저장되지 않는다.
2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해
고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다.
@@ -155,7 +157,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
## 6. 검증 게이트 (이식 정확성 확인)
-- **호스트 패리티**: `firmware/test/host/parity_test.py` + `php_verify.php` 로 `sha256(API_KEY‖body)` 가 서버
+- **호스트 패리티**: `firmware/test/host/parity_test.py` + `php_verify.php` 로 `HMAC-SHA256(key=API_KEY, msg=body)` 가 서버
`verify_signature_raw` 기대값과 일치하는지 대조(순수 로직: 서명/JSON/CRC/변환은 호스트·ARM 동일 다이제스트).
- **E2E**: 스테이징 PHP 로 `startup/periodic`(SHT30) 200 수신, `temperature_c`/`humidity_percent`/`metric_status` 적재 +
서버 임계 초과 시 SMS 트리거 확인.
diff --git a/firmware/scripts/build.ps1 b/firmware/scripts/build.ps1
index 0e27dc3..ff1eea2 100644
--- a/firmware/scripts/build.ps1
+++ b/firmware/scripts/build.ps1
@@ -32,7 +32,10 @@ param(
[switch]$Clean,
- [string]$ToolchainPrefix
+ [string]$ToolchainPrefix,
+
+ # 자리표시자(PLACEHOLDER) 서버 CA 상태로도 빌드를 강행한다(테스트 전용).
+ [switch]$AllowPlaceholderCA
)
$ErrorActionPreference = 'Stop'
@@ -78,6 +81,24 @@ networked 빌드 머신에서 먼저 실행하세요:
"@
}
+# ── 배포 가드: 자리표시자(PLACEHOLDER) 서버 CA 검출 ──────────────────────────
+# server_ca.c 가 자리표시자면 부팅 시 tls_init() 의 X.509 파싱이 실패해 bsp_fatal()
+# 로 안전 정지한다(데이터 유출은 없으나 동작 불가). "조용한 부팅 실패"를 피하려고
+# 빌드 단계에서 미리 차단한다. 실제 Cafe24 루트 CA 로 교체해야 한다(certs/README.md).
+$ServerCa = Join-Path $FwRoot 'certs/server_ca.c'
+if ((Test-Path $ServerCa) -and (Select-String -Path $ServerCa -SimpleMatch 'PLACEHOLDER' -Quiet)) {
+ if ($AllowPlaceholderCA) {
+ Write-Warning "server_ca.c 가 자리표시자(PLACEHOLDER) 입니다 — 이 펌웨어는 부팅 시 TLS 초기화 실패로 정지합니다(-AllowPlaceholderCA 로 강행)."
+ } else {
+ throw @"
+server_ca.c 가 자리표시자(PLACEHOLDER) 상태입니다 — 실제 Cafe24 루트 CA 로 교체해야 합니다.
+이 상태로 빌드한 펌웨어는 부팅 시 tls_init() 이 bsp_fatal() 로 정지합니다(동작 불가).
+ 교체: firmware/certs/README.md 및 certs/gen_ca_header.ps1 참고.
+ (테스트 목적 강행: build.ps1 ... -AllowPlaceholderCA)
+"@
+ }
+}
+
# ── 클린 빌드 ────────────────────────────────────────────────────────────────
if ($Clean -and (Test-Path $BuildDir)) {
Write-Host " (clean) 기존 빌드 디렉터리 삭제: $BuildDir" -ForegroundColor Yellow
diff --git a/firmware/scripts/vendor.ps1 b/firmware/scripts/vendor.ps1
index 8c98499..e6523cb 100644
--- a/firmware/scripts/vendor.ps1
+++ b/firmware/scripts/vendor.ps1
@@ -31,7 +31,9 @@
[CmdletBinding()]
param(
[switch]$Force,
- [switch]$KeepArchives
+ [switch]$KeepArchives,
+ # 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 실패(릴리스 빌드용).
+ [switch]$Strict
)
$ErrorActionPreference = 'Stop'
@@ -136,6 +138,9 @@ foreach ($dep in $Deps) {
# ── 무결성 검증(SHA-256) ────────────────────────────────────────────────
$actual = Get-FileSha256 $archive
if ($dep.Sha256 -eq 'TODO' -or [string]::IsNullOrWhiteSpace($dep.Sha256)) {
+ if ($Strict) {
+ throw "Strict 모드: $name 의 SHA-256 핀이 미설정(TODO) 입니다. 계산값을 `$Deps 에 기록 후 재실행하세요(재현성 고정): $actual"
+ }
Write-Host " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:" -ForegroundColor Yellow
Write-Host " $name : $actual" -ForegroundColor Yellow
}
diff --git a/firmware/scripts/vendor.sh b/firmware/scripts/vendor.sh
index aecc23b..9287afd 100644
--- a/firmware/scripts/vendor.sh
+++ b/firmware/scripts/vendor.sh
@@ -11,6 +11,7 @@
# firmware/scripts/vendor.sh # 표준 벤더링
# FORCE=1 firmware/scripts/vendor.sh # 기존 트리 덮어쓰기(재벤더링)
# KEEP_ARCHIVES=1 firmware/scripts/vendor.sh # 압축본 보존(_archives)
+# VENDOR_STRICT=1 firmware/scripts/vendor.sh # 핀(SHA-256) 미설정 시 실패(릴리스용)
#
# 요구 도구: curl(또는 wget), sha256sum(또는 shasum), unzip, tar.
#
@@ -28,6 +29,9 @@ ARCHIVE_DIR="${THIRD_PARTY}/_archives"
FORCE="${FORCE:-0}"
KEEP_ARCHIVES="${KEEP_ARCHIVES:-0}"
+# 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 즉시 실패한다. 릴리스
+# 빌드는 VENDOR_STRICT=1 로 실행해 모든 핀이 채워졌는지(재현성 고정) 강제한다.
+VENDOR_STRICT="${VENDOR_STRICT:-0}"
mkdir -p "${THIRD_PARTY}"
[ "${KEEP_ARCHIVES}" = "1" ] && mkdir -p "${ARCHIVE_DIR}"
@@ -108,6 +112,12 @@ for entry in "${DEPS[@]}"; do
# ── 무결성 검증 ────────────────────────────────────────────────────────────
actual="$(sha256_of "${archive}")"
if [ "${sha256}" = "TODO" ] || [ -z "${sha256}" ]; then
+ if [ "${VENDOR_STRICT}" = "1" ]; then
+ echo " ERROR: VENDOR_STRICT=1 인데 ${name} 의 SHA-256 핀이 미설정(TODO)입니다." >&2
+ echo " 아래 계산값을 DEPS 에 기록한 뒤 다시 실행하세요(재현성 고정):" >&2
+ echo " ${name} : ${actual}" >&2
+ exit 1
+ fi
echo " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:"
echo " ${name} : ${actual}"
elif [ "${actual}" != "$(echo "${sha256}" | tr 'A-Z' 'a-z')" ]; then
diff --git a/firmware/test/host/parity_test.py b/firmware/test/host/parity_test.py
index 06e1640..305ce26 100644
--- a/firmware/test/host/parity_test.py
+++ b/firmware/test/host/parity_test.py
@@ -8,21 +8,54 @@ parity_test.py - 펌웨어 wire 계약을 서버(PHP)와 교차검증한다.
3. 본문/서명 변조 시 서버가 거부한다.
4. SHT3x CRC8 및 변환식이 데이터시트 벡터와 일치한다.
5. fixed2() 소수 포맷이 기대 문자열과 일치한다(펌웨어 jb_fixed2 와 동일).
+ 6. (gcc/clang 가용 시) 펌웨어 실제 C 서명(common/sig.c)을 호스트 컴파일·실행해
+ Python 레퍼런스 및 서버 검증과 바이트 단위로 일치하는지 확인한다(C↔Python↔PHP).
-C 펌웨어(jsonbody.c/sig.c/sht30_convert.c)는 reference.py 와 동일 알고리즘으로
-작성되어 있어, 본 테스트 통과는 펌웨어 wire 계약이 서버와 호환됨을 의미한다.
+reference.py 는 펌웨어 jsonbody.c/sht30_convert.c 와 동일 알고리즘의 Python 미러이고,
+서명은 항목 6 에서 펌웨어 sig.c 를 실제로 컴파일해 직접 대조하므로, 본 테스트 통과는
+펌웨어 wire 계약이 서버와 호환됨을 의미한다.
"""
from __future__ import annotations
import json
+import os
+import shutil
import subprocess
import sys
+import tempfile
from pathlib import Path
import reference as ref
HERE = Path(__file__).resolve().parent
PHP_VERIFY = HERE / "php_verify.php"
+COMMON = HERE.parent.parent / "common" # firmware/common
+SIG_CLI_SRC = HERE / "sig_cli.c"
+
+
+def build_c_signer():
+ """펌웨어 실제 구현 common/{sig.c,sha256_sw.c,hexutil.c} + sig_cli.c 를 호스트 컴파일.
+ 성공 시 실행 파일 경로 문자열, 컴파일러가 없으면 None(스킵). 이 빌드가 C↔Python↔PHP
+ 3자 패리티의 핵심 — C 가 실제로 컴파일·실행되어 같은 서명을 내는지 확인한다."""
+ cc = os.environ.get("CC") or shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
+ if not cc:
+ return None
+ out = Path(tempfile.gettempdir()) / "sht30_sig_cli.bin"
+ srcs = [str(SIG_CLI_SRC), str(COMMON / "sig.c"),
+ str(COMMON / "sha256_sw.c"), str(COMMON / "hexutil.c")]
+ cmd = [cc, "-O2", "-std=c11", "-Wall", f"-I{COMMON}", "-o", str(out), *srcs]
+ proc = subprocess.run(cmd, capture_output=True)
+ if proc.returncode != 0:
+ sys.stderr.write("C signer build failed:\n" + proc.stderr.decode("utf-8", "replace") + "\n")
+ return None
+ return str(out)
+
+
+def c_sign(binary: str, api_key: str, body: str) -> str:
+ """호스트에서 컴파일한 펌웨어 sig_raw_body 로 본문을 서명(lowercase hex)."""
+ out = subprocess.run([binary, api_key], input=body.encode("utf-8"),
+ capture_output=True, check=True)
+ return out.stdout.decode("utf-8").strip()
failures: list[str] = []
@@ -76,6 +109,12 @@ def main() -> int:
api_key = php_api_key()
check(api_key != "", "PHP API_KEY should be readable")
+ # ── C 서명기 빌드 (펌웨어 sig.c 를 호스트 컴파일). 컴파일러 없으면 스킵 ───────
+ c_signer = build_c_signer()
+ if c_signer is None:
+ sys.stderr.write("[parity] NOTE: C 컴파일러 없음 — C↔Python↔PHP 패리티 스킵"
+ " (Python↔PHP 패리티만 검증). CC 설정 또는 gcc/clang 설치 시 활성화.\n")
+
# ── 1~3. SHT30 이벤트 (startup/periodic, 정상/경계/음수/정수값 float) ─────
sht_cases = [
("startup", 24.0, 48.5, "normal"),
@@ -99,6 +138,16 @@ def main() -> int:
)
assert_server_accepts(body, api_key, f"sht30/{ev}/{t}/{h}")
+ # ── 6. C↔Python↔PHP 3자 서명 패리티 (gcc 가용 시) ───────────────────
+ if c_signer is not None:
+ py_sig = ref.sign_raw(api_key, body)
+ cc_sig = c_sign(c_signer, api_key, body)
+ check(cc_sig == py_sig,
+ f"sht30/{ev}/{t}/{h}: C 서명이 Python 레퍼런스와 일치해야 함"
+ f" (C={cc_sig[:12]}.. py={py_sig[:12]}..)")
+ check(php_verify(body, cc_sig) == "OK",
+ f"sht30/{ev}/{t}/{h}: C 서명이 서버 verify_signature_raw 를 통과해야 함")
+
# ── 4. SHT3x CRC8 / 변환 데이터시트 벡터 ─────────────────────────────────
# 데이터시트: CRC8(0xBE,0xEF) = 0x92
check(ref.sht30_crc8(bytes([0xBE, 0xEF])) == 0x92, "SHT3x CRC8 datasheet vector (0xBEEF -> 0x92)")
diff --git a/firmware/test/host/sig_cli.c b/firmware/test/host/sig_cli.c
new file mode 100644
index 0000000..b623b51
--- /dev/null
+++ b/firmware/test/host/sig_cli.c
@@ -0,0 +1,50 @@
+/* =============================================================================
+ * sig_cli.c - 호스트 C 패리티 드라이버
+ *
+ * 펌웨어의 실제 서명 구현(common/sig.c: sig_raw_body)을 호스트에서 그대로 컴파일·
+ * 실행하여, Python 레퍼런스(reference.py: sign_raw)와 서버(php verify_signature_raw)
+ * 와 *바이트 단위로* 일치하는지 검증한다. 이렇게 해야 "C 는 컴파일·실행되지 않아
+ * 손으로만 맞춘" 갭이 닫힌다(C↔Python↔PHP 3자 패리티).
+ *
+ * 사용: sig_cli (본문은 stdin, 바이너리 그대로)
+ * 출력: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)) (개행 없음)
+ *
+ * parity_test.py 가 common/{sig.c,sha256_sw.c,hexutil.c} 와 함께 컴파일한다.
+ * ===========================================================================*/
+#include "sig.h"
+#include
+#include
+
+int main(int argc, char **argv)
+{
+ if (argc < 2) {
+ fprintf(stderr, "usage: %s (body on stdin)\n", argv[0]);
+ return 2;
+ }
+ const char *key = argv[1];
+
+ /* stdin 전체를 바이너리 그대로 읽는다(서명은 본문 바이트에 대해 계산되므로
+ * 어떤 변환도 하지 않는다). */
+ size_t cap = 4096, len = 0;
+ char *body = (char *)malloc(cap);
+ if (body == NULL) {
+ return 3;
+ }
+ int c;
+ while ((c = getchar()) != EOF) {
+ if (len + 1 > cap) {
+ cap *= 2;
+ char *n = (char *)realloc(body, cap);
+ if (n == NULL) { free(body); return 3; }
+ body = n;
+ }
+ body[len++] = (char)c;
+ }
+
+ char out_hex[SIG_HEX_BUFSZ];
+ sig_raw_body(key, body, len, out_hex);
+ fputs(out_hex, stdout);
+
+ free(body);
+ return 0;
+}
diff --git a/php/.htaccess b/php/.htaccess
index 212fff5..e19b0e6 100644
--- a/php/.htaccess
+++ b/php/.htaccess
@@ -1,49 +1,52 @@
-# =============================================================================
-# .htaccess - 누수감지 시스템 보안 설정
-# 배포 경로: pores.kr/raspi_leck_detecter/
-# =============================================================================
-
-# ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
-# cafe24 호스팅 관리자 패널에서 SSL 리다이렉트 설정 사용
-# (.htaccess 리다이렉트는 리버스 프록시 환경에서 무한 루프 유발)
-
-
- RewriteEngine On
-
- # var/ 디렉토리 접근 차단
- RewriteRule ^var/ - [F,L]
-
- # [v2] uploads/ 디렉토리 PHP 실행 차단
- RewriteRule ^uploads/.*\.ph(p[3457]?|t|tml)$ - [F,L]
-
- # 불필요 HTTP 메소드 차단
- RewriteCond %{REQUEST_METHOD} ^(TRACE|DELETE|PUT|PATCH) [NC]
- RewriteRule .* - [F,L]
-
-
-# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
-
-
- Require all denied
-
-
- Order deny,allow
- Deny from all
-
-
-
-# ─── 보안 헤더 ───────────────────────────────────────────
-
- Header always set X-Content-Type-Options "nosniff"
- Header always set X-Frame-Options "DENY"
- Header always set X-XSS-Protection "1; mode=block"
- Header always set Referrer-Policy "strict-origin-when-cross-origin"
- Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"
- Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
- Header always unset X-Powered-By
-
-
-# ─── 디렉토리 목록 비활성화 ──────────────────────────────
-
- Options -Indexes
-
+# =============================================================================
+# .htaccess - 누수감지 시스템 보안 설정
+# 배포 경로: pores.kr/raspi_leck_detecter/
+# =============================================================================
+
+# ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
+# cafe24 호스팅 관리자 패널에서 SSL 리다이렉트 설정 사용
+# (.htaccess 리다이렉트는 리버스 프록시 환경에서 무한 루프 유발)
+
+
+ RewriteEngine On
+
+ # var/ 디렉토리 접근 차단
+ RewriteRule ^var/ - [F,L]
+
+ # [v2] uploads/ 디렉토리 PHP 실행 차단
+ RewriteRule ^uploads/.*\.ph(p[3457]?|t|tml)$ - [F,L]
+
+ # 불필요 HTTP 메소드 차단
+ RewriteCond %{REQUEST_METHOD} ^(TRACE|DELETE|PUT|PATCH) [NC]
+ RewriteRule .* - [F,L]
+
+
+# ─── 민감 PHP 파일 직접 접근 차단 ─────────────────────────
+
+
+ Require all denied
+
+
+ Order deny,allow
+ Deny from all
+
+
+
+# ─── 보안 헤더 ───────────────────────────────────────────
+
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set X-Frame-Options "DENY"
+ Header always set X-XSS-Protection "1; mode=block"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ # TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부
+ # 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다.
+ Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"
+ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
+ Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
+ Header always unset X-Powered-By
+
+
+# ─── 디렉토리 목록 비활성화 ──────────────────────────────
+
+ Options -Indexes
+
diff --git a/php/admin_security.php b/php/admin_security.php
index 85867b1..cf6f4f9 100644
--- a/php/admin_security.php
+++ b/php/admin_security.php
@@ -143,3 +143,23 @@ function admin_audit_log_path(): string {
function admin_mfa_configured(): bool {
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== '';
}
+
+// 인증 페이지 공통: 세션 유휴(idle) + 절대(absolute) 수명 타임아웃을 강제한다.
+// 모든 인증 페이지가 logged_in 확인 직후 호출해야 한다(대시보드 외 페이지에서도
+// 절대 수명이 보장되도록 — 한 페이지에서만 검사하면 다른 페이지로 우회 가능).
+// 만료 시 세션을 파기하고 login.php?expired 로 보낸 뒤 종료한다.
+// - 유휴(idle): 마지막 활동(last_activity)에서 SESSION_TIMEOUT 초과
+// - 절대(absolute): 로그인 시각(login_time, 갱신하지 않음)에서 SESSION_ABSOLUTE_TIMEOUT 초과
+// 호출 전 config.php(SESSION_TIMEOUT/SESSION_ABSOLUTE_TIMEOUT) 와 session_start() 필요.
+function admin_enforce_session_timeout(): void {
+ $idle_at = $_SESSION['last_activity'] ?? ($_SESSION['login_time'] ?? null);
+ $login_at = $_SESSION['login_time'] ?? null;
+ $idle_expired = isset($idle_at) && (time() - $idle_at) > SESSION_TIMEOUT;
+ $absolute_expired = isset($login_at) && (time() - $login_at) > SESSION_ABSOLUTE_TIMEOUT;
+ if ($idle_expired || $absolute_expired) {
+ session_destroy();
+ header('Location: login.php?expired');
+ exit;
+ }
+ $_SESSION['last_activity'] = time();
+}
diff --git a/php/api/photo_upload.php b/php/api/photo_upload.php
new file mode 100644
index 0000000..275ee96
--- /dev/null
+++ b/php/api/photo_upload.php
@@ -0,0 +1,356 @@
+ 'error', 'message' => 'Method Not Allowed']);
+ exit;
+}
+
+require_once dirname(__DIR__) . '/config.php';
+
+// 누수 사진 1장 최대 크기(바이트). 기본 2MB. 인증 전 대용량 업로드 증폭형 DoS 방지.
+if (!defined('PHOTO_MAX_BYTES')) {
+ define('PHOTO_MAX_BYTES', 2 * 1024 * 1024);
+}
+// multipart 헤더/필드(boundary, device_id, sensor_id, log_id, timestamp 등) 오버헤드 여유.
+// Content-Length 사전 거부에서 사진 본문 외 부수 바이트를 감안한다.
+if (!defined('PHOTO_MULTIPART_OVERHEAD_BYTES')) {
+ define('PHOTO_MULTIPART_OVERHEAD_BYTES', 8192);
+}
+
+// ── 인증 전 사전 거부: Content-Length 가 상한+오버헤드를 넘으면 즉시 413 ──
+// 서명 검증/파일 수신 등 무거운 처리에 들어가기 전에 차단(증폭형 DoS 방어).
+$declared_len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
+if ($declared_len > PHOTO_MAX_BYTES + PHOTO_MULTIPART_OVERHEAD_BYTES) {
+ error_log("[PHOTO] Content-Length 과대({$declared_len}B) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('photo_too_large');
+ json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
+}
+
+// ── 운영 비밀값 강제: 기본 API_KEY 로는 수신 거부(fail-closed) ──
+if (api_key_is_default()) {
+ error_log("[PHOTO] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
+ record_ingest_rejection('key_default');
+ json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
+}
+
+// 입력 문자열 정제: 제어문자 제거(로그/경로 주입 방지). 서명 검증은 정제 전 원본으로 한다.
+function photo_sanitize_str(string $s): string {
+ return preg_replace('/[[:cntrl:]]+/', '', $s);
+}
+
+// 테이블 존재 확인(미통합 배포 graceful degrade). sensor_data.php api_table_exists 와 동일.
+function photo_table_exists(PDO $db, string $table): bool {
+ try {
+ $stmt = $db->prepare("
+ SELECT COUNT(*)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = :table
+ ");
+ $stmt->execute([':table' => $table]);
+ return (int)$stmt->fetchColumn() > 0;
+ } catch (Throwable $e) {
+ error_log("[PHOTO] table check failed: {$table} " . $e->getMessage());
+ return false;
+ }
+}
+
+// ── 재전송(replay) 멱등 선점/해제 ──────────────────────────────
+// sensor_data.php 의 ingest_nonce_claim/ingest_nonce_release 와 동일하게 동작한다.
+// (photo_upload.php 는 sensor_data.php 를 include 하지 않으므로 여기서 동일 헬퍼를 둔다.)
+// 'claimed'/'duplicate'/'unavailable'
+function photo_nonce_claim(PDO $db, string $sig): string {
+ if (!INGEST_NONCE_ENABLED || $sig === '') {
+ return 'unavailable';
+ }
+ try {
+ $ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
+ $ins->execute([':sig' => substr($sig, 0, 128)]);
+ } catch (PDOException $e) {
+ $code = $e->getCode();
+ if ($code === '23000') {
+ return 'duplicate'; // PK 충돌 = 동일 서명 재수신
+ }
+ if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
+ error_log("[PHOTO] nonce claim 오류({$code}): " . $e->getMessage());
+ }
+ return 'unavailable';
+ }
+ try {
+ $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
+ $db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
+ } catch (Throwable $e) {
+ /* 정리는 실패해도 무방 */
+ }
+ return 'claimed';
+}
+
+function photo_nonce_release(PDO $db, string $sig): void {
+ if ($sig === '') {
+ return;
+ }
+ try {
+ $db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
+ ->execute([':sig' => substr($sig, 0, 128)]);
+ } catch (Throwable $e) {
+ error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
+ }
+}
+
+// 새 var/photos/ 디렉터리에 보호 파일(.htaccess + index.html)을 best-effort 로 기록한다.
+// 첫 업로드 직후에도 웹 직접 접근이 차단된 상태가 되도록(mkdir 시점 자가보호).
+function photo_write_dir_guards(string $dir): void {
+ $htaccess = $dir . '/.htaccess';
+ if (!is_file($htaccess)) {
+ @file_put_contents(
+ $htaccess,
+ "\n Require all denied\n \n"
+ . "\n Order allow,deny\n Deny from all\n \n"
+ . "\n Options -Indexes\n \n"
+ );
+ }
+ $index = $dir . '/index.html';
+ if (!is_file($index)) {
+ @file_put_contents($index, "403 \n");
+ }
+}
+
+// ── 입력 추출 (multipart form fields) ──
+$device_id_raw = (string)($_POST['device_id'] ?? '');
+$sensor_id_raw = (string)($_POST['sensor_id'] ?? '');
+$log_id_raw = (string)($_POST['log_id'] ?? '');
+$timestamp_raw = (string)($_POST['timestamp'] ?? '');
+
+if ($device_id_raw === '' || $sensor_id_raw === '' || $log_id_raw === '' || $timestamp_raw === '') {
+ record_ingest_rejection('missing_field');
+ json_response(['status' => 'error', 'message' => '필드 누락'], 400);
+}
+
+// ── 서명 검증 (canonical = device_id|sensor_id|log_id|timestamp, 공백 없음) ──
+// 서명 입력은 클라이언트가 보낸 값을 "그대로" 이어 붙인다(클라이언트도 동일 문자열로 서명).
+$received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
+$canonical = $device_id_raw . '|' . $sensor_id_raw . '|' . $log_id_raw . '|' . $timestamp_raw;
+$expected_sig = hash_hmac('sha256', $canonical, API_KEY);
+if ($received_sig === '' || !hash_equals($expected_sig, $received_sig)) {
+ error_log("[PHOTO] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('signature');
+ json_response(['status' => 'error', 'message' => '인증 실패'], 403);
+}
+
+// ── 재전송(replay) 방어: timestamp 신선도 ──
+$ts = (int)$timestamp_raw;
+$now = time();
+if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
+ error_log("[PHOTO] timestamp 신선도 위반 (ts={$ts}, now={$now}) from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('timestamp');
+ json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
+}
+
+// ── 값 검증 ──
+$sensor_id = (int)$sensor_id_raw;
+$log_id = (int)$log_id_raw;
+if ($sensor_id < 1 || $sensor_id > 255) {
+ record_ingest_rejection('sensor_id_range');
+ json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
+}
+if ($log_id < 1) {
+ record_ingest_rejection('log_id_range');
+ json_response(['status' => 'error', 'message' => 'log_id 오류'], 400);
+}
+$device_id = photo_sanitize_str(substr(trim($device_id_raw), 0, 64));
+
+// ── 업로드 파일 검증 ──
+if (!isset($_FILES['photo']) || !is_array($_FILES['photo'])) {
+ record_ingest_rejection('no_file');
+ json_response(['status' => 'error', 'message' => 'photo 파일 누락'], 400);
+}
+$file = $_FILES['photo'];
+if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ record_ingest_rejection('upload_error');
+ json_response(['status' => 'error', 'message' => '업로드 오류'], 400);
+}
+$tmp_path = (string)($file['tmp_name'] ?? '');
+if ($tmp_path === '' || !is_uploaded_file($tmp_path)) {
+ // is_uploaded_file: HTTP 업로드로 들어온 임시파일만 허용(임의 경로 주입 차단).
+ record_ingest_rejection('not_uploaded');
+ json_response(['status' => 'error', 'message' => '잘못된 업로드'], 400);
+}
+$size = (int)($file['size'] ?? 0);
+if ($size <= 0 || $size > PHOTO_MAX_BYTES) {
+ record_ingest_rejection('photo_too_large');
+ json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
+}
+
+// MIME: 클라이언트 신고를 믿지 않고 임시파일을 finfo 로 직접 판별. image/jpeg 만 허용.
+$mime = '';
+if (class_exists('finfo')) {
+ $finfo = new finfo(FILEINFO_MIME_TYPE);
+ $mime = (string)$finfo->file($tmp_path);
+} elseif (function_exists('mime_content_type')) {
+ $mime = (string)mime_content_type($tmp_path);
+}
+if ($mime !== 'image/jpeg') {
+ error_log("[PHOTO] MIME 거부: {$mime} from " . ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('photo_mime');
+ json_response(['status' => 'error', 'message' => 'JPEG 이미지만 허용됩니다'], 415);
+}
+
+// ── 실제 파일 크기 재검증 (클라이언트 신고 $_FILES['size'] 만 믿지 않음) ──
+// finfo 와 마찬가지로 임시파일을 직접 측정해 상한을 다시 확인한다.
+$real_size = @filesize($tmp_path);
+if ($real_size === false || $real_size <= 0 || $real_size > PHOTO_MAX_BYTES) {
+ record_ingest_rejection('photo_too_large');
+ json_response(['status' => 'error', 'message' => '사진 크기 오류(최대 ' . PHOTO_MAX_BYTES . 'B)'], 413);
+}
+$size = (int)$real_size; // 저장 메타에는 실측 크기를 기록
+
+// ── log_id 존재/일관성 확인 + 저장 ──
+$nonce_state = 'unavailable';
+$photo_id = null;
+$filename = '';
+try {
+ $db = get_db();
+
+ // log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
+ // (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
+ $chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id");
+ $chk->execute([':id' => $log_id]);
+ $log_row = $chk->fetch();
+ if (!$log_row) {
+ record_ingest_rejection('log_id_unknown');
+ json_response(['status' => 'error', 'message' => 'log_id 를 찾을 수 없습니다'], 400);
+ }
+ // 교차 센서 업로드 차단: 요청의 sensor_id 가 로그 행의 센서와 다르면 거부.
+ $log_sensor_id = (int)$log_row['sensor_id'];
+ if ($log_sensor_id !== $sensor_id) {
+ error_log("[PHOTO] 교차 센서 거부: req sensor_id={$sensor_id} vs log sensor_id={$log_sensor_id} (log_id={$log_id})");
+ record_ingest_rejection('sensor_mismatch');
+ json_response(['status' => 'error', 'message' => 'log_id 와 sensor_id 가 일치하지 않습니다'], 403);
+ }
+ // 저장에는 로그 행의 권위값을 사용(클라이언트 신고가 아니라 서버가 아는 값).
+ $store_sensor_id = $log_sensor_id;
+ $store_device_id = photo_sanitize_str(substr((string)$log_row['device_id'], 0, 64));
+
+ // ── 재전송(replay) 멱등 선점 (sensor_data.php 와 동일) ──
+ // 같은 X-Signature 가 시계오차 창 안에서 다시 오면(클라이언트 재시도/캡처 재생)
+ // 두 번째 파일/행을 만들지 않고 멱등 200 으로 종료한다.
+ $nonce_state = photo_nonce_claim($db, $received_sig);
+ if ($nonce_state === 'duplicate') {
+ json_response([
+ 'status' => 'ok',
+ 'message' => '이미 처리된 요청(멱등)',
+ 'duplicate' => true,
+ ]);
+ }
+
+ // 벨트앤서스펜더: 같은 log_id 에 이미 사진이 있으면 중복으로 보고 건너뛴다
+ // (정책: log_id 당 사진 1장). nonce 가 비활성/정리된 경우의 추가 방어.
+ if (photo_table_exists($db, 'leak_photo')) {
+ $dup = $db->prepare("SELECT id, filename FROM leak_photo WHERE log_id = :log_id ORDER BY id ASC LIMIT 1");
+ $dup->execute([':log_id' => $log_id]);
+ $dup_row = $dup->fetch();
+ if ($dup_row) {
+ json_response([
+ 'status' => 'ok',
+ 'message' => '이미 사진이 저장된 log_id(멱등)',
+ 'duplicate' => true,
+ 'photo_id' => (int)$dup_row['id'],
+ 'filename' => (string)$dup_row['filename'],
+ ]);
+ }
+ }
+
+ // 저장 디렉터리 (없으면 생성). 웹에서 직접 접근되지 않도록 var/ 하위에 둔다.
+ $photo_dir = dirname(__DIR__) . '/var/photos';
+ if (!is_dir($photo_dir)) {
+ if (!@mkdir($photo_dir, 0750, true) && !is_dir($photo_dir)) {
+ error_log("[PHOTO] 디렉터리 생성 실패: {$photo_dir}");
+ photo_nonce_release($db, $received_sig);
+ json_response(['status' => 'error', 'message' => '저장소 오류'], 500);
+ }
+ }
+ // 새로 만든(또는 기존) 디렉터리에 보호 파일을 best-effort 로 기록(웹 직접 접근 차단).
+ photo_write_dir_guards($photo_dir);
+
+ // 서버 생성 무작위 파일명(클라이언트 파일명/경로 미사용 → 경로주입 원천 차단).
+ $filename = sprintf('leak_%d_%d_%s.jpg', $store_sensor_id, $log_id, bin2hex(random_bytes(8)));
+ $dest = $photo_dir . '/' . $filename;
+
+ if (!move_uploaded_file($tmp_path, $dest)) {
+ error_log("[PHOTO] move_uploaded_file 실패: {$dest}");
+ photo_nonce_release($db, $received_sig);
+ json_response(['status' => 'error', 'message' => '사진 저장 실패'], 500);
+ }
+ @chmod($dest, 0640);
+
+ // leak_photo 메타데이터 기록 (파라미터 바인딩, 권위 sensor_id/device_id 사용).
+ $captured_at = date('Y-m-d H:i:s', $ts);
+ $ins = $db->prepare("
+ INSERT INTO leak_photo
+ (log_id, sensor_id, device_id, filename, mime, bytes, captured_at, created_at)
+ VALUES
+ (:log_id, :sensor_id, :device_id, :filename, :mime, :bytes, :captured_at, NOW())
+ ");
+ $ins->execute([
+ ':log_id' => $log_id,
+ ':sensor_id' => $store_sensor_id,
+ ':device_id' => $store_device_id,
+ ':filename' => $filename,
+ ':mime' => $mime,
+ ':bytes' => $size,
+ ':captured_at' => $captured_at,
+ ]);
+ $photo_id = (int)$db->lastInsertId();
+
+} catch (PDOException $e) {
+ error_log("[PHOTO] DB 오류: " . $e->getMessage());
+ // 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
+ if (isset($db) && $nonce_state === 'claimed') {
+ photo_nonce_release($db, $received_sig);
+ }
+ json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
+}
+
+json_response([
+ 'status' => 'ok',
+ 'message' => '사진 저장 완료',
+ 'photo_id' => $photo_id,
+ 'filename' => $filename,
+]);
diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php
index d0dd885..64cc2ab 100644
--- a/php/api/sensor_data.php
+++ b/php/api/sensor_data.php
@@ -1,8 +1,9 @@
'error', 'message' => '서버 구성 오류'], 503);
}
@@ -83,6 +98,53 @@ function api_table_exists(PDO $db, string $table): bool {
}
}
+// ── 재전송(replay) 멱등 선점 ──────────────────────────────────
+// 인증·신선도(timestamp)를 통과한 요청의 서명을 nonce 로 원자적 기록한다. 시계오차
+// 창 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 한 번만 반영된다.
+// 'claimed' : 이번에 선점 → 계속 처리(저장/발송)
+// 'duplicate' : 이미 처리됨 → 멱등 200 으로 종료(재저장/재발송 없음)
+// 'unavailable' : nonce 비활성/테이블 없음/오류 → 종전대로 진행(graceful degrade)
+function ingest_nonce_claim(PDO $db, string $sig): string {
+ if (!INGEST_NONCE_ENABLED || $sig === '') {
+ return 'unavailable';
+ }
+ try {
+ $ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
+ $ins->execute([':sig' => substr($sig, 0, 128)]);
+ } catch (PDOException $e) {
+ $code = $e->getCode();
+ if ($code === '23000') {
+ return 'duplicate'; // PK 충돌 = 동일 서명 재수신
+ }
+ if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
+ error_log("[API] nonce claim 오류({$code}): " . $e->getMessage());
+ }
+ return 'unavailable';
+ }
+ // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
+ try {
+ $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
+ $db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
+ } catch (Throwable $e) {
+ /* 정리는 실패해도 무방 */
+ }
+ return 'claimed';
+}
+
+// 처리 중 저장이 실패하면(예: DB 오류) 선점한 nonce 를 되돌려, 디바이스 재시도가
+// 다시 처리될 수 있게 한다(데이터 유실 방지).
+function ingest_nonce_release(PDO $db, string $sig): void {
+ if ($sig === '') {
+ return;
+ }
+ try {
+ $db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
+ ->execute([':sig' => substr($sig, 0, 128)]);
+ } catch (Throwable $e) {
+ error_log("[API] nonce release 오류: " . $e->getMessage());
+ }
+}
+
// ── 요청 파싱 ─────────────────────────────────────────────
$raw = file_get_contents('php://input');
@@ -90,12 +152,14 @@ $raw = file_get_contents('php://input');
if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('body_too_large');
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
}
$data = json_decode($raw, true);
if (!$data) {
+ record_ingest_rejection('invalid_json');
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
}
@@ -116,6 +180,7 @@ if ($has_raw_sig) {
}
if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
+ record_ingest_rejection('signature');
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
}
@@ -123,6 +188,7 @@ if (!$sig_ok) {
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
foreach ($required as $field) {
if (!isset($data[$field])) {
+ record_ingest_rejection('missing_field');
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400);
}
}
@@ -131,9 +197,25 @@ foreach ($required as $field) {
$device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
$sensor_id = (int)$data['sensor_id'];
+// sensor_id 범위 검증(스키마 TINYINT UNSIGNED: 1..255). 범위를 벗어나면 DB INSERT
+// 단계에서 500(DB 저장 실패)이 되므로, 경계에서 깔끔하게 400 으로 거른다.
+if ($sensor_id < 1 || $sensor_id > 255) {
+ record_ingest_rejection('sensor_id_range');
+ json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
+}
$sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100));
-$event_type = in_array($data['event_type'], ['startup', 'periodic'], true)
+// 통합 수집: 온습도(startup/periodic) + 누수(leak_alert/leak_recovery) 이벤트 허용.
+$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery'], true)
? $data['event_type'] : 'periodic';
+// 누수 여부(0/1). 1번 모듈만 보낸다. leak_alert 는 항상 누수=1, leak_recovery 는 0 으로
+// 강제해 이벤트와 상태의 일관성을 보장한다(필드 누락/오값 방어).
+$is_leak = !empty($data['is_leak']) ? 1 : 0;
+if ($event_type === 'leak_alert') {
+ $is_leak = 1;
+} elseif ($event_type === 'leak_recovery') {
+ $is_leak = 0;
+}
+$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
$ts = (int)$data['timestamp'];
// ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
@@ -143,6 +225,7 @@ $now = time();
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
+ record_ingest_rejection('timestamp');
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
}
@@ -172,22 +255,37 @@ $metric_status = $metric_eval['status'];
$metric_saved = false;
// ── DB 저장 ───────────────────────────────────────────────
-// 주기(periodic) 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
+// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록.
+// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다
+// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic' && $has_metric_payload);
$log_id = null;
try {
$db = get_db();
+ // ── 재전송(replay) 멱등 선점 ──
+ // 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
+ // 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
+ $req_sig = $has_raw_sig ? (string)$_SERVER['HTTP_X_SIGNATURE'] : (string)($data['signature'] ?? '');
+ $nonce_state = ingest_nonce_claim($db, $req_sig);
+ if ($nonce_state === 'duplicate') {
+ json_response([
+ 'status' => 'ok',
+ 'message' => '이미 처리된 요청(멱등)',
+ 'duplicate' => true,
+ ]);
+ }
+
if (!$skip_log) {
$stmt = $db->prepare("
INSERT INTO sensor_log
(device_id, device_location, sensor_id, sensor_name,
- event_type, sensor_time, created_at)
+ event_type, is_leak, sensor_time, created_at)
VALUES
(:device_id, :device_location, :sensor_id, :sensor_name,
- :event_type, :sensor_time, NOW())
+ :event_type, :is_leak, :sensor_time, NOW())
");
$stmt->execute([
':device_id' => $device_id,
@@ -195,6 +293,7 @@ try {
':sensor_id' => $sensor_id,
':sensor_name' => $sensor_name,
':event_type' => $event_type,
+ ':is_leak' => $is_leak,
':sensor_time' => $sensor_dt,
]);
@@ -211,18 +310,22 @@ try {
}
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
+ // is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고
+ // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
+ // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare("
INSERT INTO sensor_status
(sensor_id, device_id, sensor_name, device_location,
- last_event_type, last_seen, offline_alerted)
+ last_event_type, is_leak, last_seen, offline_alerted)
VALUES
(:sensor_id, :device_id, :sensor_name, :device_location,
- :event_type, NOW(), 0)
+ :event_type, :is_leak_ins, NOW(), 0)
ON DUPLICATE KEY UPDATE
device_id = VALUES(device_id),
sensor_name = VALUES(sensor_name),
device_location = VALUES(device_location),
last_event_type = VALUES(last_event_type),
+ is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak),
last_seen = NOW(),
offline_alerted = 0
");
@@ -232,6 +335,8 @@ try {
':sensor_name' => $sensor_name,
':device_location' => $device_location,
':event_type' => $event_type,
+ ':is_leak_ins' => $is_leak,
+ ':is_leak_event' => $is_leak_event ? 1 : 0,
]);
if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) {
@@ -259,6 +364,52 @@ try {
$metric_saved = true;
}
+ // ── 누수 사고(에피소드) 추적 (leak_incident) ──────────
+ // leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
+ // leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
+ // leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
+ if ($is_leak_event && api_table_exists($db, 'leak_incident')) {
+ if ($event_type === 'leak_alert') {
+ $open = $db->prepare("
+ SELECT id FROM leak_incident
+ WHERE sensor_id = :sensor_id AND recovered_at IS NULL
+ ORDER BY started_at DESC, id DESC LIMIT 1
+ ");
+ $open->execute([':sensor_id' => $sensor_id]);
+ if ($open->fetchColumn() === false) {
+ $ins_inc = $db->prepare("
+ INSERT INTO leak_incident
+ (sensor_id, device_id, sensor_name, device_location,
+ started_at, log_id_alert, created_at)
+ VALUES
+ (:sensor_id, :device_id, :sensor_name, :device_location,
+ :started_at, :log_id_alert, NOW())
+ ");
+ $ins_inc->execute([
+ ':sensor_id' => $sensor_id,
+ ':device_id' => $device_id,
+ ':sensor_name' => $sensor_name,
+ ':device_location' => $device_location,
+ ':started_at' => $sensor_dt,
+ ':log_id_alert' => $log_id !== null ? (int)$log_id : null,
+ ]);
+ }
+ } else { // leak_recovery
+ $close = $db->prepare("
+ UPDATE leak_incident
+ SET recovered_at = :recovered_at, log_id_recovery = :log_id_recovery
+ WHERE sensor_id = :sensor_id AND recovered_at IS NULL
+ ORDER BY started_at DESC, id DESC
+ LIMIT 1
+ ");
+ $close->execute([
+ ':recovered_at' => $sensor_dt,
+ ':log_id_recovery' => $log_id !== null ? (int)$log_id : null,
+ ':sensor_id' => $sensor_id,
+ ]);
+ }
+ }
+
// ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
if ($was_offline) {
$online_info = [
@@ -273,6 +424,10 @@ try {
} catch (PDOException $e) {
error_log("[API] DB 오류: " . $e->getMessage());
+ // 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
+ if (isset($nonce_state, $req_sig) && $nonce_state === 'claimed') {
+ ingest_nonce_release($db, $req_sig);
+ }
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
}
@@ -289,8 +444,13 @@ if ($has_metric_payload) {
if (!empty($metric_eval['breaches'])) {
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
foreach ($metric_eval['breaches'] as $kind) {
- $value = in_array($kind, ['high_temp', 'low_temp'], true)
- ? $temperature_c : $humidity_percent;
+ if ($kind === 'condensation_risk') {
+ $value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
+ } elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
+ $value = $temperature_c;
+ } else {
+ $value = $humidity_percent;
+ }
if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
$sms_sent = true;
}
@@ -305,6 +465,22 @@ if ($has_metric_payload) {
}
}
+// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
+// leak_alert: [누수감지] SMS(쿨다운). 누수 지속 중 주기 보고가 와도 쿨다운으로 1회.
+// leak_recovery: 직전에 누수 경보가 있었을 때만 [누수복구] SMS 발송.
+// sms_send.php 에 헬퍼가 없거나(미통합 배포) 함수 미정의면 graceful 하게 건너뛴다.
+if ($is_leak_event) {
+ if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
+ if (send_leak_alert_sms($sensor_info)) {
+ $sms_sent = true;
+ }
+ } elseif ($event_type === 'leak_recovery' && function_exists('send_leak_recovery_sms')) {
+ if (send_leak_recovery_sms($sensor_info)) {
+ $sms_sent = true;
+ }
+ }
+}
+
// ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
check_offline_sensors_throttled();
@@ -316,4 +492,8 @@ json_response([
'metric_saved' => $metric_saved,
'metric_status' => $metric_status,
'sms_sent' => $sms_sent,
+ // 서버 현재 시각(Unix). 펌웨어가 RTC 드리프트 보정/시계 헬스 점검에 사용할 수 있도록
+ // 응답에 싣는다(폐쇄망에서 SNTP 가 간헐적일 때 서버시각으로 RTC 를 미세보정하는 후속
+ // 펌웨어 작업의 입력). 보안 영향 없음(인증된 200 응답에만 포함).
+ 'server_now' => $now,
]);
diff --git a/php/config.local.example.php b/php/config.local.example.php
index e928482..ebf0810 100644
--- a/php/config.local.example.php
+++ b/php/config.local.example.php
@@ -16,6 +16,11 @@ return [
'API_KEY' => 'replace-with-long-random-secret',
+ // 요청 서명은 HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트)이며 X-Signature 헤더로
+ // 전송된다. 레거시 키-접두 sha256 폴백은 기본 비활성이고, 운영(STM32 HMAC 전용)에서는
+ // 반드시 false 로 둔다. 과거 RPi(레거시) 클라이언트가 아직 남아 있을 때만 true 로 켠다.
+ 'ALLOW_LEGACY_BODY_SIGNATURE' => false,
+
'SMS_USER_ID' => 'your-cafe24-sms-user-id',
'SMS_SECURE' => 'your-cafe24-sms-secure-key',
'SMS_SENDER' => '01000000000',
@@ -43,6 +48,13 @@ return [
'METRIC_RH_LOW' => 20, // 저습 임계(%)
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
+ // 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
+ 'SMS_RECIPIENTS_TIER2' => [],
+ 'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
+ // 아래는 기본값으로 동작(필요 시에만 override):
+ // METRIC_DEWPOINT_MARGIN_C(3℃ 결로), SMS_BALANCE_WARN_THRESHOLD(30건),
+ // HEALTH_SMS_HOUR(9시), STUCK_DETECT_SAMPLES(6), SMS_RETRY_OFFLINE(true)
+
'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365,
'SENSOR_METRIC_RETENTION_DAYS' => 365,
diff --git a/php/config.php b/php/config.php
index c527c72..b737d9e 100644
--- a/php/config.php
+++ b/php/config.php
@@ -48,6 +48,12 @@ define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
// 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
+// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
+// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
+// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
+// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
+define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
+
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다.
// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다.
@@ -66,6 +72,61 @@ if (is_string($sms_recipients)) {
}
define('SMS_RECIPIENTS', $sms_recipients);
+// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
+// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
+// (멀티바이트 경계 보존; sms_truncate_euckr). 위치/센서명이 길어도 무성 절단을 막는다.
+define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
+
+// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
+// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
+// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
+// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
+// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
+const SMS_TAG_HIGH_TEMP = '[고온경보]';
+const SMS_TAG_LOW_TEMP = '[저온경보]';
+const SMS_TAG_HIGH_HUMIDITY = '[고습경보]';
+const SMS_TAG_LOW_HUMIDITY = '[저습경보]';
+const SMS_TAG_RECOVERY = '[정상복귀]';
+const SMS_TAG_DEVICE_OFFLINE = '[장비 오프라인]';
+const SMS_TAG_DEVICE_ONLINE = '[장비 복구]';
+const SMS_TAG_CONDENSATION = '[결로주의]'; // 이슬점 근접(결로 위험)
+const SMS_TAG_BALANCE_LOW = '[SMS 잔액부족]'; // 발송 잔여 건수 부족 사전경보
+const SMS_TAG_HEALTH = '[정기점검]'; // 일일 생존신호(데드맨)
+const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-but-stuck) 의심
+const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
+const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
+const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
+
+// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
+// breach 종류와 1:1 대응한다.
+const SMS_METRIC_TAGS = [
+ 'high_temp' => SMS_TAG_HIGH_TEMP,
+ 'low_temp' => SMS_TAG_LOW_TEMP,
+ 'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
+ 'low_humidity' => SMS_TAG_LOW_HUMIDITY,
+];
+
+/**
+ * sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
+ * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
+ * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
+ * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
+ * device_offline, device_online, leak_alert, leak_recovery, sms_fail
+ */
+function sms_summary_select_sql(): string {
+ return
+ "SUM(message LIKE '" . SMS_TAG_HIGH_TEMP . "%') AS high_temp,\n"
+ . " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n"
+ . " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n"
+ . " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n"
+ . " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n"
+ . " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
+ . " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n"
+ . " SUM(message LIKE '" . SMS_TAG_LEAK_ALERT . "%') AS leak_alert,\n"
+ . " SUM(message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%') AS leak_recovery,\n"
+ . " SUM(status = 'fail') AS sms_fail";
+}
+
// ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
@@ -80,6 +141,56 @@ define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800));
+// 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
+// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
+define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
+
+// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
+// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
+// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
+// 단일 SHT30 측정만으로 산출(이슬점=Magnus 근사). 0 이하로 두면 결로 경보 비활성.
+define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
+define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0));
+
+// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
+// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
+// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
+define('SMS_BALANCE_WARN_THRESHOLD', (int)cfg('SMS_BALANCE_WARN_THRESHOLD', 30));
+define('SMS_BALANCE_WARN_COOLDOWN_SEC',(int)cfg('SMS_BALANCE_WARN_COOLDOWN_SEC', 21600)); // 6시간
+
+// ─── 일일 헬스(생존신호) SMS ──────────────────────────────────────────────────
+// 하루 1통 '[정기점검]' 요약(정상 센서 수/최근 수신/SMS 잔여)을 보낸다. '무소식=정상'
+// 과 '무소식=경보경로 전체 고장'의 모호성을 제거하는 데드맨(관리자는 '안 오면 점검'만
+// 기억하면 된다). HEALTH_SMS_HOUR 시(0~23) 이후의 첫 체크에서 하루 1회 발송.
+define('HEALTH_SMS_ENABLED', (bool)cfg('HEALTH_SMS_ENABLED', true));
+define('HEALTH_SMS_HOUR', (int)cfg('HEALTH_SMS_HOUR', 9));
+
+// ─── 경보 신뢰성 (재시도 / 에스컬레이션 / 고착 검출 / 드릴) ────────────────────
+// 오프라인 SMS 발송 실패 시 offline_alerted 를 되돌려 다음 주기에 재시도한다(critical
+// 오프라인 미통지 방지 — 일회성 발송이 실패하면 영영 못 알리던 갭).
+define('SMS_RETRY_OFFLINE', (bool)cfg('SMS_RETRY_OFFLINE', true));
+
+// 미복귀 경보 에스컬레이션: 임계 경보가 ESCALATE_AFTER_SEC 이상 미복귀로 지속되면 2차
+// (야간) 수신자(SMS_RECIPIENTS_TIER2)에게 1회 통지. TIER2 가 비면 비활성(opt-in).
+$sms_tier2 = cfg('SMS_RECIPIENTS_TIER2', []);
+if (is_string($sms_tier2)) {
+ $sms_tier2 = array_filter(array_map('trim', explode(',', $sms_tier2)));
+}
+define('SMS_RECIPIENTS_TIER2', $sms_tier2);
+define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
+
+// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
+// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
+// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
+define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
+define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분
+define('STUCK_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하
+define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간
+
+// 카오스 드릴: 켜면 send_sms_cafe24 가 실제 전송 없이 실패를 반환한다(실패 경로 시험).
+// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
+define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
+
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
@@ -92,7 +203,10 @@ define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps
define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap
-define('SESSION_TIMEOUT', 3600); // 1시간
+define('SESSION_TIMEOUT', 3600); // 유휴(idle) 타임아웃: 마지막 활동 후 1시간
+// 절대 세션 수명(초). 연속 활동 중이어도 로그인 시각 기준 이 시간이 지나면 만료한다
+// (유휴 타임아웃만 있으면 활동 중 세션이 무한정 유지되는 문제 보완). 기본 12시간.
+define('SESSION_ABSOLUTE_TIMEOUT', (int)cfg('SESSION_ABSOLUTE_TIMEOUT', 43200));
// ─── Retention policy ────────────────────────────────────────────────────────
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365));
@@ -128,6 +242,100 @@ function json_response(array $data, int $http_code = 200): void {
exit;
}
+// ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
+// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
+// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
+// 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
+function mask_phone(string $value): string {
+ if (strpos($value, ',') !== false) {
+ $parts = array_map('trim', explode(',', $value));
+ return implode(', ', array_map('mask_phone', $parts));
+ }
+ $d = preg_replace('/\D+/', '', $value);
+ $len = strlen($d);
+ if ($len < 4) {
+ return str_repeat('*', $len);
+ }
+ $last4 = substr($d, -4);
+ $prefix = $len >= 7 ? substr($d, 0, 3) : '';
+ return ($prefix !== '' ? $prefix . '-' : '') . '****-' . $last4;
+}
+
+// 최근 성공 발송의 잔여 SMS 건수(result_message "발송 성공 (잔여 N건)" 에서 파싱).
+// 별도 컬럼 없이 기존 기록에서 읽는다. 알 수 없으면 null. (잔액 점검/헬스 요약 공용)
+function latest_sms_balance(PDO $db): ?int {
+ try {
+ $rm = $db->query("
+ SELECT result_message FROM sms_log
+ WHERE status = 'success' AND result_message LIKE '%잔여%'
+ ORDER BY id DESC LIMIT 1
+ ")->fetchColumn();
+ } catch (Throwable $e) {
+ return null;
+ }
+ if ($rm !== false && preg_match('/잔여\s*(\d+)\s*건/u', (string)$rm, $m)) {
+ return (int)$m[1];
+ }
+ return null;
+}
+
+// ─── 수집 거부 사유 카운터 (파일 기반, DoS 안전) ──────────────────────────────
+// 인증 전 거부(403/400/413/503/범위)를 사유별 일자 버킷으로 집계한다. DB 를 건드리지
+// 않아(파일 flock) 위조요청 폭주에도 부담이 작다. 대시보드/ops 가 읽어 '왜 데이터가
+// 안 들어오는가'(시계오차/키불일치/위조시도)를 빠르게 분별한다.
+function ingest_rejection_path(): string {
+ return __DIR__ . '/var/ingest_rejections.json';
+}
+
+function record_ingest_rejection(string $reason): void {
+ $fh = @fopen(ingest_rejection_path(), 'c+');
+ if ($fh === false) {
+ return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort)
+ }
+ if (flock($fh, LOCK_EX)) {
+ $today = date('Y-m-d');
+ $raw = stream_get_contents($fh);
+ $data = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
+ if (!is_array($data)) { $data = []; }
+ if (!isset($data[$today]) || !is_array($data[$today])) { $data[$today] = []; }
+ $data[$today][$reason] = ($data[$today][$reason] ?? 0) + 1;
+ if (count($data) > 14) { // 최근 14일만 보존(파일 무한증가 방지)
+ ksort($data);
+ $data = array_slice($data, -14, null, true);
+ }
+ ftruncate($fh, 0);
+ rewind($fh);
+ fwrite($fh, json_encode($data));
+ fflush($fh);
+ flock($fh, LOCK_UN);
+ }
+ fclose($fh);
+}
+
+// 최근 $days 일의 거부 사유별 합계(내림차순). 반환: ['reason' => count, ...].
+function read_ingest_rejections(int $days = 1): array {
+ $file = ingest_rejection_path();
+ if (!is_file($file)) {
+ return [];
+ }
+ $data = json_decode((string)@file_get_contents($file), true);
+ if (!is_array($data)) {
+ return [];
+ }
+ $cutoff = date('Y-m-d', time() - max(0, $days - 1) * 86400);
+ $totals = [];
+ foreach ($data as $day => $reasons) {
+ if ((string)$day < $cutoff || !is_array($reasons)) {
+ continue;
+ }
+ foreach ($reasons as $reason => $cnt) {
+ $totals[$reason] = ($totals[$reason] ?? 0) + (int)$cnt;
+ }
+ }
+ arsort($totals);
+ return $totals;
+}
+
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
diff --git a/php/cron_heartbeat.php b/php/cron_heartbeat.php
index 42c5857..425e216 100644
--- a/php/cron_heartbeat.php
+++ b/php/cron_heartbeat.php
@@ -62,7 +62,14 @@ function check_offline_sensors(): int {
'last_seen' => $sensor['last_seen'],
];
- send_device_offline_sms($sensor_info);
+ $sent = send_device_offline_sms($sensor_info);
+ if (!$sent && SMS_RETRY_OFFLINE) {
+ // 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
+ // 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
+ $db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
+ ->execute([':sid' => $sensor['sensor_id']]);
+ error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
+ }
$count++;
error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})");
@@ -120,6 +127,19 @@ function check_offline_sensors_throttled(): void {
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
error_log("[Heartbeat] 체크 실패: " . $e->getMessage());
}
+ // 일일 헬스(생존신호) SMS — 하루 1회 자체 dedup. 실패해도 호출자에 영향 없음.
+ try {
+ maybe_send_daily_health_sms(get_db());
+ } catch (\Throwable $e) {
+ error_log("[Heartbeat] 헬스 SMS 실패: " . $e->getMessage());
+ }
+ // 경보 신뢰성: 미복귀 에스컬레이션(opt-in) + 측정값 고착 검출(각자 쿨다운).
+ try {
+ maybe_escalate_alerts(get_db());
+ maybe_detect_stuck_sensors(get_db());
+ } catch (\Throwable $e) {
+ error_log("[Heartbeat] 에스컬레이션/고착 검출 실패: " . $e->getMessage());
+ }
}
}
@@ -131,6 +151,15 @@ if ($_is_direct) {
$is_cli = (php_sapi_name() === 'cli');
if (!$is_cli) {
+ // 기본 API_KEY 미교체 상태에서 HTTP 트리거를 허용하면 CRON_SECRET도 예측
+ // 가능한 기본값(hash_hmac('sha256','cron','change-this-api-key'))이 되므로,
+ // 신규 설치에서 인증 전 SMS/DB 부작용이 발생하는 것을 차단한다.
+ if (api_key_is_default()) {
+ http_response_code(503);
+ header('Content-Type: text/plain; charset=utf-8');
+ echo 'server not configured';
+ exit;
+ }
$key = $_GET['key'] ?? '';
if (!hash_equals(CRON_SECRET, $key)) {
http_response_code(403);
@@ -141,6 +170,13 @@ if ($_is_direct) {
}
$count = check_offline_sensors();
+ try {
+ maybe_send_daily_health_sms(get_db());
+ maybe_escalate_alerts(get_db());
+ maybe_detect_stuck_sensors(get_db());
+ } catch (\Throwable $e) {
+ error_log("[Heartbeat] 헬스/에스컬레이션/고착 실패: " . $e->getMessage());
+ }
if (!$is_cli) {
header('Content-Type: application/json; charset=utf-8');
diff --git a/php/dashboard.php b/php/dashboard.php
index 7c33008..74f0906 100644
--- a/php/dashboard.php
+++ b/php/dashboard.php
@@ -39,15 +39,9 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
exit;
}
-// 세션 타임아웃 확인
-if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > SESSION_TIMEOUT) {
- session_destroy();
- header('Location: login.php?expired');
- exit;
-}
-
-// 활동 시 로그인 시간 갱신
-$_SESSION['login_time'] = time();
+// 세션 타임아웃(유휴 + 절대 수명) 강제 — 모든 인증 페이지 공통 헬퍼(admin_security.php).
+// 유휴 타이머 갱신도 헬퍼가 처리한다.
+admin_enforce_session_timeout();
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
check_offline_sensors_throttled();
@@ -75,6 +69,20 @@ function metric_display(?string $value, string $unit): string {
return number_format((float)$value, 1) . $unit;
}
+// 테이블 존재 확인(누수 미통합 서버에서 leak_incident/leak_photo 부재 시 graceful degrade).
+function table_exists(PDO $db, string $table): bool {
+ try {
+ $stmt = $db->prepare("
+ SELECT COUNT(*) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table
+ ");
+ $stmt->execute([':table' => $table]);
+ return (int)$stmt->fetchColumn() > 0;
+ } catch (Throwable $e) {
+ return false;
+ }
+}
+
// ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll();
@@ -159,9 +167,9 @@ $recent_metrics = $db->query("
LIMIT 30
")->fetchAll();
-// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic) ────
+// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ──
$logs = $db->query("
- SELECT id, sensor_name, device_location, event_type, sensor_time
+ SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time
FROM sensor_log
ORDER BY id DESC
LIMIT 30
@@ -178,14 +186,7 @@ $sms_logs = $db->query("
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준)
$sms_summary_rows = $db->query("
SELECT
- SUM(message LIKE '[고온경보]%') AS high_temp,
- SUM(message LIKE '[저온경보]%') AS low_temp,
- SUM(message LIKE '[고습경보]%') AS high_humidity,
- SUM(message LIKE '[저습경보]%') AS low_humidity,
- SUM(message LIKE '[정상복귀]%') AS recovery,
- SUM(message LIKE '[장비 오프라인]%') AS offline,
- SUM(message LIKE '[장비 복구]%') AS device_online,
- SUM(status = 'fail') AS sms_fail
+ " . sms_summary_select_sql() . "
FROM sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch();
@@ -196,8 +197,10 @@ $summary = [
'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0),
'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0),
'recovery' => (int)($sms_summary_rows['recovery'] ?? 0),
- 'offline' => (int)($sms_summary_rows['offline'] ?? 0),
+ 'offline' => (int)($sms_summary_rows['device_offline'] ?? 0),
'device_online' => (int)($sms_summary_rows['device_online'] ?? 0),
+ 'leak_alert' => (int)($sms_summary_rows['leak_alert'] ?? 0),
+ 'leak_recovery' => (int)($sms_summary_rows['leak_recovery'] ?? 0),
'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0),
];
$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
@@ -233,6 +236,62 @@ $offline_count = count(array_filter($statuses, fn($s) => $s['is_offline']));
$alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
$normal_count = count($statuses) - $offline_count - $alert_count;
+// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ─────
+// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고).
+$recent_alert_fail = (int)$db->query("
+ SELECT COUNT(*) FROM sms_log
+ WHERE status = 'fail'
+ AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
+ AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
+ OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%'
+ OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%'
+ OR message LIKE '" . SMS_TAG_LEAK_ALERT . "%' OR message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%'
+ OR message LIKE '" . SMS_TAG_ESCALATION . "%')
+")->fetchColumn();
+// 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가).
+$ingest_rejections = read_ingest_rejections(1);
+$rejection_total = array_sum($ingest_rejections);
+
+// ── 누수(1번 모듈) 현재 상태 + 사고/사진 ───────────────────
+// 누수 센서(sensor_id=1)의 sensor_status.is_leak 로 누수/정상을 표시한다. 오프라인
+// 판정은 위 $statuses 계산과 동일한 로직을 따른다(행이 없으면 '데이터 없음').
+$leak_status = null;
+foreach ($statuses as $s) {
+ if ((int)$s['sensor_id'] === 1) {
+ $leak_status = $s;
+ break;
+ }
+}
+
+// 최근 누수 사고(에피소드) 20건 + 각 사고의 사진(leak_photo) — 테이블 없으면 graceful.
+$leak_incidents = [];
+$leak_photos_by_log = []; // log_id => [ ['id'=>..,'log_id'=>..,'captured_at'=>..], ... ]
+if (table_exists($db, 'leak_incident')) {
+ $leak_incidents = $db->query("
+ SELECT id, sensor_name, device_location, started_at, recovered_at, log_id_alert
+ FROM leak_incident
+ ORDER BY id DESC
+ LIMIT 20
+ ")->fetchAll();
+
+ if (!empty($leak_incidents) && table_exists($db, 'leak_photo')) {
+ $photo_stmt = $db->prepare("
+ SELECT id, log_id, captured_at
+ FROM leak_photo
+ WHERE log_id = :lid
+ ORDER BY id ASC
+ ");
+ foreach ($leak_incidents as $inc) {
+ $lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
+ if ($lid < 1 || isset($leak_photos_by_log[$lid])) {
+ continue;
+ }
+ $photo_stmt->execute([':lid' => $lid]);
+ $leak_photos_by_log[$lid] = $photo_stmt->fetchAll();
+ }
+ }
+}
+
if ($alert_count > 0) {
$system_state = 'danger';
$system_title = '온습도 경보 발생';
@@ -256,7 +315,7 @@ if ($alert_count > 0) {
- 온습도 모니터링 대시보드 = htmlspecialchars(APP_VERSION) ?>
+ 온습도·누수 통합 모니터링 대시보드 = htmlspecialchars(APP_VERSION) ?>