feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝

2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증.

1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp.

서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약.

DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마.

보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS.

문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cw2007 2026-06-23 16:21:11 +09:00
parent 749b284941
commit 73e49fd528
80 changed files with 6732 additions and 391 deletions

View file

@ -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)

74
SYSTEM_OVERVIEW.md Normal file
View file

@ -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` 와 동일하게 설정.

658
docs/INSTALL_GUIDE.html Normal file
View file

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

File diff suppressed because one or more lines are too long

View file

@ -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 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다.

View file

@ -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 + 감사로그로 보호합니다.
## 폴더 구조

View file

@ -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. 수정한 기존 문서
| 문서 | 반영 내용 |

View file

@ -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. 단말 설치 위치:
예) 서버실 서버랙 하단

View file

@ -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. 요약 판단

View file

@ -4,6 +4,8 @@
작성일: 2026-05-19
목적: 현재 HWP 보안대책서가 기관 보안성 검토 또는 국정원 검토 관점에서 설득력을 갖추도록, 수정해야 할 내용과 구체적 해결책을 제시한다.
> 🔴 **제출 전 차단 항목:** 사업 범위를 **SHT30 온습도 모니터링 전용**(화재/누수/정전/카메라 미구현)으로 좁히기 전에는 제출하지 않는다. 상세는 §3.1 사업 범위 과대 기재 참조.
## 1. 결론
현재 HWP는 **완벽하지 않습니다.**

View file

@ -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. 대시보드 결과

View file

@ -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 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
추가 반영 문구:

View file

@ -1,3 +1,5 @@
※ [구현 범위 정정] 본 시스템 구현 범위는 SHT30 온습도 모니터링 전용입니다(화재/누수/정전/카메라 미구현). 원본 .hwp 도 동일하게 정정 필요.
※ 아래 추출 본문은 원본 .hwp 그대로이며, 화재/누수/정전/현장 사진(카메라) 관련 서술은 실제 구현 범위가 아닙니다. 정정 지침은 docs/SECURITY_PLAN_HWP_REVIEW.md / docs/SECURITY_PLAN_PASS_READINESS.md 참조.
捤獥 汤捯 慤桥 漠杳 漠杳
『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
2026. 5.

65
docs/build_standalone.py Normal file
View file

@ -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())

View file

View file

@ -0,0 +1,22 @@
# 설치 가이드 캡처 이미지 폴더
`docs/INSTALL_GUIDE.html` 본문의 각 "📷 캡처 위치" 는 아래 파일명을 가진 `<img>` 로 연결되어 있습니다.
**운영 시스템에서 해당 화면을 캡처해 아래 파일명 그대로 이 폴더에 저장**하면, 가이드를 열었을 때
자리표시 대신 실제 이미지가 표시됩니다. (파일이 없으면 "여기에 넣으세요" 자리표시가 자동으로 보입니다.)
| 파일명 | 그림 | 캡처할 화면 |
|---|---|---|
| `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 옆에 `<img src="screenshots/11b-dashboard-mobile.png">` 를 직접 추가하면 됩니다.

View file

@ -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: <sig>` 헤더, 본문=`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: <sig_hex>" 추가, 본문은 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: <hex>" 추가, 본문은 위 body 그대로 전송.

View file

@ -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` 는 저장되지 않습니다.
---

View file

@ -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 빌드

View file

@ -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
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */

View file

@ -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;

View file

@ -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

View file

@ -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,

View file

@ -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 */

View file

@ -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 # 동일 본문에 대한 서버측 서명 기대값
```

View file

@ -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

View file

@ -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: <sig_hex>", 본문은 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 트리거 확인.

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)")

View file

@ -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 <api_key> (본문은 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 <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s <api_key> (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;
}

View file

@ -38,8 +38,11 @@
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
</IfModule>

View file

@ -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();
}

356
php/api/photo_upload.php Normal file
View file

@ -0,0 +1,356 @@
<?php
// =============================================================================
// api/photo_upload.php - 1번 모듈(누수+카메라) 누수 사진 업로드 수신 API
//
// [흐름] 1번 모듈은 누수 감지 시 api/sensor_data.php 에 leak_alert 를 보내 log_id 를
// 받은 뒤, Pi Camera 로 촬영한 JPEG 을 이 엔드포인트에 multipart 로 업로드한다.
//
// [요청 형식] POST multipart/form-data
// form fields: device_id, sensor_id, log_id, timestamp
// file field: photo (단일, image/jpeg)
// header : X-Signature
//
// [인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
// canonical = device_id . "|" . sensor_id . "|" . log_id . "|" . timestamp (공백 없음)
// 클라이언트(raspberry_pi4/leak_sensor.py upload_photo)와 바이트 동일해야 한다.
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어.
// - hash_equals 로 타이밍 안전 비교, 불일치 시 403.
// - 기본 API_KEY(미설정)면 수신 거부(fail-closed).
//
// [파일 보안]
// - 단일 file field "photo" 만 허용, 크기 상한(PHOTO_MAX_BYTES) 강제.
// - MIME 은 클라이언트 신고가 아니라 임시파일을 finfo 로 직접 검사(image/jpeg 만 허용).
// - 저장 파일명은 서버가 생성하는 무작위 이름(클라이언트 파일명/경로 미사용 → 경로주입 차단).
// - 저장 위치는 php/var/photos/ (없으면 생성). leak_photo 에 메타데이터만 기록.
//
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
// =============================================================================
header('Access-Control-Allow-Origin: https://pores.kr');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204);
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => '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,
"<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
. "<IfModule mod_autoindex.c>\n Options -Indexes\n</IfModule>\n"
);
}
$index = $dir . '/index.html';
if (!is_file($index)) {
@file_put_contents($index, "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>403</title></head><body></body></html>\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,
]);

View file

@ -1,8 +1,9 @@
<?php
// =============================================================================
// api/sensor_data.php - STM32 SHT30 보드로부터 온습도 측정 데이터를 받는 API
// api/sensor_data.php - 센서 보드로부터 측정/이벤트 데이터를 받는 통합 수집 API
// (2번 모듈=SHT30 온습도 sensor_id=2, 1번 모듈=누수+카메라 sensor_id=1)
//
// [요청 형식] POST application/json
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
// {
// "device_id": "stm32-sht30-01",
// "device_location": "서버실",
@ -17,6 +18,19 @@
// "app_version": "v2606-sht30"
// }
//
// [요청 형식 — 누수(1번 모듈)] POST application/json
// {
// "device_id": "rpi4-leak-01",
// "device_location": "서버실",
// "sensor_id": 1,
// "sensor_name": "1번 센서 (누수)",
// "event_type": "leak_alert" | "leak_recovery" | "periodic" | "startup",
// "is_leak": 1, // 누수 여부(0/1)
// "timestamp": 1700000000
// }
// - leak_alert 응답에 log_id 를 실어 보내면, 1번 모듈이 그 log_id 로 사진을
// api/photo_upload.php 에 업로드한다.
//
// [인증 방식 2가지]
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
@ -58,6 +72,7 @@ require_once dirname(__DIR__) . '/cron_heartbeat.php';
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
if (api_key_is_default()) {
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
record_ingest_rejection('key_default');
json_response(['status' => '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,
]);

View file

@ -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,

View file

@ -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 펌웨어)는 사용하지 않는다.

View file

@ -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');

View file

