diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1f77a59 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,36 @@ +{ + "permissions": { + "allow": [ + "Bash(tail:*)", + "Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)", + "Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)", + "Bash(sort -k2)" + ], + "deny": [ + "Bash(rm -rf:*)", + "Bash(rm -fr:*)", + "Bash(sudo rm:*)", + "Bash(chmod -R 777:*)", + "Bash(chmod 777:*)", + "Bash(dd:*)", + "Bash(mkfs:*)", + "Bash(:(){ :|:& };:)", + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(curl:* | sh)", + "Bash(curl:* | bash)", + "Bash(wget:* | sh)", + "Bash(wget:* | bash)", + "Bash(ssh:*)", + "Bash(scp:*)", + "Read(./**/config.local.php)", + "Read(./**/config_local.py)", + "Read(./**/secrets.h)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/*.pem)", + "Read(./**/id_rsa)", + "Read(./**/id_ed25519)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1efc7..1a5545c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,183 @@ # 변경 이력 +## Unreleased - 기능 추가 (고가치 M: 신뢰성·관측성) + +경보가 조용히 죽지 않도록 보장 + 그것을 검증하는 신뢰성·관측성 묶음. 모두 cron/서버/ +CLI 기반(새 상호작용 UI 없음), graceful degrade. (#5 임계·수신자 화면편집은 core config +로드를 DB 의존으로 바꿔 신뢰성 회귀 위험이 있어 별도 설계로 보류.) + +### M#1 수집 거부 사유 카운터 (관측성) +- `config.php:record_ingest_rejection()/read_ingest_rejections()`(파일 flock 기반, DB 무관 + → 위조요청 폭주에도 안전). `api/sensor_data.php` 의 모든 거부 분기(403 서명/400 시각/ + 413 본문/503 키/필드/sensor_id)를 사유별 일자 집계하고, 대시보드 상단에 사유별 + 건수(서명불일치→키/펌웨어, 시각오차→SNTP/RTC 등 한국어 라벨)로 노출한다. "왜 데이터가 + 안 들어오는가"를 서버 로그 없이 분별. + +### M#2 발송 실패 가시화 + 오프라인 재시도 (신뢰성) +- `dashboard.php`: 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패하면 '경보 SMS + 발송 실패 — 미수신 가능' 배너. +- `cron_heartbeat.php`: 일회성 오프라인 SMS 발송이 실패하면 `offline_alerted` 를 0 으로 + 되돌려 다음 주기에 재시도한다(`SMS_RETRY_OFFLINE`). 게이트웨이 일시오류로 장비 다운을 + 영영 못 알리던 갭을 닫음. + +### M#3 미복귀 경보 에스컬레이션 (신뢰성, opt-in) +- `sms_send.php:maybe_escalate_alerts()`: 임계 경보가 `ESCALATE_AFTER_SEC`(기본 30분) 이상 + 미복귀로 지속되면 2차(야간) 수신자 `SMS_RECIPIENTS_TIER2` 에게 `[에스컬레이션]` 1회 + 통지(에피소드당 1회 — 최신 경보-상태가 경보일 때만). TIER2 가 비면 비활성. cron 호출. +- 대화형 ack 버튼 UI 는 보류(별도 POST 표면 — 후속). + +### M#4 센서 고착(live-but-stuck) 검출 (자가신뢰성) +- `sms_send.php:maybe_detect_stuck_sensors()`: 최근 `STUCK_DETECT_SAMPLES`(6) 측정의 온도· + 습도 표준편차가 둘 다 `STUCK_DETECT_EPSILON`(0.01, 분해능 이하) 미만이면 소자 고장(같은 + 값 반복)을 의심해 `[센서고착]` 1회(쿨다운). 하트비트(수신 끊김)로는 못 잡는 무성실패를 + 보완 — 단일 센서가 거짓말하면 감시 전체가 무력화되므로 핵심. + +### M#6 카오스 드릴 (검증) +- `SMS_FORCE_FAIL` 게이트(`send_sms_cafe24` 가 실제 전송 없이 실패 반환)와 + `tests/chaos_drill.php`(CLI) — 위 실패-의존 기능이 동작하는지 손시연 없이 검증. + 라이브 종단 드릴 절차는 파일 헤더에 문서화. + +### 테스트 +- `tests/threshold_sms_test.php`: `stddev_pop` 단위 테스트(고착 판정). +- `tests/security_hardening_test.php`: M#1~#6 소스 회귀 고정. +- `run_tests.sh`: 드릴 포함. + +### 보류(별도 설계 필요) +- 경보 ack 버튼 UI(대화형 POST), 임계·수신자 화면편집(#5, core config DB-override — + DB 장애 시 config 로드 실패 → 사이트 다운 위험), 풀 카오스 하네스(prod DB 주입). + +## Unreleased - 기능 추가 (①시계동기 ②경보 생존성/결로/개인정보) + +전수 아이디에이션에서 도출한 우선순위 1~2(①+②)를 구현했다. 외부 의존 0, 대부분 +기존 파이프라인 재사용. + +### ① 폐쇄망 시계동기 — 인터넷 NTP 의존 제거 (잠재 전면장애 해소) +- **`firmware/common/app_config.h`: `APP_SNTP_SERVER` 기본값을 `pool.ntp.org`(인터넷)에서 + LAN NTP 자리표시자(`192.0.2.123`, RFC5737)로 교체**하고 폐쇄망 필수 설정임을 명시했다. + 진짜 폐쇄망에서 인터넷 NTP 는 도달 불가 → SNTP 실패 → RTC 미설정 → `timestamp=0` → + TLS 차단 + 서버 신선도 위반 전량거부 = **보고 전면장애**였다(제약 위반 상태 해소). +- `api/sensor_data.php`: 200 응답에 `server_now`(서버 Unix 시각)를 추가했다. 펌웨어가 + RTC 드리프트를 서버시각으로 미세보정(LSI 드리프트/간헐 NTP 대비)하는 **후속 펌웨어 + 작업의 입력**이다(서버측 선반영; 보안 영향 없음). + - TODO(운영): 배포 전 `APP_SNTP_SERVER` 를 실제 LAN NTP IP 로 설정. 최초 시각 확보는 + LAN NTP(또는 배터리 RTC)가 필요하며, `server_now` 소비(RTC 보정)는 펌웨어 후속 과제. + +### ②a SMS 잔액 사전경보 +- `sms_send.php`: Cafe24 응답의 잔여 건수를 파싱(`send_sms_cafe24` 가 `remaining` 반환)하고, + 임계(`SMS_BALANCE_WARN_THRESHOLD`, 기본 30) 이하이면 쿨다운(`SMS_BALANCE_WARN_COOLDOWN_SEC`) + 적용 `[SMS 잔액부족]` 경고를 1회 발송한다(`maybe_warn_sms_balance`). 추가 외부호출 0. +- `config.php:latest_sms_balance()` + `ops_checks.php` 에 'SMS 잔여 건수' 점검 추가. + +### ②b 일일 헬스(생존신호) SMS — 데드맨 +- `sms_send.php:maybe_send_daily_health_sms()`: 하루 1통 `[정기점검]` 요약(정상 센서 수/ + 최근 수신/24h 경보/SMS 잔여)을 발송한다. `HEALTH_SMS_HOUR`(기본 9시) 이후 첫 호출에서, + 날짜 dedup + 네임드 락으로 중복 없이 1회. `cron_heartbeat.php` 가 호출. '무소식=정상' + 과 '무소식=경보경로 전체고장'의 모호성을 제거(관리자는 "안 오면 점검"만 기억). + +### ②c 이슬점/결로 경보 +- `sms_send.php`: Magnus 이슬점(`dew_point_c`)·결로마진(`condensation_margin_c`)을 추가하고, + `(현재온도−이슬점) ≤ METRIC_DEWPOINT_MARGIN_C`(기본 3℃)이면 새 종류 `condensation_risk`/ + `[결로주의]` 경보를 발화한다. `evaluate_metric_thresholds`(말미 추가)·`metric_is_recovered` + (결로 해소 히스테리시스 포함)·`send_metric_alert_sms`(라벨)·`sensor_data.php`(표시값=결로마진) + 에 통합. 온도·습도가 각각 정상이어도 잡히는 결합 위험(차가운 랙면 결로)을 단일 SHT30 + 산술만으로 포착 — 누수감지기 후신인 이 시스템의 본래 목적에 부합. + +### ②d 수신번호 개인정보 마스킹 +- `config.php:mask_phone()`(010-****-1234)을 `dashboard.php`·`setup_wizard.php` 의 수신번호 + 표시에 적용했다. 발송 기록(`sms_log`)은 보존하되 화면 표시만 최소화(개인정보 최소표시). + +### 테스트 +- `tests/threshold_sms_test.php`: 이슬점/결로마진/해소·`mask_phone` 경계 테스트(기존 + 임계 벡터는 결로 미발화 확인 — 회귀 없음). +- `tests/security_hardening_test.php`: ①+② 소스 회귀 고정(SNTP 비-인터넷, server_now, + 잔액/헬스/결로/마스킹 상수·함수·호출). + +## Unreleased - 개선 구현 (다중 에이전트 전수 점검 반영) + +전체 서브시스템(펌웨어/PHP/DB/문서/빌드)을 다중 에이전트로 매핑·교차검증하여 도출한 +개선 목록을 구현했다. **호환성 유지(가산/멱등)** 를 원칙으로 했으며, 새 DB 테이블은 +없어도 동작하도록 graceful degrade 한다. + +### 보안 / 신뢰성 (서버 PHP) +- **재전송(replay) 멱등 방어 추가.** `api/sensor_data.php` 가 인증·신선도를 통과한 요청 + 서명(X-Signature)을 `ingest_nonce` 테이블에 원자적으로 기록한다. 시계오차 창 + (`INGEST_MAX_CLOCK_SKEW_SEC`) 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 + 재생) 중복 저장·중복 SMS 없이 **멱등 200** 으로 응답한다. 저장 실패 시 nonce 를 + 되돌려 재시도가 재처리되게 한다(유실 방지). 테이블이 없으면 자동 비활성 + (`INGEST_NONCE_ENABLED`). +- **임계 경보 SMS 중복 발송 경합 제거.** `sms_send.php:send_metric_alert_sms()` 의 + 쿨다운 검사→발송→기록 구간을 `(센서,종류)` 단위 MySQL 네임드 락(`GET_LOCK`)으로 + 직렬화한다. 락 미지원 환경에서는 종전 동작으로 폴백한다(가용성 우선). +- **SMS 길이 캡.** Cafe24 단문('S', EUC-KR ≤90B) 한도를 넘는 메시지를 발송 전 + 멀티바이트 경계 보존하며 자른다(`sms_truncate_euckr`, `SMS_MAX_EUCKR_BYTES`). 위치/ + 센서명이 길 때 게이트웨이의 무성 절단/거부를 막는다. +- **경보 태그 단일 출처화.** `[고온경보]` 등 상태 태그를 `config.php` 의 `SMS_TAG_*` + 상수와 `sms_summary_select_sql()` 헬퍼로 일원화하고, `sms_send/dashboard/monthly_report` + 의 하드코딩 LIKE 리터럴을 제거했다. 태그를 한 곳만 바꿔도 쿨다운/복구/대시보드/월간 + 보고서가 일관 유지된다(문자열 어긋남 무성 장애 방지). +- **세션 절대 수명 캡 추가 + 모든 인증 페이지로 확대.** 유휴 타임아웃(`SESSION_TIMEOUT`) + 만으로 연속 활동 세션이 무한정 유지되던 문제를 로그인 시각 기준 절대 수명 + (`SESSION_ABSOLUTE_TIMEOUT`, 기본 12시간)으로 보완했다. 타임아웃 검사를 + `admin_security.php:admin_enforce_session_timeout()` 공통 헬퍼로 일원화하고 + `dashboard.php`·`monthly_report.php`·`security_evidence.php`·`setup_wizard.php` 전부에서 + 호출한다(이전엔 대시보드에서만 검사돼 다른 인증 페이지로 우회 가능했던 갭을 닫음). +- **반사형 XSS 차단.** `test_mobile.php` 가 User-Agent 를 그대로 반사하던 것을 + `text/plain` + `htmlspecialchars` 로 막았다. +- **입력 검증.** `sensor_data.php` 가 `sensor_id` 범위(TINYINT 1..255)를 경계에서 검사해 + 500 대신 깔끔한 400 을 반환한다. +- **운영 자가진단 보강.** `ops_checks.php` 가 잔존 `MFA_SETUP_TOKEN` 과 활성화된 레거시 + 서명 폴백(`ALLOW_LEGACY_BODY_SIGNATURE`)을 경고한다. `config.local.example.php` 에 + `ALLOW_LEGACY_BODY_SIGNATURE => false` 를 명시했다. + +### DB +- `sql/schema_sht30.sql` · `sql/migration_drop_leak.sql`: 재전송 멱등용 `ingest_nonce` + 테이블을 가산·멱등(`CREATE TABLE IF NOT EXISTS`)으로 추가했다. + +### 펌웨어 / 빌드 (폐쇄망) +- **호스트 C 패리티 추가(미검증 갭 해소).** `firmware/test/host/sig_cli.c` 와 + `parity_test.py` 가 펌웨어 실제 서명 구현(`common/sig.c`)을 호스트 컴파일·실행하여 + Python 레퍼런스(`reference.py`)·서버(`verify_signature_raw`)와 **바이트 단위 일치**를 + 검증한다(C↔Python↔PHP 3자). 이전엔 C 가 어떤 테스트에서도 컴파일되지 않았다. +- **빌드 시 자리표시자 CA 차단.** `build.ps1` 이 `server_ca.c` 의 PLACEHOLDER 를 + 검출하면 빌드를 중단한다(부팅 시 `bsp_fatal` 로 정지하던 것을 빌드 단계에서 선차단; + `-AllowPlaceholderCA` 로 강행 가능). +- **벤더 핀 엄격 모드.** `vendor.sh`(`VENDOR_STRICT=1`)·`vendor.ps1`(`-Strict`)가 SHA-256 + 핀 미설정(TODO) 의존성이 있으면 실패한다(릴리스 재현성 강제). +- **stale 주석 정정.** `net.c`/`lwipopts.h` 의 RNG `TODO(hw)` 주석을 정정했다 — + HW RNG `bsp_rand32()` 는 `bsp.c` 에 이미 구현되어 weak 폴백을 덮어쓴다(예측성 완화는 + 이미 적용됨). `sig.h`/`secrets.h.example` 의 `sha256(key||body)` 표기를 HMAC 로 정정. + +### 문서 +- **서명 방식 문서 드리프트 일괄 정정(16개 파일).** 운영/보안 문서의 + `sha256(API_KEY+body)`(키-접두) 표기를 실제 구현인 + `HMAC-SHA256(키=API_KEY, 메시지=요청본문)` 으로 정정했다. +- `SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md` 의 **거짓 일치 인증(false all-clear)** 을 + 바로잡았다(서명 행을 HMAC 로 갱신 + §2.1 추가). +- `out_of_range` 가 펌웨어 plausibility 전용이며 DB(`sensor_metric.metric_status`)에는 + `normal/high_temp/low_temp/high_humidity/low_humidity` 만 저장됨을 명시 + (`wiring_diagram.md`, `HARDWARE.md`, `PORTING_NOTES.md`, 펌웨어 `README.md`). +- `BUILD_OFFLINE.md` 의 비실존 스크립트/툴체인 이름 정정(`vendor_deps.* → vendor.*`, + `arm-none-eabi.cmake → arm-none-eabi-toolchain.cmake`). 웹루트 경로가 펌웨어 + `app_config.h:APP_API_PATH` 와 일치해야 함을 교차참조. +- HWP 보안대책서 **과대 스코프** 경고를 명문화(온습도 전용; 화재/누수/정전/카메라 + 미구현). 원본 `.hwp` 는 운영자가 동일하게 정정 필요(`hwp_extracted_text.txt` 배너 + + `SECURITY_PLAN_HWP_REVIEW`/`PASS_READINESS`). + +### 테스트 +- `run_tests.sh`(전체 러너; php/python3/gcc 부재 시 SKIP 명시) 추가. +- `tests/threshold_sms_test.php`: `sms_truncate_euckr()` 경계 테스트(EUC-KR 멀티바이트). +- `tests/security_hardening_test.php`: 위 개선(nonce/락/SMS캡/태그상수/세션캡/XSS/ops) + 소스 회귀 고정. + +### 운영자 필수 조치 (코드로 끝낼 수 없는 항목) +- **(CRITICAL)** `firmware/certs/server_ca.c` 를 실제 Cafe24 루트 CA 로 교체(자리표시자 + 상태로는 부팅 불가; 이제 빌드 단계에서도 차단됨). +- **(HIGH)** 원본 `.hwp` 보안대책서를 온습도 전용 스코프로 정정 후 제출. +- `vendor.{sh,ps1}` 의 SHA-256 핀을 네트워크 빌드 머신에서 채우기(이후 `VENDOR_STRICT` + 로 강제). 신규/전환 DB 에 `ingest_nonce` 생성(스키마/마이그레이션 재실행). 운영 + `config.local.php` 에서 `MFA_SETUP_TOKEN` 비우기, `ALLOW_LEGACY_BODY_SIGNATURE=false` + 확인. + ## Unreleased - 보안 하드닝 (전체 코드리뷰 반영) ### ⚠️ 호환성 주의 (BREAKING) diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 0000000..b25734d --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,149 @@ +# RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝 + +## 1. 배경 / 목적 + +서버실 모니터링을 **하나의 시스템**으로 통합한다. Raspberry Pi 4 센서 모듈 2개가 각각 +cafe24 서버로 데이터를 전송하고, 서버가 저장·임계판정·SMS·**단일 대시보드 표시**를 담당한다. + +- **1번 모듈 — 누수 + 카메라** (`raspberry_pi4/`, `sensor_id=1`) +- **2번 모듈 — 온습도 SHT30** (`raspberry_pi4_sht30/`, `sensor_id=2`) + +기존에는 온습도(이 repo의 `php/` 서버)와 누수(레거시 누수 서버)가 **분리**되어 있었고 +서명 방식도 달랐다. 본 PR로 **두 모듈을 동일 서버·동일 서명(X-Signature HMAC)으로 통합**하여 +대시보드 한 화면에서 **온습도 + 누수 상태 + 현장 사진**을 함께 본다. + +``` +┌──────────────────────────┐ +│ 1번 모듈 (누수+카메라) │ leak_alert/recovery + 사진 +│ raspberry_pi4/ │──┐ +└──────────────────────────┘ │ HTTPS POST + ▼ X-Signature = HMAC-SHA256(API_KEY, body) +┌──────────────────────────┐ ┌─────────────────────────────┐ +│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │ +│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │ +└──────────────────────────┘ │ api/photo_upload.php (사진) │ + │ → MySQL / 임계·누수 SMS │ + │ → dashboard.php (통합 대시보드)│ + └─────────────────────────────┘ +``` + +--- + +## 2. 2번 모듈 신규 — `raspberry_pi4_sht30/` (온습도) + +STM32 펌웨어 `sht30_fw` 동작을 **Raspberry Pi 4 + Python**으로 이식. + +| 파일 | 역할 | +|---|---| +| `sht30_sensor.py` | 메인 — 5분 주기 측정 → 서명 POST → `startup`→`periodic`, SIGTERM 정상종료 | +| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 변환 (smbus2, 미가용 시 시뮬레이션 폴백) | +| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 **바이트 동일**) | +| `config.py` / `config_local.example.py` | 비밀 아닌 기본값 / 운영·비밀값 템플릿(fail-closed) | +| `install.sh` / `vendor.sh` | 폐쇄망 오프라인 + 온라인 설치 / 의존성 사전 다운로드, I2C 자동 활성화 | +| `test_sensor.py` / `test_connection.py` | CRC·포맷 자가점검 / 서버 서명 POST 점검 | +| `systemd/sht30-sensor.service` | 부팅 자동 실행 | + +- **I2C 배선:** SDA=GPIO2(물리3), SCL=GPIO3(물리5), 3.3V/GND, ADDR=GND→`0x44`, 4.7kΩ 풀업 +- **측정식:** `temp=-45+175·raw_t/65535`, `rh=100·raw_rh/65535`, CRC-8(poly 0x31, init 0xFF) +- **보고 필드 순서(서명 호환 고정):** `device_id, device_location, sensor_id, sensor_name, + event_type, timestamp, metric_type="sht30", temperature_c, humidity_percent, metric_status, app_version` +- 임계 판정(고온/저온/고습/저습)·SMS는 **전부 서버**가 수행. 모듈은 측정만 보고. + +--- + +## 3. 1번 모듈 통합 — `raspberry_pi4/` (누수+카메라) + +- **서명 전환:** 레거시 `sha256(API_KEY + 정렬 JSON)` body-field 서명 → **raw-body + `X-Signature` HMAC**(2번 모듈과 동일 스킴). 본문을 고정 순서 문자열로 만들어 그 바이트에 + 서명하고 그대로 POST(`data=body_bytes`). +- **누수 이벤트 본문:** `{device_id, device_location, sensor_id, sensor_name, event_type, + is_leak, timestamp}` +- **사진 업로드 서명:** canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음), + `X-Signature` 헤더. 서버 `photo_upload.php` 재구성 canonical과 바이트 동일. +- GPIO 인터럽트 누수감지 / 카메라 촬영 / 재시도 / 스레딩 동작은 유지. +- `config_local.example.py`: `API_URL`/`PHOTO_UPLOAD_URL`을 통합 서버로, `API_KEY`는 서버와 동일. + +--- + +## 4. 서버 통합 (`php/`) + +| 파일 | 변경 | +|---|---| +| `api/sensor_data.php` | `leak_alert`/`leak_recovery` 수신, `is_leak` 처리, `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. **SHT30 측정 경로 100% 유지** | +| `api/photo_upload.php` *(신규)* | 멀티파트 JPEG 업로드. HMAC 서명·timestamp 신선도·`finfo` MIME·크기상한·**서버생성 무작위 파일명**·nonce 멱등·`log_id↔sensor_id` 결속 | +| `photo.php` *(신규)* | **관리자 인증** 사진 서빙. 파일명 정규식 + `realpath` 봉쇄(경로순회 방어), `readfile` image/jpeg | +| `dashboard.php` | 누수 패널(센서1 누수/정상 + `leak_incident` 이력 + `photo.php?id=` 썸네일), 이벤트 라벨 `누수감지/누수복구`, 제목 "온습도·누수 통합" | +| `sms_send.php` | `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만) | +| `config.php` | `[누수감지]`/`[누수복구]` 태그, 쿨다운 상수, SMS 요약에 누수 카운트 | +| `var/.htaccess`, `var/photos/.htaccess` *(신규)* | mod_rewrite 무관 `Require all denied` + PHP 실행차단 | + +### DB +- `sql/migration_add_leak.sql` *(신규, 가산·멱등)* — 기존 온습도 서버에 누수 복원 + (`sensor_log.is_leak`/`sensor_status.is_leak`, `event_type` ENUM에 leak 값, + `leak_incident`+`leak_photo` 테이블). `sensor_metric`(SHT30) 보존. 재실행 안전. +- `sql/schema_sht30.sql` — 신규(통합) 설치용. 누수 요소 포함. + +--- + +## 5. 보안 하드닝 (외부 펜테스트 대비) + +**2중 독립 리뷰 → 확정 CRITICAL/HIGH = 0.** +- 광범위 감사(6도메인 → 적대 반증검증): 고위험 후보 8건 **전부 거짓양성 반증** + (TOTP 재생·CRON_SECRET 유추·setup_mfa 재등록 등 — 비번/키 선보유 필요거나 시크릿 미영속화) +- 바운티 헌터(pre-auth 원격 익스플로잇): **바운티급 0건**. SQLi/경로순회/XSS/SSRF/업로드RCE/ + `var/` 노출 전부 방어 확인. + +적용한 하드닝(저위험, 무회귀): +1. `cron_heartbeat.php` — `api_key_is_default()` fail-closed (신규설치 예측가능 CRON_SECRET 무력화) +2. `setup_mfa.php` — MFA 설정완료 후 부트스트랩 토큰 경로 차단 +3. `var/`·`var/photos/` — PHP 실행차단(`engine off` + FilesMatch + RemoveHandler) → 업로드→RCE 폴리글랏 봉쇄 +4. `php/.htaccess` — HSTS 헤더 추가 + +미반영(문서화): CSP `unsafe-inline` 제거는 대시보드 인라인 스크립트/스타일 nonce 리팩터 +필요 → 회귀위험으로 별도단계. (현 XSS 실위험 없음 — 출력 전부 `htmlspecialchars`) + +--- + +## 6. 검증 내역 + +- **wire byte-parity:** `wire.py`(2번)·`leak_sensor.py`(1번) 모두 본문/서명이 펌웨어 + `reference.py` 및 독립 HMAC과 바이트 일치(한글 UTF-8, `"`/`\` 이스케이프, round-half-up, + max uint32 ts 엣지케이스 포함). 사진 canonical 클라↔서버 동일. +- Python: 전 모듈 `py_compile` + import 그래프 + 자가점검(CRC `0xBEEF→0x92` 등) 통과. +- 적대 검증 2라운드(통합/보안) blocking 0. + +--- + +## 7. 배포 절차 + +**각 라즈베리파이4** (모듈당 1대 또는 한 대에 둘 다 — 핀 충돌 없음) +1. 해당 폴더 업로드 +2. `cp config_local.example.py config_local.py` → `API_URL`/`API_KEY`/`DEVICE_ID`(+1번 `PHOTO_UPLOAD_URL`) +3. `./install.sh` → systemd enable + +**cafe24 서버 (1회)** +1. DB: 기존 온습도 서버 → `sql/migration_add_leak.sql`; 신규 → `sql/schema_sht30.sql` +2. `php/` 업로드 (신규 `photo.php`, `api/photo_upload.php`, `var/.htaccess` 포함) +3. `php/var/photos/` 웹서버 쓰기권한 +4. `config.local.php`의 `API_KEY` = 라즈베리파이와 동일 + +--- + +## 8. 배포 전 체크리스트 + +- [ ] cafe24(PHP 있음)에서 문법/테스트: + `php -l php/cron_heartbeat.php php/setup_mfa.php php/dashboard.php php/photo.php php/api/photo_upload.php` + / `php tests/security_hardening_test.php` *(작업 환경엔 PHP 없어 미실행)* +- [ ] `migration_add_leak.sql` 1회 적용 +- [ ] `php/var/photos/` 쓰기권한 +- [ ] 운영 위생: `API_KEY` 실값 설정 + MFA 등록 후 `MFA_SETUP_TOKEN` 비우기 + +--- + +## 9. 주의 / 리스크 + +- ⚠️ 본 PR은 **세션 외 기존 미커밋 변경**(docs/firmware/기타 php 등)도 함께 포함(사용자 요청). + 순수 이 작업분만 보려면 핵심 경로는 `raspberry_pi4*/`, `php/api/`, `php/photo.php`, + `sql/migration_add_leak.sql`, `SYSTEM_OVERVIEW.md`. +- PHP 정적 실행 검증은 작업 환경 제약으로 미수행 → 위 `php -l` 게이트 필수. +- 폐쇄망: `requests`/`smbus2` 등 의존성은 `vendor.sh`로 사전 번들(aarch64). diff --git a/SYSTEM_OVERVIEW.md b/SYSTEM_OVERVIEW.md new file mode 100644 index 0000000..d732c86 --- /dev/null +++ b/SYSTEM_OVERVIEW.md @@ -0,0 +1,74 @@ +# 시스템 전체 구성 — 하나의 모니터링 시스템 (Module 1 + Module 2 → cafe24 서버 → 대시보드) + +서버실 모니터링은 **두 개의 Raspberry Pi 4 센서 모듈**이 각각 cafe24 서버로 측정/이벤트를 +보고하고, 서버가 이를 저장·판정(SMS)·**대시보드 표시**하는 하나의 시스템입니다. + +``` +┌─────────────────────────────┐ +│ 1번 모듈 (누수 + 카메라) │ GPIO 누수감지 + Pi Camera 촬영 +│ raspberry_pi4/ │──┐ leak_alert / leak_recovery + 사진 업로드 +│ Raspberry Pi 4 + Python │ │ X-Signature(HMAC-SHA256) + photo_upload.php +└─────────────────────────────┘ │ + ▼ HTTPS POST +┌─────────────────────────────┐ ┌──────────────────────────────┐ +│ 2번 모듈 (온습도 SHT30) │ │ cafe24 서버 (php/) │ +│ raspberry_pi4_sht30/ │─►│ api/sensor_data.php (수신) │ +│ Raspberry Pi 4 + Python │ │ api/photo_upload.php (사진) │ +└─────────────────────────────┘ │ → MySQL 저장 / 임계·누수 SMS │ + HTTPS POST │ → dashboard.php (통합 대시보드)│ + X-Signature(HMAC-SHA256) └──────────────────────────────┘ +``` +두 모듈 모두 동일한 raw-body `X-Signature`(HMAC-SHA256) 서명으로 **같은 서버**에 보고합니다(통합 완료). + +## 모듈 매핑 + +| 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 | +|---|---|---|---|---|---| +| **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` | +| **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`sensor_metric`) | + +두 모듈 모두: +- 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움. +- 폐쇄망(air-gapped) 설치 지원 — `vendor.sh`(빌드 머신) → 번들 전송 → `install.sh`(오프라인). +- `systemd` 유닛으로 부팅 자동 실행. +- "업로드 + config_local 채우기 + install.sh + systemd enable" 만 하면 운영 가능. + +## 서버 (cafe24 `php/`) + +- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고. +- DB 스키마: `sql/schema_sht30.sql` (신규) 또는 `sql/migration_drop_leak.sql` (전환). +- 대시보드: `dashboard.php` — `sensor_status`/`sensor_metric`/`sms_log` 표시, 기본 차트 sensor 2. +- 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`. + +--- + +## ✅ 1번 모듈(누수) 서버 통합 상태 — 단일 서버로 통합 완료 (Option 2 구현됨) + +이전에 "운영 전 결정 필요"로 남겨 두었던 두 선택지 중 **2번(단일 서버로 통합)** 을 구현했습니다. +이제 **두 모듈 모두** 이 repo 의 `php/` 서버로 동일한 `X-Signature`(HMAC-SHA256) 서명으로 보고하고, +하나의 대시보드(`dashboard.php`)에서 **누수 상태/사진 + 온습도**를 함께 봅니다. + +구현 내용: + +- **서명 통일:** 1번 모듈을 raw-body `X-Signature` HMAC 으로 전환했습니다(2번 모듈과 동일 스킴). + 본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다. + (구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.) + 사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음). +- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의 + `leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을 + 다시 추가했습니다. `sensor_metric`(SHT30)은 그대로 보존합니다. +- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `sensor_log`/`sensor_status` 갱신, + `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지. +- **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명). +- **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만). + +### 운영 적용 절차 (기존 SHT30 서버에 누수 통합) + +1. 기존 서버에서 **`sql/migration_add_leak.sql` 1회 실행**(가산·멱등 — 운영 데이터 보존, 재실행 안전). + 신규(통합) 설치는 `sql/schema_sht30.sql` 하나만 실행하면 됩니다(이미 누수 요소 포함). +2. 아래 서버 파일을 (재)배포: + `api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`, + `sql/migration_add_leak.sql`. +3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도). +4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로, + `API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정. diff --git a/docs/INSTALL_GUIDE.html b/docs/INSTALL_GUIDE.html new file mode 100644 index 0000000..d071eeb --- /dev/null +++ b/docs/INSTALL_GUIDE.html @@ -0,0 +1,658 @@ + + + + + +서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32) + + + + +
+
+

서버실 온습도(SHT30) 모니터링 시스템 v2606 — 설치 가이드

+

Cafe24 PHP/MySQL 서버부터 STM32F407 MCU 펌웨어까지, 폐쇄망(air-gapped) 배포 절차

+
+ 서버: Cafe24 PHP / MySQL + MCU: STM32F407VGT6 (sht30_fw) + 센서: SHT30 I²C 0x44 + 전송: HMAC-SHA256 + TLS 1.2 + 폐쇄망 · 인터넷 없음 +
+
+
+ +
+ +
+ 이 가이드의 이미지에 대하여. 본 문서의 아키텍처·배선 다이어그램은 코드/HARDWARE.md 기준으로 정확히 생성한 SVG입니다. + Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼 운영 환경에서만 캡처할 수 있는 화면은 + “📷 캡처 위치” 자리표시로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다. + (저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 — 부록 참조.) +
+ + + + +
+

1. 시스템 개요 & 아키텍처

+

단일 STM32F407 보드(sht30_fw, sensor_id=2)가 SHT30 센서를 I²C로 5분마다 읽어, + 본문을 HMAC-SHA256으로 서명해 TLS 1.2로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 재판정하여 + 고온/저온/고습/저습·결로·오프라인 상황을 SMS로 알리고 웹 대시보드/보고서를 제공합니다.

+ +
+ + + + + + + + + SHT30온·습도 센서 + + STM32F407sht30_fwFreeRTOS+mbedTLS + + Cafe24 PHPapi/sensor_data.php임계 재판정 + 저장 + + MySQL4 테이블 + + SMS · 대시보드경보/보고서 + I²C 0x44 + HMACTLS 1.2 + + + +
그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)
+
+ + + + + + + + + +
구분설치 위치역할
Cafe24 서버public_html/raspi_leck_detecter/센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서
Cafe24 MySQLCafe24 DB센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태
STM32F407VGT6펌웨어 플래시 (sht30_fw)SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명
서버 cronCafe24 cron 또는 외부 스케줄러cron_heartbeat.php 주기 실행(오프라인 감지·헬스·에스컬레이션)
+
웹 루트 경로 고정. 폴더명 raspi_leck_detecter 는 레거시 이름이지만 펌웨어 + app_config.h 의 APP_API_PATH 와 반드시 일치해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.
+
+ + +
+

2. 설치 전 준비물

+ + + + + + + + +
서버측(Cafe24)장치측(STM32)
+ • Cafe24 PHP 호스팅
+ • Cafe24 MySQL DB(이름/계정/비밀번호)
+ • Cafe24 SMS 서비스 계정 + secure key + 발신번호
+ • 운영 담당자 SMS 수신번호
+ • HTTPS 도메인/경로 + Cafe24 루트 CA +
+ • STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)
+ • SHT30 모듈(한진데이터 P4422-3, I²C)
+ • LAN8720 Ethernet PHY(RMII) + LAN 케이블
+ • I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)
+ • 외부 3.3V USB-UART 어댑터(USART3 콘솔)
+ • 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴) +
+
핵심 비밀값 한 쌍. 서버 config.local.php 의 API_KEY 와 펌웨어 + secrets.h 의 APP_API_KEY 는 바이트 단위로 동일해야 합니다. 서명 + X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문)) 검증의 기준입니다.
+
+ + +
+ PART A · Cafe24 서버 +

