Merge pull request 'RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝' (#1) from feat/rpi4-dual-module-unified-server into main

This commit is contained in:
cw2007 2026-06-24 09:27:07 +09:00
commit 239153a5b2
82 changed files with 6917 additions and 391 deletions

View file

@ -0,0 +1,36 @@
{
"permissions": {
"allow": [
"Bash(tail:*)",
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)",
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)",
"Bash(sort -k2)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(rm -fr:*)",
"Bash(sudo rm:*)",
"Bash(chmod -R 777:*)",
"Bash(chmod 777:*)",
"Bash(dd:*)",
"Bash(mkfs:*)",
"Bash(:(){ :|:& };:)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(curl:* | sh)",
"Bash(curl:* | bash)",
"Bash(wget:* | sh)",
"Bash(wget:* | bash)",
"Bash(ssh:*)",
"Bash(scp:*)",
"Read(./**/config.local.php)",
"Read(./**/config_local.py)",
"Read(./**/secrets.h)",
"Read(./**/.env)",
"Read(./**/.env.*)",
"Read(./**/*.pem)",
"Read(./**/id_rsa)",
"Read(./**/id_ed25519)"
]
}
}

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 - 보안 하드닝 (전체 코드리뷰 반영) ## Unreleased - 보안 하드닝 (전체 코드리뷰 반영)
### ⚠️ 호환성 주의 (BREAKING) ### ⚠️ 호환성 주의 (BREAKING)

149
PR_BODY.md Normal file
View file

@ -0,0 +1,149 @@
# RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
## 1. 배경 / 목적
서버실 모니터링을 **하나의 시스템**으로 통합한다. Raspberry Pi 4 센서 모듈 2개가 각각
cafe24 서버로 데이터를 전송하고, 서버가 저장·임계판정·SMS·**단일 대시보드 표시**를 담당한다.
- **1번 모듈 — 누수 + 카메라** (`raspberry_pi4/`, `sensor_id=1`)
- **2번 모듈 — 온습도 SHT30** (`raspberry_pi4_sht30/`, `sensor_id=2`)
기존에는 온습도(이 repo의 `php/` 서버)와 누수(레거시 누수 서버)가 **분리**되어 있었고
서명 방식도 달랐다. 본 PR로 **두 모듈을 동일 서버·동일 서명(X-Signature HMAC)으로 통합**하여
대시보드 한 화면에서 **온습도 + 누수 상태 + 현장 사진**을 함께 본다.
```
┌──────────────────────────┐
│ 1번 모듈 (누수+카메라) │ leak_alert/recovery + 사진
│ raspberry_pi4/ │──┐
└──────────────────────────┘ │ HTTPS POST
▼ X-Signature = HMAC-SHA256(API_KEY, body)
┌──────────────────────────┐ ┌─────────────────────────────┐
│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │
│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │
└──────────────────────────┘ │ api/photo_upload.php (사진) │
│ → MySQL / 임계·누수 SMS │
│ → dashboard.php (통합 대시보드)│
└─────────────────────────────┘
```
---
## 2. 2번 모듈 신규 — `raspberry_pi4_sht30/` (온습도)
STM32 펌웨어 `sht30_fw` 동작을 **Raspberry Pi 4 + Python**으로 이식.
| 파일 | 역할 |
|---|---|
| `sht30_sensor.py` | 메인 — 5분 주기 측정 → 서명 POST → `startup`→`periodic`, SIGTERM 정상종료 |
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 변환 (smbus2, 미가용 시 시뮬레이션 폴백) |
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 **바이트 동일**) |
| `config.py` / `config_local.example.py` | 비밀 아닌 기본값 / 운영·비밀값 템플릿(fail-closed) |
| `install.sh` / `vendor.sh` | 폐쇄망 오프라인 + 온라인 설치 / 의존성 사전 다운로드, I2C 자동 활성화 |
| `test_sensor.py` / `test_connection.py` | CRC·포맷 자가점검 / 서버 서명 POST 점검 |
| `systemd/sht30-sensor.service` | 부팅 자동 실행 |
- **I2C 배선:** SDA=GPIO2(물리3), SCL=GPIO3(물리5), 3.3V/GND, ADDR=GND→`0x44`, 4.7kΩ 풀업
- **측정식:** `temp=-45+175·raw_t/65535`, `rh=100·raw_rh/65535`, CRC-8(poly 0x31, init 0xFF)
- **보고 필드 순서(서명 호환 고정):** `device_id, device_location, sensor_id, sensor_name,
event_type, timestamp, metric_type="sht30", temperature_c, humidity_percent, metric_status, app_version`
- 임계 판정(고온/저온/고습/저습)·SMS는 **전부 서버**가 수행. 모듈은 측정만 보고.
---
## 3. 1번 모듈 통합 — `raspberry_pi4/` (누수+카메라)
- **서명 전환:** 레거시 `sha256(API_KEY + 정렬 JSON)` body-field 서명 → **raw-body
`X-Signature` HMAC**(2번 모듈과 동일 스킴). 본문을 고정 순서 문자열로 만들어 그 바이트에
서명하고 그대로 POST(`data=body_bytes`).
- **누수 이벤트 본문:** `{device_id, device_location, sensor_id, sensor_name, event_type,
is_leak, timestamp}`
- **사진 업로드 서명:** canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음),
`X-Signature` 헤더. 서버 `photo_upload.php` 재구성 canonical과 바이트 동일.
- GPIO 인터럽트 누수감지 / 카메라 촬영 / 재시도 / 스레딩 동작은 유지.
- `config_local.example.py`: `API_URL`/`PHOTO_UPLOAD_URL`을 통합 서버로, `API_KEY`는 서버와 동일.
---
## 4. 서버 통합 (`php/`)
| 파일 | 변경 |
|---|---|
| `api/sensor_data.php` | `leak_alert`/`leak_recovery` 수신, `is_leak` 처리, `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. **SHT30 측정 경로 100% 유지** |
| `api/photo_upload.php` *(신규)* | 멀티파트 JPEG 업로드. HMAC 서명·timestamp 신선도·`finfo` MIME·크기상한·**서버생성 무작위 파일명**·nonce 멱등·`log_id↔sensor_id` 결속 |
| `photo.php` *(신규)* | **관리자 인증** 사진 서빙. 파일명 정규식 + `realpath` 봉쇄(경로순회 방어), `readfile` image/jpeg |
| `dashboard.php` | 누수 패널(센서1 누수/정상 + `leak_incident` 이력 + `photo.php?id=` 썸네일), 이벤트 라벨 `누수감지/누수복구`, 제목 "온습도·누수 통합" |
| `sms_send.php` | `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만) |
| `config.php` | `[누수감지]`/`[누수복구]` 태그, 쿨다운 상수, SMS 요약에 누수 카운트 |
| `var/.htaccess`, `var/photos/.htaccess` *(신규)* | mod_rewrite 무관 `Require all denied` + PHP 실행차단 |
### DB
- `sql/migration_add_leak.sql` *(신규, 가산·멱등)* — 기존 온습도 서버에 누수 복원
(`sensor_log.is_leak`/`sensor_status.is_leak`, `event_type` ENUM에 leak 값,
`leak_incident`+`leak_photo` 테이블). `sensor_metric`(SHT30) 보존. 재실행 안전.
- `sql/schema_sht30.sql` — 신규(통합) 설치용. 누수 요소 포함.
---
## 5. 보안 하드닝 (외부 펜테스트 대비)
**2중 독립 리뷰 → 확정 CRITICAL/HIGH = 0.**
- 광범위 감사(6도메인 → 적대 반증검증): 고위험 후보 8건 **전부 거짓양성 반증**
(TOTP 재생·CRON_SECRET 유추·setup_mfa 재등록 등 — 비번/키 선보유 필요거나 시크릿 미영속화)
- 바운티 헌터(pre-auth 원격 익스플로잇): **바운티급 0건**. SQLi/경로순회/XSS/SSRF/업로드RCE/
`var/` 노출 전부 방어 확인.
적용한 하드닝(저위험, 무회귀):
1. `cron_heartbeat.php` — `api_key_is_default()` fail-closed (신규설치 예측가능 CRON_SECRET 무력화)
2. `setup_mfa.php` — MFA 설정완료 후 부트스트랩 토큰 경로 차단
3. `var/`·`var/photos/` — PHP 실행차단(`engine off` + FilesMatch + RemoveHandler) → 업로드→RCE 폴리글랏 봉쇄
4. `php/.htaccess` — HSTS 헤더 추가
미반영(문서화): CSP `unsafe-inline` 제거는 대시보드 인라인 스크립트/스타일 nonce 리팩터
필요 → 회귀위험으로 별도단계. (현 XSS 실위험 없음 — 출력 전부 `htmlspecialchars`)
---
## 6. 검증 내역
- **wire byte-parity:** `wire.py`(2번)·`leak_sensor.py`(1번) 모두 본문/서명이 펌웨어
`reference.py` 및 독립 HMAC과 바이트 일치(한글 UTF-8, `"`/`\` 이스케이프, round-half-up,
max uint32 ts 엣지케이스 포함). 사진 canonical 클라↔서버 동일.
- Python: 전 모듈 `py_compile` + import 그래프 + 자가점검(CRC `0xBEEF→0x92` 등) 통과.
- 적대 검증 2라운드(통합/보안) blocking 0.
---
## 7. 배포 절차
**각 라즈베리파이4** (모듈당 1대 또는 한 대에 둘 다 — 핀 충돌 없음)
1. 해당 폴더 업로드
2. `cp config_local.example.py config_local.py` → `API_URL`/`API_KEY`/`DEVICE_ID`(+1번 `PHOTO_UPLOAD_URL`)
3. `./install.sh` → systemd enable
**cafe24 서버 (1회)**
1. DB: 기존 온습도 서버 → `sql/migration_add_leak.sql`; 신규 → `sql/schema_sht30.sql`
2. `php/` 업로드 (신규 `photo.php`, `api/photo_upload.php`, `var/.htaccess` 포함)
3. `php/var/photos/` 웹서버 쓰기권한
4. `config.local.php`의 `API_KEY` = 라즈베리파이와 동일
---
## 8. 배포 전 체크리스트
- [ ] cafe24(PHP 있음)에서 문법/테스트:
`php -l php/cron_heartbeat.php php/setup_mfa.php php/dashboard.php php/photo.php php/api/photo_upload.php`
/ `php tests/security_hardening_test.php` *(작업 환경엔 PHP 없어 미실행)*
- [ ] `migration_add_leak.sql` 1회 적용
- [ ] `php/var/photos/` 쓰기권한
- [ ] 운영 위생: `API_KEY` 실값 설정 + MFA 등록 후 `MFA_SETUP_TOKEN` 비우기
---
## 9. 주의 / 리스크
- ⚠️ 본 PR은 **세션 외 기존 미커밋 변경**(docs/firmware/기타 php 등)도 함께 포함(사용자 요청).
순수 이 작업분만 보려면 핵심 경로는 `raspberry_pi4*/`, `php/api/`, `php/photo.php`,
`sql/migration_add_leak.sql`, `SYSTEM_OVERVIEW.md`.
- PHP 정적 실행 검증은 작업 환경 제약으로 미수행 → 위 `php -l` 게이트 필수.
- 폐쇄망: `requests`/`smbus2` 등 의존성은 `vendor.sh`로 사전 번들(aarch64).

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 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다. `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_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 참조. - 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. - 측정 주기: 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` | 반영 | | 관리자 TOTP MFA | `login.php`, `setup_mfa.php`, `admin_security.php`, `ADMIN_TOTP_SECRET` | 반영 |
| MFA 최초 등록 토큰 | `setup_mfa.php`, `MFA_SETUP_TOKEN` | 반영 | | MFA 최초 등록 토큰 | `setup_mfa.php`, `MFA_SETUP_TOKEN` | 반영 |
| 관리자 감사로그 | `admin_security.php`, `php/var/admin_audit.log` | 반영 | | 관리자 감사로그 | `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%) | 반영 | | 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 |
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 | | 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 |
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 | | 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
@ -21,6 +21,18 @@
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 | | 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 | | 보안 증적 보고서 | `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. 수정한 기존 문서 ## 2. 수정한 기존 문서
| 문서 | 반영 내용 | | 문서 | 반영 내용 |

View file

@ -90,6 +90,8 @@ HTTPS 적용 여부: 적용 / 미적용 / 확인 필요
DB 종류: Cafe24 MySQL 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` 폴더가 보이는 화면 - Cafe24 파일 관리자에서 `sht30_monitor` 폴더가 보이는 화면
@ -258,6 +260,7 @@ DB 백업 주기: 매일 / 매주 / 매월
3. 서버 설치 경로: 3. 서버 설치 경로:
예) public_html/sht30_monitor/ 예) public_html/sht30_monitor/
※ 실제 폴더명은 펌웨어 `app_config.h` 의 `APP_API_PATH` 와 동일해야 함
4. 단말 설치 위치: 4. 단말 설치 위치:
예) 서버실 서버랙 하단 예) 서버실 서버랙 하단