@ -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) {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; }
@ -354,6 +413,17 @@ if ($alert_count > 0) {
.legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-empty { color: #94a3b8; padding: 24px; text-align: center; }
/* 누수(leak) 패널 */
.leak-status-card { border-left: 6px solid #22c55e; }
.leak-status-card.leak { border-left-color: #ef4444; background: #fff5f5; }
.leak-status-card.offline { border-left-color: #f59e0b; background: #fffbeb; }
.pill.leak { background: #fee2e2; color: #991b1b; }
.pill.leak_alert { background: #fee2e2; color: #991b1b; }
.pill.leak_recovery { background: #dcfce7; color: #166534; }
.leak-thumbs { display: flex; flex-wrap: wrap; gap: 6px; }
.leak-thumbs a { display: inline-block; line-height: 0; }
.leak-thumbs img { width: 64px; height: 64px; object-fit: cover;
border-radius: 6px; border: 1px solid #e2e8f0; background: #f1f5f9; }
/* 테이블 가로 스크롤 컨테이너 */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
margin-bottom: 24px; border-radius: 8px; }
@ -416,7 +486,7 @@ if ($alert_count > 0) {
</head>
<body>
<header style="display:flex;align-items:center;justify-content:space-between">
<h1>온습도 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
<h1>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></h1>
<div class="top-actions">
<a href="setup_wizard.php">설치 점검</a>
<a href="monthly_report.php">월간 보고</a>
@ -437,6 +507,39 @@ if ($alert_count > 0) {
</div>
</section>
<?php if ($recent_alert_fail > 0): ?>
<section class="status-hero danger">
<div>
<h2>경보 SMS 발송 실패</h2>
<p>최근 1시간 내 경보/오프라인 SMS <?= (int)$recent_alert_fail ?>건 발송 실패 — 미수신 가능. Cafe24 SMS 잔액·계정·발신번호를 확인하세요.</p>
</div>
</section>
<?php endif; ?>
<?php if ($rejection_total > 0): ?>
<section class="status-hero warning">
<div>
<h2>수집 거부 <?= (int)$rejection_total ?>건 (최근 24시간)</h2>
<p><?php
$reason_ko = [
'signature' => '서명불일치(API키/펌웨어 점검)',
'timestamp' => '시각오차(SNTP/RTC 점검)',
'key_default' => '기본 API키(미설정)',
'body_too_large' => '본문과대',
'invalid_json' => 'JSON오류',
'missing_field' => '필드누락',
'sensor_id_range' => 'sensor_id 범위',
];
$parts = [];
foreach ($ingest_rejections as $r => $c) {
$parts[] = htmlspecialchars($reason_ko[$r] ?? $r) . ' ' . (int)$c;
}
echo implode(' · ', $parts);
?></p>
</div>
</section>
<?php endif; ?>
<section class="mobile-quick" aria-label="모바일 빠른 조회">
<div class="quick-grid">
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
@ -458,6 +561,7 @@ if ($alert_count > 0) {
</div>
<nav class="quick-links" aria-label="빠른 이동">
<a href="#sensors-section">센서 상태</a>
<a href="#leak-section">누수 감지</a>
<a href="#chart-section">온습도 추이</a>
<a href="#summary-section">운영 요약</a>
<a href="#metrics-section">측정 이력</a>
@ -585,6 +689,105 @@ if ($alert_count > 0) {
<?php endif; ?>
</div>
<!-- 누수(1번 모듈) 상태 + 사고 이력 + 사진 -->
<h2 id="leak-section">누수 감지 (1번 모듈)</h2>
<?php
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
if ($leak_status === null) {
$leak_card_class = '';
$leak_badge_class = 'offline';
$leak_badge_text = '데이터 없음';
} elseif ($leak_status['is_offline']) {
$leak_card_class = 'offline';
$leak_badge_class = 'offline';
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
$leak_card_class = 'leak';
$leak_badge_class = 'leak';
$leak_badge_text = '누수';
} else {
$leak_card_class = '';
$leak_badge_class = 'ok';
$leak_badge_text = '정상';
}
?>
<div class="sensor-grid">
<div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?>
<strong>1번 센서 (누수)</strong><br>
<div style="margin-top:10px">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div class="metric-time" style="margin-top:10px">아직 수신된 누수 센서 데이터가 없습니다.</div>
<?php else: ?>
<strong><?= htmlspecialchars($leak_status['sensor_name']) ?></strong><br>
<small style="color:#64748b"><?= htmlspecialchars($leak_status['device_location']) ?> / <?= htmlspecialchars($leak_status['device_id']) ?></small><br>
<div style="margin-top:10px">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div>
<div style="font-size:.75rem;margin-top:8px;<?= $leak_status['is_offline'] ? 'color:#dc2626;font-weight:600' : 'color:#94a3b8' ?>">
최종확인: <?= htmlspecialchars($leak_status['last_seen']) ?>
<?php if ($leak_status['is_offline']): ?>
(<?= $leak_status['offline_elapsed_min'] ?>분 전)
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<details class="mobile-detail" data-mobile-collapsed open>
<summary>누수 사고 이력 <span>최근 20건</span></summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-hide-xs">#</th><th>센서</th><th class="col-hide-xs">위치</th>
<th>감지 시각</th><th>복구 시각</th><th>사진</th>
</tr>
</thead>
<tbody>
<?php foreach ($leak_incidents as $inc):
$is_open = ($inc['recovered_at'] === null);
$lid = $inc['log_id_alert'] !== null ? (int)$inc['log_id_alert'] : 0;
$photos = $leak_photos_by_log[$lid] ?? [];
?>
<tr class="<?= $is_open ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td>
<?php if ($is_open): ?>
<span class="pill leak">미복구</span>
<?php else: ?>
<?= htmlspecialchars($inc['recovered_at']) ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($photos)): ?>
<div class="leak-thumbs">
<?php foreach ($photos as $ph): ?>
<a href="photo.php?id=<?= (int)$ph['id'] ?>" target="_blank" rel="noopener">
<img src="photo.php?id=<?= (int)$ph['id'] ?>"
alt="누수 사진 <?= htmlspecialchars($ph['captured_at']) ?>"
loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<span style="color:#94a3b8">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($leak_incidents)): ?>
<tr><td colspan="6" style="text-align:center;color:#94a3b8">아직 누수 사고 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</details>
<!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?>
@ -746,6 +949,14 @@ if ($alert_count > 0) {
<span>장비 복구 SMS</span>
<strong><?= $summary['device_online'] ?>건</strong>
</div>
<div class="report-stat">
<span>누수감지 SMS</span>
<strong style="color:<?= $summary['leak_alert'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['leak_alert'] ?>건</strong>
</div>
<div class="report-stat">
<span>누수복구 SMS</span>
<strong><?= $summary['leak_recovery'] ?>건</strong>
</div>
<div class="report-stat">
<span>SMS 실패</span>
<strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong>
@ -820,15 +1031,24 @@ if ($alert_count > 0) {
$event_labels = [
'startup' => '장비시작',
'periodic' => '정기보고',
'leak_alert' => '누수감지',
'leak_recovery' => '누수복구',
];
$evt = $row['event_type'];
$is_leak_evt = ($evt === 'leak_alert' || $evt === 'leak_recovery' || (int)($row['is_leak'] ?? 0) === 1);
?>
<tr>
<tr class="<?= $evt === 'leak_alert' ? 'alert-row' : '' ?>">
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sensor_time']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></td>
<td><?= htmlspecialchars($event_labels[$evt] ?? $evt) ?></td>
<td>
<?php if ($evt === 'leak_alert' || $evt === 'leak_recovery'): ?>
<span class="pill <?= htmlspecialchars($evt) ?>"><?= htmlspecialchars($event_labels[$evt]) ?></span>
<?php else: ?>
<?= htmlspecialchars($event_labels[$evt] ?? $evt) ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($logs)): ?>
@ -854,7 +1074,7 @@ if ($alert_count > 0) {
<td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sent_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td><?= htmlspecialchars($row['receivers']) ?></td>
<td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
<td>
<?php if ($row['status'] === 'success'): ?>
<span class="ok-color">성공</span>

View file

@ -118,7 +118,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
session_regenerate_id(true);
$_SESSION['logged_in'] = true;
$_SESSION['login_time'] = time();
$_SESSION['login_time'] = time(); // 절대 수명 기준(이후 갱신하지 않음)
$_SESSION['last_activity'] = time(); // 유휴 타이머 기준(활동 시 갱신)
$_SESSION['username'] = $username;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
admin_audit_log('login_success', $username, true);

View file

@ -20,6 +20,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
function month_range(string $month): array {
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
@ -34,16 +35,17 @@ function month_range(string $month): array {
return [$month, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')];
}
// SMS 메시지 태그(prefix) → 집계 키 매핑. sms_send.php 가 발송 시 붙이는 태그와 일치.
// SMS 메시지 태그(prefix) → 집계 키 매핑. 태그 문자열은 config.php 의 SMS_TAG_*
// 단일 출처 상수에서 가져온다(sms_send.php 가 발송 시 붙이는 태그와 자동 일치).
function report_sms_tags(): array {
return [
'high_temp' => '[고온경보]',
'low_temp' => '[저온경보]',
'high_humidity' => '[고습경보]',
'low_humidity' => '[저습경보]',
'recovery' => '[정상복귀]',
'device_offline' => '[장비 오프라인]',
'device_online' => '[장비 복구]',
'high_temp' => SMS_TAG_HIGH_TEMP,
'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
'recovery' => SMS_TAG_RECOVERY,
'device_offline' => SMS_TAG_DEVICE_OFFLINE,
'device_online' => SMS_TAG_DEVICE_ONLINE,
];
}
@ -77,14 +79,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
$stmt = $db->prepare("
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 device_offline,
SUM(message LIKE '[장비 복구]%') AS device_online,
SUM(status = 'fail') AS sms_fail
" . sms_summary_select_sql() . "
FROM sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
");
@ -137,14 +132,7 @@ $summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
// ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
$stmt = $db->prepare("
SELECT sensor_id, sensor_name,
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 device_offline,
SUM(message LIKE '[장비 복구]%') AS device_online,
SUM(status = 'fail') AS sms_fail
" . sms_summary_select_sql() . "
FROM sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name

View file

@ -82,6 +82,12 @@ function collect_ops_checks(): array {
");
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수');
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
$sms_balance = latest_sms_balance($db);
if ($sms_balance !== null) {
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여');
}
}
} catch (Throwable $e) {
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
@ -91,6 +97,8 @@ function collect_ops_checks(): array {
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/sht30-sensor.env(또는 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
$checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
$checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표');
$checks[] = ops_check('관리자 인증', 'MFA 설정 토큰 정리', MFA_SETUP_TOKEN === '', MFA_SETUP_TOKEN === '' ? '정리됨(비어 있음)' : '설정됨 — 등록 후 제거 필요', 'MFA 등록을 마쳤으면 config.local.php의 MFA_SETUP_TOKEN을 빈 값으로 비우세요. 남아 있으면 토큰 보유자가 setup_mfa.php로 새 TOTP 비밀키를 발급·검증할 수 있습니다.', 'warn', 'config.local.php MFA_SETUP_TOKEN 미설정 확인');
$checks[] = ops_check('API 보안', '레거시 서명 폴백', !ALLOW_LEGACY_BODY_SIGNATURE, ALLOW_LEGACY_BODY_SIGNATURE ? '활성(약한 키-접두 sha256 허용)' : '비활성(HMAC 전용)', '운영 클라이언트(STM32)는 HMAC만 사용하므로 config.local.php에서 ALLOW_LEGACY_BODY_SIGNATURE를 false로 두세요(레거시 RPi 클라이언트가 없는 경우).', 'warn', 'config.php ALLOW_LEGACY_BODY_SIGNATURE');
$checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log');
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
$thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH);

109
php/photo.php Normal file
View file

@ -0,0 +1,109 @@
<?php
// =============================================================================
// photo.php - 누수 사진 인증 열람 엔드포인트 (관리자 세션 전용)
//
// 누수 사진 원본은 php/var/photos/ 에 저장되며, 웹에서 직접 접근은 .htaccess 로
// 차단된다(var/photos/.htaccess: Require all denied). 따라서 대시보드의 사진
// 썸네일/원본은 반드시 이 엔드포인트(photo.php?id=<leak_photo.id>)를 통해서만
// 열람할 수 있다. 미인증 접근은 dashboard.php 와 동일하게 login.php 로 보낸다.
//
// [보안]
// - dashboard.php 와 동일한 관리자 세션 가드(세션 쿠키 하드닝 + logged_in 검사 +
// 세션 타임아웃 강제)를 그대로 사용한다(별도 인증 스킴 신설 금지).
// - id 는 양의 정수만 허용, leak_photo 에서 filename/mime/bytes 를 파라미터 바인딩
// 으로 조회. 없으면 404.
// - filename 은 서버 생성 패턴(^leak_\d+_\d+_[0-9a-f]+\.jpg$)으로 재검증
// (다중방어). 클라이언트 입력은 경로에 절대 들어가지 않는다.
// - realpath 가 var/photos/ 내부인지 확인(경로 탈출 차단) 후 readfile.
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.php';
// ── 세션 인증 (dashboard.php 와 동일한 가드) ────────────────
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
session_start();
// 세션 확인 — 미인증이면 대시보드와 동일하게 로그인으로 리다이렉트
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
// 세션 타임아웃(유휴 + 절대 수명) 강제 — 인증 페이지 공통 헬퍼(admin_security.php)
admin_enforce_session_timeout();
// ── 입력: id (양의 정수) ────────────────────────────────────
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id < 1) {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'invalid id';
exit;
}
// ── leak_photo 조회 (파라미터 바인딩) ───────────────────────
try {
$db = get_db();
$stmt = $db->prepare("SELECT filename, mime, bytes FROM leak_photo WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
} catch (Throwable $e) {
error_log("[PHOTO] 조회 오류: " . $e->getMessage());
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo 'server error';
exit;
}
if (!$row) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
$filename = (string)$row['filename'];
// ── 파일명 다중방어 검증 (서버 생성 패턴만 허용) ────────────
// 예: leak_1_42_a1b2c3d4e5f6a7b8.jpg (sensor_id, log_id, 무작위 hex)
if (!preg_match('/^leak_[0-9]+_[0-9]+_[0-9a-f]+\.jpg$/', $filename)) {
error_log("[PHOTO] 파일명 형식 거부: {$filename}");
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
// ── 경로 결합 + 탈출 차단 (realpath 가 var/photos/ 내부인지 확인) ──
$photo_dir = realpath(__DIR__ . '/var/photos');
if ($photo_dir === false) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
$path = realpath($photo_dir . '/' . $filename);
// realpath 실패(파일 없음) 또는 결합 경로가 var/photos/ 밖이면 거부(경로 탈출 방어).
if ($path === false || strpos($path, $photo_dir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'not found';
exit;
}
// ── JPEG 전송 (private, no-cache) ───────────────────────────
$size = @filesize($path);
header('Content-Type: image/jpeg');
if ($size !== false) {
header('Content-Length: ' . $size);
}
header('Cache-Control: private, no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: inline; filename="' . $filename . '"');
readfile($path);
exit;

View file

@ -17,6 +17,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
$checks = collect_ops_checks();
$failed_count = ops_failed_count($checks);

View file

@ -17,6 +17,12 @@ function setup_mfa_allowed(): bool {
if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
return true;
}
// 토큰 경로는 초기 등록 전에만 허용한다. MFA가 이미 설정된 상태에서
// MFA_SETUP_TOKEN이 남아 있어도 해당 토큰으로 재등록 화면에 접근할 수 없게 한다.
// (운영자가 등록 후 토큰을 삭제하지 않았을 때의 잔류 접근 차단.)
if (admin_mfa_configured()) {
return false;
}
$token = $_GET['token'] ?? $_POST['token'] ?? '';
return MFA_SETUP_TOKEN !== '' && hash_equals(MFA_SETUP_TOKEN, (string)$token);
}

View file

@ -17,6 +17,7 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
header('Location: login.php');
exit;
}
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(ops_checks → admin_security 경유 로드)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@ -110,7 +111,7 @@ $failed_count = ops_failed_count($checks);
<section class="result">
<strong>테스트 SMS 결과</strong>
<?php foreach ($sms_test_result as $row): ?>
<p><?= htmlspecialchars($row['receiver']) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
<p><?= htmlspecialchars(mask_phone($row['receiver'])) ?> · <?= $row['result']['success'] ? '성공' : '실패' ?> · <?= htmlspecialchars($row['result']['message']) ?></p>
<?php endforeach; ?>
</section>
<?php endif; ?>

View file

@ -6,6 +6,7 @@
// 담당 알림:
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
// - 온습도 정상 복귀 : send_metric_recovery_sms()
// - 누수 감지 / 복구 (1번 모듈) : send_leak_alert_sms() / send_leak_recovery_sms()
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
// =============================================================================
@ -32,6 +33,37 @@ function split_sender_number(string $phone): array {
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
}
/**
* EUC-KR 로 인코딩된 문자열을 최대 바이트 수로 자른다(멀티바이트 경계 보존).
* Cafe24 단문(SMS)의 EUC-KR 길이 상한을 넘지 않도록 발송 직전에 사용한다.
*
* @param string $euckr EUC-KR 로 이미 인코딩된 바이트열
* @param int $maxBytes 허용 최대 바이트
*/
function sms_truncate_euckr(string $euckr, int $maxBytes): string {
if ($maxBytes <= 0 || strlen($euckr) <= $maxBytes) {
return $euckr;
}
if (function_exists('mb_strcut')) {
// mb_strcut 은 지정 바이트 안에서 멀티바이트 글자를 쪼개지 않는다.
$cut = mb_strcut($euckr, 0, $maxBytes, 'EUC-KR');
if ($cut !== '') {
return $cut;
}
}
// mbstring 미가용 폴백: EUC-KR 2바이트 글자 경계를 직접 맞춘다(0x00-0x7F 는 1바이트,
// 0xA1-0xFE 선행 바이트는 2바이트). maxBytes 위치가 2바이트 글자 중간이면 한 글자 뺀다.
$cut = substr($euckr, 0, $maxBytes);
$highBytes = 0;
for ($i = strlen($cut) - 1; $i >= 0 && ord($cut[$i]) >= 0x80; $i--) {
$highBytes++;
}
if ($highBytes % 2 === 1) {
$cut = substr($cut, 0, -1);
}
return $cut;
}
/**
* Cafe24 SMS API로 문자 발송
*
@ -46,6 +78,11 @@ function send_sms_cafe24(string $receiver, string $message): array {
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.'];
}
// 카오스 드릴: 실제 전송 없이 실패를 반환한다(실패 경로 시험). 운영에선 false.
if (SMS_FORCE_FAIL) {
return ['success' => false, 'result_code' => 'FORCED_FAIL', 'message' => '드릴: 강제 실패', 'remaining' => null];
}
[$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message));
@ -53,6 +90,15 @@ function send_sms_cafe24(string $receiver, string $message): array {
return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패'];
}
// 단문(SMS, 'S') 길이 상한(EUC-KR 바이트). 초과분은 게이트웨이가 잘라/거부하므로
// 멀티바이트 경계를 보존하며 발송 전에 안전하게 자른다(무성 절단 방지).
$origBytes = strlen($encodedMessage);
$encodedMessage = sms_truncate_euckr($encodedMessage, SMS_MAX_EUCKR_BYTES);
if (strlen($encodedMessage) < $origBytes) {
error_log(sprintf('[Cafe24 SMS] 메시지 길이 초과 절단: %dB → %dB (상한 %dB)',
$origBytes, strlen($encodedMessage), SMS_MAX_EUCKR_BYTES));
}
$postData = [
'action' => 'go',
'user_id' => base64_encode(SMS_USER_ID),
@ -117,6 +163,8 @@ function send_sms_cafe24(string $receiver, string $message): array {
'success' => $success,
'result_code' => $resultCode,
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}",
// 게이트웨이가 돌려준 잔여 건수(숫자면 int, 아니면 null). 잔액 사전경보에 사용.
'remaining' => is_numeric($remaining) ? (int)$remaining : null,
];
}
@ -129,13 +177,18 @@ function send_sms_cafe24(string $receiver, string $message): array {
* @param string $logLabel error_log 식별 라벨
* @return bool 전원 발송 성공 여부
*/
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel): bool {
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): bool {
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
foreach (SMS_RECIPIENTS as $receiver) {
foreach ($targets as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message);
if (($result['remaining'] ?? null) !== null) {
$lastRemaining = (int)$result['remaining'];
}
$stmt = $db->prepare("
INSERT INTO sms_log
@ -164,9 +217,61 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
}
}
// 발송 직후 잔여 건수가 임계 이하이면(쿨다운 1회) 사전경보. 직접 발송이라 재귀 없음.
maybe_warn_sms_balance($db, $lastRemaining);
return $allSuccess;
}
/**
* SMS 잔여 건수가 임계 이하이면 '[SMS 잔액부족]' 경고를 1회 발송한다(쿨다운 적용).
* 게이트웨이 응답에서 얻은 잔여 건수를 그대로 받는다(추가 외부호출 없음).
* 경고는 sms_dispatch 를 거치지 않고 직접 발송·기록하여 재귀를 피한다.
*/
function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
if ($remaining === null || $remaining > SMS_BALANCE_WARN_THRESHOLD) {
return;
}
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
$recent = $db->prepare("
SELECT sent_at FROM sms_log
WHERE message LIKE :tag
ORDER BY sent_at DESC
LIMIT 1
");
$recent->execute([':tag' => SMS_TAG_BALANCE_LOW . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < SMS_BALANCE_WARN_COOLDOWN_SEC) {
return;
}
$message = SMS_TAG_BALANCE_LOW . "\n"
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n"
. "충전하지 않으면 경보 발송이 중단됩니다.\n"
. "시각: " . date('Y-m-d H:i:s');
foreach (SMS_RECIPIENTS as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message);
$stmt = $db->prepare("
INSERT INTO sms_log
(sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at)
VALUES
(0, 'SMS잔액', '', :message, :receivers,
:status, :result_code, :result_message, NOW())
");
$stmt->execute([
':message' => $message,
':receivers' => $receiverDigits,
':status' => $result['success'] ? 'success' : 'fail',
':result_code' => $result['result_code'],
':result_message' => $result['message'],
]);
}
error_log("[SMS] 잔액부족 경고 발송 - 잔여 {$remaining}건");
}
/**
* 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
*/
@ -178,6 +283,49 @@ function metric_value_str(float $value): string {
return $s;
}
/**
* 이슬점(℃) — Magnus 근사(물, 약 -45~60℃, 계수 a=17.62 b=243.12).
* 상대습도 0% 이하면 이슬점 정의 불가로 null(결로 위험 없음으로 취급).
*/
function dew_point_c(float $temp_c, float $rh): ?float {
if ($rh <= 0.0) {
return null;
}
$a = 17.62;
$b = 243.12;
$gamma = ($a * $temp_c) / ($b + $temp_c) + log($rh / 100.0);
return ($b * $gamma) / ($a - $gamma);
}
/**
* 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null.
*/
function condensation_margin_c(?float $temp_c, ?float $rh): ?float {
if ($temp_c === null || $rh === null) {
return null;
}
$td = dew_point_c($temp_c, $rh);
if ($td === null) {
return null;
}
return $temp_c - $td;
}
/**
* 결로 위험이 해소(정상)인지(히스테리시스 적용). 결로 경보 비활성/결측/산출불가는
* 정상으로 간주한다. 복귀 판정에서 사용.
*/
function condensation_is_clear(?float $temp_c, ?float $rh): bool {
if (METRIC_DEWPOINT_MARGIN_C <= 0 || $temp_c === null || $rh === null) {
return true;
}
$margin = condensation_margin_c($temp_c, $rh);
if ($margin === null) {
return true;
}
return $margin > METRIC_DEWPOINT_MARGIN_C + METRIC_DEWPOINT_HYSTERESIS_C;
}
/**
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* @return array{breaches: string[], status: string}
@ -199,6 +347,14 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches[] = 'low_humidity';
}
}
// 결로(이슬점) 위험: 온도·습도가 각각 정상이어도 결로마진이 좁으면 발화(말미에 추가
// 하여, 동시 발생 시 대표 status 는 온/습도 임계가 우선하도록 한다).
if (METRIC_DEWPOINT_MARGIN_C > 0 && $temp_c !== null && $rh !== null) {
$margin = condensation_margin_c($temp_c, $rh);
if ($margin !== null && $margin <= METRIC_DEWPOINT_MARGIN_C) {
$breaches[] = 'condensation_risk';
}
}
return [
'breaches' => $breaches,
'status' => empty($breaches) ? 'normal' : $breaches[0],
@ -207,7 +363,7 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
/**
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
* 측정 결측(null)은 해당 지표를 정상으로 간주한다.
* 결로 위험도 해소되어야 복귀로 본다. 측정 결측(null)은 해당 지표를 정상으로 간주한다.
*/
function metric_is_recovered(?float $temp_c, ?float $rh): bool {
$tOk = ($temp_c === null) || (
@ -218,7 +374,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
$rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS &&
$rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS
);
return $tOk && $hOk;
return $tOk && $hOk && condensation_is_clear($temp_c, $rh);
}
/**
@ -226,22 +382,34 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
*/
function metric_was_alerting(PDO $db, int $sensor_id): bool {
$stmt = $db->prepare("
SELECT message FROM sms_log
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
// 결로(결로주의)도 1급 경보이므로 포함해야, 복귀(정상복귀) SMS 1회가 정상 발화한다.
$tags = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP,
SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION,
SMS_TAG_RECOVERY,
];
$likes = [];
$params = [':sid' => $sensor_id];
foreach ($tags as $i => $tag) {
$key = ":t{$i}";
$likes[] = "message LIKE {$key}";
$params[$key] = $tag . '%';
}
$sql = "SELECT message FROM sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (message LIKE '[고온경보]%' OR message LIKE '[저온경보]%'
OR message LIKE '[고습경보]%' OR message LIKE '[저습경보]%'
OR message LIKE '[정상복귀]%')
ORDER BY sent_at DESC
LIMIT 1
");
$stmt->execute([':sid' => $sensor_id]);
AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC
LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$msg = $stmt->fetchColumn();
if ($msg === false) {
return false;
}
return !str_starts_with($msg, '[정상복귀]');
return !str_starts_with($msg, SMS_TAG_RECOVERY);
}
/**
@ -259,12 +427,13 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구]
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
$labels = [
'high_temp' => ['[고온경보]', '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => ['[저온경보]', '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => ['[고습경보]', '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => ['[저습경보]', '습도', '%', METRIC_RH_LOW, '이하'],
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
];
if (!isset($labels[$kind])) {
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
@ -272,6 +441,31 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
}
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
// 안전하게 폴백한다. (같은 본문 재시도/재생은 sensor_data.php 의 nonce 멱등에서 선차단)
$lockName = substr("sms_alert_{$sensor_id}_{$kind}", 0, 64);
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => $lockName]);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
}
if ($lockState === 'busy') {
error_log("[SMS] {$tag} 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
return false;
}
try {
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
$recent = $db->prepare("
SELECT sent_at FROM sms_log
@ -303,6 +497,15 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage());
}
}
}
}
/**
@ -320,7 +523,7 @@ function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh):
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = "[정상복귀]\n"
$message = SMS_TAG_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
@ -330,6 +533,124 @@ function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh):
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
}
/**
* 누수 감지 SMS 발송 (1번 모듈). 쿨다운 포함.
*
* 쿨다운: 같은 센서의 최근 성공한 [누수감지] SMS가 LEAK_ALERT_COOLDOWN_SEC 이내면
* 발송을 생략한다(누수 지속 중 주기 보고로 인한 중복 폭주 방지).
* 동시 처리 직렬화는 sms_send 의 metric 경보와 동일하게 MySQL 네임드 락으로 보장한다.
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
*/
function send_leak_alert_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// ── 동시 발송 직렬화 (중복 SMS 방지) ──
$lockName = substr("sms_leak_alert_{$sensor_id}", 0, 64);
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => $lockName]);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
}
if ($lockState === 'busy') {
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
return false;
}
try {
// ── 쿨다운 (sms_log 메시지 prefix 기준) ──
$recent = $db->prepare("
SELECT sent_at FROM sms_log
WHERE sensor_id = :sensor_id
AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at DESC
LIMIT 1
");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => SMS_TAG_LEAK_ALERT . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < LEAK_ALERT_COOLDOWN_SEC) {
$elapsed = time() - strtotime($last_at);
error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)");
return false;
}
// ── 메시지 구성 ──
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_ALERT . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage());
}
}
}
}
/**
* 누수 복구 SMS 발송 (1번 모듈). 직전에 누수 경보([누수감지])가 있었고 그 뒤 복구
* 통지가 없었을 때만 1회 발송한다(복구만 단독으로 발송되는 것 방지).
*
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
* @return bool 발송 성공 여부 (경보 이력 없거나 이미 복구 통지면 false)
*/
function send_leak_recovery_sms(array $sensor_data): bool {
$db = get_db();
$sensor_id = (int)$sensor_data['sensor_id'];
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
$stmt = $db->prepare("
SELECT message FROM sms_log
WHERE sensor_id = :sid
AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery)
ORDER BY sent_at DESC, id DESC
LIMIT 1
");
$stmt->execute([
':sid' => $sensor_id,
':alert' => SMS_TAG_LEAK_ALERT . '%',
':recovery' => SMS_TAG_LEAK_RECOVERY . '%',
]);
$last = $stmt->fetchColumn();
if ($last === false || !str_starts_with((string)$last, SMS_TAG_LEAK_ALERT)) {
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
}
$dt = date('Y-m-d H:i:s');
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = SMS_TAG_LEAK_RECOVERY . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "누수가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
}
/**
* 장비 오프라인 SMS 발송
*
@ -344,7 +665,7 @@ function send_device_offline_sms(array $sensor_data): bool {
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$last = $sensor_data['last_seen'] ?? '알 수 없음';
$message = "[장비 오프라인]\n"
$message = SMS_TAG_DEVICE_OFFLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "마지막 통신: {$last}\n"
@ -367,7 +688,7 @@ function send_device_online_sms(array $sensor_data): bool {
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = "[장비 복구]\n"
$message = SMS_TAG_DEVICE_ONLINE . "\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "복구 시각: {$dt}\n"
@ -375,3 +696,242 @@ function send_device_online_sms(array $sensor_data): bool {
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
}
/**
* 일일 헬스(생존신호) 요약 문자열. 짧게 유지(SMS 단문 길이 고려).
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
*/
function build_health_summary(PDO $db): string {
$online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn();
$total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
$alerts = (int)$db->query("
SELECT COUNT(*) FROM sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND status = 'success'
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 . "%')
")->fetchColumn();
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건";
$bal = latest_sms_balance($db);
if ($bal !== null) {
$summary .= ", 잔여 {$bal}건";
}
return $summary;
}
/**
* 하루 1회 '[정기점검]' 생존신호 SMS 를 보낸다(데드맨). HEALTH_SMS_HOUR 시 이후의
* 첫 호출에서, 오늘자 발송이 없을 때만 발송한다. cron_heartbeat 가 주기적으로 호출.
* 날짜 dedup + 네임드 락으로 중복 발송을 막는다(락 미지원 시 날짜 dedup 만으로 방어).
*/
function maybe_send_daily_health_sms(PDO $db): void {
if (!HEALTH_SMS_ENABLED || (int)date('G') < HEALTH_SMS_HOUR) {
return;
}
$today = $db->prepare("
SELECT 1 FROM sms_log
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
LIMIT 1
");
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
if ($today->fetchColumn() !== false) {
return; // 오늘 이미 발송함
}
$lockState = 'unsupported';
try {
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$lk->execute([':n' => 'daily_health_sms']);
$res = $lk->fetchColumn();
$lockState = ($res === null || $res === false)
? 'unsupported'
: (((int)$res === 1) ? 'acquired' : 'busy');
} catch (Throwable $e) {
$lockState = 'unsupported';
}
if ($lockState === 'busy') {
return; // 다른 프로세스가 처리 중
}
try {
// 락 획득 후 이중 확인(획득 사이에 다른 프로세스가 발송했을 수 있음).
$today->execute([':tag' => SMS_TAG_HEALTH . '%']);
if ($today->fetchColumn() !== false) {
return;
}
$message = SMS_TAG_HEALTH . "\n" . build_health_summary($db);
$info = ['sensor_id' => 0, 'sensor_name' => '정기점검', 'device_id' => '', 'device_location' => ''];
sms_dispatch_to_recipients($db, $info, $message, "정기점검");
error_log("[SMS] 정기점검(생존신호) 발송");
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => 'daily_health_sms']);
} catch (Throwable $e) {
/* 해제 실패 무시 */
}
}
}
}
/**
* 특정 태그의 최근 발송이 $seconds 이내인지(쿨다운 판정). 태그별 1회 발송 억제에 사용.
*/
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
$stmt = $db->prepare("
SELECT sent_at FROM sms_log
WHERE sensor_id = :sid AND message LIKE :tag
ORDER BY sent_at DESC LIMIT 1
");
$stmt->execute([':sid' => $sensor_id, ':tag' => $tag . '%']);
$last = $stmt->fetchColumn();
return $last !== false && (time() - strtotime($last)) < $seconds;
}
/**
* 모집단 표준편차(σ). 측정값 고착(변화 없음) 판정에 사용.
*/
function stddev_pop(array $values): float {
$n = count($values);
if ($n === 0) {
return 0.0;
}
$mean = array_sum($values) / $n;
$sum = 0.0;
foreach ($values as $v) {
$d = (float)$v - $mean;
$sum += $d * $d;
}
return sqrt($sum / $n);
}
/**
* 센서별 최신 경보-상태 SMS 1건(경보/복귀/에스컬레이션 태그 중). 에스컬레이션 판정용.
*/
function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
$tags = [
SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
SMS_TAG_CONDENSATION, SMS_TAG_RECOVERY, SMS_TAG_ESCALATION,
];
$likes = [];
$params = [':sid' => $sensor_id];
foreach ($tags as $i => $t) {
$k = ":t{$i}";
$likes[] = "message LIKE {$k}";
$params[$k] = $t . '%';
}
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
$sql = "SELECT message, sent_at FROM sms_log
WHERE sensor_id = :sid AND status = 'success'
AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row ?: null;
}
/**
* 미복귀 임계 경보를 2차(야간) 수신자에게 에스컬레이션한다(에피소드당 1회). 각 센서의
* 최신 경보-상태가 '경보'이고 ESCALATE_AFTER_SEC 이상 지속(미복귀·미에스컬레이션)이면
* SMS_RECIPIENTS_TIER2 로 1회 통지. TIER2 가 비면 비활성(opt-in). cron_heartbeat 가 호출.
*/
function maybe_escalate_alerts(PDO $db): void {
if (empty(SMS_RECIPIENTS_TIER2) || ESCALATE_AFTER_SEC <= 0) {
return;
}
$alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION];
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll();
foreach ($sensors as $s) {
$sid = (int)$s['sensor_id'];
$row = latest_alert_state_row($db, $sid);
if ($row === null) {
continue;
}
$msg = (string)$row['message'];
// 최신이 복귀/이미 에스컬레이션이면 에피소드 종료/처리됨 → 스킵.
if (str_starts_with($msg, SMS_TAG_RECOVERY) || str_starts_with($msg, SMS_TAG_ESCALATION)) {
continue;
}
$isAlert = false;
foreach ($alertTags as $t) {
if (str_starts_with($msg, $t)) { $isAlert = true; break; }
}
if (!$isAlert || (time() - strtotime((string)$row['sent_at'])) < ESCALATE_AFTER_SEC) {
continue;
}
// 이미 에스컬레이션을 시도했으면(성공/실패 불문) 재발화 금지. latest_alert_state_row
// 는 success 만 보므로, 실패한 에스컬레이션 행이 보이지 않아 매 주기 재발송되는 것을
// 막는다(상태 무관 recent_tag_within 으로 가드 — 에피소드당 1회 보장).
if (recent_tag_within($db, $sid, SMS_TAG_ESCALATION, ESCALATE_AFTER_SEC)) {
continue;
}
$firstLine = strtok($msg, "\n");
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
$message = SMS_TAG_ESCALATION . "\n"
. "위치: {$loc}\n센서: {$name}\n"
. "{$firstLine} 미복귀 {$mins}분 경과\n"
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");
}
}
/**
* 측정값 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 온도·습도 표준편차가
* 둘 다 STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장을 의심해 1회 알린다(쿨다운).
* 하트비트(수신 끊김)로는 못 잡는 무성실패를 보완. cron_heartbeat 가 호출.
*/
function maybe_detect_stuck_sensors(PDO $db): void {
if (!STUCK_DETECT_ENABLED || STUCK_DETECT_SAMPLES < 3) {
return;
}
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll();
foreach ($sensors as $s) {
$sid = (int)$s['sensor_id'];
$stmt = $db->prepare("
SELECT temperature_c, humidity_percent
FROM sensor_metric
WHERE sensor_id = :sid
ORDER BY measured_at DESC
LIMIT {$n}
");
$stmt->execute([':sid' => $sid]);
$rows = $stmt->fetchAll();
if (count($rows) < $n) {
continue; // 표본 부족
}
$temps = array_values(array_filter(array_column($rows, 'temperature_c'), fn($v) => $v !== null));
$hums = array_values(array_filter(array_column($rows, 'humidity_percent'), fn($v) => $v !== null));
// 온도·습도 둘 다 충분한 표본이 완전 평탄해야 고착으로 본다(한쪽만 평탄은 정상 가능).
$tStuck = count($temps) >= $n && stddev_pop($temps) < STUCK_DETECT_EPSILON;
$hStuck = count($hums) >= $n && stddev_pop($hums) < STUCK_DETECT_EPSILON;
if (!($tStuck && $hStuck)) {
continue;
}
if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) {
continue; // 쿨다운
}
$name = $s['sensor_name'] ?: "센서{$sid}";
$loc = $s['device_location'] ?: '알 수 없음';
$tval = number_format((float)$rows[0]['temperature_c'], 2);
$hval = number_format((float)$rows[0]['humidity_percent'], 2);
$message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n센서: {$name}\n"
. "최근 {$n}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n"
. "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc];
sms_dispatch_to_recipients($db, $info, $message, "센서고착");
error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)");
}
}

View file

@ -1,2 +1,7 @@
<?php
echo "OK - " . date('Y-m-d H:i:s') . " - " . $_SERVER['HTTP_USER_AGENT'];
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
echo "OK - " . date('Y-m-d H:i:s') . " - " . htmlspecialchars($ua, ENT_QUOTES, 'UTF-8') . "\n";

14
raspberry_pi4/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# 운영·비밀값 (절대 커밋 금지)
config_local.py
# Python venv / 캐시
venv/
__pycache__/
*.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
wheelhouse/
debs/
# 런타임 로그
*.log

152
raspberry_pi4/README.md Normal file
View file

@ -0,0 +1,152 @@
# 누수감지 센서 모듈 — Raspberry Pi 4 Model B 전용
e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO 로 읽어,
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을
촬영해 업로드**하는 모니터링 모듈입니다.
> 원래 **Raspberry Pi Zero 2 W** 용으로 작성된 모듈을 **Pi 4 Model B 전용**으로 이식한
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다.
---
## 1. 구성 파일
| 파일 | 역할 |
|---|---|
| `leak_sensor.py` | 메인 — GPIO 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 |
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
| `requirements.txt` | Python 의존성(핀 고정) |
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
| `test_connection.py` | 서버 연결/서명 점검 |
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 |
---
## 2. 하드웨어 (Pi 4 Model B 기준)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ)
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔)
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI**
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요)
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장)
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
### 2.1 핀 배치 (기본 센서 1개)
| 신호 | BCM | 40핀 헤더 물리 핀 |
|---|---|---|
| 센서 입력 | GPIO17 | 11 |
| 3.3V | — | 1 |
| GND | — | 6 (또는 9/14/20/25/30/34/39) |
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다.
> (예: GPIO27=물리 13번, GPIO22=물리 15번)
### 2.2 전기 설계 (중요)
- `ACTIVE_LOW = False`: 건조 시 GPIO=LOW(0), 누수 시 GPIO=HIGH(1)
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요.
---
## 3. 서버 호환성 (반드시 확인)
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에
보고합니다.
> ⚠️ **현재 이 repo 의 `php/` 서버는 SHT30 온습도 전용으로 전환**되었습니다
> (`sql/migration_drop_leak.sql` 가 `leak_photo`/`leak_incident` 테이블과 `is_leak` 컬럼을
> 제거하고 `event_type` 을 `startup|periodic` 로 축소). 따라서 현재 repo 서버는 leak 이벤트와
> 사진 업로드를 **거부**합니다.
>
> 이 Pi 4 모듈은 **leak 를 받는 기존 서버**(기존 `raspi_leck_detecter` 엔드포인트)로 보고하도록
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 설정해야 합니다.
---
## 4. 설치
### 4.1 설정값 준비
```bash
cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID 채움
```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
### 4.2 온라인 설치 (인터넷 가능 환경)
```bash
chmod +x install.sh
./install.sh
```
### 4.3 폐쇄망(air-gapped) 오프라인 설치
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
# (타깃 Pi 4, 인터넷 X)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
```
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다.
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다.
---
## 5. 실행
```bash
source venv/bin/activate
python3 leak_sensor.py
```
`RPi.GPIO` 가 없는 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
### 연결 점검
```bash
source venv/bin/activate
python3 test_connection.py
```
---
## 6. 부팅 자동 실행 (systemd)
```bash
# 폴더를 /home/pi/raspberry_pi4 에 배치했다고 가정 (다르면 .service 의 경로 수정)
sudo cp systemd/leak-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable leak-sensor
sudo systemctl start leak-sensor
sudo systemctl status leak-sensor
journalctl -u leak-sensor -f
```
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다.
---
## 7. 보안 메모
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
커밋되는 파일에는 placeholder 만 존재합니다.
- 보고 본문은 `API_KEY + 정렬된 JSON` 의 SHA-256 서명으로 위변조를 방지합니다
(`leak_sensor.make_signature` ↔ 서버 검증 로직과 동일 포맷).
- ⚠️ 이 서명은 **레거시 누수 서버와의 호환을 위해 의도적으로** `SHA-256(API_KEY + JSON)` 방식을 유지합니다. HMAC-SHA256 이 길이연장 공격에 면역이라 더 안전하지만, 전환하려면 **서버 검증부를 동시에 변경**해야 합니다.
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.

82
raspberry_pi4/config.py Normal file
View file

@ -0,0 +1,82 @@
# =============================================================================
# config.py - 누수감지 시스템 v2 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
#
# 서버 URL / API_KEY / 기기 식별자 등 운영·비밀값은 config_local.py 에서 로드한다.
# config_local.py 는 .gitignore 로 커밋 제외 대상이다.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
# =============================================================================
# ─────────────────────────────────────────────
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일)
# GPIO17 = 40핀 헤더 물리 11번 핀
# ─────────────────────────────────────────────
SENSORS = [
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"},
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준)
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번
]
# 센서 활성 레벨 설정
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시:
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False
# 누수: GPIO = HIGH(1)
ACTIVE_LOW = False
# GPIO 내부 풀저항 설정
# 반드시 False (PUD_OFF) 유지!
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함.
# → 외부 4.7MΩ 풀다운 저항만 사용
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ─────────────────────────────────────────────
# 주기 설정
# ─────────────────────────────────────────────
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
# ─────────────────────────────────────────────
# 로그 설정
# ─────────────────────────────────────────────
LOG_FILE = "/var/log/leak_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3
# ─────────────────────────────────────────────
# 재시도 설정
# ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초)
HTTP_RETRY_COUNT = 3 # 재시도 횟수
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초)
# ─────────────────────────────────────────────
# 카메라 설정
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI →
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요).
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용.
# ─────────────────────────────────────────────
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀
PHOTO_WIDTH = 640 # 촬영 해상도 (가로)
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로)
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB)
# ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
# 명확한 오류로 즉시 중단한다.
# ─────────────────────────────────────────────
try:
from config_local import ( # noqa: F401 (leak_sensor.py 가 config 에서 재수입)
API_URL,
API_KEY,
PHOTO_UPLOAD_URL,
DEVICE_ID,
DEVICE_LOCATION,
)
except ImportError as e:
raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
) from e

View file

@ -0,0 +1,35 @@
# =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 누수감지 모듈)
#
# 사용법: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
# (.gitignore 로 제외되어 있음).
#
# ⚠️ 서버 호환성
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합).
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고,
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
# =============================================================================
# ─────────────────────────────────────────────
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
# ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
PHOTO_UPLOAD_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/photo_upload.php"
# ─────────────────────────────────────────────
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
# ─────────────────────────────────────────────
API_KEY = "CHANGE_ME"
# ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분)
# ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실"

107
raspberry_pi4/install.sh Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env bash
# =============================================================================
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
#
# 폐쇄망(air-gapped) 지원:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행.
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " 누수감지 센서 v2 (Pi 4) 설치"
echo "=========================================="
# 0) 보드 모델 안내 (강제 아님)
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}"
case "${MODEL}" in
*"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
esac
fi
# 오프라인 모드 판정
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
else
OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
fi
fi
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱
echo "[1/3] 시스템 패키지 설치..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}"
fi
# 2) Python venv + requests
echo "[2/3] Python 가상환경 설정..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
echo " venv 생성 완료"
else
echo " venv 이미 존재 — 건너뜀"
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
exit 1
fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
echo " 온라인 pip 설치 (인터넷 필요)"
pip install --upgrade pip
pip install -r requirements.txt
fi
echo " 패키지 설치 완료"
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록)
echo "[3/3] 로그 파일 생성..."
sudo mkdir -p /var/log
sudo touch /var/log/leak_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인 (서비스 사용자와 일치해야 파일 로깅 가능)"
fi
sudo chmod 640 /var/log/leak_sensor.log
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
echo " cp config_local.example.py config_local.py"
fi
echo ""
echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})"
echo "=========================================="
echo " 실행: source venv/bin/activate && python3 leak_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고"
echo ""

View file

@ -0,0 +1,531 @@
#!/usr/bin/env python3
# =============================================================================
# leak_sensor.py - e-con 5C-08 누수감지센서 모니터링 메인 스크립트 (v2, Pi 4 전용)
#
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
# - 원래 Raspberry Pi Zero 2 W 용이던 모듈을 Pi 4 Model B 전용으로 이식.
# - GPIO BCM 번호 체계·rpicam/libcamera 카메라 스택은 두 보드가 동일하므로
# 센서/촬영 로직은 그대로 유지. 차이는 배선/전원/이더넷/카메라 커넥터 문서뿐.
# (Pi 4 = 15핀 1.0mm CSI / Zero 2 W = 22핀 0.5mm CSI → 리본 비호환,
# Gigabit Ethernet, USB-C 5V/3A) → README.md 참고.
#
# [동작 흐름]
# 1. GPIO 초기화 → 센서 핀 입력 설정
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고
# 5. 복구(정상) 감지 시 복구 알림 POST
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태)
#
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다.
# =============================================================================
import time
import threading
import logging
import logging.handlers
import hashlib
import hmac
import platform
import sys
import subprocess
import os
import tempfile
import requests
try:
import RPi.GPIO as GPIO
SIMULATION = False
except (ImportError, RuntimeError):
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드
SIMULATION = True
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.")
from config import (
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
API_URL, API_KEY,
REPORT_INTERVAL, LEAK_REPORT_INTERVAL, DEBOUNCE_MS,
DEVICE_ID, DEVICE_LOCATION,
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
CAMERA_ENABLED, PHOTO_UPLOAD_URL,
PHOTO_WIDTH, PHOTO_HEIGHT, PHOTO_QUALITY,
)
# ─────────────────────────────────────────────
# 로거 설정
# ─────────────────────────────────────────────
def setup_logger():
logger = logging.getLogger("leak_sensor")
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# 파일 핸들러 (로테이션)
try:
fh = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
encoding="utf-8"
)
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
pass # 권한 없으면 콘솔만 사용
# 콘솔 핸들러
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt)
logger.addHandler(ch)
return logger
log = setup_logger()
# ─────────────────────────────────────────────
# 시뮬레이션 GPIO (테스트용)
# ─────────────────────────────────────────────
class SimGPIO:
BCM = "BCM"
IN = "IN"
PUD_UP = "PUD_UP"
PUD_DOWN = "PUD_DOWN"
PUD_OFF = "PUD_OFF"
FALLING = "FALLING"
RISING = "RISING"
BOTH = "BOTH"
_state = {}
@staticmethod
def setmode(mode): pass
@staticmethod
def setup(pin, mode, pull_up_down=None): SimGPIO._state[pin] = 1
@staticmethod
def input(pin): return SimGPIO._state.get(pin, 1)
@staticmethod
def add_event_detect(pin, edge, callback=None, bouncetime=0): pass
@staticmethod
def cleanup(): pass
if SIMULATION:
GPIO = SimGPIO()
# ─────────────────────────────────────────────
# 누수 상태 관리
# ─────────────────────────────────────────────
class SensorState:
def __init__(self):
self.lock = threading.Lock()
# 센서별 상태 딕셔너리 {sensor_id: bool}
self.leak_detected = {s["id"]: False for s in SENSORS}
self.last_report_time = {s["id"]: 0 for s in SENSORS}
def is_leaking(self, sensor_id):
with self.lock:
return self.leak_detected.get(sensor_id, False)
def any_leaking(self):
with self.lock:
return any(self.leak_detected.values())
def set_leak(self, sensor_id, value):
with self.lock:
changed = self.leak_detected.get(sensor_id) != value
self.leak_detected[sensor_id] = value
return changed
state = SensorState()
# ─────────────────────────────────────────────
# HTTP POST 유틸
# ─────────────────────────────────────────────
# [보안 메모 — raw-body HMAC 서명 (2번 모듈과 동일 스킴)]
# 서버(php/api/sensor_data.php + config.php::verify_signature_raw)는 "받은 본문
# 바이트 그대로"에 대해 X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY,
# msg=raw_body)) 를 검증한다. 따라서 "서명한 바이트"와 "실제 전송한 바이트"가
# 100% 동일해야 한다. requests 의 json= 직렬화에 맡기면 공백/순서/부동소수 포맷이
# 달라져 서명이 깨질 수 있으므로, 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤
# 그 바이트를 그대로 POST(data=body_bytes) 한다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기 — 서버가 거부한다.)
def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로."""
out = []
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == '\\':
out.append('\\\\')
elif o < 0x20:
out.append('\\u%04x' % o)
else:
out.append(ch)
return "".join(out)
def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
"""누수 이벤트 본문 JSON 문자열을 고정 필드 순서로 생성(서명 호환).
필드 순서(서버 파싱은 순서 무관이나, 서명은 바이트 동일성을 요구하므로 고정):
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
"""
return (
'{'
f'"device_id":"{_esc(DEVICE_ID)}",'
f'"device_location":"{_esc(DEVICE_LOCATION)}",'
f'"sensor_id":{int(sensor["id"])},'
f'"sensor_name":"{_esc(sensor["name"])}",'
f'"event_type":"{_esc(event_type)}",'
f'"is_leak":{1 if is_leak else 0},'
f'"timestamp":{int(timestamp)}'
'}'
)
def sign_raw(body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body 바이트))."""
return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()
def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool):
"""누수 이벤트를 raw-body HMAC 서명으로 POST.
body 문자열을 만들고 그 바이트에 서명한 뒤 data=body_bytes 로 그대로 전송한다.
want_response=False 면 성공 여부(bool), True 면 응답 JSON(dict) 또는 None 을 반환.
"""
body = leak_event_body(event_type, sensor, is_leak, int(time.time()))
body_bytes = body.encode("utf-8")
signature = sign_raw(body)
headers = {
"Content-Type": "application/json",
"X-Device-Id": DEVICE_ID,
"X-Signature": signature,
"User-Agent": "LeakSensor/2.0",
}
for attempt in range(1, HTTP_RETRY_COUNT + 1):
try:
resp = requests.post(
API_URL,
data=body_bytes,
timeout=HTTP_TIMEOUT,
headers=headers,
)
if resp.status_code == 200:
log.info(f"[POST OK] 센서{sensor['id']} event={event_type} leak={is_leak} → {resp.text[:80]}")
if not want_response:
return True
try:
return resp.json()
except ValueError:
log.warning("[POST] 응답 JSON 파싱 실패")
return None
else:
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT:
time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패. 센서{sensor['id']}")
return False if not want_response else None
def post_to_server(event_type: str, sensor: dict, is_leak: bool):
"""PHP API 서버에 센서 데이터 POST (성공 여부 bool 반환).
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
"""
return _post_event(event_type, sensor, is_leak, want_response=False)
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
"""post_to_server 와 동일하나 응답 JSON(dict) 또는 None 을 반환."""
return _post_event(event_type, sensor, is_leak, want_response=True)
# ─────────────────────────────────────────────
# 카메라 촬영 / 사진 업로드
# ─────────────────────────────────────────────
def capture_photo(output_path: str) -> bool:
"""
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영.
성공 시 True, 실패 시 False.
"""
for cmd in ["rpicam-still", "libcamera-still"]:
try:
result = subprocess.run(
[
cmd,
"--width", str(PHOTO_WIDTH),
"--height", str(PHOTO_HEIGHT),
"--quality", str(PHOTO_QUALITY),
"--nopreview",
"--timeout", "2000",
"-o", output_path,
],
capture_output=True, text=True, timeout=15
)
if result.returncode == 0 and os.path.exists(output_path):
file_size = os.path.getsize(output_path)
log.info(f"[CAMERA] 촬영 성공: {output_path} ({file_size} bytes, cmd={cmd})")
return True
else:
log.warning(f"[CAMERA] {cmd} 실패 (rc={result.returncode}): {result.stderr[:200]}")
except FileNotFoundError:
continue # 이 명령어가 없으면 다음 것 시도
except subprocess.TimeoutExpired:
log.warning(f"[CAMERA] {cmd} 타임아웃")
except Exception as e:
log.warning(f"[CAMERA] {cmd} 예외: {e}")
log.error("[CAMERA] 촬영 실패: rpicam-still, libcamera-still 모두 사용 불가")
return False
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
"""
촬영된 사진을 photo_upload.php에 multipart POST.
성공 시 True, 실패 시 False.
[인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (공백 없음)
서버 php/api/photo_upload.php 의 canonical 과 바이트 동일해야 한다.
"""
timestamp = int(time.time())
sensor_id = int(sensor["id"])
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음).
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다.
sensor_id_str = str(sensor_id)
log_id_str = str(log_id)
timestamp_str = str(timestamp)
canonical = f"{DEVICE_ID}|{sensor_id_str}|{log_id_str}|{timestamp_str}"
signature = hmac.new(API_KEY.encode("utf-8"), canonical.encode("utf-8"),
hashlib.sha256).hexdigest()
try:
with open(photo_path, "rb") as f:
files = {"photo": (os.path.basename(photo_path), f, "image/jpeg")}
form_data = {
"device_id": DEVICE_ID,
"sensor_id": sensor_id_str,
"log_id": log_id_str,
"timestamp": timestamp_str,
}
resp = requests.post(
PHOTO_UPLOAD_URL,
files=files,
data=form_data,
timeout=30,
headers={
"X-Device-Id": DEVICE_ID,
"X-Signature": signature,
"User-Agent": "LeakSensor/2.0",
}
)
if resp.status_code == 200:
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} → {resp.text[:80]}")
return True
else:
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
return False
except Exception as e:
log.error(f"[PHOTO UPLOAD ERR] {e}")
return False
def _handle_leak_with_photo(sensor: dict):
"""
누수 감지 시 처리 순서:
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송)
2. 카메라 촬영 (CAMERA_ENABLED 시)
3. photo_upload.php에 업로드
4. 임시파일 삭제
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료.
"""
# 1) leak_alert POST → log_id 수신
resp_data = post_to_server_with_response("leak_alert", sensor, True)
if resp_data is None:
log.error(f"[PHOTO] 센서{sensor['id']} leak_alert POST 실패 → 촬영 건너뜀")
return
log_id = resp_data.get("log_id")
if not log_id:
log.warning(f"[PHOTO] 센서{sensor['id']} 응답에 log_id 없음 → 촬영 건너뜀")
return
# 2) 카메라 촬영
if not CAMERA_ENABLED:
log.info(f"[PHOTO] 카메라 비활성화 → 촬영 건너뜀 (log_id={log_id})")
return
# /tmp에 임시 JPEG 파일 생성
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
os.close(fd)
try:
if not capture_photo(photo_path):
log.warning(f"[PHOTO] 촬영 실패 → 업로드 건너뜀 (log_id={log_id})")
return
# 3) 업로드
upload_photo(photo_path, log_id, sensor)
finally:
# 4) 임시파일 정리
try:
if os.path.exists(photo_path):
os.unlink(photo_path)
except OSError:
pass
# ─────────────────────────────────────────────
# GPIO 인터럽트 콜백
# ─────────────────────────────────────────────
def make_callback(sensor: dict):
def callback(channel):
gpio_val = GPIO.input(sensor["gpio"])
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수
if ACTIVE_LOW:
is_leak = (gpio_val == 0)
else:
is_leak = (gpio_val == 1)
changed = state.set_leak(sensor["id"], is_leak)
if changed:
if is_leak:
log.warning(f"[LEAK DETECTED] 센서{sensor['id']} ({sensor['name']}) !!!")
# leak_alert + 사진 촬영/업로드를 한 스레드에서 순차 실행
threading.Thread(
target=_handle_leak_with_photo,
args=(sensor,),
daemon=True
).start()
else:
log.info(f"[LEAK RECOVERED] 센서{sensor['id']} ({sensor['name']}) 정상 복구")
threading.Thread(
target=post_to_server,
args=("leak_recovery", sensor, False),
daemon=True
).start()
return callback
# ─────────────────────────────────────────────
# 주기 보고 스레드
# ─────────────────────────────────────────────
def periodic_reporter():
"""
정상 상태: REPORT_INTERVAL 주기로 보고
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리)
"""
log.info("[REPORTER] 주기 보고 스레드 시작")
last_sent = {s["id"]: 0 for s in SENSORS}
while True:
now = time.time()
for sensor in SENSORS:
sid = sensor["id"]
is_leak = state.is_leaking(sid)
interval = LEAK_REPORT_INTERVAL if is_leak else REPORT_INTERVAL
if now - last_sent[sid] >= interval:
post_to_server("periodic", sensor, is_leak)
last_sent[sid] = now
time.sleep(1)
# ─────────────────────────────────────────────
# GPIO 초기화
# ─────────────────────────────────────────────
def setup_gpio():
GPIO.setmode(GPIO.BCM)
# 외부 4.7MΩ 풀다운만 사용 → 내부 풀저항은 PUD_OFF 필수
# (내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 GPIO를 HIGH로 올리지 못함)
pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF
for sensor in SENSORS:
gpio = sensor["gpio"]
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
# 초기 상태 읽기
gpio_val = GPIO.input(gpio)
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
state.set_leak(sensor["id"], is_leak)
log.info(f"[INIT] 센서{sensor['id']} GPIO{gpio} → {'누수!' if is_leak else '정상'}")
# 엣지 감지 인터럽트 등록
GPIO.add_event_detect(
gpio,
GPIO.BOTH,
callback=make_callback(sensor),
bouncetime=DEBOUNCE_MS
)
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서")
# ─────────────────────────────────────────────
# 메인
# ─────────────────────────────────────────────
def main():
log.info("=" * 60)
log.info(f"누수감지 시스템 v2 (Pi 4) 시작 | 기기={DEVICE_ID} | 위치={DEVICE_LOCATION}")
log.info(f"플랫폼={platform.machine()} | API={API_URL}")
log.info(f"카메라={'활성' if CAMERA_ENABLED else '비활성'}")
log.info(f"센서={[s['name'] for s in SENSORS]}")
log.info(f"모드={'시뮬레이션' if SIMULATION else '실제 GPIO'}")
log.info("=" * 60)
try:
setup_gpio()
# 시작 시 현재 상태 즉시 보고
for sensor in SENSORS:
post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
# 주기 보고 스레드 시작
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
reporter_thread.start()
log.info("[MAIN] 모니터링 시작. Ctrl+C로 종료.")
# 메인 스레드는 살아있어야 인터럽트 작동
while True:
time.sleep(1)
except KeyboardInterrupt:
log.info("[MAIN] 사용자에 의해 종료됨.")
except Exception as e:
log.exception(f"[MAIN] 예외 발생: {e}")
raise
finally:
GPIO.cleanup()
log.info("[MAIN] GPIO 정리 완료.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,4 @@
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
# pip install --no-index --find-links=wheelhouse -r requirements.txt
requests==2.32.3

View file

@ -0,0 +1,45 @@
# =============================================================================
# /etc/systemd/system/leak-sensor.service
# Raspberry Pi 4 부팅 시 누수감지 모니터를 자동 실행하는 서비스
#
# 설치 방법:
# sudo cp systemd/leak-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable leak-sensor
# sudo systemctl start leak-sensor
# sudo systemctl status leak-sensor
#
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준.
# =============================================================================
[Unit]
Description=e-con 5C-08 Leak Sensor Monitor v2 (Pi 4, with Camera)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
SupplementaryGroups=video
WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=leak-sensor
# 환경 변수
Environment=PYTHONUNBUFFERED=1
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
PrivateTmp=true
# 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5
StartLimitInterval=600s
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행.
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유)."""
import hashlib
import hmac
import time
import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50)
print(" 서버 연결 테스트 (Pi 4)")
print("=" * 50)
print(f" API_BASE = {API_BASE}")
# ── 1단계: curl로 test.php 호출 ──
print("\n[1/3] curl로 test.php 연결 확인...")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-A", "LeakSensor/2.0",
"-X", "POST", f"{API_BASE}/test.php"],
capture_output=True, text=True, timeout=10
)
code = result.stdout.strip()
print(f" HTTP 응답코드: {code}")
if code == "200":
print(" → 성공! 서버 연결 정상")
elif code == "403":
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중")
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요")
else:
print(f" → 예상 외 응답: {code}")
except Exception as e:
print(f" → curl 실패: {e}")
# ── 2단계: curl로 sensor_data.php 호출 ──
print("\n[2/3] curl로 sensor_data.php 연결 확인...")
try:
result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}",
"-A", "LeakSensor/2.0",
"-X", "POST", API_URL,
"-H", "Content-Type: application/json",
"-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'],
capture_output=True, text=True, timeout=10
)
lines = result.stdout.strip().split("\n")
code = lines[-1] if lines else "?"
body = "\n".join(lines[:-1])
print(f" HTTP 응답코드: {code}")
print(f" 응답 내용: {body[:200]}")
except Exception as e:
print(f" → curl 실패: {e}")
# ── 3단계: Python requests로 서명 포함 호출 ──
print("\n[3/3] Python requests로 sensor_data.php 호출...")
try:
import requests
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다).
ts = int(time.time())
body = (
'{'
f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":1,'
f'"sensor_name":"테스트 센서",'
f'"event_type":"startup",'
f'"is_leak":0,'
f'"timestamp":{ts}'
'}'
)
body_bytes = body.encode("utf-8")
signature = hmac.new(API_KEY.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
resp = requests.post(
API_URL,
data=body_bytes,
timeout=10,
headers={
"Content-Type": "application/json",
"X-Device-Id": DEVICE_ID,
"X-Signature": signature,
"User-Agent": "LeakSensor/2.0",
}
)
print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200:
print(" → 성공! 모든 연결 정상")
elif resp.status_code == 403:
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
else:
print(f" → 응답코드: {resp.status_code}")
except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
except Exception as e:
print(f" → 실패: {e}")
print("\n" + "=" * 50)
print(" 테스트 완료")
print("=" * 50)

58
raspberry_pi4/vendor.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
#
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit,
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch
# 에서 받는 것이 가장 안전하다. 필요 시 환경변수로 타깃 조정:
# TARGET_PLATFORM (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_aarch64}"
TARGET_PYVER="${TARGET_PYVER:-311}"
TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
echo "[vendor] (1/2) Python wheel → wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
mkdir -p wheelhouse
python3 -m pip download -r requirements.txt -d wheelhouse \
--only-binary=:all: \
--platform "${TARGET_PLATFORM}" \
--python-version "${TARGET_PYVER}" \
--implementation cp \
--abi "${TARGET_ABI}" \
|| { echo "[ERROR] 타깃 wheel 다운로드 실패 (TARGET_PLATFORM/TARGET_PYVER 확인)"; exit 1; }
python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \
|| echo "[WARN] sdist 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs
CAM="${CAM:-}"
if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv ${CAM}"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
MISS=0
for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
MISS=1
fi
done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

14
raspberry_pi4_sht30/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# 운영·비밀값 (절대 커밋 금지)
config_local.py
# Python venv / 캐시
venv/
__pycache__/
*.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
wheelhouse/
debs/
# 런타임 로그
*.log

View file

@ -0,0 +1,156 @@
# 온습도(SHT30) 센서 모듈 — Raspberry Pi 4 Model B 전용 (2번 모듈)
**Sensirion SHT30**(한진데이터 P4422-3, I2C) 온습도 센서를 Raspberry Pi 4 Model B 의
I2C 버스로 읽어, 주기적으로 cafe24 서버(`php/api/sensor_data.php`)에 보고하는 모듈입니다.
서버가 측정값을 저장하고, **고온/저온/고습/저습 임계 판정과 SMS, 대시보드 표시**를 모두 수행합니다.
> 이 폴더는 STM32F407 펌웨어(`sht30_fw`)의 동작을 **Raspberry Pi 4 + Python 으로 이식**한
> "2번 모듈"입니다. wire 포맷(본문/서명)·CRC·변환식은 펌웨어와 **바이트 단위로 동일**합니다.
> "1번 모듈"(누수+카메라)은 `../raspberry_pi4/` 폴더입니다. 전체 구성은 루트의
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md)를 보세요.
---
## 1. 구성 파일
| 파일 | 역할 |
|---|---|
| `sht30_sensor.py` | 메인 — 주기 측정 → 본문 생성/서명 → POST(재시도), startup→periodic |
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 물리값 변환 (smbus2, 시뮬레이션 폴백) |
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 바이트 동일) |
| `config.py` | 비밀 아닌 기본 설정(센서 id/주기/범위/ I2C). `config_local` 에서 비밀값 로드 |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
| `requirements.txt` | Python 의존성(`requests`, `smbus2`, 핀 고정) |
| `install.sh` | Pi 4 설치(폐쇄망 오프라인 + 온라인 폴백, I2C 활성화 포함) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
| `test_connection.py` | 서버 연결/서명 POST 점검 |
| `test_sensor.py` | CRC/포맷 자가점검 + 측정 1회(하드웨어 없이도 포맷 점검 통과) |
| `systemd/sht30-sensor.service` | 부팅 자동 실행 서비스 유닛 |
---
## 2. 하드웨어 (Pi 4 Model B 기준)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- **SHT30** 온습도 센서 모듈 (I2C, 한진데이터 P4422-3)
- I2C 풀업 저항 **4.7kΩ × 2** (모듈에 내장된 경우 생략)
- 전원: **USB-C 5V/3A**, 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
### 2.1 핀 배치 (I2C 버스 1)
| 신호 | Pi 4 핀 | 비고 |
|---|---|---|
| SHT30 VCC | 3.3V (물리 1) | **5V 금지** |
| SHT30 GND | GND (물리 6/9/14/…) | |
| SHT30 SDA | GPIO2 / SDA1 (물리 3) | + 4.7kΩ 풀업 → 3.3V |
| SHT30 SCL | GPIO3 / SCL1 (물리 5) | + 4.7kΩ 풀업 → 3.3V |
| SHT30 ADDR | GND → 주소 **0x44** | VDD 면 0x45 (`config.py` `I2C_ADDR`) |
---
## 3. 서버 호환성 (반드시 확인)
이 모듈은 **현재 repo 의 `php/` 서버(=cafe24 SHT30 대시보드 서버)**로 보고합니다.
서버는 **raw-body HMAC 서명**을 요구합니다.
```
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=요청본문 바이트 ) )
```
- `config_local.py` 의 `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **정확히 동일**해야 합니다.
- 서버는 `timestamp` 신선도(±600초)로 재전송을 막으므로 Pi 시각이 **NTP 로 동기**되어 있어야 합니다(Raspberry Pi OS 기본 활성).
- `sensor_id=2` 로 보고하며, 서버 대시보드(`dashboard.php`)는 기본 차트로 sensor 2 를 표시합니다.
- 임계 경보(고온/저온/고습/저습)·SMS·복구 판정은 **모두 서버**가 수행합니다. 이 모듈은 측정값만 보고합니다.
> 서버 측 스키마/배포는 [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql),
> [`../docs/INSTALL_PI_SERVER.md`](../docs/INSTALL_PI_SERVER.md),
> [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md) 참고.
---
## 4. 설치
### 4.1 설정값 준비
```bash
cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION 채움
```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
### 4.2 온라인 설치 (인터넷 가능 환경)
```bash
chmod +x install.sh
./install.sh
sudo reboot # I2C 를 새로 켠 경우 1회 재부팅
```
### 4.3 폐쇄망(air-gapped) 오프라인 설치
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
# (타깃 Pi 4, 인터넷 X)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
```
> `smbus2` 는 순수 파이썬(arch 무관)이라 x86 빌드 머신에서 받아도 됩니다.
> `requests` 등 다른 wheel/deb 는 **aarch64** 용을 받아야 합니다(타깃 arch 에서 `vendor.sh` 권장).
---
## 5. 실행 / 점검
```bash
# 센서가 보이는지 (0x44)
i2cdetect -y 1
# 포맷/CRC 자가점검 + 측정 1회 (하드웨어 없으면 시뮬레이션)
source venv/bin/activate
python3 test_sensor.py
# 서버 연결/서명 점검
python3 test_connection.py
# 실제 실행
python3 sht30_sensor.py
```
`smbus2` 미설치 또는 I2C 미활성 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다.
---
## 6. 부팅 자동 실행 (systemd)
```bash
# 폴더를 /home/pi/raspberry_pi4_sht30 에 배치했다고 가정 (다르면 .service 의 경로 수정)
sudo cp systemd/sht30-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable sht30-sensor
sudo systemctl start sht30-sensor
sudo systemctl status sht30-sensor
journalctl -u sht30-sensor -f
```
`SupplementaryGroups=i2c` 가 `/dev/i2c-1` 접근에 필요합니다.
---
## 7. 보안 메모
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
커밋되는 파일에는 placeholder 만 존재합니다.
- 보고 본문은 `HMAC-SHA256(API_KEY, raw_body)` 서명(`X-Signature` 헤더)으로 위변조를 방지합니다
(길이확장 공격에 면역). 레거시 body-field sha256 폴백은 서버에서 기본 비활성입니다.
- 서명하는 바이트와 전송하는 바이트는 **항상 동일**합니다(`wire.py` 가 본문을 직접 만들어
그 바이트에 서명 후 raw POST). 펌웨어 `sig.c` / `reference.py` / 서버 `verify_signature_raw`
와 바이트 단위로 일치합니다.
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.

View file

@ -0,0 +1,82 @@
# =============================================================================
# config.py - 온습도(SHT30) 모니터링 모듈 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값
#
# 서버 URL / API_KEY / 기기 식별자(DEVICE_ID/LOCATION) 등 운영·비밀값은
# config_local.py 에서 로드한다. config_local.py 는 .gitignore 로 커밋 제외.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
#
# 이 모듈은 "2번 모듈"(온습도)이다. 서버(php/) 대시보드는 sensor_id=2 를 기본
# 온습도 센서로 표시하므로 SENSOR_ID 기본값은 2 로 둔다.
# =============================================================================
# ─────────────────────────────────────────────
# 센서 식별 (서버 대시보드 표시용; 비밀 아님)
# SENSOR_ID 는 서버 sensor_metric / sensor_status 의 키. 대시보드 기본 차트=2.
# ─────────────────────────────────────────────
SENSOR_ID = 2
SENSOR_NAME = "2번 센서 (SHT30)"
# ─────────────────────────────────────────────
# I2C 버스 설정 (Pi 4 40핀 헤더)
# SDA = GPIO2 (물리 3번), SCL = GPIO3 (물리 5번) → I2C 버스 1
# ADDR 핀: GND=0x44, VDD=0x45
# ─────────────────────────────────────────────
I2C_BUS = 1
I2C_ADDR = 0x44
# 강제 시뮬레이션. True 면 하드웨어 없이 더미 측정값으로 동작(서버 연동 점검용).
# False 라도 smbus2 미설치/ I2C 미활성이면 드라이버가 자동 시뮬레이션으로 폴백.
FORCE_SIMULATION = False
# ─────────────────────────────────────────────
# 측정/보고 주기 (firmware app_config.h 와 동일 기본값)
# ─────────────────────────────────────────────
REPORT_INTERVAL = 300 # 정상 측정·보고 간격 (초) = 5분
MEASURE_RETRY_DELAY = 5 # 측정 실패 후 재시도 대기 (초)
# ─────────────────────────────────────────────
# 온습도 물리 plausibility 범위 (firmware classify_metric_status 와 동일)
# 이 범위를 벗어나면 metric_status="out_of_range" 로 보고한다.
# ※ 운영 임계(고온/저온/고습/저습) 판정·SMS 는 모두 서버(config.php METRIC_*)가
# 수행한다. 이 모듈은 측정값만 충실히 보고한다.
# ─────────────────────────────────────────────
TEMP_LOW_C = -40.0
TEMP_HIGH_C = 125.0
RH_LOW = 0.0
RH_HIGH = 100.0
# 모듈 식별 버전 (서버 app_version 필드; 서버는 검증하지 않고 기록만 함)
APP_VERSION = "v2606-sht30-rpi4"
# ─────────────────────────────────────────────
# 로그 설정
# ─────────────────────────────────────────────
LOG_FILE = "/var/log/sht30_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3
# ─────────────────────────────────────────────
# HTTP 재시도 설정 (firmware APP_HTTP_* 와 동일 기본값)
# ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # 단일 요청 타임아웃 (초)
HTTP_RETRY_COUNT = 3 # 재시도 횟수
HTTP_RETRY_DELAY = 5 # 재시도 간 대기 (초)
# ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
# 명확한 오류로 즉시 중단한다.
# ─────────────────────────────────────────────
try:
from config_local import ( # noqa: F401 (sht30_sensor.py 가 config 에서 재수입)
API_URL,
API_KEY,
DEVICE_ID,
DEVICE_LOCATION,
)
except ImportError as e:
raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
) from e

View file

@ -0,0 +1,31 @@
# =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 온습도 SHT30 모듈 / 2번 모듈)
#
# 사용법: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
# (.gitignore 로 제외되어 있음).
#
# ⚠️ 서버 호환성
# 이 모듈(2번)은 SHT30 온습도 측정을 cafe24 서버 php/api/sensor_data.php 로
# 보고한다. 서버는 raw-body 서명을 요구한다:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버 임계 판정(고온/저온/고습/저습)·SMS·대시보드는 모두 서버가 수행한다.
# =============================================================================
# ─────────────────────────────────────────────
# 서버 API 엔드포인트 (SHT30 수신 서버 = cafe24 php/)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
# ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
# ─────────────────────────────────────────────
# 공유 비밀키 — 서버 config.local.php 의 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
# ─────────────────────────────────────────────
API_KEY = "CHANGE_ME"
# ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분). sensor_id/sensor_name 은 config.py 기본값(2)을 사용.
# ─────────────────────────────────────────────
DEVICE_ID = "rpi4-sht30-01"
DEVICE_LOCATION = "서버실"

View file

@ -0,0 +1,140 @@
#!/usr/bin/env bash
# =============================================================================
# 온습도(SHT30) 모듈 v2 (Raspberry Pi 4 Model B) 설치 스크립트 — 2번 모듈
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
#
# 폐쇄망(air-gapped) 지원:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙). 사전
# 다운로드는 vendor.sh 로 빌드 머신에서 수행.
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
#
# 이 스크립트는 I2C 도 활성화한다(dtparam=i2c_arm=on + i2c-dev 로드 + i2c 그룹).
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}"
echo "=========================================="
echo " 온습도(SHT30) 모듈 v2 (Pi 4) 설치 — 2번 모듈"
echo "=========================================="
# 0) 보드 모델 안내 (강제 아님)
if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}"
case "${MODEL}" in
*"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;;
esac
fi
# 오프라인 모드 판정
OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
else
OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
fi
fi
# 1) 시스템 패키지: requests + venv + i2c-tools
echo "[1/4] 시스템 패키지 설치..."
if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update
sudo apt-get install -y python3-requests python3-venv i2c-tools
fi
# 2) I2C 활성화 (dtparam + 모듈 로드 + 그룹)
echo "[2/4] I2C 활성화..."
# 부트 설정 경로: Bookworm=/boot/firmware/config.txt, 구버전=/boot/config.txt
BOOT_CFG=""
for c in /boot/firmware/config.txt /boot/config.txt; do
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
done
if [ -n "${BOOT_CFG}" ]; then
if ! grep -q "^dtparam=i2c_arm=on" "${BOOT_CFG}"; then
echo " ${BOOT_CFG} 에 dtparam=i2c_arm=on 추가 (재부팅 후 적용)"
echo "dtparam=i2c_arm=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
NEED_REBOOT=1
else
echo " dtparam=i2c_arm=on 이미 설정됨"
fi
else
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 수동 I2C 활성화 필요"
fi
# i2c-dev 모듈을 즉시 로드 + 부팅 시 자동 로드 등록
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev 즉시 로드 실패 (재부팅 후 적용될 수 있음)"
if [ -d /etc/modules-load.d ] && ! grep -qrs "i2c-dev" /etc/modules-load.d/ 2>/dev/null; then
echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null
fi
# 서비스 사용자를 i2c 그룹에 추가 (/dev/i2c-1 접근 권한)
if getent group i2c >/dev/null 2>&1; then
sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod 실패 — SERVICE_USER=${SERVICE_USER} 확인"
fi
# 3) Python venv + requests + smbus2
echo "[3/4] Python 가상환경 설정..."
if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv
echo " venv 생성 완료"
else
echo " venv 이미 존재 — 건너뜀"
fi
# shellcheck disable=SC1091
source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2
exit 1
fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
pip install --no-index --find-links=wheelhouse -r requirements.txt
else
echo " 온라인 pip 설치 (인터넷 필요)"
pip install --upgrade pip
pip install -r requirements.txt
fi
echo " 패키지 설치 완료"
# 4) 로그 파일 (최소 권한: 소유자만 쓰기)
echo "[4/4] 로그 파일 생성..."
sudo mkdir -p /var/log
sudo touch /var/log/sht30_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인"
fi
sudo chmod 640 /var/log/sht30_sensor.log
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then
echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:"
echo " cp config_local.example.py config_local.py"
fi
echo ""
echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})"
echo "=========================================="
if [ "${NEED_REBOOT:-0}" = "1" ]; then
echo " ⚠️ I2C 를 방금 활성화했습니다 → 'sudo reboot' 후 센서가 인식됩니다."
fi
echo " 센서 확인: i2cdetect -y 1 (0x44 또는 0x45 가 보여야 함)"
echo " 실행: source venv/bin/activate && python3 sht30_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고"
echo ""

View file

@ -0,0 +1,8 @@
# Pi 4 온습도(SHT30) 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
# pip install --no-index --find-links=wheelhouse -r requirements.txt
#
# smbus2 는 순수 파이썬(arch 무관 py3-none-any wheel)이라 x86 빌드 머신에서 받아도
# Pi 4(aarch64)에서 그대로 동작한다. requests 도 함께 핀 고정.
requests==2.32.3
smbus2==0.5.0

View file

@ -0,0 +1,139 @@
# =============================================================================
# sht30_driver.py - SHT30 I2C 측정 드라이버 (Raspberry Pi 4, I2C 버스 1)
#
# [이식 출처]
# firmware/common/sht30.c (0x2C06 명령 -> ~20ms 대기 -> 6바이트 read)
# firmware/common/sht30_convert.c (CRC-8 검증 + 물리값 변환)
# firmware/test/host/reference.py (sht30_crc8 / sht30_parse 파이썬 레퍼런스)
#
# [동작]
# 1) 측정 명령 0x2C 0x06 전송 (고반복성, clock stretching 비활성)
# 2) ~20ms 대기 (고반복성 측정 최대 15ms + 마진)
# 3) 6바이트 read: T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc
# 4) CRC-8(poly 0x31, init 0xFF) 검증 후 변환:
# temp_c = -45 + 175 * raw_t / 65535
# rh = 100 * raw_rh / 65535
#
# [I2C 접근] smbus2(순수 파이썬, arch 무관 wheel) 의 i2c_msg 로 write/read 를
# 명시적으로 분리 전송한다. read_i2c_block_data 는 레지스터 바이트를 먼저
# 쓰므로 SHT30 측정 프레임을 깨뜨린다 → i2c_msg.read 사용.
#
# [시뮬레이션] smbus2 미설치 또는 /dev/i2c-* 부재(개발 PC)면 SIMULATION=True 로
# 전환되어 그럴듯한 더미 측정값을 돌려준다(서버 연동/포맷 점검용).
# =============================================================================
from __future__ import annotations
import time
# ── 상수 (firmware/common/sht30.c, app_config.h 와 동일) ─────────────────────
SHT30_CMD = [0x2C, 0x06] # high-repeatability, clock stretching off
MEAS_DELAY_SEC = 0.02 # 측정 완료 대기 (15ms + 마진)
FRAME_LEN = 6
try:
from smbus2 import SMBus, i2c_msg
SMBUS_AVAILABLE = True
except ImportError:
SMBUS_AVAILABLE = False
# ── CRC-8 (sht30_convert.c::sht30_crc8 와 동일) ──────────────────────────────
def crc8(data: bytes) -> int:
"""SHT3x CRC-8: poly 0x31, init 0xFF, no reflection, no final xor."""
crc = 0xFF
for b in data:
crc ^= b
for _ in range(8):
crc = ((crc << 1) ^ 0x31) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF
return crc
# ── 프레임 파싱 (sht30_convert.c::sht30_parse 와 동일) ───────────────────────
def parse_frame(frame: bytes):
"""6바이트 측정 프레임 검증 + 변환 → (temp_c, rh).
CRC 불일치 시 ValueError 를 던진다(호출측이 재시도/스킵).
"""
if len(frame) != FRAME_LEN:
raise ValueError(f"프레임 길이 오류: {len(frame)} (기대 {FRAME_LEN})")
if crc8(frame[0:2]) != frame[2]:
raise ValueError("온도 CRC 불일치")
if crc8(frame[3:5]) != frame[5]:
raise ValueError("습도 CRC 불일치")
raw_t = (frame[0] << 8) | frame[1]
raw_rh = (frame[3] << 8) | frame[4]
temp_c = -45.0 + 175.0 * (raw_t / 65535.0)
rh = 100.0 * (raw_rh / 65535.0)
return temp_c, rh
class SHT30:
"""SHT30 단발 측정 드라이버.
bus_num: I2C 버스 번호 (Pi 4 40핀 헤더 = 1)
addr: 7-bit I2C 주소 (ADDR=GND 면 0x44, ADDR=VDD 면 0x45)
simulate: True 면 하드웨어 없이 더미값 반환(강제 시뮬레이션)
"""
def __init__(self, bus_num: int = 1, addr: int = 0x44, simulate: bool = False):
self.bus_num = bus_num
self.addr = addr
self.simulate = simulate or not SMBUS_AVAILABLE
self._bus = None
self._sim_tick = 0
def open(self):
if self.simulate:
return
# /dev/i2c-<bus_num> 가 없으면(예: i2c 미활성) 명확한 오류로 안내한다.
try:
self._bus = SMBus(self.bus_num)
except (FileNotFoundError, PermissionError, OSError) as e:
raise RuntimeError(
f"I2C 버스 {self.bus_num} 열기 실패: {e}. "
f"`sudo raspi-config` 로 I2C 활성화 및 사용자 i2c 그룹 확인."
) from e
def close(self):
if self._bus is not None:
try:
self._bus.close()
finally:
self._bus = None
def measure(self):
"""단발 측정 → (temp_c, rh). 실패 시 예외(통신/CRC).
시뮬레이션 모드에서는 24~26℃, 48~54% 부근의 더미값을 반환한다.
"""
if self.simulate:
self._sim_tick += 1
temp = 24.0 + (self._sim_tick % 5) * 0.5 # 24.0~26.0
rh = 48.0 + (self._sim_tick % 7) * 1.0 # 48.0~54.0
return temp, rh
if self._bus is None:
self.open()
# 1) 측정 명령 전송 (write)
write = i2c_msg.write(self.addr, SHT30_CMD)
self._bus.i2c_rdwr(write)
# 2) 측정 완료 대기 (clock stretching 비활성 → 호스트가 명시적 대기)
time.sleep(MEAS_DELAY_SEC)
# 3) 6바이트 read
read = i2c_msg.read(self.addr, FRAME_LEN)
self._bus.i2c_rdwr(read)
frame = bytes(read)
# 4) CRC 검증 + 변환
return parse_frame(frame)
def __enter__(self):
self.open()
return self
def __exit__(self, *exc):
self.close()
return False

View file

@ -0,0 +1,246 @@
#!/usr/bin/env python3
# =============================================================================
# sht30_sensor.py - SHT30 온습도 모니터링 메인 (2번 모듈, Raspberry Pi 4 전용)
#
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더)
# - SHT30(Sensirion, 한진데이터 P4422-3) I2C 온습도 센서를 I2C 버스 1에 연결.
# - SDA=GPIO2(물리 3), SCL=GPIO3(물리 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ 풀업.
#
# [이식 출처] STM32 펌웨어 sht30_fw 의 동작을 Raspberry Pi 4 + Python 으로 이식.
# firmware/board_sht30/app_sht30.c (측정 루프 / startup→periodic / 범위 분류)
# firmware/common/reporter.c (본문 생성 → 서명 → POST → 재시도)
# firmware/common/sht30*.c (I2C 측정 / CRC / 변환) → sht30_driver.py
# firmware/test/host/reference.py (wire 포맷) → wire.py
#
# [동작 흐름]
# 1. SHT30 드라이버 open (실패/미가용 시 시뮬레이션 폴백)
# 2. 주기(REPORT_INTERVAL)마다 측정 → 서버에 POST
# 3. "최초 성공" 측정만 event_type="startup", 이후 "periodic"
# 4. 측정 실패(통신/CRC)는 크래시하지 않고 로그 후 짧게 쉬고 재시도
# 5. 물리 범위를 벗어나면 metric_status="out_of_range" (서버가 운영 임계 재판정)
#
# [인증] 서버는 raw-body HMAC 서명을 요구한다(레거시 body-sig 비활성).
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=전송 본문 바이트))
# → 서명한 바이트와 전송 바이트가 동일하도록 본문을 직접 만들어 raw POST 한다.
#
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만, 서버 URL/API_KEY/기기 식별자는
# config_local.py(미커밋)에서 로드한다. 없으면 기동 시 fail-closed 로 즉시 중단.
# =============================================================================
import time
import math
import logging
import logging.handlers
import signal
import sys
import requests
from config import (
SENSOR_ID, SENSOR_NAME,
I2C_BUS, I2C_ADDR, FORCE_SIMULATION,
API_URL, API_KEY,
DEVICE_ID, DEVICE_LOCATION,
REPORT_INTERVAL, MEASURE_RETRY_DELAY,
TEMP_LOW_C, TEMP_HIGH_C, RH_LOW, RH_HIGH,
APP_VERSION,
LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT,
HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY,
)
import wire
from sht30_driver import SHT30, SMBUS_AVAILABLE
# ─────────────────────────────────────────────
# 로거 설정
# ─────────────────────────────────────────────
def setup_logger():
logger = logging.getLogger("sht30_sensor")
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
try:
fh = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
encoding="utf-8"
)
fh.setFormatter(fmt)
logger.addHandler(fh)
except PermissionError:
pass # 권한 없으면 콘솔만 사용
ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt)
logger.addHandler(ch)
return logger
log = setup_logger()
# ─────────────────────────────────────────────
# 종료 시그널 처리 (systemd stop = SIGTERM 깔끔 종료)
# ─────────────────────────────────────────────
class StopFlag:
def __init__(self):
self.stop = False
def request(self, signum, frame):
self.stop = True
log.info(f"[SIGNAL] {signal.Signals(signum).name} 수신 → 종료 준비")
stop_flag = StopFlag()
# ─────────────────────────────────────────────
# 온습도 물리 범위 분류 (firmware classify_metric_status 와 동일)
# 서버는 이 값을 신뢰하지 않고 운영 임계로 재판정하므로, 여기서는 센서
# 물리 plausibility(범위/ NaN·Inf)만 본다.
# ─────────────────────────────────────────────
def classify_metric_status(temp_c: float, rh: float) -> str:
if not (math.isfinite(temp_c) and math.isfinite(rh)):
return "out_of_range"
if temp_c < TEMP_LOW_C or temp_c > TEMP_HIGH_C:
return "out_of_range"
if rh < RH_LOW or rh > RH_HIGH:
return "out_of_range"
return "normal"
# ─────────────────────────────────────────────
# 서버 보고 (raw-body + X-Signature HMAC)
# ─────────────────────────────────────────────
def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) -> bool:
"""SHT30 측정값을 서버에 POST. 성공(HTTP 200) 시 True.
본문을 직접 만들어 그 바이트에 서명하고 그 바이트를 그대로 전송한다
(서명한 바이트 == 전송 바이트 보장 → 서버 raw-body 검증과 일치).
"""
timestamp = int(time.time())
body = wire.sht30_event_body(
device_id=DEVICE_ID,
device_location=DEVICE_LOCATION,
sensor_id=SENSOR_ID,
sensor_name=SENSOR_NAME,
event_type=event_type,
timestamp=timestamp,
temperature_c=temp_c,
humidity_percent=rh,
metric_status=metric_status,
app_version=APP_VERSION,
)
body_bytes = body.encode("utf-8")
signature = wire.sign_raw(API_KEY, body)
headers = {
"Content-Type": "application/json",
"X-Signature": signature,
"X-Device-Id": DEVICE_ID,
"User-Agent": "SHT30Sensor/2.0",
}
for attempt in range(1, HTTP_RETRY_COUNT + 1):
try:
# data=body_bytes 로 raw 본문을 그대로 전송(json= 재직렬화 금지).
resp = requests.post(
API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT
)
if resp.status_code == 200:
log.info(f"[POST OK] {event_type} T={temp_c:.2f}C RH={rh:.2f}% "
f"status={metric_status} → {resp.text[:100]}")
return True
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: "
f"{resp.text[:160]}")
except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
except requests.exceptions.RequestException as e:
log.warning(f"[POST ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패 (event={event_type})")
return False
# ─────────────────────────────────────────────
# 측정 루프 (firmware sht30_measure_task 대응)
# ─────────────────────────────────────────────
def measure_loop(sensor: SHT30):
# 최초 성공 측정만 startup, 이후 periodic (서버 sensor_log 1회 startup 기록)
first_success = True
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")
while not stop_flag.stop:
try:
temp_c, rh = sensor.measure()
except (OSError, ValueError, RuntimeError) as e:
# 측정 실패(통신/CRC): 크래시 금지. 로그 후 짧게 쉬고 재시도.
log.error(f"[MEASURE FAIL] {e} → {MEASURE_RETRY_DELAY}s 후 재시도")
_interruptible_sleep(MEASURE_RETRY_DELAY)
continue
metric_status = classify_metric_status(temp_c, rh)
event_type = "startup" if first_success else "periodic"
log.info(f"[MEASURE] {event_type}: T={temp_c:.2f}C RH={rh:.2f}% status={metric_status}")
ok = post_reading(event_type, temp_c, rh, metric_status)
if ok and first_success:
# 최초 성공 보고 후에만 startup→periodic 전환(최초 보고가 반드시 startup)
first_success = False
# 정상 보고 주기 대기 (종료 시그널이 오면 즉시 깸)
_interruptible_sleep(REPORT_INTERVAL)
def _interruptible_sleep(seconds: float):
"""1초 단위로 나눠 자며 종료 시그널에 빠르게 반응한다."""
end = time.time() + seconds
while not stop_flag.stop and time.time() < end:
time.sleep(min(1.0, end - time.time()))
# ─────────────────────────────────────────────
# 메인
# ─────────────────────────────────────────────
def main():
signal.signal(signal.SIGTERM, stop_flag.request)
signal.signal(signal.SIGINT, stop_flag.request)
simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE
log.info("=" * 60)
log.info(f"온습도(SHT30) 모니터링 v2 (Pi 4, 2번 모듈) 시작")
log.info(f"기기={DEVICE_ID} | 위치={DEVICE_LOCATION} | 센서={SENSOR_NAME}(id={SENSOR_ID})")
log.info(f"API={API_URL}")
log.info(f"모드={'시뮬레이션' if simulate else f'실제 I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}")
if simulate and not FORCE_SIMULATION:
log.warning("smbus2 미설치/ I2C 미가용 → 시뮬레이션 모드. 실측은 install.sh 후 재실행.")
log.info("=" * 60)
sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate)
try:
sensor.open()
except RuntimeError as e:
log.error(f"[INIT] {e}")
sys.exit(1)
try:
measure_loop(sensor)
except Exception as e: # noqa: BLE001 — 최상위 안전망(로그 후 종료)
log.exception(f"[MAIN] 예외 발생: {e}")
raise
finally:
sensor.close()
log.info("[MAIN] 종료 완료.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,46 @@
# =============================================================================
# /etc/systemd/system/sht30-sensor.service
# Raspberry Pi 4 부팅 시 온습도(SHT30) 모니터를 자동 실행하는 서비스 (2번 모듈)
#
# 설치 방법:
# sudo cp systemd/sht30-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable sht30-sensor
# sudo systemctl start sht30-sensor
# sudo systemctl status sht30-sensor
#
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
# 아래는 /home/pi/raspberry_pi4_sht30 에 배치한 경우 기준.
# =============================================================================
[Unit]
Description=SHT30 Temp/Humidity Sensor Monitor v2 (Pi 4, Module 2)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
# /dev/i2c-1 접근에 i2c 그룹 필요
SupplementaryGroups=i2c
WorkingDirectory=/home/pi/raspberry_pi4_sht30
ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sht30-sensor
# 환경 변수
Environment=PYTHONUNBUFFERED=1
# 프로세스 전용 /tmp 격리
PrivateTmp=true
# 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5
StartLimitInterval=600s
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""서버 연결 테스트 — Pi 4 SHT30(2번) 모듈에서 실행.
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
"""
import time
import subprocess
import requests
from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
SENSOR_ID, SENSOR_NAME, APP_VERSION,
)
import wire
API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50)
print(" 서버 연결 테스트 (Pi 4 SHT30 / 2번 모듈)")
print("=" * 50)
print(f" API_URL = {API_URL}")
# ── 1단계: curl 로 엔드포인트 도달 확인 (서명 없음 → 403 인증실패가 정상) ──
print("\n[1/2] curl 로 sensor_data.php 도달 확인 (서명 없이 → 인증실패 403 이 정상)...")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"-A", "SHT30Sensor/2.0", "-X", "POST", API_URL,
"-H", "Content-Type: application/json", "-d", "{}"],
capture_output=True, text=True, timeout=10
)
code = result.stdout.strip()
print(f" HTTP 응답코드: {code}")
if code in ("400", "403"):
print(" → 엔드포인트 도달 OK (서명/필드 없이 거부됨 — 정상)")
elif code == "200":
print(" → 200? 서버가 서명 없이 수락 — 서버 설정 점검 필요")
elif code == "404":
print(" → 404: API_URL 경로 확인 필요")
else:
print(f" → 응답: {code} (호스팅/방화벽/.htaccess 점검)")
except Exception as e:
print(f" → curl 실패: {e}")
# ── 2단계: wire.py 로 서명한 실제 측정 POST (startup, 더미값) ──
print("\n[2/2] 서명된 측정 POST (startup, 더미 24.00C/48.00%)...")
try:
ts = int(time.time())
body = wire.sht30_event_body(
DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME,
"startup", ts, 24.0, 48.0, "normal", APP_VERSION,
)
sig = wire.sign_raw(API_KEY, body)
print(f" 본문: {body}")
print(f" X-Signature: {sig[:16]}...")
resp = requests.post(
API_URL, data=body.encode("utf-8"),
headers={
"Content-Type": "application/json",
"X-Signature": sig,
"X-Device-Id": DEVICE_ID,
"User-Agent": "SHT30Sensor/2.0",
},
timeout=10,
)
print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200:
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
elif resp.status_code == 403:
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치")
elif resp.status_code == 400:
print(" → 400: timestamp 신선도(±600s) 확인 — Pi 시각(NTP) 동기 점검")
else:
print(f" → 응답코드: {resp.status_code}")
except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests")
except Exception as e:
print(f" → 실패: {e}")
print("\n" + "=" * 50)
print(" 테스트 완료")
print("=" * 50)

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""센서/포맷 자가점검 — Pi 4 SHT30(2번) 모듈.
1) CRC-8 / fixed2 / 본문 포맷이 펌웨어·서버 규약과 일치하는지 단위 점검
2) 실제(또는 시뮬레이션) SHT30 측정 1회 → 변환값 출력
하드웨어 없이도 1)은 항상 통과해야 한다(서명 호환성의 핵심 보증).
"""
import sys
import wire
from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE
# 포맷/CRC 자가점검은 비밀값(config_local.py) 없이도 가능해야 한다.
# config.py 는 config_local.py 가 없으면 import 시 fail-closed 로 중단하므로,
# 개발 PC 점검을 위해 기본값으로 폴백한다(서버 연결은 test_connection.py 담당).
try:
from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION
except RuntimeError:
I2C_BUS, I2C_ADDR, FORCE_SIMULATION = 1, 0x44, False
print(" [info] config_local.py 없음 → I2C 기본값(bus=1, 0x44)으로 점검")
def check(name, got, want):
ok = got == want
print(f" [{'OK ' if ok else 'FAIL'}] {name}: got={got!r} want={want!r}")
return ok
print("=" * 50)
print(" SHT30 자가점검 (2번 모듈)")
print("=" * 50)
passed = True
# ── 1) CRC-8 점검 (Sensirion 표준 벡터 0xBEEF → 0x92) ──
print("\n[1] CRC-8 (poly 0x31, init 0xFF):")
passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92)
# ── 2) fixed2 점검 (round-half-up, reference.py/jb_fixed2 동일) ──
print("\n[2] fixed2 (소수 2자리 round-half-up):")
passed &= check("24.0", wire.fixed2(24.0), "24.00")
passed &= check("48.5", wire.fixed2(48.5), "48.50")
passed &= check("-3.245", wire.fixed2(-3.245), "-3.25")
passed &= check("0.0", wire.fixed2(0.0), "0.00")
# ── 3) 프레임 파싱 점검 (CRC 포함 합성 프레임) ──
print("\n[3] parse_frame (합성 프레임 변환):")
raw_t, raw_rh = 0x6400, 0x8000 # 임의 raw 값
frame = bytes([
raw_t >> 8, raw_t & 0xFF, crc8(bytes([raw_t >> 8, raw_t & 0xFF])),
raw_rh >> 8, raw_rh & 0xFF, crc8(bytes([raw_rh >> 8, raw_rh & 0xFF])),
])
t, rh = parse_frame(frame)
exp_t = -45.0 + 175.0 * (raw_t / 65535.0)
exp_rh = 100.0 * (raw_rh / 65535.0)
passed &= check("temp_c", round(t, 4), round(exp_t, 4))
passed &= check("rh", round(rh, 4), round(exp_rh, 4))
# ── 4) 본문 포맷 점검 (필드 순서/타입) ──
print("\n[4] 본문 포맷 (필드 순서 고정):")
body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic",
1700000000, 24.0, 48.0, "normal", "vX")
expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
'"sensor_name":"s2","event_type":"periodic","timestamp":1700000000,'
'"metric_type":"sht30","temperature_c":24.00,"humidity_percent":48.00,'
'"metric_status":"normal","app_version":"vX"}')
passed &= check("body", body, expected)
print(f" 서명(key=test): {wire.sign_raw('test', body)[:16]}...")
# ── 5) 실제/시뮬레이션 측정 1회 ──
print("\n[5] 측정 1회:")
sim = FORCE_SIMULATION or not SMBUS_AVAILABLE
print(f" 모드: {'시뮬레이션' if sim else f'실제 I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}")
try:
with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s:
t, rh = s.measure()
print(f" 측정값: T={t:.2f}C RH={rh:.2f}%")
except Exception as e:
print(f" 측정 실패: {e}")
if not sim:
print(" (i2cdetect -y 1 로 0x44 응답 확인 / 배선·풀업·I2C 활성화 점검)")
print("\n" + "=" * 50)
print(f" 결과: {'전체 통과 ✅' if passed else '실패 항목 있음 ❌'}")
print("=" * 50)
sys.exit(0 if passed else 1)

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
# =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 (SHT30 2번 모듈)
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
#
# requests 는 arch 종속이 적으나 smbus2 는 순수 파이썬(py3-none-any)이라 어떤
# 빌드 머신에서 받아도 Pi 4 에서 동작한다. wheel 핀은 타깃(Bookworm 64-bit,
# aarch64, Python 3.11)에 맞춘다. .deb 는 타깃 OS/arch 에서 받는 것이 가장 안전.
# 환경변수: TARGET_PLATFORM(기본 manylinux2014_aarch64), TARGET_PYVER(기본 311)
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_aarch64}"
TARGET_PYVER="${TARGET_PYVER:-311}"
TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
echo "[vendor] (1/2) Python wheel → wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
mkdir -p wheelhouse
# requests 등 arch 종속 wheel: 타깃 플랫폼 명시 핀.
python3 -m pip download -r requirements.txt -d wheelhouse \
--only-binary=:all: \
--platform "${TARGET_PLATFORM}" \
--python-version "${TARGET_PYVER}" \
--implementation cp \
--abi "${TARGET_ABI}" \
|| echo "[WARN] 일부 wheel 의 타깃 플랫폼 핀 실패 — 순수파이썬(any) 폴백을 아래에서 받는다."
# smbus2(py3-none-any) 등 순수 파이썬 wheel 및 sdist 폴백.
python3 -m pip download -r requirements.txt -d wheelhouse \
|| echo "[WARN] sdist/any 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs
BASE_PKGS="python3-requests python3-venv i2c-tools"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)"
MISS=0
for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
MISS=1
fi
done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

View file

@ -0,0 +1,98 @@
# =============================================================================
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명)
#
# [왜 별도 모듈인가]
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다.
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다.
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다.
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.)
#
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다.
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성)
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명)
# - firmware/test/host/reference.py (검증된 Python 레퍼런스)
# - php/config.php::verify_signature_raw (서버 검증부)
#
# fixed2() <-> jb_fixed2() / reference.fixed2()
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
# sign_raw() <-> sig_raw_body() / reference.sign_raw()
# =============================================================================
from __future__ import annotations
import hashlib
import hmac
def fixed2(v: float) -> str:
"""소수 2자리 고정 포맷 (round-half-up, locale 무관).
jb_fixed2() / reference.fixed2() 와 바이트 동일.
24.0 -> "24.00"
48.5 -> "48.50"
-3.245 -> "-3.25"
"""
neg = v < 0
if neg:
v = -v
scaled = int(v * 100.0 + 0.5) # round half up (C: (long long)(v*100+0.5))
ip = scaled // 100
fp = scaled % 100
sign = "-" if (neg and scaled != 0) else ""
return f"{sign}{ip}.{fp:02d}"
def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일).
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다.
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증).
"""
out = []
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == '\\':
out.append('\\\\')
elif o < 0x20:
out.append('\\u%04x' % o)
else:
out.append(ch)
return "".join(out)
def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
sensor_name: str, event_type: str, timestamp: int,
temperature_c: float, humidity_percent: float,
metric_status: str, app_version: str) -> str:
"""SHT30 이벤트 본문 JSON 문자열 생성.
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환).
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화.
"""
return (
'{'
f'"device_id":"{_esc(device_id)}",'
f'"device_location":"{_esc(device_location)}",'
f'"sensor_id":{int(sensor_id)},'
f'"sensor_name":"{_esc(sensor_name)}",'
f'"event_type":"{_esc(event_type)}",'
f'"timestamp":{int(timestamp)},'
f'"metric_type":"sht30",'
f'"temperature_c":{fixed2(temperature_c)},'
f'"humidity_percent":{fixed2(humidity_percent)},'
f'"metric_status":"{_esc(metric_status)}",'
f'"app_version":"{_esc(app_version)}"'
'}'
)
def sign_raw(api_key: str, body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
sig_raw_body() / reference.sign_raw() / php verify_signature_raw 와 동일.
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일).
"""
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest()

View file

@ -19,7 +19,7 @@
[SMS 발송 / 대시보드]
```
> 인증: 펌웨어는 `X-Signature: sha256(API_KEY + 요청본문)`(raw-body) 헤더로 서명하며,
> 인증: 펌웨어는 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하며,
> 서버는 이 서명을 검증합니다. 온습도 임계 판정은 모두 서버(`php/config.php` `METRIC_*`
> 상수 + `api/sensor_data.php`)에서 수행하므로, 폐쇄망에서 임계값 변경 시 서버 설정만
> 바꾸면 되고 펌웨어 재플래시가 필요하지 않습니다.