3. 서버 파일 업로드

+

Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.

+
public_html/raspi_leck_detecter/
+  .htaccess              config.php            config.local.php   ← 서버에서만 생성
+  config.local.example.php
+  login.php              dashboard.php         setup_wizard.php
+  setup_mfa.php          admin_security.php    ops_checks.php
+  sms_send.php           cron_heartbeat.php    monthly_report.php
+  security_evidence.php  retention_cleanup.php setup_hash.php
+  blocked.php            test_mobile.php
+  api/
+    sensor_data.php
+  var/
+    .gitkeep             ← PHP가 쓰기 가능해야 함(상태/감사/카운터 파일)
+
업로드 금지. config.local.php 와 펌웨어 secrets.h 는 비밀값이므로 + 저장소/공유에 올리지 않습니다. php/var/ 는 .htaccess 로 외부 접근이 차단됩니다(확인 필수).
+
+ 그림 2 캡처 + +
그림 2. 서버 파일 업로드 결과(웹 루트 트리)
+
+
+ +
+ PART A · Cafe24 서버 +

4. 운영 설정 — config.local.php

+

config.local.example.php 를 복사해 config.local.php 를 만들고 실제 운영 값으로 교체합니다.

+
<?php
+return [
+    'DB_HOST' => 'localhost',
+    'DB_PORT' => 3306,
+    'DB_NAME' => 'your_db_name',
+    'DB_USER' => 'your_db_user',
+    'DB_PASS' => 'your_db_password',
+
+    'API_KEY' => 'replace-with-long-random-secret',   // 펌웨어 APP_API_KEY 와 동일!
+    'ALLOW_LEGACY_BODY_SIGNATURE' => false,            // HMAC 전용(레거시 RPi 없으면 false)
+
+    'SMS_USER_ID' => 'your-cafe24-sms-user-id',
+    'SMS_SECURE'  => 'your-cafe24-sms-secure-key',
+    'SMS_SENDER'  => '01000000000',
+    'SMS_RECIPIENTS' => ['01000000000'],
+    'SMS_RECIPIENTS_TIER2' => [],                      // 미복귀 30분 시 2차(야간) 통지(opt-in)
+
+    'ADMIN_USER' => 'admin',
+    'ADMIN_PASSWORD_HASH' => '',   // setup_hash.php 출력으로 채움(빈값=로그인 차단)
+    'ADMIN_TOTP_SECRET'   => '',   // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
+    'MFA_SETUP_TOKEN'     => 'replace-with-temporary-random-token',  // 등록 후 비울 것
+
+    // 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
+    'METRIC_TEMP_HIGH_C' => 30, 'METRIC_TEMP_LOW_C' => 10,
+    'METRIC_RH_HIGH' => 70,      'METRIC_RH_LOW' => 20,
+];
+
fail-closed 기본값. ADMIN_PASSWORD_HASH 또는 ADMIN_TOTP_SECRET 가 + 비어 있으면 관리자 로그인이 차단됩니다(보안). 4·6단계에서 채웁니다.
+
+ +
+ PART A · Cafe24 서버 +