View file

@ -5,6 +5,8 @@
> ⚠️ HWP 바이너리는 직접 편집할 수 없습니다. 이 문서는 운영자가 HWP 본문에 반영하는 것을 전제로 한 **before → after 갱신 가이드**입니다. 인용한 라인 번호는 `docs/assets/security_plan/hwp_extracted_text.txt`(현재 HWP 추출본, 편집 금지) 기준이며 참고용입니다. > ⚠️ 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)를 함께 적용합니다. 심사 대응 수준의 상세 보완안은 [SECURITY_PLAN_PASS_READINESS.md](SECURITY_PLAN_PASS_READINESS.md)를 함께 적용합니다.
## 1. 요약 판단 ## 1. 요약 판단

View file

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

View file

@ -106,7 +106,7 @@ SHT30_SENSOR_ID=2
SHT30_I2C_ADDR=0x44 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. 대시보드 결과 ## 4. 대시보드 결과

View file

@ -59,13 +59,13 @@ HWP 반영 문구:
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` - `php/config.php`
- `verify_signature_raw(string $raw_body): bool` - `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()`로 상수시간 서명 비교 - `hash_equals()`로 상수시간 서명 비교
- 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h` - 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h`
- `APP_API_KEY` 기반 raw-body 서명 생성 - `APP_API_KEY` 기반 raw-body 서명 생성
@ -237,11 +237,13 @@ HWP 반영 문구:
curl -I https://example.com/sht30_monitor/login.php curl -I https://example.com/sht30_monitor/login.php
``` ```
> 위 `sht30_monitor` 는 예시 웹루트 폴더명이며, 실제 경로는 펌웨어 `firmware/common/app_config.h` 의 `APP_API_PATH`(예: `/raspi_leck_detecter/...`)와 동일하게 맞춰야 한다.
## 3. 내가 만들어줄 수 있는 HWP 첨부 문구 ## 3. 내가 만들어줄 수 있는 HWP 첨부 문구
아래 문구는 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 기반 상시 모니터링 체계 구축』을 위한 보안대책서 『IoT 기반 상시 모니터링 체계 구축』을 위한 보안대책서
2026. 5. 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 해결로 단순화) ### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨): 서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
- 서버 검증식: `sha256(API_KEY . 요청_본문_바이트_그대로)`, 값은 HTTP 헤더 **`X-Signature`**로 전송. - 서버 검증식: `HMAC-SHA256(키=API_KEY, 메시지=요청_본문_바이트_그대로)`(소문자 hex), 값은 HTTP 헤더 **`X-Signature`**로 전송. HMAC을 사용하므로 길이확장 공격에 견고하다.
- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 SHA-256으로 서명만 하면 된다. - 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 API_KEY 로 HMAC-SHA256 서명만 하면 된다.
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거). → 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (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`. 3. `POST` 시 `Content-Type: application/json` + `X-Signature: <sig>` 헤더, 본문=`body`.
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시). 4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**. - 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
@ -147,7 +147,7 @@ main()
```c ```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 그대로 전송 // 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); m->metric_status, m->app_version, (long)m->timestamp);
} }
// sha256(API_KEY || body) → hex. body는 전송 바이트와 100% 동일해야 한다. // HMAC-SHA256(key=API_KEY, msg=body) → hex. body는 전송 바이트와 100% 동일해야 한다.
void sig_sha256_hex(const char *body, char hex[65]) { // HMAC 구조이므로 키-접두 sha256과 달리 길이확장 공격에 견고하다.
uint8_t d[32]; mbedtls_sha256_context c; void sig_raw_body(const char *key, const char *body, char hex[65]) {
mbedtls_sha256_init(&c); mbedtls_sha256_starts(&c, 0); uint8_t d[32];
mbedtls_sha256_update(&c, (const uint8_t*)API_KEY, strlen(API_KEY)); const mbedtls_md_info_t *md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_sha256_update(&c, (const uint8_t*)body, strlen(body)); mbedtls_md_hmac(md, (const uint8_t*)key, strlen(key),
mbedtls_sha256_finish(&c, d); mbedtls_sha256_free(&c); (const uint8_t*)body, strlen(body), d);
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]); for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
} }
// POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송. // POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송.