52
run_tests.sh Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# =============================================================================
# run_tests.sh - 전체 회귀 테스트 러너 (CI / 수동)
#
# 실행 대상:
# 1) PHP 단위/회귀 테스트 3종 (tests/*.php)
# 2) 펌웨어 호스트 패리티 (firmware/test/host/parity_test.py)
# - C↔Python↔PHP 서명 패리티(gcc/clang + php 가용 시 C 포함)
#
# php / python3 / gcc 가 없으면 해당 묶음을 SKIP 으로 *명확히* 보고한다(무성 스킵 금지).
# 하나라도 FAIL 이면 종료코드 1.
# =============================================================================
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fail=0; ran=0; skipped=0
have() { command -v "$1" >/dev/null 2>&1; }
run_php() { # test_file
local f="$1"
if ! have php; then echo "SKIP (php 없음): ${f}"; skipped=$((skipped+1)); return; fi
echo "── php ${f}"
if php "${ROOT}/${f}"; then ran=$((ran+1)); else echo " FAIL: ${f}"; fail=1; fi
}
echo "=== 1) PHP 회귀 테스트 ==="
run_php tests/raw_body_signature_test.php
run_php tests/security_hardening_test.php
run_php tests/threshold_sms_test.php
run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴
echo ""
echo "=== 2) 펌웨어 호스트 패리티 (C↔Python↔PHP) ==="
if have python3 && have php; then
echo "── python3 firmware/test/host/parity_test.py"
if ( cd "${ROOT}/firmware/test/host" && python3 parity_test.py ); then
ran=$((ran+1))
else
echo " FAIL: parity_test.py"; fail=1
fi
if ! have gcc && ! have cc && ! have clang; then
echo " NOTE: C 컴파일러 없음 — 위 패리티는 Python↔PHP 만 검증(C 서명 미컴파일)."
fi
else
echo "SKIP (python3/php 없음): firmware/test/host/parity_test.py"; skipped=$((skipped+1))
fi
echo ""
echo "=== 요약: 실행 ${ran}, 스킵 ${skipped} ==="
if [ "${fail}" -ne 0 ]; then echo "결과: FAIL"; exit 1; fi
if [ "${ran}" -eq 0 ]; then echo "결과: NO-OP (실행된 테스트 없음 — php/python3 설치 필요)"; exit 0; fi
echo "결과: PASS"