5. 데이터베이스 설치 (phpMyAdmin)

+
+
+

Cafe24 phpMyAdmin 접속 → 대상 DB 선택

+

Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(DB_NAME)를 클릭합니다.

+
+
+

스키마 실행

+

신규 설치는 sql/schema_sht30.sql 전체를 복사해 SQL 탭에 붙여넣고 실행. + 생성 테이블: sensor_log, sensor_status, sensor_metric, sms_log + (+ 재전송 방어용 ingest_nonce).

+ + + + + + +
SQL 파일적용 상황
sql/schema_sht30.sql신규 설치(통합 스키마)
sql/migration_drop_leak.sql기존 누수(v2605) 설치 → 온습도 전용 전환. 비가역 — 실행 전 DB 전체 백업
+
중복 실행 주의. 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. + 기존 설치는 setup_wizard.php 점검 결과를 먼저 확인하세요.
+
+
+
+ 그림 3 캡처 + +
그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)
+
+
+ +
+ PART A · Cafe24 서버 +

6. 권한 · 관리자 비밀번호 · MFA 등록

+
+
+

상태 파일 폴더 쓰기 권한

+

raspi_leck_detecter/var/ 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).

+
+
+

관리자 비밀번호 해시 생성(서버 CLI)

+
php setup_hash.php "새관리자비밀번호"
+