View file

@ -67,7 +67,9 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
- 첫 측정값(온도/습도) 및 startup 보고 - 첫 측정값(온도/습도) 및 startup 보고
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답 - 네트워크/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 │ │ 스위치/공유기 │ └──────────────┘ │ 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 호스팅 (변경 없음) │ │ Cafe24 호스팅 (변경 없음) │
@ -40,7 +40,7 @@
``` ```
- 펌웨어는 매 보고마다 `connect → TLS 핸드셰이크 → POST → close` 를 수행한다(메모리 절약, 보고 주기가 길어 성능 무관). - 펌웨어는 매 보고마다 `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_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함). - **임계(고온/저온/고습/저습) 판정과 SMS 발송은 서버(PHP)에서 수행**한다. 펌웨어는 온습도 원값과 plausibility 상태(`metric_status`)만 보고하고, 경보 임계는 서버 `config.php` 의 `METRIC_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함).
--- ---
@ -74,7 +74,7 @@
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) | | 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) | | 로깅 | 로테이팅 파일 로그 | 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) 참고. 자세한 함수 단위 매핑은 [`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 단일 타깃) # 2) 빌드 (sht30_fw 단일 타깃)
cmake -S firmware -B firmware/build -G Ninja \ 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 cmake --build firmware/build --target sht30_fw # → sht30_fw.bin/.hex/.elf
# 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정 # 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정
@ -119,7 +119,7 @@ firmware/
├─ README.md ← (이 문서) ├─ README.md ← (이 문서)
├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합 ├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합
├─ cmake/ ├─ cmake/
│ └─ arm-none-eabi.cmake 크로스 툴체인 파일 (gcc 15.2) │ └─ arm-none-eabi-toolchain.cmake 크로스 툴체인 파일 (gcc 15.2)
├─ scripts/ ├─ scripts/
│ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비) │ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비)
│ ├─ build.ps1 cmake configure + sht30_fw 빌드 │ ├─ build.ps1 cmake configure + sht30_fw 빌드

View file

@ -39,7 +39,15 @@
#define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */ #define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */
/* ── 시간 동기 (SNTP) — TLS 인증서 유효기간/timestamp 필드에 필수 ────────── */ /* ── 시간 동기 (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 #define APP_SNTP_RETRY_DELAY_MS 15000u
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */ /* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */

View file

@ -236,15 +236,15 @@ void net_ip_str(char *buf, uint32_t buflen)
} }
} }
/* ── LWIP_RAND() 백엔드 ───────────────────────────────────────────────────── /* ── LWIP_RAND() 백엔드 (약한 폴백) ─────────────────────────────────────────
* lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 하드웨어 RNG 가 * lwipopts.h 의 LWIP_RAND() 가 bsp_rand32() 를 호출한다. 운영 펌웨어(sht30_fw)는
* 정식 구현(rng.c/bsp.c)될 때까지의 약한 대체 구현. RNG 페리페럴이 준비되면 * bsp.c 의 *강한* 하드웨어 RNG 구현(bsp_rand32, RNG->DR)이 이 weak 심볼을 덮어쓰므로
* 그쪽 strong 심볼이 우선하도록 weak 로 둔다. * 실제로는 HW RNG 가 사용된다(TCP ISN / DHCP·DNS 트랜잭션 ID 예측성 완화). 이 weak
* TODO(hw): bsp/rng 모듈에서 HW RNG(RNG->DR) 기반 강한 bsp_rand32() 제공. */ * 구현은 bsp.c 를 링크하지 않는 빌드(예: 호스트 패리티)에서만 쓰이는 폴백이다. */
__attribute__((weak)) uint32_t bsp_rand32(void) __attribute__((weak)) uint32_t bsp_rand32(void)
{ {
/* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합). /* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합).
* 운영에서는 반드시 HW RNG 구현으로 대체. */ * 운영 빌드에서는 bsp.c 의 강한 HW RNG 구현이 이 폴백을 대체한다. */
static uint32_t s = 0x1234abcdU; static uint32_t s = 0x1234abcdU;
s ^= (uint32_t)xTaskGetTickCount(); s ^= (uint32_t)xTaskGetTickCount();
s ^= s << 13; s ^= s << 13;

View file

@ -6,7 +6,7 @@
* secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록). * secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록).
* *
* APP_API_KEY 는 서버 php/config.php 의 API_KEY 와 반드시 동일해야 한다. * 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 #ifndef SECRETS_H
#define SECRETS_H #define SECRETS_H

View file

@ -16,7 +16,7 @@
#define SIG_HEX_LEN 64 /* SHA-256 hex 길이 (NUL 제외) */ #define SIG_HEX_LEN 64 /* SHA-256 hex 길이 (NUL 제외) */
#define SIG_HEX_BUFSZ (SIG_HEX_LEN + 1) #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 바이트. * out_hex 는 최소 SIG_HEX_BUFSZ 바이트.
* key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */ * key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */
void sig_raw_body(const char *key, const char *body, size_t 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_NETIF_TX_SINGLE_PBUF 1 /* 송신 시 단일 pbuf 선호(HAL_ETH 단순화) */
#define LWIP_RAND() ((u32_t)bsp_rand32()) #define LWIP_RAND() ((u32_t)bsp_rand32())
/* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수(bsp/별도 모듈 제공). /* LWIP_RAND 용: 하드웨어 RNG 기반 32비트 난수.
* TODO(hw): bsp.c 또는 rng.c 에서 bsp_rand32() 를 RNG 페리페럴로 구현. * 운영 펌웨어(sht30_fw)는 bsp.c 가 RNG 페리페럴(RNG->DR) 기반 강한 bsp_rand32() 를
* (현재 net.c 에 약한 대체 구현이 있을 수 있음) */ * 제공하며, net.c 의 weak 폴백을 덮어쓴다(bsp.c 미링크 빌드에서만 weak 폴백 사용). */
uint32_t bsp_rand32(void); uint32_t bsp_rand32(void);
#endif /* LWIPOPTS_H */ #endif /* LWIPOPTS_H */

View file

@ -51,10 +51,10 @@ firmware/third_party/
```bash ```bash
# 빌드 머신(인터넷 가능)에서 1회 실행. 고정 버전을 받아 third_party/ 로 정리한다. # 빌드 머신(인터넷 가능)에서 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). > STM32CubeF4 `v1.28.x`, FreeRTOS-Kernel `V11.x`, lwip `STABLE-2.2.x`, mbedtls `v3.6.x` (LTS).
> 스크립트 미제공 시: 각 릴리스 tarball 을 수동으로 받아 위 디렉터리 구조로 풀고, **버전을 기록**한다. > 스크립트 미제공 시: 각 릴리스 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` 와 바이트 단위로 동일**하게 채운다. `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` 안에는 키가 임베드되므로 > - `secrets.h` 는 **저장소에 커밋하지 않는다**(`.gitignore` 등록). 산출 `.bin` 안에는 키가 임베드되므로
> `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제). > `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제).
@ -126,7 +126,7 @@ const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
```bash ```bash
# 구성 (크로스 툴체인 파일 지정) # 구성 (크로스 툴체인 파일 지정)
cmake -S firmware -B firmware/build -G Ninja \ 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 은 타깃이 자동 주입) # SHT30 보드 산출물 빌드 (보드 매크로 -DBOARD_SHT30 은 타깃이 자동 주입)
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex 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건을 보장한다. > 빌드 중 인터넷 접근이 발생하면 **벤더링 누락**이다 — `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 (선택) 호스트 패리티 게이트 — 서버 기대값 대조 ### 4.1 (선택) 호스트 패리티 게이트 — 서버 기대값 대조
`.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다. `.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다.
```bash ```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 python3 firmware/test/host/parity_test.py
php firmware/test/host/php_verify.php # 동일 본문에 대한 서버측 서명 기대값 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`. - 7-bit 주소: **0x44** (`APP_SHT30_I2C_ADDR`). HAL 호출 시 `0x44<<1`.
- 명령: `0x2C06` (high-repeatability, clock-stretch off) → ~20ms 대기 → 6바이트 read. - 명령: `0x2C06` (high-repeatability, clock-stretch off) → ~20ms 대기 → 6바이트 read.
- 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장. - 풀업: 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_*` 상수**가 판정한다(펌웨어는 원값만 보고). 운영 경보 임계(고온/저온/고습/저습 → 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 SHT30 모듈 STM32

View file

@ -84,7 +84,7 @@ SHT30 시나리오: `startup` → (300s 마다) `periodic` 측정 보고. 서버
| **TLS 해시/암호** | OS OpenSSL | **소프트웨어**(SHA-256/AES) | F407 에 HW 해시 가속기 없음. RSA-2048 핸드셰이크 ~1–2s 허용 | | **TLS 해시/암호** | OS OpenSSL | **소프트웨어**(SHA-256/AES) | F407 에 HW 해시 가속기 없음. RSA-2048 핸드셰이크 ~1–2s 허용 |
| **동시성** | Python threads + Lock | FreeRTOS tasks/queue/timer (네이티브 API) | RTOS. 태스크 스택은 CCM(64KB) 배치 | | **동시성** | Python threads + Lock | FreeRTOS tasks/queue/timer (네이티브 API) | RTOS. 태스크 스택은 CCM(64KB) 배치 |
| **시간원** | OS NTP | SNTP→RTC (TLS 전에 수행) | UDP SNTP 는 TLS 불필요 → 닭-달걀 회피 | | **시간원** | 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` 처리 제거 | | **본문 필드(wire)** | `is_leak` 포함(누수 시스템 잔재) | **`is_leak` 필드 제거** | 단일 SHT30 보드에 무의미. `jb_sht30_event` 가 더 이상 직렬화하지 않으며, 서버 `sensor_data.php` 도 `is_leak` 처리 제거 |
| **임계 판정/SMS** | (서버측) | **서버 `config.php` METRIC_* 에서 판정** | 폐쇄망 재플래시 비용 회피. 펌웨어는 원값 + `metric_status` 만 보고(§4) | | **임계 판정/SMS** | (서버측) | **서버 `config.php` METRIC_* 에서 판정** | 폐쇄망 재플래시 비용 회피. 펌웨어는 원값 + `metric_status` 만 보고(§4) |
| **설정** | env / `config.py` | 컴파일 타임 `app_config.h`/`board_config.h` + `secrets.h` | 런타임 env 없음(옵션: flash 설정/UART 명령 미구현) | | **설정** | 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 ```c
/* 펌웨어 절차 (httpapi.c::api_post_once 가 내부 수행) */ /* 펌웨어 절차 (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 바이트 그대로 전송 */ /* HTTP 헤더에 "X-Signature: <sig_hex>", 본문은 body 바이트 그대로 전송 */
``` ```
@ -119,6 +119,8 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
1. **펌웨어(측정·보고)**: 300s 마다 SHT30 을 읽어 `temperature_c`/`humidity_percent` **원값**(소수 2자리)을 계산하고, 1. **펌웨어(측정·보고)**: 300s 마다 SHT30 을 읽어 `temperature_c`/`humidity_percent` **원값**(소수 2자리)을 계산하고,
센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해 센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해
`sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.** `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_*` 임계와 비교해 2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해
고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다. 고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다.
@ -155,7 +157,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
## 6. 검증 게이트 (이식 정확성 확인) ## 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 동일 다이제스트). `verify_signature_raw` 기대값과 일치하는지 대조(순수 로직: 서명/JSON/CRC/변환은 호스트·ARM 동일 다이제스트).
- **E2E**: 스테이징 PHP 로 `startup/periodic`(SHT30) 200 수신, `temperature_c`/`humidity_percent`/`metric_status` 적재 + - **E2E**: 스테이징 PHP 로 `startup/periodic`(SHT30) 200 수신, `temperature_c`/`humidity_percent`/`metric_status` 적재 +
서버 임계 초과 시 SMS 트리거 확인. 서버 임계 초과 시 SMS 트리거 확인.