105
sql/migration_add_leak.sql Normal file
View file

@ -0,0 +1,105 @@
-- =============================================================================
-- migration_add_leak.sql - SHT30 전용 서버에 누수(leak) 수신을 다시 추가(통합)
--
-- [목적] 1번 모듈(누수+카메라, sensor_id=1)과 2번 모듈(SHT30 온습도, sensor_id=2)이
-- "하나의 cafe24 서버 php/" 로 함께 보고하도록 통합한다. 이 마이그레이션은
-- sql/migration_drop_leak.sql 가 제거했던 누수 요소를 "가산(additive)·멱등"으로
-- 되돌린다(= migration_drop_leak.sql 의 역방향, 단 데이터 삭제 없이 가산만 수행).
--
-- [안전성] 운영 중인 SHT30 서버에서 그대로 실행해도 안전하다:
-- - sensor_metric(SHT30 측정) / sms_log / sensor_status / ingest_nonce 등 기존 테이블·
-- 데이터는 전혀 건드리지 않는다.
-- - 모든 변경은 "없을 때만 추가"(IF NOT EXISTS / information_schema 가드)이므로
-- 여러 번 재실행해도 오류가 나지 않는다.
--
-- [되돌리는 항목]
-- - sensor_log.event_type ENUM 에 leak_alert / leak_recovery 재추가
-- - sensor_log.is_leak 컬럼(없으면 추가)
-- - sensor_status.is_leak 컬럼(없으면 추가)
-- - leak_incident 테이블(없으면 생성) — 누수 사고(에피소드) 시작/복구 추적
-- - leak_photo 테이블(없으면 생성) — 누수 시 촬영 사진 메타데이터
--
-- 신규(통합) 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하면 된다
-- (schema_sht30.sql 이 이미 누수 요소를 포함하도록 통합되어 있다).
-- =============================================================================
SET NAMES utf8mb4;
-- 1) sensor_log.event_type: 누수 enum 값을 다시 허용(멱등 — 동일 enum 재적용은 안전).
ALTER TABLE `sensor_log`
MODIFY `event_type` ENUM('startup','periodic','leak_alert','leak_recovery')
NOT NULL DEFAULT 'periodic';
-- 2~3) 컬럼/인덱스 추가는 "없을 때만" 수행해 재실행해도 오류가 없게 한다.
-- (MySQL/MariaDB 는 ADD COLUMN IF NOT EXISTS 호환이 일정치 않으므로
-- information_schema 로 가드하는 멱등 프로시저를 사용한다.)
DROP PROCEDURE IF EXISTS _mig_add_leak;
DELIMITER //
CREATE PROCEDURE _mig_add_leak()
BEGIN
DECLARE db VARCHAR(64);
SELECT DATABASE() INTO db;
-- sensor_log.is_leak: 누수 여부(0/1) — 없으면 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_log`
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '누수 여부 (1=누수, 0=정상)' AFTER `event_type`;
END IF;
-- sensor_status.is_leak: 최신 누수 상태(0/1) — 없으면 추가
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_status`
ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '최신 누수 상태 (1=누수, 0=정상)' AFTER `last_event_type`;
END IF;
END //
DELIMITER ;
CALL _mig_add_leak();
DROP PROCEDURE IF EXISTS _mig_add_leak;
-- 4) leak_incident: 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은
-- 센서의 leak_recovery 시 가장 최근 미복구 행을 닫는다(recovered_at 채움).
CREATE TABLE IF NOT EXISTS `leak_incident` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)',
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`),
INDEX `idx_started_at` (`started_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사고(에피소드) 추적 (시작/복구)';
-- 5) leak_photo: 누수 시 촬영한 사진 메타데이터. 실제 파일은 php/var/photos/ 에 저장되고
-- filename 은 서버가 생성한 무작위 파일명(클라이언트 경로 미사용 — 경로주입 방지).
CREATE TABLE IF NOT EXISTS `leak_photo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',
`mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg',
`bytes` INT UNSIGNED NOT NULL DEFAULT 0,
`captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_log_id` (`log_id`),
INDEX `idx_captured_at` (`captured_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사진 메타데이터 (파일은 var/photos/)';

View file

@ -93,3 +93,14 @@ DELIMITER ;
CALL _mig_drop_leak();
DROP PROCEDURE IF EXISTS _mig_drop_leak;
-- 5) 수집 재전송(replay) 멱등 nonce 테이블 추가(없을 때만 — 가산/멱등).
-- api/sensor_data.php 가 서명을 nonce 로 기록해 시계오차 창 내 중복 수신을 멱등
-- 처리한다(중복 저장·중복 SMS 방지). 이 테이블이 없어도 수집은 동작한다.
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`signature`),
INDEX `idx_seen_at` (`seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';

View file

@ -1,19 +1,22 @@
-- =============================================================================
-- schema_sht30.sql - SHT30 온습도 모니터링 시스템 신규 설치용 통합 스키마
-- schema_sht30.sql - 통합 모니터링 시스템 신규 설치용 스키마
-- (1번 모듈=누수+카메라 sensor_id=1, 2번 모듈=SHT30 온습도 sensor_id=2 — 단일 서버)
--
-- 신규 설치: 이 파일 하나만 실행합니다.
-- SOURCE /path/to/sql/schema_sht30.sql;
--
-- 기존(누수 v2605) 설치를 SHT30 전용으로 전환할 때는 이 파일이 아니라
-- sql/migration_drop_leak.sql 을 사용합니다(데이터 보존 + 누수 잔재 정리).
-- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을
-- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시
-- 더하려면 sql/migration_add_leak.sql(가산·멱등).
--
-- 누수(leak) 관련 요소는 포함하지 않습니다:
-- - 테이블: leak_photo, leak_incident
-- - 컬럼: sensor_log.is_leak, sensor_status.is_leak, leak_periodic_count
-- - 이벤트: event_type 의 leak_alert / leak_recovery
-- 온습도 임계 경보(고온/저온/고습/저습) 판정과 SMS 발송은 서버 PHP(config.php
-- 의 METRIC_* 상수 + api/sensor_data.php)에서 수행하며, 발송 이력은 sms_log 에
-- 남습니다. 별도의 경보 상태 테이블은 두지 않습니다(sms_log 기준 쿨다운/복구 판정).
-- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소:
-- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
-- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼,
-- event_type 의 leak_alert / leak_recovery,
-- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블.
-- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며,
-- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
-- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다.
-- =============================================================================
SET NAMES utf8mb4;
@ -25,8 +28,9 @@ CREATE TABLE IF NOT EXISTS `sensor_log` (
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호',
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '센서 이름',
`event_type` ENUM('startup','periodic')
`event_type` ENUM('startup','periodic','leak_alert','leak_recovery')
NOT NULL DEFAULT 'periodic',
`is_leak` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '누수 여부 (1=누수, 0=정상)',
`sensor_time` DATETIME NOT NULL COMMENT '센서 측정 시각 (기기 기준)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'DB 저장 시각',
@ -45,6 +49,7 @@ CREATE TABLE IF NOT EXISTS `sensor_status` (
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`last_event_type` VARCHAR(20) NOT NULL DEFAULT '',
`is_leak` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '최신 누수 상태 (1=누수, 0=정상)',
`last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0
@ -66,7 +71,7 @@ CREATE TABLE IF NOT EXISTS `sensor_metric` (
`temperature_c` DECIMAL(6,2) NULL,
`humidity_percent` DECIMAL(5,2) NULL,
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal'
COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity',
COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity/condensation_risk',
`measured_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -99,3 +104,64 @@ CREATE TABLE IF NOT EXISTS `sms_log` (
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='SMS 발송 이력 (온습도 임계 경보/복구, 장비 오프라인/복구)';
-- 수집 재전송(replay) 멱등 nonce. api/sensor_data.php 가 인증·신선도를 통과한 요청의
-- 서명(X-Signature)을 여기에 원자적으로 기록한다. 시계오차 창(INGEST_MAX_CLOCK_SKEW_SEC)
-- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이
-- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은
-- 동작하며 멱등 방어만 비활성화된다 — graceful degrade).
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
COMMENT '요청 서명(X-Signature) — 멱등 키',
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
COMMENT '최초 수신 시각(정리 기준)',
PRIMARY KEY (`signature`),
INDEX `idx_seen_at` (`seen_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';
-- 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은 센서의 leak_recovery
-- 시 가장 최근 미복구(recovered_at IS NULL) 행을 닫는다. 대시보드의 누수 상태/이력 표시에 사용.
CREATE TABLE IF NOT EXISTS `leak_incident` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)',
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`),
INDEX `idx_started_at` (`started_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사고(에피소드) 추적 (시작/복구)';
-- 누수 사진 메타데이터. 실제 파일은 php/var/photos/ 에 서버 생성 무작위 파일명으로 저장
-- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다.
CREATE TABLE IF NOT EXISTS `leak_photo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',
`mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg',
`bytes` INT UNSIGNED NOT NULL DEFAULT 0,
`captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_log_id` (`log_id`),
INDEX `idx_captured_at` (`captured_at`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='누수 사진 메타데이터 (파일은 var/photos/)';

50
tests/chaos_drill.php Normal file
View file

@ -0,0 +1,50 @@
<?php
// =============================================================================
// chaos_drill.php - 경보 실패 경로 드릴 (CLI 전용)
//
// SMS_FORCE_FAIL 을 주입해 실제 전송 없이 '발송 실패' 경로를 시험한다. 발송실패
// 가시화/재시도/잔액경보 등 *실패에 의존하는* 신뢰성 기능이 동작하는지 손시연 없이
// 검증한다(기존 회귀 테스트는 정상 경로만 본다).
//
// 라이브 종단 드릴(운영 점검): config.local.php 에 일시적으로 'SMS_FORCE_FAIL' => true
// 를 두고 임계 초과를 유발한 뒤, 대시보드의 '경보 SMS 발송 실패' 배너와 오프라인 재시도
// 예약(error_log)을 확인하고, 끝나면 반드시 다시 false 로 되돌린다.
//
// 주의: config.local.php 가 SMS_FORCE_FAIL 을 명시 설정하면 아래 env 주입은 무시된다.
// =============================================================================
declare(strict_types=1);
if (php_sapi_name() !== 'cli') {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
exit("CLI only\n");
}
putenv('SMS_FORCE_FAIL=1'); // config 로드 전에 주입(cfg 가 getenv 로 읽음)
$root = dirname(__DIR__);
require_once $root . '/php/sms_send.php'; // config.php 포함
$failures = [];
function chk(bool $cond, string $msg): void {
global $failures;
if (!$cond) { $failures[] = $msg; }
}
chk(SMS_FORCE_FAIL === true, 'SMS_FORCE_FAIL should be forced ON for the drill (env override). config.local.php 가 명시 설정하면 무시될 수 있음.');
$r = send_sms_cafe24('01000000000', '드릴 메시지');
chk(($r['success'] ?? null) === false, 'forced send must report failure (success=false).');
chk(($r['result_code'] ?? '') === 'FORCED_FAIL', 'forced send must use FORCED_FAIL result code.');
chk(array_key_exists('remaining', $r), 'result must include the remaining key.');
if ($failures) {
fwrite(STDERR, "Chaos drill FAILURES:\n");
foreach ($failures as $f) {
fwrite(STDERR, "- {$f}\n");
}
exit(1);
}
echo "Chaos drill passed: SMS 실패 주입 경로 동작 확인.\n";
echo "라이브 드릴: config.local.php 에 'SMS_FORCE_FAIL'=>true 후 임계초과 유발 → 대시보드 실패 배너/오프라인 재시도 확인 → 반드시 false 로 복구.\n";

View file

@ -85,14 +85,46 @@ check(str_contains($smsSend, 'function send_metric_alert_sms'), 'sms_send.php sh
check(str_contains($smsSend, 'function send_metric_recovery_sms'), 'sms_send.php should provide send_metric_recovery_sms().');
check(str_contains($smsSend, 'function evaluate_metric_thresholds'), 'sms_send.php should provide evaluate_metric_thresholds().');
// ── 제거된 누수/사진/사고 기능이 재유입되지 않았는지 ────────
// ── 통합 누수+카메라(1번 모듈) 기능 회귀 (소스 기반 고정) ────
// 누수 모듈이 SHT30 서버에 통합되었으므로, 누수/사진/사고 기능은 존재해야 한다.
$dashboard = file_text($root . '/php/dashboard.php');
$monthly = file_text($root . '/php/monthly_report.php');
check(!str_contains($dashboard, 'leak_photo') && !str_contains($dashboard, 'admin_photo_url'), 'dashboard.php should not reference removed photo/leak features.');
check(!str_contains($monthly, 'leak_incident') && !str_contains($monthly, 'admin_photo_url'), 'monthly_report.php should not reference removed photo/incident features.');
check(!is_file($root . '/php/photo.php'), 'php/photo.php (leak photo proxy) should be removed.');
check(!is_file($root . '/php/incidents.php'), 'php/incidents.php should be removed.');
check(!is_file($root . '/php/api/photo_upload.php'), 'php/api/photo_upload.php should be removed.');
$sensorApi = file_text($root . '/php/api/sensor_data.php');
// 수집 API 가 누수 이벤트(leak_alert/leak_recovery)를 수신한다
check(str_contains($sensorApi, 'leak_alert') && str_contains($sensorApi, 'leak_recovery'),
'sensor_data.php should accept leak_alert/leak_recovery events.');
// 사진 업로드 API 존재 + HMAC 서명/상수시간 비교/기본키 fail-closed
$photoUpload = $root . '/php/api/photo_upload.php';
check(is_file($photoUpload), 'php/api/photo_upload.php should exist (leak photo upload endpoint).');
$photoUploadText = file_text($photoUpload);
check(str_contains($photoUploadText, 'hash_hmac'), 'photo_upload.php should verify an HMAC signature.');
check(str_contains($photoUploadText, 'hash_equals'), 'photo_upload.php should use constant-time hash_equals.');
check(str_contains($photoUploadText, 'api_key_is_default'), 'photo_upload.php should fail closed on the default API key.');
// SMS 누수 경보/복구 헬퍼
check(str_contains($smsSend, 'function send_leak_alert_sms'), 'sms_send.php should provide send_leak_alert_sms().');
check(str_contains($smsSend, 'function send_leak_recovery_sms'), 'sms_send.php should provide send_leak_recovery_sms().');
// 인증 사진 열람 엔드포인트(photo.php): 관리자 세션 가드 + 파일명 정규식 검증
check(is_file($root . '/php/photo.php'), 'php/photo.php (authenticated leak photo endpoint) should exist.');
$photoText = file_text($root . '/php/photo.php');
check(str_contains($photoText, 'admin_enforce_session_timeout') && str_contains($photoText, "\$_SESSION['logged_in']"),
'photo.php should enforce the same admin session guard as dashboard.php.');
check(str_contains($photoText, 'leak_[0-9]+_[0-9]+_[0-9a-f]+'),
'photo.php should validate the server-generated filename regex (defense-in-depth).');
// 대시보드가 누수 패널(사진 열람은 인증 endpoint 경유)을 노출
check(str_contains($dashboard, 'leak_photo') || str_contains($dashboard, 'photo.php?id='),
'dashboard.php should render the leak panel via the authenticated photo endpoint.');
// var/ 자가보호: 사진 디렉터리가 웹 직접 접근으로 노출되지 않도록 .htaccess 가 있어야 한다
check(is_file($root . '/php/var/.htaccess'), 'php/var/.htaccess should exist (self-contained var/ protection).');
$varHtaccess = file_text($root . '/php/var/.htaccess');
check(str_contains($varHtaccess, 'Require all denied'),
"php/var/.htaccess should deny web access ('Require all denied').");
check(is_file($root . '/php/var/photos/.htaccess'), 'php/var/photos/.htaccess should exist.');
$retention = $root . '/php/retention_cleanup.php';
check(is_file($retention), 'php/retention_cleanup.php should implement retention cleanup.');
@ -114,6 +146,107 @@ check(str_contains($securityEvidence, '감사로그'), 'security_evidence.php sh
check(str_contains($securityEvidence, '보관기간'), 'security_evidence.php should report retention controls.');
check(str_contains($securityEvidence, '백업'), 'security_evidence.php should report backup evidence controls.');
// ── 보안 하드닝 개선 회귀 (소스 기반 고정) ──────────────────
$sensorApi = file_text($root . '/php/api/sensor_data.php');
$schemaSql = file_text($root . '/sql/schema_sht30.sql');
$testMobile = file_text($root . '/php/test_mobile.php');
$opsChecks = file_text($root . '/php/ops_checks.php');
// 재전송(replay) 멱등 nonce
check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.');
check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.');
check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.');
check(str_contains($schemaSql, 'ingest_nonce'), 'schema_sht30.sql should create the ingest_nonce table.');
// sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400)
check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');
// SMS 길이 캡 + 동시 발송 직렬화(중복 SMS 방지)
check(str_contains($config, 'SMS_MAX_EUCKR_BYTES'), 'config.php should define SMS_MAX_EUCKR_BYTES.');
check(str_contains($smsSend, 'function sms_truncate_euckr'), 'sms_send.php should cap SMS length (sms_truncate_euckr).');
check(str_contains($smsSend, 'GET_LOCK'), 'sms_send.php should serialize duplicate alert SMS with a named lock.');
// 경보 태그 단일 출처(상수). 하드코딩 LIKE 리터럴이 제거되어야 한다.
check(str_contains($config, 'SMS_TAG_HIGH_TEMP'), 'config.php should centralize SMS alert tags as constants.');
check(str_contains($config, 'function sms_summary_select_sql'), 'config.php should expose sms_summary_select_sql().');
check(!str_contains($dashboard, "LIKE '[고온경보]%'"), 'dashboard.php should not hardcode alert-tag LIKE literals.');
check(!str_contains($monthly, "LIKE '[고온경보]%'"), 'monthly_report.php should not hardcode alert-tag LIKE literals.');
// 세션 절대 수명 캡(유휴 타임아웃만으로 무한정 유지되는 문제 보완)
check(str_contains($config, 'SESSION_ABSOLUTE_TIMEOUT'), 'config.php should define an absolute session lifetime cap.');
check(str_contains($login, 'last_activity'), 'login.php should record last_activity for the idle timer.');
// 세션 타임아웃은 모든 인증 페이지에서 강제되어야 한다(한 페이지만 검사하면 다른 페이지로 우회 가능)
check(str_contains(file_text($adminSecurity), 'function admin_enforce_session_timeout'),
'admin_security.php should provide a shared admin_enforce_session_timeout() helper.');
foreach (['dashboard.php', 'monthly_report.php', 'security_evidence.php', 'setup_wizard.php'] as $authPage) {
check(str_contains(file_text($root . '/php/' . $authPage), 'admin_enforce_session_timeout'),
"{$authPage} should enforce session timeout on every authenticated page.");
}
// ── ①+② 신규 기능 회귀 (소스 기반 고정) ─────────────────────
$appConfig = file_text($root . '/firmware/common/app_config.h');
$cron = file_text($root . '/php/cron_heartbeat.php');
// ① 폐쇄망 SNTP: 인터넷 NTP 기본값 금지 + 서버시각 응답
// #define 값만 검사한다(설명 주석에 등장하는 'pool.ntp.org' 토큰에 오탐하지 않도록).
check(!preg_match('/#define\s+APP_SNTP_SERVER\s+"[^"]*pool\.ntp\.org/', $appConfig), 'app_config.h must NOT default APP_SNTP_SERVER to internet pool.ntp.org (air-gap violation).');
check(str_contains($appConfig, 'APP_SNTP_SERVER'), 'app_config.h should still define APP_SNTP_SERVER.');
check(str_contains($sensorApi, 'server_now'), 'sensor_data.php should return server_now (firmware clock correction input).');
// ②a SMS 잔액 사전경보
check(str_contains($config, 'SMS_BALANCE_WARN_THRESHOLD'), 'config.php should define SMS_BALANCE_WARN_THRESHOLD.');
check(str_contains($config, 'function latest_sms_balance'), 'config.php should expose latest_sms_balance().');
check(str_contains($smsSend, 'function maybe_warn_sms_balance'), 'sms_send.php should warn on low SMS balance.');
check(str_contains($opsChecks, 'SMS 잔여'), 'ops_checks.php should surface SMS balance.');
// ②b 일일 헬스(생존신호) SMS
check(str_contains($config, 'HEALTH_SMS_ENABLED'), 'config.php should define HEALTH_SMS_ENABLED.');
check(str_contains($smsSend, 'function maybe_send_daily_health_sms'), 'sms_send.php should provide the daily health SMS.');
check(str_contains($cron, 'maybe_send_daily_health_sms'), 'cron_heartbeat.php should invoke the daily health SMS.');
// ②c 이슬점/결로 경보
check(str_contains($config, 'METRIC_DEWPOINT_MARGIN_C'), 'config.php should define METRIC_DEWPOINT_MARGIN_C.');
check(str_contains($config, 'SMS_TAG_CONDENSATION'), 'config.php should define the condensation alert tag.');
check(str_contains($smsSend, 'function dew_point_c'), 'sms_send.php should compute dew point.');
check(str_contains($smsSend, 'condensation_risk'), 'sms_send.php should classify condensation_risk.');
// ②d 수신번호 마스킹
check(str_contains($config, 'function mask_phone'), 'config.php should provide mask_phone().');
check(str_contains($dashboard, 'mask_phone'), 'dashboard.php should mask receiver numbers.');
// ── 고가치(M) 기능 회귀 (소스 기반 고정) ─────────────────────
// M#1 수집 거부사유 카운터
check(str_contains($config, 'function record_ingest_rejection'), 'config.php should provide record_ingest_rejection().');
check(str_contains($config, 'function read_ingest_rejections'), 'config.php should provide read_ingest_rejections().');
check(str_contains($sensorApi, 'record_ingest_rejection'), 'sensor_data.php should count ingest rejections.');
check(str_contains($dashboard, 'ingest_rejections'), 'dashboard.php should surface ingest rejections.');
// M#2 발송실패 가시화 + 오프라인 재시도
check(str_contains($cron, 'SMS_RETRY_OFFLINE') && str_contains($cron, 'offline_alerted = 0'), 'cron_heartbeat should retry offline SMS on send failure.');
check(str_contains($dashboard, 'recent_alert_fail'), 'dashboard.php should show a failed-alert-SMS banner.');
// M#3 에스컬레이션
check(str_contains($config, 'SMS_RECIPIENTS_TIER2'), 'config.php should define SMS_RECIPIENTS_TIER2.');
check(str_contains($config, 'SMS_TAG_ESCALATION'), 'config.php should define the escalation tag.');
check(str_contains($smsSend, 'function maybe_escalate_alerts'), 'sms_send.php should escalate unrecovered alerts.');
check(str_contains($cron, 'maybe_escalate_alerts'), 'cron_heartbeat should invoke escalation.');
// M#4 센서 고착 검출
check(str_contains($config, 'STUCK_DETECT_ENABLED'), 'config.php should define STUCK_DETECT_ENABLED.');
check(str_contains($config, 'SMS_TAG_SENSOR_STUCK'), 'config.php should define the sensor-stuck tag.');
check(str_contains($smsSend, 'function maybe_detect_stuck_sensors'), 'sms_send.php should detect stuck sensors.');
check(str_contains($smsSend, 'function stddev_pop'), 'sms_send.php should provide stddev_pop().');
check(str_contains($cron, 'maybe_detect_stuck_sensors'), 'cron_heartbeat should invoke stuck detection.');
// M#6 카오스 드릴 훅
check(str_contains($config, 'SMS_FORCE_FAIL'), 'config.php should define SMS_FORCE_FAIL.');
check(str_contains($smsSend, 'FORCED_FAIL'), 'sms_send.php should honor SMS_FORCE_FAIL.');
check(is_file($root . '/tests/chaos_drill.php'), 'tests/chaos_drill.php should exist.');
// 반사형 XSS 차단: test_mobile.php 는 raw User-Agent 를 그대로 반사하지 않는다
check(str_contains($testMobile, 'htmlspecialchars') && str_contains($testMobile, 'text/plain'),
'test_mobile.php should escape and text/plain the reflected User-Agent.');
// 운영 자가진단: 레거시 서명 폴백 / MFA 설정 토큰 잔존 경고
check(str_contains($opsChecks, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'ops_checks.php should flag enabled legacy signature fallback.');
check(str_contains($opsChecks, 'MFA_SETUP_TOKEN'), 'ops_checks.php should flag a lingering MFA setup token.');
check(str_contains($configExample, 'ALLOW_LEGACY_BODY_SIGNATURE'), 'config.local.example.php should document ALLOW_LEGACY_BODY_SIGNATURE.');
if ($failures) {
fwrite(STDERR, "Security hardening regression failures:\n");
foreach ($failures as $failure) {

View file

@ -75,6 +75,63 @@ eq(metric_value_str(-3.0), '-3', '-3.0 -> -3');
eq(metric_value_str(100.0), '100', '100.0 -> 100');
eq(metric_value_str(0.0), '0', '0.0 -> 0');
// ── SMS 길이 캡(EUC-KR, sms_truncate_euckr) ─────────────────
check(function_exists('sms_truncate_euckr'), 'sms_truncate_euckr() should exist.');
check(defined('SMS_MAX_EUCKR_BYTES'), 'SMS_MAX_EUCKR_BYTES should be defined.');
eq(sms_truncate_euckr('abcdefghij', 5), 'abcde', 'ascii truncate to 5 bytes');
eq(sms_truncate_euckr('abc', 90), 'abc', 'message under cap is unchanged');
eq(sms_truncate_euckr('', 90), '', 'empty stays empty');
eq(sms_truncate_euckr('abc', 0), 'abc', 'non-positive cap is a no-op');
// EUC-KR 한글은 글자당 2바이트. 홀수 바이트 경계에서 멀티바이트 글자를 쪼개지 않아야 한다.
$euckr = iconv('UTF-8', 'EUC-KR', '가나다라마'); // 5글자 = 10바이트
check(strlen($euckr) === 10, 'precondition: 5 hangul = 10 EUC-KR bytes');
$cut5 = sms_truncate_euckr($euckr, 5); // 5바이트 → '가나'(4바이트)로 절단(글자 보존)
check(strlen($cut5) <= 5 && strlen($cut5) % 2 === 0, 'euckr truncate keeps 2-byte char boundary');
eq(iconv('EUC-KR', 'UTF-8', $cut5), '가나', 'euckr 10B truncated to 5B -> 가나');
$cut6 = sms_truncate_euckr($euckr, 6); // 6바이트 → '가나다'(6바이트)
eq(iconv('EUC-KR', 'UTF-8', $cut6), '가나다', 'euckr 10B truncated to 6B -> 가나다');
// ── 이슬점 / 결로 마진 ───────────────────────────────────────
check(function_exists('dew_point_c'), 'dew_point_c() should exist.');
check(function_exists('condensation_margin_c'), 'condensation_margin_c() should exist.');
check(function_exists('condensation_is_clear'), 'condensation_is_clear() should exist.');
// Magnus 근사: Td(20℃,50%) ≈ 9.3℃ (±0.5)
$td = dew_point_c(20.0, 50.0);
check($td !== null && abs($td - 9.3) < 0.5, 'dew point of 20C/50% ~= 9.3C (got ' . var_export($td, true) . ')');
// 결로마진 = T - Td ≈ 10.7℃
$m = condensation_margin_c(20.0, 50.0);
check($m !== null && abs($m - 10.7) < 0.6, 'condensation margin 20C/50% ~= 10.7C (got ' . var_export($m, true) . ')');
// 습도 0%/결측은 산출 불가 → null, 위험 없음
check(dew_point_c(20.0, 0.0) === null, 'dew point undefined at 0% RH');
check(condensation_margin_c(null, 50.0) === null, 'margin null when temp missing');
// 고온고습(margin 좁음)은 결로 위험으로 미해소
check(condensation_is_clear(18.0, 92.0) === false, '18C/92% has tight margin -> not clear');
// 여유 있는 값은 해소
check(condensation_is_clear(20.0, 50.0) === true, '20C/50% margin wide -> clear');
// 결측은 정상(해소)로 간주
check(condensation_is_clear(null, 50.0) === true, 'null temp -> condensation clear');
// evaluate_metric_thresholds 가 결로 위험을 별도 종류로 추가
if (METRIC_DEWPOINT_MARGIN_C > 0) {
check(in_array('condensation_risk', evaluate_metric_thresholds(18.0, 92.0)['breaches'], true),
'18C/92% should include condensation_risk breach');
check(!in_array('condensation_risk', evaluate_metric_thresholds($tMid, $hMid)['breaches'], true),
'normal mid values have no condensation_risk');
}
// ── 수신번호 마스킹 ─────────────────────────────────────────
check(function_exists('mask_phone'), 'mask_phone() should exist.');
eq(mask_phone('01012345678'), '010-****-5678', 'mask 11-digit mobile');
eq(mask_phone('010-1234-5678'), '010-****-5678', 'mask with hyphens');
eq(mask_phone('123'), '***', 'too short -> all masked');
eq(mask_phone('01011112222, 01033334444'), '010-****-2222, 010-****-4444', 'mask comma-separated list');
// ── 표준편차 (센서 고착 검출) ────────────────────────────────
check(function_exists('stddev_pop'), 'stddev_pop() should exist.');
check(stddev_pop([1.0, 1.0, 1.0, 1.0]) === 0.0, 'identical values -> stddev 0 (stuck signal)');
check(abs(stddev_pop([2, 4, 4, 4, 5, 5, 7, 9]) - 2.0) < 1e-9, 'known population stddev == 2.0');
check(stddev_pop([]) === 0.0, 'empty -> 0');
check(stddev_pop([23.45, 23.45, 23.45, 23.45, 23.45, 23.45]) === 0.0, 'flat SHT30 readings -> stuck');
if ($failures) {
fwrite(STDERR, "Threshold SMS logic test failures:\n");
foreach ($failures as $f) {