출력된 bcrypt 해시를 config.local.php 의 ADMIN_PASSWORD_HASH 에 넣습니다.

+
+
+

MFA(TOTP) 등록

+

MFA_SETUP_TOKEN 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.

+
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
+

화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 ADMIN_TOTP_SECRET 줄을 + config.local.php 에 반영 → 등록 후 MFA_SETUP_TOKEN 을 빈 값으로 바꿉니다.

+
+ 그림 4 캡처 + +
그림 4. 관리자 MFA(TOTP) 등록 화면
+
+
+
+

설치 점검 화면 확인

+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+

확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 · + var/ 쓰기 권한 · 테스트 SMS 발송.

+
+ 그림 5 캡처 + +
그림 5. 설치 점검(setup_wizard) 통과 화면
+
+
+
+
+ +
+ PART A · Cafe24 서버 +

7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션

+

cron_heartbeat.php 를 1분 간격으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).

+

방법 A — 서버 CLI cron

+
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
+

방법 B — HTTP 호출(외부 스케줄러)

+
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
+

CRON_SECRET = HMAC-SHA256("cron", API_KEY) 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/phpinfo()로 확인. + 마지막 수신 후 HEARTBEAT_TIMEOUT_SEC(기본 1200초=20분) 초과 시 오프라인 판정.