View file

@ -32,7 +32,10 @@ param(
[switch]$Clean, [switch]$Clean,
[string]$ToolchainPrefix [string]$ToolchainPrefix,
# 자리표시자(PLACEHOLDER) 서버 CA 상태로도 빌드를 강행한다(테스트 전용).
[switch]$AllowPlaceholderCA
) )
$ErrorActionPreference = 'Stop' $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)) { if ($Clean -and (Test-Path $BuildDir)) {
Write-Host " (clean) 기존 빌드 디렉터리 삭제: $BuildDir" -ForegroundColor Yellow Write-Host " (clean) 기존 빌드 디렉터리 삭제: $BuildDir" -ForegroundColor Yellow

View file

@ -31,7 +31,9 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[switch]$Force, [switch]$Force,
[switch]$KeepArchives [switch]$KeepArchives,
# 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 실패(릴리스 빌드용).
[switch]$Strict
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@ -136,6 +138,9 @@ foreach ($dep in $Deps) {
# ── 무결성 검증(SHA-256) ──────────────────────────────────────────────── # ── 무결성 검증(SHA-256) ────────────────────────────────────────────────
$actual = Get-FileSha256 $archive $actual = Get-FileSha256 $archive
if ($dep.Sha256 -eq 'TODO' -or [string]::IsNullOrWhiteSpace($dep.Sha256)) { 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 " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:" -ForegroundColor Yellow
Write-Host " $name : $actual" -ForegroundColor Yellow Write-Host " $name : $actual" -ForegroundColor Yellow
} }

View file

@ -11,6 +11,7 @@
# firmware/scripts/vendor.sh # 표준 벤더링 # firmware/scripts/vendor.sh # 표준 벤더링
# FORCE=1 firmware/scripts/vendor.sh # 기존 트리 덮어쓰기(재벤더링) # FORCE=1 firmware/scripts/vendor.sh # 기존 트리 덮어쓰기(재벤더링)
# KEEP_ARCHIVES=1 firmware/scripts/vendor.sh # 압축본 보존(_archives) # KEEP_ARCHIVES=1 firmware/scripts/vendor.sh # 압축본 보존(_archives)
# VENDOR_STRICT=1 firmware/scripts/vendor.sh # 핀(SHA-256) 미설정 시 실패(릴리스용)
# #
# 요구 도구: curl(또는 wget), sha256sum(또는 shasum), unzip, tar. # 요구 도구: curl(또는 wget), sha256sum(또는 shasum), unzip, tar.
# #
@ -28,6 +29,9 @@ ARCHIVE_DIR="${THIRD_PARTY}/_archives"
FORCE="${FORCE:-0}" FORCE="${FORCE:-0}"
KEEP_ARCHIVES="${KEEP_ARCHIVES:-0}" KEEP_ARCHIVES="${KEEP_ARCHIVES:-0}"
# 엄격 핀 모드: SHA-256 이 'TODO'(미설정)인 의존성이 있으면 즉시 실패한다. 릴리스
# 빌드는 VENDOR_STRICT=1 로 실행해 모든 핀이 채워졌는지(재현성 고정) 강제한다.
VENDOR_STRICT="${VENDOR_STRICT:-0}"
mkdir -p "${THIRD_PARTY}" mkdir -p "${THIRD_PARTY}"
[ "${KEEP_ARCHIVES}" = "1" ] && mkdir -p "${ARCHIVE_DIR}" [ "${KEEP_ARCHIVES}" = "1" ] && mkdir -p "${ARCHIVE_DIR}"
@ -108,6 +112,12 @@ for entry in "${DEPS[@]}"; do
# ── 무결성 검증 ──────────────────────────────────────────────────────────── # ── 무결성 검증 ────────────────────────────────────────────────────────────
actual="$(sha256_of "${archive}")" actual="$(sha256_of "${archive}")"
if [ "${sha256}" = "TODO" ] || [ -z "${sha256}" ]; then 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 " [경고] SHA-256 미설정 — 검증 생략. 계산값을 기록하세요:"
echo " ${name} : ${actual}" echo " ${name} : ${actual}"
elif [ "${actual}" != "$(echo "${sha256}" | tr 'A-Z' 'a-z')" ]; then elif [ "${actual}" != "$(echo "${sha256}" | tr 'A-Z' 'a-z')" ]; then

View file

@ -8,21 +8,54 @@ parity_test.py - 펌웨어 wire 계약을 서버(PHP)와 교차검증한다.
3. 본문/서명 변조 시 서버가 거부한다. 3. 본문/서명 변조 시 서버가 거부한다.
4. SHT3x CRC8 및 변환식이 데이터시트 벡터와 일치한다. 4. SHT3x CRC8 및 변환식이 데이터시트 벡터와 일치한다.
5. fixed2() 소수 포맷이 기대 문자열과 일치한다(펌웨어 jb_fixed2 와 동일). 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 와 동일 알고리즘으로 reference.py 는 펌웨어 jsonbody.c/sht30_convert.c 와 동일 알고리즘의 Python 미러이고,
작성되어 있어, 본 테스트 통과는 펌웨어 wire 계약이 서버와 호환됨을 의미한다. 서명은 항목 6 에서 펌웨어 sig.c 를 실제로 컴파일해 직접 대조하므로, 본 테스트 통과는
펌웨어 wire 계약이 서버와 호환됨을 의미한다.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
import reference as ref import reference as ref
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
PHP_VERIFY = HERE / "php_verify.php" 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] = [] failures: list[str] = []
@ -76,6 +109,12 @@ def main() -> int:
api_key = php_api_key() api_key = php_api_key()
check(api_key != "", "PHP API_KEY should be readable") 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) ───── # ── 1~3. SHT30 이벤트 (startup/periodic, 정상/경계/음수/정수값 float) ─────
sht_cases = [ sht_cases = [
("startup", 24.0, 48.5, "normal"), ("startup", 24.0, 48.5, "normal"),
@ -99,6 +138,16 @@ def main() -> int:
) )
assert_server_accepts(body, api_key, f"sht30/{ev}/{t}/{h}") 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 / 변환 데이터시트 벡터 ───────────────────────────────── # ── 4. SHT3x CRC8 / 변환 데이터시트 벡터 ─────────────────────────────────
# 데이터시트: CRC8(0xBE,0xEF) = 0x92 # 데이터시트: CRC8(0xBE,0xEF) = 0x92
check(ref.sht30_crc8(bytes([0xBE, 0xEF])) == 0x92, "SHT3x CRC8 datasheet vector (0xBEEF -> 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-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin" 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 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 Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
Header always unset X-Powered-By Header always unset X-Powered-By
</IfModule> </IfModule>

View file

@ -143,3 +143,23 @@ function admin_audit_log_path(): string {
function admin_mfa_configured(): bool { function admin_mfa_configured(): bool {
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== ''; 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 <?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_id": "stm32-sht30-01",
// "device_location": "서버실", // "device_location": "서버실",
@ -17,6 +18,19 @@
// "app_version": "v2606-sht30" // "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가지] // [인증 방식 2가지]
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body) // 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음 // - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
@ -58,6 +72,7 @@ require_once dirname(__DIR__) . '/cron_heartbeat.php';
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증) // 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
if (api_key_is_default()) { if (api_key_is_default()) {
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요."); error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
record_ingest_rejection('key_default');
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503); 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'); $raw = file_get_contents('php://input');
@ -90,12 +152,14 @@ $raw = file_get_contents('php://input');
if (strlen($raw) > INGEST_MAX_BODY_BYTES) { if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from " error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?')); . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('body_too_large');
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413); json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
} }
$data = json_decode($raw, true); $data = json_decode($raw, true);
if (!$data) { if (!$data) {
record_ingest_rejection('invalid_json');
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400); json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
} }
@ -116,6 +180,7 @@ if ($has_raw_sig) {
} }
if (!$sig_ok) { if (!$sig_ok) {
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})"); error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
record_ingest_rejection('signature');
json_response(['status' => 'error', 'message' => '인증 실패'], 403); json_response(['status' => 'error', 'message' => '인증 실패'], 403);
} }
@ -123,6 +188,7 @@ if (!$sig_ok) {
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp']; $required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
foreach ($required as $field) { foreach ($required as $field) {
if (!isset($data[$field])) { if (!isset($data[$field])) {
record_ingest_rejection('missing_field');
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400); 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_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100)); $device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
$sensor_id = (int)$data['sensor_id']; $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)); $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'; ? $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']; $ts = (int)$data['timestamp'];
// ── 재전송(replay) 방어: timestamp 신선도 검증 ──────────── // ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
@ -143,6 +225,7 @@ $now = time();
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) { 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 " error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
. ($_SERVER['REMOTE_ADDR'] ?? '?')); . ($_SERVER['REMOTE_ADDR'] ?? '?'));
record_ingest_rejection('timestamp');
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400); json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
} }
@ -172,22 +255,37 @@ $metric_status = $metric_eval['status'];
$metric_saved = false; $metric_saved = false;
// ── DB 저장 ─────────────────────────────────────────────── // ── DB 저장 ───────────────────────────────────────────────
// 주기(periodic) 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 // 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록. // (대시보드 로그 정리). startup 은 1회 sensor_log 기록.
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic' && $has_metric_payload); $skip_log = ($event_type === 'periodic' && $has_metric_payload);
$log_id = null; $log_id = null;
try { try {
$db = get_db(); $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) { if (!$skip_log) {
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sensor_log INSERT INTO sensor_log
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
event_type, sensor_time, created_at) event_type, is_leak, sensor_time, created_at)
VALUES VALUES
(:device_id, :device_location, :sensor_id, :sensor_name, (:device_id, :device_location, :sensor_id, :sensor_name,
:event_type, :sensor_time, NOW()) :event_type, :is_leak, :sensor_time, NOW())
"); ");
$stmt->execute([ $stmt->execute([
':device_id' => $device_id, ':device_id' => $device_id,
@ -195,6 +293,7 @@ try {
':sensor_id' => $sensor_id, ':sensor_id' => $sensor_id,
':sensor_name' => $sensor_name, ':sensor_name' => $sensor_name,
':event_type' => $event_type, ':event_type' => $event_type,
':is_leak' => $is_leak,
':sensor_time' => $sensor_dt, ':sensor_time' => $sensor_dt,
]); ]);
@ -211,18 +310,22 @@ try {
} }
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ── // ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
// is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare(" $stmt2 = $db->prepare("
INSERT INTO sensor_status INSERT INTO sensor_status
(sensor_id, device_id, sensor_name, device_location, (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 VALUES
(:sensor_id, :device_id, :sensor_name, :device_location, (:sensor_id, :device_id, :sensor_name, :device_location,
:event_type, NOW(), 0) :event_type, :is_leak_ins, NOW(), 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
device_id = VALUES(device_id), device_id = VALUES(device_id),
sensor_name = VALUES(sensor_name), sensor_name = VALUES(sensor_name),
device_location = VALUES(device_location), device_location = VALUES(device_location),
last_event_type = VALUES(last_event_type), last_event_type = VALUES(last_event_type),
is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak),
last_seen = NOW(), last_seen = NOW(),
offline_alerted = 0 offline_alerted = 0
"); ");
@ -232,6 +335,8 @@ try {
':sensor_name' => $sensor_name, ':sensor_name' => $sensor_name,
':device_location' => $device_location, ':device_location' => $device_location,
':event_type' => $event_type, ':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')) { if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) {
@ -259,6 +364,52 @@ try {
$metric_saved = true; $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 발송 ───────────────────── // ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
if ($was_offline) { if ($was_offline) {
$online_info = [ $online_info = [
@ -273,6 +424,10 @@ try {
} catch (PDOException $e) { } catch (PDOException $e) {
error_log("[API] DB 오류: " . $e->getMessage()); 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); json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
} }
@ -289,8 +444,13 @@ if ($has_metric_payload) {
if (!empty($metric_eval['breaches'])) { if (!empty($metric_eval['breaches'])) {
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용) // 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
foreach ($metric_eval['breaches'] as $kind) { foreach ($metric_eval['breaches'] as $kind) {
$value = in_array($kind, ['high_temp', 'low_temp'], true) if ($kind === 'condensation_risk') {
? $temperature_c : $humidity_percent; $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)) { if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
$sms_sent = true; $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분 쓰로틀) ── // ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
check_offline_sensors_throttled(); check_offline_sensors_throttled();
@ -316,4 +492,8 @@ json_response([
'metric_saved' => $metric_saved, 'metric_saved' => $metric_saved,
'metric_status' => $metric_status, 'metric_status' => $metric_status,
'sms_sent' => $sms_sent, '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', '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_USER_ID' => 'your-cafe24-sms-user-id',
'SMS_SECURE' => 'your-cafe24-sms-secure-key', 'SMS_SECURE' => 'your-cafe24-sms-secure-key',
'SMS_SENDER' => '01000000000', 'SMS_SENDER' => '01000000000',
@ -43,6 +48,13 @@ return [
'METRIC_RH_LOW' => 20, // 저습 임계(%) 'METRIC_RH_LOW' => 20, // 저습 임계(%)
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분) '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, 'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365, 'SENSOR_LOG_RETENTION_DAYS' => 365,
'SENSOR_METRIC_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 를 차단한다. // 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다.
define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192)); 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) 수용 여부. 기본 비활성. // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다. // 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다.
// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다. // 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다.
@ -66,6 +72,61 @@ if (is_string($sms_recipients)) {
} }
define('SMS_RECIPIENTS', $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) ────────────────────────── // ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다. // 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이 // 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
@ -80,6 +141,56 @@ define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분. // 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)); 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 (장비 오프라인 감지) ──────────────────────── // ─── Heartbeat (장비 오프라인 감지) ────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본 // SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. // 오프라인 판정은 약 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_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps 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('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 ──────────────────────────────────────────────────────── // ─── Retention policy ────────────────────────────────────────────────────────
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365)); 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; 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) ─────── // ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트 // 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다. // 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.

View file

@ -62,7 +62,14 @@ function check_offline_sensors(): int {
'last_seen' => $sensor['last_seen'], '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++; $count++;
error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})"); error_log("[Heartbeat] 오프라인 감지 - 센서{$sensor['sensor_id']} ({$sensor['sensor_name']})");
@ -120,6 +127,19 @@ function check_offline_sensors_throttled(): void {
// heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨 // heartbeat 실패가 호출자(dashboard, sensor_data)를 죽이면 안 됨
error_log("[Heartbeat] 체크 실패: " . $e->getMessage()); 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'); $is_cli = (php_sapi_name() === 'cli');
if (!$is_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'] ?? ''; $key = $_GET['key'] ?? '';
if (!hash_equals(CRON_SECRET, $key)) { if (!hash_equals(CRON_SECRET, $key)) {
http_response_code(403); http_response_code(403);
@ -141,6 +170,13 @@ if ($_is_direct) {
} }
$count = check_offline_sensors(); $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) { if (!$is_cli) {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');

View file

@ -39,15 +39,9 @@ if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
exit; exit;
} }
// 세션 타임아웃 확인 // 세션 타임아웃(유휴 + 절대 수명) 강제 — 모든 인증 페이지 공통 헬퍼(admin_security.php).
if (isset($_SESSION['login_time']) && (time() - $_SESSION['login_time']) > SESSION_TIMEOUT) { // 유휴 타이머 갱신도 헬퍼가 처리한다.
session_destroy(); admin_enforce_session_timeout();
header('Location: login.php?expired');
exit;
}
// 활동 시 로그인 시간 갱신
$_SESSION['login_time'] = time();
// ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ────── // ── Heartbeat: 오프라인 센서 체크 (HEARTBEAT_CHECK_INTERVAL_SEC 쓰로틀) ──────
check_offline_sensors_throttled(); check_offline_sensors_throttled();
@ -75,6 +69,20 @@ function metric_display(?string $value, string $unit): string {
return number_format((float)$value, 1) . $unit; 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(); $statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll();
@ -159,9 +167,9 @@ $recent_metrics = $db->query("
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic) ──── // ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ──
$logs = $db->query(" $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 FROM sensor_log
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
@ -178,14 +186,7 @@ $sms_logs = $db->query("
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준) // 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준)
$sms_summary_rows = $db->query(" $sms_summary_rows = $db->query("
SELECT SELECT
SUM(message LIKE '[고온경보]%') AS high_temp, " . sms_summary_select_sql() . "
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
FROM sms_log FROM sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
@ -196,8 +197,10 @@ $summary = [
'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0), 'high_humidity' => (int)($sms_summary_rows['high_humidity'] ?? 0),
'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0), 'low_humidity' => (int)($sms_summary_rows['low_humidity'] ?? 0),
'recovery' => (int)($sms_summary_rows['recovery'] ?? 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), '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), 'sms_fail' => (int)($sms_summary_rows['sms_fail'] ?? 0),
]; ];
$summary['alert_total'] = $summary['high_temp'] + $summary['low_temp'] $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'])); $alert_count = count(array_filter($statuses, fn($s) => $s['is_alerting'] && !$s['is_offline']));
$normal_count = count($statuses) - $offline_count - $alert_count; $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) { if ($alert_count > 0) {
$system_state = 'danger'; $system_state = 'danger';
$system_title = '온습도 경보 발생'; $system_title = '온습도 경보 발생';
@ -256,7 +315,7 @@ if ($alert_count > 0) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>온습도 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title> <title>온습도·누수 통합 모니터링 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333; } 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; } .legend-swatch { width: 14px; height: 4px; border-radius: 2px; display: inline-block; }
.chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } .chart-svg-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.chart-empty { color: #94a3b8; padding: 24px; text-align: center; } .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; .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch;
margin-bottom: 24px; border-radius: 8px; } margin-bottom: 24px; border-radius: 8px; }
@ -416,7 +486,7 @@ if ($alert_count > 0) {
</head> </head>
<body> <body>
<header style="display:flex;align-items:center;justify-content:space-between"> <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"> <div class="top-actions">
<a href="setup_wizard.php">설치 점검</a> <a href="setup_wizard.php">설치 점검</a>
<a href="monthly_report.php">월간 보고</a> <a href="monthly_report.php">월간 보고</a>
@ -437,6 +507,39 @@ if ($alert_count > 0) {
</div> </div>
</section> </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="모바일 빠른 조회"> <section class="mobile-quick" aria-label="모바일 빠른 조회">
<div class="quick-grid"> <div class="quick-grid">
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section"> <a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
@ -458,6 +561,7 @@ if ($alert_count > 0) {
</div> </div>
<nav class="quick-links" aria-label="빠른 이동"> <nav class="quick-links" aria-label="빠른 이동">
<a href="#sensors-section">센서 상태</a> <a href="#sensors-section">센서 상태</a>
<a href="#leak-section">누수 감지</a>
<a href="#chart-section">온습도 추이</a> <a href="#chart-section">온습도 추이</a>
<a href="#summary-section">운영 요약</a> <a href="#summary-section">운영 요약</a>
<a href="#metrics-section">측정 이력</a> <a href="#metrics-section">측정 이력</a>
@ -585,6 +689,105 @@ if ($alert_count > 0) {
<?php endif; ?> <?php endif; ?>
</div> </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, 외부 라이브러리 없음) --> <!-- 온습도 추이 차트 (인라인 SVG, 외부 라이브러리 없음) -->
<h2 id="chart-section">온습도 추이 <h2 id="chart-section">온습도 추이
<?php if ($chart_sensor_id > 0): ?> <?php if ($chart_sensor_id > 0): ?>
@ -746,6 +949,14 @@ if ($alert_count > 0) {
<span>장비 복구 SMS</span> <span>장비 복구 SMS</span>
<strong><?= $summary['device_online'] ?>건</strong> <strong><?= $summary['device_online'] ?>건</strong>
</div> </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"> <div class="report-stat">
<span>SMS 실패</span> <span>SMS 실패</span>
<strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong> <strong style="color:<?= $summary['sms_fail'] > 0 ? '#dc2626' : 'inherit' ?>"><?= $summary['sms_fail'] ?>건</strong>
@ -818,17 +1029,26 @@ if ($alert_count > 0) {
<tbody> <tbody>
<?php foreach ($logs as $row): <?php foreach ($logs as $row):
$event_labels = [ $event_labels = [
'startup' => '장비시작', 'startup' => '장비시작',
'periodic' => '정기보고', 'periodic' => '정기보고',
'leak_alert' => '누수감지',
'leak_recovery' => '누수복구',
]; ];
$evt = $row['event_type']; $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 class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sensor_time']) ?></td> <td><?= htmlspecialchars($row['sensor_time']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td> <td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td class="col-hide-xs"><?= htmlspecialchars($row['device_location']) ?></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> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($logs)): ?> <?php if (empty($logs)): ?>
@ -854,7 +1074,7 @@ if ($alert_count > 0) {
<td class="col-hide-xs"><?= (int)$row['id'] ?></td> <td class="col-hide-xs"><?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars($row['sent_at']) ?></td> <td><?= htmlspecialchars($row['sent_at']) ?></td>
<td><?= htmlspecialchars($row['sensor_name']) ?></td> <td><?= htmlspecialchars($row['sensor_name']) ?></td>
<td><?= htmlspecialchars($row['receivers']) ?></td> <td><?= htmlspecialchars(mask_phone($row['receivers'] ?? '')) ?></td>
<td> <td>
<?php if ($row['status'] === 'success'): ?> <?php if ($row['status'] === 'success'): ?>
<span class="ok-color">성공</span> <span class="ok-color">성공</span>

View file

@ -117,9 +117,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_erro
}); });
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['logged_in'] = true; $_SESSION['logged_in'] = true;
$_SESSION['login_time'] = time(); $_SESSION['login_time'] = time(); // 절대 수명 기준(이후 갱신하지 않음)
$_SESSION['username'] = $username; $_SESSION['last_activity'] = time(); // 유휴 타이머 기준(활동 시 갱신)
$_SESSION['username'] = $username;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
admin_audit_log('login_success', $username, true); 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'); header('Location: login.php');
exit; exit;
} }
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
function month_range(string $month): array { function month_range(string $month): array {
if (!preg_match('/^\d{4}-\d{2}$/', $month)) { 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')]; 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 { function report_sms_tags(): array {
return [ return [
'high_temp' => '[고온경보]', 'high_temp' => SMS_TAG_HIGH_TEMP,
'low_temp' => '[저온경보]', 'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => '[고습경보]', 'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => '[저습경보]', 'low_humidity' => SMS_TAG_LOW_HUMIDITY,
'recovery' => '[정상복귀]', 'recovery' => SMS_TAG_RECOVERY,
'device_offline' => '[장비 오프라인]', 'device_offline' => SMS_TAG_DEVICE_OFFLINE,
'device_online' => '[장비 복구]', 'device_online' => SMS_TAG_DEVICE_ONLINE,
]; ];
} }
@ -77,14 +79,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
// ── 월간 요약: SMS 태그별 건수 + 실패 건수 ─────────────────────────────────── // ── 월간 요약: SMS 태그별 건수 + 실패 건수 ───────────────────────────────────
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT SELECT
SUM(message LIKE '[고온경보]%') AS high_temp, " . sms_summary_select_sql() . "
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
FROM sms_log FROM sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at 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 태그별 건수 + 실패 건수 ─────────────────────────────────────── // ── 센서별 SMS 태그별 건수 + 실패 건수 ───────────────────────────────────────
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT sensor_id, sensor_name, SELECT sensor_id, sensor_name,
SUM(message LIKE '[고온경보]%') AS high_temp, " . sms_summary_select_sql() . "
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
FROM sms_log FROM sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name 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(); $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 최근 실패 건수'); $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) { } catch (Throwable $e) {
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면'); $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('설정', '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('설정', '관리자 비밀번호', 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', 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('관리자 인증', '감사로그', 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', '수신자 목록 현행화 확인표'); $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); $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'); header('Location: login.php');
exit; exit;
} }
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(대시보드 외 페이지에서도 강제)
$checks = collect_ops_checks(); $checks = collect_ops_checks();
$failed_count = ops_failed_count($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) { if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
return true; return true;
} }
// 토큰 경로는 초기 등록 전에만 허용한다. MFA가 이미 설정된 상태에서
// MFA_SETUP_TOKEN이 남아 있어도 해당 토큰으로 재등록 화면에 접근할 수 없게 한다.
// (운영자가 등록 후 토큰을 삭제하지 않았을 때의 잔류 접근 차단.)
if (admin_mfa_configured()) {
return false;
}
$token = $_GET['token'] ?? $_POST['token'] ?? ''; $token = $_GET['token'] ?? $_POST['token'] ?? '';
return MFA_SETUP_TOKEN !== '' && hash_equals(MFA_SETUP_TOKEN, (string)$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'); header('Location: login.php');
exit; exit;
} }
admin_enforce_session_timeout(); // 유휴 + 절대 수명 타임아웃(ops_checks → admin_security 경유 로드)
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@ -110,7 +111,7 @@ $failed_count = ops_failed_count($checks);
<section class="result"> <section class="result">
<strong>테스트 SMS 결과</strong> <strong>테스트 SMS 결과</strong>
<?php foreach ($sms_test_result as $row): ?> <?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; ?> <?php endforeach; ?>
</section> </section>
<?php endif; ?> <?php endif; ?>

View file

@ -6,6 +6,7 @@
// 담당 알림: // 담당 알림:
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms() // - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
// - 온습도 정상 복귀 : send_metric_recovery_sms() // - 온습도 정상 복귀 : send_metric_recovery_sms()
// - 누수 감지 / 복구 (1번 모듈) : send_leak_alert_sms() / send_leak_recovery_sms()
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online // - 장비 오프라인 / 복구 : send_device_offline_sms() / online
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다. // 임계 판정 자체는 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)]; 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로 문자 발송 * Cafe24 SMS API로 문자 발송
* *
@ -46,6 +78,11 @@ function send_sms_cafe24(string $receiver, string $message): array {
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.']; 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); [$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message)); $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 인코딩 실패']; 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 = [ $postData = [
'action' => 'go', 'action' => 'go',
'user_id' => base64_encode(SMS_USER_ID), 'user_id' => base64_encode(SMS_USER_ID),
@ -117,6 +163,8 @@ function send_sms_cafe24(string $receiver, string $message): array {
'success' => $success, 'success' => $success,
'result_code' => $resultCode, 'result_code' => $resultCode,
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$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 식별 라벨 * @param string $logLabel error_log 식별 라벨
* @return bool 전원 발송 성공 여부 * @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']}"; $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$allSuccess = true; $allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
$targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달
foreach (SMS_RECIPIENTS as $receiver) { foreach ($targets as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver); $receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message); $result = send_sms_cafe24($receiverDigits, $message);
if (($result['remaining'] ?? null) !== null) {
$lastRemaining = (int)$result['remaining'];
}
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sms_log 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; 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". * 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
*/ */
@ -178,6 +283,49 @@ function metric_value_str(float $value): string {
return $s; 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_*)와 비교해 경보 종류를 산출한다. * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* @return array{breaches: string[], status: string} * @return array{breaches: string[], status: string}
@ -199,6 +347,14 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches[] = 'low_humidity'; $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 [ return [
'breaches' => $breaches, 'breaches' => $breaches,
'status' => empty($breaches) ? 'normal' : $breaches[0], 'status' => empty($breaches) ? 'normal' : $breaches[0],
@ -207,7 +363,7 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
/** /**
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true. * 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
* 측정 결측(null)은 해당 지표를 정상으로 간주한다. * 결로 위험도 해소되어야 복귀로 본다. 측정 결측(null)은 해당 지표를 정상으로 간주한다.
*/ */
function metric_is_recovered(?float $temp_c, ?float $rh): bool { function metric_is_recovered(?float $temp_c, ?float $rh): bool {
$tOk = ($temp_c === null) || ( $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_HIGH - METRIC_RH_HYSTERESIS &&
$rh > METRIC_RH_LOW + 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 기준). * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
*/ */
function metric_was_alerting(PDO $db, int $sensor_id): bool { function metric_was_alerting(PDO $db, int $sensor_id): bool {
$stmt = $db->prepare(" // 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
SELECT message FROM sms_log // 결로(결로주의)도 1급 경보이므로 포함해야, 복귀(정상복귀) SMS 1회가 정상 발화한다.
WHERE sensor_id = :sid $tags = [
AND status = 'success' SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP,
AND (message LIKE '[고온경보]%' OR message LIKE '[저온경보]%' SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY,
OR message LIKE '[고습경보]%' OR message LIKE '[저습경보]%' SMS_TAG_CONDENSATION,
OR message LIKE '[정상복귀]%') SMS_TAG_RECOVERY,
ORDER BY sent_at DESC ];
LIMIT 1 $likes = [];
"); $params = [':sid' => $sensor_id];
$stmt->execute([':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 (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC
LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$msg = $stmt->fetchColumn(); $msg = $stmt->fetchColumn();
if ($msg === false) { if ($msg === false) {
return 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(); $db = get_db();
$sensor_id = (int)$sensor_data['sensor_id']; $sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구] // [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
$labels = [ $labels = [
'high_temp' => ['[고온경보]', '온도', '℃', METRIC_TEMP_HIGH_C, '이상'], 'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
'low_temp' => ['[저온경보]', '온도', '℃', METRIC_TEMP_LOW_C, '이하'], 'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
'high_humidity' => ['[고습경보]', '습도', '%', METRIC_RH_HIGH, '이상'], 'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'],
'low_humidity' => ['[저습경보]', '습도', '%', METRIC_RH_LOW, '이하'], 'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'],
'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'],
]; ];
if (!isset($labels[$kind])) { if (!isset($labels[$kind])) {
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}"); error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
@ -272,37 +441,71 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
} }
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ────────────── // ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
$recent = $db->prepare(" // 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
SELECT sent_at FROM sms_log // 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
WHERE sensor_id = :sensor_id // MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
AND status = 'success' // 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
AND message LIKE :tag // 안전하게 폴백한다. (같은 본문 재시도/재생은 sensor_data.php 의 nonce 멱등에서 선차단)
ORDER BY sent_at DESC $lockName = substr("sms_alert_{$sensor_id}_{$kind}", 0, 64);
LIMIT 1 $lockState = 'unsupported';
"); try {
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']); $lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
$last_at = $recent->fetchColumn(); $lk->execute([':n' => $lockName]);
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) { $res = $lk->fetchColumn();
$elapsed = time() - strtotime($last_at); $lockState = ($res === null || $res === false)
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); ? '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; return false;
} }
// ── 메시지 구성 ────────────────────────────────────────── try {
$dt = date('Y-m-d H:i:s'); // ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; $recent = $db->prepare("
$loc = $sensor_data['device_location'] ?? '알 수 없음'; SELECT sent_at FROM sms_log
$valStr = metric_value_str($value); WHERE sensor_id = :sensor_id
$thrStr = metric_value_str((float)$threshold); AND status = 'success'
AND message LIKE :tag
ORDER BY sent_at DESC
LIMIT 1
");
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']);
$last_at = $recent->fetchColumn();
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) {
$elapsed = time() - strtotime($last_at);
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
return false;
}
$message = "{$tag}\n" // ── 메시지 구성 ──────────────────────────────────────────
. "위치: {$loc}\n" $dt = date('Y-m-d H:i:s');
. "센서: {$name}\n" $name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n" $loc = $sensor_data['device_location'] ?? '알 수 없음';
. "시각: {$dt}"; $valStr = metric_value_str($value);
$thrStr = metric_value_str((float)$threshold);
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}"); $message = "{$tag}\n"
. "위치: {$loc}\n"
. "센서: {$name}\n"
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
. "시각: {$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']}"; $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음'; $loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = "[정상복귀]\n" $message = SMS_TAG_RECOVERY . "\n"
. "위치: {$loc}\n" . "위치: {$loc}\n"
. "센서: {$name}\n" . "센서: {$name}\n"
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\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, "정상복귀"); 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 발송 * 장비 오프라인 SMS 발송
* *
@ -344,7 +665,7 @@ function send_device_offline_sms(array $sensor_data): bool {
$loc = $sensor_data['device_location'] ?? '알 수 없음'; $loc = $sensor_data['device_location'] ?? '알 수 없음';
$last = $sensor_data['last_seen'] ?? '알 수 없음'; $last = $sensor_data['last_seen'] ?? '알 수 없음';
$message = "[장비 오프라인]\n" $message = SMS_TAG_DEVICE_OFFLINE . "\n"
. "위치: {$loc}\n" . "위치: {$loc}\n"
. "센서: {$name}\n" . "센서: {$name}\n"
. "마지막 통신: {$last}\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']}"; $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
$loc = $sensor_data['device_location'] ?? '알 수 없음'; $loc = $sensor_data['device_location'] ?? '알 수 없음';
$message = "[장비 복구]\n" $message = SMS_TAG_DEVICE_ONLINE . "\n"
. "위치: {$loc}\n" . "위치: {$loc}\n"
. "센서: {$name}\n" . "센서: {$name}\n"
. "복구 시각: {$dt}\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, "장비복구"); 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 <?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 발송 / 대시보드] [SMS 발송 / 대시보드]
``` ```
> 인증: 펌웨어는 `X-Signature: sha256(API_KEY + 요청본문)`(raw-body) 헤더로 서명하며, > 인증: 펌웨어는 `X-Signature = lowercase_hex(HMAC-SHA256(키=API_KEY, 메시지=요청본문 바이트))`(raw-body) 헤더로 서명하며,
> 서버는 이 서명을 검증합니다. 온습도 임계 판정은 모두 서버(`php/config.php` `METRIC_*` > 서버는 이 서명을 검증합니다. 온습도 임계 판정은 모두 서버(`php/config.php` `METRIC_*`
> 상수 + `api/sensor_data.php`)에서 수행하므로, 폐쇄망에서 임계값 변경 시 서버 설정만 > 상수 + `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(); CALL _mig_drop_leak();
DROP PROCEDURE IF EXISTS _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; -- SOURCE /path/to/sql/schema_sht30.sql;
-- --
-- 기존(누수 v2605) 설치를 SHT30 전용으로 전환할 때는 이 파일이 아니라 -- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을
-- sql/migration_drop_leak.sql 을 사용합니다(데이터 보존 + 누수 잔재 정리). -- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시
-- 더하려면 sql/migration_add_leak.sql(가산·멱등).
-- --
-- 누수(leak) 관련 요소는 포함하지 않습니다: -- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소:
-- - 테이블: leak_photo, leak_incident -- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
-- - 컬럼: sensor_log.is_leak, sensor_status.is_leak, leak_periodic_count -- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼,
-- - 이벤트: event_type 의 leak_alert / leak_recovery -- event_type 의 leak_alert / leak_recovery,
-- 온습도 임계 경보(고온/저온/고습/저습) 판정과 SMS 발송은 서버 PHP(config.php -- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블.
-- 의 METRIC_* 상수 + api/sensor_data.php)에서 수행하며, 발송 이력은 sms_log 에 -- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며,
-- 남습니다. 별도의 경보 상태 테이블은 두지 않습니다(sms_log 기준 쿨다운/복구 판정). -- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
-- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다.
-- ============================================================================= -- =============================================================================
SET NAMES utf8mb4; SET NAMES utf8mb4;
@ -25,8 +28,9 @@ CREATE TABLE IF NOT EXISTS `sensor_log` (
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치', `device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
`sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호', `sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호',
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '' 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', NOT NULL DEFAULT 'periodic',
`is_leak` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '누수 여부 (1=누수, 0=정상)',
`sensor_time` DATETIME NOT NULL COMMENT '센서 측정 시각 (기기 기준)', `sensor_time` DATETIME NOT NULL COMMENT '센서 측정 시각 (기기 기준)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'DB 저장 시각', `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 '', `sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '', `device_location` VARCHAR(100) NOT NULL DEFAULT '',
`last_event_type` VARCHAR(20) 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 `last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP, ON UPDATE CURRENT_TIMESTAMP,
`offline_alerted` TINYINT(1) NOT NULL DEFAULT 0 `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, `temperature_c` DECIMAL(6,2) NULL,
`humidity_percent` DECIMAL(5,2) NULL, `humidity_percent` DECIMAL(5,2) NULL,
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal' `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, `measured_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -99,3 +104,64 @@ CREATE TABLE IF NOT EXISTS `sms_log` (
DEFAULT CHARSET=utf8mb4 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
COMMENT='SMS 발송 이력 (온습도 임계 경보/복구, 장비 오프라인/복구)'; 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 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().'); 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'); $dashboard = file_text($root . '/php/dashboard.php');
$monthly = file_text($root . '/php/monthly_report.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.'); $sensorApi = file_text($root . '/php/api/sensor_data.php');
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.'); // 수집 API 가 누수 이벤트(leak_alert/leak_recovery)를 수신한다
check(!is_file($root . '/php/incidents.php'), 'php/incidents.php should be removed.'); check(str_contains($sensorApi, 'leak_alert') && str_contains($sensorApi, 'leak_recovery'),
check(!is_file($root . '/php/api/photo_upload.php'), 'php/api/photo_upload.php should be removed.'); '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'; $retention = $root . '/php/retention_cleanup.php';
check(is_file($retention), 'php/retention_cleanup.php should implement retention cleanup.'); 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 retention controls.');
check(str_contains($securityEvidence, '백업'), 'security_evidence.php should report backup evidence 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) { if ($failures) {
fwrite(STDERR, "Security hardening regression failures:\n"); fwrite(STDERR, "Security hardening regression failures:\n");
foreach ($failures as $failure) { 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(100.0), '100', '100.0 -> 100');
eq(metric_value_str(0.0), '0', '0.0 -> 0'); 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) { if ($failures) {
fwrite(STDERR, "Threshold SMS logic test failures:\n"); fwrite(STDERR, "Threshold SMS logic test failures:\n");
foreach ($failures as $f) { foreach ($failures as $f) {