+
cron이 없으면? 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단, + 완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 cron 등록을 권장합니다.
+
+ 그림 6 캡처 + +
그림 6. Cafe24 cron(예약작업) 등록
+
+
+ + +
+ PART B · STM32 MCU +

8. 하드웨어 배선 (STM32F407VGT6)

+

LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.

+ +
+ + + + + STM32F407VGT6 + 168MHz · sht30_fw + HSE 8MHz→PLL · IWDG 20s · LSE RTC + + + SHT30P4422-3 · I²C 0x44 + VDDSCLSDAGND + + 3.3V + PB6 SCL + PB7 SDA + GND + + 4.7kΩ↑3.3V (SCL/SDA) + + + LAN8720 PHY (RMII · AF11) + PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV + PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1 + MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK + + + + 콘솔 & 상태 LED + USART3: PD8 TX / PD9 RX (AF7, 115200 8N1) + 외부 3.3V USB-UART · 상태 LED = PD12 + + + ※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음. + ※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9). + +
그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)
+
+ +

8.1 SHT30 I²C1

+ + + + + + + + +
SHT30STM32 핀비고
VDD3.3VSCL/SDA 각각 4.7kΩ → 3.3V 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 0x2C06 → ~20ms → 6바이트 read
SCLPB6
SDAPB7
GND / ADDRGND
+

8.2 LAN8720 RMII (AF11)

+ + + + + + + + +
STM32 핀RMII 신호STM32 핀RMII 신호
PA1ETH_REF_CLK (50MHz 입력)PC4ETH_RXD0
PA2ETH_MDIO ⚠️ (USART2 금지)PC5ETH_RXD1
PC1ETH_MDCPB11ETH_TX_EN
PA7ETH_CRS_DVPB12/PB13ETH_TXD0 / TXD1
+

8.3 콘솔 · 상태 LED · 클럭

+ +
+ +
+ PART B · STM32 MCU +

9. 폐쇄망 펌웨어 빌드

+
폐쇄망 원칙. 다운로드는 인터넷 가능한 빌드 머신에서만 일어나고, 결과물 .bin 이 에어갭을 + 넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.
+
+
+

의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)

+
bash firmware/scripts/vendor.sh        # 또는 vendor.ps1
+# 릴리스 빌드는 핀(SHA-256) 강제:
+VENDOR_STRICT=1 bash firmware/scripts/vendor.sh
+

STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 firmware/third_party/ 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.

+
+
+

비밀값 — secrets.h

+
cp firmware/common/secrets.h.example firmware/common/secrets.h
+# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.
+
secrets.h 는 커밋 금지. 산출 .bin 에 키가 임베드되므로 .bin 자체를 비밀로 취급합니다.
+
+
+

서버 루트 CA 임베드 — certs/server_ca.c (필수)

+
openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
+#   → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
+#   (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)
+
자리표시자 CA로는 부팅 불가. 기본 server_ca.c 는 PLACEHOLDER라 tls_init()가 + 실패해 부팅이 정지(fail-closed)합니다. build.ps1 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.
+
+
+

설정값 확인 — app_config.h

+ + + + + + + +
설정값
APP_API_HOST / APP_API_PATH운영 도메인 / /raspi_leck_detecter/api/sensor_data.php
APP_SNTP_SERVER ⚠️폐쇄망 LAN NTP 서버 IP(기본 192.0.2.123 자리표시자 — 반드시 교체)
APP_SHT30_I2C_ADDR / APP_SHT30_REPORT_INTERVAL_SEC0x44 / 300(5분)
+
SNTP는 폐쇄망 전면장애 지점. 인터넷 NTP(pool.ntp.org)는 폐쇄망에서 도달 불가 → + 시간 미동기 → timestamp=0 → TLS 차단 + 서버 전량 거부. 반드시 사내 LAN NTP IP로 설정하세요.
+
+
+

빌드 (cmake + ninja)

+
cmake -S firmware -B firmware/build -G Ninja \
+      -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
+cmake --build firmware/build --target sht30_fw   # → sht30_fw.elf / .bin / .hex
+

(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: python3 firmware/test/host/parity_test.py

+
+
+
+ 그림 8 캡처 + +
그림 8. 펌웨어 빌드 성공 로그
+
+
+ +
+ PART B · STM32 MCU +

10. 에어갭 전달 · 플래시 · 콘솔 확인

+
+
+

무결성 해시 + 신뢰 매체 전달

+
sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt   # 빌드 머신
+# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조
+
+
+

ST-Link 플래시 (격리 호스트)

+
pwsh firmware/scripts/flash.ps1                 # 권장(자동 탐지)
+# 대안:
+st-flash write sht30_fw.bin 0x08000000
+STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst
+
+ 그림 9 캡처 + +
그림 9. STM32 펌웨어 플래시 완료
+
+
+
+

콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)

+

외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:

+
bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
+        → TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200
+

I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면 + API_KEY↔APP_API_KEY, TLS 실패면 server_ca.c/SNTP를 재점검.

+
+ 그림 10 캡처 + +
그림 10. STM32 콘솔 부팅 로그(정상 보고)
+
+
+
+
+ + +
+

11. 최종 검증 (종단)

+

서버에서 아래 화면을 확인합니다.

+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+https://your-domain.example/raspi_leck_detecter/dashboard.php
+https://your-domain.example/raspi_leck_detecter/monthly_report.php
+https://your-domain.example/raspi_leck_detecter/security_evidence.php
+
+ 정상 설치 기준 +
    +
  • setup_wizard.php 필수 점검 항목 통과 + 테스트 SMS 수신
  • +
  • dashboard.php 에 최신 온/습도와 센서 상태(sensor_id=2) 표시
  • +
  • sensor_metric 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록
  • +
  • 임계 초과 테스트 시 metric_status 경보 + SMS 수신 / 월간 보고서에 내역 조회
  • +
+
+
+ 그림 11 캡처 + +
그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일
+
+
+ + +
+

12. 문제 해결

+ + + + + + + + + + + + +
증상확인 지점
API 403서버 API_KEY ↔ 펌웨어 APP_API_KEY 일치, raw-body 서명
API 500Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부
로그인 불가ADMIN_PASSWORD_HASH, ADMIN_TOTP_SECRET, 인증 앱 시간 동기
SMS 미발송Cafe24 SMS 계정·secure key·발신번호 등록·잔액, sms_log. 대시보드 ‘수집 거부/발송 실패’ 배너 확인
온습도 미표시I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, sensor_metric, 펌웨어 측정 로그
TLS 실패server_ca.c 실제 CA 반영, SNTP 시간 동기(APP_SNTP_SERVER=LAN NTP)
데이터 전혀 안 들어옴대시보드 수집 거부 사유 카운터(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별
오프라인 알림 과다/지연HEARTBEAT_TIMEOUT_SEC(1200초), cron 주기
+
+ + +
+

13. 운영자가 별도 보관할 값

+
아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.
+ +
+ + +
+

부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)

+
주의: 아래 캡처는 구버전 v2605 누수감지(카메라/사진) UI 또는 Raspberry Pi 기준이라 + 현재 v2606 STM32/온습도 시스템 화면과 다릅니다. 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는 + 현재 운영 시스템에서 직접 캡처한 이미지를 넣으세요.
+

본 HTML 파일은 docs/ 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):

+ + + + + + + + + + +
경로내용상태
examples/captures/admin_dashboard_overview.png관리자 대시보드 개요⚠️ v2605 누수 UI
examples/captures/admin_dashboard_mobile.png대시보드 모바일⚠️ v2605
examples/captures/admin_dashboard_incident.png경보/사고 카드⚠️ v2605(사고/사진)
evidence/mfa_setup_page_capture.pngMFA 등록 페이지참고(개념 동일)
examples/assets/sht30_sensor2_wiring.svg배선도⚠️ Raspberry Pi 기준
assets/security_plan/generated/security_plan_target_architecture.png목표 아키텍처보안계획 다이어그램
+ +

현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.

+
+ +

+ 서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(Ctrl/Cmd+P) 지원 +

+
+ + diff --git a/docs/INSTALL_GUIDE.standalone.html b/docs/INSTALL_GUIDE.standalone.html new file mode 100644 index 0000000..6c5d941 --- /dev/null +++ b/docs/INSTALL_GUIDE.standalone.html @@ -0,0 +1,658 @@ + + + + + +서버실 온습도(SHT30) 모니터링 v2606 — 설치 가이드 (Cafe24 → STM32) + + + + +
+
+

서버실 온습도(SHT30) 모니터링 시스템 v2606 — 설치 가이드

+

Cafe24 PHP/MySQL 서버부터 STM32F407 MCU 펌웨어까지, 폐쇄망(air-gapped) 배포 절차

+
+ 서버: Cafe24 PHP / MySQL + MCU: STM32F407VGT6 (sht30_fw) + 센서: SHT30 I²C 0x44 + 전송: HMAC-SHA256 + TLS 1.2 + 폐쇄망 · 인터넷 없음 +
+
+
+ +
+ +
+ 이 가이드의 이미지에 대하여. 본 문서의 아키텍처·배선 다이어그램은 코드/HARDWARE.md 기준으로 정확히 생성한 SVG입니다. + Cafe24 관리자 콘솔·phpMyAdmin·플래싱 툴·실제 대시보드 화면처럼 운영 환경에서만 캡처할 수 있는 화면은 + “📷 캡처 위치” 자리표시로 두었습니다. 각 자리표시의 캡션이 가리키는 화면을 직접 캡처해 그 자리에 넣으면 됩니다. + (저장소의 기존 캡처는 구버전 v2605 누수감지 UI라 현재 화면과 다릅니다 — 부록 참조.) +
+ + + + +
+

1. 시스템 개요 & 아키텍처

+

단일 STM32F407 보드(sht30_fw, sensor_id=2)가 SHT30 센서를 I²C로 5분마다 읽어, + 본문을 HMAC-SHA256으로 서명해 TLS 1.2로 Cafe24 PHP 서버에 POST합니다. 서버가 임계를 재판정하여 + 고온/저온/고습/저습·결로·오프라인 상황을 SMS로 알리고 웹 대시보드/보고서를 제공합니다.

+ +
+ + + + + + + + + SHT30온·습도 센서 + + STM32F407sht30_fwFreeRTOS+mbedTLS + + Cafe24 PHPapi/sensor_data.php임계 재판정 + 저장 + + MySQL4 테이블 + + SMS · 대시보드경보/보고서 + I²C 0x44 + HMACTLS 1.2 + + + +
그림 1. 전체 데이터 흐름 — SHT30 → STM32(서명/TLS) → Cafe24(재판정/저장) → MySQL + SMS/대시보드. (코드 기준 생성)
+
+ + + + + + + + + +
구분설치 위치역할
Cafe24 서버public_html/raspi_leck_detecter/센서 API 수신, DB 저장, 임계 판정, SMS, 대시보드/보고서
Cafe24 MySQLCafe24 DB센서 로그 · 온습도 측정값 · SMS 로그 · 장비 상태
STM32F407VGT6펌웨어 플래시 (sht30_fw)SHT30 I²C 측정, 5분 주기 보고, raw-body HMAC 서명
서버 cronCafe24 cron 또는 외부 스케줄러cron_heartbeat.php 주기 실행(오프라인 감지·헬스·에스컬레이션)
+
웹 루트 경로 고정. 폴더명 raspi_leck_detecter 는 레거시 이름이지만 펌웨어 + app_config.h 의 APP_API_PATH 와 반드시 일치해야 합니다. 폴더명을 바꾸려면 펌웨어도 함께 바꿔 재빌드해야 합니다.
+
+ + +
+

2. 설치 전 준비물

+ + + + + + + + +
서버측(Cafe24)장치측(STM32)
+ • Cafe24 PHP 호스팅
+ • Cafe24 MySQL DB(이름/계정/비밀번호)
+ • Cafe24 SMS 서비스 계정 + secure key + 발신번호
+ • 운영 담당자 SMS 수신번호
+ • HTTPS 도메인/경로 + Cafe24 루트 CA +
+ • STM32F407VGT6 보드(DISCOVERY 계열/커스텀 PCB)
+ • SHT30 모듈(한진데이터 P4422-3, I²C)
+ • LAN8720 Ethernet PHY(RMII) + LAN 케이블
+ • I²C 풀업 4.7kΩ × 2 (모듈 내장 시 생략)
+ • 외부 3.3V USB-UART 어댑터(USART3 콘솔)
+ • 폐쇄망 빌드 머신(arm-none-eabi-gcc, CMake, Ninja, ST-Link 툴) +
+
핵심 비밀값 한 쌍. 서버 config.local.php 의 API_KEY 와 펌웨어 + secrets.h 의 APP_API_KEY 는 바이트 단위로 동일해야 합니다. 서명 + X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문)) 검증의 기준입니다.
+
+ + +
+ PART A · Cafe24 서버 +

3. 서버 파일 업로드

+

Cafe24 FTP(또는 파일 관리자)로 웹 루트 아래에 PHP 트리를 업로드합니다.

+
public_html/raspi_leck_detecter/
+  .htaccess              config.php            config.local.php   ← 서버에서만 생성
+  config.local.example.php
+  login.php              dashboard.php         setup_wizard.php
+  setup_mfa.php          admin_security.php    ops_checks.php
+  sms_send.php           cron_heartbeat.php    monthly_report.php
+  security_evidence.php  retention_cleanup.php setup_hash.php
+  blocked.php            test_mobile.php
+  api/
+    sensor_data.php
+  var/
+    .gitkeep             ← PHP가 쓰기 가능해야 함(상태/감사/카운터 파일)
+
업로드 금지. config.local.php 와 펌웨어 secrets.h 는 비밀값이므로 + 저장소/공유에 올리지 않습니다. php/var/ 는 .htaccess 로 외부 접근이 차단됩니다(확인 필수).
+
+ 그림 2 캡처 + +
그림 2. 서버 파일 업로드 결과(웹 루트 트리)
+
+
+ +
+ PART A · Cafe24 서버 +

4. 운영 설정 — config.local.php

+

config.local.example.php 를 복사해 config.local.php 를 만들고 실제 운영 값으로 교체합니다.

+
<?php
+return [
+    'DB_HOST' => 'localhost',
+    'DB_PORT' => 3306,
+    'DB_NAME' => 'your_db_name',
+    'DB_USER' => 'your_db_user',
+    'DB_PASS' => 'your_db_password',
+
+    'API_KEY' => 'replace-with-long-random-secret',   // 펌웨어 APP_API_KEY 와 동일!
+    'ALLOW_LEGACY_BODY_SIGNATURE' => false,            // HMAC 전용(레거시 RPi 없으면 false)
+
+    'SMS_USER_ID' => 'your-cafe24-sms-user-id',
+    'SMS_SECURE'  => 'your-cafe24-sms-secure-key',
+    'SMS_SENDER'  => '01000000000',
+    'SMS_RECIPIENTS' => ['01000000000'],
+    'SMS_RECIPIENTS_TIER2' => [],                      // 미복귀 30분 시 2차(야간) 통지(opt-in)
+
+    'ADMIN_USER' => 'admin',
+    'ADMIN_PASSWORD_HASH' => '',   // setup_hash.php 출력으로 채움(빈값=로그인 차단)
+    'ADMIN_TOTP_SECRET'   => '',   // setup_mfa.php 등록 후 채움(빈값=로그인 차단)
+    'MFA_SETUP_TOKEN'     => 'replace-with-temporary-random-token',  // 등록 후 비울 것
+
+    // 온습도 임계(서버 판정 → SMS). 기본값은 config.php 의 METRIC_*
+    'METRIC_TEMP_HIGH_C' => 30, 'METRIC_TEMP_LOW_C' => 10,
+    'METRIC_RH_HIGH' => 70,      'METRIC_RH_LOW' => 20,
+];
+
fail-closed 기본값. ADMIN_PASSWORD_HASH 또는 ADMIN_TOTP_SECRET 가 + 비어 있으면 관리자 로그인이 차단됩니다(보안). 4·6단계에서 채웁니다.
+
+ +
+ PART A · Cafe24 서버 +

5. 데이터베이스 설치 (phpMyAdmin)

+
+
+

Cafe24 phpMyAdmin 접속 → 대상 DB 선택

+

Cafe24 호스팅 관리 → MySQL → phpMyAdmin. 좌측에서 운영 DB(DB_NAME)를 클릭합니다.

+
+
+

스키마 실행

+

신규 설치는 sql/schema_sht30.sql 전체를 복사해 SQL 탭에 붙여넣고 실행. + 생성 테이블: sensor_log, sensor_status, sensor_metric, sms_log + (+ 재전송 방어용 ingest_nonce).

+ + + + + + +
SQL 파일적용 상황
sql/schema_sht30.sql신규 설치(통합 스키마)
sql/migration_drop_leak.sql기존 누수(v2605) 설치 → 온습도 전용 전환. 비가역 — 실행 전 DB 전체 백업
+
중복 실행 주의. 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. + 기존 설치는 setup_wizard.php 점검 결과를 먼저 확인하세요.
+
+
+
+ 그림 3 캡처 + +
그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)
+
+
+ +
+ PART A · Cafe24 서버 +

6. 권한 · 관리자 비밀번호 · MFA 등록

+
+
+

상태 파일 폴더 쓰기 권한

+

raspi_leck_detecter/var/ 가 PHP에서 쓰기 가능해야 합니다(브루트포스/하트비트/감사로그/거부 카운터 파일 저장).

+
+
+

관리자 비밀번호 해시 생성(서버 CLI)

+
php setup_hash.php "새관리자비밀번호"
+

출력된 bcrypt 해시를 config.local.php 의 ADMIN_PASSWORD_HASH 에 넣습니다.

+
+
+

MFA(TOTP) 등록

+

MFA_SETUP_TOKEN 을 임시 설정한 뒤 아래 주소로 접속해 인증 앱(Google/Microsoft Authenticator, Authy)에 등록합니다.

+
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
+

화면의 Base32 키를 인증 앱에 등록 → 6자리 코드 검증 → 표시된 ADMIN_TOTP_SECRET 줄을 + config.local.php 에 반영 → 등록 후 MFA_SETUP_TOKEN 을 빈 값으로 바꿉니다.

+
+ 그림 4 캡처 + +
그림 4. 관리자 MFA(TOTP) 등록 화면
+
+
+
+

설치 점검 화면 확인

+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+

확인 항목: DB 연결 · 필수 테이블 · API 키 교체 · 관리자 해시 · 관리자 TOTP · SMS 수신자 · + var/ 쓰기 권한 · 테스트 SMS 발송.

+
+ 그림 5 캡처 + +
그림 5. 설치 점검(setup_wizard) 통과 화면
+
+
+
+
+ +
+ PART A · Cafe24 서버 +

7. cron 설정 — 오프라인 감지 · 헬스 · 에스컬레이션

+

cron_heartbeat.php 를 1분 간격으로 실행합니다(오프라인 감지 + 일일 헬스 SMS + 미복귀 에스컬레이션).

+

방법 A — 서버 CLI cron

+
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
+

방법 B — HTTP 호출(외부 스케줄러)

+
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
+

CRON_SECRET = HMAC-SHA256("cron", API_KEY) 입니다(코드가 파생). 절대 경로는 Cafe24 파일 관리자/phpinfo()로 확인. + 마지막 수신 후 HEARTBEAT_TIMEOUT_SEC(기본 1200초=20분) 초과 시 오프라인 판정.

+
cron이 없으면? 다른 페이지(ingest/대시보드) 접속 시 5분 쓰로틀로 piggyback 실행됩니다. 단, + 완전 유휴 상태에선 오프라인 감지가 늦어질 수 있으니 cron 등록을 권장합니다.
+
+ 그림 6 캡처 + +
그림 6. Cafe24 cron(예약작업) 등록
+
+
+ + +
+ PART B · STM32 MCU +

8. 하드웨어 배선 (STM32F407VGT6)

+

LQFP-100 기준. SHT30 I²C, LAN8720 RMII, USART3 콘솔, 상태 LED를 결선합니다.

+ +
+ + + + + STM32F407VGT6 + 168MHz · sht30_fw + HSE 8MHz→PLL · IWDG 20s · LSE RTC + + + SHT30P4422-3 · I²C 0x44 + VDDSCLSDAGND + + 3.3V + PB6 SCL + PB7 SDA + GND + + 4.7kΩ↑3.3V (SCL/SDA) + + + LAN8720 PHY (RMII · AF11) + PA1 REF_CLK · PA2 MDIO · PC1 MDC · PA7 CRS_DV + PC4/PC5 RXD0/1 · PB11 TXEN · PB12/PB13 TXD0/1 + MDIO 1.5kΩ↑3.3V · 50MHz REF_CLK + + + + 콘솔 & 상태 LED + USART3: PD8 TX / PD9 RX (AF7, 115200 8N1) + 외부 3.3V USB-UART · 상태 LED = PD12 + + + ※ PB6/PB7(I²C)와 PB11/12/13(RMII)은 같은 PB포트지만 충돌 없음. + ※ PA2는 ETH_MDIO 전용 — USART2 사용 금지(콘솔은 USART3 PD8/PD9). + +
그림 7. STM32F407 + SHT30 + LAN8720 + USART3 콘솔 배선 (HARDWARE.md 핀맵 기준 생성)
+
+ +

8.1 SHT30 I²C1

+ + + + + + + + +
SHT30STM32 핀비고
VDD3.3VSCL/SDA 각각 4.7kΩ → 3.3V 풀업(모듈 내장 시 생략). 100kHz 권장. 명령 0x2C06 → ~20ms → 6바이트 read
SCLPB6
SDAPB7
GND / ADDRGND
+

8.2 LAN8720 RMII (AF11)

+ + + + + + + + +
STM32 핀RMII 신호STM32 핀RMII 신호
PA1ETH_REF_CLK (50MHz 입력)PC4ETH_RXD0
PA2ETH_MDIO ⚠️ (USART2 금지)PC5ETH_RXD1
PC1ETH_MDCPB11ETH_TX_EN
PA7ETH_CRS_DVPB12/PB13ETH_TXD0 / TXD1
+

8.3 콘솔 · 상태 LED · 클럭

+ +
+ +
+ PART B · STM32 MCU +

9. 폐쇄망 펌웨어 빌드

+
폐쇄망 원칙. 다운로드는 인터넷 가능한 빌드 머신에서만 일어나고, 결과물 .bin 이 에어갭을 + 넘어 전달됩니다. 설치 호스트는 어떤 것도 직접 가져오지 않습니다.
+
+
+

의존성 벤더링(빌드 머신, 외부 다운로드는 여기서만)

+
bash firmware/scripts/vendor.sh        # 또는 vendor.ps1
+# 릴리스 빌드는 핀(SHA-256) 강제:
+VENDOR_STRICT=1 bash firmware/scripts/vendor.sh
+

STM32CubeF4 HAL/CMSIS · FreeRTOS-Kernel · lwip(+sntp) · mbedtls 를 firmware/third_party/ 에 고정 버전으로 배치. 빌드·런타임 외부 fetch 0건.

+
+
+

비밀값 — secrets.h

+
cp firmware/common/secrets.h.example firmware/common/secrets.h
+# APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.
+
secrets.h 는 커밋 금지. 산출 .bin 에 키가 임베드되므로 .bin 자체를 비밀로 취급합니다.
+
+
+

서버 루트 CA 임베드 — certs/server_ca.c (필수)

+
openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
+#   → 루트 CA 를 PEM 으로 저장 후 certs/server_ca.c 의 SERVER_CA_PEM 에 반영
+#   (SERVER_CA_PEM_LEN = sizeof(...) , 종결 NUL 포함)
+
자리표시자 CA로는 부팅 불가. 기본 server_ca.c 는 PLACEHOLDER라 tls_init()가 + 실패해 부팅이 정지(fail-closed)합니다. build.ps1 이 PLACEHOLDER를 감지하면 빌드를 중단합니다 — 반드시 실제 CA로 교체하세요.
+
+
+

설정값 확인 — app_config.h

+ + + + + + + +
설정값
APP_API_HOST / APP_API_PATH운영 도메인 / /raspi_leck_detecter/api/sensor_data.php
APP_SNTP_SERVER ⚠️폐쇄망 LAN NTP 서버 IP(기본 192.0.2.123 자리표시자 — 반드시 교체)
APP_SHT30_I2C_ADDR / APP_SHT30_REPORT_INTERVAL_SEC0x44 / 300(5분)
+
SNTP는 폐쇄망 전면장애 지점. 인터넷 NTP(pool.ntp.org)는 폐쇄망에서 도달 불가 → + 시간 미동기 → timestamp=0 → TLS 차단 + 서버 전량 거부. 반드시 사내 LAN NTP IP로 설정하세요.
+
+
+

빌드 (cmake + ninja)

+
cmake -S firmware -B firmware/build -G Ninja \
+      -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
+cmake --build firmware/build --target sht30_fw   # → sht30_fw.elf / .bin / .hex
+

(선택) 호스트 패리티 게이트로 C↔Python↔PHP 서명 일치 확인: python3 firmware/test/host/parity_test.py

+
+
+
+ 그림 8 캡처 + +
그림 8. 펌웨어 빌드 성공 로그
+
+
+ +
+ PART B · STM32 MCU +

10. 에어갭 전달 · 플래시 · 콘솔 확인

+
+
+

무결성 해시 + 신뢰 매체 전달

+
sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt   # 빌드 머신
+# USB 등 신뢰 매체로 격리 호스트에 전달 후 재계산 대조
+
+
+

ST-Link 플래시 (격리 호스트)

+
pwsh firmware/scripts/flash.ps1                 # 권장(자동 탐지)
+# 대안:
+st-flash write sht30_fw.bin 0x08000000
+STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst
+
+ 그림 9 캡처 + +
그림 9. STM32 펌웨어 플래시 완료
+
+
+
+

콘솔 로그 확인 (USART3 PD8/PD9, 115200 8N1)

+

외부 USB-UART 어댑터를 연결하고 부팅 시퀀스를 관찰합니다:

+
bsp_init → 링크업 → DHCP(또는 static) → SNTP 시간 동기
+        → TLS 핸드셰이크 → 첫 startup POST → 서버 HTTP 200
+

I²C1 0x44 응답 · 첫 온/습도 측정 · 상태 LED(PD12) 패턴을 함께 확인. 서명 불일치(403)면 + API_KEY↔APP_API_KEY, TLS 실패면 server_ca.c/SNTP를 재점검.

+
+ 그림 10 캡처 + +
그림 10. STM32 콘솔 부팅 로그(정상 보고)
+
+
+
+
+ + +
+

11. 최종 검증 (종단)

+

서버에서 아래 화면을 확인합니다.

+
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
+https://your-domain.example/raspi_leck_detecter/dashboard.php
+https://your-domain.example/raspi_leck_detecter/monthly_report.php
+https://your-domain.example/raspi_leck_detecter/security_evidence.php
+
+ 정상 설치 기준 +
    +
  • setup_wizard.php 필수 점검 항목 통과 + 테스트 SMS 수신
  • +
  • dashboard.php 에 최신 온/습도와 센서 상태(sensor_id=2) 표시
  • +
  • sensor_metric 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록
  • +
  • 임계 초과 테스트 시 metric_status 경보 + SMS 수신 / 월간 보고서에 내역 조회
  • +
+
+
+ 그림 11 캡처 + +
그림 11. 운영 대시보드(v2606 온습도) — 데스크톱 / 모바일
+
+
+ + +
+

12. 문제 해결

+ + + + + + + + + + + + +
증상확인 지점
API 403서버 API_KEY ↔ 펌웨어 APP_API_KEY 일치, raw-body 서명
API 500Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부
로그인 불가ADMIN_PASSWORD_HASH, ADMIN_TOTP_SECRET, 인증 앱 시간 동기
SMS 미발송Cafe24 SMS 계정·secure key·발신번호 등록·잔액, sms_log. 대시보드 ‘수집 거부/발송 실패’ 배너 확인
온습도 미표시I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, sensor_metric, 펌웨어 측정 로그
TLS 실패server_ca.c 실제 CA 반영, SNTP 시간 동기(APP_SNTP_SERVER=LAN NTP)
데이터 전혀 안 들어옴대시보드 수집 거부 사유 카운터(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별
오프라인 알림 과다/지연HEARTBEAT_TIMEOUT_SEC(1200초), cron 주기
+
+ + +
+

13. 운영자가 별도 보관할 값

+
아래 값은 저장소에 넣지 말고 운영자만 안전하게 보관합니다.
+ +
+ + +
+

부록 — 저장소 내 기존 이미지 자산 (레거시 · 참고용)

+
주의: 아래 캡처는 구버전 v2605 누수감지(카메라/사진) UI 또는 Raspberry Pi 기준이라 + 현재 v2606 STM32/온습도 시스템 화면과 다릅니다. 화면 구성 참고용으로만 사용하고, 위 본문의 “📷 캡처 위치”에는 + 현재 운영 시스템에서 직접 캡처한 이미지를 넣으세요.
+

본 HTML 파일은 docs/ 폴더 기준 상대경로로 아래 자산을 참조할 수 있습니다(폴더 동반 시 표시됨):

+ + + + + + + + + + +
경로내용상태
examples/captures/admin_dashboard_overview.png관리자 대시보드 개요⚠️ v2605 누수 UI
examples/captures/admin_dashboard_mobile.png대시보드 모바일⚠️ v2605
examples/captures/admin_dashboard_incident.png경보/사고 카드⚠️ v2605(사고/사진)
evidence/mfa_setup_page_capture.pngMFA 등록 페이지참고(개념 동일)
examples/assets/sht30_sensor2_wiring.svg배선도⚠️ Raspberry Pi 기준
assets/security_plan/generated/security_plan_target_architecture.png목표 아키텍처보안계획 다이어그램
+ +

현재 시스템의 정확한 아키텍처·배선은 본문 그림 1·7(생성 SVG)을 기준으로 하세요.

+
+ +

+ 서버실 온습도(SHT30) 모니터링 시스템 v2606 · 설치 가이드 · 폐쇄망(air-gapped) 배포 · 인쇄(Ctrl/Cmd+P) 지원 +

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