chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트). 전체 코드리뷰에서 도출된 보안 하드닝 10건 반영: - 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시) - 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php) - 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점) - CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php) - 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화 _backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
BIN
(20260518)_IoT_기반_모니터링_체계_구축을_위한_보안대책서.hwp
Normal file
BIN
(Y수정)_(20260520)_IoT_기반_모니터링_체계_구축을_위한_보안대책서.hwp
Normal file
BIN
(Y수정)_(20260528)_IoT_기반_모니터링_체계_구축을_위한_보안대책서.hwp
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# =============================================================================
|
||||
# .gitignore - leak_sms_v3
|
||||
# =============================================================================
|
||||
|
||||
# ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ──────────────────────────────
|
||||
php/config.local.php
|
||||
firmware/**/secrets.h
|
||||
*.env
|
||||
*.env.*
|
||||
!*.env.example
|
||||
|
||||
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
|
||||
_backup/
|
||||
|
||||
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
|
||||
php/var/*
|
||||
!php/var/.gitkeep
|
||||
|
||||
# ── 빌드 산출물 ───────────────────────────────────────────────────────────
|
||||
firmware/build/
|
||||
*.o
|
||||
*.elf
|
||||
*.bin
|
||||
*.map
|
||||
|
||||
# ── 로컬 도구 / 에디터 상태 ───────────────────────────────────────────────
|
||||
.claude/settings.local.json
|
||||
.impeccable/
|
||||
|
||||
# ── OS 잡파일 ─────────────────────────────────────────────────────────────
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
91
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 변경 이력
|
||||
|
||||
## Unreleased - 보안 하드닝 (전체 코드리뷰 반영)
|
||||
|
||||
### ⚠️ 호환성 주의 (BREAKING)
|
||||
- **요청 서명을 키 접두 SHA256 → HMAC-SHA256 으로 전환했습니다.** 길이확장(length-
|
||||
extension) 공격에 견디기 위함입니다. `X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)`.
|
||||
서버(`verify_signature_raw`)·펌웨어(`sig.c`)·호스트 레퍼런스(`reference.py`)를 동시에
|
||||
바꿨으므로, **펌웨어 재플래시와 서버 배포를 함께** 진행해야 합니다(구 펌웨어 ↔ 신 서버는
|
||||
서명 불일치로 거부됨). 호스트 패리티 테스트와 ARM `-Wconversion -Werror` 컴파일로 검증했습니다.
|
||||
|
||||
### 서버 (PHP)
|
||||
- `api/sensor_data.php`: 재전송(replay) 방어를 추가했습니다 — 본문 `timestamp` 가
|
||||
`INGEST_MAX_CLOCK_SKEW_SEC`(기본 600초) 범위를 벗어나거나 0 이하이면 거부합니다.
|
||||
- `api/sensor_data.php`: 운영 비밀값 강제(fail-closed). `API_KEY` 가 예시 기본값이면
|
||||
수신을 503 으로 거부합니다(`api_key_is_default()`).
|
||||
- `api/sensor_data.php`: 디바이스 문자열(device_id/sensor_name/device_location/metric_type)
|
||||
의 제어문자를 제거해 SMS/로그 라인 주입을 막습니다.
|
||||
- `cron_heartbeat.php`: 오프라인 SMS 중복 발송 경합을 제거했습니다 — `offline_alerted`
|
||||
0→1 전이를 원자적 조건부 UPDATE 로 선점한 프로세스만 발송합니다.
|
||||
- `monthly_report.php`: CSV 내보내기의 수식 주입(formula injection)을 방지합니다(자유
|
||||
텍스트 셀이 `= + - @` 등으로 시작하면 무력화).
|
||||
- `retention_cleanup.php`: 감사로그 회전을 배타 락(flock)으로 묶어 동시 기록 유실을 막습니다.
|
||||
- `login.php`: 브루트포스 카운터를 원자적 읽기-수정-쓰기로 변경해 동시 실패 시 카운트
|
||||
덮어쓰기(lost update)를 막습니다.
|
||||
- `config.local.example.php`: 예시 TOTP 비밀키를 공개 테스트 값에서 명백히 무효한
|
||||
플레이스홀더로 교체했습니다(MFA 가 실제 비밀키 설정 전까지 fail-closed).
|
||||
|
||||
### DB
|
||||
- `sql/migration_drop_leak.sql`: information_schema 가드 프로시저로 멱등화했습니다(재실행/
|
||||
부분 적용 DB 에서도 오류 없이 진행).
|
||||
|
||||
## Unreleased - SHT30 온습도 전용 전환
|
||||
|
||||
### 시스템 범위
|
||||
- 시스템을 서버실 **온습도(SHT30) 24시간 모니터링 + 임계 초과 SMS + 웹 대시보드** 전용으로 전환했습니다. 누수 감지/카메라/사진/사고대응(incident) 기능은 전면 제거했습니다.
|
||||
- 단일 보드 구성으로 단순화: `sht30_fw`(`sensor_id=2`, `device_id=stm32-sht30-01`)만 사용합니다. 누수 보드(`board_leak`/`leak_fw`)는 제거했습니다.
|
||||
|
||||
### 서버 (PHP/DB)
|
||||
- 온습도 임계 SMS를 추가했습니다: 고온 30℃ / 저온 10℃ / 고습 70% / 저습 20%, 복귀 히스테리시스 ±1℃·±3%, 동일 종류 쿨다운 1800초(30분). 임계는 서버 판정값(`php/config.php` `METRIC_*`, `config.local.php` override)이라 폐쇄망에서 펌웨어 재플래시 없이 변경 가능합니다.
|
||||
- 측정값을 `sensor_metric`에 저장하고 `metric_status`로 경보/정상복귀를 판정합니다. 장비 오프라인/복구는 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)으로 판정합니다.
|
||||
- 사진 업로드/열람 경로(`api/photo_upload.php`, `photo.php`)와 사고 대응(`incidents.php`)을 제거했습니다.
|
||||
- 신규 설치용 통합 스키마를 `sql/schema_sht30.sql`로 정리했습니다(테이블: `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`). 기존 누수 설치 전환용 `sql/migration_drop_leak.sql`을 추가해 레거시 누수 컬럼/테이블(`leak_photo`, `leak_incident`, `is_leak` 등)을 정리합니다.
|
||||
|
||||
### 펌웨어 (STM32F407 `sht30_fw`)
|
||||
- 누수 이벤트 본문에서 `is_leak` 와이어 필드를 제거하고 온습도 측정 보고로 단일화했습니다.
|
||||
- SHT30 I2C 배선을 확정했습니다: I2C1 PB6(SCL)/PB7(SDA), 주소 0x44, 3.3V/GND, 4.7kΩ 풀업. 측정 주기 `APP_SHT30_REPORT_INTERVAL_SEC=300`(5분).
|
||||
- 레거시 4.7MΩ 누수 풀다운 회로 및 e-con 5C-08 결선 안내를 폐기했습니다.
|
||||
|
||||
### 문서
|
||||
- README, 설치/운영 문서, 배선도, 정리 보고서를 SHT30 온습도 전용으로 재작성했습니다. 누수/사진/카메라/사고대응 관련 절·표를 온습도 임계 SMS/쿨다운/오프라인 기준으로 치환했습니다.
|
||||
|
||||
## Unreleased - 메인 MCU 마이그레이션 (Raspberry Pi → STM32F407VGT6)
|
||||
|
||||
### 서버 (PHP)
|
||||
- `sensor_data.php` 인증을 이중 모드로 확장했습니다: `X-Signature` 헤더 기반 raw-body 서명(신규, STM32 펌웨어용)과 기존 본문 필드 서명(RPi, 하위 호환)을 모두 수용합니다.
|
||||
- `config.php`에 `verify_signature_raw()`를 추가했습니다 — `sha256(API_KEY . 요청본문)`로 검증하여 JSON 재직렬화/부동소수 포맷 차이로 인한 서명 불일치(R1)를 제거합니다.
|
||||
- raw-body/레거시 서명 회귀 테스트 `tests/raw_body_signature_test.php`를 추가했습니다.
|
||||
|
||||
### 펌웨어 (신규 `firmware/`, STM32F407VGT6 / Cortex-M4F)
|
||||
- 메인 MCU를 Raspberry Pi Zero 2 W(Linux/Python)에서 STM32F407VGT6 베어메탈로 이식했습니다. 구성: 유선 Ethernet(LAN8720 RMII) + LwIP + mbedTLS(HTTPS) + FreeRTOS. WiFi/카메라는 제거.
|
||||
- 두 보드 변형: `BOARD_LEAK`(누수, sensor_id=1), `BOARD_SHT30`(온습도, sensor_id=2). RPi 2대 구성을 미러링.
|
||||
- 이식 가능한 코어(`sha256_sw`/`sig`/`jsonbody`/`sht30_convert`/`httpapi`)는 호스트 패리티(Python↔PHP `verify_signature_raw`) + ARM 크로스컴파일(`-Werror -Wconversion`)로 검증했습니다.
|
||||
- 다중 에이전트 코드리뷰에서 확인된 결함을 수정: TLS 인증서 유효기간 검증 활성화(RTC/SNTP 시간 공급), FreeRTOS 정적메모리 콜백 중복정의 제거, LwIP 코어락 위반(PHY 링크/DNS) 수정, 로그 UART PA2 충돌 → USART3(PD8/PD9) 이동, EXTI 핸들러 추가, HW RNG 기반 `bsp_rand32`.
|
||||
- 폐쇄망 빌드: 의존성은 `firmware/third_party`에 벤더링, 오프라인 빌드/플래시 절차 문서화(`firmware/docs/BUILD_OFFLINE.md`).
|
||||
- `docs/stm32f407_migration_plan.md`(계획+구현 현황), `firmware/docs/{HARDWARE,PORTING_NOTES}.md`를 추가했습니다.
|
||||
- 레거시 RPi 펌웨어(`raspberry_pi/`)와 systemd 유닛(`systemd/`)을 제거했습니다(STM32 펌웨어로 대체).
|
||||
|
||||
## v2605 - 2026-05-19
|
||||
|
||||
- v2605 정리 전 원본을 `_backup/pre_v2605_20260519/`에 보관했습니다.
|
||||
- PHP/RPi 운영 비밀값을 코드 하드코딩에서 로컬 설정/환경 변수 방식으로 분리했습니다.
|
||||
- `config.local.example.php`, `config.example.py`, `leak-sensor.env.example`을 추가했습니다.
|
||||
- 누수경보 SMS 쿨다운이 실제 발송 로직에 적용되도록 수정했습니다.
|
||||
- 누수 지속 SMS를 시간 기반 5분 정책으로 정리했습니다.
|
||||
- heartbeat 체크 주기와 오프라인 판정 시간을 분리했습니다.
|
||||
- 사진 업로드가 실제 `leak_alert` 로그 ID를 검증하도록 강화했습니다.
|
||||
- 신규 설치용 통합 DB 스키마 `sql/schema_v2605.sql`을 추가했습니다.
|
||||
- README와 보고서를 실제 코드 기준으로 최신화했습니다.
|
||||
- 누수 사고 대응 테이블과 대시보드 사고 패널을 추가했습니다.
|
||||
- 누수 사고 `확인함`, `출동 중`, `조치 완료`, `오탐` 상태 변경을 지원합니다.
|
||||
- 미확인 누수 사고 SMS 에스컬레이션을 추가했습니다.
|
||||
- 대시보드 첫 화면을 위험 상태 우선 구조로 재배치했습니다.
|
||||
- 운영 자가진단 패널을 추가해 설정값, 마이그레이션, 권한, SMS 실패를 표시합니다.
|
||||
- 30일 운영 요약을 추가해 사고 상태, 평균 확인/조치 시간, SMS 실패, 사진 저장 현황을 보여줍니다.
|
||||
- 설치/운영 점검 화면 `php/setup_wizard.php`를 추가했습니다.
|
||||
- 보안대책서 첨부용 증적 보고서 `php/security_evidence.php`와 운영·보안 체크리스트를 추가했습니다.
|
||||
- 월간 보고서 화면 `php/monthly_report.php`를 추가했습니다.
|
||||
- 누수 사진 타임라인 다중 촬영을 추가했습니다.
|
||||
- 사진 타임라인 DB 컬럼과 기존 설치용 `sql/migration_photo_timeline.sql`을 추가했습니다.
|
||||
- Pi 증적 수집 스크립트를 추가해 API 키 원문 없이 서비스/포트/환경파일 상태를 확인할 수 있게 했습니다.
|
||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
v2606
|
||||
255
docs/INSTALL_PI_SERVER.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# STM32 펌웨어 및 Cafe24 서버 설치 설명서 v2606
|
||||
|
||||
이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다.
|
||||
|
||||
## 1. 설치 대상
|
||||
|
||||
| 구분 | 설치 위치 | 역할 |
|
||||
|---|---|---|
|
||||
| Cafe24 서버 | `public_html/raspi_leck_detecter/` | 센서 API 수신, DB 저장, 임계 판정, SMS 발송, 대시보드/보고서 제공 |
|
||||
| Cafe24 MySQL | Cafe24 DB | 센서 로그, 온습도 측정값, SMS 로그, 장비 상태 저장 |
|
||||
| STM32F407VGT6 (`sht30_fw`) | 펌웨어 플래시 | SHT30 I2C 측정, 5분 주기 보고, raw-body 서명 |
|
||||
| 서버 cron | Cafe24 cron 또는 외부 스케줄러 | `cron_heartbeat.php` 주기 실행(장비 오프라인 감지) |
|
||||
|
||||
## 2. 설치 전 준비물
|
||||
|
||||
- STM32F407VGT6 보드 (STM32F4-DISCOVERY 계열 또는 동등 커스텀 PCB)
|
||||
- SHT30 온습도 모듈 (한진데이터 P4422-3, I2C)
|
||||
- LAN8720 Ethernet PHY 모듈 (RMII) + LAN 케이블
|
||||
- I2C 풀업 저항 4.7kΩ × 2 (모듈 내장 풀업 시 생략)
|
||||
- 외부 3.3V USB-UART 어댑터 (USART3 PD8/PD9 콘솔 로그용)
|
||||
- Cafe24 PHP 호스팅
|
||||
- Cafe24 MySQL DB
|
||||
- Cafe24 SMS 서비스 계정 및 secure key
|
||||
- 운영 담당자 SMS 수신 번호
|
||||
- HTTPS 접속 가능한 도메인 또는 호스팅 경로 + Cafe24 루트 CA
|
||||
|
||||
배선은 [wiring_diagram.md](./wiring_diagram.md)를 먼저 적용합니다. SHT30 I2C(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업)와 LAN8720 RMII 결선은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md)를 따릅니다.
|
||||
|
||||
## 3. 서버에 설치할 파일
|
||||
|
||||
Cafe24 웹 루트 아래에 다음 구조로 PHP 파일을 업로드합니다.
|
||||
|
||||
```text
|
||||
public_html/raspi_leck_detecter/
|
||||
.htaccess
|
||||
blocked.php
|
||||
config.php
|
||||
config.local.php
|
||||
config.local.example.php
|
||||
ops_checks.php
|
||||
sms_send.php
|
||||
cron_heartbeat.php
|
||||
dashboard.php
|
||||
setup_mfa.php
|
||||
admin_security.php
|
||||
setup_wizard.php
|
||||
security_evidence.php
|
||||
monthly_report.php
|
||||
login.php
|
||||
retention_cleanup.php
|
||||
setup_hash.php
|
||||
api/
|
||||
sensor_data.php
|
||||
var/
|
||||
.gitkeep
|
||||
```
|
||||
|
||||
운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다.
|
||||
|
||||
```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',
|
||||
|
||||
'SMS_USER_ID' => 'your-cafe24-sms-user-id',
|
||||
'SMS_SECURE' => 'your-cafe24-sms-secure-key',
|
||||
'SMS_SENDER' => '01000000000',
|
||||
'SMS_RECIPIENTS' => [
|
||||
'01000000000',
|
||||
],
|
||||
|
||||
'ADMIN_USER' => 'admin',
|
||||
'ADMIN_PASSWORD_HASH' => '$2y$10$replace.with.password_hash.output',
|
||||
'ADMIN_TOTP_SECRET' => 'replace-with-base32-secret',
|
||||
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
|
||||
|
||||
// 온습도 임계 override (기본값은 config.php 의 METRIC_*)
|
||||
'METRIC_TEMP_HIGH_C' => 30,
|
||||
'METRIC_TEMP_LOW_C' => 10,
|
||||
'METRIC_RH_HIGH' => 70,
|
||||
'METRIC_RH_LOW' => 20,
|
||||
|
||||
'SMS_LOG_RETENTION_DAYS' => 365,
|
||||
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
||||
'SENSOR_METRIC_RETENTION_DAYS' => 365,
|
||||
'ADMIN_AUDIT_RETENTION_DAYS' => 365,
|
||||
];
|
||||
```
|
||||
|
||||
`API_KEY`는 STM32 펌웨어 `firmware/common/secrets.h`의 `APP_API_KEY`와 반드시 바이트 단위로 같아야 합니다(raw-body 서명 `X-Signature = sha256(API_KEY + 요청본문)`).
|
||||
|
||||
`ADMIN_TOTP_SECRET`은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 등록한 Base32 비밀키입니다. 이 값이 없으면 관리자 로그인을 허용하지 않습니다. 담당자 변경 시 관리자 비밀번호와 TOTP 비밀키를 모두 교체하고 변경 기록을 운영 인수인계 자료에 남깁니다.
|
||||
|
||||
최초 등록은 `MFA_SETUP_TOKEN`을 임시로 설정한 뒤 다음 주소에서 진행합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
|
||||
```
|
||||
|
||||
`setup_mfa.php`에서 생성된 수동 입력 키를 Google Authenticator에 등록하고 6자리 코드를 검증한 뒤, 화면에 표시되는 `ADMIN_TOTP_SECRET` 줄을 `config.local.php`에 반영합니다. 등록 완료 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제합니다.
|
||||
|
||||
## 4. 서버 DB 설치
|
||||
|
||||
신규 설치라면 Cafe24 phpMyAdmin 또는 MySQL 콘솔에서 다음 파일을 실행합니다.
|
||||
|
||||
```sql
|
||||
SOURCE /path/to/sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다.
|
||||
|
||||
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
|
||||
|
||||
| SQL 파일 | 적용 상황 |
|
||||
|---|---|
|
||||
| [schema_sht30.sql](../sql/schema_sht30.sql) | 신규 설치(통합 스키마) |
|
||||
| [migration_drop_leak.sql](../sql/migration_drop_leak.sql) | 기존 누수 설치 → 온습도 전용 전환(레거시 컬럼/테이블 정리, `sensor_metric` 보장) |
|
||||
|
||||
이미 적용된 마이그레이션을 중복 실행하면 DB 오류가 날 수 있으므로, 기존 설치에서는 `setup_wizard.php`의 점검 결과를 먼저 확인합니다.
|
||||
|
||||
## 5. 서버 권한 및 관리자 설정
|
||||
|
||||
상태 파일 폴더는 PHP가 쓸 수 있어야 합니다.
|
||||
|
||||
```text
|
||||
php/var/
|
||||
```
|
||||
|
||||
관리자 비밀번호 해시는 서버 CLI에서 생성합니다.
|
||||
|
||||
```bash
|
||||
php setup_hash.php "새관리자비밀번호"
|
||||
```
|
||||
|
||||
출력된 해시를 `config.local.php`의 `ADMIN_PASSWORD_HASH`에 넣습니다. 관리자 인증 앱 비밀키도 `config.local.php`의 `ADMIN_TOTP_SECRET`에 설정합니다.
|
||||
|
||||
서버 설정 후 브라우저에서 아래 화면을 확인합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
|
||||
```
|
||||
|
||||
점검 화면에서 확인할 항목은 다음과 같습니다.
|
||||
|
||||
- DB 연결
|
||||
- 필수 테이블 존재 여부(`sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`)
|
||||
- API 키 기본값 교체 여부
|
||||
- 관리자 해시 설정 여부
|
||||
- 관리자 TOTP 설정 여부
|
||||
- SMS 수신자 설정 여부
|
||||
- 상태 파일 폴더 쓰기 권한
|
||||
- 테스트 SMS 발송
|
||||
|
||||
## 6. 서버 cron 설정
|
||||
|
||||
장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php
|
||||
```
|
||||
|
||||
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다.
|
||||
|
||||
```cron
|
||||
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
|
||||
```
|
||||
|
||||
호스팅 환경마다 실제 절대 경로가 다르므로 Cafe24 파일 관리자 또는 `phpinfo()`로 경로를 확인합니다. 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과하면 오프라인으로 판정합니다.
|
||||
|
||||
## 7. STM32 펌웨어 빌드 (폐쇄망)
|
||||
|
||||
폐쇄망 빌드 머신에서 펌웨어를 빌드합니다. 의존성 벤더링과 오프라인 빌드/플래시 절차는 [firmware/docs/BUILD_OFFLINE.md](../firmware/docs/BUILD_OFFLINE.md)를 따릅니다.
|
||||
|
||||
```bash
|
||||
cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 바이트 단위로 동일하게 채운다.
|
||||
```
|
||||
|
||||
빌드 전 확인할 설정값:
|
||||
|
||||
| 위치 | 설정 |
|
||||
|---|---|
|
||||
| `firmware/common/secrets.h` | `APP_API_KEY` = 서버 `API_KEY` |
|
||||
| `firmware/common/app_config.h` | `APP_API_HOST`, `APP_API_PATH`(`/raspi_leck_detecter/api/sensor_data.php`), 네트워크(DHCP/static) |
|
||||
| `firmware/common/app_config.h` | `APP_SHT30_REPORT_INTERVAL_SEC`(기본 300초), `APP_SHT30_I2C_ADDR`(0x44) |
|
||||
| `firmware/certs/server_ca.c` | 자리표시자 CA → 실제 Cafe24 루트 CA |
|
||||
|
||||
`sht30_fw` 타깃을 빌드해 `sht30_fw.bin`을 생성하고 보드에 플래시합니다.
|
||||
|
||||
## 8. STM32 펌웨어 동작 확인
|
||||
|
||||
USART3(PD8 TX / PD9 RX, 115200 8N1)에 외부 3.3V USB-UART 어댑터를 연결해 콘솔 로그를 확인합니다.
|
||||
|
||||
첫 부팅 후 확인할 항목:
|
||||
|
||||
- I2C1 초기화 및 SHT30 주소 0x44 응답
|
||||
- 첫 측정값(온도/습도)
|
||||
- 네트워크(DHCP/static) → SNTP 시간 동기 → TLS 핸드셰이크
|
||||
- 서버 200 응답 및 `startup` 보고
|
||||
|
||||
상태 LED(PD12) 패턴: 부팅=점등 / 정상 보고=느린 토글 / 망 단절·TLS 실패=빠른 점멸.
|
||||
|
||||
## 9. 설치 후 최종 확인
|
||||
|
||||
서버에서 확인합니다.
|
||||
|
||||
```text
|
||||
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`의 필수 점검 항목이 통과
|
||||
- `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시
|
||||
- `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능
|
||||
- STM32 콘솔 로그에 서버 200 응답 기록 확인
|
||||
- `sensor_metric`에 주기 측정값(periodic) 저장
|
||||
- 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신
|
||||
- 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능
|
||||
|
||||
## 10. 문제 해결
|
||||
|
||||
| 증상 | 확인 지점 |
|
||||
|---|---|
|
||||
| API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 |
|
||||
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 |
|
||||
| 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
|
||||
| SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) |
|
||||
| TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) |
|
||||
| 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 |
|
||||
|
||||
## 11. 운영자가 보관해야 할 값
|
||||
|
||||
다음 값은 코드 저장소에 넣지 말고 운영자만 별도로 보관합니다.
|
||||
|
||||
- Cafe24 DB 이름, 계정, 비밀번호
|
||||
- `API_KEY` / 펌웨어 `APP_API_KEY`
|
||||
- Cafe24 SMS user id
|
||||
- Cafe24 SMS secure key
|
||||
- SMS 발신번호
|
||||
- SMS 수신자 번호
|
||||
- 관리자 계정과 원문 비밀번호, `ADMIN_TOTP_SECRET`
|
||||
- 서버 업로드 경로
|
||||
- 장비 ID(`stm32-sht30-01`)와 설치 위치
|
||||
128
docs/OPERATIONS_SECURITY_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 운영·보안 점검표
|
||||
|
||||
작성일: 2026-06-10
|
||||
대상: 서버실 온습도(SHT30) 모니터링 시스템 v2606 운영 담당자
|
||||
|
||||
이 문서는 보안대책서 첨부자료와 월간 운영 점검에 함께 사용할 체크리스트입니다. 원문 API 키, SMS 키, DB 비밀번호, 관리자 비밀번호는 기록하지 않습니다.
|
||||
|
||||
## 1. 점검 주기
|
||||
|
||||
| 주기 | 점검 목적 | 보관 증적 |
|
||||
|---|---|---|
|
||||
| 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 |
|
||||
| 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `sensor_metric` 최근 기록 |
|
||||
| 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 |
|
||||
| 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 |
|
||||
| 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 |
|
||||
|
||||
## 2. 월간 운영 점검
|
||||
|
||||
| 영역 | 점검 항목 | 정상 기준 | 증적 |
|
||||
|---|---|---|---|
|
||||
| 서비스 상태 | `dashboard.php` 접속 | 최신 온습도와 센서 상태 표시 | 대시보드 캡처 |
|
||||
| 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 |
|
||||
| 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 |
|
||||
| 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV |
|
||||
| 장비 상태 | `sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 |
|
||||
| 측정 수집 | `sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 |
|
||||
| SMS | 최근 실패 | 반복 실패 없음 | `sms_log` 또는 대시보드 |
|
||||
| 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) |
|
||||
| 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 |
|
||||
| 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 |
|
||||
| 감사로그 | `php/var/admin_audit.log` | 로그인/로그아웃/MFA 등록 기록 존재 | 감사로그 파일 |
|
||||
|
||||
## 3. 서버 점검 명령
|
||||
|
||||
브라우저에서 다음 화면을 확인합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/setup_wizard.php
|
||||
https://your-domain.example/raspi_leck_detecter/security_evidence.php
|
||||
https://your-domain.example/raspi_leck_detecter/security_evidence.php?format=md
|
||||
https://your-domain.example/raspi_leck_detecter/monthly_report.php
|
||||
```
|
||||
|
||||
응답 헤더는 운영 서버에서 확인합니다.
|
||||
|
||||
```bash
|
||||
curl -I https://your-domain.example/raspi_leck_detecter/login.php
|
||||
```
|
||||
|
||||
## 4. STM32 펌웨어 점검
|
||||
|
||||
USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
|
||||
|
||||
- I2C1 초기화 및 SHT30 주소 0x44 응답
|
||||
- 최근 측정값(온도/습도)과 주기 보고
|
||||
- 네트워크/SNTP/TLS 핸드셰이크 및 서버 200 응답
|
||||
- 상태 LED(PD12) 패턴: 정상 보고=느린 토글 / 망 단절·TLS 실패=빠른 점멸
|
||||
|
||||
장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다.
|
||||
|
||||
## 5. 온습도 임계 경보 점검
|
||||
|
||||
| 항목 | 확인 내용 | 기록 |
|
||||
|---|---|---|
|
||||
| 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 |
|
||||
| SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 |
|
||||
| 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `sms_log` 간격 |
|
||||
| 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 |
|
||||
| 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 |
|
||||
|
||||
## 6. 조치 기준
|
||||
|
||||
| 상태 | 조치 |
|
||||
|---|---|
|
||||
| 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) |
|
||||
| SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 |
|
||||
| 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 |
|
||||
| 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블 확인 |
|
||||
| HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 |
|
||||
| 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 |
|
||||
|
||||
## 7. 관리자 MFA
|
||||
|
||||
`config.local.php`에 `ADMIN_TOTP_SECRET`을 설정합니다. 이 값은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 등록한 Base32 비밀키와 같아야 합니다.
|
||||
|
||||
```php
|
||||
'ADMIN_TOTP_SECRET' => 'replace-with-base32-secret',
|
||||
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
|
||||
```
|
||||
|
||||
운영자는 관리자 비밀번호와 인증 앱 6자리 코드를 모두 입력해야 로그인할 수 있습니다. 담당자 변경 시 비밀번호를 재설정하고 TOTP 비밀키도 새로 발급합니다.
|
||||
|
||||
초기 등록은 다음 주소에서 진행합니다. `MFA_SETUP_TOKEN`은 최초 등록 또는 교체 작업 때만 임시로 설정하고, 등록 완료 후 빈 값으로 바꾸거나 삭제합니다.
|
||||
|
||||
```text
|
||||
https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
|
||||
```
|
||||
|
||||
화면의 수동 입력 키를 Google Authenticator에 등록한 뒤 6자리 코드를 확인하고, 검증된 `ADMIN_TOTP_SECRET` 줄을 `config.local.php`에 반영합니다. 보안상 외부 QR 생성 API를 쓰지 않으며 비밀키를 외부 서버로 전송하지 않습니다.
|
||||
|
||||
로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다.
|
||||
|
||||
## 8. 보관기간 정리
|
||||
|
||||
먼저 dry-run으로 삭제 대상을 확인합니다.
|
||||
|
||||
```bash
|
||||
php retention_cleanup.php --dry-run
|
||||
```
|
||||
|
||||
결과를 검토한 뒤 운영 백업이 완료된 상태에서만 실제 정리를 수행합니다.
|
||||
|
||||
```bash
|
||||
php retention_cleanup.php --execute
|
||||
```
|
||||
|
||||
기본 보관기간은 SMS 로그 365일, 센서 로그 365일, 온습도 측정값 365일, 관리자 감사로그 365일입니다. 기관 기준이 다르면 `config.local.php`의 `SMS_LOG_RETENTION_DAYS`, `SENSOR_LOG_RETENTION_DAYS`, `SENSOR_METRIC_RETENTION_DAYS`, `ADMIN_AUDIT_RETENTION_DAYS`로 조정합니다.
|
||||
|
||||
## 9. 백업·복구 증적 생성
|
||||
|
||||
백업 파일 목록과 복구 테스트 결과는 다음 명령으로 Markdown 증적을 생성합니다.
|
||||
|
||||
```bash
|
||||
php scripts/backup_evidence.php --backup-dir /path/to/backups --restore-test "YYYY-MM restore OK"
|
||||
```
|
||||
|
||||
복구 테스트 결과에는 DB 복구, 관리자 로그인, SMS 테스트, 월간 보고서 조회, 온습도 측정값 표시 결과를 포함합니다.
|
||||
207
docs/README.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# 서버실 온습도(SHT30) 모니터링 시스템 v2606
|
||||
|
||||
Sensirion SHT30 온습도 센서, STM32F407VGT6(FreeRTOS) 펌웨어, Cafe24 PHP/MySQL, Cafe24 SMS를 이용한 서버실 온습도 24시간 모니터링 및 임계 초과 SMS 알림 시스템입니다. 측정값은 5분 주기로 서버에 전송되어 웹 대시보드에 표시되고, 고온/저온/고습/저습 임계를 초과하면 SMS로 통보합니다. 펌웨어는 유선 Ethernet(LAN8720 RMII) + 온칩 TLS(mbedTLS HTTPS)로 서버에 보고합니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
[SHT30] --I2C--> [STM32F407VGT6 (sht30_fw)] --유선 Ethernet/TLS--> [Cafe24 PHP API]
|
||||
|
|
||||
[MySQL DB]
|
||||
|
|
||||
[Cafe24 SMS / Dashboard]
|
||||
```
|
||||
|
||||
- 단일 보드 `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 + 감사로그로 보호합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
docs/
|
||||
README.md # 설치/운영 허브 문서
|
||||
INSTALL_PI_SERVER.md # STM32 펌웨어 및 Cafe24 서버 설치 설명서
|
||||
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
|
||||
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
|
||||
wiring_diagram.md # SHT30 I2C 배선도
|
||||
SHT30_SENSOR2_SETUP.md # SHT30 설치 가이드(STM32 기본 / RPi 대안)
|
||||
solutions/ # 실행-검토 사이클에서 얻은 교훈/반복 방지 기록
|
||||
firmware/ # STM32F407VGT6 베어메탈 펌웨어 (sht30_fw)
|
||||
common/ # 이식 코어(sha256/sig/jsonbody/sht30_convert/httpapi/net 등)
|
||||
board_sht30/ # SHT30 보드 진입점/측정 태스크
|
||||
common/secrets.h.example # APP_API_KEY 템플릿 (실 secrets.h 는 운영자가 생성, 미커밋)
|
||||
certs/ # 서버 CA (운영 CA 로 교체)
|
||||
docs/ # HARDWARE.md, BUILD_OFFLINE.md, PORTING_NOTES.md
|
||||
php/
|
||||
config.php # 공통 설정 로더(METRIC_* 임계, HEARTBEAT_TIMEOUT_SEC 등)
|
||||
config.local.example.php # 서버 운영 설정 예시
|
||||
ops_checks.php # 운영 점검 공통 헬퍼
|
||||
sms_send.php # Cafe24 SMS 발송
|
||||
cron_heartbeat.php # 장비 오프라인 감지
|
||||
dashboard.php # 웹 대시보드(현재 온습도/임계 상태)
|
||||
setup_wizard.php # 설치/운영 점검 화면
|
||||
security_evidence.php # 보안대책서 첨부 증적 보고서
|
||||
monthly_report.php # 월간 운영 보고서
|
||||
login.php # 대시보드 로그인
|
||||
setup_mfa.php # 관리자 TOTP MFA 등록
|
||||
admin_security.php # 관리자 보안/감사 헬퍼
|
||||
retention_cleanup.php # 로그 보관기간 정리
|
||||
blocked.php # 차단 안내 화면
|
||||
api/sensor_data.php # STM32 센서 데이터 수신
|
||||
sql/
|
||||
schema_sht30.sql # 신규 설치용 통합 스키마
|
||||
migration_drop_leak.sql # 기존 누수 설치 → 온습도 전용 전환 마이그레이션
|
||||
_backup/
|
||||
pre_v2605_20260519/ # 정리 전 원본 백업
|
||||
```
|
||||
|
||||
## 신규 설치 순서
|
||||
|
||||
1. 배선을 확인합니다.
|
||||
|
||||
[wiring_diagram.md](./wiring_diagram.md)의 SHT30 I2C 배선(PB6 SCL / PB7 SDA, 주소 0x44, 3.3V/GND, 4.7kΩ 풀업)을 적용합니다.
|
||||
|
||||
2. Cafe24 MySQL에 통합 스키마를 실행합니다.
|
||||
|
||||
```sql
|
||||
SOURCE /path/to/sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다.
|
||||
|
||||
3. PHP 파일을 업로드합니다.
|
||||
|
||||
```text
|
||||
public_html/raspi_leck_detecter/
|
||||
api/
|
||||
sensor_data.php
|
||||
var/.gitkeep
|
||||
.htaccess
|
||||
blocked.php
|
||||
config.php
|
||||
config.local.php
|
||||
ops_checks.php
|
||||
sms_send.php
|
||||
cron_heartbeat.php
|
||||
dashboard.php
|
||||
setup_wizard.php
|
||||
security_evidence.php
|
||||
monthly_report.php
|
||||
login.php
|
||||
setup_mfa.php
|
||||
admin_security.php
|
||||
retention_cleanup.php
|
||||
```
|
||||
|
||||
4. 서버 운영 설정을 만듭니다.
|
||||
|
||||
`php/config.local.example.php`를 `php/config.local.php`로 복사한 뒤 실제 DB, API, SMS, 관리자 해시, 관리자 TOTP 값을 입력합니다. 온습도 임계(METRIC_*)를 기관 기준에 맞게 바꾸려면 같은 파일에서 override 합니다(폐쇄망에서 펌웨어 재플래시 불필요).
|
||||
|
||||
관리자 비밀번호 해시는 서버 CLI에서 생성합니다.
|
||||
|
||||
```bash
|
||||
php setup_hash.php "새비밀번호"
|
||||
```
|
||||
|
||||
최초 관리자 MFA 등록은 `MFA_SETUP_TOKEN`을 임시로 설정한 뒤 `setup_mfa.php?token=임시토큰`에서 Google Authenticator 호환 TOTP 키를 생성·검증합니다. 등록 후 화면에 표시되는 `ADMIN_TOTP_SECRET`을 `config.local.php`에 반영하고, `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제합니다.
|
||||
|
||||
5. STM32 펌웨어를 빌드·플래시합니다.
|
||||
|
||||
폐쇄망 빌드 머신에서 의존성을 벤더링한 뒤 `sht30_fw`를 빌드합니다(상세는 [firmware/docs/BUILD_OFFLINE.md](../firmware/docs/BUILD_OFFLINE.md)).
|
||||
|
||||
```bash
|
||||
cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 동일하게 채운다.
|
||||
```
|
||||
|
||||
`firmware/common/app_config.h`의 `APP_API_HOST`/`APP_API_PATH`와 네트워크 주소(DHCP/static)를 운영 값으로 설정하고, `firmware/certs/server_ca.c`의 자리표시자 CA를 실제 Cafe24 루트 CA로 교체합니다.
|
||||
|
||||
6. 서버 cron을 등록합니다.
|
||||
|
||||
장비 오프라인 감지를 위해 `cron_heartbeat.php`를 1분 간격으로 호출합니다.
|
||||
|
||||
```cron
|
||||
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
|
||||
```
|
||||
|
||||
## 기존 누수 설치 → 온습도 전용 전환
|
||||
|
||||
1. 현재 운영 파일과 DB를 백업합니다.
|
||||
2. `php/config.local.example.php` 기준으로 `config.local.php`를 갱신합니다(METRIC_* 임계 확인).
|
||||
3. DB에 전환 마이그레이션을 적용합니다.
|
||||
|
||||
```sql
|
||||
SOURCE /path/to/sql/migration_drop_leak.sql;
|
||||
```
|
||||
|
||||
이 마이그레이션은 레거시 누수 컬럼/테이블(`leak_photo`, `leak_incident`, `is_leak` 등)을 정리하고 `sensor_metric`을 보장합니다.
|
||||
4. 신규 설치라면 위 마이그레이션 대신 `sql/schema_sht30.sql`만 사용합니다.
|
||||
|
||||
## 이벤트 정책
|
||||
|
||||
| 이벤트 | 조건 | DB | SMS |
|
||||
|---|---|---|---|
|
||||
| `startup` | 기기 시작 첫 측정 | `sensor_log` | X |
|
||||
| `periodic` | 정상 주기 측정(5분) | `sensor_metric` | X |
|
||||
| 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `sensor_metric.metric_status` | O (종류별 30분 쿨다운) |
|
||||
| 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `sensor_metric` | O (1회) |
|
||||
| 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `sensor_status` | O |
|
||||
| 장비 복구 | 오프라인 후 재수신 | `sensor_status` | O |
|
||||
|
||||
## 운영 기준
|
||||
|
||||
- API 키, DB 비밀번호, SMS 인증값, 관리자 해시는 코드에 직접 넣지 않습니다.
|
||||
- PHP 서버는 `config.local.php` 또는 환경 변수에서 운영 값을 읽습니다.
|
||||
- STM32 펌웨어는 `firmware/common/secrets.h`의 `APP_API_KEY`로 raw-body 서명을 만들며, 이 값은 서버 `API_KEY`와 같아야 합니다.
|
||||
- 온습도 임계는 서버 판정값입니다. 기본값: 고온 30℃ / 저온 10℃ / 고습 70% / 저습 20%, 복귀 히스테리시스 ±1℃·±3%, 동일 종류 쿨다운 1800초(30분). `php/config.php`의 `METRIC_*`로 정의하고 `config.local.php`에서 override 합니다.
|
||||
- 임계 경보 SMS는 종류(고온/저온/고습/저습)별로 30분 쿨다운이 적용되어 중복 발송을 막습니다. 정상복귀 SMS는 1회만 발송합니다.
|
||||
- 관리자 로그인은 비밀번호 해시 검증 후 TOTP 인증 앱의 6자리 코드를 추가 검증합니다. `ADMIN_TOTP_SECRET`이 없으면 관리자 로그인을 허용하지 않습니다.
|
||||
- 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다.
|
||||
- heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다.
|
||||
- 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다.
|
||||
- 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다.
|
||||
- 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다.
|
||||
- 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다.
|
||||
- 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다.
|
||||
|
||||
## 운영 화면
|
||||
|
||||
| 화면 | 경로 | 용도 |
|
||||
|---|---|---|
|
||||
| 대시보드 | `dashboard.php` | 현재 온습도, 임계 상태, 센서 상태, 30일 요약 |
|
||||
| 설치 점검 | `setup_wizard.php` | DB/설정/권한/SMS 테스트 |
|
||||
| 보안 증적 | `security_evidence.php` | 보안통제 매트릭스와 운영 점검 결과 |
|
||||
| 월간 보고서 | `monthly_report.php` | 월별 운영 요약, CSV 다운로드, 인쇄/PDF |
|
||||
| MFA 등록 | `setup_mfa.php` | 관리자 TOTP 인증 앱 등록 및 검증 |
|
||||
|
||||
## 점검 명령
|
||||
|
||||
서버 로그/응답 헤더를 확인합니다.
|
||||
|
||||
```bash
|
||||
curl -I https://your-domain.example/raspi_leck_detecter/login.php
|
||||
```
|
||||
|
||||
STM32 펌웨어는 USART3(PD8/PD9, 115200) 콘솔 로그로 네트워크/SNTP/TLS 핸드셰이크/서버 200 응답을 확인합니다.
|
||||
|
||||
서버 보관기간 정리 대상과 백업 증빙을 생성합니다.
|
||||
|
||||
```bash
|
||||
php retention_cleanup.php --dry-run
|
||||
php scripts/backup_evidence.php --backup-dir /path/to/backups --restore-test "YYYY-MM restore OK"
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 확인 지점 |
|
||||
|---|---|
|
||||
| API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 |
|
||||
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `sensor_metric` 등 테이블 생성 여부 |
|
||||
| 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
|
||||
| SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `sms_log` |
|
||||
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 |
|
||||
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) |
|
||||
| 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) |
|
||||
| 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 |
|
||||
68
docs/SECURITY_DOCUMENT_CONSISTENCY_AUDIT.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 보안 구현-문서 일치성 점검표
|
||||
|
||||
작성일: 2026-05-28 (SHT30 전환 반영: 본 클러스터 정리)
|
||||
목적: SHT30 온습도 모니터링 전환 구현이 기존 보안대책서 보완 문서, 설치 문서, 증빙 패키지 문서에 일관되게 반영되었는지 확인한다.
|
||||
|
||||
## 1. 구현 기준
|
||||
|
||||
| 구현 통제 | 코드/파일 근거 | 문서 반영 상태 |
|
||||
|---|---|---|
|
||||
| 관리자 비밀번호 해시 | `login.php`, `setup_hash.php`, `config.php` | 반영 |
|
||||
| 관리자 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`) | 반영 |
|
||||
| 측정값 범위 검증 | `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` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
|
||||
| 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `sensor_status.offline_alerted`, `sms_log` | 반영 |
|
||||
| 측정 이력 저장 | `sensor_metric` 테이블 | 반영 |
|
||||
| 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 |
|
||||
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
|
||||
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |
|
||||
|
||||
## 2. 수정한 기존 문서
|
||||
|
||||
| 문서 | 반영 내용 |
|
||||
|---|---|
|
||||
| `docs/SECURITY_EVIDENCE_PACKAGE.md` | MFA 증빙, 측정/임계 경보 증빙, 보관기간 dry-run, 백업·복구 증빙, PNG 재생성은 운영자 몫 명시 (사진 접근통제 증빙 제거) |
|
||||
| `docs/SOURCE_SECURITY_EVIDENCE.md` | raw-body 서명, 측정값 범위 검증, 서버 임계 판정·쿨다운, 오프라인 감지, `secrets.h` 비밀값 분리로 치환 (사진 업로드/프록시 절 제거) |
|
||||
| `docs/SECURITY_PLAN_PASS_READINESS.md` | 보안통제 매트릭스(측정값/임계 경보 행 추가, 사진 행 제거), 관리자 접근통제 문구, 월간 점검표, 개인정보+오발송 방지 문구, 자체 점검표, 증적 목록, 잔여위험 표 갱신 |
|
||||
| `docs/SECURITY_PLAN_HWP_REVIEW.md` | 최신 리비전(Y수정)(20260528) 기준 before→after, SHT30 구성요소, 임계 경보·오프라인 대응 절차, 사진정보 절 삭제·개인정보만 유지, 반영 순서 갱신 |
|
||||
| `docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md` | 제공자료 가이드를 온습도·임계 SMS·STM32 기준으로 치환 (사진 보관기간·카메라·photo 항목 제거) |
|
||||
| `scripts/generate_security_plan_images.py` | 누수 사고 절차도 함수 제거, 데이터 흐름·목표 구성도·문서맵을 SHT30 측정→임계→SMS 흐름으로 교체 |
|
||||
|
||||
## 3. HWP에 넣을 때 특히 맞춰야 하는 문구
|
||||
|
||||
| 주제 | HWP 표현 기준 |
|
||||
|---|---|
|
||||
| 사업 범위 | “화재/누수/사진”이 아니라 “서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림”으로 표현 |
|
||||
| 관리자 인증 | “비밀번호 해시 + TOTP MFA + 세션 쿠키 보호 + 로그인 실패 제한 + 감사로그”로 표현 |
|
||||
| MFA 등록 | “`MFA_SETUP_TOKEN`은 최초 등록/교체 때만 임시 사용하고 등록 후 제거”로 표현 |
|
||||
| Google 연동 | “Google API 연동”이 아니라 “Google Authenticator 호환 TOTP”로 표현 |
|
||||
| 임계 경보 | “서버 임계 판정(`METRIC_*`: 고온30/저온10·고습70/저습20) + 30분 쿨다운 + 복구 히스테리시스”로 오발송 방지 표현 |
|
||||
| 개인정보 | 처리 개인정보는 “SMS 수신자 전화번호”만. 현장 사진/시설정보는 처리하지 않음으로 명시 |
|
||||
| 보관기간 | SMS/센서 로그/측정 이력/관리자 감사로그 365일을 기본값으로 쓰고 기관 기준 우선 적용 |
|
||||
| 백업 증빙 | 백업 수행 자체와 별도로 `backup_evidence.php` 결과를 복구 가능성 증빙으로 첨부 |
|
||||
|
||||
## 4. 아직 운영자가 채워야 하는 실제 환경 증적
|
||||
|
||||
아래 항목은 코드나 문서만으로 확정할 수 없으므로 운영 환경 기준으로 별도 확보해야 한다.
|
||||
|
||||
- 실제 도메인 HTTPS 접속 화면과 인증서 정보
|
||||
- Cafe24 배포 경로와 SSL 리다이렉트 적용 여부
|
||||
- 단말(STM32) 네트워크 분리 또는 방화벽 정책 자료
|
||||
- STM32 보드, SHT30 센서 설치 사진과 자산 정보
|
||||
- 실제 SMS 수신자 현행화 확인표
|
||||
- 실제 DB/설정파일 백업 주기와 담당자
|
||||
- 실제 복구 테스트 결과
|
||||
- 운영 서버의 `retention_cleanup.php --dry-run` 결과
|
||||
- 운영 서버의 `scripts/backup_evidence.php` 결과
|
||||
- 보안대책서 구성도/흐름도 PNG 재생성(`python scripts/generate_security_plan_images.py`)
|
||||
|
||||
## 5. 제출 전 금지 사항
|
||||
|
||||
- API 키, SMS secure key, DB 비밀번호, 관리자 원문 비밀번호, `ADMIN_TOTP_SECRET` 원문을 HWP 또는 첨부자료에 넣지 않는다.
|
||||
- MFA 화면 캡처에는 운영 비밀키를 노출하지 않는다. 증빙용 임시 키를 사용하거나 운영 키를 마스킹한다.
|
||||
- 사업 범위를 화재/누수/현장 사진으로 과대 기재하지 않는다. 현재 구현은 온습도 모니터링 + 임계 SMS + 오프라인 알림이다.
|
||||
- “Google API로 연동”이라고 쓰지 않는다. 실제 구현은 외부 Google API 없이 서버 내부에서 TOTP를 검증하는 방식이다.
|
||||
87
docs/SECURITY_EVIDENCE_PACKAGE.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# 보안대책서 첨부자료 패키지
|
||||
|
||||
작성일: 2026-05-20
|
||||
목적: HWP 보안대책서에 붙일 증적 자료를 빠짐없이 모으기 위한 패키지 구성안
|
||||
|
||||
## 1. 제출 패키지 구성
|
||||
|
||||
아래 순서로 첨부하면 심사자가 “통제 항목 → 실제 적용 증적 → 운영 절차”를 따라 확인하기 쉽습니다.
|
||||
|
||||
| 순서 | 자료 | 생성/준비 방법 |
|
||||
|---:|---|---|
|
||||
| 1 | 보안통제 매트릭스 | `security_evidence.php?format=md` 다운로드 후 HWP 표로 반영 |
|
||||
| 2 | 서버 운영 점검 결과 | `setup_wizard.php`, `security_evidence.php` 화면 캡처 |
|
||||
| 3 | 월간 운영 보고서 | `monthly_report.php` PDF 또는 CSV |
|
||||
| 4 | 단말 운영 증적 | STM32 USART3(PD8/PD9, 115200) 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS/서버 200) 캡처 |
|
||||
| 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 |
|
||||
| 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID |
|
||||
| 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 |
|
||||
| 8 | 측정/임계 경보 증적 | 측정값 저장(`sensor_metric`), 종류별 경보·정상복귀 SMS(`sms_log`) 화면 |
|
||||
| 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 |
|
||||
| 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 |
|
||||
|
||||
> 보안대책서용 구성도/흐름도 PNG는 `scripts/generate_security_plan_images.py`로 생성하며(온습도 흐름으로 갱신됨), **PNG 재생성은 운영자가 `python scripts/generate_security_plan_images.py`로 수행**합니다.
|
||||
|
||||
## 2. 프로젝트에서 자동 또는 반자동으로 만들 수 있는 자료
|
||||
|
||||
| 자료 | 파일/화면 | 비고 |
|
||||
|---|---|---|
|
||||
| 보안 증적 보고서 | `php/security_evidence.php` | 운영 점검 결과와 보안통제 매트릭스 |
|
||||
| Markdown 증적 | `php/security_evidence.php?format=md` | HWP 붙여넣기용 |
|
||||
| 단말 증적 | STM32 USART3 부팅 콘솔 로그 캡처 | 비밀값 원문 없이 링크업/DHCP/SNTP/TLS/서버 200 상태만 출력 |
|
||||
| 월간 보고서 | `php/monthly_report.php` | 생성 시각과 보고 범위 포함 |
|
||||
| 설치 점검 | `php/setup_wizard.php` | DB, 설정, 권한, SMS 테스트 |
|
||||
| MFA 등록 증빙 | `docs/evidence/security_plan_mfa_evidence.html` | 보안대책서 삽입 문구, MFA 화면 캡처, Mermaid 흐름도 |
|
||||
| 기술 설명 | `docs/SOURCE_SECURITY_EVIDENCE.md` | API, HTTPS, 측정값 검증, 세션, 비밀값 분리 설명 |
|
||||
| 운영 점검표 | `docs/OPERATIONS_SECURITY_CHECKLIST.md` | 월간 점검 양식 |
|
||||
| 보관기간 정리 결과 | `php/retention_cleanup.php --dry-run` | SMS 로그, 센서 로그, 온습도 측정 이력, 관리자 감사로그 정리 대상 확인 |
|
||||
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 백업 파일 목록, 설정/감사로그 존재 여부, 복구 테스트 결과 |
|
||||
|
||||
## 3. 운영자가 반드시 채워야 하는 자료
|
||||
|
||||
코드로 대신 만들 수 없는 실제 운영 증적입니다.
|
||||
|
||||
| 항목 | 필요한 내용 |
|
||||
|---|---|
|
||||
| 실제 도메인 | HTTPS 접속 화면, 인증서 정보, Cafe24 SSL 설정 |
|
||||
| 설치 위치 | STM32 보드와 SHT30 센서 설치 사진, 장비 ID, 설치 장소 |
|
||||
| 네트워크 | 단말이 연결된 망, 방화벽 정책, 외부 인바운드 차단 여부 |
|
||||
| 운영자 | 점검 담당, SMS 수신자, 장애 대응 담당 역할 |
|
||||
| 보관기간 | 센서 로그, SMS 로그, 온습도 측정 이력, 감사로그 보관기간 |
|
||||
| 백업 | DB/설정 파일 백업 주기와 최근 백업 파일 목록 |
|
||||
| 관리자 MFA | 실제 운영 담당자 인증 앱 등록 확인, 담당자 변경 시 재등록 이력 |
|
||||
| 감사로그 | 로그인 성공/실패, MFA 등록 검증 로그 파일 존재 여부 |
|
||||
|
||||
## 4. 권장 폴더명
|
||||
|
||||
보안대책서와 함께 제출할 파일을 아래처럼 묶습니다.
|
||||
|
||||
```text
|
||||
security-evidence-YYYYMMDD/
|
||||
01_security_control_matrix.md
|
||||
02_setup_wizard.png
|
||||
03_security_evidence.png
|
||||
04_monthly_report.pdf
|
||||
05_device-security-evidence.md
|
||||
06_https_certificate.png
|
||||
07_network_firewall_policy.pdf
|
||||
08_installation_photos/
|
||||
09_mfa_evidence.html
|
||||
10_metric_alert_sms.png
|
||||
11_retention_cleanup_dry_run.json
|
||||
12_backup_restore_evidence.md
|
||||
13_operations_security_checklist.md
|
||||
```
|
||||
|
||||
## 5. 제출 전 점검
|
||||
|
||||
- [ ] API 키, SMS secure key, DB 비밀번호, 관리자 원문 비밀번호, `ADMIN_TOTP_SECRET` 원문이 첨부자료에 노출되지 않는다.
|
||||
- [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다.
|
||||
- [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다.
|
||||
- [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다.
|
||||
- [ ] 측정/경보 증빙은 측정값 저장(`sensor_metric`)과 종류별 경보·정상복귀 SMS(`sms_log`)를 함께 보여준다.
|
||||
- [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다.
|
||||
- [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다.
|
||||
- [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다.
|
||||
- [ ] 보관기간 정리는 `--dry-run` 결과를 먼저 보관하고, 실제 삭제는 백업 완료 후 수행한다.
|
||||
- [ ] 백업 파일 목록과 담당자 역할이 최신이다.
|
||||
303
docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# 보안대책서 첨부·제공자료 가이드
|
||||
|
||||
작성일: 2026-05-20
|
||||
대상: HWP 보안대책서의 `4. HWP에 반드시 추가해야 할 보안통제 매트릭스`
|
||||
|
||||
## 1. 먼저 정리
|
||||
|
||||
`보안통제 매트릭스`는 사용자가 따로 첨부해야 하는 파일이 아니라, **HWP 본문에 들어갈 표**입니다.
|
||||
|
||||
사용자가 준비해야 하는 것은 표의 마지막 열인 **확인 증적**입니다.
|
||||
즉, 심사자가 “그 보안대책이 실제로 적용됐나요?”라고 물었을 때 보여줄 자료입니다.
|
||||
|
||||
소스코드, API 인증, 측정값 검증, 관리자 로그인, 비밀값 분리처럼 코드에서 설명 가능한 항목은 [SOURCE_SECURITY_EVIDENCE.md](SOURCE_SECURITY_EVIDENCE.md)에 별도로 정리했습니다.
|
||||
|
||||
프로젝트에는 보안대책서 첨부용 증적을 바로 만들기 위한 산출물도 포함되어 있습니다.
|
||||
|
||||
| 산출물 | 용도 |
|
||||
|---|---|
|
||||
| `php/security_evidence.php` | 운영 점검 결과와 보안통제 매트릭스를 화면으로 확인 |
|
||||
| `php/security_evidence.php?format=md` | HWP에 붙여넣을 Markdown 보안 증적 보고서 다운로드 |
|
||||
| STM32 USART3 부팅 콘솔 로그 캡처 | 단말 링크업/DHCP/SNTP/TLS 핸드셰이크/서버 200 OK 증적 확인 |
|
||||
| `docs/SECURITY_EVIDENCE_PACKAGE.md` | 제출 첨부자료 패키지 구성 순서 |
|
||||
| `docs/OPERATIONS_SECURITY_CHECKLIST.md` | 월간 운영·보안 점검표 |
|
||||
| `docs/SOURCE_SECURITY_EVIDENCE.md` | 소스코드 기반 기술통제 설명 |
|
||||
| `docs/evidence/security_plan_mfa_evidence.html` | 관리자 MFA, 감사로그, 보관기간·백업 증빙 문구와 화면 캡처 |
|
||||
| `php/retention_cleanup.php` | SMS 로그, 센서 로그, 온습도 측정 이력, 관리자 감사로그 보관기간 정리 |
|
||||
| `scripts/backup_evidence.php` | 백업 파일 목록과 복구 테스트 결과 Markdown 증빙 생성 |
|
||||
|
||||
## 2. 결론: 사용자가 지금 제공해야 할 것
|
||||
|
||||
최소로는 아래 8가지만 있으면 HWP 보완이 가능합니다.
|
||||
|
||||
| 우선순위 | 제공자료 | 왜 필요한가 |
|
||||
|---:|---|---|
|
||||
| 1 | 실제 구축 범위 확정 | 화재/누수/전원 이상을 넣을지, 온습도/오프라인만 넣을지 결정해야 문서 범위가 맞음 |
|
||||
| 2 | 서버 접속 URL | HTTPS 적용, API URL, 대시보드 URL을 문서에 정확히 적기 위함 |
|
||||
| 3 | Cafe24 서버 설치 경로 | PHP 파일 위치를 증적으로 쓰기 위함 |
|
||||
| 4 | STM32 단말 설치 위치와 장비 정보 | 장비 식별, 설치 위치, 자산대장 항목 작성 |
|
||||
| 5 | 기관망 연결 방식 | 단말이 업무망에 붙는지, 별도 인터넷인지, 방화벽 정책이 있는지 확인 |
|
||||
| 6 | 운영 담당자 역할 | 누가 점검하고, 누가 SMS를 받고, 누가 경보를 조치하는지 명확화 |
|
||||
| 7 | 보관기간 기준 | SMS 로그, 센서 로그, 측정 이력을 며칠/몇 년 보관할지 결정 |
|
||||
| 8 | 캡처 가능한 운영 화면 | 설치 점검, 대시보드, 월간 보고서, SMS 테스트 결과 증적 |
|
||||
|
||||
반대로 아래 항목은 사용자가 일일이 설명하지 않아도 됩니다. 프로젝트 소스코드에서 근거를 뽑아 문서화할 수 있습니다.
|
||||
|
||||
| 항목 | 처리 방식 |
|
||||
|---|---|
|
||||
| API 서명 검증 | 코드 근거와 HWP 문구 작성 가능 |
|
||||
| JSON API 필수값 검증 | 코드 근거와 HWP 문구 작성 가능 |
|
||||
| 측정값 범위 검증 | 코드 근거와 HWP 문구 작성 가능 |
|
||||
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*` 근거와 HWP 문구 작성 가능 |
|
||||
| 관리자 TOTP MFA | 코드 근거, 등록 화면 캡처, HWP 문구 작성 가능 |
|
||||
| 관리자 감사로그 | 코드 근거와 운영 점검 증빙 작성 가능 |
|
||||
| 보관기간 정리 | `retention_cleanup.php --dry-run` 결과로 증빙 가능 |
|
||||
| 백업·복구 증빙 | `scripts/backup_evidence.php` 결과로 증빙 가능 |
|
||||
| 관리자 로그인/세션 보호 | 코드 근거와 HWP 문구 작성 가능 |
|
||||
| 비밀값 분리 구조 | 코드 근거와 HWP 문구 작성 가능 |
|
||||
| 보안 헤더 | `.htaccess` 근거 제시 가능 |
|
||||
| HTTPS URL 구조 | 코드/설정 예시는 제시 가능. 단 실제 적용 증적은 운영 화면 필요 |
|
||||
|
||||
## 3. 자료별로 정확히 무엇을 주면 되는가
|
||||
|
||||
### 3.1 실제 구축 범위
|
||||
|
||||
아래 중 하나로 답하면 됩니다.
|
||||
|
||||
| 선택 | 의미 | 문서 반영 |
|
||||
|---|---|---|
|
||||
| A | 이번 구축은 온습도 모니터링만 한다 | 화재/누수/전원 이상은 향후 확장으로 분리 |
|
||||
| B | 온습도 모니터링 + 장비 오프라인 감지만 한다 | 현재 프로젝트와 가장 잘 맞음 |
|
||||
| C | 화재/누수/전원 이상도 이번 범위에 포함한다 | 추가 센서, 코드, 증적이 더 필요 |
|
||||
|
||||
권장 답변:
|
||||
|
||||
```text
|
||||
이번 1차 구축 범위는 서버실 온습도 모니터링, 임계 SMS 경보, 정상복귀 알림, 장비 오프라인 감지, 대시보드/월간보고까지로 한정한다.
|
||||
화재·누수 및 전원 이상 감지는 향후 확장 범위로 분리한다.
|
||||
```
|
||||
|
||||
### 3.2 서버 정보
|
||||
|
||||
비밀번호나 API 키 원문은 주면 안 됩니다. 아래처럼 마스킹해서 주면 됩니다.
|
||||
|
||||
```text
|
||||
서비스 도메인: https://example.com
|
||||
설치 경로: public_html/sht30_monitor/
|
||||
API URL: https://example.com/sht30_monitor/api/sensor_data.php
|
||||
대시보드 URL: https://example.com/sht30_monitor/dashboard.php
|
||||
HTTPS 적용 여부: 적용 / 미적용 / 확인 필요
|
||||
DB 종류: Cafe24 MySQL
|
||||
```
|
||||
|
||||
첨부하면 좋은 증적:
|
||||
|
||||
- Cafe24 파일 관리자에서 `sht30_monitor` 폴더가 보이는 화면
|
||||
- `setup_wizard.php` 점검 화면
|
||||
- `dashboard.php` 로그인 화면 또는 대시보드 화면
|
||||
- `monthly_report.php` 화면
|
||||
|
||||
### 3.3 단말 정보
|
||||
|
||||
```text
|
||||
장비명: STM32 보드 (SHT30 온습도 센서 연계)
|
||||
장비 ID: stm32-sht30-01
|
||||
설치 위치: 서버실 / 서버랙 주변 / 기타
|
||||
센서 모델: SHT30 온습도 센서
|
||||
측정 주기: 5분 (펌웨어 기본)
|
||||
네트워크 방식: 유선 LAN / Wi-Fi / 기타
|
||||
단말 접속 방식: 현장 직접 접속 / SSH / 기타
|
||||
```
|
||||
|
||||
첨부하면 좋은 증적:
|
||||
|
||||
- 단말과 센서 설치 사진
|
||||
- STM32 USART3(PD8/PD9, 115200) 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS 핸드셰이크) 캡처
|
||||
- 동일 콘솔에서 서버 200 OK 응답이 보이는 정상 보고 로그 캡처
|
||||
|
||||
### 3.4 네트워크 연결 방식
|
||||
|
||||
이 부분이 심사에서 중요합니다. 아래 질문에 답이 필요합니다.
|
||||
|
||||
```text
|
||||
1. 단말이 기관 업무망에 연결되는가?
|
||||
2. 단말이 별도 인터넷망 또는 별도 Wi-Fi를 사용하는가?
|
||||
3. 기관 방화벽에서 Cafe24 서버로 나가는 443 포트만 허용하는가?
|
||||
4. 외부에서 단말로 접속할 수 있는 포트가 열려 있는가?
|
||||
5. 단말 원격관리는 어떻게 하는가?
|
||||
```
|
||||
|
||||
권장 구조:
|
||||
|
||||
```text
|
||||
단말은 기관 업무망으로 들어오는 인바운드 접속을 허용하지 않고,
|
||||
승인된 Cafe24 서버로 HTTPS 아웃바운드 통신만 수행한다.
|
||||
```
|
||||
|
||||
첨부하면 좋은 증적:
|
||||
|
||||
- 네트워크 구성도
|
||||
- 방화벽 허용 정책
|
||||
- 단말에서 `ss -lntup` 실행 결과
|
||||
- 기관망 미연결 또는 분리 운영 확인 문구
|
||||
|
||||
### 3.5 운영 담당자 정보
|
||||
|
||||
개인 이름을 꼭 문서에 넣을 필요는 없습니다. 역할 중심으로 적으면 됩니다.
|
||||
|
||||
```text
|
||||
사업총괄: 혁신전략실장
|
||||
사업관리: 정보기반팀
|
||||
사업수행: 우편혁신AI연구팀
|
||||
보안검토/점검: 정보보안센터
|
||||
SMS 수신자: 전산실 운영 담당자 2명
|
||||
월간 점검 담당: 정보기반팀 담당자
|
||||
```
|
||||
|
||||
첨부하면 좋은 증적:
|
||||
|
||||
- 운영 담당자 지정표
|
||||
- SMS 수신자 현행화 확인표
|
||||
- 월간 점검표 양식
|
||||
|
||||
### 3.6 보관기간 기준
|
||||
|
||||
기관 내부 기준이 없으면 아래 권장안을 쓰면 됩니다.
|
||||
|
||||
| 데이터 | 권장 보관기간 |
|
||||
|---|---:|
|
||||
| SMS 발송 로그 | 1년 |
|
||||
| 센서 이벤트 로그 | 1년 |
|
||||
| 온습도 측정 이력 | 1년 |
|
||||
| 관리자 감사로그 | 1년 |
|
||||
| 백업 파일 | 1년 또는 기관 기준 |
|
||||
|
||||
사용자가 결정해서 제공할 내용:
|
||||
|
||||
```text
|
||||
SMS 로그 보관기간: 1년
|
||||
센서 로그 보관기간: 1년
|
||||
온습도 측정 이력 보관기간: 1년
|
||||
관리자 감사로그 보관기간: 1년
|
||||
기관 내부 규정 우선 적용 여부: 예 / 아니오
|
||||
```
|
||||
|
||||
### 3.7 백업·복구 정보
|
||||
|
||||
```text
|
||||
DB 백업 주기: 매일 / 매주 / 매월
|
||||
설정파일 백업 위치: 운영자 보관 / 암호화 저장소 / 기타
|
||||
복구 테스트 주기: 월 1회 / 분기 1회 / 필요 시
|
||||
```
|
||||
|
||||
첨부하면 좋은 증적:
|
||||
|
||||
- DB 백업 파일 목록
|
||||
- 복구 테스트 결과표
|
||||
- 백업 담당자 지정표
|
||||
|
||||
## 4. 보안통제 매트릭스 행별로 필요한 증적
|
||||
|
||||
| 매트릭스 항목 | 사용자가 줘야 하는 것 | 프로젝트에서 이미 준비 가능한 것 |
|
||||
|---|---|---|
|
||||
| 사업 범위 | 이번 구축 범위 확정 문장 | HWP 교체 문구 |
|
||||
| 네트워크 | 단말 네트워크 연결 방식, 방화벽 정책 | 네트워크 구성도 이미지 |
|
||||
| 전송구간 | 실제 서비스 URL, HTTPS 적용 여부 | API URL 구조, 설치 문서 |
|
||||
| API 인증 | API 키 원문이 아니라 “서버/단말 키 일치 확인” 결과 | 코드의 서명 검증 설명 |
|
||||
| 비밀값 | 설정파일 권한 캡처 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`) 구조 설명 |
|
||||
| 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 |
|
||||
| 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 |
|
||||
| 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 |
|
||||
| 로그관리 | 로그 보관기간, 관리자 감사로그 | `sensor_log`, `sensor_metric`, `sms_log`, `php/var/admin_audit.log` 설명 |
|
||||
| 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 |
|
||||
| 패치 | 월간 패치 점검 담당자 | 점검표 양식 |
|
||||
| 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 |
|
||||
| 장애대응 | 임계 경보/오프라인 대응 담당자 | 대시보드, 임계 경보, 월간보고 설명 |
|
||||
|
||||
## 5. 지금 당장 준비할 첨부자료 체크리스트
|
||||
|
||||
### 필수
|
||||
|
||||
- [ ] 실제 구축 범위 확정 문장
|
||||
- [ ] 서버 도메인과 설치 경로
|
||||
- [ ] 단말 설치 위치와 장비 ID
|
||||
- [ ] 네트워크 연결 방식 설명
|
||||
- [ ] SMS 수신자 관리 기준
|
||||
- [ ] 로그/측정 이력 보관기간 기준
|
||||
- [ ] 관리자 MFA 등록 확인표
|
||||
- [ ] 임계 설정(`METRIC_*`) 확인 화면
|
||||
- [ ] `setup_wizard.php` 점검 화면 캡처
|
||||
- [ ] 대시보드 화면 캡처
|
||||
- [ ] 단말 서비스 상태 화면 캡처
|
||||
|
||||
### 있으면 통과 가능성을 높이는 자료
|
||||
|
||||
- [ ] 방화벽 정책 또는 네트워크 분리 확인 자료
|
||||
- [ ] 설치 사진
|
||||
- [ ] 자산관리대장
|
||||
- [ ] 테스트 SMS 수신 화면
|
||||
- [ ] 측정값 저장/임계 경보 테스트 결과
|
||||
- [ ] DB 테이블 생성 화면
|
||||
- [ ] 월간 보고서 화면
|
||||
- [ ] 백업 파일 목록
|
||||
- [ ] 보관기간 정리 dry-run 결과
|
||||
- [ ] 백업·복구 증빙 Markdown
|
||||
- [ ] 복구 테스트 결과
|
||||
- [ ] 월간 보안점검표
|
||||
|
||||
## 6. 사용자에게 요청할 답변 양식
|
||||
|
||||
아래 양식만 채워주면 보안대책서에 넣을 표와 첨부자료 목록을 확정할 수 있습니다.
|
||||
|
||||
```text
|
||||
1. 이번 구축 범위:
|
||||
예) 서버실 온습도 모니터링 + 임계 SMS 경보 + 장비 오프라인 감지 + 대시보드 + 월간보고
|
||||
|
||||
2. 서버 도메인:
|
||||
예) https://example.com
|
||||
|
||||
3. 서버 설치 경로:
|
||||
예) public_html/sht30_monitor/
|
||||
|
||||
4. 단말 설치 위치:
|
||||
예) 서버실 서버랙 하단
|
||||
|
||||
5. 단말 네트워크 방식:
|
||||
예) 별도 Wi-Fi / 기관망 / 유선 LAN / 기타
|
||||
|
||||
6. 외부에서 단말로 접속 가능 여부:
|
||||
예) 불가 / SSH만 가능 / 확인 필요
|
||||
|
||||
7. SMS 수신자 수:
|
||||
예) 운영 담당자 2명
|
||||
|
||||
8. 온습도 임계값:
|
||||
예) 고온30/저온10℃, 고습70/저습20%
|
||||
|
||||
9. 로그 보관기간:
|
||||
예) 센서 1년, SMS 1년, 측정 이력 1년
|
||||
|
||||
10. 백업 주기:
|
||||
예) DB 주 1회
|
||||
```
|
||||
|
||||
## 7. API 키와 비밀번호 제공 금지
|
||||
|
||||
다음 값은 문서 작성자에게 원문으로 제공하지 않는 것이 원칙입니다.
|
||||
|
||||
- DB 비밀번호
|
||||
- API 키 원문
|
||||
- SMS secure key
|
||||
- 관리자 원문 비밀번호
|
||||
- `ADMIN_TOTP_SECRET` 원문
|
||||
- 서버 FTP/SFTP 비밀번호
|
||||
- 단말 로그인 비밀번호
|
||||
|
||||
문서에는 다음처럼 마스킹해서 적습니다.
|
||||
|
||||
```text
|
||||
API_KEY: 운영 비밀값으로 서버와 단말에 동일 설정, 원문 미기재
|
||||
SMS_SECURE: Cafe24 SMS 인증키, 원문 미기재
|
||||
DB_PASS: config.local.php에만 저장, 원문 미기재
|
||||
ADMIN_TOTP_SECRET: 운영 비밀값으로 서버에만 저장, 증빙에는 원문 미기재 또는 증빙용 임시 키 사용
|
||||
```
|
||||
241
docs/SECURITY_PLAN_HWP_REVIEW.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# 보안대책서 HWP 수정·추가 검토서
|
||||
|
||||
검토 대상: `(Y수정)_(20260528)_IoT_기반_모니터링_체계_구축을_위한_보안대책서.hwp` (최신 리비전)
|
||||
검토 기준: 현재 프로젝트 구현 상태(서버실 SHT30 온습도 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림), 서버/STM32 단말 설치 구조, 대시보드·임계 경보·월간보고 기능
|
||||
|
||||
> ⚠️ HWP 바이너리는 직접 편집할 수 없습니다. 이 문서는 운영자가 HWP 본문에 반영하는 것을 전제로 한 **before → after 갱신 가이드**입니다. 인용한 라인 번호는 `docs/assets/security_plan/hwp_extracted_text.txt`(현재 HWP 추출본, 편집 금지) 기준이며 참고용입니다.
|
||||
|
||||
심사 대응 수준의 상세 보완안은 [SECURITY_PLAN_PASS_READINESS.md](SECURITY_PLAN_PASS_READINESS.md)를 함께 적용합니다.
|
||||
|
||||
## 1. 요약 판단
|
||||
|
||||
현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다.
|
||||
|
||||
- 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다.
|
||||
- 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`sensor_metric`), SMS 이력(`sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다.
|
||||
- API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다.
|
||||
- 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다.
|
||||
- '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다.
|
||||
- 설치 후 검증 증적, 보안 점검 결과, 테스트 SMS 결과, DB 마이그레이션 적용 여부, MFA 등록 증빙, 보관기간 정리 dry-run, 백업 증빙을 첨부할 수 있는 증적 목록이 필요합니다. 운영 서버 반영 후 `security_evidence.php?format=md`, STM32 USART3 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS/서버 200) 캡처, `scripts/backup_evidence.php` 결과를 함께 첨부하면 코드 기반 통제와 실제 장비 상태를 연결할 수 있습니다.
|
||||
|
||||
## 2. HWP에서 바로 수정해야 할 부분 (before → after)
|
||||
|
||||
아래 라인 번호는 `hwp_extracted_text.txt` 기준 참고값입니다.
|
||||
|
||||
| 위치(추출본 라인) | before (현재 HWP 문구) | after (수정 방향) | 우선순위 |
|
||||
|---|---|---|---|
|
||||
| 사업목적 (L9) | "전산실 내 **화재, 누수, 장비 전원 이상** 상태를 실시간으로 감지" | "전산실(서버실) 내 **온습도 이상 및 장비 통신 이상** 상태를 실시간으로 감지" (5.1 교체안 적용) | 높음 |
|
||||
| 구축범위 (L11, L15~L20) | "화재 및 누수, 장비 오프라인 등 이상상태", "**감지 센서(화재/누수)**", "**카메라 연계 / 현장 사진 캡처**" | `SHT30 온습도 센서`, `STM32 보드`, `Cafe24 PHP API`, `MySQL`, `임계 SMS`, `대시보드`, `월간 보고서`로 재작성. 카메라·현장 사진 행 삭제 | 높음 |
|
||||
| 구축방식 (L29, L31~L37) | "화재·누수 감지센서가 이상상태를 감지하면 IoT 보드로 전달", "**카메라(현장 사진 캡처)**" | "SHT30 온습도 센서 측정 → STM32 보드 수집·보고 → 서버 임계 판정 → SMS/대시보드"로 교체 (5.2 교체안 적용). 카메라/현장 사진 박스 삭제 | 높음 |
|
||||
| 모니터링 시스템 구성 (L30~L38) | 감지센서(화재/누수) → IoT 보드 → 웹호스팅 서버 → SMS, 카메라(현장 사진) | `security_plan_target_architecture.png` / `security_plan_data_flow.png`로 교체. SHT30→STM32→서버 임계 판정→SMS/대시보드 흐름 | 높음 |
|
||||
| 네트워크 구성도 설명 (L45) | "IoT 보드에서 웹호스팅 서버로 감지 정보 및 **현장 사진**을 전송" | "STM32 단말로 들어오는 인바운드 포트는 열지 않고, 단말이 서버로 **온습도 측정값**만 아웃바운드 HTTPS POST" — '현장 사진' 문구 삭제 | 높음 |
|
||||
| 사진정보 및 개인정보 보호대책 (L102~L105) | "**현장 사진**은 화재·누수 등 이벤트 확인 목적으로만 수집", "**사진정보**는 암호화 저장/접근권한 제한" | **사진 관련 2개 행(L103, L104) 삭제.** SMS 수신자 전화번호 보호 문구(L105)만 유지하고, 그 자리에 '온습도 임계 경보 + 오발송 방지(30분 쿨다운/복구 히스테리시스)' 통제 추가 (5.3 교체안 적용) | 높음 |
|
||||
| 권한 및 계정관리 (L106~L110) | 관리자 계정 최소 부여, 비밀번호 정책 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, `MFA_SETUP_TOKEN`, 세션 쿠키 옵션, 로그인 실패 잠금, 감사로그, 비밀번호/TOTP 교체 절차 추가 | 높음 |
|
||||
| 단말기 보안대책 (L135~L143) | 기본 계정 변경, 하드코딩 금지 | 비밀값은 `firmware/common/secrets.h`(`APP_API_KEY`)에 두고 미커밋(.gitignore)으로 빌드 머신에서만 보관, API 키 서버 `config.php`의 `API_KEY`와 일치, 불필요 기능 비활성화 추가. (카메라 권한 관리 문구 불필요) | 높음 |
|
||||
| 플랫폼/서비스 보안대책 (L150~L156) | 인증, 패치, 기본 서비스 제거 | API 서명 검증, 측정값 범위 검증(온도 -40~125℃ / 습도 0~100%), DB prepared statement, TOTP MFA, `config.local.php` 비공개 관리 추가. (사진 업로드/MIME/매직바이트 검증 문구 삭제) | 높음 |
|
||||
| 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `sensor_log`, `sensor_metric`, `sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 |
|
||||
| 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 |
|
||||
|
||||
## 3. HWP에 추가해야 할 장·절
|
||||
|
||||
### 3.1 실제 시스템 구성요소 표
|
||||
|
||||
다음 표를 `사업내용` 또는 `시스템 구성도` 앞에 추가합니다.
|
||||
|
||||
| 구성요소 | 실제 파일/기능 | 보안 통제 |
|
||||
|---|---|---|
|
||||
| STM32 보드 | 펌웨어 측정·보고, `config.py`(연계 설정) | 환경파일 기반 비밀값 관리, 로컬 로그, 불필요 서비스 차단 |
|
||||
| SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 |
|
||||
| PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 |
|
||||
| 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 |
|
||||
| MySQL DB | `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 |
|
||||
| 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 |
|
||||
| MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 |
|
||||
| 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 |
|
||||
| 보안 증적 | `security_evidence.php` | 보안통제 매트릭스, 운영 점검 결과, Markdown 다운로드 |
|
||||
| 월간 보고 | `monthly_report.php` | 월별 온습도 경보·SMS·오프라인 현황 출력 |
|
||||
| SMS 발송 | `sms_send.php` | 발신 계정 보호, 수신자 최소화, 발송 로그 관리 |
|
||||
| 오프라인 감지 | `cron_heartbeat.php` | heartbeat 타임아웃 기준 오프라인/복구 SMS |
|
||||
| 보관기간 정리 | `retention_cleanup.php` | SMS 로그, 센서 로그, 측정 이력, 관리자 감사로그 정리 대상 확인 및 실행 |
|
||||
| 백업 증빙 | `scripts/backup_evidence.php` | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 출력 |
|
||||
|
||||
### 3.2 데이터 흐름 및 보호조치
|
||||
|
||||
다음 흐름을 본문에 추가합니다.
|
||||
|
||||
1. SHT30 센서가 서버실 온도·습도를 5분 주기로 측정한다.
|
||||
2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다.
|
||||
3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만).
|
||||
4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다.
|
||||
5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `sensor_metric`에 저장하고, 정상 보고는 `sensor_status`를 갱신한다.
|
||||
6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
|
||||
7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다.
|
||||
8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다.
|
||||
|
||||
삽입 이미지:
|
||||

|
||||
|
||||
> 참고: 위 PNG는 `scripts/generate_security_plan_images.py`로 생성합니다. 스크립트는 온습도 흐름으로 갱신되었으나 **PNG 재생성은 운영자 몫**입니다(`python scripts/generate_security_plan_images.py`).
|
||||
|
||||
### 3.3 임계 경보·오프라인 대응 절차
|
||||
|
||||
현재 HWP에는 보안대책은 있으나 온습도 임계 발생 후의 운영 절차가 부족합니다. 다음 절차를 추가합니다(기존 '누수 사고 대응 절차'는 삭제).
|
||||
|
||||
| 단계 | 처리 내용 | 증적 |
|
||||
|---|---|---|
|
||||
| 측정 | SHT30 측정값 수신, `sensor_metric` 기록, 임계 판정 | `sensor_metric.metric_status` |
|
||||
| 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) |
|
||||
| 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 |
|
||||
| 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 |
|
||||
| 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `sms_log` 복구 메시지 |
|
||||
| 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `sms_log` |
|
||||
| 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` |
|
||||
|
||||
### 3.4 설치·운영 점검 절차
|
||||
|
||||
`setup_wizard.php`와 `security_evidence.php`를 운영 점검 도구로 명시합니다.
|
||||
|
||||
| 점검 항목 | 확인 방법 | 불합격 시 조치 |
|
||||
|---|---|---|
|
||||
| DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 |
|
||||
| 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 또는 `sql/migration_drop_leak.sql` 적용 |
|
||||
| 측정 이력 테이블 | 설치 점검 화면 | `sensor_metric` 생성 확인 |
|
||||
| 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 |
|
||||
| API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 |
|
||||
| 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 |
|
||||
| 관리자 MFA | MFA 등록 화면 | `setup_mfa.php`에서 인증 앱 등록 후 `ADMIN_TOTP_SECRET` 반영 |
|
||||
| 관리자 감사로그 | 보안 증적 화면 또는 파일 확인 | `php/var/admin_audit.log` 생성 및 로그인 기록 확인 |
|
||||
| SMS 수신자 | 설치 점검 화면 | 운영 담당자 번호 현행화 |
|
||||
| 테스트 SMS | 설치 점검 화면 | Cafe24 SMS 계정·잔액·발신번호 확인 |
|
||||
| 보안통제 매트릭스 | 보안 증적 화면 | `security_evidence.php?format=md` 결과를 HWP 부록에 첨부 |
|
||||
| 보관기간 정리 | CLI 실행 결과 | `php retention_cleanup.php --dry-run` 결과를 HWP 부록에 첨부 |
|
||||
| 백업·복구 증빙 | CLI 실행 결과 | `php scripts/backup_evidence.php --backup-dir ...` 결과를 HWP 부록에 첨부 |
|
||||
| 단말 운영 증적 | STM32 USART3 부팅 콘솔 로그 | 링크업/DHCP/SNTP/TLS/서버 200 캡처를 HWP 부록에 첨부 |
|
||||
|
||||
### 3.5 보관기간 및 파기 기준
|
||||
|
||||
HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화합니다.
|
||||
|
||||
| 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 |
|
||||
|---|---|---:|---|
|
||||
| 센서 이벤트 로그 | `sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) |
|
||||
| 온습도 측정 이력 | `sensor_metric` | 365일 | 기간 초과분 삭제 |
|
||||
| SMS 발송 로그 | `sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 |
|
||||
| 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 |
|
||||
| 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 |
|
||||
|
||||
기관 내부 규정이 별도로 있으면 내부 규정을 우선 적용합니다.
|
||||
|
||||
### 3.6 백업·복구 기준
|
||||
|
||||
다음 항목을 `플랫폼/서비스 보안대책` 또는 별도 `백업 및 복구` 절에 추가합니다.
|
||||
|
||||
- MySQL DB는 정기 백업을 수행하고 월 1회 이상 복구 가능 여부를 점검한다.
|
||||
- 서버 설정 파일 `config.local.php`는 별도 암호화 저장소에 보관한다.
|
||||
- 단말 비밀값 헤더 `firmware/common/secrets.h`(`APP_API_KEY`)는 저장소에 커밋하지 않고(.gitignore), 빌드 머신의 운영자만 접근 가능한 위치에 보관·백업한다.
|
||||
- 복구 훈련 시 API 연결, 측정값 수신, 임계 SMS 발송, 대시보드 로그인, 월간 보고서 조회를 함께 검증한다.
|
||||
|
||||
## 4. HWP에 삽입할 이미지 파일
|
||||
|
||||
### 4.1 HWP 원본에서 추출한 이미지
|
||||
|
||||
다음 이미지는 HWP 내부 `BinData`에서 추출했습니다. BMP 스트림은 HWP 압축을 해제해 정상 이미지 파일로 저장했습니다.
|
||||
|
||||
| 파일 | 용도 판단 |
|
||||
|---|---|
|
||||
| [bin0001.png](assets/security_plan/hwp_embedded/bin0001.png) | 기존 모니터링 시스템 구성도 |
|
||||
| [bin0002.png](assets/security_plan/hwp_embedded/bin0002.png) | 표지/타이틀 이미지 일부 |
|
||||
| [bin0003.png](assets/security_plan/hwp_embedded/bin0003.png) | 표지/타이틀 이미지 일부 |
|
||||
| [bin0004.png](assets/security_plan/hwp_embedded/bin0004.png) | 기관 슬로건 이미지 |
|
||||
| [bin0005.png](assets/security_plan/hwp_embedded/bin0005.png) | 기관 슬로건 이미지 |
|
||||
| [bin0006.png](assets/security_plan/hwp_embedded/bin0006.png) | 한국우편사업진흥원 로고 |
|
||||
|
||||
> 기존 구성도 이미지(bin0001)는 과거 범위(화재/누수/카메라) 기준이므로, 아래 4.2의 온습도 구성도/흐름도로 교체하는 것을 권장합니다.
|
||||
|
||||
### 4.2 새로 생성한 보완 이미지
|
||||
|
||||
아래 이미지는 수정 HWP에 추가 삽입할 목적으로 생성합니다. 스크립트는 온습도 흐름으로 갱신되었으며 **PNG 재생성은 운영자가 수행**합니다.
|
||||
|
||||
| 파일 | HWP 삽입 위치 |
|
||||
|---|---|
|
||||
| [security_plan_target_architecture.png](assets/security_plan/generated/security_plan_target_architecture.png) | `시스템 구성도` 절 |
|
||||
| [security_plan_data_flow.png](assets/security_plan/generated/security_plan_data_flow.png) | `데이터 흐름 및 보호조치` 신규 절 |
|
||||
| [security_plan_document_update_map.png](assets/security_plan/generated/security_plan_document_update_map.png) | 내부 검토용 또는 부록 |
|
||||
|
||||
## 5. 원본 HWP 문구 교체 예시
|
||||
|
||||
### 5.1 사업목적 교체안 (추출본 L9)
|
||||
|
||||
기존 문구:
|
||||
|
||||
> 전산실 내 화재, 누수, 장비 전원 이상 상태를 실시간으로 감지할 수 있는 IoT 기반 모니터링 체계를 구축
|
||||
|
||||
교체 문구:
|
||||
|
||||
> 전산실(서버실) 내 온도·습도 이상 및 장비 통신 이상 상태를 실시간으로 감지하는 IoT 기반 모니터링 체계를 구축하고, 임계 초과 시 SMS 알림과 웹 대시보드를 통해 신속한 확인 및 환경 정상화 체계를 마련한다. 별도 센서를 이용한 화재·누수 감지는 향후 확장 범위로 별도 보안성 검토 및 통제대책 수립 후 반영한다.
|
||||
|
||||
### 5.2 구축방식 교체안 (추출본 L29)
|
||||
|
||||
기존 문구:
|
||||
|
||||
> 전산실 내 설치된 화재·누수 감지센서가 이상상태를 감지하면, 해당 신호를 IoT 보드로 전달하고 IoT 보드가 감지정보를 수집·처리하여 웹호스팅 서버로 전송
|
||||
|
||||
교체 문구:
|
||||
|
||||
> 전산실 내 설치된 SHT30 온습도 센서가 온도·습도를 주기적으로 측정하면 STM32 보드가 측정값을 수집하여 정상 보고를 구성한다. 단말은 기관 내부 업무망으로 인바운드 접속을 허용하지 않고, 승인된 Cafe24 웹호스팅 서버의 PHP API로 HTTPS/TLS 기반 아웃바운드 POST만 수행한다. 서버는 API 키 기반 서명과 측정값 범위를 검증한 후 운영 임계(고온/저온/고습/저습)로 재판정하여 MySQL에 저장하고, 임계 초과 시 담당자에게 SMS를 발송하며 대시보드와 월간 보고서를 제공한다.
|
||||
|
||||
### 5.3 사진정보 보호대책 절 교체안 (추출본 L102~L105)
|
||||
|
||||
기존 절(현장 사진 관련 L103·L104 행)은 **삭제**하고, 절 제목을 `개인정보 및 경보 오발송 방지 대책`으로 바꾼 뒤 아래로 대체합니다.
|
||||
|
||||
기존 문구:
|
||||
|
||||
> 현장 사진은 화재·누수 등 이벤트 발생 확인 목적으로만 수집 / 사진정보는 웹호스팅 서버에 암호화 저장하거나 접근권한을 제한하여 관리, 보관기간은 최소한으로 설정
|
||||
|
||||
교체 문구:
|
||||
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
|
||||
|
||||
## 6. 추가 증적 목록
|
||||
|
||||
HWP 최종본 또는 부록에 다음 증적을 첨부하는 것을 권장합니다.
|
||||
|
||||
- 서버 설치 경로 캡처
|
||||
- `setup_wizard.php` 점검 통과 화면
|
||||
- `security_evidence.php?format=md` 보안 증적 보고서
|
||||
- `docs/evidence/security_plan_mfa_evidence.html` 관리자 MFA 및 운영 보안 보완 증빙
|
||||
- 대시보드 정상/임계 경보/오프라인 상태 화면
|
||||
- 테스트 SMS 수신 화면
|
||||
- 월간 보고서 화면
|
||||
- 관리자 감사로그 파일 존재 및 마스킹된 이벤트 일부
|
||||
- DB 테이블 생성 또는 마이그레이션 적용 결과
|
||||
- `php retention_cleanup.php --dry-run` 보관기간 정리 대상 확인 결과
|
||||
- `php scripts/backup_evidence.php --backup-dir ...` 백업·복구 증빙 보고서
|
||||
- 단말 STM32 USART3(PD8/PD9, 115200) 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS 핸드셰이크) 캡처
|
||||
- 단말 콘솔의 서버 200 OK 정상 POST 응답 로그 캡처
|
||||
- `docs/SECURITY_EVIDENCE_PACKAGE.md` 기준 제출 패키지 목록
|
||||
- `docs/OPERATIONS_SECURITY_CHECKLIST.md` 월간 점검표
|
||||
- 운영 담당자 및 SMS 수신자 현행화 확인표
|
||||
|
||||
## 7. 반영 순서
|
||||
|
||||
1. 원본 HWP의 사업범위를 현재 구현 범위(서버실 온습도 + 임계 SMS)에 맞게 수정한다.
|
||||
2. 기존 구성도(화재/누수/카메라 기준)는 온습도 구성도(`security_plan_target_architecture.png`)와 흐름도(`security_plan_data_flow.png`)로 교체한다.
|
||||
3. 관리적 보안대책에 보관기간, 개인정보 최소수집, 담당자 변경 절차를 추가한다.
|
||||
4. 물리적 보안대책에서 카메라 문구를 삭제하고 STM32 보드·SHT30 센서 고정, 자산대장, 탈거 방지 항목을 구체화한다.
|
||||
5. 기술적 보안대책에 API 서명, 환경파일 비밀값 분리, 측정값 범위 검증, 서버 임계 판정, 관리자 TOTP MFA, 감사로그, 로그/보관기간/백업 기준을 추가한다.
|
||||
6. 사진정보 절을 삭제하고 개인정보 + 경보 오발송 방지 절로 대체한다. 임계 경보·오프라인 대응 절차와 설치 점검 절차를 신규 절로 추가한다.
|
||||
7. 최종본 부록에 `security_evidence.php?format=md`, 단말 증적 보고서, 설치·검증 증적 목록을 첨부한다.
|
||||
8. 법령·지침 명칭과 조문 번호는 제출 직전 최신 원문 기준으로 재확인한다.
|
||||
|
||||
## 8. 남은 확인 필요사항
|
||||
|
||||
| 확인 항목 | 이유 |
|
||||
|---|---|
|
||||
| 온습도 임계값의 기관 운영 기준 | 고온30/저온10℃·고습70/저습20%가 해당 서버실 운영 기준과 맞는지 확인 필요 |
|
||||
| 화재·누수 감지를 향후 확장 범위로 명시할지 | 현재 코드 기준으로는 온습도 모니터링 중심이므로 문서 범위 과대 기재 위험 |
|
||||
| Cafe24 서버에서 HTTPS 강제 적용 여부 | API와 관리자 페이지 보호 근거 |
|
||||
| 기관 경계 방화벽에서 허용할 목적지·포트 | 네트워크 구성도와 보안대책의 핵심 통제 |
|
||||
| `security_evidence.php` 실제 점검 결과의 실패 항목 | 실패 항목이 있으면 조치 결과 또는 예외 승인 근거 필요 |
|
||||
| DB 백업 주기와 복구 담당자 | 운영 단계 보안관리 증적 필요 |
|
||||
| 월간 점검 담당자와 점검 주기 | 보안대책 이행 여부 확인 근거 |
|
||||
388
docs/SECURITY_PLAN_PASS_READINESS.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# 보안대책서 통과 준비도 심화 보완안
|
||||
|
||||
검토 대상: `(Y수정)_(20260528)_IoT_기반_모니터링_체계_구축을_위한_보안대책서.hwp` (최신 리비전)
|
||||
작성일: 2026-05-19
|
||||
목적: 현재 HWP 보안대책서가 기관 보안성 검토 또는 국정원 검토 관점에서 설득력을 갖추도록, 수정해야 할 내용과 구체적 해결책을 제시한다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
현재 HWP는 **완벽하지 않습니다.**
|
||||
현재 상태 그대로는 “국정원에 통과 가능한 수준”이라고 보기 어렵습니다.
|
||||
|
||||
이유는 보안대책의 큰 항목은 있으나, 심사자가 확인하려는 다음 질문에 대한 답이 부족하기 때문입니다.
|
||||
|
||||
1. 이 사업이 정말 보안성 검토 생략 또는 자체 보안대책 대상인지 근거가 명확한가?
|
||||
2. 실제 구축 범위와 문서의 사업 범위가 일치하는가?
|
||||
3. 기관 업무망과 IoT 장비·외부 웹호스팅 서버 사이의 경계가 명확한가?
|
||||
4. 데이터가 어디서 생성되고, 어디로 이동하고, 어디에 저장되고, 누가 보는지 명확한가?
|
||||
5. API 키, 관리자 계정, SMS 수신자 번호 같은 민감 요소를 어떻게 보호하는가?
|
||||
6. 보안대책이 실제 시스템에서 구현되었고, 증적으로 확인 가능한가?
|
||||
7. 온습도 임계 경보·미확인·장비 오프라인 상황에서 누가 무엇을 하는가?
|
||||
8. 남은 위험을 기관이 알고 수용하거나 추가 조치하는가?
|
||||
|
||||
현재 문서는 **선언형 보안대책서**에 가깝습니다. 통과 가능성을 높이려면 **구현 기반 보안대책서 + 운영 증적 목록 + 잔여위험 수용표** 형태로 바꿔야 합니다.
|
||||
|
||||
## 2. 객관적 점수
|
||||
|
||||
| 평가 항목 | 현재 수준 | 보완 후 목표 |
|
||||
|---|---:|---:|
|
||||
| 사업 범위 명확성 | 55 | 90 |
|
||||
| 네트워크/시스템 구성 명확성 | 60 | 90 |
|
||||
| 관리적 보안대책 | 70 | 85 |
|
||||
| 물리적 보안대책 | 70 | 85 |
|
||||
| 기술적 보안대책 | 55 | 90 |
|
||||
| 개인정보 보호(SMS 수신자) | 50 | 85 |
|
||||
| 운영 절차/경보 대응 | 50 | 85 |
|
||||
| 증적 준비도 | 35 | 85 |
|
||||
| 전체 통과 준비도 | 55~60 | 80~85 |
|
||||
|
||||
## 3. 통과 리스크가 큰 차단 이슈
|
||||
|
||||
### 3.1 사업 범위 과대 기재
|
||||
|
||||
현재 HWP는 “화재, 누수, 장비 전원 이상”을 감지한다고 표현합니다. 그러나 현재 프로젝트 구현은 서버실 온습도(SHT30) 측정, 임계(고온/저온/고습/저습) SMS 경보, 정상복귀 SMS, 장비 오프라인/복구 감지, 대시보드 중심입니다.
|
||||
|
||||
**위험:**
|
||||
문서가 실제 시스템보다 넓은 보안범위를 주장하면, 심사자는 화재 센서, 누수 센서, 전원 이상 감지, 해당 센서의 보안통제와 검증 증적을 요구할 수 있습니다.
|
||||
|
||||
**해결책:**
|
||||
HWP 본문에서 1차 구축 범위를 다음처럼 명확히 제한합니다.
|
||||
|
||||
> 본 사업의 1차 구축 범위는 전산실(서버실) 내 온습도 모니터링, 임계 초과 시 SMS 경보, 정상복귀 알림, 장비 통신 이상 감지, 웹 대시보드 및 월간 운영 보고 체계 구축으로 한정한다. 화재·누수 감지 및 장비 전원 이상 감지는 향후 센서 추가 연계 시 별도 보안성 검토 또는 자체 보안대책 보완 후 반영한다.
|
||||
|
||||
### 3.2 보안성 검토 생략 사유의 불충분
|
||||
|
||||
HWP는 국가 정보보안 기본지침 제16조를 인용하지만, 왜 이 사업이 생략 대상인지에 대한 연결 논리가 약합니다.
|
||||
|
||||
**위험:**
|
||||
기관 내부망, 외부 웹서버, SMS 개인정보가 포함되면 단순 장비 도입으로 보기 어렵다는 지적을 받을 수 있습니다.
|
||||
|
||||
**해결책:**
|
||||
HWP에는 “검토 생략”을 단정하지 말고 아래 중 하나로 정리해야 합니다.
|
||||
|
||||
| 선택지 | 사용 조건 | 문서 표현 |
|
||||
|---|---|---|
|
||||
| A. 자체 보안대책 수립 | 기관 보안담당자가 자체 검토 가능하다고 판단한 경우 | “본 사업은 기관 보안담당 부서의 판단에 따라 자체 보안대책을 수립·시행하고, 필요 시 상급기관 또는 국가정보원 보안성 검토 대상 여부를 확인한다.” |
|
||||
| B. 보안성 검토 의뢰 | 외부망 연계 성격이 크다고 판단한 경우 | “본 사업은 정보통신망 구성 및 외부 연계가 포함되므로 사업계획 단계에서 보안성 검토 대상 여부를 확인하고, 필요 시 보안성 검토를 의뢰한다.” |
|
||||
| C. 검토 생략 | 기존 검토 완료 사업의 망 구성 변경 없는 후속 운영 등으로 명확한 경우 | “기존 보안성 검토 결과를 준수하며, 정보통신망 구성을 변경하지 않는 범위에서 후속 운영으로 수행한다.” |
|
||||
|
||||
**권장:**
|
||||
현재 구조는 외부 웹호스팅, IoT 단말, SMS를 포함하므로 **“검토 생략” 단정은 위험**합니다. 최소한 “보안성 검토 대상 여부를 기관 보안담당 부서와 사전 확인” 문구를 넣어야 합니다.
|
||||
|
||||
### 3.3 외부 웹호스팅 사용의 보안 근거 부족
|
||||
|
||||
Cafe24 웹호스팅 서버가 기관 내부망 밖에 있으므로 신뢰 경계가 큽니다.
|
||||
|
||||
**위험:**
|
||||
심사자는 “왜 기관 내부 서버가 아닌 외부 호스팅인가”, “기관 업무망과 어떤 방식으로 분리되는가”, “외부 서버 침해 시 기관에 영향이 없는가”를 물을 수 있습니다.
|
||||
|
||||
**해결책:**
|
||||
외부 웹호스팅 사용 사유와 통제 기준을 문서화합니다.
|
||||
|
||||
HWP 추가 문구:
|
||||
|
||||
> 웹호스팅 서버는 기관 내부 업무망과 분리된 인터넷 영역에 위치하며, IoT 단말은 기관 업무망으로의 접속 권한을 갖지 않는다. IoT 단말은 승인된 웹호스팅 서버 API로 HTTPS 기반 아웃바운드 통신만 수행하고, 기관 내부망으로부터의 인바운드 접속이나 원격제어 경로는 구성하지 않는다. 웹호스팅 서버에는 업무자료를 저장하지 않으며, 온습도 측정 이력, 임계 경보·SMS 발송 이력 등 운영 목적상 필요한 최소 정보만 저장한다.
|
||||
|
||||
## 4. HWP에 반드시 추가해야 할 보안통제 매트릭스
|
||||
|
||||
아래 표는 “관리적/물리적/기술적 대책”을 심사자가 확인 가능한 구조로 바꾼 것입니다. HWP에 그대로 표로 넣는 것을 권장합니다.
|
||||
|
||||
이 표를 채우기 위해 사용자가 제공해야 하는 자료는 [SECURITY_PLAN_ATTACHMENT_GUIDE.md](SECURITY_PLAN_ATTACHMENT_GUIDE.md)에 정리했습니다.
|
||||
소스코드에서 확인 가능한 API·인증·업로드 검증 근거는 [SOURCE_SECURITY_EVIDENCE.md](SOURCE_SECURITY_EVIDENCE.md)에 정리했습니다.
|
||||
운영 서버 반영 후에는 `php/security_evidence.php?format=md`에서 아래 매트릭스의 코드 기반 증적을 Markdown으로 내려받고, STM32 단말에서는 USART3(PD8/PD9, 115200) 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS/서버 200)를 캡처해 장비 증적으로 사용합니다. 제출 패키지 순서는 [SECURITY_EVIDENCE_PACKAGE.md](SECURITY_EVIDENCE_PACKAGE.md)를 기준으로 정리합니다.
|
||||
|
||||
| 영역 | 위험 | 보안대책 | 구현 위치 | 확인 증적 |
|
||||
|---|---|---|---|---|
|
||||
| 사업 범위 | 문서 범위와 실제 구현 불일치 | 1차 구축 범위를 온습도·임계 SMS·오프라인·대시보드로 한정 | 보안대책서 사업개요 | 범위 정의 문구 |
|
||||
| 네트워크 | 단말이 기관망 침해 경로가 될 위험 | 단말 인바운드 차단, 서버로 HTTPS 아웃바운드만 허용 | STM32 펌웨어, 기관 방화벽 | 방화벽 정책, STM32 USART3 콘솔 로그(아웃바운드 HTTPS만 수행 확인) |
|
||||
| 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` |
|
||||
| API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 |
|
||||
| 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 |
|
||||
| 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `sms_log`, `sensor_metric` 상태 |
|
||||
| 비밀값 | DB/SMS/API 키 유출 | 서버는 `config.local.php`, STM32 단말은 `firmware/common/secrets.h`(`APP_API_KEY`)로 분리, 저장소 미포함(.gitignore) | 서버/단말 설정 | STM32 콘솔 로그, .gitignore 설정, 저장소 검색 결과 |
|
||||
| 관리자 인증 | 대시보드 무단 접근 | 비밀번호 해시, TOTP MFA, 세션 쿠키 보호, 로그인 실패 제한, 감사로그 | `login.php`, `setup_mfa.php`, `admin_security.php`, `dashboard.php`, `security_evidence.php` | 로그인 화면, MFA 등록 증빙, 감사로그, 보안 증적 보고서 |
|
||||
| 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `sms_log` | 수신자 목록 관리대장 |
|
||||
| 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 |
|
||||
| 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 |
|
||||
| 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 |
|
||||
| 물리보안 | 센서/보드 탈거·조작 | 서버실 통제구역 설치, 함체 고정, 자산대장 등록 | 서버실 | 설치 사진, 자산대장 |
|
||||
| 장애대응 | 경보 미확인/오프라인 방치 | 미확인 경보 재알림, 장비 오프라인 감지 | `cron_heartbeat.php` | SMS 로그, 오프라인 이력 |
|
||||
|
||||
## 5. 기술적 보안대책 상세 보완 문구
|
||||
|
||||
### 5.1 API 인증 및 무결성
|
||||
|
||||
HWP에 아래 문구를 추가합니다.
|
||||
|
||||
> IoT 단말과 서버 간 API 호출은 HTTPS/TLS 통신을 기본으로 하며, 요청 본문에는 장비 ID, 센서 ID, 이벤트 유형, 온도/습도 측정값, 타임스탬프를 포함한다. 서버는 사전에 등록된 API 키를 기반으로 요청 서명을 검증하고, 서명 불일치 또는 API 키 불일치 요청은 저장하지 않고 거부한다. 이를 통해 임의의 외부 사용자가 허위 측정값이나 경보를 주입하는 위험을 낮춘다.
|
||||
|
||||
확인 증적:
|
||||
|
||||
- 정상 API 호출 결과
|
||||
- API 키 불일치 시 403 거부 결과
|
||||
- PHP API 코드 또는 테스트 로그
|
||||
|
||||
### 5.2 측정값 검증 및 서버 임계 판정
|
||||
|
||||
> 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다.
|
||||
|
||||
확인 증적:
|
||||
|
||||
- 정상 측정값 저장 결과(`sensor_metric`)
|
||||
- 비정상 메소드/필드 누락/범위 초과 차단 결과
|
||||
- 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`sms_log`)
|
||||
|
||||
### 5.3 관리자 페이지 접근통제
|
||||
|
||||
> 웹 대시보드, 설치 점검, 월간 보고서 등 관리자 기능은 로그인된 관리자 세션에서만 접근 가능하도록 구성한다. 관리자 비밀번호는 평문으로 저장하지 않고 `password_hash` 기반 해시값으로 저장하며, 비밀번호 검증 후 Google Authenticator 등 표준 TOTP 인증 앱의 6자리 일회용 코드를 추가로 검증한다. 세션 쿠키는 HttpOnly, Secure, SameSite 속성을 적용하고, 로그인 성공·실패, 로그아웃, MFA 등록 검증 이벤트는 관리자 감사로그에 기록한다. 관리자 권한은 운영 담당자에게만 부여하고 담당자 변경 시 비밀번호와 TOTP 비밀키를 모두 교체한다.
|
||||
|
||||
> 최초 MFA 등록 또는 담당자 교체 시에는 임시 `MFA_SETUP_TOKEN`으로 `setup_mfa.php`에 접근하여 Base32 수동 입력 키 또는 `otpauth://` URI를 인증 앱에 등록한다. 등록 화면은 외부 Google API 또는 외부 QR 생성 API로 비밀키를 전송하지 않으며, 등록 완료 후 `ADMIN_TOTP_SECRET`을 운영 설정에 반영하고 `MFA_SETUP_TOKEN`은 삭제 또는 빈 값으로 변경한다.
|
||||
|
||||
추가 권장 구현:
|
||||
|
||||
- 로그인 실패 횟수 제한
|
||||
- 관리자 접속 IP 제한. Cafe24 웹호스팅 등에서 IP 제한 적용이 어렵다면 MFA, 강한 비밀번호, 실패 제한, 감사로그 월간 점검으로 보완
|
||||
- HTTPS 강제 리다이렉트
|
||||
- 세션 유휴시간 만료
|
||||
|
||||
### 5.4 비밀값 관리
|
||||
|
||||
> DB 계정, API 키, SMS 인증키, 관리자 비밀번호 해시는 소스 코드에 직접 저장하지 않는다. 서버는 `config.local.php` 또는 환경변수에서 운영 비밀값을 읽고, STM32 단말은 `firmware/common/secrets.h`의 `APP_API_KEY`(컴파일 타임 고정)를 사용한다. 해당 파일은 저장소에 커밋하지 않고(.gitignore) 빌드 머신에만 보관하며, 문서·공유폴더에 업로드하지 않는다.
|
||||
|
||||
권장 파일 권한:
|
||||
|
||||
```text
|
||||
서버 config.local.php: 웹서버 계정만 읽기 가능
|
||||
STM32 단말 firmware/common/secrets.h: 저장소 미커밋(.gitignore), 빌드 머신에서만 보관
|
||||
```
|
||||
|
||||
### 5.5 장비 오프라인 감지
|
||||
|
||||
> IoT 단말은 주기적으로 정상 상태를 서버에 보고하며, 서버는 마지막 보고 시각을 기준으로 장비 오프라인 상태를 판정한다. 일정 시간 이상 보고가 없으면 SMS를 통해 담당자에게 장비 이상을 통지하고, 대시보드에 오프라인 상태를 표시한다.
|
||||
|
||||
확인 증적:
|
||||
|
||||
- `sensor_status` 마지막 보고 시각
|
||||
- `cron_heartbeat.php` 실행 로그
|
||||
- 오프라인 SMS 발송 이력
|
||||
|
||||
## 6. 관리적 보안대책 상세 보완 문구
|
||||
|
||||
### 6.1 운영 책임과 RACI
|
||||
|
||||
HWP에 아래 책임표를 추가합니다.
|
||||
|
||||
| 업무 | 정보기반팀 | 우편혁신AI연구팀 | 정보보안센터 | 운영 담당자 |
|
||||
|---|---|---|---|---|
|
||||
| 보안대책서 승인 | A | R | C | I |
|
||||
| 서버 계정 및 DB 관리 | R | C | C | I |
|
||||
| 단말 장비 설치·교체 | C | R | C | I |
|
||||
| 보안 점검 | C | I | A/R | C |
|
||||
| SMS 수신자 현행화 | C | R | C | A/R |
|
||||
| 경보 대응 기록 | C | C | I | A/R |
|
||||
| 백업·복구 점검 | A/R | C | C | I |
|
||||
|
||||
범례:
|
||||
|
||||
- R: 수행
|
||||
- A: 최종 책임
|
||||
- C: 협의
|
||||
- I: 통보
|
||||
|
||||
### 6.2 월간 보안점검 항목
|
||||
|
||||
| 점검 항목 | 주기 | 기준 | 증적 |
|
||||
|---|---:|---|---|
|
||||
| 대시보드 로그인 계정 확인 | 월 1회 | 불필요 계정 없음 | 계정 점검표 |
|
||||
| 관리자 MFA 설정 확인 | 월 1회 | `ADMIN_TOTP_SECRET` 설정 및 인증 앱 등록 완료 | MFA 등록 확인표 |
|
||||
| 관리자 감사로그 확인 | 월 1회 | 로그인 실패, 설정 변경 이상 없음 | `php/var/admin_audit.log` |
|
||||
| SMS 수신자 확인 | 월 1회 | 현 담당자 번호만 등록 | 수신자 목록 |
|
||||
| 단말 동작 상태 | 월 1회 | 주기 보고·서버 200 정상 | STM32 USART3 콘솔 로그 |
|
||||
| 최근 오프라인 이벤트 | 월 1회 | 반복 장애 없음 | 대시보드 캡처 |
|
||||
| 임계 설정 확인 | 월 1회 | `METRIC_*` 운영 기준과 일치 | 설치 점검 화면 |
|
||||
| 측정 이력 용량 | 월 1회 | 보관기간 초과분 없음 | retention dry-run |
|
||||
| DB 백업 | 월 1회 이상 | 백업 생성 및 복구 가능 | 백업 파일, 복구 테스트 |
|
||||
| 보안 패치 | 월 1회 | OS/PHP/Python 업데이트 확인 | 패치 점검표 |
|
||||
| API 키 변경 필요성 | 분기 1회 | 유출 의심 없음 | 점검 결과 |
|
||||
|
||||
### 6.3 보관기간과 파기
|
||||
|
||||
| 데이터 | 개인정보/민감도 | 보관기간 권장 | 파기 방법 |
|
||||
|---|---|---:|---|
|
||||
| SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 |
|
||||
| SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 |
|
||||
| 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`sensor_metric`) |
|
||||
| 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 |
|
||||
| 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 |
|
||||
| API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 |
|
||||
|
||||
기관 내부 개인정보·기록물 관리 기준이 더 엄격하면 내부 기준을 우선 적용합니다.
|
||||
|
||||
## 7. 물리적 보안대책 상세 보완 문구
|
||||
|
||||
> STM32 보드와 SHT30 온습도 센서는 서버실 내 지정 위치에 고정 설치하고, 임의 탈거 또는 포트 접근이 어렵도록 함체 또는 보호 케이스에 수용한다. 장비에는 자산번호를 부여하고 모델명, 일련번호, MAC 주소, 설치 위치, 설치일, 관리 담당자를 자산대장에 기록한다. 장비 점검·교체 시에는 사전 승인된 작업자만 출입하고 작업 완료 후 설치 상태와 센서 동작을 확인한다.
|
||||
|
||||
추가 증적:
|
||||
|
||||
- 설치 위치 사진
|
||||
- 자산관리대장
|
||||
- 출입기록
|
||||
- 장비 점검표
|
||||
- 센서 측정 테스트 결과
|
||||
|
||||
## 8. 네트워크 보안대책 상세 보완
|
||||
|
||||
### 8.1 권장 네트워크 설명
|
||||
|
||||
> IoT 단말은 기관 내부 업무망에 접속하지 않고, 별도 인터넷 연결 또는 분리된 IoT 통신 경로를 사용한다. 기관 경계 방화벽에서는 승인된 Cafe24 웹호스팅 서버의 HTTPS 포트로 향하는 통신만 허용하고, IoT 단말로 들어오는 외부 접속은 허용하지 않는다.
|
||||
|
||||
### 8.2 네트워크 정책표
|
||||
|
||||
| 출발지 | 목적지 | 포트 | 방향 | 허용 사유 | 통제 |
|
||||
|---|---|---:|---|---|---|
|
||||
| STM32 단말 | Cafe24 PHP API | TCP 443 | Outbound | 온습도 측정값 전송 | 목적지 도메인/IP 제한 |
|
||||
| 운영자 PC | Cafe24 대시보드 | TCP 443 | Outbound | 상태 확인/경보 처리 | 관리자 인증 |
|
||||
| Cafe24 서버 | Cafe24 SMS API | TCP 443 | Outbound | SMS 발송 | SMS 계정 인증 |
|
||||
| 외부 인터넷 | STM32 단말 | - | Inbound | 불필요 | 차단 |
|
||||
| STM32 단말 | 기관 업무망 | - | Any | 불필요 | 차단 |
|
||||
|
||||
### 8.3 심사 대응 포인트
|
||||
|
||||
심사자 질문:
|
||||
|
||||
> IoT 장비가 침해되면 기관 내부망으로 들어올 수 있습니까?
|
||||
|
||||
권장 답변:
|
||||
|
||||
> 본 구성에서 STM32 단말은 기관 내부 업무망과 라우팅되지 않으며, 서버 API로 아웃바운드 HTTPS POST만 수행한다. 단말에 대한 외부 인바운드 접속은 허용하지 않고, 원격관리가 필요한 경우 현장 접속 또는 승인된 관리 절차를 따른다.
|
||||
|
||||
## 9. 개인정보 영향 보완
|
||||
|
||||
이 시스템은 주민등록번호 등 고유식별정보를 처리하지 않으며, 현장 사진 등 시설정보도 수집하지 않습니다. 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정되므로, 해당 개인정보 보호대책과 경보 오발송 방지 대책을 별도 항목으로 분리합니다.
|
||||
|
||||
### 9.1 처리 정보 목록
|
||||
|
||||
| 정보 | 예시 | 목적 | 접근자 | 저장 위치 |
|
||||
|---|---|---|---|---|
|
||||
| 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `sms_log` |
|
||||
| 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `sensor_metric`, MySQL |
|
||||
| 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `sensor_log`, MySQL |
|
||||
| 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` |
|
||||
| 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB |
|
||||
|
||||
### 9.2 개인정보 보호 문구
|
||||
|
||||
> SMS 수신자 휴대전화번호는 이상상태 알림 발송 목적에 한해 최소한으로 수집·이용한다. 담당자 변경, 전보, 퇴직, 업무 변경 시 수신자 목록을 즉시 현행화하며, 불필요한 번호는 삭제한다. SMS 발송 로그는 장애 대응 및 감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
|
||||
|
||||
### 9.3 경보 오발송 방지 문구
|
||||
|
||||
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `sms_log`에 기록하며, 보관기간 경과 시 정리한다.
|
||||
|
||||
## 10. 보안성 검토 제출 관점의 필수 첨부물
|
||||
|
||||
공개 안내 기준으로 보안성 검토 요청에는 사업 개요, 사업계획서, 제안요청서, 정보통신망 구성도, 체크리스트, 자체 보안대책 등이 요구되는 경우가 있습니다. 현재 HWP에는 일부만 있으므로 아래 부록을 추가해야 합니다.
|
||||
|
||||
| 첨부물 | 현재 준비 상태 | 보완 필요 |
|
||||
|---|---|---|
|
||||
| 정보화사업 개요 | 있음 | 실제 구축 범위 정정 |
|
||||
| 사업계획서 요약 | 일부 있음 | 일정, 운영주체, 범위 명확화 |
|
||||
| 정보통신망 구성도 | 있음 | 업무망 분리, 외부 호스팅, 포트 정책 추가 |
|
||||
| 시스템 구성도 | 있음 | 실제 파일/서버/DB/SMS 구성 반영 |
|
||||
| 보안성 검토 체크리스트 | 없음 | 자체 점검표 추가 |
|
||||
| 자체 보안대책 | 있음 | 구현·증적 기반으로 구체화 |
|
||||
| 개인정보 처리표 | 부족 | 처리항목, 보관기간, 접근자 추가 |
|
||||
| 운영·장애 대응 절차 | 부족 | 임계 경보/오프라인/미확인 대응 절차 추가 |
|
||||
| 보안 증적 보고서 | 생성 가능 | `php/security_evidence.php?format=md` 결과 첨부 |
|
||||
| 단말 운영 증적 | 생성 가능 | STM32 USART3 부팅 콘솔 로그(링크업/DHCP/SNTP/TLS/서버 200) 캡처 첨부 |
|
||||
| 설치·검증 증적 | 없음 | 화면 캡처와 테스트 로그 첨부 |
|
||||
|
||||
## 11. 자체 보안점검 체크리스트
|
||||
|
||||
HWP 부록에 아래 체크리스트를 넣습니다.
|
||||
|
||||
| 번호 | 점검 항목 | 기준 | 결과 | 증적 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | 사업 범위가 실제 구현과 일치하는가 | 온습도/임계 SMS/오프라인 중심으로 명시 | | |
|
||||
| 2 | 업무망과 IoT 구간이 분리되어 있는가 | 단말에서 업무망 접근 없음 | | |
|
||||
| 3 | API 통신이 HTTPS인가 | `https://` URL 사용 | | |
|
||||
| 4 | API 키와 서명 검증이 적용되었는가 | 비정상 키 403 거부 | | |
|
||||
| 5 | 비밀값이 코드에 하드코딩되어 있지 않은가 | `config.local.php`, env 사용 | | |
|
||||
| 6 | 관리자 비밀번호가 해시로 저장되는가 | `password_hash` 사용 | | |
|
||||
| 7 | 대시보드 접근에 로그인과 MFA가 필요한가 | 미로그인 접근 차단, TOTP 6자리 코드 검증 | | |
|
||||
| 8 | 측정값 범위 검증이 있는가 | 온도 -40~125℃ / 습도 0~100% 범위 검증 | | |
|
||||
| 9 | 서버 임계 판정·쿨다운이 동작하는가 | `METRIC_*` 임계, 30분 쿨다운, 복구 히스테리시스 | | |
|
||||
| 10 | SMS 수신자가 최신인가 | 현 담당자 번호만 존재 | | |
|
||||
| 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | |
|
||||
| 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | |
|
||||
| 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | |
|
||||
| 14 | 온습도 측정·저장이 검증되었는가 | `sensor_metric` 측정 이력 저장 | | |
|
||||
| 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | |
|
||||
| 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | |
|
||||
| 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | |
|
||||
| 18 | 백업 증빙을 생성했는가 | `scripts/backup_evidence.php` 결과 첨부 | | |
|
||||
|
||||
## 12. HWP에 넣을 “보안대책 이행 증적” 부록 문구
|
||||
|
||||
> 본 사업은 보안대책의 이행 여부를 확인하기 위해 구축 완료 시 다음 증적을 보관한다. 증적은 사업 산출물 또는 운영 인수인계 자료로 관리하며, 보안담당 부서 요청 시 제출한다.
|
||||
|
||||
| 증적 | 확인 내용 |
|
||||
|---|---|
|
||||
| 설치 점검 화면 캡처 | DB, 테이블, 권한, SMS, API 키 설정 확인 |
|
||||
| 보안 증적 Markdown | 운영 점검 결과와 보안통제 매트릭스 확인 |
|
||||
| MFA 등록 증빙 | 관리자 TOTP 등록 절차와 임시 토큰 제거 기준 확인 |
|
||||
| 대시보드 화면 캡처 | 온습도 상태, 임계 경보, 오프라인 현황 확인 |
|
||||
| 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 |
|
||||
| API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 |
|
||||
| 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 |
|
||||
| 측정값 저장 결과 | `sensor_metric` 기록 및 임계 상태 확인 |
|
||||
| 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`sms_log`) 확인 |
|
||||
| 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 |
|
||||
| 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 |
|
||||
| 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 |
|
||||
| 단말 증적(USART3 콘솔 로그) | 부팅 자동 보고, 링크업/DHCP/SNTP/TLS 핸드셰이크, 서버 200 OK 확인 |
|
||||
| DB 마이그레이션 결과 | 필수 테이블과 컬럼 생성 확인 |
|
||||
| 자산관리대장 | STM32 보드, SHT30 센서 등록 확인 |
|
||||
| 백업/복구 점검표 | 장애 시 복구 가능성 확인 |
|
||||
|
||||
## 13. 잔여위험 및 수용 방안
|
||||
|
||||
보안대책서에는 모든 위험이 제거된 것처럼 쓰면 안 됩니다. 남은 위험과 수용 또는 보완 계획을 명시하는 편이 더 신뢰도가 높습니다.
|
||||
|
||||
| 잔여위험 | 영향 | 현재 통제 | 추가 조치 |
|
||||
|---|---|---|---|
|
||||
| 외부 웹호스팅 서버 침해 | 측정·로그 노출 가능 | 관리자 MFA, API 검증, 최소정보 저장 | 정기 백업, 감사로그 점검 |
|
||||
| 단말 장비 물리 탈취 | API 키 노출 가능 | 서버실 출입통제, 환경파일 권한 | 키 주기 교체, 장비 함체 잠금 |
|
||||
| SMS 계정 유출 | 오발송·비용 발생 | 설정파일 분리, 30분 쿨다운 | SMS 계정 비밀번호 주기 변경, 발송량 모니터링 |
|
||||
| 임계 오판정 | 불필요 경보 또는 누락 | 서버 임계 판정, 복구 히스테리시스 | 운영 기준에 맞춘 `METRIC_*` 주기 점검 |
|
||||
| 오프라인 감지 실패 | 환경 감시 공백 | heartbeat 감지 | 월간 서비스 상태 점검 |
|
||||
| 단일 서버 장애 | 대시보드/알림 중단 | Cafe24 운영 | 백업, 복구 절차, 대체 알림 절차 |
|
||||
|
||||
## 14. 최우선 수정 순서
|
||||
|
||||
1. **사업 범위 정정**: 화재/누수/전원 이상을 현재 구축 범위에서 제외하거나 향후 확장으로 분리하고 온습도 모니터링으로 명시한다.
|
||||
2. **검토 생략 표현 완화**: 국정원 통과를 확정하듯 쓰지 말고 보안성 검토 대상 여부 확인 문구를 넣는다.
|
||||
3. **네트워크 구성도 보강**: 단말 아웃바운드 HTTPS, 기관 업무망 분리, 외부 웹호스팅 경계를 표시한다.
|
||||
4. **기술대책 구체화**: API 서명, 비밀값 분리, 관리자 인증, 측정값 범위 검증, 서버 임계 판정을 구현 기반으로 쓴다.
|
||||
5. **개인정보 처리표 추가**: SMS 번호의 목적, 접근자, 보관기간을 명시한다(현장 사진 항목 없음).
|
||||
6. **운영 절차 추가**: 임계 경보, 미확인, 오프라인, 정상복귀 흐름을 넣는다.
|
||||
7. **증적 부록 추가**: `security_evidence.php?format=md`, 단말 증적 보고서, 설치 점검, 테스트 SMS, DB, 측정값 저장, 대시보드 캡처를 첨부한다.
|
||||
8. **잔여위험 표 추가**: 외부 호스팅, 단말 탈취, 임계 오판정, 단일 서버 장애 위험을 수용/보완 계획과 함께 쓴다.
|
||||
|
||||
### 14.1 최종 제출 전 운영자가 제공해야 할 자료
|
||||
|
||||
아래 항목은 코드나 문서만으로 확정할 수 없으므로 운영자가 실제 환경 기준으로 제공해야 합니다.
|
||||
|
||||
1. 실제 서버 도메인과 HTTPS 접속 화면, 인증서 정보
|
||||
2. 단말 네트워크 분리 또는 방화벽 정책 확인 자료
|
||||
3. STM32 보드, SHT30 센서 설치 위치 사진과 자산 정보
|
||||
4. DB, 설정파일 백업 주기와 담당자
|
||||
5. SMS 수신자 현행화 확인표
|
||||
|
||||
## 15. 참고한 공개 자료
|
||||
|
||||
- 국가정보원 보안적합성 검증 안내: https://www.nis.go.kr/AF/1_7_2_1.do
|
||||
- 국가정보원 보안적합성 검증 FAQ: https://www.nis.go.kr/AF/1_7_2_5.do
|
||||
- 울산광역시교육청 보안성 검토 안내: https://use.go.kr/www/eduinfo/protect/security/security03.jsp
|
||||
- 개인정보보호위원회 개인정보 포털, 안전성 확보조치 관련 안내: https://www.privacy.go.kr/per/his/impactAssessmentItemPopup.do?itemCd=ITEMCD00000000000375
|
||||
|
||||
주의: 국가 정보보안 기본지침 원문과 기관 내부 보안업무 지침은 제출 직전 보안담당 부서가 최신본으로 재확인해야 합니다.
|
||||
119
docs/SHT30_SENSOR2_SETUP.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# SHT30 온습도 센서 설치 가이드
|
||||
|
||||
서버실 온습도 모니터링을 위해 Sensirion SHT30(한진데이터 P4422-3, I2C) 센서를 설치하고 서버에 연동하는 가이드입니다. 기본 구성은 STM32F407VGT6 펌웨어(`sht30_fw`, `sensor_id=2`, `device_id=stm32-sht30-01`)이며, 폐쇄망의 다른 환경을 위한 Raspberry Pi I2C 대안도 함께 설명합니다.
|
||||
|
||||
## 1. 서버 준비
|
||||
|
||||
1. 웹 서버 DB에 통합 스키마를 적용합니다(신규 설치).
|
||||
|
||||
```sql
|
||||
SOURCE sql/schema_sht30.sql;
|
||||
```
|
||||
|
||||
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
|
||||
|
||||
```sql
|
||||
SOURCE sql/migration_drop_leak.sql;
|
||||
```
|
||||
|
||||
2. 서버 파일을 배포합니다.
|
||||
|
||||
```text
|
||||
php/api/sensor_data.php
|
||||
php/dashboard.php
|
||||
php/ops_checks.php
|
||||
php/config.php
|
||||
sql/schema_sht30.sql
|
||||
```
|
||||
|
||||
API는 `sht30` 측정을 수신하면 `sensor_status`를 갱신하고 측정값을 `sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다.
|
||||
|
||||
## 2. 기본 구성 — STM32F407VGT6 (`sht30_fw`)
|
||||
|
||||
### 2.1 I2C 배선
|
||||
|
||||
SHT30을 STM32 I2C1 버스에 연결합니다(상세는 [wiring_diagram.md](./wiring_diagram.md), [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3).
|
||||
|
||||
```text
|
||||
SHT30 VDD -> STM32 3.3V
|
||||
SHT30 GND -> STM32 GND
|
||||
SHT30 SCL -> STM32 PB6 (I2C1_SCL, AF4) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 SDA -> STM32 PB7 (I2C1_SDA, AF4) + 4.7kΩ 풀업 -> 3.3V
|
||||
SHT30 ADDR -> GND (주소 0x44; VDD 면 0x45)
|
||||
```
|
||||
|
||||
- 7-bit 주소 `0x44`(`APP_SHT30_I2C_ADDR`), I2C 100kHz 권장.
|
||||
- 명령 `0x2C06`(high-repeatability) → 약 20ms 대기 → 6바이트 read.
|
||||
|
||||
### 2.2 펌웨어 설정·빌드 (폐쇄망)
|
||||
|
||||
```bash
|
||||
cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
# secrets.h 의 APP_API_KEY 를 서버 config.local.php 의 API_KEY 와 동일하게 채운다.
|
||||
```
|
||||
|
||||
| 위치 | 설정 |
|
||||
|---|---|
|
||||
| `firmware/common/secrets.h` | `APP_API_KEY` = 서버 `API_KEY` |
|
||||
| `firmware/common/app_config.h` | `APP_API_HOST`, `APP_API_PATH`(`/raspi_leck_detecter/api/sensor_data.php`), 네트워크 |
|
||||
| `firmware/common/app_config.h` | `APP_SHT30_REPORT_INTERVAL_SEC`(기본 300초), `APP_SHT30_I2C_ADDR`(0x44) |
|
||||
| `firmware/certs/server_ca.c` | 자리표시자 CA → 실제 Cafe24 루트 CA |
|
||||
|
||||
벤더링·빌드·플래시 절차는 [firmware/docs/BUILD_OFFLINE.md](../firmware/docs/BUILD_OFFLINE.md)를 따릅니다. `sht30_fw` 타깃을 빌드해 보드에 플래시합니다.
|
||||
|
||||
### 2.3 동작 확인
|
||||
|
||||
USART3(PD8/PD9, 115200) 콘솔 로그에서 확인합니다.
|
||||
|
||||
- I2C1 초기화 및 SHT30 0x44 응답, 첫 측정값
|
||||
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답 → `startup` 보고
|
||||
- 이후 5분 주기 `periodic` 측정 보고
|
||||
|
||||
## 3. 대안 구성 — Raspberry Pi I2C (STM32 외 대안)
|
||||
|
||||
STM32 보드를 쓸 수 없는 환경에서는 Raspberry Pi 40핀 헤더 I2C 버스 1을 사용할 수 있습니다.
|
||||
|
||||
```text
|
||||
SHT30 VCC -> Raspberry Pi 3.3V (pin 1)
|
||||
SHT30 GND -> Raspberry Pi GND
|
||||
SHT30 SDA -> Raspberry Pi GPIO2 / SDA1 / pin 3
|
||||
SHT30 SCL -> Raspberry Pi GPIO3 / SCL1 / pin 5
|
||||
SHT30 ADDR -> GND (주소 0x44; 점퍼 변경 시 0x45)
|
||||
```
|
||||
|
||||
I2C를 먼저 활성화합니다.
|
||||
|
||||
```bash
|
||||
sudo raspi-config # Interface Options -> I2C -> enable
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-venv python3-smbus i2c-tools
|
||||
```
|
||||
|
||||
센서가 보이는지 확인합니다.
|
||||
|
||||
```bash
|
||||
i2cdetect -y 1 # 44 또는 45 가 보여야 함
|
||||
```
|
||||
|
||||
환경 파일 `/etc/sht30-sensor.env`를 생성하고 다음 값을 채웁니다.
|
||||
|
||||
```text
|
||||
API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
|
||||
API_KEY=서버와 동일한 API_KEY
|
||||
DEVICE_ID=rpi-sht30-01
|
||||
DEVICE_LOCATION=온습도 측정 위치
|
||||
SHT30_SENSOR_ID=2
|
||||
SHT30_I2C_ADDR=0x44
|
||||
```
|
||||
|
||||
> 대안 구성에서도 `sensor_id=2`, raw-body 서명(`X-Signature = sha256(API_KEY + body)`), 5분 측정 주기는 동일합니다. 임계 판정과 SMS는 모두 서버에서 수행하므로 측정 송신부만 환경에 맞게 구성하면 됩니다.
|
||||
|
||||
## 4. 대시보드 결과
|
||||
|
||||
첫 정상 보고 후:
|
||||
|
||||
- `sensor_status`에 `sensor_id=2` 항목이 생성됩니다.
|
||||
- `sensor_metric`에 온도/습도 이력이 누적됩니다.
|
||||
- `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다.
|
||||
|
||||
장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다.
|
||||
273
docs/SOURCE_SECURITY_EVIDENCE.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# 소스코드 기반 보안통제 증적 정리
|
||||
|
||||
작성일: 2026-05-20
|
||||
목적: 보안대책서의 `보안통제 매트릭스` 중 소스코드, HTTPS, API, 인증, 측정값 검증 관련 항목을 프로젝트 코드 기준으로 설명한다.
|
||||
|
||||
## 1. 결론
|
||||
|
||||
아래 항목은 프로젝트 소스코드에서 근거를 제시할 수 있습니다.
|
||||
|
||||
| 항목 | 내가 작성/증명 가능한가 | 비고 |
|
||||
|---|---:|---|
|
||||
| API 요청 서명 검증 (raw-body) | 가능 | `config.php`, `api/sensor_data.php`, 펌웨어 `sig.c` |
|
||||
| JSON 센서 API 필수값 검증 | 가능 | `api/sensor_data.php` |
|
||||
| 측정값 범위 검증 | 가능 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) |
|
||||
| 서버 임계 판정·쿨다운 | 가능 | `config.php` `METRIC_*`, `api/sensor_data.php` |
|
||||
| DB prepared statement 사용 | 가능 | PHP API와 대시보드 주요 DB 처리 |
|
||||
| 관리자 로그인 기본 보호 | 가능 | `login.php`, `dashboard.php`, `setup_wizard.php`, `monthly_report.php` |
|
||||
| 관리자 TOTP MFA | 가능 | `login.php`, `setup_mfa.php`, `admin_security.php` |
|
||||
| 관리자 감사로그 | 가능 | `admin_security.php`, `php/var/admin_audit.log` |
|
||||
| 비밀값 코드 분리 | 가능 | `config.php`, `config.local.example.php`, 펌웨어 `secrets.h` |
|
||||
| 보관기간 정리 | 가능 | `retention_cleanup.php` |
|
||||
| 백업·복구 증빙 | 가능 | `scripts/backup_evidence.php` |
|
||||
| 보안 헤더 설정 | 가능 | `php/.htaccess` |
|
||||
| HTTPS 사용 구조 | 일부 가능 | 코드와 문서에는 HTTPS URL 사용. 실제 인증서/리다이렉트는 운영 서버 캡처 필요 |
|
||||
| 방화벽 정책 | 불가 | 기관 또는 Cafe24/네트워크 담당자 자료 필요 |
|
||||
| 실제 서버 설치 경로 | 불가 | 운영 서버 캡처 필요 |
|
||||
| 실제 API 키 일치 여부 | 불가 | 원문 키 없이 운영자가 확인해야 함 |
|
||||
|
||||
운영 서버 반영 후에는 `php/security_evidence.php?format=md`에서 소스코드 기반 통제와 운영 점검 결과를 묶은 Markdown 보고서를 내려받을 수 있습니다. 단말(STM32) 측은 서비스 상태와 비밀값 파일 권한, 최근 로그를 운영자가 별도로 수집합니다. 두 결과 모두 API 키나 SMS 키 원문을 출력하지 않는 것을 기준으로 합니다.
|
||||
|
||||
## 2. 보안통제 매트릭스에 넣을 코드 기반 설명
|
||||
|
||||
### 2.1 전송구간 보호
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> STM32 단말은 서버 API URL을 HTTPS 주소로 설정하여 온습도 측정값을 전송한다. 서버 측 SMS 연계도 HTTPS 기반 Cafe24 SMS 엔드포인트를 사용한다. 운영 서버에서는 Cafe24 SSL 설정 또는 웹서버 설정을 통해 HTTPS 접속을 적용하며, 제출 시 실제 도메인의 HTTPS 접속 화면과 인증서 정보를 증적으로 첨부한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- 펌웨어 `firmware/common/app_config.h` / `net.c`
|
||||
- 서버 API URL을 HTTPS로 설정하여 측정값 전송
|
||||
- `php/config.php`
|
||||
- `SMS_ENDPOINT=https://sslsms.cafe24.com/sms_sender.php`
|
||||
- `php/.htaccess`
|
||||
- HTTPS 강제 리다이렉트는 Cafe24 호스팅 관리자 패널에서 설정하도록 주석 명시
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 실제 서비스 URL이 `https://`로 접속되는 화면
|
||||
- 브라우저 인증서 정보 화면
|
||||
- Cafe24 SSL 리다이렉트 설정 화면 또는 HTTPS 적용 확인 화면
|
||||
|
||||
주의:
|
||||
|
||||
> 현재 `.htaccess`는 HTTPS 리다이렉트를 직접 강제하지 않습니다. 리버스 프록시 환경에서 무한 리다이렉트가 발생할 수 있어 Cafe24 관리자 패널의 SSL 리다이렉트 설정을 사용하는 구조입니다. 따라서 HTTPS 적용 증적은 운영 서버 화면이 필요합니다.
|
||||
|
||||
### 2.2 API 인증 및 요청 무결성
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> API 요청은 서버와 STM32 단말이 공유하는 API 키를 기반으로 SHA-256 서명을 생성·검증한다. 단말은 요청 본문 바이트 전체에 대해 `sha256(API_KEY || raw_body)`를 계산하여 `X-Signature` 헤더로 전송하고, 서버는 동일하게 계산한 값과 상수시간 비교한다. 서명이 일치하지 않으면 `403 인증 실패`로 거부하며 DB에 저장하지 않는다. 이를 통해 임의의 외부 요청이 허위 측정값을 위조해 등록하는 위험을 낮춘다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/config.php`
|
||||
- `verify_signature_raw(string $raw_body): bool`
|
||||
- `hash('sha256', API_KEY . $raw_body)` 계산 후 `X-Signature`(`HTTP_X_SIGNATURE`)와 비교
|
||||
- `hash_equals()`로 상수시간 서명 비교
|
||||
- 펌웨어 `firmware/common/sig.c`, `firmware/common/secrets.h`
|
||||
- `APP_API_KEY` 기반 raw-body 서명 생성
|
||||
- 서버 `API_KEY`와 동일해야 함(`secrets.h.example` 주석 명시)
|
||||
- `php/api/sensor_data.php`
|
||||
- 서명 검증 실패 시 `403 인증 실패`
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 정상 API 테스트 결과
|
||||
- API 키 불일치 또는 서명 불일치 요청이 `403`으로 거부되는 테스트 결과
|
||||
|
||||
증적 예시:
|
||||
|
||||
```text
|
||||
정상 요청: 200 OK, {"status":"ok", ...}
|
||||
비정상 서명 요청: 403, {"status":"error","message":"인증 실패"}
|
||||
```
|
||||
|
||||
### 2.3 센서 데이터 API 입력값 검증
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 센서 데이터 API는 POST JSON 요청만 허용하고, 필수 필드가 누락된 요청은 저장하지 않는다. 이벤트 유형은 허용 목록으로 제한하며, 온도·습도 측정값은 서버에서 타입을 변환하고 물리적으로 가능한 범위를 벗어난 값은 무효 처리한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/api/sensor_data.php`
|
||||
- `POST` 외 요청은 `405 Method Not Allowed`
|
||||
- JSON 파싱 실패 시 `400`
|
||||
- 필수 필드: `device_id`, `sensor_id`, `event_type`, `timestamp`
|
||||
- 허용 이벤트: `startup`, `periodic`
|
||||
- 측정값 범위 검증: 온도 -40~125℃, 습도 0~100% 초과 시 `null` 처리
|
||||
- PDO prepared statement로 DB 저장
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 정상 측정값 저장 화면 또는 DB 기록(`sensor_metric`)
|
||||
- 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과
|
||||
|
||||
### 2.4 서버 임계 판정 및 SMS 오발송 방지
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/config.php`
|
||||
- `METRIC_TEMP_HIGH_C`(30), `METRIC_TEMP_LOW_C`(10), `METRIC_RH_HIGH`(70), `METRIC_RH_LOW`(20)
|
||||
- `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0)
|
||||
- `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분)
|
||||
- `php/api/sensor_data.php`
|
||||
- `evaluate_metric_thresholds()`로 서버 재판정 → `sensor_metric`
|
||||
- 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정
|
||||
- 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()`
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 임계 초과 시 종류별 경보 SMS 발송 이력(`sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`)
|
||||
- 정상복귀 SMS 발송 이력
|
||||
- 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과
|
||||
|
||||
### 2.5 장비 오프라인 감지
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 단말은 주기적으로 정상 보고를 전송하며, 서버는 마지막 보고 시각을 기준으로 장비 오프라인 상태를 판정한다. heartbeat 타임아웃을 초과하면 SMS로 담당자에게 장비 이상을 통지하고, 보고가 재개되면 복구 SMS를 발송한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/config.php`
|
||||
- `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300)
|
||||
- `php/cron_heartbeat.php`
|
||||
- 마지막 보고 시각 기준 오프라인 판정, `sensor_status.offline_alerted` 관리
|
||||
- `php/api/sensor_data.php`
|
||||
- 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- `sensor_status` 마지막 보고 시각
|
||||
- `cron_heartbeat.php` 실행 로그
|
||||
- 오프라인/복구 SMS 발송 이력(`sms_log`)
|
||||
|
||||
### 2.6 비밀값 코드 분리
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> DB 비밀번호, API 키, SMS 인증키, 관리자 비밀번호 해시는 소스코드에 직접 저장하지 않고 운영 환경 설정 파일로 분리한다. 서버는 `config.local.php` 또는 환경변수에서 값을 읽고, STM32 단말은 펌웨어 `secrets.h`(`APP_API_KEY`)에서 API 키를 읽는다. 비밀값 원문은 보안대책서와 저장소에 포함하지 않는다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/config.php`
|
||||
- `config.local.php` 또는 환경변수에서 운영값 로드
|
||||
- `cfg()` 함수 사용
|
||||
- `php/config.local.example.php`
|
||||
- 운영 설정 예시만 제공
|
||||
- 펌웨어 `firmware/common/secrets.h.example`
|
||||
- `APP_API_KEY` 템플릿만 제공, `secrets.h`는 저장소 미커밋(`.gitignore`)
|
||||
- 서버 `php/config.php`의 `API_KEY`와 동일해야 함
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- `config.local.php`가 서버에만 존재한다는 배포 화면
|
||||
- 펌웨어 `secrets.h`가 저장소에 커밋되지 않았다는 점검 결과
|
||||
- 비밀값 원문이 문서와 저장소에 없다는 점검 결과
|
||||
|
||||
### 2.7 관리자 로그인, TOTP MFA 및 세션 보호
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 관리자 페이지는 로그인된 세션에서만 접근 가능하며, 관리자 비밀번호는 평문이 아닌 해시값으로 저장한다. 로그인 요청은 CSRF 토큰을 검증하고, 비밀번호 검증 후 Google Authenticator 등 표준 TOTP 인증 앱의 6자리 일회용 코드를 추가 검증한다. 실패 횟수 제한 및 일정 시간 잠금을 적용하고, 세션 쿠키에는 HttpOnly, Secure, SameSite 속성을 적용한다. 로그인 성공·실패, 로그아웃, MFA 등록 검증은 감사로그로 기록한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/login.php`
|
||||
- `ADMIN_PASSWORD_HASH` 기반 `password_verify()`
|
||||
- `ADMIN_TOTP_SECRET` 기반 TOTP 6자리 코드 검증
|
||||
- CSRF 토큰 검증
|
||||
- IP별 로그인 실패 횟수 제한
|
||||
- 5회 실패 시 15분 잠금
|
||||
- `session_regenerate_id(true)`
|
||||
- `session.cookie_httponly=1`
|
||||
- `session.cookie_secure=1`
|
||||
- `session.cookie_samesite=Strict`
|
||||
- `php/setup_mfa.php`
|
||||
- `MFA_SETUP_TOKEN` 또는 로그인 세션 기반으로 최초 등록 화면 접근 제한
|
||||
- Base32 수동 입력 키와 `otpauth://` 등록 URI 제공
|
||||
- 외부 Google API 또는 외부 QR 생성 API로 비밀키를 전송하지 않음
|
||||
- `php/admin_security.php`
|
||||
- TOTP 검증, 등록 URI 생성, 감사로그 기록 헬퍼 제공
|
||||
- `php/dashboard.php`, `php/setup_wizard.php`, `php/security_evidence.php`, `php/monthly_report.php`
|
||||
- 로그인 세션 없으면 `login.php`로 리다이렉트
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 미로그인 상태에서 대시보드 접근 시 로그인 화면으로 이동하는 화면
|
||||
- 로그인 화면
|
||||
- MFA 등록 화면 또는 [docs/evidence/security_plan_mfa_evidence.html](evidence/security_plan_mfa_evidence.html)
|
||||
- 관리자 비밀번호 해시 생성 결과
|
||||
- 로그인 실패 잠금 화면
|
||||
- `php/var/admin_audit.log` 존재 및 로그인 이벤트 일부 마스킹 화면
|
||||
|
||||
### 2.8 보안 헤더 및 민감 파일 접근 차단
|
||||
|
||||
HWP 반영 문구:
|
||||
|
||||
> 웹 서버 설정은 민감 PHP 파일 직접 접근을 차단하고, 보안 헤더를 설정하여 브라우저 기반 공격 위험을 낮춘다. 디렉터리 목록을 비활성화하고 불필요 HTTP 메소드를 차단한다.
|
||||
|
||||
코드 근거:
|
||||
|
||||
- `php/.htaccess`
|
||||
- `config.php`, `config.local.php`, `config.local.example.php`, `ops_checks.php`, `sms_send.php`, `setup_hash.php`, `test_mobile.php` 직접 접근 차단
|
||||
- `TRACE`, `DELETE`, `PUT`, `PATCH` 차단
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `Content-Security-Policy`
|
||||
- `Permissions-Policy`
|
||||
- `Options -Indexes`
|
||||
|
||||
사용자가 제공해야 하는 증적:
|
||||
|
||||
- 민감 파일 직접 URL 접근 차단 화면
|
||||
- 응답 헤더 확인 결과
|
||||
|
||||
응답 헤더 확인 예시:
|
||||
|
||||
```bash
|
||||
curl -I https://example.com/sht30_monitor/login.php
|
||||
```
|
||||
|
||||
## 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 토큰, 세션 쿠키 보안 속성, 로그인 실패 제한을 적용한다.
|
||||
|
||||
추가 반영 문구:
|
||||
|
||||
> 관리자 페이지는 비밀번호 인증 후 TOTP 기반 다중인증을 추가로 요구하며, TOTP 비밀키는 `ADMIN_TOTP_SECRET`으로 관리한다. 최초 등록 또는 담당자 변경 시에는 임시 `MFA_SETUP_TOKEN`으로 `setup_mfa.php`에 접근하여 인증 앱 등록을 검증하고, 등록 완료 후 임시 토큰을 제거한다. 로그인, 로그아웃, 로그인 실패, MFA 등록 검증 이벤트는 관리자 감사로그에 기록한다. 보관기간 경과 데이터는 `retention_cleanup.php --dry-run`으로 대상을 확인한 뒤 백업 완료 후 정리하며, 백업 파일 목록과 복구 테스트 결과는 `scripts/backup_evidence.php`로 증빙한다.
|
||||
|
||||
## 4. 그래도 사용자가 제공해야 하는 것
|
||||
|
||||
아래는 코드만으로는 증명할 수 없습니다.
|
||||
|
||||
| 항목 | 왜 코드로 증명 불가한가 | 사용자가 줄 자료 |
|
||||
|---|---|---|
|
||||
| 실제 HTTPS 적용 | 운영 도메인 인증서와 서버 설정 문제 | 브라우저 HTTPS 화면, 인증서 정보, Cafe24 SSL 설정 |
|
||||
| 실제 방화벽 정책 | 기관/네트워크 장비 설정 | 방화벽 허용 정책 또는 담당자 확인 문구 |
|
||||
| 실제 단말 인바운드 차단 | 운영 장비 상태 문제 | 단말에서 `ss -lntup` 결과 |
|
||||
| 실제 서버 파일 권한 | Cafe24 배포 상태 문제 | 파일 관리자 또는 권한 화면 |
|
||||
| 실제 SMS 수신자 관리 | 운영자 개인정보 관리 문제 | 수신자 현행화 확인표 |
|
||||
| 실제 백업 수행 | 운영 절차 문제 | 백업 파일 목록, 복구 테스트 결과 |
|
||||
|
||||
## 5. 첨부자료를 줄일 수 있는 방식
|
||||
|
||||
사용자가 모든 증적을 준비하기 어렵다면, 최소 증적은 아래 5개로 줄일 수 있습니다.
|
||||
|
||||
1. HTTPS로 접속되는 대시보드 화면
|
||||
2. `setup_wizard.php` 점검 화면
|
||||
3. API 정상/403 테스트 결과
|
||||
4. 단말 `systemctl status leak-sensor` 화면
|
||||
5. 측정값 저장(`sensor_metric`) 및 임계 경보 SMS(`sms_log`) 화면
|
||||
|
||||
이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다.
|
||||
BIN
docs/assets/security_plan/generated/security_plan_data_flow.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0001.bmp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0001.png
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0002.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0003.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0004.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0005.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0006.bmp
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
docs/assets/security_plan/hwp_embedded/bin0006.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
156
docs/assets/security_plan/hwp_extracted_text.txt
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
捤獥 汤捯 慤桥 漠杳 漠杳
|
||||
『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 인증키는 최소권한 원칙으로 관리
|
||||
ㅇ 중요 데이터는 주기적으로 백업하고 복구 가능 여부 점검
|
||||
BIN
docs/evidence/mfa_setup_page_capture.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
307
docs/evidence/security_plan_mfa_evidence.html
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 MFA 및 운영 보안 보완 증빙</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans KR", "Malgun Gothic", Arial, sans-serif;
|
||||
color: #172033;
|
||||
background: #f5f7fb;
|
||||
line-height: 1.65;
|
||||
}
|
||||
main {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
color: #123f8c;
|
||||
}
|
||||
h2 {
|
||||
margin: 34px 0 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid #dbe7f8;
|
||||
font-size: 20px;
|
||||
color: #1d3557;
|
||||
}
|
||||
h3 {
|
||||
margin: 22px 0 8px;
|
||||
font-size: 16px;
|
||||
color: #20324d;
|
||||
}
|
||||
p, li, td, th {
|
||||
font-size: 14px;
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 24px;
|
||||
color: #526175;
|
||||
font-size: 13px;
|
||||
}
|
||||
.copy-block {
|
||||
border: 1px solid #cbd8ea;
|
||||
background: #fbfdff;
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.evidence-image {
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
border: 1px solid #d5dde8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, .12);
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ccd7e5;
|
||||
padding: 10px 12px;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #eef4fb;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
code, pre {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diagram {
|
||||
border: 1px solid #ccd7e5;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.diagram svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.note {
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
main { padding: 0; }
|
||||
.copy-block, .diagram, pre { break-inside: avoid; }
|
||||
.evidence-image { max-width: 100%; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>관리자 MFA 및 운영 보안 보완 증빙</h1>
|
||||
<p class="meta">작성일: 2026-05-28 / 대상: SHT30 온습도 모니터링 시스템 관리자 웹 기능</p>
|
||||
|
||||
<h2>1. 보안대책서 삽입 문구</h2>
|
||||
<div class="copy-block">
|
||||
<h3>관리자 접근통제 보완</h3>
|
||||
<p>
|
||||
본 시스템은 관리자 페이지 접근 시 단일 비밀번호 인증에 의존하지 않도록
|
||||
TOTP(Time-based One-Time Password) 기반의 다중인증(MFA)을 적용하였다.
|
||||
관리자 계정은 비밀번호 검증 후 Google Authenticator, Microsoft Authenticator, Authy 등
|
||||
표준 TOTP 인증 앱에서 생성한 6자리 일회용 인증코드를 추가로 검증해야 한다.
|
||||
TOTP 검증은 서버 내부에서 수행되며, 비밀키는 외부 Google API 또는 외부 QR 생성 서비스로 전송하지 않는다.
|
||||
</p>
|
||||
<p>
|
||||
최초 등록 또는 담당자 변경 시에는 임시 설정 토큰인 <code>MFA_SETUP_TOKEN</code>을 사용하여
|
||||
등록 화면에 접근한다. 등록 화면은 Base32 수동 입력 키와 <code>otpauth://</code> 표준 등록 URI를 제공하고,
|
||||
운영자는 인증 앱에 등록한 뒤 6자리 코드를 검증한다. 검증 완료 후 발급된
|
||||
<code>ADMIN_TOTP_SECRET</code> 값을 서버 설정에 반영하며, 등록 완료 즉시
|
||||
<code>MFA_SETUP_TOKEN</code>은 삭제 또는 빈 값으로 변경한다.
|
||||
</p>
|
||||
<p>
|
||||
관리자 인증 성공, 실패 및 MFA 등록 검증 이벤트는 감사 로그에 기록되도록 하여
|
||||
비인가 접근 시도 및 계정 운영 이력을 사후 확인할 수 있도록 하였다.
|
||||
운영 로그는 보존기간 정책에 따라 정리할 수 있도록 정리 스크립트와
|
||||
백업 증빙 생성 절차를 마련하였다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>2. 구현 항목 요약</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th>구현 내용</th>
|
||||
<th>보안 효과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>관리자 MFA</td>
|
||||
<td><code>ADMIN_TOTP_SECRET</code> 기반 TOTP 6자리 코드 검증</td>
|
||||
<td>관리자 비밀번호 유출 시에도 추가 인증 없이는 관리자 페이지 접근 차단</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MFA 등록 절차</td>
|
||||
<td><code>setup_mfa.php</code>에서 Base32 키와 <code>otpauth://</code> URI 제공</td>
|
||||
<td>Cafe24 웹호스팅 환경에서도 별도 Google API 없이 인증 앱 등록 가능</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>임시 등록 토큰</td>
|
||||
<td><code>MFA_SETUP_TOKEN</code>으로 최초 등록 화면 접근 제한</td>
|
||||
<td>MFA 설정 페이지의 임의 접근 및 비인가 등록 시도 방지</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>감사 로그</td>
|
||||
<td>로그인 성공/실패 및 MFA 등록 검증 이벤트 기록</td>
|
||||
<td>비인가 접근 시도 추적 및 운영 이력 증빙 가능</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>보존기간/백업 증빙</td>
|
||||
<td>보존기간 정리 스크립트와 백업 증빙 생성 스크립트 추가</td>
|
||||
<td>운영 로그 최소 보관, 복구 가능성 및 운영 증빙 확보</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>3. 실제 화면 캡처</h2>
|
||||
<p>
|
||||
아래 화면은 로컬 증빙용 임시 토큰과 임시 TOTP 키를 사용하여 렌더링한
|
||||
Google Authenticator 호환 MFA 등록 화면이다. 운영 환경에서는 실제 운영 비밀키를 별도로 생성하여 사용한다.
|
||||
</p>
|
||||
<img class="evidence-image" src="mfa_setup_page_capture.png" alt="Google Authenticator MFA 등록 화면 캡처">
|
||||
<p class="note">
|
||||
캡처 파일: <code>docs/evidence/mfa_setup_page_capture.png</code><br>
|
||||
증빙용 URL: <code>/setup_mfa.php?token=증빙용임시토큰</code>
|
||||
</p>
|
||||
|
||||
<h2>4. 보안 흐름도</h2>
|
||||
<div class="diagram" aria-label="관리자 MFA 보안 흐름도">
|
||||
<svg viewBox="0 0 920 420" xmlns="http://www.w3.org/2000/svg" role="img">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#2f5597"/>
|
||||
</marker>
|
||||
<style>
|
||||
.box { fill: #f8fbff; stroke: #2f5597; stroke-width: 1.5; rx: 8; }
|
||||
.secure { fill: #eefaf1; stroke: #1f7a3f; stroke-width: 1.5; rx: 8; }
|
||||
.deny { fill: #fff1f2; stroke: #be123c; stroke-width: 1.5; rx: 8; }
|
||||
.text { font: 14px "Malgun Gothic", Arial, sans-serif; fill: #172033; }
|
||||
.title { font: 700 15px "Malgun Gothic", Arial, sans-serif; fill: #172033; }
|
||||
.line { stroke: #2f5597; stroke-width: 1.7; marker-end: url(#arrow); fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="box" x="36" y="42" width="160" height="78"/>
|
||||
<text class="title" x="116" y="74" text-anchor="middle">관리자</text>
|
||||
<text class="text" x="116" y="98" text-anchor="middle">ID/PW 입력</text>
|
||||
|
||||
<rect class="box" x="260" y="42" width="170" height="78"/>
|
||||
<text class="title" x="345" y="72" text-anchor="middle">비밀번호 검증</text>
|
||||
<text class="text" x="345" y="96" text-anchor="middle">bcrypt 해시 확인</text>
|
||||
|
||||
<rect class="secure" x="494" y="42" width="190" height="78"/>
|
||||
<text class="title" x="589" y="72" text-anchor="middle">TOTP MFA 검증</text>
|
||||
<text class="text" x="589" y="96" text-anchor="middle">6자리 일회용 코드</text>
|
||||
|
||||
<rect class="secure" x="748" y="42" width="142" height="78"/>
|
||||
<text class="title" x="819" y="72" text-anchor="middle">관리자 세션</text>
|
||||
<text class="text" x="819" y="96" text-anchor="middle">접근 허용</text>
|
||||
|
||||
<rect class="box" x="36" y="208" width="200" height="86"/>
|
||||
<text class="title" x="136" y="238" text-anchor="middle">MFA 최초 등록</text>
|
||||
<text class="text" x="136" y="263" text-anchor="middle">MFA_SETUP_TOKEN</text>
|
||||
<text class="text" x="136" y="283" text-anchor="middle">임시 접근</text>
|
||||
|
||||
<rect class="secure" x="320" y="208" width="220" height="86"/>
|
||||
<text class="title" x="430" y="238" text-anchor="middle">인증 앱 등록</text>
|
||||
<text class="text" x="430" y="263" text-anchor="middle">Base32 키 / otpauth URI</text>
|
||||
<text class="text" x="430" y="283" text-anchor="middle">외부 API 전송 없음</text>
|
||||
|
||||
<rect class="secure" x="624" y="208" width="206" height="86"/>
|
||||
<text class="title" x="727" y="238" text-anchor="middle">운영 설정 반영</text>
|
||||
<text class="text" x="727" y="263" text-anchor="middle">ADMIN_TOTP_SECRET</text>
|
||||
<text class="text" x="727" y="283" text-anchor="middle">등록 토큰 제거</text>
|
||||
|
||||
<rect class="deny" x="178" y="342" width="240" height="52"/>
|
||||
<text class="title" x="298" y="373" text-anchor="middle">비인가 접근 차단</text>
|
||||
|
||||
<rect class="secure" x="504" y="342" width="240" height="52"/>
|
||||
<text class="title" x="624" y="373" text-anchor="middle">감사 로그 및 보존기간 관리</text>
|
||||
|
||||
<path class="line" d="M196 81 H260"/>
|
||||
<path class="line" d="M430 81 H494"/>
|
||||
<path class="line" d="M684 81 H748"/>
|
||||
<path class="line" d="M236 251 H320"/>
|
||||
<path class="line" d="M540 251 H624"/>
|
||||
<path class="line" d="M819 120 C819 170 760 178 727 208"/>
|
||||
<path class="line" d="M819 120 C819 324 700 326 624 342"/>
|
||||
<path class="line" d="M819 120 C819 324 410 326 298 342"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2>5. Mermaid 원문</h2>
|
||||
<p>보안대책서 또는 별도 Markdown 문서에서 재렌더링할 수 있도록 Mermaid 원문을 함께 첨부한다.</p>
|
||||
<pre>flowchart LR
|
||||
A[관리자 ID/PW 입력] --> B[비밀번호 bcrypt 검증]
|
||||
B --> C{TOTP MFA 코드 검증}
|
||||
C -- 성공 --> D[관리자 세션 발급]
|
||||
C -- 실패 --> E[로그인 차단 및 감사 로그 기록]
|
||||
|
||||
F[MFA 최초 등록] -- MFA_SETUP_TOKEN 임시 접근 --> G[setup_mfa.php]
|
||||
G -- Base32 키 / otpauth URI 제공 --> H[Google Authenticator 등록]
|
||||
H -- 6자리 코드 검증 --> I[ADMIN_TOTP_SECRET 운영 설정 반영]
|
||||
I -- 등록 후 --> J[MFA_SETUP_TOKEN 삭제 또는 빈 값 처리]
|
||||
|
||||
D -- 운영 이력 --> M[관리자 감사 로그]
|
||||
D -- 보존기간 정책 --> N[로그 정리 및 백업 증빙]</pre>
|
||||
|
||||
<h2>6. 검증 결과</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>검증 항목</th>
|
||||
<th>결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>MFA 등록 화면 실제 렌더링</td>
|
||||
<td>Chrome headless 캡처 완료: <code>mfa_setup_page_capture.png</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>보안 하드닝 회귀 테스트</td>
|
||||
<td><code>php tests\security_hardening_test.php</code> 통과</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PHP 문법 검사</td>
|
||||
<td><code>admin_security.php</code>, <code>setup_mfa.php</code>, <code>config.php</code>, <code>config.local.example.php</code> 통과</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>외부 비밀키 전송 여부</td>
|
||||
<td>외부 Google API 또는 외부 QR 생성 API 미사용. 서버 내부 TOTP 검증만 수행</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/evidence/security_plan_mfa_evidence_preview.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
165
docs/examples/admin_dashboard_capture_source.html
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 대시보드 캡처 소스</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: "Noto Sans KR", "Malgun Gothic", sans-serif; background: #f0f4f8; color: #243044; overflow-x: hidden; }
|
||||
header { background: #1a56db; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; gap: 14px; }
|
||||
header h1 { font-size: 1.26rem; letter-spacing: 0; }
|
||||
.top-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.top-actions span { background: rgba(255,255,255,.15); border-radius: 6px; padding: 7px 12px; font-size: .84rem; font-weight: 700; }
|
||||
.container { max-width: 1120px; margin: 24px auto; padding: 0 16px 40px; }
|
||||
.hero { border-radius: 8px; color: #fff; padding: 20px 22px; margin-bottom: 18px; display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: center; box-shadow: 0 4px 18px rgba(15,23,42,.14); background: #166534; }
|
||||
.hero h2 { font-size: 1.3rem; margin-bottom: 4px; }
|
||||
.hero p { color: rgba(255,255,255,.86); font-size: .9rem; overflow-wrap: anywhere; }
|
||||
.clock { text-align: right; color: rgba(255,255,255,.78); font-size: .82rem; white-space: nowrap; }
|
||||
.quick { display: none; }
|
||||
.ops { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(280px, .85fr); gap: 16px; margin-bottom: 22px; }
|
||||
.panel { background: #fff; border-radius: 8px; padding: 18px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.panel h2 { color: #475569; font-size: 1rem; margin-bottom: 12px; }
|
||||
.trend { display: grid; gap: 12px; }
|
||||
.trend-row { display: grid; grid-template-columns: 120px 1fr; gap: 12px; align-items: center; font-size: .84rem; font-weight: 800; color: #334155; }
|
||||
.bar { height: 14px; border-radius: 999px; display: block; }
|
||||
.bar.temp { background: #fb7185; } .bar.hum { background: #38bdf8; }
|
||||
.trend-note { color: #64748b; font-size: .76rem; line-height: 1.4; margin-top: 2px; }
|
||||
.diag-list { display: grid; gap: 8px; }
|
||||
.diag { border: 1px solid #e2e8f0; border-radius: 7px; padding: 9px 10px; display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: start; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; background: #22c55e; }
|
||||
.diag.warn .dot { background: #f59e0b; }
|
||||
.diag strong { display: block; font-size: .82rem; }
|
||||
.diag span { color: #64748b; font-size: .74rem; line-height: 1.35; }
|
||||
.cards { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
.card span { display: block; color: #64748b; font-size: .78rem; margin-bottom: 5px; }
|
||||
.card strong { font-size: 1.6rem; }
|
||||
.sensors { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.sensor { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.1); border-left: 6px solid #22c55e; }
|
||||
.sensor.offline { border-left-color: #f59e0b; background: #fffbeb; }
|
||||
.sensor h3 { font-size: 1rem; margin-bottom: 4px; }
|
||||
.sensor small { color: #64748b; }
|
||||
.badge { display: inline-block; margin-top: 10px; border-radius: 999px; padding: 3px 10px; background: #dcfce7; color: #166534; font-size: .76rem; font-weight: 800; }
|
||||
.badge.warn { background: #fef3c7; color: #92400e; }
|
||||
.metric { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.metric span { border-radius: 999px; background: #eef2ff; color: #3730a3; padding: 5px 9px; font-size: .8rem; font-weight: 900; }
|
||||
.last { margin-top: 7px; color: #94a3b8; font-size: .76rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
th { background: #f8fafc; font-size: .8rem; color: #64748b; padding: 10px 14px; text-align: left; border-bottom: 1px solid #e2e8f0; }
|
||||
td { padding: 9px 14px; border-bottom: 1px solid #f1f5f9; font-size: .85rem; }
|
||||
.ok { color: #16a34a; font-weight: 800; } .err { color: #dc2626; font-weight: 800; }
|
||||
.section-title { color: #475569; font-size: 1rem; margin: 24px 0 12px; }
|
||||
.mobile-links { display: none; }
|
||||
@media (max-width: 760px) {
|
||||
header { display: block; padding: 13px 16px; }
|
||||
header h1 { font-size: 1.05rem; }
|
||||
.top-actions { margin-top: 10px; overflow-x: auto; flex-wrap: nowrap; padding-bottom: 2px; }
|
||||
.top-actions span { flex: 0 0 auto; padding: 7px 10px; }
|
||||
.container { margin: 16px auto; padding: 0 10px 32px; }
|
||||
.hero { grid-template-columns: 1fr; padding: 16px; margin-bottom: 12px; }
|
||||
.hero p { word-break: break-all; max-width: 310px; }
|
||||
.clock { text-align: left; }
|
||||
.quick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.quick .card { min-height: 78px; padding: 12px; }
|
||||
.quick .card strong { font-size: 1.3rem; }
|
||||
.mobile-links { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 12px; }
|
||||
.mobile-links span { flex: 0 0 auto; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 999px; padding: 8px 11px; font-size: .78rem; font-weight: 800; }
|
||||
.ops { grid-template-columns: 1fr; }
|
||||
.cards { display: none; }
|
||||
.sensors { grid-template-columns: 1fr; gap: 10px; }
|
||||
.sensor { padding: 16px; }
|
||||
.panel { padding: 14px; }
|
||||
.trend-row { grid-template-columns: 100px 1fr; }
|
||||
.capture-desktop-only { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>온습도 모니터링 관리자 대시보드 v2605</h1>
|
||||
<div class="top-actions">
|
||||
<span>월간 보고서</span>
|
||||
<span>보안 증적</span>
|
||||
<span>로그아웃</span>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ※ 캡처 이미지(captures/*.png)는 SHT30 온습도 화면으로 재촬영 필요 -->
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h2>정상 감시 중</h2>
|
||||
<p>현재 임계 경보와 중요 운영 경고가 없습니다. 1번과 2번 센서가 온라인입니다.</p>
|
||||
</div>
|
||||
<div class="clock">2026-05-21 09:30:00<br>30초 자동 새로고침</div>
|
||||
</section>
|
||||
|
||||
<div class="quick">
|
||||
<div class="card"><span>임계 경보</span><strong class="ok">0건</strong></div>
|
||||
<div class="card"><span>오프라인</span><strong class="ok">0대</strong></div>
|
||||
<div class="card"><span>평균 온도</span><strong>24.3C</strong></div>
|
||||
<div class="card"><span>평균 습도</span><strong>48%</strong></div>
|
||||
</div>
|
||||
<div class="mobile-links"><span>센서 상태</span><span>온습도 추이</span><span>임계 SMS</span><span>로그</span><span>SMS</span></div>
|
||||
|
||||
<div class="ops">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<h2>온습도 추이 (24시간)</h2>
|
||||
<div class="trend">
|
||||
<div class="trend-row"><span>온도 24.3C</span><i class="bar temp" style="width:62%"></i></div>
|
||||
<div class="trend-row"><span>습도 48.1%</span><i class="bar hum" style="width:48%"></i></div>
|
||||
<p class="trend-note">임계: 고온 30C / 저온 10C / 고습 70% / 저습 20%. 현재 모든 값이 정상 범위입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="panel">
|
||||
<h2>운영 자가진단</h2>
|
||||
<div class="diag-list">
|
||||
<div class="diag"><i class="dot"></i><div><strong>API 키 · 정상</strong><span>RPi와 PHP API_KEY 일치</span></div></div>
|
||||
<div class="diag"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>sensor_metric 저장 가능</span></div></div>
|
||||
<div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="cards capture-desktop-only">
|
||||
<div class="card"><span>등록 센서</span><strong>2대</strong></div>
|
||||
<div class="card"><span>임계 경보</span><strong class="ok">0건</strong></div>
|
||||
<div class="card"><span>오프라인</span><strong class="ok">0대</strong></div>
|
||||
<div class="card"><span>30일 임계 SMS</span><strong>3건</strong></div>
|
||||
<div class="card"><span>SMS 실패</span><strong>1건</strong></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">센서 현재 상태</h2>
|
||||
<div class="sensors" id="sensors">
|
||||
<article class="sensor">
|
||||
<h3>1번 센서 (SHT30 온습도)</h3>
|
||||
<small>서버실 하부 랙 / rpi-sht30-02</small><br>
|
||||
<span class="badge">정상</span>
|
||||
<div class="metric"><span>온도 23.8C</span><span>습도 46.5%</span></div>
|
||||
<div class="last">측정: 2026-05-21 09:29:42</div>
|
||||
</article>
|
||||
<article class="sensor">
|
||||
<h3>2번 센서 (SHT30 온습도)</h3>
|
||||
<small>서버실 상단 랙 / rpi-sht30-01</small><br>
|
||||
<span class="badge">정상</span>
|
||||
<div class="metric"><span>온도 24.3C</span><span>습도 48.1%</span></div>
|
||||
<div class="last">측정: 2026-05-21 09:29:18</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">최근 임계 경보 SMS 이력</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>#</th><th>시각</th><th>센서</th><th>이벤트</th><th>측정값</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>224</td><td>09:29:18</td><td>2번 센서</td><td>정기보고</td><td>24.3C / 48.1%</td><td><span class="ok">정상</span></td></tr>
|
||||
<tr><td>223</td><td>09:28:42</td><td>1번 센서</td><td>정기보고</td><td>23.8C / 46.5%</td><td><span class="ok">정상</span></td></tr>
|
||||
<tr><td>218</td><td>03:14:05</td><td>2번 센서</td><td>고온 임계 SMS</td><td>31.2C / 41.0%</td><td><span class="err">고온</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
422
docs/examples/admin_dashboard_user_manual.html
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 대시보드 사용설명서</title>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #1d4ed8;
|
||||
--blue-dark: #123c7c;
|
||||
--green: #166534;
|
||||
--red: #991b1b;
|
||||
--amber: #92400e;
|
||||
--ink: #172033;
|
||||
--muted: #64748b;
|
||||
--line: #d9e2ef;
|
||||
--paper: #fff;
|
||||
--page: #f4f7fb;
|
||||
--soft-blue: #eff6ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--page);
|
||||
line-height: 1.68;
|
||||
}
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 4px solid #0f766e;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2.7vw, 2.2rem);
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.subtitle {
|
||||
color: #dbeafe;
|
||||
margin-top: 7px;
|
||||
font-size: .94rem;
|
||||
}
|
||||
.print-btn {
|
||||
border: 1px solid rgba(255,255,255,.32);
|
||||
background: rgba(255,255,255,.13);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 56px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, .9fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.panel h2, section h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.14rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.panel p, section p { margin: 0 0 10px; color: #334155; }
|
||||
.toc {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 3px 0 18px;
|
||||
}
|
||||
.toc a {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: var(--soft-blue);
|
||||
color: var(--blue);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: .84rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
section {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
h3 {
|
||||
margin: 18px 0 8px;
|
||||
color: #334155;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.shot {
|
||||
margin: 14px 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.shot img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.shot figcaption {
|
||||
padding: 9px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
background: #fbfdff;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tile {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
padding: 14px;
|
||||
}
|
||||
.tile strong { display: block; margin-bottom: 5px; }
|
||||
.tile span { color: var(--muted); font-size: .9rem; }
|
||||
.state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: .78rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
.state.ok { background: #dcfce7; color: var(--green); }
|
||||
.state.warn { background: #fef3c7; color: var(--amber); }
|
||||
.state.danger { background: #fee2e2; color: var(--red); }
|
||||
.notice {
|
||||
border-left: 5px solid var(--blue);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--soft-blue);
|
||||
color: #1e3a8a;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.notice.warn { border-left-color: #f59e0b; background: #fffbeb; color: #713f12; }
|
||||
.notice.danger { border-left-color: #ef4444; background: #fef2f2; color: #7f1d1d; }
|
||||
.step-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
counter-reset: step;
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.step::before {
|
||||
counter-increment: step;
|
||||
content: counter(step);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
}
|
||||
.step p { margin: 0; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: .9rem;
|
||||
}
|
||||
th { background: #f8fafc; color: #475569; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
code {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
background: #eef2ff;
|
||||
color: #312e81;
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
font-size: .88em;
|
||||
}
|
||||
.daily {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.check {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
.check strong { display: block; margin-bottom: 4px; }
|
||||
.check span { color: var(--muted); font-size: .88rem; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.header-inner { align-items: flex-start; flex-direction: column; }
|
||||
.summary { grid-template-columns: 1fr; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.daily { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
header { padding: 18px 16px; }
|
||||
main { padding: 16px 10px 42px; }
|
||||
section, .panel { padding: 16px; }
|
||||
th, td { font-size: .82rem; padding: 8px; }
|
||||
.step { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.step::before { width: 30px; height: 30px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
header, .toc, .print-btn { display: none; }
|
||||
main { max-width: none; padding: 0; }
|
||||
section, .panel { break-inside: avoid; border-color: #cbd5e1; }
|
||||
.shot { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<div>
|
||||
<h1>관리자 대시보드 사용설명서</h1>
|
||||
<div class="subtitle">SHT30 온습도 모니터링, 임계 경보 SMS, 1번·2번 센서 상태 확인용</div>
|
||||
</div>
|
||||
<button class="print-btn" type="button" onclick="window.print()">인쇄 / PDF</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="summary">
|
||||
<article class="panel">
|
||||
<h2>문서 목적</h2>
|
||||
<p>관리자가 대시보드에 접속했을 때 가장 먼저 무엇을 봐야 하는지, 임계 경보가 발생했을 때 어떤 순서로 확인해야 하는지, 1번/2번 센서의 온습도 상태를 어떻게 판독해야 하는지 설명한다.</p>
|
||||
<p>이 문서의 캡처는 운영 대시보드와 동일한 구성으로 만든 예시 화면이다. 실제 숫자, 시간, 측정값은 운영 DB 데이터에 따라 달라진다.</p>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>관리자가 매일 확인할 것</h2>
|
||||
<div class="daily">
|
||||
<div class="check"><strong>임계 경보</strong><span>0건인지 확인. 1건 이상이면 해당 센서의 온습도와 임계 초과 항목을 확인.</span></div>
|
||||
<div class="check"><strong>오프라인</strong><span>0대인지 확인. 오프라인이면 Pi 전원/네트워크/API 점검.</span></div>
|
||||
<div class="check"><strong>센서 온습도</strong><span>1번·2번 센서의 온도/습도와 최신 측정 시간이 갱신되는지 확인.</span></div>
|
||||
<div class="check"><strong>운영 자가진단</strong><span>API 키, DB 테이블, SMS 실패 경고를 확인.</span></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<nav class="toc" aria-label="문서 목차">
|
||||
<a href="#login">접속</a>
|
||||
<a href="#overview">첫 화면</a>
|
||||
<a href="#sensors">센서 상태</a>
|
||||
<a href="#alerts">임계 경보</a>
|
||||
<a href="#logs">추이/로그</a>
|
||||
<a href="#mobile">모바일</a>
|
||||
<a href="#trouble">이상 대처</a>
|
||||
</nav>
|
||||
|
||||
<section id="login">
|
||||
<h2>1. 접속과 로그인</h2>
|
||||
<div class="step-list">
|
||||
<div class="step"><p>브라우저에서 운영 URL의 <code>dashboard.php</code>로 접속한다. 반드시 HTTPS 주소를 사용한다.</p></div>
|
||||
<div class="step"><p>관리자 계정으로 로그인한다. 공용 PC에서는 저장된 비밀번호를 사용하지 않는다.</p></div>
|
||||
<div class="step"><p>작업 완료 후 우측 상단 <code>로그아웃</code>을 눌러 세션을 종료한다.</p></div>
|
||||
</div>
|
||||
<div class="notice warn">로그인이 반복해서 풀리면 서버 시간, 세션 저장 경로, 브라우저 쿠키 차단 여부를 확인한다.</div>
|
||||
</section>
|
||||
|
||||
<section id="overview">
|
||||
<h2>2. 첫 화면에서 보는 순서</h2>
|
||||
<figure class="shot">
|
||||
<img src="captures/admin_dashboard_overview.png" alt="관리자 대시보드 데스크톱 전체 화면 캡처">
|
||||
<figcaption>데스크톱 대시보드 예시 캡처. 상단 상태바, 온습도 추이, 자가진단, 요약 카드, 센서 카드 순서로 확인한다. ※ 캡처 이미지는 SHT30 화면으로 재촬영 필요.</figcaption>
|
||||
</figure>
|
||||
<div class="grid">
|
||||
<div class="tile"><strong>상단 상태바</strong><span><span class="state ok">정상</span>이면 운영 경고 없음. <span class="state warn">주의</span>나 <span class="state danger">위험</span>이면 바로 하단 세부 항목을 확인한다.</span></div>
|
||||
<div class="tile"><strong>요약 카드</strong><span>등록 센서, 임계 경보, 오프라인, 30일 임계 SMS, SMS 실패를 빠르게 확인한다.</span></div>
|
||||
<div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sensors">
|
||||
<h2>3. 센서 상태 판독</h2>
|
||||
<p>센서 카드는 현장의 장비별 온습도 상태를 보여준다. 정상/임계 경보/오프라인 우선순위로 색상이 바뀐다.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>센서</th><th>관리자가 확인할 내용</th><th>주의 기준</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1번 센서</td>
|
||||
<td>SHT30 온습도 장비. 온도, 습도, 측정 시간이 표시된다.</td>
|
||||
<td>온습도 값이 사라짐, 측정 시간이 갱신되지 않음, 임계 초과, 오프라인 표시.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2번 센서</td>
|
||||
<td>SHT30 온습도 장비. 온도, 습도, 측정 시간이 표시된다.</td>
|
||||
<td>온습도 값이 사라짐, 측정 시간이 갱신되지 않음, 임계 초과, 오프라인 표시.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="notice">센서가 정상이라면 카드 안에 <code>온도 24.3C</code>, <code>습도 48.1%</code> 같은 최신값이 표시된다. 임계(고온 30C / 저온 10C / 고습 70% / 저습 20%)를 넘으면 경보 SMS가 발송된다.</div>
|
||||
</section>
|
||||
|
||||
<section id="alerts">
|
||||
<h2>4. 임계 경보 확인 순서</h2>
|
||||
<figure class="shot">
|
||||
<img src="captures/admin_dashboard_overview.png" alt="온습도 추이와 임계 경보 영역 캡처">
|
||||
<figcaption>온습도 추이 영역 예시 캡처. 임계(고온/저온/고습/저습)를 넘으면 경보 SMS가 발송되고 추이 차트와 임계 SMS 이력에 표시된다. ※ 캡처 이미지는 SHT30 화면으로 재촬영 필요.</figcaption>
|
||||
</figure>
|
||||
<div class="step-list">
|
||||
<div class="step"><p><strong>임계 경보</strong> 건수가 1건 이상이면 해당 센서의 온도/습도와 초과 항목을 먼저 확인한다.</p></div>
|
||||
<div class="step"><p>온습도 추이 차트에서 값이 임계선을 넘은 시점과 지속 시간을 확인한다.</p></div>
|
||||
<div class="step"><p>임계 경보 SMS 이력에서 고온/저온/고습/저습 중 어떤 종류의 경보가 발송됐는지 확인한다.</p></div>
|
||||
<div class="step"><p>값이 임계 안으로 돌아오면 <code>정상복귀</code> SMS가 발송된다. 복귀 여부를 이력에서 확인한다.</p></div>
|
||||
</div>
|
||||
<div class="notice danger">임계 경보가 반복되면 서버실 공조/환기 상태와 센서 설치 위치를 점검한다. 오프라인 경보가 함께 뜨면 Pi 전원/네트워크부터 확인한다.</div>
|
||||
</section>
|
||||
|
||||
<section id="logs">
|
||||
<h2>5. 온습도 추이, 로그, SMS, 월간 보고서</h2>
|
||||
<div class="grid">
|
||||
<div class="tile"><strong>온습도 추이 차트</strong><span>센서별 온도/습도 시계열과 임계선을 확인한다. 임계 초과 구간이 강조된다.</span></div>
|
||||
<div class="tile"><strong>최근 이벤트 로그</strong><span>정기보고, 임계 경보, 정상복귀, 오프라인, 장비 시작 이벤트를 시간순으로 확인한다.</span></div>
|
||||
<div class="tile"><strong>SMS 발송 이력</strong><span>임계 경보 문자 성공/실패, 수신자, 결과 메시지를 확인한다.</span></div>
|
||||
</div>
|
||||
<h3>월간 보고서</h3>
|
||||
<p>상단 <code>월간 보고서</code>에서 월별 종류별 임계 경보 SMS 건수, 정상복귀/오프라인/복구 건수, 평균/최저/최고 온습도, CSV 다운로드를 확인한다. 대외 제출용은 인쇄/PDF 저장으로 보관한다.</p>
|
||||
</section>
|
||||
|
||||
<section id="mobile">
|
||||
<h2>6. 모바일에서 빠르게 확인하기</h2>
|
||||
<figure class="shot" style="max-width:430px">
|
||||
<img src="captures/admin_dashboard_mobile.png" alt="관리자 대시보드 모바일 화면 캡처">
|
||||
<figcaption>모바일 예시 캡처. 상단 요약 카드와 빠른 링크를 먼저 보고 필요한 섹션으로 이동한다. ※ 캡처 이미지는 SHT30 화면으로 재촬영 필요.</figcaption>
|
||||
</figure>
|
||||
<p>모바일에서는 표와 상세 로그가 접히거나 가로 스크롤로 보일 수 있다. 긴급 상황에서는 <code>임계 경보</code>, <code>오프라인</code>, <code>평균 온도</code>, <code>평균 습도</code> 네 항목을 먼저 확인한다.</p>
|
||||
</section>
|
||||
|
||||
<section id="trouble">
|
||||
<h2>7. 이상 상태별 조치</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>화면 표시</th><th>의미</th><th>관리자 조치</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="state danger">임계 경보 1건 이상</span></td>
|
||||
<td>온도 또는 습도가 임계(고온 30C / 저온 10C / 고습 70% / 저습 20%)를 넘었다.</td>
|
||||
<td>온습도 추이와 초과 항목 확인 → 서버실 공조/환기 점검 → 정상복귀 SMS 수신 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="state warn">오프라인 1대 이상</span></td>
|
||||
<td>라즈베리파이가 heartbeat 시간 안에 보고하지 않았다.</td>
|
||||
<td>전원, 네트워크, systemd 서비스, API URL/API_KEY 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="state warn">센서 온습도 미표시</span></td>
|
||||
<td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td>
|
||||
<td><code>sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="state warn">SMS 실패</span></td>
|
||||
<td>Cafe24 SMS 연동 또는 수신자 설정 문제가 있다.</td>
|
||||
<td>SMS 계정, 잔액, 발신번호, 수신자 번호, 서버 외부 통신 가능 여부 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="state danger">API 키 경고</span></td>
|
||||
<td>운영 키가 기본값이거나 Pi와 서버 키가 다를 수 있다.</td>
|
||||
<td>서버 <code>config.local.php</code>와 Pi 환경파일의 <code>LEAK_API_KEY</code>를 동일하게 맞춘다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
47
docs/examples/assets/sht30_sensor2_dashboard.svg
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 520" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Dashboard preview with sensor2 temperature and humidity</title>
|
||||
<desc id="desc">Monitoring dashboard card showing sensor2 status and latest temperature and humidity values.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#0f172a" flood-opacity=".14"/>
|
||||
</filter>
|
||||
<style>
|
||||
.h { font-family: Segoe UI, Arial, sans-serif; fill: #0f172a; font-size: 24px; font-weight: 800; }
|
||||
.b { font-family: Segoe UI, Arial, sans-serif; fill: #475569; font-size: 15px; font-weight: 650; }
|
||||
.s { font-family: Segoe UI, Arial, sans-serif; fill: #64748b; font-size: 13px; font-weight: 650; }
|
||||
.v { font-family: Segoe UI, Arial, sans-serif; fill: #3730a3; font-size: 30px; font-weight: 900; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1000" height="520" fill="#f0f4f8"/>
|
||||
<rect x="0" y="0" width="1000" height="76" fill="#1a56db"/>
|
||||
<rect x="46" y="26" width="210" height="22" rx="5" fill="#ffffff" opacity=".92"/>
|
||||
<rect x="758" y="24" width="78" height="28" rx="6" fill="#ffffff" opacity=".18"/>
|
||||
<rect x="850" y="24" width="104" height="28" rx="6" fill="#ffffff" opacity=".18"/>
|
||||
|
||||
<rect x="46" y="116" width="908" height="112" rx="8" fill="#166534" filter="url(#shadow)"/>
|
||||
<text x="78" y="161" class="h" fill="#ffffff">Normal monitoring</text>
|
||||
<text x="78" y="192" class="b" fill="#dcfce7">No threshold alerts. Sensor2 environmental values are reporting.</text>
|
||||
<rect x="736" y="146" width="166" height="42" rx="21" fill="#ffffff" opacity=".16"/>
|
||||
|
||||
<text x="46" y="280" class="h">Sensor status</text>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="46" y="308" width="280" height="142" rx="8" fill="#ffffff"/>
|
||||
<rect x="46" y="308" width="7" height="142" rx="3" fill="#22c55e"/>
|
||||
<text x="76" y="346" class="h" font-size="18">Sensor 1 SHT30</text>
|
||||
<text x="76" y="374" class="s">temperature + humidity sensor</text>
|
||||
<rect x="76" y="398" width="82" height="26" rx="13" fill="#dcfce7"/>
|
||||
<text x="96" y="416" class="s" fill="#166534">OK</text>
|
||||
</g>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="360" y="308" width="594" height="142" rx="8" fill="#ffffff"/>
|
||||
<rect x="360" y="308" width="7" height="142" rx="3" fill="#22c55e"/>
|
||||
<text x="390" y="346" class="h" font-size="18">Sensor 2 SHT30</text>
|
||||
<text x="390" y="374" class="s">rpi-sht30-01 / I2C address 0x44</text>
|
||||
<rect x="390" y="398" width="82" height="26" rx="13" fill="#dcfce7"/>
|
||||
<text x="410" y="416" class="s" fill="#166534">OK</text>
|
||||
<rect x="536" y="390" width="150" height="42" rx="21" fill="#eef2ff"/>
|
||||
<text x="558" y="420" class="v" font-size="24">24.3 C</text>
|
||||
<rect x="704" y="390" width="150" height="42" rx="21" fill="#eef2ff"/>
|
||||
<text x="730" y="420" class="v" font-size="24">48.1%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
59
docs/examples/assets/sht30_sensor2_data_flow.svg
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 430" role="img" aria-labelledby="title desc">
|
||||
<title id="title">SHT30 sensor2 data flow</title>
|
||||
<desc id="desc">SHT30 sends readings through the Raspberry Pi to the PHP API, database, and dashboard.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#2563eb"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="9" flood-color="#0f172a" flood-opacity=".12"/>
|
||||
</filter>
|
||||
<style>
|
||||
.title { font-family: Segoe UI, Arial, sans-serif; fill: #0f172a; font-size: 22px; font-weight: 800; }
|
||||
.body { font-family: Segoe UI, Arial, sans-serif; fill: #475569; font-size: 14px; font-weight: 650; }
|
||||
.code { font-family: Consolas, Menlo, monospace; fill: #0f172a; font-size: 14px; font-weight: 700; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1000" height="430" fill="#f8fafc"/>
|
||||
<text x="48" y="58" class="title">Sensor2 telemetry path</text>
|
||||
<text x="48" y="83" class="body">Status heartbeat stays in sensor_status. Temperature and humidity history goes to sensor_metric.</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="54" y="142" width="184" height="136" rx="14" fill="#ffffff"/>
|
||||
<rect x="82" y="172" width="60" height="52" rx="10" fill="#ccfbf1"/>
|
||||
<circle cx="176" cy="198" r="28" fill="#14b8a6"/>
|
||||
</g>
|
||||
<text x="78" y="320" class="title" font-size="18">SHT30</text>
|
||||
<text x="78" y="344" class="body">temperature / humidity</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="300" y="142" width="184" height="136" rx="14" fill="#ffffff"/>
|
||||
<rect x="330" y="170" width="92" height="72" rx="12" fill="#1f2937"/>
|
||||
<rect x="434" y="164" width="28" height="84" rx="8" fill="#e5e7eb"/>
|
||||
</g>
|
||||
<text x="318" y="320" class="title" font-size="18">Pi sensor2</text>
|
||||
<text x="318" y="344" class="code">sht30_monitor.py</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="548" y="142" width="184" height="136" rx="14" fill="#ffffff"/>
|
||||
<rect x="588" y="172" width="104" height="72" rx="12" fill="#dbeafe"/>
|
||||
<path d="M610 194 h60 M610 212 h60 M610 230 h42" stroke="#2563eb" stroke-width="7" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="566" y="320" class="title" font-size="18">PHP API</text>
|
||||
<text x="566" y="344" class="code">sensor_data.php</text>
|
||||
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="796" y="118" width="154" height="78" rx="14" fill="#ffffff"/>
|
||||
<rect x="830" y="138" width="86" height="14" rx="7" fill="#94a3b8"/>
|
||||
<rect x="830" y="162" width="86" height="14" rx="7" fill="#94a3b8"/>
|
||||
<rect x="796" y="226" width="154" height="78" rx="14" fill="#ffffff"/>
|
||||
<rect x="820" y="248" width="108" height="16" rx="8" fill="#bfdbfe"/>
|
||||
<rect x="820" y="274" width="76" height="12" rx="6" fill="#93c5fd"/>
|
||||
</g>
|
||||
<text x="804" y="332" class="title" font-size="18">DB + Dashboard</text>
|
||||
<text x="804" y="356" class="code">sensor_metric</text>
|
||||
|
||||
<path d="M240 210 H288" stroke="#2563eb" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M486 210 H536" stroke="#2563eb" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
<path d="M734 210 H784" stroke="#2563eb" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
73
docs/examples/assets/sht30_sensor2_wiring.svg
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 600" role="img" aria-labelledby="title desc">
|
||||
<title id="title">SHT30 sensor2 wiring diagram</title>
|
||||
<desc id="desc">Raspberry Pi 40 pin header connected to SHT30 VCC, GND, SDA, and SCL pins.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#0f172a" flood-opacity=".14"/>
|
||||
</filter>
|
||||
<style>
|
||||
.label { font-family: Segoe UI, Arial, sans-serif; fill: #0f172a; font-size: 22px; font-weight: 700; }
|
||||
.sub { font-family: Segoe UI, Arial, sans-serif; fill: #475569; font-size: 15px; font-weight: 600; }
|
||||
.pin { font-family: Segoe UI, Arial, sans-serif; fill: #0f172a; font-size: 13px; font-weight: 700; }
|
||||
.small { font-family: Segoe UI, Arial, sans-serif; fill: #64748b; font-size: 12px; font-weight: 600; }
|
||||
</style>
|
||||
</defs>
|
||||
<rect width="1000" height="600" fill="#f8fafc"/>
|
||||
<rect x="52" y="62" width="380" height="476" rx="18" fill="#ffffff" filter="url(#shadow)"/>
|
||||
<rect x="86" y="108" width="256" height="344" rx="18" fill="#1f2937"/>
|
||||
<rect x="116" y="138" width="78" height="58" rx="8" fill="#334155"/>
|
||||
<rect x="222" y="138" width="84" height="58" rx="8" fill="#334155"/>
|
||||
<rect x="116" y="222" width="190" height="72" rx="10" fill="#111827"/>
|
||||
<circle cx="360" cy="124" r="18" fill="#94a3b8"/>
|
||||
<circle cx="360" cy="424" r="18" fill="#94a3b8"/>
|
||||
<text x="86" y="86" class="label">Raspberry Pi</text>
|
||||
<text x="86" y="470" class="sub">40-pin header, I2C bus 1</text>
|
||||
<g transform="translate(368 142)">
|
||||
<rect x="0" y="0" width="42" height="274" rx="12" fill="#e5e7eb"/>
|
||||
<g>
|
||||
<circle cx="14" cy="18" r="6" fill="#f97316"/><circle cx="28" cy="18" r="6" fill="#f97316"/>
|
||||
<circle cx="14" cy="44" r="6" fill="#22c55e"/><circle cx="28" cy="44" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="70" r="6" fill="#3b82f6"/><circle cx="28" cy="70" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="96" r="6" fill="#eab308"/><circle cx="28" cy="96" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="122" r="6" fill="#94a3b8"/><circle cx="28" cy="122" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="148" r="6" fill="#94a3b8"/><circle cx="28" cy="148" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="174" r="6" fill="#94a3b8"/><circle cx="28" cy="174" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="200" r="6" fill="#94a3b8"/><circle cx="28" cy="200" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="226" r="6" fill="#94a3b8"/><circle cx="28" cy="226" r="6" fill="#94a3b8"/>
|
||||
<circle cx="14" cy="252" r="6" fill="#94a3b8"/><circle cx="28" cy="252" r="6" fill="#94a3b8"/>
|
||||
</g>
|
||||
</g>
|
||||
<text x="324" y="164" class="pin">3V3</text>
|
||||
<text x="324" y="190" class="pin">SDA</text>
|
||||
<text x="324" y="216" class="pin">SCL</text>
|
||||
<text x="324" y="242" class="pin">GND</text>
|
||||
|
||||
<rect x="654" y="126" width="260" height="318" rx="18" fill="#0f766e" filter="url(#shadow)"/>
|
||||
<rect x="696" y="176" width="176" height="106" rx="12" fill="#ccfbf1" opacity=".95"/>
|
||||
<circle cx="724" cy="336" r="12" fill="#0f172a" opacity=".28"/>
|
||||
<circle cx="842" cy="336" r="12" fill="#0f172a" opacity=".28"/>
|
||||
<text x="684" y="96" class="label">SHT30 P4422-3</text>
|
||||
<text x="702" y="232" class="label" font-size="26">SHT30</text>
|
||||
<g>
|
||||
<rect x="626" y="160" width="72" height="30" rx="15" fill="#ffffff"/>
|
||||
<rect x="626" y="216" width="72" height="30" rx="15" fill="#ffffff"/>
|
||||
<rect x="626" y="272" width="72" height="30" rx="15" fill="#ffffff"/>
|
||||
<rect x="626" y="328" width="72" height="30" rx="15" fill="#ffffff"/>
|
||||
<text x="646" y="181" class="pin">VCC</text>
|
||||
<text x="648" y="237" class="pin">SDA</text>
|
||||
<text x="648" y="293" class="pin">SCL</text>
|
||||
<text x="648" y="349" class="pin">GND</text>
|
||||
</g>
|
||||
|
||||
<path d="M 396 160 C 490 120, 560 128, 626 175" fill="none" stroke="#f97316" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M 382 186 C 488 188, 560 196, 626 231" fill="none" stroke="#22c55e" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M 382 212 C 486 236, 560 252, 626 287" fill="none" stroke="#3b82f6" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M 382 238 C 486 294, 558 336, 626 343" fill="none" stroke="#111827" stroke-width="8" stroke-linecap="round"/>
|
||||
|
||||
<g transform="translate(72 504)">
|
||||
<circle cx="0" cy="0" r="6" fill="#f97316"/><text x="14" y="5" class="small">3.3V to VCC</text>
|
||||
<circle cx="142" cy="0" r="6" fill="#22c55e"/><text x="156" y="5" class="small">GPIO2 SDA to SDA</text>
|
||||
<circle cx="314" cy="0" r="6" fill="#3b82f6"/><text x="328" y="5" class="small">GPIO3 SCL to SCL</text>
|
||||
<circle cx="486" cy="0" r="6" fill="#111827"/><text x="500" y="5" class="small">GND to GND</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
BIN
docs/examples/captures/admin_dashboard_incident.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/examples/captures/admin_dashboard_mobile.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
docs/examples/captures/admin_dashboard_overview.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/examples/captures/admin_dashboard_user_manual_preview.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
135
docs/examples/monthly_report_2026-05_dummy.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>월간 보고서 - 2026-05</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
|
||||
header { background: #1a56db; color: #fff; padding: 16px 24px; display:flex; justify-content:space-between; align-items:center; gap:12px; }
|
||||
header h1 { margin:0; font-size:1.15rem; }
|
||||
header a { color:#fff; text-decoration:none; background:rgba(255,255,255,.15); padding:7px 12px; border-radius:6px; font-size:.85rem; }
|
||||
.container { max-width: 1060px; margin: 24px auto; padding: 0 16px; }
|
||||
.toolbar { background:#fff; border-radius:8px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.1); display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:18px; }
|
||||
input, button, .btn { border:1px solid #cbd5e1; border-radius:6px; padding:8px 10px; font-size:.9rem; }
|
||||
button, .btn { background:#1a56db; color:#fff; border-color:#1a56db; text-decoration:none; cursor:pointer; }
|
||||
.toolbar label { display:flex; align-items:center; gap:6px; }
|
||||
.grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:18px; }
|
||||
.card { background:#fff; border-radius:8px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.card span { color:#64748b; font-size:.78rem; display:block; margin-bottom:5px; }
|
||||
.card strong { font-size:1.35rem; }
|
||||
table { width:100%; border-collapse:collapse; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 1px 4px rgba(0,0,0,.1); margin-bottom:22px; }
|
||||
th, td { padding:9px 12px; border-bottom:1px solid #e2e8f0; text-align:left; font-size:.84rem; }
|
||||
th { background:#f8fafc; color:#64748b; }
|
||||
h2 { font-size:1rem; color:#475569; margin:22px 0 10px; }
|
||||
.status { font-weight:700; }
|
||||
.table-wrap { overflow-x:auto; -webkit-overflow-scrolling:touch; margin-bottom:22px; border-radius:8px; }
|
||||
.table-wrap table { margin-bottom:0; min-width:720px; }
|
||||
@media print {
|
||||
body { background:#fff; }
|
||||
header, .toolbar { display:none; }
|
||||
.container { margin:0; max-width:none; }
|
||||
.card, table { box-shadow:none; }
|
||||
}
|
||||
@media (max-width:760px) { .grid { grid-template-columns: repeat(2, minmax(0,1fr)); } header { flex-direction:column; align-items:flex-start; } }
|
||||
@media (max-width:520px) {
|
||||
.container { margin:16px auto; padding:0 10px; }
|
||||
header { padding:14px 16px; }
|
||||
header h1 { font-size:1rem; }
|
||||
.toolbar { display:grid; grid-template-columns:1fr; }
|
||||
.toolbar label, .toolbar input, .toolbar button, .toolbar .btn { width:100%; }
|
||||
.toolbar label { display:grid; gap:6px; }
|
||||
.grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap:8px; }
|
||||
.card { padding:12px; }
|
||||
.card strong { font-size:1.05rem; }
|
||||
}
|
||||
@media (max-width:360px) { .grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>2026-05 월간 운영 보고서</h1>
|
||||
<div>
|
||||
<a href="#">대시보드</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container">
|
||||
<form class="toolbar" method="GET">
|
||||
<label>월 <input type="month" name="month" value="2026-05"></label>
|
||||
<button type="submit">조회</button>
|
||||
<a class="btn" href="#">CSV 다운로드</a>
|
||||
<button type="button" onclick="window.print()">인쇄/PDF</button>
|
||||
</form>
|
||||
|
||||
<section class="card" style="margin-bottom:18px">
|
||||
<span>보고서 생성</span>
|
||||
<strong style="font-size:1rem">2026-05-20 13:50:00</strong>
|
||||
<p style="color:#64748b;font-size:.84rem;margin:8px 0 0">범위: 온습도 임계 경보, 장비 오프라인, SMS, 정상복귀/복구</p>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="card"><span>고온 경보</span><strong>2건</strong></div>
|
||||
<div class="card"><span>저온 경보</span><strong>0건</strong></div>
|
||||
<div class="card"><span>고습 경보</span><strong>1건</strong></div>
|
||||
<div class="card"><span>저습 경보</span><strong>0건</strong></div>
|
||||
<div class="card"><span>정상복귀</span><strong>3건</strong></div>
|
||||
<div class="card"><span>장비 오프라인</span><strong>1건</strong></div>
|
||||
<div class="card"><span>SMS 성공/실패</span><strong>8/1</strong></div>
|
||||
<div class="card"><span>측정 건수</span><strong>8,640건</strong></div>
|
||||
</section>
|
||||
|
||||
<h2>센서별 요약</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>센서</th><th>위치</th><th>임계 경보</th><th>평균 온도</th><th>평균 습도</th><th>최근 측정</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>센서 1</td>
|
||||
<td>전산실 서버랙 A열 하단</td>
|
||||
<td>2</td>
|
||||
<td>24.1C</td>
|
||||
<td>47.3%</td>
|
||||
<td>2026-05-18 09:41:20</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>센서 2</td>
|
||||
<td>전산실 상단 랙</td>
|
||||
<td>1</td>
|
||||
<td>25.4C</td>
|
||||
<td>52.0%</td>
|
||||
<td>2026-05-31 23:59:11</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>임계 경보 SMS 내역</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>발생</th><th>센서</th><th>위치</th><th>경보 종류</th><th>측정값</th><th>정상복귀</th><th>SMS</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>2026-05-12 16:08:11</td>
|
||||
<td>센서 2</td>
|
||||
<td>전산실 상단 랙</td>
|
||||
<td class="status">고습</td>
|
||||
<td>25.1C / 72.4%</td>
|
||||
<td>2026-05-12 16:38:30</td>
|
||||
<td>성공</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2026-05-18 09:13:42</td>
|
||||
<td>센서 1</td>
|
||||
<td>전산실 서버랙 A열 하단</td>
|
||||
<td class="status">고온</td>
|
||||
<td>31.2C / 41.0%</td>
|
||||
<td>2026-05-18 09:45:20</td>
|
||||
<td>성공</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
560
docs/examples/sht30_sensor2_setup.html
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>2번 센서 SHT30 라즈베리파이 셋팅 가이드</title>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #1d4ed8;
|
||||
--blue-weak: #eff6ff;
|
||||
--ink: #172033;
|
||||
--muted: #64748b;
|
||||
--line: #d9e2ef;
|
||||
--paper: #ffffff;
|
||||
--page: #f4f7fb;
|
||||
--green: #15803d;
|
||||
--teal: #0f766e;
|
||||
--amber: #b45309;
|
||||
--red: #b91c1c;
|
||||
--violet: #3730a3;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
||||
background: var(--page);
|
||||
color: var(--ink);
|
||||
line-height: 1.65;
|
||||
}
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header {
|
||||
background: #123c7c;
|
||||
color: #fff;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 4px solid #0f766e;
|
||||
}
|
||||
.topbar {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2.8vw, 2.25rem);
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.version {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
background: rgba(255,255,255,.12);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: .82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 56px;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, .92fr);
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.hero-copy {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--teal);
|
||||
font-weight: 800;
|
||||
font-size: .82rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hero h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(1.25rem, 2.4vw, 1.85rem);
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.hero p { margin: 0; color: var(--muted); font-size: .98rem; }
|
||||
.hero img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.toc {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 4px 0 18px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.toc a {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: var(--blue-weak);
|
||||
color: var(--blue);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: .84rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
section {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
section h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
section h3 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 1rem;
|
||||
color: #334155;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tile {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
.tile strong { display: block; margin-bottom: 5px; }
|
||||
.tile span { color: var(--muted); font-size: .9rem; }
|
||||
.notice {
|
||||
border-left: 5px solid var(--amber);
|
||||
background: #fffbeb;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
color: #713f12;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.ok {
|
||||
border-left-color: var(--green);
|
||||
background: #f0fdf4;
|
||||
color: #14532d;
|
||||
}
|
||||
.image-panel {
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
.image-panel img {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
.caption {
|
||||
color: var(--muted);
|
||||
font-size: .82rem;
|
||||
margin-top: 7px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 12px;
|
||||
font-size: .9rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { background: #f8fafc; color: #475569; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.codebox {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1e293b;
|
||||
background: #0f172a;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
color: #e2e8f0;
|
||||
font: 13px/1.55 Consolas, "Courier New", monospace;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,.12);
|
||||
color: #fff;
|
||||
font-size: .75rem;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
counter-reset: step;
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.step::before {
|
||||
counter-increment: step;
|
||||
content: counter(step);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.step p { margin: 0; }
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: .8rem;
|
||||
font-weight: 800;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status.warn { background: #fef3c7; color: #92400e; }
|
||||
.status.err { background: #fee2e2; color: #991b1b; }
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--blue);
|
||||
color: #fff;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
font-size: .88rem;
|
||||
}
|
||||
.btn.secondary { background: #334155; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.topbar { align-items: flex-start; flex-direction: column; }
|
||||
.version { white-space: normal; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
header { padding: 16px; }
|
||||
main { padding: 16px 10px 42px; }
|
||||
section, .hero-copy { padding: 16px; }
|
||||
th, td { font-size: .82rem; padding: 8px; }
|
||||
pre { font-size: 12px; padding: 42px 12px 12px; }
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
header, .toc, .footer-actions, .copy-btn { display: none; }
|
||||
main { padding: 0; max-width: none; }
|
||||
section, .hero-copy { break-inside: avoid; border-color: #cbd5e1; }
|
||||
.hero { display: block; }
|
||||
.hero img { margin-top: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>2번 센서 SHT30 라즈베리파이 셋팅 가이드</h1>
|
||||
<div style="color:#dbeafe;margin-top:7px;font-size:.94rem">온습도 전용 Raspberry Pi를 기존 모니터링에 sensor_id=2로 연결</div>
|
||||
</div>
|
||||
<div class="version">v2605 Sensor2</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">설치 목표</div>
|
||||
<h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2>
|
||||
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
|
||||
<div class="footer-actions">
|
||||
<button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button>
|
||||
<a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a>
|
||||
</div>
|
||||
</div>
|
||||
<img src="assets/sht30_sensor2_data_flow.svg" alt="SHT30 센서2 데이터 흐름도">
|
||||
</div>
|
||||
|
||||
<nav class="toc" aria-label="문서 목차">
|
||||
<a href="#prepare">준비물</a>
|
||||
<a href="#wiring">배선</a>
|
||||
<a href="#server">서버 설정</a>
|
||||
<a href="#pi">라즈베리파이</a>
|
||||
<a href="#service">자동 실행</a>
|
||||
<a href="#verify">확인</a>
|
||||
<a href="#trouble">문제 대처</a>
|
||||
</nav>
|
||||
|
||||
<section id="prepare">
|
||||
<h2>1. 준비물</h2>
|
||||
<div class="grid">
|
||||
<div class="tile">
|
||||
<strong>라즈베리파이 1대</strong>
|
||||
<span>온습도 전용 장비. 장비 ID 예시는 `rpi-sht30-01`.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<strong>SHT30 P4422-3 센서</strong>
|
||||
<span>I2C 방식 온습도 센서. 기본 주소는 보통 `0x44`.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<strong>서버 반영 파일</strong>
|
||||
<span>API, DB migration, dashboard 수정 파일이 서버에 올라가 있어야 한다.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice ok">
|
||||
센서 번호는 `SHT30_SENSOR_ID=2`로 고정한다. 기존 1번 SHT30 온습도 센서와 sensor_id로 분리해서 운영한다.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="wiring">
|
||||
<h2>2. SHT30 배선</h2>
|
||||
<div class="image-panel">
|
||||
<img src="assets/sht30_sensor2_wiring.svg" alt="라즈베리파이와 SHT30 센서 배선도">
|
||||
<div class="caption">이미지 안의 라벨은 한글 폰트 깨짐을 피하기 위해 영문으로 표기했다.</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>SHT30</th><th>Raspberry Pi</th><th>설명</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>VCC</td><td>3.3V</td><td>5V가 아니라 3.3V 사용을 기본으로 한다.</td></tr>
|
||||
<tr><td>GND</td><td>GND</td><td>공통 접지.</td></tr>
|
||||
<tr><td>SDA</td><td>GPIO2 / SDA1 / pin 3</td><td>I2C 데이터 라인.</td></tr>
|
||||
<tr><td>SCL</td><td>GPIO3 / SCL1 / pin 5</td><td>I2C 클럭 라인.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="server">
|
||||
<h2>3. 서버 DB와 PHP 반영</h2>
|
||||
<div class="step-list">
|
||||
<div class="step">
|
||||
<p>운영 DB에 온습도 이력 테이블을 추가한다.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<p>수정된 PHP 파일을 서버의 `public_html/raspi_leck_detecter/` 경로에 반영한다.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<p>대시보드 진단 영역에서 `SHT30 metric table` 경고가 사라지는지 확인한다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>SOURCE sql/migration_sensor_metric.sql;</code></pre>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>파일</th><th>역할</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>php/api/sensor_data.php</td><td>SHT30 온도/습도 필드 수신 및 저장.</td></tr>
|
||||
<tr><td>php/dashboard.php</td><td>센서 카드에 최신 온도/습도 표시.</td></tr>
|
||||
<tr><td>sql/migration_sensor_metric.sql</td><td>온습도 이력 테이블 생성.</td></tr>
|
||||
<tr><td>sql/schema_v2605.sql</td><td>신규 설치용 통합 스키마.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pi">
|
||||
<h2>4. 라즈베리파이 설정</h2>
|
||||
<h3>I2C 활성화</h3>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>sudo raspi-config</code></pre>
|
||||
</div>
|
||||
<p>`Interface Options`에서 `I2C`를 활성화한 뒤 재부팅한다.</p>
|
||||
|
||||
<h3>패키지 설치</h3>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>sudo apt-get update
|
||||
sudo apt-get install -y python3-venv python3-smbus i2c-tools
|
||||
cd /home/pi/leak_sensor
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>SHT30 주소 확인</h3>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>i2cdetect -y 1</code></pre>
|
||||
</div>
|
||||
<p>표에서 `44`가 보이면 기본값 그대로 사용한다. `45`가 보이면 환경설정의 `SHT30_I2C_ADDR`를 `0x45`로 바꾼다.</p>
|
||||
|
||||
<h3>환경설정 생성</h3>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>sudo cp sht30-sensor.env.example /etc/sht30-sensor.env
|
||||
sudo nano /etc/sht30-sensor.env</code></pre>
|
||||
</div>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>LEAK_API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
|
||||
LEAK_API_KEY=서버_config와_동일한_API_KEY
|
||||
LEAK_DEVICE_ID=rpi-sht30-01
|
||||
LEAK_DEVICE_LOCATION=온습도 측정 위치
|
||||
|
||||
SHT30_SENSOR_ID=2
|
||||
SHT30_SENSOR_NAME=2번 센서 (SHT30 온습도)
|
||||
SHT30_I2C_BUS=1
|
||||
SHT30_I2C_ADDR=0x44
|
||||
SHT30_REPORT_INTERVAL=300
|
||||
SHT30_SIMULATION=false</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="service">
|
||||
<h2>5. 자동 실행 등록</h2>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>sudo cp systemd/sht30-sensor.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable sht30-sensor
|
||||
sudo systemctl start sht30-sensor
|
||||
sudo systemctl status sht30-sensor</code></pre>
|
||||
</div>
|
||||
<p>상태가 `active (running)`이면 부팅 후 자동으로 SHT30 값을 서버에 전송한다.</p>
|
||||
</section>
|
||||
|
||||
<section id="verify">
|
||||
<h2>6. 동작 확인</h2>
|
||||
<div class="grid">
|
||||
<div class="tile">
|
||||
<strong>센서 읽기</strong>
|
||||
<span>`python3 sht30_monitor.py --read-once`가 온도/습도 JSON을 출력해야 한다.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<strong>서버 전송</strong>
|
||||
<span>`python3 sht30_monitor.py --once` 실행 후 응답에 `metric_saved`가 `true`인지 확인한다.</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<strong>대시보드</strong>
|
||||
<span>2번 센서 카드에 온도와 습도 칩이 표시되어야 한다.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="codebox">
|
||||
<button class="copy-btn" type="button">복사</button>
|
||||
<pre><code>source /home/pi/leak_sensor/venv/bin/activate
|
||||
python3 sht30_monitor.py --read-once
|
||||
python3 sht30_monitor.py --once
|
||||
journalctl -u sht30-sensor -f</code></pre>
|
||||
</div>
|
||||
<div class="image-panel">
|
||||
<img src="assets/sht30_sensor2_dashboard.svg" alt="2번 센서가 대시보드에 온도와 습도를 표시하는 예시">
|
||||
<div class="caption">대시보드에서는 센서2 상태와 최신 온습도 값이 같은 센서 카드 안에 표시된다.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="trouble">
|
||||
<h2>7. 문제 대처표</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>증상</th><th>판단</th><th>조치</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="status err">주소 없음</span> `i2cdetect -y 1`에 `44` 또는 `45`가 없음</td>
|
||||
<td>배선, I2C 비활성화, 전원 문제 가능성.</td>
|
||||
<td>SDA/SCL 반대 연결 여부 확인, `raspi-config` I2C 활성화, 3.3V/GND 재확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="status err">403</span> API 인증 실패</td>
|
||||
<td>라즈베리파이 `LEAK_API_KEY`와 서버 `API_KEY`가 다름.</td>
|
||||
<td>`/etc/sht30-sensor.env`와 서버 `config.local.php`의 키를 동일하게 맞춘다.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="status warn">metric_saved false</span></td>
|
||||
<td>서버는 받았지만 `sensor_metric` 테이블이 없음.</td>
|
||||
<td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="status warn">오프라인</span> 대시보드에서 센서2 오프라인</td>
|
||||
<td>서비스 중단 또는 네트워크/API 장애.</td>
|
||||
<td>`systemctl status sht30-sensor`, `journalctl -u sht30-sensor -n 80` 확인.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="status err">CRC 오류</span> 측정 실패 반복</td>
|
||||
<td>I2C 신호 불안정 또는 케이블 문제.</td>
|
||||
<td>케이블 길이를 줄이고 접지를 재확인한다. 필요하면 센서 주소와 보드를 교차 점검한다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll(".copy-btn").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const code = button.parentElement.querySelector("code").innerText;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
const old = button.textContent;
|
||||
button.textContent = "완료";
|
||||
setTimeout(() => { button.textContent = old; }, 1200);
|
||||
} catch (error) {
|
||||
button.textContent = "실패";
|
||||
setTimeout(() => { button.textContent = "복사"; }, 1200);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
title: Sensor telemetry extensions should keep status and measurements separate
|
||||
date: 2026-05-21
|
||||
category: docs/solutions/best-practices
|
||||
module: raspberry_pi/php sensor telemetry
|
||||
problem_type: best_practice
|
||||
component: database
|
||||
severity: medium
|
||||
applies_when:
|
||||
- Adding a non-leak sensor to the leak monitoring system
|
||||
- Reporting environmental readings such as SHT30 temperature and humidity
|
||||
- Extending Raspberry Pi hardware while preserving existing heartbeat behavior
|
||||
tags: [sensor-telemetry, sht30, raspberry-pi, monitoring]
|
||||
---
|
||||
|
||||
# Sensor telemetry extensions should keep status and measurements separate
|
||||
|
||||
> 참고: 이 문서는 누수감지 시절의 기록(레거시)이며, 현재 시스템은 SHT30 온습도 전용으로 전환되었습니다.
|
||||
|
||||
## Context
|
||||
|
||||
The second Raspberry Pi uses an SHT30 temperature/humidity sensor instead of a leak contact sensor. The existing API and dashboard already track `sensor_status` and heartbeat, but the original schema had no place to persist temperature/humidity readings.
|
||||
|
||||
## Guidance
|
||||
|
||||
Keep the device online/offline state in `sensor_status`, and store changing environmental readings in a separate metric table such as `sensor_metric`.
|
||||
|
||||
For SHT30 sensor2, the Pi should continue sending the existing required fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "rpi-sht30-01",
|
||||
"sensor_id": 2,
|
||||
"event_type": "periodic",
|
||||
"is_leak": 0,
|
||||
"timestamp": 1779290000
|
||||
}
|
||||
```
|
||||
|
||||
Add telemetry fields before generating the API signature:
|
||||
|
||||
```json
|
||||
{
|
||||
"metric_type": "sht30",
|
||||
"temperature_c": 24.35,
|
||||
"humidity_percent": 48.1,
|
||||
"metric_status": "normal"
|
||||
}
|
||||
```
|
||||
|
||||
The API should update `sensor_status` on every accepted report and insert measurement rows into `sensor_metric` when the table exists. This lets heartbeat/offline monitoring keep working even if metric storage has not yet been migrated.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Mixing environmental readings into `sensor_log` would make normal reports noisy and would not model temperature/humidity as time-series data. A separate metric table keeps device status and environmental telemetry clear enough for dashboard display, monthly reports, and threshold alerts (이번 전환에서 서버측 온습도 임계 SMS로 구현됨).
|
||||
|
||||
The Raspberry Pi source should also support both `smbus2` and Raspberry Pi OS `python3-smbus`. `smbus2` can be used as a context manager, but legacy `smbus` may require explicit `close()` handling.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Add sensor2 or later environmental sensors.
|
||||
- Add readings that are not leak events but need history.
|
||||
- Add Pi hardware that should participate in the existing offline heartbeat.
|
||||
- Add I2C hardware where distro packages and pip packages may expose different Python APIs.
|
||||
|
||||
## Examples
|
||||
|
||||
For SHT30:
|
||||
|
||||
```text
|
||||
SHT30 VCC -> Raspberry Pi 3.3V
|
||||
SHT30 GND -> Raspberry Pi GND
|
||||
SHT30 SDA -> GPIO2 / SDA1 / pin 3
|
||||
SHT30 SCL -> GPIO3 / SCL1 / pin 5
|
||||
```
|
||||
|
||||
For deployment:
|
||||
|
||||
```text
|
||||
1. Apply sql/migration_sensor_metric.sql.
|
||||
2. Configure /etc/sht30-sensor.env with SHT30_SENSOR_ID=2.
|
||||
3. Run python3 sht30_monitor.py --read-once.
|
||||
4. Run python3 sht30_monitor.py --once.
|
||||
5. Confirm sensor_id=2 appears on dashboard.php with latest temperature/humidity.
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/SHT30_SENSOR2_SETUP.md`
|
||||
- `raspberry_pi/sht30_monitor.py`
|
||||
- `sql/migration_sensor_metric.sql`
|
||||
- `php/api/sensor_data.php`
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: Review Cycle Knowledge Capture for Leak SMS v2605
|
||||
date: 2026-05-19
|
||||
last_updated: 2026-05-20
|
||||
category: docs/solutions/workflow-issues
|
||||
module: leak_sms_v2 operations workflow
|
||||
problem_type: workflow_issue
|
||||
component: development_workflow
|
||||
severity: medium
|
||||
applies_when:
|
||||
- "Finishing a review and execution cycle on the leak monitoring project"
|
||||
- "Adding schema, dashboard, report, or Raspberry Pi runtime behavior in one pass"
|
||||
- "Adding security evidence pages, attachment packages, or operational checklists"
|
||||
tags: [v2605, review-cycle, compound, verification, operations]
|
||||
---
|
||||
|
||||
# Review Cycle Knowledge Capture for Leak SMS v2605
|
||||
|
||||
> 참고: 이 문서는 누수감지 시절의 기록(레거시)이며, 현재 시스템은 SHT30 온습도 전용으로 전환되었습니다.
|
||||
|
||||
## Context
|
||||
|
||||
The v2605 hardening work touched PHP pages, MySQL schema, Raspberry Pi capture logic, operations documentation, and deployment notes at the same time. That creates two recurring risks: a feature can be visible in the UI before the DB migration is applied, and static syntax checks can miss broken HTML or operational gaps.
|
||||
|
||||
## Guidance
|
||||
|
||||
At the end of each execution-review cycle, capture the concrete lessons in `docs/solutions/` before declaring the work finished. For this project, the minimum checklist is:
|
||||
|
||||
- Run PHP syntax checks on every changed PHP entry point, including new admin pages.
|
||||
- Run Python compile checks after Raspberry Pi runtime edits.
|
||||
- Search non-backup files for stale secrets, old endpoint names, or legacy phone numbers.
|
||||
- Keep stale-secret search patterns out of durable docs or mask them; otherwise the verification command can reintroduce the exact legacy values it is meant to detect.
|
||||
- Confirm new SQL migrations are documented in both the project README and the top-level report.
|
||||
- If a feature depends on optional schema, code the PHP page to degrade safely when the table or column is absent.
|
||||
- When adding security-evidence features, update both sides of the handoff: runtime screens/scripts (`security_evidence.php`, `collect_evidence.py`) and the HWP attachment docs that tell an operator what to submit.
|
||||
- After `python -m py_compile`, remove all generated `__pycache__` directories under the workspace, not just the runtime package that was edited.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The deployment target is a small operational system where a hidden migration miss can block incident response after a real leak alert. Capturing these checks makes the next cycle faster and reduces repeat mistakes around migrations, secrets, and PHP pages that pass `php -l` but still need markup review.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Before calling a v2605 improvement complete.
|
||||
- After adding DB columns used by dashboard/report pages.
|
||||
- After adding Raspberry Pi environment variables or runtime timing logic.
|
||||
- Before handing off deployment notes to an operator.
|
||||
|
||||
## Examples
|
||||
|
||||
For photo timeline work, the compatible pattern is to detect `leak_photo.sequence_no` and `leak_photo.photo_stage` before using them. New installs can use `sql/schema_v2605.sql`, while existing installs apply `sql/migration_photo_timeline.sql`.
|
||||
|
||||
For final verification, keep generated cache directories out of the handoff after running `python -m py_compile`.
|
||||
|
||||
For security evidence work, the runtime artifacts and the submission guide must stay aligned. If `php/security_evidence.php?format=md` exists, `docs/SECURITY_EVIDENCE_PACKAGE.md`, `docs/SECURITY_PLAN_PASS_READINESS.md`, and `docs/SECURITY_PLAN_HWP_REVIEW.md` should all point to it. If a Pi evidence collector exists, installation docs must include the exact command and explain that raw API/SMS keys are not printed.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/README.md`
|
||||
- `sql/migration_photo_timeline.sql`
|
||||
- `php/setup_wizard.php`
|
||||
- `php/security_evidence.php`
|
||||
- `php/monthly_report.php`
|
||||
- `raspberry_pi/collect_evidence.py`
|
||||
- `docs/SECURITY_EVIDENCE_PACKAGE.md`
|
||||
257
docs/stm32f407_migration_plan.md
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
# 메인 MCU 마이그레이션 계획: Raspberry Pi Zero 2 W → STM32F407VGT6 (Cortex-M4)
|
||||
|
||||
> 대상: SHT30 온습도 모니터링 시스템 펌웨어 이식 (SHT30 단일 보드)
|
||||
> 작성일: 2026-06-09 · 구현 완료: 2026-06-09 · 단일 보드 전환: 2026-06
|
||||
|
||||
## 0. 구현 현황 (✅ 완료)
|
||||
|
||||
펌웨어가 `firmware/` 에 구현되었고 레거시 RPi 펌웨어(`raspberry_pi/`, `systemd/`)는 제거되었습니다.
|
||||
시스템은 **SHT30 온습도 단일 보드**로 전환되었습니다(누수 보드/누수 펌웨어는 제거됨).
|
||||
|
||||
**산출물**: 검증된 이식 코어(`common/{sha256_sw,hexutil,sig,jsonbody,sht30_convert,httpapi}.c`),
|
||||
타깃 모듈(`net/ethernetif/tls_mbedtls/timesync/sht30/reporter/applog/watchdog/bsp/rtos_hooks`),
|
||||
보드 앱(`board_sht30/`), 설정(`config/{FreeRTOSConfig,lwipopts,mbedtls_config,stm32f4xx_hal_conf}.h`),
|
||||
빌드(`CMakeLists.txt`, `cmake/`, `ld/`, `scripts/`), 인증서(`certs/`), 문서(`README.md`, `docs/`).
|
||||
|
||||
**검증 (이 환경에서 실행 가능한 범위)**:
|
||||
- 이식 코어 ARM 크로스컴파일 게이트: `arm-none-eabi-gcc -mcpu=cortex-m4 ... -Werror -Wconversion` 무경고 통과.
|
||||
- wire 계약 패리티: Python 레퍼런스(=C와 동일 알고리즘) → 서버 PHP `verify_signature_raw` 수용 통과(`firmware/test/host/parity_test.py`).
|
||||
- PHP 서명/보안 회귀: `tests/raw_body_signature_test.php`, `tests/security_hardening_test.php` 통과.
|
||||
- ⚠️ **전체 펌웨어 링크/실행은 미검증**: HAL/FreeRTOS/lwip/mbedtls 는 폐쇄망 빌드 머신에서 벤더링되므로(`firmware/third_party`), 실제 보드 빌드·플래시·HIL 시험은 운영 측에서 수행해야 함.
|
||||
|
||||
**다중 에이전트 코드리뷰 후 수정된 결함**:
|
||||
- (CRITICAL) TLS 인증서 유효기간(notBefore/notAfter) 미검증 → `MBEDTLS_HAVE_TIME_DATE` + RTC/SNTP 시간 공급(`tls_platform_time`), 시간 미동기 시 connect 차단.
|
||||
- (CRITICAL) FreeRTOS 정적메모리 콜백 중복정의(커널 제공과 충돌) → `rtos_hooks.c` 에서 제거(커널 소유).
|
||||
- (HIGH) LwIP 코어락 위반: PHY 링크 통지 raw API → `netifapi_*` + 콜백 내부 raw `netif_set_up`(중첩락 데드락 회피).
|
||||
- (CRITICAL/통합) 로그 UART PA2(=RMII MDIO) 충돌 → USART3(PD8/PD9), FreeRTOS 훅 단일 소유, `-u _printf_float`.
|
||||
- (MEDIUM/LOW) `close_notify` 무한루프 상한, `dns_setserver` 코어락, HW RNG `bsp_rand32`, SHT30 태스크 스택 12KB, CA 자리표시자 동작 주석.
|
||||
|
||||
**배포 전 하드웨어 확정 필요 — TODO(hw)** (상세: `firmware/docs/HARDWARE.md`, `PORTING_NOTES.md`):
|
||||
LAN8720 PHY 주소·RMII 50MHz REF_CLK 소스, HSE 주파수(PLLM), RTC 클럭원(LSE 32.768k vs LSI),
|
||||
상태 LED 핀, MAC 주소(노드별 고유), SHT30 I2C 핀(PB6/PB7),
|
||||
실제 Cafe24 루트 CA(`certs/server_ca.c`), `secrets.h`(APP_API_KEY).
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 사항 (확정)
|
||||
|
||||
| 항목 | 현재 (RPi) | 신규 (STM32F407VGT6) |
|
||||
|---|---|---|
|
||||
| 메인 MCU | RPi Zero 2 W (Linux/Python) | STM32F407VGT6 (Cortex-M4F, bare HW) |
|
||||
| 네트워크 | WiFi | **유선 Ethernet (LAN8720 RMII PHY)** |
|
||||
| TLS/HTTPS | OpenSSL (`requests`) | **온칩 mbedTLS + LwIP** |
|
||||
| 보드 구성 | RPi SHT30 노드 | **STM32 SHT30 단일 보드** (SHT30=sensor_id 2) |
|
||||
| 임계 판정/SMS | 서버측 처리 | **서버 `config.php` METRIC_* 에서 판정** (펌웨어는 원값만 보고) |
|
||||
| 동시성 | Python threads | **FreeRTOS tasks** |
|
||||
| 시간원 | NTP (OS) | **SNTP (LwIP) → RTC** |
|
||||
| 설정 | env / systemd | **컴파일 타임 `config.h` + (옵션) flash 설정** |
|
||||
| 로깅 | rotating file log | **UART 콘솔 + (옵션) 서버 로그** |
|
||||
| 프로세스 관리 | systemd 재시작 | **IWDG 워치독 + 재연결 로직** |
|
||||
|
||||
핵심 원칙: **서버측 PHP API는 변경하지 않는다(가능한 한).** 펌웨어가 기존
|
||||
서명 규약·요청 포맷을 바이트 단위로 그대로 재현하여 무중단으로 전환한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 하드웨어 사양 / 제약 (STM32F407VGT6)
|
||||
|
||||
- Cortex-M4F @ 168 MHz, **Flash 1 MB**, **RAM 192 KB** (112+16 KB SRAM + 64 KB CCM)
|
||||
- Ethernet MAC 내장 → 외부 PHY 필요 (**LAN8720, RMII**)
|
||||
- 하드웨어 **RNG 있음** (TLS 엔트로피에 사용) — 중요
|
||||
- 하드웨어 해시/암호 가속기 **없음** (CRYP/HASH는 F415/417 전용) → **SHA-256/AES는 소프트웨어(mbedTLS sw)**
|
||||
- I²C 3채널, USART 다수, EXTI, RTC, IWDG
|
||||
|
||||
### 메모리 예산 (가장 큰 리스크)
|
||||
- mbedTLS TLS1.2 클라이언트(ECDHE-RSA + AES-GCM) 기본 record buffer 2×16 KB = 32 KB.
|
||||
→ **`MBEDTLS_SSL_IN/OUT_CONTENT_LEN`을 4~6 KB로 축소** (서버 응답이 작으므로 가능)
|
||||
→ LwIP pbuf pool과 합산 시 SRAM 빠듯 → **FreeRTOS task stack을 CCM(64 KB)에 배치**
|
||||
- RSA-2048 핸드셰이크는 소프트웨어로 ~1–2 s 소요(허용 가능, 보고 주기가 길다)
|
||||
|
||||
---
|
||||
|
||||
## 3. 현재 코드 → 펌웨어 모듈 매핑
|
||||
|
||||
| 현재 (Python) | 신규 (C 펌웨어 모듈) | 비고 |
|
||||
|---|---|---|
|
||||
| `sht30_monitor.py` | `app_sht30.c` (Task) | SHT30 보드 메인 |
|
||||
| `make_signature()` | `sig.c : sig_raw_body()` | mbedtls_sha256 (raw-body) |
|
||||
| 표준 JSON 직렬화 | `jsonbody.c : jb_sht30_event()` | raw-body 서명 → 정규화 불필요 |
|
||||
| `post_to_server()` | `api.c : api_post_json()` | TLS + 재시도 루프 |
|
||||
| `post_reading()` | `reporter.c : report_sht30_event()` | 본문→서명→POST→재시도 |
|
||||
| `run_loop()` / `periodic_reporter()` | `Task_PeriodicReport` | vTaskDelay 기반 |
|
||||
| `read_sht30()` | `sht30.c : sht30_read()` | I²C HAL |
|
||||
| `crc8_sht3x()` | `sht30.c : sht30_crc8()` | 동일 알고리즘 |
|
||||
| `classify_reading()` | reporter 호출부 `metric_status` | plausibility 만. 임계는 서버 |
|
||||
| `config.py` | `config.h` (+ flash 설정 옵션) | |
|
||||
| `setup_logger()` | `log.c` (UART) | |
|
||||
| `systemd/*.service` | IWDG + 메인 재연결 | |
|
||||
|
||||
---
|
||||
|
||||
## 4. 펌웨어 아키텍처 (FreeRTOS)
|
||||
|
||||
```
|
||||
main()
|
||||
├─ HAL/Clock/RNG/IWDG init
|
||||
├─ Ethernet/LwIP init (RMII LAN8720)
|
||||
├─ Task_Net : 링크 업 대기 → DHCP(or static) → SNTP로 RTC 설정 → "online" 플래그
|
||||
├─ Task_Sensor : SHT30 주기(300s) I2C 측정 → temperature_c/humidity_percent + metric_status
|
||||
├─ Task_Report : SHT30_REPORT_INTERVAL(300s) 로 api_post_json() (startup → periodic)
|
||||
└─ Task_Watchdog: 각 task 헬스 비트 확인 후 IWDG refresh
|
||||
```
|
||||
|
||||
- SHT30 측정값(원값 + plausibility `metric_status`)을 서버로 POST → **서버가 `config.php` METRIC_* 임계와 비교해 고온/저온/고습/저습 시 SMS 발송**.
|
||||
- TLS 컨텍스트는 매 요청마다 connect→handshake→POST→close (메모리 절약). 보고 주기가 길어 성능 무관.
|
||||
|
||||
---
|
||||
|
||||
## 5. 단계별 마이그레이션 계획
|
||||
|
||||
### Phase 0 — 환경/툴체인 (폐쇄망 대응)
|
||||
- STM32CubeIDE 또는 `arm-none-eabi-gcc` + CMake + OpenOCD/ST-Link.
|
||||
- **오프라인 번들**: 툴체인 설치본, CubeF4 HAL 팩, FreeRTOS·LwIP·mbedTLS 소스를
|
||||
벤더링(`third_party/`에 고정 버전 포함). 빌드 시 외부 다운로드 0건.
|
||||
- ST-Link/J-Link로 플래시 + UART 콘솔 확보.
|
||||
|
||||
### Phase 1 — 프로젝트 스켈레톤
|
||||
- CubeMX로 클럭(HSE 8 MHz → 168 MHz), RMII, I²C, USART, RNG, IWDG, RTC 핀맵 생성.
|
||||
- FreeRTOS(CMSIS-RTOS2), LwIP, mbedTLS 통합. CCM RAM에 task stack 배치(linker script).
|
||||
- "Hello UART" + LED blink로 부팅 검증.
|
||||
|
||||
### Phase 2 — 네트워크 브링업
|
||||
- LAN8720 RMII 드라이버 + LwIP. DHCP 우선, 실패 시 static fallback(`config.h`).
|
||||
- **SNTP 클라이언트로 시간 동기 → RTC 설정** (TLS 인증서 유효기간 검증·`timestamp` 필드에 필수).
|
||||
순서 주의: SNTP는 UDP라 TLS 불필요 → TLS보다 먼저 수행(닭-달걀 회피).
|
||||
- 링크 다운/DHCP 실패 시 백오프 재시도.
|
||||
|
||||
### Phase 3 — TLS + HTTPS 클라이언트
|
||||
- mbedTLS config 축소(`mbedtls_config.h`): TLS1.2만, ECDHE-RSA + AES-GCM,
|
||||
`MBEDTLS_SSL_IN/OUT_CONTENT_LEN ≈ 4096`, MFL extension.
|
||||
- 엔트로피: 하드웨어 RNG 연결.
|
||||
- **서버 루트 CA PEM을 flash에 임베드**(Cafe24 인증서 체인 확인 필요).
|
||||
- `api_post_json(url_path, body, out_resp)` 구현: connect→handshake(SNI)→
|
||||
`POST` 헤더 작성→본문 전송→상태코드 파싱.
|
||||
- 검증: `https://your-domain/.../sensor_data.php`로 200 수신.
|
||||
|
||||
### Phase 4 — 서명 (raw-body 방식, R1 해결로 단순화)
|
||||
서버가 **raw-body 서명**을 지원하도록 변경 완료 (PHP `verify_signature_raw`, 본 저장소 적용됨):
|
||||
- 서버 검증식: `sha256(API_KEY . 요청_본문_바이트_그대로)`, 값은 HTTP 헤더 **`X-Signature`**로 전송.
|
||||
- 펌웨어는 **자신이 전송하는 본문 바이트 그대로**를 SHA-256으로 서명만 하면 된다.
|
||||
→ 키 정렬·부동소수 포맷·슬래시 이스케이프 **재현 불필요** (SHT30 float 문제 원천 제거).
|
||||
- 클라이언트 절차:
|
||||
1. `body` 문자열 1회 생성(필드 순서 자유). 2. `sig = sha256_hex(API_KEY || body)`.
|
||||
3. `POST` 시 `Content-Type: application/json` + `X-Signature: <sig>` 헤더, 본문=`body`.
|
||||
4. 본문의 `signature` 필드는 불필요(있어도 서버가 무시).
|
||||
- 서버는 `X-Signature` 헤더가 없으면 레거시 본문 필드 서명으로 폴백 → **RPi 무중단 공존**.
|
||||
- 회귀 테스트: `tests/raw_body_signature_test.php` (통과 확인됨).
|
||||
|
||||
```c
|
||||
// 펌웨어: 전송 바이트 그대로 서명
|
||||
sig_sha256_hex(body, sig_hex); // sha256(API_KEY || body)
|
||||
// HTTP 헤더에 "X-Signature: <sig_hex>" 추가, 본문은 body 그대로 전송
|
||||
```
|
||||
|
||||
### Phase 5 — SHT30 보드 앱 (sensor_id=2)
|
||||
- I²C: 예 `I2C1`(PB6/PB7), addr `0x44`, cmd `0x2C06`(high-repeatability), 20 ms 대기, 6 byte read.
|
||||
- `sht30_crc8`(poly 0x31, init 0xFF) 온도/습도 각각 검증 — Python과 동일.
|
||||
- 변환: `T=-45+175*raw/65535`, `RH=100*raw/65535`, **소수 2자리 반올림**.
|
||||
- 필드: `metric_type="sht30"`, `temperature_c`, `humidity_percent`, `metric_status`, `app_version` (`is_leak` 필드 없음).
|
||||
- 이벤트: `startup`(부팅 시 1회) → `periodic`(300 s) 주기 측정 보고.
|
||||
- ⚠️ **float 서명 패리티**(아래 리스크 R1) 반드시 처리.
|
||||
|
||||
### Phase 6 — 서버 임계 판정 / SMS (펌웨어 외부)
|
||||
- 펌웨어는 `temperature_c`/`humidity_percent` **원값** + `metric_status`(plausibility) 만 보고한다.
|
||||
- 고온/저온/고습/저습 **운영 임계는 서버 `php/config.php` 의 `METRIC_*` 상수**가 판정하고, 초과 시 `sensor_data.php` 가 SMS 게이트웨이로 문자를 발송한다.
|
||||
- 폐쇄망 재플래시 비용을 피하려 임계를 서버에 두므로, 임계 변경 시 펌웨어 재빌드/재배포가 불필요하다.
|
||||
|
||||
### Phase 7 — 신뢰성/운영
|
||||
- IWDG 워치독, TLS/네트워크 실패 시 지수 백오프 재연결.
|
||||
- 브라운아웃 리셋(BOR) 설정, 부팅 카운터.
|
||||
- 로그: UART(개발) + (옵션) 실패 통계 서버 보고.
|
||||
- 설정 변경: flash 마지막 섹터에 `device_id/location/static IP/API_KEY` 저장 + UART 명령으로 갱신(옵션). 미구현 시 `config.h` 재빌드.
|
||||
|
||||
### Phase 8 — 검증
|
||||
- §7 호스트 서명 패리티 단위 테스트 통과.
|
||||
- 실서버(또는 스테이징 PHP)로 `startup/periodic`(SHT30) E2E + 서버 임계 초과 시 SMS 트리거 확인.
|
||||
- 24 h 안정성 + 망 단절 복구 + 워치독 동작 시험.
|
||||
|
||||
### Phase 9 — 빌드/배포 (폐쇄망)
|
||||
- 단일 `.bin`/`.hex` 산출, 오프라인 플래시 절차 문서화.
|
||||
- 단일 빌드 타깃 `sht30_fw`(컴파일 플래그 `BOARD_SHT30`).
|
||||
- 펌웨어 버전 = `app_version` 필드로 보고(`v2606-sht30`, `firmware/VERSION`=`fw-2.0.0`).
|
||||
|
||||
---
|
||||
|
||||
## 6. 핵심 코드 스케치
|
||||
|
||||
### 6.1 본문 생성 + raw-body 서명 (키 정렬·정규화 불필요)
|
||||
```c
|
||||
// 필드 순서/포맷 자유 — 서버는 보낸 바이트 그대로 서명을 검증한다.
|
||||
// SHT30 본문에는 is_leak 필드가 없다(온습도 원값 + metric_status 만 보고).
|
||||
int build_body_sht30(char *out, size_t n, const Sht30Msg *m) {
|
||||
return snprintf(out, n,
|
||||
"{\"device_id\":\"%s\",\"device_location\":\"%s\","
|
||||
"\"sensor_id\":%d,\"sensor_name\":\"%s\",\"event_type\":\"%s\","
|
||||
"\"metric_type\":\"sht30\",\"temperature_c\":%.2f,\"humidity_percent\":%.2f,"
|
||||
"\"metric_status\":\"%s\",\"app_version\":\"%s\",\"timestamp\":%ld}",
|
||||
m->device_id, m->device_location, m->sensor_id, m->sensor_name,
|
||||
m->event_type, m->temperature_c, m->humidity_percent,
|
||||
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);
|
||||
for (int i=0;i<32;i++) sprintf(hex+i*2, "%02x", d[i]);
|
||||
}
|
||||
// POST 시 헤더에 "X-Signature: <hex>" 추가, 본문은 위 body 그대로 전송.
|
||||
```
|
||||
주의(유일): 서명한 `body` 바이트와 실제 전송 바이트가 **반드시 동일**해야 한다.
|
||||
문자열 필드에 `"`나 `\`가 들어갈 수 있으면 JSON 이스케이프만 적용(서버는 그 바이트로 검증하므로 PHP 포맷 재현 불요).
|
||||
|
||||
### 6.2 SHT30 읽기 (HAL)
|
||||
```c
|
||||
HAL_I2C_Master_Transmit(&hi2c1, 0x44<<1, (uint8_t[]){0x2C,0x06}, 2, 100);
|
||||
HAL_Delay(20);
|
||||
uint8_t b[6]; HAL_I2C_Master_Receive(&hi2c1, 0x44<<1, b, 6, 100);
|
||||
if (sht30_crc8(b,2)!=b[2] || sht30_crc8(b+3,2)!=b[5]) return ERR_CRC;
|
||||
float t = -45.0f + 175.0f*((b[0]<<8|b[1])/65535.0f);
|
||||
float rh = 100.0f*((b[3]<<8|b[4])/65535.0f);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 & 대응
|
||||
|
||||
| ID | 리스크 | 영향 | 대응 |
|
||||
|---|---|---|---|
|
||||
| **R1** | ~~float 서명 패리티 (SHT30)~~ | ~~SHT30 보고 전건 401 가능~~ | ✅ **해결됨**: PHP를 **raw-body 서명**으로 전환(`verify_signature_raw`, `X-Signature` 헤더). 본문 바이트 그대로 서명 → JSON 재직렬화/float 포맷/슬래시 이스케이프 의존 제거. 레거시 본문 필드 서명은 RPi 호환을 위해 이중 모드로 유지. 상세 §5 Phase 4 참조 |
|
||||
| **R2** | TLS RAM 부족(192 KB) | 핸드셰이크 실패/리셋 | content_len 4 KB 축소, CCM에 stack, LwIP pbuf 튜닝 |
|
||||
| **R3** | Cafe24 인증서 cipher/체인/만료 | TLS 연결 불가 | 사전 `openssl s_client`로 체인·cipher 확인, 루트 CA 임베드, 만료 시 펌웨어 업데이트 절차 |
|
||||
| **R4** | SNTP 실패 → TLS 시간검증·timestamp 오류 | 인증/중복판정 오류 | SNTP 재시도, 실패 시 RTC 보존값 사용 + 경보 |
|
||||
| **R5** | `/` 이스케이프 누락 | 서명 불일치(값에 `/` 포함 시) | 정규화기에서 `\/` 처리 |
|
||||
| **R6** | 임계 판정을 서버에 위임(폐쇄망 재플래시 회피) | 임계 변경이 서버 운영에 종속 | 경보 임계는 서버 `config.php` METRIC_* 가 소유. 펌웨어는 원값+`metric_status` 만 보고(§5 Phase 6) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 산출물 / 디렉터리(제안)
|
||||
```
|
||||
firmware/
|
||||
├─ third_party/ {CMSIS, STM32F4_HAL, FreeRTOS, lwip, mbedtls} # 벤더링(폐쇄망)
|
||||
├─ common/ {api.c, sig.c, jsonbody.c, net.c, sntp.c, log.c, config.h}
|
||||
├─ board_sht30/ {app_sht30.c, sht30.c, main.c}
|
||||
├─ certs/ server_ca.pem.h
|
||||
├─ test/ test_signature_parity.c # 호스트 빌드, PHP 기대값과 대조
|
||||
└─ CMakeLists.txt
|
||||
```
|
||||
|
||||
## 9. 다음 작업
|
||||
1. **R1/R3 사전조사**: Cafe24 TLS 체인 확인, float 서명 정책 결정(PHP raw-body 서명으로 전환 권장).
|
||||
2. Phase 0–1 스켈레톤 + UART/LED 부팅.
|
||||
3. Phase 2–3 네트워크/TLS로 `sensor_data.php` 200 확보.
|
||||
4. Phase 4 서명 패리티 테스트 그린.
|
||||
5. Phase 5 SHT30 앱 완성 → E2E(+ 서버 임계 SMS 확인).
|
||||
|
|
@ -0,0 +1,832 @@
|
|||
# Operational Security Completion Implementation Plan
|
||||
|
||||
> 참고: 이 문서는 누수감지 시절의 기록(레거시)이며, 현재 시스템은 SHT30 온습도 전용으로 전환되었습니다.
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn the v2605 leak monitoring project into a submission-ready and field-operable system with concrete security evidence, operational diagnostics, and review-friendly reports.
|
||||
|
||||
**Architecture:** Keep the current procedural PHP/Cafe24 structure. Extract reusable operational checks into a small PHP helper, add a login-protected evidence report page, add a Pi-side sanitized evidence collector, and update documentation so security controls map to actual code and captured evidence.
|
||||
|
||||
**Tech Stack:** PHP 7+/PDO/MySQL on Cafe24, Apache `.htaccess`, Raspberry Pi Python 3, systemd, Markdown documentation.
|
||||
|
||||
---
|
||||
|
||||
## Origin
|
||||
|
||||
Source requirements: `docs/superpowers/specs/2026-05-20-operational-security-completion-design.md`
|
||||
|
||||
This plan intentionally avoids implementing fire detection, power anomaly detection, multi-tenant productization, or HWP binary editing. Those remain outside the current scope.
|
||||
|
||||
## File Structure
|
||||
|
||||
Create:
|
||||
|
||||
- `php/ops_checks.php`
|
||||
Shared server-side operational/security checks used by setup and evidence pages.
|
||||
|
||||
- `php/security_evidence.php`
|
||||
Login-protected evidence report page for security review attachments. Supports browser view and Markdown download.
|
||||
|
||||
- `raspberry_pi/collect_evidence.py`
|
||||
Sanitized Pi-side evidence collector that prints service, network, camera, and API test evidence without exposing secrets.
|
||||
|
||||
- `docs/OPERATIONS_SECURITY_CHECKLIST.md`
|
||||
Operator checklist for monthly security/operation review.
|
||||
|
||||
- `docs/SECURITY_EVIDENCE_PACKAGE.md`
|
||||
Submission package index: what to attach, where it comes from, and what it proves.
|
||||
|
||||
Modify:
|
||||
|
||||
- `php/setup_wizard.php`
|
||||
Use shared checks and link to `security_evidence.php`.
|
||||
|
||||
- `php/dashboard.php`
|
||||
Add clearer links/status hints for evidence, SMS failures, and offline state without changing its core flow.
|
||||
|
||||
- `php/monthly_report.php`
|
||||
Add print-friendly metadata and evidence-oriented summary rows.
|
||||
|
||||
- `docs/README.md`
|
||||
Link the new evidence and operations checklist docs.
|
||||
|
||||
- `docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md`
|
||||
Narrow the user-provided items now that code-based evidence is generated locally.
|
||||
|
||||
- `docs/SOURCE_SECURITY_EVIDENCE.md`
|
||||
Point to the new evidence page and Pi collector.
|
||||
|
||||
- `CHANGELOG.md`
|
||||
Record the operational/security completion improvements.
|
||||
|
||||
Verification:
|
||||
|
||||
- PHP lint for all changed PHP files.
|
||||
- Python compile for Raspberry Pi scripts.
|
||||
- Static secret/stale-value search excluding `_backup`.
|
||||
- Manual browser verification on deployed Cafe24 remains an operator step, documented but not locally provable.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract Shared Operational Checks
|
||||
|
||||
**Files:**
|
||||
- Create: `php/ops_checks.php`
|
||||
- Modify: `php/setup_wizard.php`
|
||||
- Test: PHP lint plus manual inclusion through `setup_wizard.php`
|
||||
|
||||
- [ ] **Step 1: Create `php/ops_checks.php` with reusable checks**
|
||||
|
||||
Create a small helper that returns normalized check rows. Use this structure:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// =============================================================================
|
||||
// ops_checks.php - 운영/보안 점검 공통 헬퍼 (v2605)
|
||||
// =============================================================================
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function ops_table_exists(PDO $db, string $table): bool {
|
||||
$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;
|
||||
}
|
||||
|
||||
function ops_column_exists(PDO $db, string $table, string $column): bool {
|
||||
$stmt = $db->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :table
|
||||
AND COLUMN_NAME = :column
|
||||
");
|
||||
$stmt->execute([':table' => $table, ':column' => $column]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function ops_check(string $category, string $label, bool $ok, string $detail, string $fix = '', string $severity = 'warn', string $evidence = ''): array {
|
||||
return [
|
||||
'category' => $category,
|
||||
'label' => $label,
|
||||
'ok' => $ok,
|
||||
'detail' => $detail,
|
||||
'fix' => $fix,
|
||||
'severity' => $severity,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
|
||||
function collect_ops_checks(): array {
|
||||
$checks = [];
|
||||
|
||||
try {
|
||||
$db = get_db();
|
||||
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
|
||||
|
||||
$required_tables = ['sensor_log', 'sensor_status', 'sms_log', 'leak_photo', 'leak_incident'];
|
||||
foreach ($required_tables as $table) {
|
||||
$exists = ops_table_exists($db, $table);
|
||||
$checks[] = ops_check(
|
||||
'DB',
|
||||
"테이블 {$table}",
|
||||
$exists,
|
||||
$exists ? '확인됨' : '누락됨',
|
||||
$table === 'leak_incident' ? 'sql/migration_incident_response.sql 또는 sql/schema_v2605.sql 실행' : 'sql/schema_v2605.sql 실행',
|
||||
$table === 'leak_incident' ? 'critical' : 'warn',
|
||||
'DB 테이블 목록 또는 설치 점검 화면'
|
||||
);
|
||||
}
|
||||
|
||||
if (ops_table_exists($db, 'leak_photo')) {
|
||||
foreach (['sequence_no', 'photo_stage'] as $column) {
|
||||
$exists = ops_column_exists($db, 'leak_photo', $column);
|
||||
$checks[] = ops_check('DB', "사진 타임라인 컬럼 {$column}", $exists, $exists ? '확인됨' : '누락됨', 'sql/migration_photo_timeline.sql 실행', 'warn', 'leak_photo 컬럼 목록');
|
||||
}
|
||||
}
|
||||
|
||||
$recent_sms_fail_stmt = $db->query("
|
||||
SELECT COUNT(*)
|
||||
FROM sms_log
|
||||
WHERE status = 'fail'
|
||||
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
");
|
||||
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
|
||||
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수');
|
||||
} catch (Throwable $e) {
|
||||
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
|
||||
}
|
||||
|
||||
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000';
|
||||
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/leak-sensor.env의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
|
||||
$checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "새비밀번호" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
|
||||
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
|
||||
$checks[] = ops_check('파일', '사진 저장 폴더', is_dir(PHOTO_UPLOAD_DIR) && is_writable(PHOTO_UPLOAD_DIR), PHOTO_UPLOAD_DIR, 'uploads/photos 생성 및 쓰기 권한 확인', 'warn', '폴더 권한 화면');
|
||||
$checks[] = ops_check('파일', '상태 파일 폴더', is_dir(__DIR__ . '/var') && is_writable(__DIR__ . '/var'), __DIR__ . '/var', 'php/var 생성 및 쓰기 권한 확인', 'warn', '폴더 권한 화면');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
function ops_failed_count(array $checks): int {
|
||||
return count(array_filter($checks, fn($check) => !$check['ok']));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace duplicate setup check functions in `php/setup_wizard.php`**
|
||||
|
||||
Remove `setup_table_exists()`, `setup_column_exists()`, and `setup_check()` from `setup_wizard.php`. Add:
|
||||
|
||||
```php
|
||||
require_once __DIR__ . '/ops_checks.php';
|
||||
```
|
||||
|
||||
Replace the current manual `$checks` construction with:
|
||||
|
||||
```php
|
||||
$checks = collect_ops_checks();
|
||||
```
|
||||
|
||||
Keep the existing SMS test POST handling unchanged.
|
||||
|
||||
- [ ] **Step 3: Update setup wizard rendering for new check fields**
|
||||
|
||||
Where checks render, preserve the existing UI but read the new fields:
|
||||
|
||||
```php
|
||||
<h2><?= htmlspecialchars($check['category']) ?> · <?= htmlspecialchars($check['label']) ?> · <?= $check['ok'] ? '정상' : '확인 필요' ?></h2>
|
||||
<p><?= htmlspecialchars($check['detail']) ?></p>
|
||||
<?php if (!$check['ok'] && $check['fix'] !== ''): ?>
|
||||
<div class="fix">조치: <?= htmlspecialchars($check['fix']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (($check['evidence'] ?? '') !== ''): ?>
|
||||
<div class="fix">증적: <?= htmlspecialchars($check['evidence']) ?></div>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add evidence page link to setup wizard**
|
||||
|
||||
In the header link area, add a link:
|
||||
|
||||
```php
|
||||
<a href="security_evidence.php">보안 증적</a>
|
||||
```
|
||||
|
||||
If the header currently has a single link, convert it to a small flex group matching `dashboard.php`.
|
||||
|
||||
- [ ] **Step 5: Verify PHP syntax**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
php -l php\ops_checks.php
|
||||
php -l php\setup_wizard.php
|
||||
```
|
||||
|
||||
Expected: both commands print `No syntax errors detected`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Login-Protected Security Evidence Report
|
||||
|
||||
**Files:**
|
||||
- Create: `php/security_evidence.php`
|
||||
- Modify: `php/.htaccess`
|
||||
- Test: PHP lint and login-protected manual browser check
|
||||
|
||||
- [ ] **Step 1: Create `php/security_evidence.php`**
|
||||
|
||||
Create a login-required report page that uses `collect_ops_checks()` and supports Markdown download using `?format=md`.
|
||||
|
||||
Core structure:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// =============================================================================
|
||||
// security_evidence.php - 보안대책서 첨부 증적 보고서 (v2605)
|
||||
// =============================================================================
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/ops_checks.php';
|
||||
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.cookie_secure', 1);
|
||||
ini_set('session.cookie_samesite', 'Strict');
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
session_start();
|
||||
|
||||
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$checks = collect_ops_checks();
|
||||
$failed_count = ops_failed_count($checks);
|
||||
$generated_at = date('Y-m-d H:i:s');
|
||||
|
||||
$control_rows = [
|
||||
['사업 범위', '1차 구축 범위를 누수·오프라인·사진·SMS·대시보드로 한정', '보안대책서 사업개요', 'SECURITY_PLAN_PASS_READINESS.md'],
|
||||
['전송구간', 'HTTPS/TLS 사용, API 서명 검증', 'sensor_data.php, photo_upload.php, leak_sensor.py', 'SOURCE_SECURITY_EVIDENCE.md'],
|
||||
['API 인증', '공유 API 키와 요청 서명 검증', 'config.php, leak_sensor.py', '403 테스트 결과'],
|
||||
['비밀값', 'config.local.php와 /etc/leak-sensor.env로 분리', 'config.php, config.py', '설정 파일 권한 화면'],
|
||||
['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한', 'login.php', '로그인 화면'],
|
||||
['사진정보', 'JPEG 검증, 로그 ID 검증, 업로드 실행 차단', 'photo_upload.php, uploads/photos/.htaccess', '사진 업로드 결과'],
|
||||
['로그관리', '센서/SMS/사진/사고 로그 저장', 'MySQL', '월간 보고서'],
|
||||
['장애대응', '미확인 사고 재알림, 장비 오프라인 감지', 'incidents.php, cron_heartbeat.php', 'SMS 로그, 사고 이력'],
|
||||
];
|
||||
|
||||
if (($_GET['format'] ?? '') === 'md') {
|
||||
header('Content-Type: text/markdown; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="security-evidence-' . date('Ymd-His') . '.md"');
|
||||
echo "# 누수감지 시스템 보안 증적 보고서\n\n";
|
||||
echo "- 생성시각: {$generated_at}\n";
|
||||
echo "- 버전: " . APP_VERSION . "\n";
|
||||
echo "- 점검 결과: " . ($failed_count === 0 ? "전체 통과" : "확인 필요 {$failed_count}건") . "\n\n";
|
||||
echo "## 운영 점검 결과\n\n";
|
||||
echo "| 분류 | 항목 | 결과 | 상세 | 조치 | 증적 |\n";
|
||||
echo "|---|---|---|---|---|---|\n";
|
||||
foreach ($checks as $check) {
|
||||
echo '| ' . $check['category'] . ' | ' . $check['label'] . ' | ' . ($check['ok'] ? '정상' : '확인 필요') . ' | ' . str_replace('|', '/', $check['detail']) . ' | ' . str_replace('|', '/', $check['fix']) . ' | ' . str_replace('|', '/', $check['evidence']) . " |\n";
|
||||
}
|
||||
echo "\n## 보안통제 매트릭스\n\n";
|
||||
echo "| 영역 | 보안대책 | 구현 위치 | 확인 증적 |\n";
|
||||
echo "|---|---|---|---|\n";
|
||||
foreach ($control_rows as $row) {
|
||||
echo '| ' . implode(' | ', $row) . " |\n";
|
||||
}
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
Render the HTML body with:
|
||||
|
||||
- title: `보안 증적 보고서`
|
||||
- summary card: generated time, `APP_VERSION`, failed count
|
||||
- operational checks table
|
||||
- security control matrix table
|
||||
- download link: `security_evidence.php?format=md`
|
||||
- print button: `window.print()`
|
||||
|
||||
- [ ] **Step 2: Ensure helper files remain directly blocked**
|
||||
|
||||
Modify `php/.htaccess` `FilesMatch` to include `ops_checks`:
|
||||
|
||||
```apache
|
||||
<FilesMatch "^(config|config\.local|config\.local\.example|sms_send|incidents|ops_checks|setup_hash|test_mobile)\.php$">
|
||||
```
|
||||
|
||||
Do not block `security_evidence.php`; it is a login-protected page.
|
||||
|
||||
- [ ] **Step 3: Verify PHP syntax**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
php -l php\security_evidence.php
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `security_evidence.php` passes PHP lint.
|
||||
- `.htaccess` is not PHP; do not lint it. Instead visually confirm `ops_checks` is in the `FilesMatch` rule.
|
||||
|
||||
- [ ] **Step 4: Manual browser test after deployment**
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Unauthenticated `security_evidence.php` redirects to `login.php`.
|
||||
- Authenticated access displays checks and control matrix.
|
||||
- `?format=md` downloads Markdown without secrets.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Raspberry Pi Evidence Collector
|
||||
|
||||
**Files:**
|
||||
- Create: `raspberry_pi/collect_evidence.py`
|
||||
- Modify: `docs/INSTALL_PI_SERVER.md`
|
||||
- Test: Python compile and dry-run on development machine
|
||||
|
||||
- [ ] **Step 1: Create `raspberry_pi/collect_evidence.py`**
|
||||
|
||||
The script must not print API keys, passwords, or SMS secrets. It should report whether required env vars exist, not their values.
|
||||
|
||||
Implementation outline:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Sanitized Raspberry Pi evidence collector for security review attachments."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ENV_FILE = Path("/etc/leak-sensor.env")
|
||||
|
||||
|
||||
def run_command(command, timeout=10):
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
||||
return {
|
||||
"command": " ".join(command),
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout.strip()[:4000],
|
||||
"stderr": result.stderr.strip()[:2000],
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"command": " ".join(command), "returncode": -1, "stdout": "", "stderr": str(exc)}
|
||||
|
||||
|
||||
def env_presence():
|
||||
keys = [
|
||||
"LEAK_API_URL",
|
||||
"LEAK_PHOTO_UPLOAD_URL",
|
||||
"LEAK_API_KEY",
|
||||
"LEAK_DEVICE_ID",
|
||||
"LEAK_DEVICE_LOCATION",
|
||||
"LEAK_CAMERA_ENABLED",
|
||||
"LEAK_PHOTO_TIMELINE_ENABLED",
|
||||
"LEAK_PHOTO_TIMELINE_DELAYS",
|
||||
]
|
||||
return {key: bool(os.environ.get(key)) for key in keys}
|
||||
|
||||
|
||||
def collect():
|
||||
evidence = {
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"hostname": run_command(["hostname"])["stdout"],
|
||||
"env_file_exists": ENV_FILE.exists(),
|
||||
"env_file_permission": run_command(["ls", "-l", str(ENV_FILE)]) if ENV_FILE.exists() else None,
|
||||
"env_presence": env_presence(),
|
||||
"service_status": run_command(["systemctl", "status", "leak-sensor", "--no-pager"], timeout=15),
|
||||
"service_enabled": run_command(["systemctl", "is-enabled", "leak-sensor"], timeout=10),
|
||||
"listening_ports": run_command(["ss", "-lntup"], timeout=10),
|
||||
"camera_tools": {
|
||||
"rpicam-still": shutil.which("rpicam-still") is not None,
|
||||
"libcamera-still": shutil.which("libcamera-still") is not None,
|
||||
},
|
||||
"recent_logs": run_command(["journalctl", "-u", "leak-sensor", "-n", "80", "--no-pager"], timeout=15),
|
||||
}
|
||||
return evidence
|
||||
|
||||
|
||||
def print_markdown(evidence):
|
||||
print("# Raspberry Pi 보안/운영 증적")
|
||||
print()
|
||||
print(f"- 생성시각: {evidence['generated_at']}")
|
||||
print(f"- 호스트명: {evidence['hostname']}")
|
||||
print(f"- 환경파일 존재: {'예' if evidence['env_file_exists'] else '아니오'}")
|
||||
if evidence["env_file_permission"]:
|
||||
print(f"- 환경파일 권한: `{evidence['env_file_permission']['stdout']}`")
|
||||
print()
|
||||
print("## 환경변수 설정 여부")
|
||||
print()
|
||||
print("| 항목 | 설정 여부 |")
|
||||
print("|---|---|")
|
||||
for key, present in evidence["env_presence"].items():
|
||||
print(f"| {key} | {'설정됨' if present else '미설정'} |")
|
||||
print()
|
||||
print("## 서비스 상태")
|
||||
print()
|
||||
print("```text")
|
||||
print(evidence["service_status"]["stdout"] or evidence["service_status"]["stderr"])
|
||||
print("```")
|
||||
print()
|
||||
print("## 수신 포트")
|
||||
print()
|
||||
print("```text")
|
||||
print(evidence["listening_ports"]["stdout"] or evidence["listening_ports"]["stderr"])
|
||||
print("```")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--format", choices=["json", "md"], default="md")
|
||||
args = parser.parse_args()
|
||||
evidence = collect()
|
||||
if args.format == "json":
|
||||
print(json.dumps(evidence, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print_markdown(evidence)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Document Pi evidence command in `docs/INSTALL_PI_SERVER.md`**
|
||||
|
||||
Add under the Pi final verification section:
|
||||
|
||||
```bash
|
||||
cd /home/pi/leak_sensor
|
||||
source venv/bin/activate
|
||||
python3 collect_evidence.py --format md > pi-security-evidence.md
|
||||
```
|
||||
|
||||
Note that the output intentionally does not include raw API keys.
|
||||
|
||||
- [ ] **Step 3: Verify Python syntax**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m py_compile raspberry_pi\collect_evidence.py
|
||||
```
|
||||
|
||||
Expected: exit code 0 and no output.
|
||||
|
||||
- [ ] **Step 4: Dry-run on non-Pi development machine**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python raspberry_pi\collect_evidence.py --format json
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- JSON output is produced.
|
||||
- systemd/camera commands may show errors on Windows; this is acceptable if the script does not crash.
|
||||
- No API key value is printed.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Improve Report and Dashboard Evidence Quality
|
||||
|
||||
**Files:**
|
||||
- Modify: `php/monthly_report.php`
|
||||
- Modify: `php/dashboard.php`
|
||||
- Test: PHP lint and visual/manual verification after deployment
|
||||
|
||||
- [ ] **Step 1: Add report metadata to `php/monthly_report.php`**
|
||||
|
||||
Add near the existing `$summary` block:
|
||||
|
||||
```php
|
||||
$report_generated_at = date('Y-m-d H:i:s');
|
||||
$report_scope = '누수 감지, 장비 오프라인, SMS, 사진, 사고 대응';
|
||||
```
|
||||
|
||||
Render above the summary cards:
|
||||
|
||||
```php
|
||||
<section class="card" style="margin-bottom:18px">
|
||||
<span>보고서 생성</span>
|
||||
<strong style="font-size:1rem"><?= htmlspecialchars($report_generated_at) ?></strong>
|
||||
<p style="color:#64748b;font-size:.84rem;margin-top:8px">범위: <?= htmlspecialchars($report_scope) ?></p>
|
||||
</section>
|
||||
```
|
||||
|
||||
This makes printed/PDF reports usable as evidence.
|
||||
|
||||
- [ ] **Step 2: Extend monthly CSV with evidence metadata**
|
||||
|
||||
Before existing CSV rows, add:
|
||||
|
||||
```php
|
||||
fputcsv($out, ['generated_at', $report_generated_at]);
|
||||
fputcsv($out, ['scope', $report_scope]);
|
||||
```
|
||||
|
||||
Expected CSV begins with month, generated time, and scope.
|
||||
|
||||
- [ ] **Step 3: Add dashboard link to evidence page**
|
||||
|
||||
In `php/dashboard.php` header links, add:
|
||||
|
||||
```php
|
||||
<a href="security_evidence.php" style="color:#fff;font-size:.85rem;text-decoration:none;background:rgba(255,255,255,.15);padding:6px 14px;border-radius:6px;">보안 증적</a>
|
||||
```
|
||||
|
||||
Keep existing `setup_wizard.php`, `monthly_report.php`, and logout links.
|
||||
|
||||
- [ ] **Step 4: Add a short evidence hint near dashboard diagnostics**
|
||||
|
||||
In the operational diagnostics section, add one concise sentence:
|
||||
|
||||
```php
|
||||
<p style="color:#64748b;font-size:.84rem;margin-top:8px">보안대책서 첨부용 점검표는 상단의 보안 증적 화면에서 내려받을 수 있습니다.</p>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify PHP syntax**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
php -l php\monthly_report.php
|
||||
php -l php\dashboard.php
|
||||
```
|
||||
|
||||
Expected: both commands print `No syntax errors detected`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Finalize Documentation Package
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/OPERATIONS_SECURITY_CHECKLIST.md`
|
||||
- Create: `docs/SECURITY_EVIDENCE_PACKAGE.md`
|
||||
- Modify: `docs/README.md`
|
||||
- Modify: `docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md`
|
||||
- Modify: `docs/SOURCE_SECURITY_EVIDENCE.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
- Test: Markdown link/path sanity checks
|
||||
|
||||
- [ ] **Step 1: Create `docs/OPERATIONS_SECURITY_CHECKLIST.md`**
|
||||
|
||||
Include this structure:
|
||||
|
||||
```markdown
|
||||
# 운영·보안 점검 체크리스트
|
||||
|
||||
## 월간 점검
|
||||
|
||||
| 점검 항목 | 기준 | 증적 |
|
||||
|---|---|---|
|
||||
| 대시보드 로그인 | 관리자만 접근 가능 | 로그인 화면, 접근통제 확인 |
|
||||
| 설치 점검 | 확인 필요 항목 없음 또는 조치 계획 존재 | setup_wizard.php 캡처 |
|
||||
| 보안 증적 | 점검 결과와 보안통제 매트릭스 확인 | security_evidence.php 캡처 또는 MD 다운로드 |
|
||||
| Pi 서비스 | leak-sensor active 상태 | collect_evidence.py 결과 |
|
||||
| SMS 발송 | 최근 실패 건수 확인 | monthly_report.php 또는 sms_log |
|
||||
| 사진 저장 | 사진 타임라인 확인 | dashboard.php 캡처 |
|
||||
| 백업 | DB/사진/설정 백업 확인 | 백업 파일 목록 |
|
||||
| 보관기간 | 사진/로그 기간 초과분 정리 | 삭제/정리 기록 |
|
||||
|
||||
## 사고 발생 시
|
||||
|
||||
1. 대시보드에서 사고 상태와 사진 타임라인을 확인한다.
|
||||
2. SMS 수신 여부와 `sms_log` 실패 여부를 확인한다.
|
||||
3. 현장 조치 후 사고 상태를 `조치 완료` 또는 `오탐`으로 기록한다.
|
||||
4. 월간 보고서에 사고 처리 내역이 반영됐는지 확인한다.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `docs/SECURITY_EVIDENCE_PACKAGE.md`**
|
||||
|
||||
Include:
|
||||
|
||||
```markdown
|
||||
# 보안대책서 첨부자료 패키지
|
||||
|
||||
## 필수 첨부
|
||||
|
||||
| 첨부자료 | 생성 위치 | 증명하는 것 |
|
||||
|---|---|---|
|
||||
| 보안 증적 보고서 | `php/security_evidence.php?format=md` | 보안통제와 운영 점검 연결 |
|
||||
| 설치 점검 화면 | `php/setup_wizard.php` | DB/권한/SMS/설정 상태 |
|
||||
| 대시보드 화면 | `php/dashboard.php` | 현재 위험, 사고, 사진, 장비 상태 |
|
||||
| 월간 보고서 | `php/monthly_report.php` | SMS/사진/사고 처리 현황 |
|
||||
| Pi 증적 보고서 | `raspberry_pi/collect_evidence.py --format md` | Pi 서비스, 포트, 환경파일 상태 |
|
||||
| HTTPS 접속 화면 | 운영 도메인 브라우저 | 전송구간 보호 적용 |
|
||||
|
||||
## 제출 전 마스킹
|
||||
|
||||
- API 키 원문 제거
|
||||
- DB 비밀번호 제거
|
||||
- SMS secure key 제거
|
||||
- 관리자 비밀번호 원문 제거
|
||||
- 담당자 휴대전화번호는 필요 시 뒷자리 마스킹
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `docs/README.md`**
|
||||
|
||||
Add the new documents under folder structure:
|
||||
|
||||
```text
|
||||
OPERATIONS_SECURITY_CHECKLIST.md # 월간 운영·보안 점검표
|
||||
SECURITY_EVIDENCE_PACKAGE.md # 보안대책서 첨부자료 패키지
|
||||
```
|
||||
|
||||
Add an operations section that points to:
|
||||
|
||||
- `setup_wizard.php`
|
||||
- `security_evidence.php`
|
||||
- `monthly_report.php`
|
||||
- `raspberry_pi/collect_evidence.py`
|
||||
|
||||
- [ ] **Step 4: Update security guide docs**
|
||||
|
||||
In `docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md`, add that API/source evidence is generated by:
|
||||
|
||||
```text
|
||||
php/security_evidence.php
|
||||
raspberry_pi/collect_evidence.py
|
||||
docs/SOURCE_SECURITY_EVIDENCE.md
|
||||
```
|
||||
|
||||
In `docs/SOURCE_SECURITY_EVIDENCE.md`, add:
|
||||
|
||||
```text
|
||||
운영 서버 반영 후 `security_evidence.php?format=md`를 내려받으면 코드 기반 보안통제와 운영 점검 결과를 하나의 증적 파일로 보관할 수 있다.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `CHANGELOG.md`**
|
||||
|
||||
Add under v2605:
|
||||
|
||||
```markdown
|
||||
- 보안대책서 첨부용 증적 보고서 계획과 운영·보안 체크리스트를 추가했습니다.
|
||||
- Pi 증적 수집 스크립트 계획을 추가해 API 키 원문 없이 서비스/포트/환경파일 상태를 확인할 수 있게 했습니다.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify Markdown paths**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem docs -Recurse -File | Where-Object { $_.Extension -eq '.md' } | Select-String -Pattern 'security_evidence.php|collect_evidence.py|OPERATIONS_SECURITY_CHECKLIST|SECURITY_EVIDENCE_PACKAGE'
|
||||
```
|
||||
|
||||
Expected: new docs and links appear in README/security docs.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full Verification Pass
|
||||
|
||||
**Files:**
|
||||
- All changed PHP, Python, Markdown files
|
||||
|
||||
- [ ] **Step 1: Run PHP lint**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$files = @(
|
||||
'php\config.php',
|
||||
'php\ops_checks.php',
|
||||
'php\setup_wizard.php',
|
||||
'php\security_evidence.php',
|
||||
'php\dashboard.php',
|
||||
'php\monthly_report.php',
|
||||
'php\api\sensor_data.php',
|
||||
'php\api\photo_upload.php',
|
||||
'php\login.php',
|
||||
'php\sms_send.php',
|
||||
'php\incidents.php',
|
||||
'php\cron_heartbeat.php'
|
||||
)
|
||||
foreach ($file in $files) { php -l $file }
|
||||
```
|
||||
|
||||
Expected: every file reports `No syntax errors detected`.
|
||||
|
||||
- [ ] **Step 2: Run Python compile checks**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
python -m py_compile raspberry_pi\config.py raspberry_pi\leak_sensor.py raspberry_pi\test_connection.py raspberry_pi\collect_evidence.py scripts\generate_security_plan_images.py
|
||||
```
|
||||
|
||||
Expected: exit code 0 and no output.
|
||||
|
||||
- [ ] **Step 3: Search for stale secrets and legacy values**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem -Recurse -File |
|
||||
Where-Object { $_.FullName -notlike '*\_backup\*' } |
|
||||
Select-String -Pattern '<legacy-patterns-redacted>' -CaseSensitive:$false
|
||||
```
|
||||
|
||||
Expected: no matches outside intentional documentation examples.
|
||||
|
||||
- [ ] **Step 4: Confirm no Python cache remains**
|
||||
|
||||
If `py_compile` creates cache files, remove them after verifying the resolved path is inside the workspace:
|
||||
|
||||
```powershell
|
||||
$target = Resolve-Path -LiteralPath 'raspberry_pi\__pycache__' -ErrorAction SilentlyContinue
|
||||
if ($target) {
|
||||
$workspace = (Resolve-Path -LiteralPath '.').Path
|
||||
if ($target.Path.StartsWith($workspace)) {
|
||||
Remove-Item -LiteralPath $target.Path -Recurse -Force
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual deployment verification checklist**
|
||||
|
||||
After uploading to Cafe24 and Pi:
|
||||
|
||||
- Visit `login.php`; verify login is required.
|
||||
- Visit `setup_wizard.php`; capture the result.
|
||||
- Visit `security_evidence.php`; capture the result.
|
||||
- Download `security_evidence.php?format=md`; confirm no secrets are present.
|
||||
- Visit `dashboard.php`; confirm evidence link and diagnostics.
|
||||
- Visit `monthly_report.php`; print/PDF preview includes generated time and scope.
|
||||
- Run `python3 collect_evidence.py --format md` on Pi; confirm no API key is printed.
|
||||
- Run `python3 test_connection.py` on Pi; confirm unsigned request is rejected and signed request succeeds.
|
||||
|
||||
Expected: these items produce the minimum security evidence package.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Completion Notes
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/SECURITY_PLAN_PASS_READINESS.md`
|
||||
- Modify: `docs/SECURITY_PLAN_HWP_REVIEW.md`
|
||||
|
||||
- [ ] **Step 1: Point final security docs to the new evidence outputs**
|
||||
|
||||
In `docs/SECURITY_PLAN_PASS_READINESS.md`, add references to:
|
||||
|
||||
- `php/security_evidence.php`
|
||||
- `php/security_evidence.php?format=md`
|
||||
- `raspberry_pi/collect_evidence.py --format md`
|
||||
- `docs/SECURITY_EVIDENCE_PACKAGE.md`
|
||||
|
||||
- [ ] **Step 2: Clarify remaining user-provided evidence**
|
||||
|
||||
Keep the remaining user-provided list short:
|
||||
|
||||
```text
|
||||
1. 실제 서버 도메인과 HTTPS 화면
|
||||
2. Pi 네트워크 분리 또는 방화벽 정책 확인
|
||||
3. 설치 위치 사진과 자산 정보
|
||||
4. 백업 주기와 담당자
|
||||
5. SMS 수신자 현행화 확인
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify final docs contain no over-claim**
|
||||
|
||||
Search:
|
||||
|
||||
```powershell
|
||||
Select-String -Path docs\SECURITY_PLAN_*.md,docs\SOURCE_SECURITY_EVIDENCE.md -Pattern '국정원.*통과|완벽|보장|반드시 통과'
|
||||
```
|
||||
|
||||
Expected: no text claims guaranteed approval.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Map
|
||||
|
||||
| Requirement | Implemented by |
|
||||
|---|---|
|
||||
| 심사 대응 패키지 | Tasks 1, 2, 5, 7 |
|
||||
| 코드 기반 보안근거 | Tasks 2, 5, 7 |
|
||||
| 운영 실패 가시화 | Tasks 1, 3, 4 |
|
||||
| 대시보드/보고서 증적 품질 | Task 4 |
|
||||
| 자동 증적 생성 | Tasks 2, 3 |
|
||||
| 확장 범위 분리 | Task 7 plus existing security docs |
|
||||
| 비밀값 미노출 | Tasks 2, 3, 6 |
|
||||
|
||||
## Execution Notes
|
||||
|
||||
- This workspace currently has no `.git` directory, so commit steps are skipped in this workspace.
|
||||
- Do not put real API keys, DB passwords, SMS secure keys, or administrator passwords into Markdown.
|
||||
- If a deployed Cafe24 behavior cannot be verified locally, document it as an operator evidence requirement instead of claiming it as verified.
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
# 누수감지 시스템 운영·보안 완성형 개선 요구사항
|
||||
|
||||
> 참고: 이 문서는 누수감지 시절의 기록(레거시)이며, 현재 시스템은 SHT30 온습도 전용으로 전환되었습니다.
|
||||
|
||||
작성일: 2026-05-20
|
||||
상태: 사용자 승인 방향 기반 초안
|
||||
대상 프로젝트: `leak_sms_v2` v2605
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 프로젝트는 누수 감지, Raspberry Pi 전송, Cafe24 PHP API, MySQL 저장, SMS 알림, 사진 업로드, 대시보드, 사고 대응, 월간 보고서까지 기본 운영 흐름을 갖췄다.
|
||||
|
||||
다음 개선의 목표는 기능을 무작정 확장하는 것이 아니라, **제출 가능한 보안대책서와 현장 운영 가능한 시스템**으로 완성도를 올리는 것이다. 화재 감지, 전원 이상, 다중 지점 같은 확장은 지금 바로 구현하지 않고 향후 확장 범위로 분리한다.
|
||||
|
||||
## 2. 목표
|
||||
|
||||
1. 보안성 검토 또는 기관 보안검토에 대응할 수 있는 제출 패키지를 만든다.
|
||||
2. 실제 현장 운영 중 실패할 수 있는 지점(API, SMS, 카메라, Pi, DB)을 더 잘 드러내고 대응 가능하게 만든다.
|
||||
3. 운영자가 대시보드와 보고서에서 현재 위험, 미확인 사고, 사진, SMS 실패, 장비 상태를 빠르게 판단하게 한다.
|
||||
4. 설치·운영·보안 증적을 수동 설명이 아니라 시스템과 문서에서 뽑을 수 있게 한다.
|
||||
5. 향후 화재/전원/다중 Pi 확장을 위해 문서상 경계와 재검토 조건을 정리한다.
|
||||
|
||||
## 3. 비목표
|
||||
|
||||
- 이번 단계에서 화재 감지 센서를 실제 구현하지 않는다.
|
||||
- 이번 단계에서 전원 이상 감지 회로를 실제 구현하지 않는다.
|
||||
- 이번 단계에서 다중 기관/다중 고객용 제품 구조로 전환하지 않는다.
|
||||
- 이번 단계에서 Cafe24 외 별도 클라우드, 앱, 푸시 알림을 추가하지 않는다.
|
||||
- 이번 단계에서 HWP 파일을 직접 자동 편집하지 않는다. HWP에 붙일 문구, 표, 이미지, 첨부자료를 준비한다.
|
||||
|
||||
## 4. 사용자와 이해관계자
|
||||
|
||||
| 사용자 | 필요한 것 |
|
||||
|---|---|
|
||||
| 운영 담당자 | 누수, 장비 오프라인, SMS 실패, 사진 상태를 빠르게 확인하고 조치 기록 |
|
||||
| 보안 담당자 | 보안통제, 구현 근거, 증적, 잔여위험을 확인 |
|
||||
| 사업/관리 담당자 | 제출 가능한 보안대책서와 설치·운영 산출물 |
|
||||
| 현장 점검자 | Pi, 센서, 카메라, 네트워크 상태 점검 절차 |
|
||||
|
||||
## 5. 개선 접근
|
||||
|
||||
선택한 접근은 **운영·보안 완성형**이다.
|
||||
|
||||
### 5.1 Phase 1: 심사 대응 패키지
|
||||
|
||||
보안대책서와 실제 구현 사이의 간극을 줄인다.
|
||||
|
||||
요구사항:
|
||||
|
||||
- 보안대책서에 넣을 보안통제 매트릭스를 완성한다.
|
||||
- 소스코드 기반 보안근거 문서를 유지한다.
|
||||
- 사용자가 제공해야 할 자료와 코드에서 이미 설명 가능한 자료를 분리한다.
|
||||
- HTTPS, API 서명, 사진 업로드 검증, 관리자 인증, 비밀값 분리, 로그관리, 백업, 물리보안을 각각 증적과 연결한다.
|
||||
- 최종 제출 전 “첨부자료 체크리스트”를 운영자가 따라갈 수 있게 한다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
- 보안대책서 본문에 넣을 표와 문구가 준비되어 있다.
|
||||
- 제출용 첨부자료 목록이 명확하다.
|
||||
- API/HTTPS/인증/사진정보 관련 질문에 코드 근거로 답할 수 있다.
|
||||
- 사용자가 제공해야 하는 자료가 10개 내외의 실무 항목으로 정리되어 있다.
|
||||
|
||||
### 5.2 Phase 2: 현장 운영 안정성
|
||||
|
||||
실패 상황을 숨기지 않고 운영자가 볼 수 있게 한다.
|
||||
|
||||
요구사항:
|
||||
|
||||
- Pi 오프라인 상태가 대시보드에서 명확히 보여야 한다.
|
||||
- SMS 발송 실패가 대시보드와 월간 보고서에 드러나야 한다.
|
||||
- 카메라 촬영 실패 또는 사진 업로드 실패가 Pi 로그에만 묻히지 않도록 운영 점검 항목에 포함해야 한다.
|
||||
- API 실패, DB 실패, SMS 실패, 사진 실패를 장애 유형별 대응 절차와 연결한다.
|
||||
- 백업·복구 점검 기준을 문서와 운영 체크리스트에 반영한다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
- 운영자가 “지금 감시가 정상인지” 1분 안에 판단할 수 있다.
|
||||
- 장애 유형별로 확인 위치와 조치가 문서화되어 있다.
|
||||
- 월간 보고서에서 SMS 실패와 사고 처리 상태를 확인할 수 있다.
|
||||
|
||||
### 5.3 Phase 3: 관리자 UX와 보고서 품질
|
||||
|
||||
대시보드와 보고서를 심사자·운영자 관점에서 읽기 쉽게 만든다.
|
||||
|
||||
요구사항:
|
||||
|
||||
- 대시보드 첫 화면은 위험 상태, 미확인 사고, 오프라인 장비, 최근 사진을 우선 보여준다.
|
||||
- 설치 점검 화면은 통과/경고/실패를 명확히 구분한다.
|
||||
- 월간 보고서는 출력물로 제출해도 의미가 있도록 요약, 사고 내역, SMS/사진 현황을 정리한다.
|
||||
- 사진 타임라인은 사고별로 묶어 누수 상황을 이해하기 쉽게 표시한다.
|
||||
- 모바일에서도 운영자가 핵심 상태를 확인할 수 있어야 한다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
- 대시보드만 보고도 현재 위험 우선순위를 알 수 있다.
|
||||
- 월간 보고서 출력물이 보안대책서 증적 또는 운영 보고 자료로 쓸 수 있다.
|
||||
- 설치 점검 결과를 캡처하면 보안 증적으로 이해 가능하다.
|
||||
|
||||
### 5.4 Phase 4: 자동 증적 생성
|
||||
|
||||
운영자가 보안대책서 첨부자료를 반복 수작업으로 만들지 않게 한다.
|
||||
|
||||
요구사항:
|
||||
|
||||
- 설치 점검 결과를 HTML 또는 CSV로 저장할 수 있게 한다.
|
||||
- API 연결 테스트 결과를 문서에 붙일 수 있는 형태로 출력한다.
|
||||
- SMS 테스트 결과와 최근 SMS 실패 현황을 확인 가능하게 한다.
|
||||
- Pi 상태 확인 명령과 결과 양식을 문서화한다.
|
||||
- 보안통제 매트릭스의 각 항목이 어떤 증적으로 확인되는지 연결한다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
- 보안대책서 첨부자료를 준비할 때 “무엇을 캡처해야 하는지”가 명확하다.
|
||||
- 운영자는 실제 비밀번호/API 키를 노출하지 않고 증적을 만들 수 있다.
|
||||
- 최소 증적 5개만으로도 시스템 보안구조를 설명할 수 있다.
|
||||
|
||||
### 5.5 Phase 5: 확장 준비
|
||||
|
||||
확장은 설계상 가능하게 두되, 현 단계 심사 범위를 늘리지 않는다.
|
||||
|
||||
요구사항:
|
||||
|
||||
- 화재 감지, 전원 이상, 다중 Pi, 다중 위치는 “향후 확장”으로 분리한다.
|
||||
- 확장 시 보안성 재검토 조건을 명확히 한다.
|
||||
- 현재 HWP에는 1차 구축 범위를 누수/오프라인 중심으로 제한한다.
|
||||
- 센서 추가 시 필요한 보안통제 항목을 별도 표로 남긴다.
|
||||
|
||||
성공 기준:
|
||||
|
||||
- 현재 제출 범위가 과대 기재되지 않는다.
|
||||
- 확장 요구가 생겨도 보안대책서에서 어떤 절차를 따라야 하는지 설명 가능하다.
|
||||
|
||||
## 6. 우선순위
|
||||
|
||||
| 순위 | 개선 항목 | 이유 |
|
||||
|---:|---|---|
|
||||
| 1 | 보안대책서/증적 패키지 | 승인·심사 리스크가 가장 큼 |
|
||||
| 2 | 운영 실패 가시화 | 실제 누수 상황에서 신뢰성에 직결 |
|
||||
| 3 | 대시보드/보고서 품질 | 운영자와 심사자 모두에게 중요 |
|
||||
| 4 | 자동 증적 생성 | 반복 작업과 제출 준비 부담 감소 |
|
||||
| 5 | 확장 준비 | 범위를 늘리지 않고 향후 대응력 확보 |
|
||||
|
||||
## 7. 산출물
|
||||
|
||||
이미 존재하거나 보완할 산출물:
|
||||
|
||||
- `docs/SECURITY_PLAN_HWP_REVIEW.md`
|
||||
- `docs/SECURITY_PLAN_PASS_READINESS.md`
|
||||
- `docs/SECURITY_PLAN_ATTACHMENT_GUIDE.md`
|
||||
- `docs/SOURCE_SECURITY_EVIDENCE.md`
|
||||
- `docs/INSTALL_PI_SERVER.md`
|
||||
- `docs/assets/security_plan/generated/*.png`
|
||||
|
||||
추가로 만들 산출물:
|
||||
|
||||
- 운영 점검 체크리스트
|
||||
- 보안대책서 최종 첨부자료 패키지 목차
|
||||
- 설치 점검 결과 저장 기능
|
||||
- API/사진 업로드 테스트 결과 저장 양식
|
||||
- 월간 보고서 제출용 출력 개선안
|
||||
- 장애 대응 절차표
|
||||
|
||||
## 8. 주요 위험과 대응
|
||||
|
||||
| 위험 | 대응 |
|
||||
|---|---|
|
||||
| HWP 사업 범위가 실제 구현보다 넓음 | 1차 범위를 누수/오프라인 중심으로 정정 |
|
||||
| HTTPS 적용이 코드만으로 증명되지 않음 | 실제 도메인 인증서/SSL 설정 화면을 증적으로 요구 |
|
||||
| API 키 원문 노출 위험 | 문서에는 원문 미기재, 일치 여부만 증명 |
|
||||
| 외부 웹호스팅에 대한 심사 질문 | 업무망 분리, 아웃바운드 HTTPS, 최소정보 저장 원칙 명시 |
|
||||
| 사진정보가 민감 시설정보로 해석될 위험 | 목적 제한, 접근 제한, 보관기간, 삭제 기준 명시 |
|
||||
| 기능 확장이 심사 범위를 키움 | 화재/전원/다중 Pi는 향후 확장으로 분리 |
|
||||
|
||||
## 9. 성공 상태
|
||||
|
||||
이 개선이 끝났다고 볼 수 있는 상태는 다음과 같다.
|
||||
|
||||
- HWP 보안대책서에 넣을 문구, 표, 이미지, 첨부자료가 준비되어 있다.
|
||||
- 사용자는 어떤 자료를 직접 제공해야 하는지 알고 있다.
|
||||
- 코드 기반 보안대책은 문서화되어 있다.
|
||||
- 대시보드와 보고서는 운영·심사 증적으로 쓸 수 있다.
|
||||
- 현장 장애 상황별 대응 절차가 문서화되어 있다.
|
||||
- 확장 기능은 현재 범위와 분리되어 보안성 검토 리스크를 키우지 않는다.
|
||||
|
||||
## 10. 다음 단계
|
||||
|
||||
다음 단계에서는 이 요구사항을 아래 순서의 구현 계획으로 분해한다.
|
||||
|
||||
1. 보안대책서 제출 패키지 정리
|
||||
2. 운영 실패 가시화 개선
|
||||
3. 대시보드/보고서 증적 품질 개선
|
||||
4. 자동 증적 생성 기능
|
||||
5. 확장 범위 문서화
|
||||
79
docs/wiring_diagram.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# SHT30 온습도 센서 ↔ STM32F407VGT6 I2C 배선도
|
||||
|
||||
Sensirion SHT30(한진데이터 P4422-3 모듈)을 STM32F407VGT6의 I2C1 버스에 연결합니다. 단일 보드 `sht30_fw`(`sensor_id=2`, `device_id=stm32-sht30-01`)가 5분 주기로 온습도를 측정해 유선 Ethernet/TLS로 서버에 보고합니다.
|
||||
|
||||
상세 핀맵·전원·클럭원은 [firmware/docs/HARDWARE.md](../firmware/docs/HARDWARE.md) §3을 따릅니다. 본 문서는 SHT30 I2C 결선 요약입니다.
|
||||
|
||||
## 센서 동작 개요
|
||||
|
||||
```
|
||||
SHT30 (I2C, 7-bit 주소 0x44)
|
||||
명령 0x2C06 (high-repeatability, clock-stretch off)
|
||||
→ 약 20ms 대기 → 6바이트 read (T 2B + CRC, RH 2B + CRC)
|
||||
→ 펌웨어가 온도(℃)/상대습도(%)로 변환
|
||||
```
|
||||
|
||||
SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드레인이므로 외부 풀업이 필요합니다(모듈에 내장 풀업이 있으면 생략).
|
||||
|
||||
---
|
||||
|
||||
## 필요 부품 (최소 구성)
|
||||
|
||||
| 부품 | 규격 | 용도 |
|
||||
|------|------|------|
|
||||
| STM32F407VGT6 보드 | LQFP-100, Cortex-M4F | 메인 컨트롤러 (`sht30_fw`) |
|
||||
| SHT30 온습도 모듈 | 한진데이터 P4422-3 (I2C) | 온도/습도 측정 |
|
||||
| 풀업 저항 | **4.7kΩ × 2** | SCL/SDA 풀업 (모듈 내장 풀업 시 생략) |
|
||||
| LAN8720 PHY 모듈 | RMII | 유선 Ethernet |
|
||||
| 3.3V 전원 | - | MCU/PHY/SHT30 공통 레일 |
|
||||
| 점퍼 와이어 | - | 연결 |
|
||||
|
||||
---
|
||||
|
||||
## I2C 배선 (STM32 I2C1)
|
||||
|
||||
| STM32 핀 | 신호 | AF | SHT30 핀 | 비고 |
|
||||
|----------|------|----|----------|------|
|
||||
| **PB6** | `I2C1_SCL` | AF4 | SCL | 오픈드레인, 4.7kΩ 풀업 → 3.3V |
|
||||
| **PB7** | `I2C1_SDA` | AF4 | SDA | 오픈드레인, 4.7kΩ 풀업 → 3.3V |
|
||||
| 3.3V | 전원 | - | VDD | 공통 3.3V 레일 |
|
||||
| GND | 접지 | - | GND | 공통 GND |
|
||||
|
||||
- 7-bit 주소: **0x44** (`APP_SHT30_I2C_ADDR`). ADDR 핀을 GND에 연결한 기본값. ADDR을 VDD에 연결하면 0x45.
|
||||
- I2C 속도: 100kHz(Standard) 권장.
|
||||
|
||||
### 회로도
|
||||
|
||||
```
|
||||
SHT30 모듈 STM32F407
|
||||
┌────────┐
|
||||
│ VDD ──┼────────────── 3.3V ──┬──[4.7kΩ]──┐ ┌──[4.7kΩ]── 3.3V
|
||||
│ SCL ──┼────────────── PB6 ───┘ │ │
|
||||
│ SDA ──┼────────────── PB7 ────────────────┼───┘
|
||||
│ GND ──┼────────────── GND │
|
||||
│ ADDR ─┼── GND (주소 0x44; VDD 면 0x45) │
|
||||
└────────┘
|
||||
```
|
||||
|
||||
> ADDR 핀 결선으로 0x44(ADDR→GND) / 0x45(ADDR→VDD)가 결정됩니다. 본 설계는 **0x44** 고정입니다.
|
||||
|
||||
---
|
||||
|
||||
## 동작 확인
|
||||
|
||||
펌웨어는 USART3(PD8 TX / PD9 RX, AF7, 115200 8N1) 콘솔에 측정/보고 로그를 출력합니다. 첫 부팅 후 다음을 확인합니다.
|
||||
|
||||
- I2C1 초기화 및 SHT30 주소 0x44 응답
|
||||
- 첫 측정값(온도/습도) 및 startup 보고
|
||||
- 네트워크/SNTP/TLS 핸드셰이크 → 서버 200 응답
|
||||
|
||||
정상 측정 범위(벗어나면 `metric_status="out_of_range"`): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- SCL/SDA는 오픈드레인이므로 **외부 풀업(4.7kΩ)** 없이 동작하지 않습니다(모듈 내장 풀업이 있는 경우만 예외).
|
||||
- I2C1(PB6/PB7)과 RMII(PB11/PB12/PB13)는 같은 PB 포트지만 핀이 분리되어 충돌이 없습니다.
|
||||
- 케이블이 길면(1m 이상) I2C 풀업을 더 낮은 값(예 2.2kΩ)으로 조정하거나 속도를 낮춥니다.
|
||||
- 측정 주기는 펌웨어 `APP_SHT30_REPORT_INTERVAL_SEC`(기본 300초)로 설정합니다.
|
||||
70
firmware/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# =============================================================================
|
||||
# firmware/.gitignore - STM32F407 펌웨어
|
||||
# =============================================================================
|
||||
|
||||
# ── 빌드 산출물 ──────────────────────────────────────────────────────────────
|
||||
build/
|
||||
out/
|
||||
cmake-build-*/
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
*.ninja
|
||||
.ninja_deps
|
||||
.ninja_log
|
||||
compile_commands.json
|
||||
|
||||
# 오브젝트/라이브러리/실행 이미지
|
||||
*.o
|
||||
*.obj
|
||||
*.a
|
||||
*.lib
|
||||
*.elf
|
||||
*.bin
|
||||
*.hex
|
||||
*.srec
|
||||
|
||||
# 링커/디버그 부산물
|
||||
*.map
|
||||
*.lst
|
||||
*.su # -fstack-usage
|
||||
*.d # gcc 의존성 파일
|
||||
*.dump
|
||||
*.list
|
||||
|
||||
# ── 비밀값 (절대 커밋 금지) ──────────────────────────────────────────────────
|
||||
# 운영자가 secrets.h.example 을 복사해 만든 실제 비밀값. (APP_API_KEY 등)
|
||||
common/secrets.h
|
||||
secrets.h
|
||||
*.key
|
||||
*.pem
|
||||
# 단, 예시 템플릿은 추적한다.
|
||||
!common/secrets.h.example
|
||||
|
||||
# ── 인증서 작업 산출물 ───────────────────────────────────────────────────────
|
||||
# 운영자가 추출한 원본 PEM/체인은 커밋하지 않는다(임베드된 server_ca.c 만 추적).
|
||||
certs/*.pem
|
||||
certs/*.crt
|
||||
certs/chain_all.pem
|
||||
certs/cafe24_root.pem
|
||||
|
||||
# ── 벤더링된 제3자 소스 (대용량, 외부 저장소 사본) ──────────────────────────
|
||||
# 폐쇄망용으로 빌드 머신에서 vendor.ps1/vendor.sh 로 채운다. git 미추적.
|
||||
# (README/스크립트는 추적, 실제 소스 트리는 제외)
|
||||
third_party/*
|
||||
!third_party/README.md
|
||||
!third_party/vendor.ps1
|
||||
!third_party/vendor.sh
|
||||
|
||||
# ── 호스트 테스트 부산물 ─────────────────────────────────────────────────────
|
||||
test/host/__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ── IDE / OS ─────────────────────────────────────────────────────────────────
|
||||
.vscode/
|
||||
.idea/
|
||||
.cache/
|
||||
*.swp
|
||||
*~
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
283
firmware/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# =============================================================================
|
||||
# CMakeLists.txt - SHT30 온습도 모니터링 STM32F407 펌웨어
|
||||
#
|
||||
# 타깃:
|
||||
# sht30_fw : SHT30 온습도 보드(-DBOARD_SHT30, sensor_id=2) board_sht30/*
|
||||
#
|
||||
# 스택: STM32CubeF4 HAL + CMSIS / FreeRTOS-Kernel(네이티브 API) / LwIP / mbedTLS.
|
||||
# 전부 firmware/third_party 아래에 "벤더링"되어 있어야 한다(폐쇄망 — 빌드/런타임
|
||||
# 모두 네트워크 금지). 벤더링은 networked 빌드 머신에서 scripts/vendor.{ps1,sh}
|
||||
# 로 1회 수행한다.
|
||||
#
|
||||
# 구성(configure):
|
||||
# cmake -S firmware -B firmware/build -G Ninja \
|
||||
# -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake \
|
||||
# -DCMAKE_BUILD_TYPE=Release
|
||||
# cmake --build firmware/build
|
||||
#
|
||||
# 산출물(타깃별): <name>.elf / <name>.bin / <name>.hex / <name>.map + 크기 출력.
|
||||
# =============================================================================
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
# 툴체인 파일 없이 호스트 컴파일러로 들어오는 사고 방지(친절한 에러).
|
||||
if(NOT CMAKE_TOOLCHAIN_FILE AND NOT CMAKE_CROSSCOMPILING)
|
||||
message(WARNING
|
||||
"툴체인 파일이 지정되지 않았습니다. arm-none-eabi 크로스 빌드를 위해 "
|
||||
"-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-toolchain.cmake 를 사용하세요.")
|
||||
endif()
|
||||
|
||||
project(sht30_sensor_fw
|
||||
VERSION 26.6.0
|
||||
DESCRIPTION "STM32F407 SHT30 temperature/humidity sensor firmware (air-gapped)"
|
||||
LANGUAGES C ASM)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# 크기/성능 균형: -Os(크기 최적화) + 디버그 심볼(.elf 에만, .bin 비대화 없음).
|
||||
set(CMAKE_C_FLAGS_RELEASE "-Os -g")
|
||||
set(CMAKE_C_FLAGS_RELWITHDEBINFO "-Os -g")
|
||||
set(CMAKE_C_FLAGS_DEBUG "-Og -g3")
|
||||
|
||||
# =============================================================================
|
||||
# 경로 변수
|
||||
# =============================================================================
|
||||
set(FW_ROOT "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(FW_COMMON "${FW_ROOT}/common")
|
||||
set(FW_CONFIG "${FW_ROOT}/config")
|
||||
set(FW_CERTS "${FW_ROOT}/certs")
|
||||
set(FW_LD "${FW_ROOT}/ld/STM32F407VGTx_FLASH.ld")
|
||||
set(TP "${FW_ROOT}/third_party") # 벤더링 루트 (폐쇄망)
|
||||
|
||||
# 벤더 트리 (scripts/vendor.* 가 채운다). 핀 고정 버전 가정.
|
||||
# TODO(vendor): 아래 하위 경로/파일명은 핀 고정 버전에 맞춰 vendor 스크립트와
|
||||
# 동기화할 것. 버전이 바뀌면 GLOB 결과 및 startup/포트 경로가 달라질 수 있다.
|
||||
set(CUBE "${TP}/STM32CubeF4")
|
||||
set(CMSIS "${CUBE}/Drivers/CMSIS")
|
||||
set(HAL "${CUBE}/Drivers/STM32F4xx_HAL_Driver")
|
||||
set(FREERTOS "${TP}/FreeRTOS-Kernel")
|
||||
set(LWIP "${TP}/lwip")
|
||||
set(MBEDTLS "${TP}/mbedtls")
|
||||
|
||||
# 벤더 트리 부재/미벤더링 시 configure 단계에서 명확히 안내한다.
|
||||
# third_party 디렉터리 자체가 없거나, 디렉터리는 있으나 각 의존성 트리가 아직
|
||||
# 없으면(README 만 있는 미벤더링 상태) "Cannot find source file" 같은 모호한
|
||||
# 에러 대신 친절한 안내를 띄운다.
|
||||
set(_vendor_missing "")
|
||||
foreach(_v "${CUBE}" "${FREERTOS}" "${LWIP}" "${MBEDTLS}")
|
||||
if(NOT EXISTS "${_v}")
|
||||
list(APPEND _vendor_missing "${_v}")
|
||||
endif()
|
||||
endforeach()
|
||||
if(_vendor_missing)
|
||||
string(REPLACE ";" "\n " _vendor_missing_str "${_vendor_missing}")
|
||||
message(FATAL_ERROR
|
||||
"벤더링되지 않은 의존성이 있습니다(폐쇄망 빌드 전 벤더링 필요).\n"
|
||||
" 누락:\n ${_vendor_missing_str}\n"
|
||||
" networked 빌드 머신에서 먼저 실행하세요:\n"
|
||||
" pwsh firmware/scripts/vendor.ps1 (또는 firmware/scripts/vendor.sh)")
|
||||
endif()
|
||||
|
||||
# =============================================================================
|
||||
# 컴파일 정의 (전 타깃 공통)
|
||||
# =============================================================================
|
||||
# STM32F407xx : CMSIS 디바이스 선택
|
||||
# USE_HAL_DRIVER : HAL 활성화
|
||||
# (USE_FULL_LL_DRIVER 는 정의하지 않음 → LL 미사용)
|
||||
# MBEDTLS_CONFIG_FILE: 메모리 절약형 mbedtls 설정(firmware/common 또는 config 에 위치)
|
||||
set(COMMON_DEFS
|
||||
STM32F407xx
|
||||
USE_HAL_DRIVER
|
||||
MBEDTLS_CONFIG_FILE="mbedtls_config.h"
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 인클루드 디렉터리 (전 타깃 공통)
|
||||
# - 프로젝트 헤더: common(컨트랙트), config(FreeRTOSConfig/lwipopts/mbedtls_config),
|
||||
# certs(server_ca)
|
||||
# - 벤더 헤더: CMSIS, HAL Inc, FreeRTOS include + portable/GCC/ARM_CM4F,
|
||||
# lwip src/include, mbedtls include
|
||||
#
|
||||
# 참고: mbedtls_config.h / lwipopts.h / stm32f4xx_hal_conf.h 는 프로젝트 측
|
||||
# config 또는 common 에 둔다(타 서브에이전트 산출). 두 경로 모두 추가하여
|
||||
# 배치 위치에 무관하게 찾도록 한다. TODO(hw): 실제 배치 확정 시 한 곳으로 정리.
|
||||
# =============================================================================
|
||||
set(COMMON_INCLUDES
|
||||
${FW_COMMON}
|
||||
${FW_CONFIG}
|
||||
${FW_CERTS}
|
||||
|
||||
# ── CMSIS / HAL ──────────────────────────────────────────────────────────
|
||||
${CMSIS}/Include
|
||||
${CMSIS}/Device/ST/STM32F4xx/Include
|
||||
${HAL}/Inc
|
||||
${HAL}/Inc/Legacy
|
||||
|
||||
# ── FreeRTOS (네이티브 API) ──────────────────────────────────────────────
|
||||
${FREERTOS}/include
|
||||
${FREERTOS}/portable/GCC/ARM_CM4F
|
||||
|
||||
# ── LwIP ─────────────────────────────────────────────────────────────────
|
||||
${LWIP}/src/include
|
||||
${LWIP}/src/include/lwip
|
||||
${LWIP}/src/include/netif
|
||||
# LwIP 의 sys_arch/cc.h 포트 헤더(FreeRTOS 통합)는 프로젝트 port 에 둔다.
|
||||
# TODO(hw): lwip port(sys_arch.c + arch/cc.h, sys_arch.h) 경로 확정 시 추가.
|
||||
# 기본 후보: ${FW_ROOT}/net/lwip_port 또는 ${FW_CONFIG}
|
||||
${LWIP}/contrib/ports/freertos/include
|
||||
|
||||
# ── mbedTLS ──────────────────────────────────────────────────────────────
|
||||
${MBEDTLS}/include
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 소스 수집
|
||||
# =============================================================================
|
||||
|
||||
# ── (A) 검증된 포터블 코어 + 모든 공통 모듈 .c ──────────────────────────────
|
||||
# common/*.c 를 GLOB 한다. 검증 코어(sha256_sw/hexutil/sig/jsonbody/sht30_convert/
|
||||
# httpapi)와 보드 무관 모듈(reporter/sht30/bsp 등)이 모두 포함된다.
|
||||
# CONFIGURE_DEPENDS: 새 .c 추가 시 재구성 트리거(개발 편의).
|
||||
file(GLOB COMMON_SOURCES CONFIGURE_DEPENDS "${FW_COMMON}/*.c")
|
||||
|
||||
# 임베드된 서버 CA(PEM → C 배열).
|
||||
list(APPEND COMMON_SOURCES "${FW_CERTS}/server_ca.c")
|
||||
|
||||
# ── (B) 벤더: CMSIS startup(어셈블리) + system 초기화 ───────────────────────
|
||||
# startup 은 GCC 변형(Templates/gcc) 사용. 정확한 경로는 벤더 버전에 따라 다름.
|
||||
# TODO(vendor): 핀 고정 CubeF4 의 startup/system 경로를 확인하여 갱신.
|
||||
set(STARTUP_S "${CMSIS}/Device/ST/STM32F4xx/Source/Templates/gcc/startup_stm32f407xx.s")
|
||||
set(SYSTEM_C "${CMSIS}/Device/ST/STM32F4xx/Source/Templates/system_stm32f4xx.c")
|
||||
|
||||
# ── (C) 벤더: STM32F4xx HAL 드라이버 ────────────────────────────────────────
|
||||
# 필요한 HAL 모듈만 컴파일하면 크기/시간이 줄지만, 의존이 많아 GLOB 후
|
||||
# 템플릿(_template) 파일만 제외하는 방식이 견고하다.
|
||||
# TODO(vendor): 미사용 모듈(예: ll_*, 사용 안 하는 주변장치)은 빌드 시간 절약을
|
||||
# 위해 선택적으로 제외 가능. 우선 정확성 위해 전체 컴파일.
|
||||
file(GLOB HAL_SOURCES CONFIGURE_DEPENDS "${HAL}/Src/*.c")
|
||||
# *_template.c 는 사용자 재정의용 예제이므로 제외(중복 심볼/약한 정의 회피).
|
||||
list(FILTER HAL_SOURCES EXCLUDE REGEX ".*_template\\.c$")
|
||||
|
||||
# ── (D) 벤더: FreeRTOS 커널 + heap_4 + Cortex-M4F 포트 ──────────────────────
|
||||
# TODO(vendor): FreeRTOS-Kernel 루트의 *.c (tasks/queue/list/timers/event_groups/
|
||||
# stream_buffer/croutine) 를 컴파일. heap_4 와 ARM_CM4F port 는 명시 지정.
|
||||
file(GLOB FREERTOS_CORE CONFIGURE_DEPENDS "${FREERTOS}/*.c")
|
||||
set(FREERTOS_SOURCES
|
||||
${FREERTOS_CORE}
|
||||
${FREERTOS}/portable/MemMang/heap_4.c
|
||||
${FREERTOS}/portable/GCC/ARM_CM4F/port.c
|
||||
)
|
||||
|
||||
# ── (E) 벤더: LwIP 코어 + IPv4 + netif + apps/sntp + FreeRTOS sys_arch ──────
|
||||
# LwIP 는 Filelists.cmake 를 제공하지만 버전에 따라 변수명이 다르므로 GLOB 후
|
||||
# 필요한 하위 트리만 포함한다.
|
||||
# TODO(vendor): 핀 고정 lwip 버전의 src/Filelists.cmake 를 include 하여
|
||||
# ${lwipcore_SRCS} 등 공식 목록을 쓰는 편이 더 견고함. 우선 GLOB 로 구성.
|
||||
file(GLOB LWIP_CORE CONFIGURE_DEPENDS "${LWIP}/src/core/*.c")
|
||||
file(GLOB LWIP_CORE_IPV4 CONFIGURE_DEPENDS "${LWIP}/src/core/ipv4/*.c")
|
||||
file(GLOB LWIP_NETIF CONFIGURE_DEPENDS "${LWIP}/src/netif/*.c")
|
||||
file(GLOB LWIP_API CONFIGURE_DEPENDS "${LWIP}/src/api/*.c")
|
||||
# SNTP 앱(시간 동기 — timesync.c 가 사용).
|
||||
set(LWIP_SNTP "${LWIP}/src/apps/sntp/sntp.c")
|
||||
# FreeRTOS sys_arch 포트(LwIP ↔ FreeRTOS 통합). 위치는 벤더 contrib.
|
||||
# TODO(hw): sys_arch.c 가 프로젝트 net/lwip_port 에 있다면 그쪽을 사용.
|
||||
set(LWIP_SYS_ARCH "${LWIP}/contrib/ports/freertos/sys_arch.c")
|
||||
set(LWIP_SOURCES
|
||||
${LWIP_CORE}
|
||||
${LWIP_CORE_IPV4}
|
||||
${LWIP_NETIF}
|
||||
${LWIP_API}
|
||||
${LWIP_SNTP}
|
||||
${LWIP_SYS_ARCH}
|
||||
)
|
||||
|
||||
# ── (F) 벤더: mbedTLS 라이브러리 ────────────────────────────────────────────
|
||||
# TODO(vendor): library/*.c 전체 컴파일(설정 헤더에서 미사용 기능은 컴파일
|
||||
# 아웃되므로 코드 크기에는 큰 영향 없음). psa/ 하위는 사용 안 하면 제외 가능.
|
||||
file(GLOB MBEDTLS_SOURCES CONFIGURE_DEPENDS "${MBEDTLS}/library/*.c")
|
||||
|
||||
# =============================================================================
|
||||
# 공유 펌웨어 INTERFACE 라이브러리(정의/인클루드/플래그 모음)
|
||||
# - 두 실행 타깃이 동일 베이스를 공유하되, 보드 매크로만 다르게 한다.
|
||||
# =============================================================================
|
||||
add_library(fw_base INTERFACE)
|
||||
target_include_directories(fw_base INTERFACE ${COMMON_INCLUDES})
|
||||
target_compile_definitions(fw_base INTERFACE ${COMMON_DEFS})
|
||||
target_compile_options(fw_base INTERFACE
|
||||
-Wall -Wextra
|
||||
# HAL/LwIP/mbedtls 벤더 코드의 경고로 빌드가 막히지 않게 -Werror 는 미사용.
|
||||
$<$<COMPILE_LANGUAGE:C>:-fno-common>
|
||||
)
|
||||
|
||||
# 벤더 소스(타깃 무관 동일). 두 실행파일에 그대로 포함된다.
|
||||
set(VENDOR_SOURCES
|
||||
${STARTUP_S}
|
||||
${SYSTEM_C}
|
||||
${HAL_SOURCES}
|
||||
${FREERTOS_SOURCES}
|
||||
${LWIP_SOURCES}
|
||||
${MBEDTLS_SOURCES}
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 실행 타깃 생성 헬퍼
|
||||
# board_dir: board_sht30 (보드 전용 main/app 소스)
|
||||
# board_def: BOARD_SHT30
|
||||
# =============================================================================
|
||||
function(add_board_firmware target_name board_dir board_def)
|
||||
# 보드 전용 소스 수집(main.c + app_*.c 등). GLOB + CONFIGURE_DEPENDS.
|
||||
file(GLOB BOARD_SOURCES CONFIGURE_DEPENDS "${FW_ROOT}/${board_dir}/*.c")
|
||||
if(NOT BOARD_SOURCES)
|
||||
message(FATAL_ERROR "${board_dir} 에 소스(.c)가 없습니다: ${target_name}")
|
||||
endif()
|
||||
|
||||
add_executable(${target_name}
|
||||
${BOARD_SOURCES}
|
||||
${COMMON_SOURCES}
|
||||
${VENDOR_SOURCES}
|
||||
)
|
||||
|
||||
target_link_libraries(${target_name} PRIVATE fw_base)
|
||||
target_compile_definitions(${target_name} PRIVATE ${board_def})
|
||||
|
||||
# 링커 스크립트 + 맵 파일. -T 로 .ld 지정, .map 으로 메모리 맵 출력.
|
||||
target_link_options(${target_name} PRIVATE
|
||||
-T${FW_LD}
|
||||
-Wl,-Map=$<TARGET_FILE_DIR:${target_name}>/${target_name}.map,--cref
|
||||
)
|
||||
# .ld 변경 시 재링크 트리거.
|
||||
set_target_properties(${target_name} PROPERTIES
|
||||
LINK_DEPENDS "${FW_LD}"
|
||||
SUFFIX ".elf")
|
||||
|
||||
# ── 후처리: .bin / .hex 생성 + 크기 출력 ───────────────────────────────
|
||||
set(elf "$<TARGET_FILE:${target_name}>")
|
||||
set(out_dir "$<TARGET_FILE_DIR:${target_name}>")
|
||||
add_custom_command(TARGET ${target_name} POST_BUILD
|
||||
COMMAND ${CMAKE_OBJCOPY} -O binary ${elf} ${out_dir}/${target_name}.bin
|
||||
COMMAND ${CMAKE_OBJCOPY} -O ihex ${elf} ${out_dir}/${target_name}.hex
|
||||
COMMAND ${CMAKE_SIZE} --format=berkeley ${elf}
|
||||
COMMENT "[${target_name}] objcopy → .bin/.hex, size 출력"
|
||||
VERBATIM)
|
||||
endfunction()
|
||||
|
||||
# =============================================================================
|
||||
# 펌웨어 타깃 (SHT30 단일 보드)
|
||||
# =============================================================================
|
||||
add_board_firmware(sht30_fw board_sht30 BOARD_SHT30)
|
||||
|
||||
# =============================================================================
|
||||
# Configure 요약
|
||||
# =============================================================================
|
||||
message(STATUS "──────────────────────────────────────────────")
|
||||
message(STATUS " ${PROJECT_NAME} ${PROJECT_VERSION}")
|
||||
message(STATUS " toolchain : ${CMAKE_C_COMPILER}")
|
||||
message(STATUS " build type: ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS " ld script : ${FW_LD}")
|
||||
message(STATUS " third_party: ${TP}")
|
||||
message(STATUS " targets : sht30_fw (BOARD_SHT30)")
|
||||
message(STATUS "──────────────────────────────────────────────")
|
||||
175
firmware/README.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# SHT30 온습도 모니터링 STM32F407 펌웨어
|
||||
|
||||
> Raspberry Pi SHT30 온습도 노드를 **STM32F407VGT6** 베어메탈 펌웨어로 대체한 프로젝트.
|
||||
> 기존 서버측 PHP API / 서명 규약 / 요청 포맷을 **바이트 단위로 그대로 재현**하여
|
||||
> 서버 무중단으로 RPi → STM32 전환을 목표로 한다.
|
||||
|
||||
- 대상 MCU: STM32F407VGT6 (Cortex-M4F @168MHz, Flash 1MB, SRAM 192KB + CCM 64KB)
|
||||
- 네트워크: **유선 Ethernet** (LAN8720 RMII PHY) → LwIP + mbedTLS (HTTPS)
|
||||
- 런타임: **FreeRTOS** (네이티브 API — `task.h`/`queue.h`/`timers.h`/`semphr.h`)
|
||||
- 빌드: `arm-none-eabi-gcc` 15.2 + CMake + Ninja
|
||||
- 배포: **폐쇄망(air-gapped)** — 모든 서드파티 의존성은 `third_party/`에 벤더링, 빌드·런타임에 외부 다운로드 0건
|
||||
|
||||
상세 이식 근거는 [`../docs/stm32f407_migration_plan.md`](../docs/stm32f407_migration_plan.md) 참고.
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 아키텍처
|
||||
|
||||
물리 센서에서 SMS 발송까지의 전체 경로. **굵게** 표시한 구간이 본 펌웨어가 대체하는 부분이다.
|
||||
|
||||
```
|
||||
┌──────────────┐ I2C1 ┌────────────────────────────┐ Ethernet(RMII) ┌──────────────┐
|
||||
│ SHT30 센서 │ ──────────▶ │ STM32F407VGT6 │ ───────────────▶ │ LAN8720 PHY │
|
||||
│ (온습도) │ PB6/PB7 │ (FreeRTOS 펌웨어) │ │ (외부 PHY) │
|
||||
│ 0x44 │ (SCL/SDA) │ │ └──────┬───────┘
|
||||
│ │ │ Task_Net : 링크/DHCP/SNTP │ │ UTP
|
||||
│ │ │ Task_Sensor: 5분 주기 측정 │ ▼
|
||||
│ │ │ Task_Report: TLS POST │ ┌──────────────┐
|
||||
└──────────────┘ │ Task_Watchdog: IWDG refresh │ │ 스위치/공유기 │
|
||||
└────────────────────────────┘ └──────┬───────┘
|
||||
│
|
||||
lowercase_hex(sha256(API_KEY || raw_body)) X-Signature 헤더 │ HTTPS/TLS1.2
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Cafe24 호스팅 (변경 없음) │
|
||||
│ sensor_data.php ──▶ MySQL (로그 저장) │
|
||||
│ │ │
|
||||
│ └──▶ 서버 임계 판정(config.php METRIC_*) ──▶ SMS │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 펌웨어는 매 보고마다 `connect → TLS 핸드셰이크 → POST → close` 를 수행한다(메모리 절약, 보고 주기가 길어 성능 무관).
|
||||
- 서명은 본문 바이트 그대로 `sha256(API_KEY || body)` 를 계산해 **`X-Signature`** 헤더로 보낸다. 본문의 키 정렬/float 포맷 재현이 불필요하다(서버 `verify_signature_raw`).
|
||||
- **임계(고온/저온/고습/저습) 판정과 SMS 발송은 서버(PHP)에서 수행**한다. 펌웨어는 온습도 원값과 plausibility 상태(`metric_status`)만 보고하고, 경보 임계는 서버 `config.php` 의 `METRIC_*` 상수가 결정한다(폐쇄망 재플래시 비용을 피하기 위함).
|
||||
|
||||
---
|
||||
|
||||
## 2. 보드 구성 (SHT30 단일 보드)
|
||||
|
||||
이 프로젝트는 **SHT30 온습도 보드 단일 구성**이다. CMake `sht30_fw` 타깃이 `-DBOARD_SHT30` 을 자동으로 주입한다.
|
||||
|
||||
| 빌드 매크로 | CMake 타깃 | 산출물 | sensor_id | device_id | 센서 | 동작 |
|
||||
|----------------|-----------|-----------------|-----------|------------------|---------------|--------------------------------------------|
|
||||
| `-DBOARD_SHT30`| `sht30_fw`| `sht30_fw.bin` | 2 | `stm32-sht30-01` | SHT30 온습도 | 주기(300s) I2C 측정 + 원값 보고 |
|
||||
|
||||
식별값 정의는 [`common/board_config.h`](common/board_config.h)(`BOARD_SHT30` 단일). 공통 튜너블은 [`common/app_config.h`](common/app_config.h).
|
||||
|
||||
---
|
||||
|
||||
## 3. 기능 패리티 (RPi 대비)
|
||||
|
||||
| 기능 | RPi (Python) | STM32 펌웨어 | 상태 |
|
||||
|----------------------|----------------------------------|-------------------------------------------|-----------|
|
||||
| SHT30 측정/CRC | `smbus2` + `crc8_sht3x` | HAL I2C + `sht30_crc8`/`sht30_parse` | 유지 |
|
||||
| 주기 측정/보고 | 300s (최소 30s) | `APP_SHT30_REPORT_INTERVAL_SEC` (300s) | 유지 |
|
||||
| metric_status 판정 | `classify_reading()` plausibility | reporter 호출부 `metric_status` | 유지 |
|
||||
| 임계 경보/SMS | (서버측 처리) | **서버 `config.php` METRIC_* 에서 판정** | 유지(서버) |
|
||||
| HTTP 재시도 | requests + 루프(3회/5s) | `report_*` 내부 재시도(3회/5s) | 유지 |
|
||||
| TLS/HTTPS | OpenSSL(`requests`) | mbedTLS + LwIP | 변경(이식) |
|
||||
| 네트워크 | WiFi | 유선 Ethernet(LAN8720 RMII) | 변경(이식) |
|
||||
| 동시성 | Python threads | FreeRTOS tasks | 변경(이식) |
|
||||
| 시간원 | OS NTP | SNTP(LwIP) → RTC | 변경(이식) |
|
||||
| 서명 | 본문 필드 정규화 서명 | raw-body `X-Signature` | 변경(단순화)|
|
||||
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
|
||||
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
|
||||
|
||||
> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다.
|
||||
|
||||
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.
|
||||
|
||||
---
|
||||
|
||||
## 4. 빠른 시작 (빌드/플래시)
|
||||
|
||||
> 폐쇄망 전체 절차는 [`docs/BUILD_OFFLINE.md`](docs/BUILD_OFFLINE.md). 핀맵/배선은 [`docs/HARDWARE.md`](docs/HARDWARE.md).
|
||||
|
||||
```bash
|
||||
# 0) 비밀값 준비 (한 번)
|
||||
cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
# secrets.h 의 APP_API_KEY 를 서버 php/config.php 의 API_KEY 와 동일하게 채운다.
|
||||
|
||||
# 1) 서버 루트 CA 임베드 (한 번)
|
||||
# 실제 Cafe24 인증서 체인의 루트 CA PEM 으로 certs/server_ca.c 를 생성한다.
|
||||
# (certs/server_ca.h 가 SERVER_CA_PEM / SERVER_CA_PEM_LEN 을 extern 으로 선언함)
|
||||
|
||||
# 2) 빌드 (sht30_fw 단일 타깃)
|
||||
cmake -S firmware -B firmware/build -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
|
||||
cmake --build firmware/build --target sht30_fw # → sht30_fw.bin/.hex/.elf
|
||||
|
||||
# 3) 플래시 (ST-Link) — 보드 파라미터 없이 sht30_fw 고정
|
||||
pwsh firmware/scripts/flash.ps1
|
||||
# 대안: st-flash write firmware/build/sht30_fw.bin 0x08000000
|
||||
# 대안: openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
|
||||
# -c "program sht30_fw.elf verify reset exit"
|
||||
|
||||
# 4) 콘솔 확인 (USART3 PD8/PD9, 115200 8N1)
|
||||
# 부팅 로그 → 링크업/DHCP/SNTP → 첫 startup POST 200 확인
|
||||
```
|
||||
|
||||
> 참고: `cmake/`, `third_party/`, `board_sht30/`, `certs/server_ca.c` 는 별도 작업으로 생성/벤더링된다(아래 디렉터리 맵 참고). 위 명령은 그 산출물이 갖춰진 뒤의 표준 절차다.
|
||||
|
||||
---
|
||||
|
||||
## 5. `firmware/` 디렉터리 맵
|
||||
|
||||
```
|
||||
firmware/
|
||||
├─ README.md ← (이 문서)
|
||||
├─ CMakeLists.txt 빌드 루트: sht30_fw 타깃, third_party 통합
|
||||
├─ cmake/
|
||||
│ └─ arm-none-eabi.cmake 크로스 툴체인 파일 (gcc 15.2)
|
||||
├─ scripts/
|
||||
│ ├─ vendor.* 빌드 머신에서 고정 버전 의존성 벤더링(폐쇄망 준비)
|
||||
│ ├─ build.ps1 cmake configure + sht30_fw 빌드
|
||||
│ └─ flash.ps1 sht30_fw 플래싱(보드 파라미터 없이 고정)
|
||||
├─ common/ 공유 코드/계약 헤더
|
||||
│ ├─ app_config.h 튜너블: API 호스트/포트/경로, 주기, 재시도, SNTP, 망주소, 워치독, 핀, SHT30 범위
|
||||
│ ├─ board_config.h BOARD_DEVICE_ID/LOCATION/SENSOR_ID/SENSOR_NAME (BOARD_SHT30 단일)
|
||||
│ ├─ secrets.h.example APP_API_KEY 템플릿 (실 secrets.h 는 운영자가 생성, 미커밋)
|
||||
│ ├─ bsp.{h,c} 클럭/HAL/LED 초기화 (HSE 8MHz → 168MHz)
|
||||
│ ├─ net.{h,c} ETH MAC + LAN8720 RMII + LwIP, DHCP/static
|
||||
│ ├─ timesync.{h,c} SNTP → RTC
|
||||
│ ├─ tls.{h,c} mbedTLS over LwIP socket (transport_t 제공)
|
||||
│ ├─ transport.h 바이트 스트림 전송 추상화(connect/send/recv/close)
|
||||
│ ├─ httpapi.{h,c} HTTP/1.1 POST 작성 + X-Signature + 상태 파싱 (검증된 코어)
|
||||
│ ├─ sig.{h,c} raw-body 서명 sig_raw_body() (검증된 코어)
|
||||
│ ├─ jsonbody.{h,c} 본문 JSON 생성 jb_sht30_event (검증된 코어)
|
||||
│ ├─ sha256_backend.h SHA-256 추상 인터페이스
|
||||
│ ├─ sha256_sw.c 자급식 SHA-256 (공개 도메인) (검증된 코어)
|
||||
│ ├─ hexutil.{h,c} 바이트 ↔ 소문자 hex (검증된 코어)
|
||||
│ ├─ sht30.h SHT30 I2C 드라이버 계약
|
||||
│ ├─ sht30_convert.{h,c} SHT30 CRC8/변환 (이식성 순수 로직) (검증된 코어)
|
||||
│ ├─ reporter.h 이벤트 보고 헬퍼(본문→서명→POST→재시도) 계약
|
||||
│ ├─ watchdog.h IWDG 계약
|
||||
│ └─ applog.h UART 로그 계약
|
||||
├─ board_sht30/ SHT30 보드(sensor_id=2) 진입점 + 드라이버
|
||||
│ ├─ main.c FreeRTOS 태스크 구성 + 부팅
|
||||
│ ├─ app_sht30.c 주기 측정 → metric_status → 보고
|
||||
│ └─ sht30.c HAL I2C 측정
|
||||
├─ certs/
|
||||
│ ├─ server_ca.h SERVER_CA_PEM / SERVER_CA_PEM_LEN extern 선언
|
||||
│ └─ server_ca.c 실제 Cafe24 루트 CA PEM (운영자가 교체) ← TODO(hw)
|
||||
├─ third_party/ 벤더링(폐쇄망): CMSIS, STM32F4_HAL, FreeRTOS-Kernel, lwip, mbedtls
|
||||
├─ test/host/ 호스트 빌드 패리티 테스트 (PHP 기대값 대조)
|
||||
│ ├─ parity_test.py
|
||||
│ ├─ php_verify.php
|
||||
│ └─ reference.py
|
||||
├─ build/ CMake/Ninja 산출물 (armgate: 호스트 컴파일 게이트 .o)
|
||||
└─ docs/
|
||||
├─ HARDWARE.md SHT30 보드 핀맵/배선/핀 충돌 해소
|
||||
├─ BUILD_OFFLINE.md 폐쇄망 빌드·전달·플래시·롤백 절차
|
||||
└─ PORTING_NOTES.md Python → C 매핑 / 동작 패리티 / 변경점 / TODO(hw)
|
||||
```
|
||||
|
||||
> 위 맵 중 `CMakeLists.txt`, `cmake/`, `scripts/`, `board_sht30/`, `third_party/`, `certs/server_ca.c` 는 별도 작업으로 채워진다. 본 문서·`common/`·`certs/server_ca.h`·`test/`·`build/` 산출물은 이미 존재한다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 핵심 제약 (반드시 인지)
|
||||
|
||||
- **하드웨어 해시 가속기 없음**: STM32F407 에는 CRYP/HASH 블록이 없다(F415/F417 전용). SHA-256/AES 는 **소프트웨어**로 수행한다. RSA-2048 핸드셰이크는 ~1–2s 소요(보고 주기가 길어 허용).
|
||||
- **하드웨어 RNG 있음**: TLS 엔트로피 소스로 사용한다.
|
||||
- **SRAM 예산이 최대 리스크**: mbedTLS record buffer 를 `MBEDTLS_SSL_IN/OUT_CONTENT_LEN ≈ 4096` 으로 축소하고, FreeRTOS 태스크 스택을 CCM(64KB)에 배치한다(linker script).
|
||||
- **폐쇄망**: 빌드·설치·런타임 모두 인터넷 접근 불가를 가정한다. 모든 의존성·인증서·SNTP 대상 IP 까지 사전에 준비한다(자세히 [`docs/BUILD_OFFLINE.md`](docs/BUILD_OFFLINE.md)).
|
||||
1
firmware/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
fw-2.0.0
|
||||
218
firmware/board_sht30/app_sht30.c
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/* =============================================================================
|
||||
* app_sht30.c - SHT30 온습도 보드 애플리케이션 태스크
|
||||
*
|
||||
* RPi sht30_monitor.py 의 동작을 STM32 + FreeRTOS 로 이식한다.
|
||||
*
|
||||
* - 측정 태스크(sht30_measure_task):
|
||||
* net 업 대기 -> SNTP 시간 동기 -> 주기적으로 SHT30 측정 후 서버 보고.
|
||||
* sht30_monitor.py 의 first_success 로직과 동일하게, "최초 성공" 측정은
|
||||
* event_type="startup", 이후는 "periodic" 으로 보고한다.
|
||||
* 온습도가 정상 범위를 벗어나면 metric_status="out_of_range" 로 분류한다.
|
||||
* 측정 실패 시 크래시하지 않고 로그만 남긴 뒤 다음 주기에 재시도한다.
|
||||
*
|
||||
* - 헬스 태스크(health_task):
|
||||
* IWDG 워치독을 주기적으로 refresh 하고, 태스크 스택 여유를 로깅한다.
|
||||
* (RPi systemd watchdog / 헬스 체크 대응)
|
||||
*
|
||||
* 두 태스크의 분리 이유:
|
||||
* 네트워크 I/O(TLS 핸드셰이크/전송)는 수 초간 블로킹될 수 있으므로, 워치독
|
||||
* refresh 를 측정 태스크에 의존시키면 통신 지연이 곧 리셋으로 이어질 수 있다.
|
||||
* 따라서 워치독은 독립적인 고우선순위 헬스 태스크에서 갱신한다.
|
||||
* ===========================================================================*/
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
|
||||
#include "app_config.h" /* 주기/범위/버전 + board_config.h + secrets.h */
|
||||
#include "applog.h"
|
||||
#include "watchdog.h"
|
||||
#include "net.h"
|
||||
#include "timesync.h"
|
||||
#include "sht30.h"
|
||||
#include "reporter.h"
|
||||
#include "bsp.h"
|
||||
|
||||
/* ── 내부 타이밍 상수 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* net 업 / 시간 동기 대기 타임아웃. 실패해도 무한 재시도(폐쇄망 부팅 대비). */
|
||||
#define APP_NET_WAIT_TIMEOUT_MS (APP_NET_DHCP_TIMEOUT_MS + 10000u)
|
||||
#define APP_TIMESYNC_TIMEOUT_MS 30000u
|
||||
|
||||
/* 헬스(워치독) 태스크 주기. 워치독 타임아웃의 1/4 이하로 충분히 여유 있게. */
|
||||
#define HEALTH_TICK_MS (APP_WATCHDOG_TIMEOUT_MS / 4u)
|
||||
|
||||
/* 측정 실패 후 짧은 재시도 간격 (정상 주기와 별도). 센서 글리치 복구용. */
|
||||
#define MEASURE_RETRY_MS 5000u
|
||||
|
||||
/* 스택 여유 로깅 주기 (헬스 틱 N회마다 1번). */
|
||||
#define STACK_LOG_EVERY_N_TICKS 12u
|
||||
|
||||
/* 측정 태스크 핸들 (스택 워터마크 로깅용으로 헬스 태스크가 참조) */
|
||||
static TaskHandle_t s_measure_task = NULL;
|
||||
|
||||
/* ── 헬퍼: 온습도 정상 범위 분류 (sht30_monitor.py metric_status) ─────────── */
|
||||
static const char *classify_metric_status(double temp_c, double rh)
|
||||
{
|
||||
/* NaN/Inf 방어: 비정상 부동소수도 out_of_range 로 취급 */
|
||||
if (!isfinite(temp_c) || !isfinite(rh)) {
|
||||
return "out_of_range";
|
||||
}
|
||||
if (temp_c < APP_SHT30_TEMP_LOW_C || temp_c > APP_SHT30_TEMP_HIGH_C) {
|
||||
return "out_of_range";
|
||||
}
|
||||
if (rh < APP_SHT30_RH_LOW || rh > APP_SHT30_RH_HIGH) {
|
||||
return "out_of_range";
|
||||
}
|
||||
return "normal";
|
||||
}
|
||||
|
||||
/* ── 헬퍼: 네트워크 업 + 시간 동기 보장 (무한 재시도) ─────────────────────── */
|
||||
static void ensure_network_and_time(void)
|
||||
{
|
||||
/* 1) 링크 업 + IP 확보까지 대기. 폐쇄망에서 케이블/스위치 지연이 있을 수
|
||||
* 있으므로 타임아웃 시 경고만 남기고 계속 재시도한다. */
|
||||
while (net_wait_up(APP_NET_WAIT_TIMEOUT_MS) != 0) {
|
||||
LOGW("net not up yet, retrying...");
|
||||
/* 워치독은 헬스 태스크가 별도로 refresh 하므로 여기서는 단순 대기 */
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
}
|
||||
|
||||
char ip[16];
|
||||
net_ip_str(ip, sizeof(ip));
|
||||
LOGI("network up, ip=%s", ip);
|
||||
|
||||
/* 2) SNTP 시작 후 최초 동기까지 대기. TLS 인증서 유효기간 검증과 보고
|
||||
* timestamp 필드에 정확한 Unix 시간이 필요하므로 동기 완료를 보장한다.
|
||||
* (SNTP 는 UDP 라 TLS 불필요 -> TLS 연결보다 먼저 수행) */
|
||||
timesync_init();
|
||||
while (timesync_wait(APP_TIMESYNC_TIMEOUT_MS) != 0) {
|
||||
LOGW("time sync pending, retrying...");
|
||||
vTaskDelay(pdMS_TO_TICKS(APP_SNTP_RETRY_DELAY_MS));
|
||||
}
|
||||
LOGI("time synced, epoch=%lu", (unsigned long)timesync_now());
|
||||
}
|
||||
|
||||
/* ── 측정 태스크 ─────────────────────────────────────────────────────────── */
|
||||
/* RPi sht30_monitor.py main loop 대응:
|
||||
* while True:
|
||||
* reading = read_sht30()
|
||||
* if reading is None: 로그 후 continue
|
||||
* event_type = "startup" if first_success else "periodic"
|
||||
* post_reading(...); first_success = False
|
||||
* sleep(REPORT_INTERVAL)
|
||||
*/
|
||||
static void sht30_measure_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
/* 부팅 시 네트워크/시간 준비 (sht30_monitor.py 의 시작 전 OS NTP 대응) */
|
||||
ensure_network_and_time();
|
||||
|
||||
/* first_success: 첫 성공 측정만 "startup" 으로 보고 (RPi 동일 로직) */
|
||||
int first_success = 1;
|
||||
|
||||
LOGI("sht30 measure task started (interval=%us)",
|
||||
(unsigned)APP_SHT30_REPORT_INTERVAL_SEC);
|
||||
|
||||
for (;;) {
|
||||
double temp_c = 0.0;
|
||||
double rh = 0.0;
|
||||
|
||||
int rc = sht30_measure(&temp_c, &rh);
|
||||
if (rc != 0) {
|
||||
/* 측정 실패: 크래시 금지. 로그 후 짧게 쉬고 다음 주기에 재시도.
|
||||
* rc == -1: I2C 통신 오류, rc == -2: CRC 오류 */
|
||||
LOGE("sht30_measure failed (rc=%d), retry in %ums",
|
||||
rc, (unsigned)MEASURE_RETRY_MS);
|
||||
vTaskDelay(pdMS_TO_TICKS(MEASURE_RETRY_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *metric_status = classify_metric_status(temp_c, rh);
|
||||
const char *event_type = first_success ? "startup" : "periodic";
|
||||
|
||||
/* 운영 가시성: 측정값/상태 로깅 (소수 2자리로 표시) */
|
||||
LOGI("sht30 %s: T=%.2fC RH=%.2f%% status=%s",
|
||||
event_type, temp_c, rh, metric_status);
|
||||
|
||||
/* 서버 보고. reporter 가 본문 생성 -> 서명 -> TLS POST -> 재시도 처리.
|
||||
* 반환 0 성공(HTTP 200), 음수 실패. */
|
||||
int prc = report_sht30_event(event_type, temp_c, rh, metric_status);
|
||||
if (prc == 0) {
|
||||
/* 최초 성공 보고 이후에는 startup -> periodic 으로 전환 */
|
||||
if (first_success) {
|
||||
first_success = 0;
|
||||
}
|
||||
bsp_led_toggle(); /* 성공 보고 표시 */
|
||||
} else {
|
||||
/* 보고 실패: reporter 내부 재시도까지 모두 소진한 경우.
|
||||
* 다음 정상 주기에 다시 시도한다. first_success 는 유지하여
|
||||
* 최초 보고가 반드시 "startup" 으로 도달하도록 한다. */
|
||||
LOGW("report_sht30_event failed (rc=%d), will retry next cycle", prc);
|
||||
}
|
||||
|
||||
/* 정상 보고 주기 대기 (sht30_monitor.py REPORT_INTERVAL). */
|
||||
vTaskDelay(pdMS_TO_TICKS((TickType_t)APP_SHT30_REPORT_INTERVAL_SEC * 1000u));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 헬스 태스크 ─────────────────────────────────────────────────────────── */
|
||||
/* 워치독 refresh + 주기적 스택 여유 로깅. 네트워크/측정 블로킹과 독립적으로
|
||||
* 동작해야 하므로 측정 태스크보다 높은 우선순위로 생성된다(main.c). */
|
||||
static void health_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
uint32_t tick = 0;
|
||||
|
||||
for (;;) {
|
||||
/* IWDG 갱신: 이 호출이 APP_WATCHDOG_TIMEOUT_MS 내에 끊기면 MCU 리셋 */
|
||||
watchdog_refresh();
|
||||
|
||||
/* 주기적 스택 워터마크 로깅 (개발/운영 진단용) */
|
||||
if ((tick % STACK_LOG_EVERY_N_TICKS) == 0u) {
|
||||
UBaseType_t health_free = uxTaskGetStackHighWaterMark(NULL);
|
||||
UBaseType_t measure_free = (s_measure_task != NULL)
|
||||
? uxTaskGetStackHighWaterMark(s_measure_task)
|
||||
: 0;
|
||||
LOGI("health: heap_free=%u stack_min[health]=%u stack_min[measure]=%u",
|
||||
(unsigned)xPortGetFreeHeapSize(),
|
||||
(unsigned)health_free,
|
||||
(unsigned)measure_free);
|
||||
}
|
||||
|
||||
tick++;
|
||||
vTaskDelay(pdMS_TO_TICKS(HEALTH_TICK_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 태스크 생성 진입점 (main.c 에서 호출) ───────────────────────────────── */
|
||||
/* 스택 크기: TLS 핸드셰이크는 mbedTLS 가 측정 태스크 문맥에서 수행되므로
|
||||
* 측정 태스크에 넉넉한 스택을 준다(reporter 가 동일 태스크에서 호출됨). */
|
||||
#define MEASURE_TASK_STACK_WORDS 3072u /* 12 KB (word=4B): mbedTLS 핸드셰이크 피크 여유 */
|
||||
#define HEALTH_TASK_STACK_WORDS 256u /* 1 KB: 로깅/워치독만 */
|
||||
|
||||
/* 우선순위: 헬스(워치독) > 측정. 워치독 갱신이 통신 지연에 막히지 않게 한다. */
|
||||
#define MEASURE_TASK_PRIO (tskIDLE_PRIORITY + 2)
|
||||
#define HEALTH_TASK_PRIO (tskIDLE_PRIORITY + 3)
|
||||
|
||||
void app_sht30_start(void)
|
||||
{
|
||||
BaseType_t ok;
|
||||
|
||||
ok = xTaskCreate(sht30_measure_task, "sht30",
|
||||
MEASURE_TASK_STACK_WORDS, NULL,
|
||||
MEASURE_TASK_PRIO, &s_measure_task);
|
||||
if (ok != pdPASS) {
|
||||
bsp_fatal("failed to create sht30 measure task");
|
||||
}
|
||||
|
||||
ok = xTaskCreate(health_task, "health",
|
||||
HEALTH_TASK_STACK_WORDS, NULL,
|
||||
HEALTH_TASK_PRIO, NULL);
|
||||
if (ok != pdPASS) {
|
||||
bsp_fatal("failed to create health task");
|
||||
}
|
||||
}
|
||||
89
firmware/board_sht30/main.c
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/* =============================================================================
|
||||
* main.c - SHT30 온습도 보드 펌웨어 진입점 (BOARD_SHT30)
|
||||
*
|
||||
* 부팅 순서 (RPi sht30_monitor.py 기동 대응):
|
||||
* bsp_init() : HAL/클럭(HSE 8MHz -> 168MHz)/공통 GPIO/LED
|
||||
* applog_init() : USART3 디버그 로그(PD8/PD9, 115200) — PA2 는 ETH_MDIO 전용
|
||||
* watchdog_init() : IWDG 활성화 (이후 헬스 태스크가 refresh)
|
||||
* sht30_init() : I2C 주변장치 + 센서 초기화
|
||||
* net_init() : ETH MAC + LAN8720 PHY(RMII) + LwIP
|
||||
* tls_init() : mbedTLS 엔트로피/DRBG 시드, 루트 CA 파싱
|
||||
* app_sht30_start() : 측정 태스크 + 헬스 태스크 생성
|
||||
* vTaskStartScheduler() : FreeRTOS 스케줄러 기동(반환하지 않음)
|
||||
*
|
||||
* 폐쇄망(air-gapped): 모든 의존성(HAL/CMSIS/FreeRTOS/lwip/mbedtls)은 third_party
|
||||
* 에 벤더링되어 있으며 런타임에 외부 네트워크를 사용하지 않는다(SNTP/HTTPS는
|
||||
* 사내 망 내부 서버 대상).
|
||||
* ===========================================================================*/
|
||||
#include <stdint.h>
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
|
||||
#include "app_config.h" /* board_config.h + secrets.h 포함 */
|
||||
#include "bsp.h"
|
||||
#include "applog.h"
|
||||
#include "watchdog.h"
|
||||
#include "sht30.h"
|
||||
#include "net.h"
|
||||
#include "tls.h"
|
||||
|
||||
/* 이 펌웨어는 SHT30 보드 전용이다(CMake sht30_fw 타깃이 -DBOARD_SHT30 정의). */
|
||||
#if !defined(BOARD_SHT30)
|
||||
#error "board_sht30/main.c 는 -DBOARD_SHT30 로 빌드해야 합니다."
|
||||
#endif
|
||||
|
||||
/* app_sht30.c 에서 정의 (전용 헤더 없이 모듈 내부 함수로 연결) */
|
||||
extern void app_sht30_start(void);
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* 1) 클럭/HAL/공통 주변장치. 가장 먼저 수행해야 이후 초기화가 유효하다. */
|
||||
bsp_init();
|
||||
|
||||
/* 2) 디버그 로그 UART. 이후 단계의 진단 메시지를 위해 일찍 켠다. */
|
||||
applog_init();
|
||||
LOGI("=== SHT30 board boot ===");
|
||||
LOGI("device_id=%s sensor_id=%d ver=%s",
|
||||
BOARD_DEVICE_ID, BOARD_SENSOR_ID, APP_VERSION);
|
||||
LOGI("location=%s name=%s", BOARD_DEVICE_LOCATION, BOARD_SENSOR_NAME);
|
||||
|
||||
/* 3) 독립 워치독(IWDG). 활성화 즉시 카운트다운이 시작되므로, 스케줄러 기동
|
||||
* 후 헬스 태스크가 첫 refresh 를 하기 전까지 타임아웃되지 않도록
|
||||
* APP_WATCHDOG_TIMEOUT_MS 는 충분히 크게(20s) 설정되어 있다. */
|
||||
watchdog_init(APP_WATCHDOG_TIMEOUT_MS);
|
||||
|
||||
/* 4) SHT30 센서(I2C). 실패해도 부팅은 계속한다: 측정 태스크가 매 주기
|
||||
* 재시도하므로(센서 일시 미연결 대비) 여기서 멈추지 않는다. */
|
||||
if (sht30_init() != 0) {
|
||||
LOGE("sht30_init failed (will retry in measure loop)");
|
||||
} else {
|
||||
LOGI("sht30 init ok (i2c addr=0x%02X)", APP_SHT30_I2C_ADDR);
|
||||
}
|
||||
|
||||
/* 5) 네트워크 스택 초기화 (LwIP tcpip 스레드 포함). 링크 업 대기는
|
||||
* 측정 태스크에서 수행한다. */
|
||||
net_init();
|
||||
LOGI("net init ok");
|
||||
|
||||
/* 6) TLS 전역 초기화 (엔트로피/DRBG, 임베드 루트 CA 파싱). 한 번만 수행. */
|
||||
if (tls_init() != 0) {
|
||||
/* TLS 가 없으면 서버 보고가 불가능하므로 치명적 오류로 처리.
|
||||
* (워치독이 결국 리셋하여 재기동을 시도) */
|
||||
bsp_fatal("tls_init failed");
|
||||
}
|
||||
LOGI("tls init ok");
|
||||
|
||||
/* 7) 애플리케이션 태스크(측정 + 헬스) 생성. */
|
||||
app_sht30_start();
|
||||
LOGI("tasks created, starting scheduler");
|
||||
|
||||
/* 8) 스케줄러 기동. 정상적으로는 반환하지 않는다. */
|
||||
vTaskStartScheduler();
|
||||
|
||||
/* 여기에 도달했다면 힙 부족으로 스케줄러가 기동하지 못한 것이다. */
|
||||
bsp_fatal("scheduler returned (insufficient heap)");
|
||||
for (;;) {
|
||||
/* unreachable */
|
||||
}
|
||||
}
|
||||
136
firmware/certs/README.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# 서버 루트 CA 인증서 (`certs/`)
|
||||
|
||||
STM32F407 펌웨어는 폐쇄망(air-gapped)에서 동작하므로 런타임에 CA 인증서를
|
||||
다운로드할 수 없다. 따라서 서버(Cafe24) TLS 검증에 사용할 **루트 CA PEM 을
|
||||
flash 에 임베드**한다(`server_ca.c`). mbedTLS(`tls_mbedtls.c`)가 부팅 시
|
||||
`mbedtls_x509_crt_parse()` 로 이 PEM 을 파싱하고, 매 핸드셰이크마다 서버
|
||||
인증서 체인을 검증한다.
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `server_ca.h` | `SERVER_CA_PEM[]` / `SERVER_CA_PEM_LEN` 선언 |
|
||||
| `server_ca.c` | PEM 본문(현재 **PLACEHOLDER**, 반드시 교체) |
|
||||
| `gen_ca_header.ps1` | PEM 파일 → `server_ca.c` 재생성 스크립트 |
|
||||
|
||||
> ⚠️ **현재 `server_ca.c` 는 자리표시자(PLACEHOLDER)다.** 컴파일은 되지만 실제
|
||||
> 서버 인증서 검증은 실패한다. 배포 전 반드시 실제 Cafe24 루트 CA 로 교체할 것.
|
||||
|
||||
---
|
||||
|
||||
## 1. 서버 인증서 체인 추출
|
||||
|
||||
배포 대상 호스트(`app_config.h` 의 `APP_API_HOST`, 기본 `your-domain.example`)에
|
||||
대해 **빌드 머신에서** 다음을 실행한다. (`-servername` 으로 SNI 지정 필수 —
|
||||
가상호스팅 환경에서 올바른 인증서를 받기 위함.)
|
||||
|
||||
```sh
|
||||
HOST=your-domain.example # = APP_API_HOST 와 동일하게
|
||||
|
||||
# 전체 체인(leaf + intermediate + root) 보기
|
||||
openssl s_client -showcerts -connect "$HOST:443" -servername "$HOST" </dev/null
|
||||
```
|
||||
|
||||
출력의 `-----BEGIN CERTIFICATE-----` ~ `-----END CERTIFICATE-----` 블록들이 체인이다.
|
||||
보통 순서는 **[0] leaf(서버) → [1] intermediate → [2] root** 다.
|
||||
|
||||
펌웨어 검증의 신뢰 앵커(trust anchor)는 **root CA** 다. 단, Cafe24 가 root 를
|
||||
체인에 포함하지 않거나 cross-signed 인 경우가 있어, **확실한 검증을 위해
|
||||
intermediate + root 를 한 PEM 파일에 연결**해 두는 것을 권장한다(아래 2번).
|
||||
|
||||
---
|
||||
|
||||
## 2. 트러스트 PEM 만들기 (intermediate + root 연결)
|
||||
|
||||
가장 안전한 방법은 발급기관(CA)의 공식 root/intermediate PEM 을 받아 연결하는 것이다.
|
||||
체인에서 직접 뽑으려면 아래처럼 블록을 분리/연결한다.
|
||||
|
||||
```sh
|
||||
# s_client 출력에서 인증서 블록만 추려 파일로 저장(예: certs.pem)
|
||||
openssl s_client -showcerts -connect "$HOST:443" -servername "$HOST" </dev/null \
|
||||
| sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p' > chain_all.pem
|
||||
|
||||
# chain_all.pem 에서 intermediate + root 를 골라 cafe24_root.pem 으로 연결.
|
||||
# (leaf[0] 는 제외. intermediate/root 만 trust 파일에 둔다.)
|
||||
# - 가장 확실: CA 벤더 공식 사이트에서 root/intermediate PEM 다운로드 후 cat 으로 연결
|
||||
cat intermediate.pem root.pem > cafe24_root.pem
|
||||
```
|
||||
|
||||
> 팁: root 만으로 검증이 되면 root 한 장이면 충분하다. intermediate 까지 넣으면
|
||||
> 서버가 intermediate 를 안 보내는 경우에도 검증이 안정적이다(여분은 무해).
|
||||
|
||||
---
|
||||
|
||||
## 3. 체인 / cipher 사전 검증 (위험 R3 사전조사)
|
||||
|
||||
펌웨어에 넣기 전에, 추출한 trust PEM 으로 **검증이 실제로 통과**하는지,
|
||||
그리고 mbedTLS 클라이언트가 쓸 **TLS1.2 ECDHE-RSA-AES-GCM** 을 서버가
|
||||
받아주는지 확인한다.
|
||||
|
||||
```sh
|
||||
# (a) 우리 trust PEM 으로 체인 검증 통과 여부
|
||||
openssl s_client -connect "$HOST:443" -servername "$HOST" \
|
||||
-CAfile cafe24_root.pem </dev/null 2>/dev/null \
|
||||
| grep -E "Verify return code|Verification"
|
||||
# 기대: "Verify return code: 0 (ok)"
|
||||
|
||||
# (b) mbedTLS 가 협상할 cipher/프로토콜 강제 확인 (TLS1.2 + ECDHE-RSA-AES-GCM)
|
||||
openssl s_client -connect "$HOST:443" -servername "$HOST" \
|
||||
-tls1_2 -cipher 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384' \
|
||||
-CAfile cafe24_root.pem </dev/null 2>/dev/null \
|
||||
| grep -E "Protocol|Cipher|Verify return code"
|
||||
# 기대: Protocol = TLSv1.2, Cipher = ECDHE-RSA-AES(128|256)-GCM-...
|
||||
|
||||
# (c) 인증서 만료일 확인 (leaf + CA)
|
||||
openssl s_client -connect "$HOST:443" -servername "$HOST" </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -dates -subject -issuer
|
||||
openssl x509 -in cafe24_root.pem -noout -dates -subject
|
||||
```
|
||||
|
||||
펌웨어 `mbedtls_config.h` 가 ECDHE-RSA + AES-GCM(TLS1.2)만 켜므로, 위 (b) 가
|
||||
실패하면(서버가 해당 cipher 미지원) **TLS 연결 불가**다 → 서버 측 cipher
|
||||
설정을 맞추거나 `mbedtls_config.h` 의 허용 cipher/곡선을 조정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. `server_ca.c` 재생성
|
||||
|
||||
검증을 통과한 `cafe24_root.pem` 으로 C 소스를 재생성한다(빌드 머신, PowerShell).
|
||||
|
||||
```powershell
|
||||
# firmware/certs 디렉터리에서
|
||||
./gen_ca_header.ps1 -PemPath cafe24_root.pem
|
||||
# 또는 출력 경로 지정:
|
||||
./gen_ca_header.ps1 -PemPath C:\path\cafe24_root.pem -OutPath .\server_ca.c
|
||||
```
|
||||
|
||||
스크립트는 PEM 각 라인을 `"...\n"` C 리터럴로 변환하고,
|
||||
`SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM)` 로 정의한다(종결 NUL 포함 —
|
||||
mbedTLS PEM 파싱 요구사항). 생성 후 재빌드하면 새 CA 가 적용된다.
|
||||
|
||||
생성 결과를 빠르게 sanity-check:
|
||||
|
||||
```sh
|
||||
# 생성된 .c 안의 PEM 이 다시 openssl 로 파싱되는지(따옴표/escape 깨짐 검출용)
|
||||
grep -oE 'BEGIN CERTIFICATE|END CERTIFICATE' server_ca.c
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 만료 → 펌웨어 업데이트 (위험 R3)
|
||||
|
||||
폐쇄망이라 인증서를 OTA 로 갱신할 수 없다. 임베드한 root/intermediate CA 가
|
||||
**만료**되거나 서버가 **발급기관/체인을 교체**하면 TLS 핸드셰이크가 실패하고
|
||||
보고가 중단된다.
|
||||
|
||||
대응:
|
||||
|
||||
1. **만료일 모니터링**: 위 3-(c) 로 CA `notAfter` 를 기록·달력 알림 설정.
|
||||
2. **사전 교체**: 만료 1~3개월 전 새 CA 로 `gen_ca_header.ps1` 재생성 → 재빌드.
|
||||
3. **재배포 절차**: 폐쇄망 반입 절차(USB/오프라인 번들)로 새 `.bin/.hex`
|
||||
플래싱(`sht30_fw` 단일 타깃). `firmware/VERSION` 갱신.
|
||||
4. **여유 신뢰 앵커**: 가능하면 차기 root 까지 함께 임베드(여러 CA 연결)하여
|
||||
교체 윈도를 넓힌다(여분 CA 는 검증에 무해).
|
||||
|
||||
> 참고: `docs/stm32f407_migration_plan.md` 위험표 **R3** —
|
||||
> "Cafe24 인증서 cipher/체인/만료 → TLS 연결 불가 → 사전 `openssl s_client` 로
|
||||
> 체인·cipher 확인, 루트 CA 임베드, 만료 시 펌웨어 업데이트 절차".
|
||||
113
firmware/certs/gen_ca_header.ps1
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
PEM 인증서 파일로부터 firmware/certs/server_ca.c 를 재생성한다.
|
||||
|
||||
.DESCRIPTION
|
||||
Cafe24 서버 루트 CA(필요 시 intermediate+root 를 연결한 체인) PEM 을 읽어,
|
||||
mbedTLS 가 그대로 파싱할 수 있는 C 문자열 리터럴 형태로 server_ca.c 에 임베드한다.
|
||||
|
||||
- 각 PEM 라인을 "....\n" C 리터럴로 변환(끝의 종결 NUL 은 sizeof 에 포함).
|
||||
- SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM) 로 정의(NUL 포함 → mbedTLS 요구).
|
||||
- 폐쇄망: 이 스크립트는 *빌드 머신*에서만 실행. 타깃은 결과 .c 만 컴파일.
|
||||
|
||||
.PARAMETER PemPath
|
||||
입력 PEM 파일 경로(.pem/.crt). 하나 이상의 -----BEGIN CERTIFICATE----- 블록 포함.
|
||||
|
||||
.PARAMETER OutPath
|
||||
출력 C 파일 경로. 기본값: 이 스크립트와 같은 폴더의 server_ca.c
|
||||
|
||||
.EXAMPLE
|
||||
./gen_ca_header.ps1 -PemPath cafe24_root.pem
|
||||
|
||||
.EXAMPLE
|
||||
# 체인 추출 → 헤더 생성 (한 번에)
|
||||
openssl s_client -showcerts -connect your-domain.example:443 -servername your-domain.example </dev/null `
|
||||
| openssl x509 -out cafe24_root.pem
|
||||
./gen_ca_header.ps1 -PemPath cafe24_root.pem
|
||||
|
||||
.NOTES
|
||||
위험 R3(docs/stm32f407_migration_plan.md): 인증서 만료 시 펌웨어 재빌드/재배포 필요.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$PemPath,
|
||||
|
||||
[Parameter(Position = 1)]
|
||||
[string]$OutPath = (Join-Path $PSScriptRoot 'server_ca.c')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- 입력 검증 ----------------------------------------------------------------
|
||||
if (-not (Test-Path -LiteralPath $PemPath)) {
|
||||
throw "PEM 파일을 찾을 수 없습니다: $PemPath"
|
||||
}
|
||||
|
||||
# CRLF/CR 을 LF 로 정규화하고 라인 분해. 빈 끝줄은 제거.
|
||||
$raw = Get-Content -LiteralPath $PemPath -Raw
|
||||
$normalized = $raw -replace "`r`n", "`n" -replace "`r", "`n"
|
||||
$lines = $normalized -split "`n"
|
||||
|
||||
# 최소한 BEGIN/END CERTIFICATE 마커가 있어야 한다.
|
||||
if (-not ($normalized -match '-----BEGIN CERTIFICATE-----')) {
|
||||
throw "유효한 PEM 인증서 블록(-----BEGIN CERTIFICATE-----)이 없습니다: $PemPath"
|
||||
}
|
||||
if (-not ($normalized -match '-----END CERTIFICATE-----')) {
|
||||
throw "PEM 종료 마커(-----END CERTIFICATE-----)가 없습니다: $PemPath"
|
||||
}
|
||||
|
||||
# 인증서 블록 개수(intermediate+root 연결이면 2개 이상).
|
||||
$certCount = ([regex]::Matches($normalized, '-----BEGIN CERTIFICATE-----')).Count
|
||||
|
||||
# --- C 문자열 리터럴로 변환 ---------------------------------------------------
|
||||
# 후행 빈 라인 제거(마지막 -----END----- 뒤의 공백 라인).
|
||||
$body = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($line in $lines) {
|
||||
$body.Add($line) | Out-Null
|
||||
}
|
||||
while ($body.Count -gt 0 -and [string]::IsNullOrEmpty($body[$body.Count - 1])) {
|
||||
$body.RemoveAt($body.Count - 1)
|
||||
}
|
||||
|
||||
# 각 라인을 C escape: 백슬래시/큰따옴표 이스케이프 후 "...\n" 형태로.
|
||||
$escapedLines = foreach ($line in $body) {
|
||||
$esc = $line -replace '\\', '\\' -replace '"', '\"'
|
||||
' "' + $esc + '\n"'
|
||||
}
|
||||
|
||||
$generatedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz')
|
||||
$pemFileName = Split-Path -Leaf $PemPath
|
||||
|
||||
$header = @"
|
||||
/* =============================================================================
|
||||
* server_ca.c - 서버 루트 CA 인증서 (PEM, flash 임베드) [자동 생성]
|
||||
*
|
||||
* gen_ca_header.ps1 로 자동 생성됨. 직접 편집하지 말 것.
|
||||
* source PEM : $pemFileName
|
||||
* 생성 시각 : $generatedAt
|
||||
* 인증서 블록: $certCount 개
|
||||
*
|
||||
* mbedTLS PEM 요구사항: SERVER_CA_PEM_LEN 은 종결 NUL 을 포함해야 한다
|
||||
* → sizeof(SERVER_CA_PEM) 사용.
|
||||
* 위험 R3: 인증서 만료/교체 시 이 파일 재생성 + 펌웨어 재배포 필요.
|
||||
* ===========================================================================*/
|
||||
#include "server_ca.h"
|
||||
|
||||
const char SERVER_CA_PEM[] =
|
||||
"@
|
||||
|
||||
$footer = @"
|
||||
;
|
||||
|
||||
/* 길이: 종결 NUL 포함(mbedTLS PEM 요구). */
|
||||
const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
|
||||
"@
|
||||
|
||||
$content = $header + "`n" + ($escapedLines -join "`n") + $footer + "`n"
|
||||
|
||||
# UTF-8 (BOM 없음) 로 기록 — arm-none-eabi-gcc 가 깔끔히 읽도록.
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($OutPath, $content, $utf8NoBom)
|
||||
|
||||
Write-Host "[OK] $OutPath 생성 완료 (인증서 블록 $certCount 개, source: $pemFileName)"
|
||||
50
firmware/certs/server_ca.c
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* =============================================================================
|
||||
* server_ca.c - 서버(Cafe24) 루트 CA 인증서 (PEM, flash 임베드)
|
||||
*
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!! PLACEHOLDER !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
* 아래 인증서는 *자리표시자(PLACEHOLDER)* 이며 실제 서버 검증에 사용할 수 없다.
|
||||
* 배포 전에 반드시 실제 Cafe24 서버의 루트 CA 체인으로 교체해야 한다.
|
||||
*
|
||||
* 교체 방법(요약, 자세히는 certs/README.md):
|
||||
* 1) 서버에서 체인 추출:
|
||||
* openssl s_client -showcerts -connect <host>:443 -servername <host>
|
||||
* 2) 루트(필요 시 intermediate + root 를 한 파일에 연결)를 PEM 으로 저장
|
||||
* 3) PowerShell 로 이 파일 재생성:
|
||||
* ./certs/gen_ca_header.ps1 -PemPath cafe24_root.pem
|
||||
*
|
||||
* 동작(fail-closed): 이 자리표시자는 컴파일은 되지만 유효한 X.509 DER 이 아니므로
|
||||
* tls_init() 의 mbedtls_x509_crt_parse() 단계에서 파싱이 실패한다. 그 결과
|
||||
* tls_init() 이 음수를 반환하고 main 이 bsp_fatal() 로 안전 정지(부팅 중단)한다.
|
||||
* 즉 자리표시자 상태로는 펌웨어가 애플리케이션까지 도달하지 못한다(데이터 유출 없음).
|
||||
* 실제 CA 로 교체하면 정상 부팅 후 핸드셰이크에서 풀 검증이 수행된다.
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
*
|
||||
* TODO(hw): 위 절차로 실제 Cafe24 루트 CA 로 교체할 것. (위험 R3)
|
||||
* ===========================================================================*/
|
||||
#include "server_ca.h"
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* PLACEHOLDER CA — 실제 인증서가 아님. 반드시 교체할 것.
|
||||
*
|
||||
* 형식 주의:
|
||||
* - 각 라인 끝에 명시적 "\n" 을 넣는다(PEM 라인 구분자).
|
||||
* - BEGIN/END 마커와 본문 base64 가 모두 있어야 mbedTLS PEM 파서가 인식한다.
|
||||
* - 마지막 라인 뒤 "\n" 까지 포함하고, C 문자열의 종결 NUL 은 LEN 에 반영된다.
|
||||
* ---------------------------------------------------------------------------*/
|
||||
const char SERVER_CA_PEM[] =
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"MIIB/zCCAaWgAwIBAgIUUExBQ0VIT0xERVJfRE9fTk9UX1VTRTAKBggqhkjOPQQD\n"
|
||||
"AjBFMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MR8wHQYDVQQK\n"
|
||||
"DBZQTEFDRUhPTERFUiBDQSAtIEZBS0UwHhcNMjUwMTAxMDAwMDAwWhcNMjYwMTAx\n"
|
||||
"MDAwMDAwWjBFMQswCQYDVQQGEwJYWDEVMBMGA1UEBwwMRGVmYXVsdCBDaXR5MR8w\n"
|
||||
"HQYDVQQKDBZQTEFDRUhPTERFUiBDQSAtIEZBS0UwWTATBgcqhkjOPQIBBggqhkjO\n"
|
||||
"PQMBBwNCAAQEXAMPLEPLACEHOLDERKEYBYTESDONOTUSEINPRODUCTIONXXXXXXXX\n"
|
||||
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXo1Mw\n"
|
||||
"UTAdBgNVHQ4EFgQUUExBQ0VIT0xERVJfRkFLRV9DQV9YMB8GA1UdIwQYMBaAFFBM\n"
|
||||
"QUNFSE9MREVSX0ZBS0VfQ0FfWDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMC\n"
|
||||
"A0gAMEUCIQDPLACEHOLDERSIGNATUREVALUEDONOTTRUSTXXXXXXXXXXXXXXAiEA\n"
|
||||
"PLACEHOLDERSIGNATUREVALUEDONOTTRUSTYYYYYYYYYYYYYYYYYYYYYYYYYY=\n"
|
||||
"-----END CERTIFICATE-----\n";
|
||||
|
||||
/* 길이: sizeof 는 종결 NUL 1바이트를 포함한다(mbedTLS PEM 요구사항). */
|
||||
const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
|
||||
35
firmware/certs/server_ca.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* =============================================================================
|
||||
* server_ca.h - 서버(Cafe24) 루트 CA 인증서 (flash 임베드)
|
||||
*
|
||||
* tls_mbedtls.c 가 mbedtls_x509_crt_parse() 로 이 PEM 을 파싱하여
|
||||
* 서버 인증서 체인을 검증한다(폐쇄망 → 런타임 CA 다운로드 불가, 펌웨어에 고정).
|
||||
*
|
||||
* 중요(mbedTLS PEM 요구사항):
|
||||
* mbedtls_x509_crt_parse() 는 인자가 PEM 일 때, buflen 에 마지막 NUL 종결자가
|
||||
* "포함"되어야 한다고 요구한다. 따라서 SERVER_CA_PEM_LEN 은 strlen() 이 아니라
|
||||
* sizeof(SERVER_CA_PEM) (즉, 종결 NUL 1바이트 포함) 로 정의한다.
|
||||
*
|
||||
* 만료/교체(위험 R3, docs/stm32f407_migration_plan.md):
|
||||
* 루트 CA 가 만료되거나 발급기관이 교체되면 TLS 연결이 불가해진다.
|
||||
* 폐쇄망이라 OTA 로 갱신할 수 없으므로, 만료 시 펌웨어 재빌드/재배포가 필요하다.
|
||||
* 교체 절차는 certs/README.md 참고.
|
||||
* ===========================================================================*/
|
||||
#ifndef SERVER_CA_H
|
||||
#define SERVER_CA_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* PEM 형식 서버 루트 CA (NUL 종결 C 문자열). */
|
||||
extern const char SERVER_CA_PEM[];
|
||||
|
||||
/* PEM 바이트 길이. **종결 NUL 을 포함**한다(mbedTLS PEM 파싱 요구사항).
|
||||
* 즉 SERVER_CA_PEM_LEN == sizeof(SERVER_CA_PEM). */
|
||||
extern const unsigned int SERVER_CA_PEM_LEN;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* SERVER_CA_H */
|
||||
75
firmware/cmake/arm-none-eabi-toolchain.cmake
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# =============================================================================
|
||||
# arm-none-eabi-toolchain.cmake - STM32F407VGT6 (Cortex-M4F) 크로스 컴파일 툴체인
|
||||
#
|
||||
# 사용:
|
||||
# cmake -S firmware -B firmware/build -G Ninja \
|
||||
# -DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi-toolchain.cmake
|
||||
#
|
||||
# 대상: Cortex-M4F, 하드웨어 단정밀도 FPU(FPv4-SP-D16), Thumb.
|
||||
# 폐쇄망(air-gapped): newlib-nano + nosys stub 으로 자급식 바이너리 생성.
|
||||
# - 외부 런타임/네트워크 의존 없음(빌드 산출물 자체 완결).
|
||||
# =============================================================================
|
||||
|
||||
# ── 베어메탈/RTOS 타깃: OS 없음 → Generic ────────────────────────────────────
|
||||
set(CMAKE_SYSTEM_NAME Generic)
|
||||
set(CMAKE_SYSTEM_PROCESSOR arm)
|
||||
|
||||
# CMake 가 호스트 라이브러리/헤더를 찾지 않도록(크로스 빌드 격리).
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
|
||||
# ── 툴 위치 탐색 ─────────────────────────────────────────────────────────────
|
||||
# PATH 에서 arm-none-eabi-* 를 찾는다. 다른 경로면 -DTOOLCHAIN_PREFIX 로 지정.
|
||||
# 예) -DTOOLCHAIN_PREFIX="C:/Program Files (x86)/Arm/.../bin/arm-none-eabi-"
|
||||
if(NOT DEFINED TOOLCHAIN_PREFIX)
|
||||
set(TOOLCHAIN_PREFIX "arm-none-eabi-")
|
||||
endif()
|
||||
|
||||
# Windows 실행파일 확장자(.exe) 처리: PATH 탐색 시 자동 인식되지만 명시 경로 대비.
|
||||
if(WIN32)
|
||||
set(TC_EXT ".exe")
|
||||
else()
|
||||
set(TC_EXT "")
|
||||
endif()
|
||||
|
||||
set(CMAKE_C_COMPILER "${TOOLCHAIN_PREFIX}gcc${TC_EXT}")
|
||||
set(CMAKE_CXX_COMPILER "${TOOLCHAIN_PREFIX}g++${TC_EXT}")
|
||||
set(CMAKE_ASM_COMPILER "${TOOLCHAIN_PREFIX}gcc${TC_EXT}")
|
||||
set(CMAKE_OBJCOPY "${TOOLCHAIN_PREFIX}objcopy${TC_EXT}" CACHE FILEPATH "objcopy")
|
||||
set(CMAKE_OBJDUMP "${TOOLCHAIN_PREFIX}objdump${TC_EXT}" CACHE FILEPATH "objdump")
|
||||
set(CMAKE_SIZE "${TOOLCHAIN_PREFIX}size${TC_EXT}" CACHE FILEPATH "size")
|
||||
set(CMAKE_AR "${TOOLCHAIN_PREFIX}ar${TC_EXT}" CACHE FILEPATH "ar")
|
||||
|
||||
# ── try_compile: 실행파일이 아닌 정적 라이브러리로 검증 ──────────────────────
|
||||
# 베어메탈 타깃은 링커 스크립트/startup 없이 실행파일을 만들 수 없으므로
|
||||
# CMake 의 컴파일러 식별 단계에서 STATIC_LIBRARY 를 사용한다.
|
||||
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
|
||||
|
||||
# ── CPU/FPU 아키텍처 플래그 (C / C++ / ASM / LINK 공통) ──────────────────────
|
||||
set(CPU_FLAGS "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")
|
||||
|
||||
# 함수/데이터 섹션 분리 → 링크 시 미사용 섹션 제거(--gc-sections)로 크기 최소화.
|
||||
set(OPT_FLAGS "-ffunction-sections -fdata-sections")
|
||||
|
||||
# 공통 컴파일 플래그(언어 무관). CMAKE_<LANG>_FLAGS_INIT 은 캐시 채우기 전 1회 적용.
|
||||
set(CMAKE_C_FLAGS_INIT "${CPU_FLAGS} ${OPT_FLAGS}")
|
||||
set(CMAKE_CXX_FLAGS_INIT "${CPU_FLAGS} ${OPT_FLAGS} -fno-exceptions -fno-rtti")
|
||||
set(CMAKE_ASM_FLAGS_INIT "${CPU_FLAGS} -x assembler-with-cpp")
|
||||
|
||||
# ── 링크 플래그 ──────────────────────────────────────────────────────────────
|
||||
# newlib-nano(--specs=nano.specs): 작은 libc.
|
||||
# nosys(--specs=nosys.specs): _read/_write/_sbrk 등 시스템콜을 빈 stub 로 제공
|
||||
# → 폐쇄망/베어메탈에서 OS 없이 링크 성립. (printf 의 retarget 은 applog/UART 측에서.)
|
||||
# --gc-sections: 미사용 코드 제거. -Map: 메모리 맵 산출(크기 분석/디버깅).
|
||||
# -u _printf_float: newlib-nano 의 vsnprintf/printf 가 %f 를 처리하도록 부동소수
|
||||
# 포맷 코어를 강제 링크한다(약 +7KB flash, 1MB 중 무시할 수준). app_sht30.c 등
|
||||
# 진단 로그가 온습도를 %.2f 로 출력하므로 필요하다. (서버로 보내는 본문은
|
||||
# jb_fixed2() 로 printf 없이 만들므로 wire 계약과는 무관하다.)
|
||||
set(CMAKE_EXE_LINKER_FLAGS_INIT
|
||||
"${CPU_FLAGS} --specs=nano.specs --specs=nosys.specs \
|
||||
-u _printf_float \
|
||||
-Wl,--gc-sections \
|
||||
-Wl,--print-memory-usage \
|
||||
-Wl,--no-warn-rwx-segments")
|
||||
79
firmware/common/app_config.h
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/* =============================================================================
|
||||
* app_config.h - SHT30 온습도 모니터링 STM32F407 펌웨어 공통 설정 (컴파일 타임)
|
||||
*
|
||||
* 이 헤더는 SHT30 온습도 보드의 네트워크/API/주기/핀 설정을 담는다.
|
||||
* 운영 비밀값(API_KEY 등)은 secrets.h 로 분리한다(저장소 미포함).
|
||||
* 보드 식별값(device_id 등)은 board_config.h 에서 BOARD_* 매크로로 정의한다.
|
||||
*
|
||||
* RPi config.py 대응:
|
||||
* API_URL/API_KEY -> APP_API_HOST/APP_API_PATH/secrets.h:APP_API_KEY
|
||||
* REPORT_INTERVAL -> APP_SHT30_REPORT_INTERVAL_SEC
|
||||
* HTTP_TIMEOUT/RETRY -> APP_HTTP_*_*
|
||||
* ===========================================================================*/
|
||||
#ifndef APP_CONFIG_H
|
||||
#define APP_CONFIG_H
|
||||
|
||||
/* ── 펌웨어 버전 (서버로 보고하는 app_version 필드) ───────────────────────── */
|
||||
#define APP_VERSION "v2606-sht30"
|
||||
|
||||
/* ── 서버 API (Cafe24 PHP, HTTPS) ───────────────────────────────────────── */
|
||||
/* 서버 호스트명. TLS SNI 및 HTTP Host 헤더, 인증서 CN/SAN 검증에 사용된다. */
|
||||
#ifndef APP_API_HOST
|
||||
#define APP_API_HOST "your-domain.example"
|
||||
#endif
|
||||
#ifndef APP_API_PORT
|
||||
#define APP_API_PORT 443
|
||||
#endif
|
||||
/* sensor_data.php 경로 (서버 디렉터리 구조에 맞게 조정) */
|
||||
#ifndef APP_API_PATH
|
||||
#define APP_API_PATH "/raspi_leck_detecter/api/sensor_data.php"
|
||||
#endif
|
||||
|
||||
/* ── 주기/타이밍 (초) ───────────────────────────────────────────────────── */
|
||||
/* SHT30 측정/보고 주기 (sht30_monitor.py: 기본 300초, 최소 30초) */
|
||||
#define APP_SHT30_REPORT_INTERVAL_SEC 300u
|
||||
|
||||
/* ── HTTP/네트워크 재시도 (config.py HTTP_*_*) ──────────────────────────── */
|
||||
#define APP_HTTP_TIMEOUT_MS 10000u /* 단일 요청 타임아웃 */
|
||||
#define APP_HTTP_RETRY_COUNT 3u /* 재시도 횟수 */
|
||||
#define APP_HTTP_RETRY_DELAY_MS 5000u /* 재시도 간 대기 */
|
||||
|
||||
/* ── 시간 동기 (SNTP) — TLS 인증서 유효기간/timestamp 필드에 필수 ────────── */
|
||||
#define APP_SNTP_SERVER "pool.ntp.org"
|
||||
#define APP_SNTP_RETRY_DELAY_MS 15000u
|
||||
|
||||
/* ── 네트워크 주소 지정 ─────────────────────────────────────────────────── */
|
||||
/* 1 = DHCP 우선(실패 시 아래 static fallback), 0 = static 고정 */
|
||||
#define APP_NET_USE_DHCP 1
|
||||
#define APP_NET_DHCP_TIMEOUT_MS 20000u
|
||||
/* static fallback (DHCP 실패 또는 APP_NET_USE_DHCP=0 일 때) */
|
||||
#define APP_NET_STATIC_IP "192.168.0.50"
|
||||
#define APP_NET_STATIC_NETMASK "255.255.255.0"
|
||||
#define APP_NET_STATIC_GW "192.168.0.1"
|
||||
#define APP_NET_STATIC_DNS "8.8.8.8"
|
||||
|
||||
/* ── 워치독 (IWDG) ──────────────────────────────────────────────────────── */
|
||||
#define APP_WATCHDOG_TIMEOUT_MS 20000u /* 이 시간 내 refresh 없으면 리셋 */
|
||||
|
||||
/* ── 디버그 로그 UART (USART3: PD8 TX / PD9 RX) ──────────────────────────
|
||||
* 주의: PA2/PA3 는 LAN8720 RMII(ETH_MDIO 등)와 충돌하므로 로그 UART 로 쓸 수 없다.
|
||||
* 따라서 ST-Link VCP(PA2/PA3) 대신 USART3 PD8/PD9 를 사용한다. HARDWARE.md 참조. */
|
||||
#define APP_LOG_UART_BAUD 115200u
|
||||
|
||||
/* ── SHT30 보드: I2C 설정 (board_sht30) ─────────────────────────────────── */
|
||||
#define APP_SHT30_I2C_ADDR 0x44u /* 7-bit 주소 */
|
||||
/* 센서 plausibility 범위 (벗어나면 metric_status="out_of_range").
|
||||
* 주의: 운영 경보 임계(고온/저온/고습/저습 → SMS)는 서버(php/config.php 의
|
||||
* METRIC_* 상수)에서 판정한다. 폐쇄망에서 임계값을 바꿀 때 펌웨어 재플래시가
|
||||
* 불필요하도록, 펌웨어는 측정 원값만 보고하고 아래 범위는 센서 물리/타당성
|
||||
* 한계로만 사용한다. */
|
||||
#define APP_SHT30_TEMP_LOW_C (-40.0)
|
||||
#define APP_SHT30_TEMP_HIGH_C (125.0)
|
||||
#define APP_SHT30_RH_LOW (0.0)
|
||||
#define APP_SHT30_RH_HIGH (100.0)
|
||||
|
||||
/* 운영 비밀값/보드 식별 */
|
||||
#include "secrets.h"
|
||||
#include "board_config.h"
|
||||
|
||||
#endif /* APP_CONFIG_H */
|
||||
176
firmware/common/applog.c
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/* =============================================================================
|
||||
* applog.c - UART 디버그 로그 (RPi setup_logger() 대응)
|
||||
*
|
||||
* USART3(PD8 TX / PD9 RX, APP_LOG_UART_BAUD) 로 printf 스타일
|
||||
* 라인 로그를 출력한다. 라인은 다음 형식으로 찍힌다:
|
||||
*
|
||||
* [ 12345 ms] INFO 메시지...
|
||||
*
|
||||
* 직렬화:
|
||||
* FreeRTOS 스케줄러 동작 중이면 뮤텍스로 라인 단위 직렬화한다.
|
||||
* 부팅 초기(스케줄러 시작 전, 또는 ISR 문맥)에는 뮤텍스 없이 그대로 출력한다.
|
||||
*
|
||||
* USART3 HAL 핸들과 MspInit(핀/클럭) 은 이 파일이 소유한다.
|
||||
* ===========================================================================*/
|
||||
#include "applog.h"
|
||||
#include "app_config.h"
|
||||
|
||||
#include "stm32f4xx_hal.h"
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
#include "semphr.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ── 설정 상수 ───────────────────────────────────────────────────────────── */
|
||||
/* 한 줄 로그 최대 길이(접두부 포함). 스택 버퍼 크기. */
|
||||
#define LOG_LINE_MAX 160
|
||||
/* HAL_UART_Transmit 블로킹 타임아웃(ms). */
|
||||
#define LOG_TX_TIMEOUT_MS 100u
|
||||
/* 뮤텍스 획득 대기(ms). 초과 시 직렬화 포기하고 그대로 출력(로그는 best-effort). */
|
||||
#define LOG_MUTEX_WAIT_MS 50u
|
||||
|
||||
/* ── USART3 HAL 핸들 (이 모듈 소유) ───────────────────────────────────────── */
|
||||
static UART_HandleTypeDef huart_log;
|
||||
|
||||
/* ── 직렬화용 뮤텍스 + 초기화 플래그 ─────────────────────────────────────── */
|
||||
static SemaphoreHandle_t s_logMutex = NULL;
|
||||
static volatile int s_inited = 0;
|
||||
|
||||
/* =============================================================================
|
||||
* USART3 MspInit — PD8(TX)/PD9(RX), AF7, 클럭 게이팅
|
||||
* HAL_UART_Init() 내부에서 HAL_UART_MspInit() 약함수 형태로 호출된다.
|
||||
* (여러 UART 를 쓰는 프로젝트라면 instance 분기. 여기선 USART3 만 처리.)
|
||||
* ===========================================================================*/
|
||||
void HAL_UART_MspInit(UART_HandleTypeDef *uartHandle)
|
||||
{
|
||||
GPIO_InitTypeDef g = {0};
|
||||
|
||||
if (uartHandle->Instance != USART3) {
|
||||
return; /* 다른 UART 는 해당 모듈이 처리 */
|
||||
}
|
||||
|
||||
__HAL_RCC_GPIOD_CLK_ENABLE();
|
||||
__HAL_RCC_USART3_CLK_ENABLE();
|
||||
|
||||
/* PD8=USART3_TX, PD9=USART3_RX, AF7.
|
||||
* (PA2/PA3 는 LAN8720 RMII 의 ETH_MDIO/미사용과 충돌하므로 사용 불가 →
|
||||
* 로그 UART 는 USART3 PD8/PD9 로 배치한다. HARDWARE.md 핀맵 참조.) */
|
||||
g.Pin = GPIO_PIN_8 | GPIO_PIN_9;
|
||||
g.Mode = GPIO_MODE_AF_PP;
|
||||
g.Pull = GPIO_PULLUP;
|
||||
g.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
|
||||
g.Alternate = GPIO_AF7_USART3;
|
||||
HAL_GPIO_Init(GPIOD, &g);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* applog_init — USART3 초기화 + 로그 뮤텍스 생성
|
||||
* ===========================================================================*/
|
||||
void applog_init(void)
|
||||
{
|
||||
huart_log.Instance = USART3;
|
||||
huart_log.Init.BaudRate = APP_LOG_UART_BAUD;
|
||||
huart_log.Init.WordLength = UART_WORDLENGTH_8B;
|
||||
huart_log.Init.StopBits = UART_STOPBITS_1;
|
||||
huart_log.Init.Parity = UART_PARITY_NONE;
|
||||
huart_log.Init.Mode = UART_MODE_TX_RX;
|
||||
huart_log.Init.HwFlowCtl = UART_HWCONTROL_NONE;
|
||||
huart_log.Init.OverSampling = UART_OVERSAMPLING_16;
|
||||
|
||||
if (HAL_UART_Init(&huart_log) != HAL_OK) {
|
||||
/* UART 를 못 켜면 로그는 무음 처리(디바이스 동작 자체는 계속). */
|
||||
s_inited = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
/* 뮤텍스 생성 시도. 실패해도(메모리 부족 등) 로그는 비직렬화로 동작. */
|
||||
s_logMutex = xSemaphoreCreateMutex();
|
||||
s_inited = 1;
|
||||
}
|
||||
|
||||
/* ── 스케줄러가 돌고 있고 ISR 문맥이 아닐 때만 뮤텍스를 쓴다 ──────────────── */
|
||||
static int log_can_use_mutex(void)
|
||||
{
|
||||
if (s_logMutex == NULL) {
|
||||
return 0;
|
||||
}
|
||||
if (xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) {
|
||||
return 0; /* 스케줄러 시작 전: 뮤텍스 블로킹 불가 */
|
||||
}
|
||||
if (__get_IPSR() != 0u) {
|
||||
return 0; /* ISR 문맥: 블로킹 뮤텍스 호출 금지 */
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ── 원시 바이트 전송(블로킹) ────────────────────────────────────────────── */
|
||||
static void log_tx(const char *buf, uint16_t len)
|
||||
{
|
||||
if (len == 0u) {
|
||||
return;
|
||||
}
|
||||
(void)HAL_UART_Transmit(&huart_log, (uint8_t *)buf, len, LOG_TX_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* applog — printf 스타일 라인 로그
|
||||
* 형식: "[%8lu ms] %-5s <msg>\r\n"
|
||||
* ===========================================================================*/
|
||||
void applog(const char *level, const char *fmt, ...)
|
||||
{
|
||||
char line[LOG_LINE_MAX];
|
||||
int n;
|
||||
int pos = 0;
|
||||
va_list ap;
|
||||
int locked = 0;
|
||||
|
||||
if (!s_inited) {
|
||||
return; /* UART 미초기화: 무음 */
|
||||
}
|
||||
if (level == NULL) {
|
||||
level = "?";
|
||||
}
|
||||
|
||||
/* 업타임 스탬프(ms). 스케줄러 전/후 모두 HAL_GetTick() 으로 일관 — TIM6 기반. */
|
||||
n = snprintf(line, sizeof(line), "[%8lu ms] %-5s ",
|
||||
(unsigned long)HAL_GetTick(), level);
|
||||
if (n < 0) {
|
||||
return;
|
||||
}
|
||||
pos = (n < (int)sizeof(line)) ? n : (int)sizeof(line) - 1;
|
||||
|
||||
/* 사용자 메시지 */
|
||||
va_start(ap, fmt);
|
||||
n = vsnprintf(line + pos, sizeof(line) - (size_t)pos, fmt, ap);
|
||||
va_end(ap);
|
||||
if (n > 0) {
|
||||
pos += n;
|
||||
if (pos > (int)sizeof(line) - 1) {
|
||||
pos = (int)sizeof(line) - 1; /* vsnprintf 가 잘라낸 경우 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 줄바꿈(\r\n). 공간이 모자라면 마지막 2바이트를 덮어쓴다. */
|
||||
if (pos > (int)sizeof(line) - 3) {
|
||||
pos = (int)sizeof(line) - 3;
|
||||
}
|
||||
line[pos++] = '\r';
|
||||
line[pos++] = '\n';
|
||||
|
||||
/* 직렬화 후 전송 */
|
||||
if (log_can_use_mutex()) {
|
||||
if (xSemaphoreTake(s_logMutex, pdMS_TO_TICKS(LOG_MUTEX_WAIT_MS)) == pdTRUE) {
|
||||
locked = 1;
|
||||
}
|
||||
}
|
||||
|
||||
log_tx(line, (uint16_t)pos);
|
||||
|
||||
if (locked) {
|
||||
(void)xSemaphoreGive(s_logMutex);
|
||||
}
|
||||
}
|
||||
18
firmware/common/applog.h
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* =============================================================================
|
||||
* applog.h - UART 디버그 로그 (RPi setup_logger() 대응, 파일 로그는 없음)
|
||||
*
|
||||
* 구현(applog.c): USART3(PD8/PD9, 115200) 로 printf 스타일 출력.
|
||||
* (PA2/PA3 는 LAN8720 RMII(ETH_MDIO)와 충돌하여 사용 불가 — HARDWARE.md §0)
|
||||
* RTOS 환경에서 뮤텍스로 라인 단위 직렬화 권장.
|
||||
* ===========================================================================*/
|
||||
#ifndef APPLOG_H
|
||||
#define APPLOG_H
|
||||
|
||||
void applog_init(void);
|
||||
void applog(const char *level, const char *fmt, ...);
|
||||
|
||||
#define LOGI(...) applog("INFO", __VA_ARGS__)
|
||||
#define LOGW(...) applog("WARN", __VA_ARGS__)
|
||||
#define LOGE(...) applog("ERR", __VA_ARGS__)
|
||||
|
||||
#endif /* APPLOG_H */
|
||||
27
firmware/common/board_config.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* =============================================================================
|
||||
* board_config.h - 보드 식별/역할 (SHT30 온습도 보드 전용)
|
||||
*
|
||||
* 이 프로젝트는 SHT30 온습도 보드 전용이다. CMake sht30_fw 타깃이 -DBOARD_SHT30
|
||||
* 을 정의한다(아래에서 미정의 시 보강 정의). reporter.c / jsonbody.c 가 아래
|
||||
* BOARD_* 식별값을 사용해 서버 보고 본문을 구성한다.
|
||||
*
|
||||
* RPi 대응:
|
||||
* sht30_monitor.py SENSOR_ID=2 -> BOARD_SHT30 (sensor_id=2)
|
||||
* ===========================================================================*/
|
||||
#ifndef BOARD_CONFIG_H
|
||||
#define BOARD_CONFIG_H
|
||||
|
||||
/* SHT30 보드 전용. CMake 가 -DBOARD_SHT30 을 넘기지만, 단독 컴파일/정적분석에서도
|
||||
* 식별값이 정의되도록 미정의 시 보강한다. */
|
||||
#ifndef BOARD_SHT30
|
||||
#define BOARD_SHT30 1
|
||||
#endif
|
||||
|
||||
#define BOARD_NAME "sht30"
|
||||
#define BOARD_DEVICE_ID "stm32-sht30-01"
|
||||
#define BOARD_DEVICE_LOCATION "\xEC\x84\x9C\xEB\xB2\x84\xEC\x8B\xA4" /* 서버실 */
|
||||
#define BOARD_SENSOR_ID 2
|
||||
/* 2번 센서 (SHT30 온습도) */
|
||||
#define BOARD_SENSOR_NAME "2\xEB\xB2\x88 \xEC\x84\xBC\xEC\x84\x9C (SHT30)"
|
||||
|
||||
#endif /* BOARD_CONFIG_H */
|
||||
250
firmware/common/bsp.c
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/* =============================================================================
|
||||
* bsp.c - 보드 지원 패키지 (클럭/HAL/공통 주변장치 초기화) [STM32F407VGT6]
|
||||
*
|
||||
* 책임:
|
||||
* - HAL_Init() 및 SystemClock_Config(): HSE 8MHz -> PLL -> SYSCLK 168MHz,
|
||||
* AHB 168MHz, APB1 42MHz, APB2 84MHz, Flash 5WS, PWR/VOS Scale1.
|
||||
* - 공통 GPIO RCC 클럭 게이팅 + 상태 LED.
|
||||
* - bsp_led_set/toggle, bsp_fatal(IRQ 차단 + 고속 점멸 + 워치독 미갱신 → 리셋).
|
||||
* - HAL 타임베이스를 SysTick 이 아닌 TIM6 으로 둬서 FreeRTOS 가 SysTick 을
|
||||
* 단독으로 소유하게 한다(HAL_InitTick / TIM6_DAC_IRQHandler → HAL_IncTick).
|
||||
*
|
||||
* 참고: SystemClock_Config 값은 STM32F407 + 8MHz HSE(예: STM32F4-Discovery,
|
||||
* 또는 LAN8720 RMII 보드의 25MHz 크리스털을 8MHz 로 분주 공급하는 구성)
|
||||
* 기준의 표준 168MHz 설정이다. HSE 주파수가 다르면 PLLM 을 조정한다.
|
||||
* ===========================================================================*/
|
||||
#include "bsp.h"
|
||||
#include "app_config.h"
|
||||
|
||||
#include "stm32f4xx_hal.h"
|
||||
|
||||
/* ── 상태 LED 핀 ───────────────────────────────────────────────────────────
|
||||
* TODO(hw): 보드에 맞게 조정. 기본값은 STM32F4-Discovery 의 녹색 LED(PD12).
|
||||
* - Discovery 계열: PD12(녹), PD13(주황), PD14(빨), PD15(파)
|
||||
* - Nucleo-F4 계열 : PA5 (LD2) 로 바꿀 것
|
||||
* 여기서는 단일 "상태 LED" 만 운영 가시성 용도로 사용한다.
|
||||
*/
|
||||
#define BSP_LED_PORT GPIOD
|
||||
#define BSP_LED_PIN GPIO_PIN_12
|
||||
#define BSP_LED_GPIO_CLK_EN() __HAL_RCC_GPIOD_CLK_ENABLE()
|
||||
|
||||
/* bsp_fatal() 고속 점멸 주기(루프 카운트). 정확한 타이밍은 불필요(곧 리셋됨). */
|
||||
#define BSP_FATAL_BLINK_BUSYLOOP 400000u
|
||||
|
||||
/* ── HAL 타임베이스 전용 타이머 핸들 (TIM6) ───────────────────────────────── */
|
||||
static TIM_HandleTypeDef htim6;
|
||||
|
||||
/* =============================================================================
|
||||
* SystemClock_Config — HSE 8MHz -> 168MHz
|
||||
* PLL: VCO_in = HSE/PLLM = 8/8 = 1MHz
|
||||
* VCO_out = 1MHz * PLLN(336) = 336MHz
|
||||
* SYSCLK = VCO_out / PLLP(2) = 168MHz
|
||||
* PLLQ(7) -> 48MHz (USB/SDIO/RNG 클럭)
|
||||
* AHB=/1(168), APB1=/4(42), APB2=/2(84), Flash latency 5WS.
|
||||
* ===========================================================================*/
|
||||
static void SystemClock_Config(void)
|
||||
{
|
||||
RCC_OscInitTypeDef osc = {0};
|
||||
RCC_ClkInitTypeDef clk = {0};
|
||||
|
||||
/* 전압 레귤레이터 클럭 게이팅 + Scale1 (168MHz 동작에 필수). */
|
||||
__HAL_RCC_PWR_CLK_ENABLE();
|
||||
__HAL_PWR_VOLTAGE_SCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);
|
||||
|
||||
/* HSE -> PLL */
|
||||
osc.OscillatorType = RCC_OSCILLATORTYPE_HSE;
|
||||
osc.HSEState = RCC_HSE_ON;
|
||||
osc.PLL.PLLState = RCC_PLL_ON;
|
||||
osc.PLL.PLLSource = RCC_PLLSOURCE_HSE;
|
||||
osc.PLL.PLLM = 8; /* 8MHz HSE -> 1MHz VCO 입력 */
|
||||
osc.PLL.PLLN = 336; /* -> 336MHz VCO 출력 */
|
||||
osc.PLL.PLLP = RCC_PLLP_DIV2; /* -> 168MHz SYSCLK */
|
||||
osc.PLL.PLLQ = 7; /* -> 48MHz (RNG/USB) */
|
||||
if (HAL_RCC_OscConfig(&osc) != HAL_OK) {
|
||||
bsp_fatal("RCC_OscConfig");
|
||||
}
|
||||
|
||||
/* 버스 클럭: SYSCLK=PLL, AHB=/1, APB1=/4, APB2=/2. Flash 5WS. */
|
||||
clk.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK |
|
||||
RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
|
||||
clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
|
||||
clk.AHBCLKDivider = RCC_SYSCLK_DIV1; /* HCLK = 168MHz */
|
||||
clk.APB1CLKDivider = RCC_HCLK_DIV4; /* PCLK1 = 42MHz */
|
||||
clk.APB2CLKDivider = RCC_HCLK_DIV2; /* PCLK2 = 84MHz */
|
||||
if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_5) != HAL_OK) {
|
||||
bsp_fatal("RCC_ClockConfig");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 공통 GPIO 클럭 게이팅 ──────────────────────────────────────────────────
|
||||
* RMII(LAN8720)/I2C/UART 등 다른 모듈이 자기 핀을 켜기 전에 흔히 쓰는 포트들을
|
||||
* 미리 enable 해 둔다(중복 enable 은 무해). 각 드라이버가 자기 MspInit 에서
|
||||
* 추가로 켜도 된다.
|
||||
*/
|
||||
static void bsp_gpio_clocks_init(void)
|
||||
{
|
||||
__HAL_RCC_GPIOA_CLK_ENABLE(); /* RMII: PA1/PA2/PA7 */
|
||||
__HAL_RCC_GPIOB_CLK_ENABLE(); /* RMII: PB11/PB12/PB13 · SHT30 I2C1: PB6/PB7 */
|
||||
__HAL_RCC_GPIOC_CLK_ENABLE(); /* RMII: PC1/PC4/PC5 */
|
||||
__HAL_RCC_GPIOD_CLK_ENABLE(); /* 로그 UART USART3: PD8/PD9 · 상태 LED: PD12 */
|
||||
}
|
||||
|
||||
/* ── 상태 LED 초기화 ────────────────────────────────────────────────────── */
|
||||
static void bsp_led_init(void)
|
||||
{
|
||||
GPIO_InitTypeDef g = {0};
|
||||
BSP_LED_GPIO_CLK_EN();
|
||||
g.Pin = BSP_LED_PIN;
|
||||
g.Mode = GPIO_MODE_OUTPUT_PP;
|
||||
g.Pull = GPIO_NOPULL;
|
||||
g.Speed = GPIO_SPEED_FREQ_LOW;
|
||||
HAL_GPIO_Init(BSP_LED_PORT, &g);
|
||||
HAL_GPIO_WritePin(BSP_LED_PORT, BSP_LED_PIN, GPIO_PIN_RESET);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 공개 API
|
||||
* ===========================================================================*/
|
||||
void bsp_init(void)
|
||||
{
|
||||
HAL_Init(); /* NVIC 그룹/Flash prefetch/기본 타임베이스 설정 */
|
||||
SystemClock_Config(); /* 168MHz */
|
||||
bsp_gpio_clocks_init();
|
||||
bsp_led_init();
|
||||
}
|
||||
|
||||
void bsp_led_set(int on)
|
||||
{
|
||||
HAL_GPIO_WritePin(BSP_LED_PORT, BSP_LED_PIN,
|
||||
on ? GPIO_PIN_SET : GPIO_PIN_RESET);
|
||||
}
|
||||
|
||||
void bsp_led_toggle(void)
|
||||
{
|
||||
HAL_GPIO_TogglePin(BSP_LED_PORT, BSP_LED_PIN);
|
||||
}
|
||||
|
||||
void bsp_fatal(const char *reason)
|
||||
{
|
||||
(void)reason; /* 로그는 호출측 책임. 여기선 안전 정지만 한다. */
|
||||
|
||||
/* 인터럽트 전면 차단: 더 이상 어떤 태스크/ISR 도 실행되지 않게 한다.
|
||||
* 이 함수는 워치독을 갱신하지 않으므로 IWDG 가 곧 MCU 를 리셋한다. */
|
||||
__disable_irq();
|
||||
|
||||
for (;;) {
|
||||
bsp_led_toggle();
|
||||
for (volatile uint32_t i = 0; i < BSP_FATAL_BLINK_BUSYLOOP; ++i) {
|
||||
__NOP();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* bsp_rand32 — 하드웨어 RNG 기반 32비트 난수 (강한 심볼)
|
||||
*
|
||||
* lwipopts.h 의 LWIP_RAND() 백엔드. net.c 의 약한(weak) 예측 가능 xorshift 대체
|
||||
* 구현을 이 강한 심볼이 덮어쓴다. TCP 초기 시퀀스 번호 / DHCP·DNS 트랜잭션 ID 의
|
||||
* 예측 가능성을 줄인다.
|
||||
*
|
||||
* 주의: RNG 주변장치는 TLS(tls_mbedtls.c)도 사용한다. 서로 다른 HAL 핸들이지만
|
||||
* 둘 다 RNG->DR 을 읽기만 하므로 공존 가능하다(드물게 상관된 값이 나올 수 있으나
|
||||
* lwip 용도에는 충분). RNG 클럭 소스는 PLLQ=48MHz (SystemClock_Config 참조).
|
||||
* RNG 불가 시 마지막 수단으로 틱 혼합값을 반환한다(예측 가능 — 정상 동작에선 미사용).
|
||||
* ===========================================================================*/
|
||||
static RNG_HandleTypeDef s_bsp_rng;
|
||||
static int s_bsp_rng_ready = 0;
|
||||
|
||||
uint32_t bsp_rand32(void)
|
||||
{
|
||||
if (!s_bsp_rng_ready) {
|
||||
__HAL_RCC_RNG_CLK_ENABLE();
|
||||
s_bsp_rng.Instance = RNG;
|
||||
if (HAL_RNG_Init(&s_bsp_rng) != HAL_OK) {
|
||||
return 0x9E3779B9u ^ (uint32_t)HAL_GetTick(); /* RNG 불가: 폴백 */
|
||||
}
|
||||
s_bsp_rng_ready = 1;
|
||||
}
|
||||
|
||||
uint32_t v = 0;
|
||||
if (HAL_RNG_GenerateRandomNumber(&s_bsp_rng, &v) != HAL_OK) {
|
||||
return 0x9E3779B9u ^ (uint32_t)HAL_GetTick(); /* 일시 오류: 폴백 */
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* HAL 타임베이스 오버라이드 (TIM6)
|
||||
*
|
||||
* 기본 HAL 은 SysTick 을 타임베이스로 쓴다. 그러나 FreeRTOS 도 SysTick 을
|
||||
* 스케줄러 틱으로 사용하므로 충돌한다. 아래 약(weak) 함수를 재정의하여 HAL
|
||||
* 타임베이스를 TIM6(기본 우선순위) 으로 옮긴다. FreeRTOS 는 SysTick 을 단독
|
||||
* 소유하고, HAL_GetTick()/HAL_Delay() 는 TIM6 인터럽트로 동작한다.
|
||||
*
|
||||
* HAL_Init() 내부에서 TICK_INT_PRIORITY 로 HAL_InitTick() 이 호출된다.
|
||||
* ===========================================================================*/
|
||||
HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
|
||||
{
|
||||
RCC_ClkInitTypeDef clkcfg;
|
||||
uint32_t flashLatency;
|
||||
uint32_t pclk1;
|
||||
uint32_t tim_clk;
|
||||
uint32_t prescaler;
|
||||
|
||||
/* TIM6 클럭 = APB1 타이머 클럭. APB1 분주(>1)면 타이머 클럭은 PCLK1 x2. */
|
||||
HAL_RCC_GetClockConfig(&clkcfg, &flashLatency);
|
||||
pclk1 = HAL_RCC_GetPCLK1Freq();
|
||||
if (clkcfg.APB1CLKDivider == RCC_HCLK_DIV1) {
|
||||
tim_clk = pclk1;
|
||||
} else {
|
||||
tim_clk = pclk1 * 2u; /* APB1 prescaler != 1 → 타이머 클럭 2배 (84MHz) */
|
||||
}
|
||||
|
||||
/* 1MHz(=1us) 카운팅이 되도록 프리스케일러 계산 → 1ms 마다 업데이트 IRQ. */
|
||||
prescaler = (tim_clk / 1000000u) - 1u;
|
||||
|
||||
__HAL_RCC_TIM6_CLK_ENABLE();
|
||||
|
||||
htim6.Instance = TIM6;
|
||||
htim6.Init.Prescaler = prescaler; /* → 1MHz */
|
||||
htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
|
||||
htim6.Init.Period = 1000u - 1u; /* 1000 카운트 = 1ms */
|
||||
htim6.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
|
||||
htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
|
||||
if (HAL_TIM_Base_Init(&htim6) != HAL_OK) {
|
||||
return HAL_ERROR;
|
||||
}
|
||||
|
||||
/* TIM6 업데이트 인터럽트 활성화. uwTickPrio 는 HAL 내부에서 참조됨. */
|
||||
if (TickPriority < (1uL << __NVIC_PRIO_BITS)) {
|
||||
HAL_NVIC_SetPriority(TIM6_DAC_IRQn, TickPriority, 0);
|
||||
uwTickPrio = TickPriority;
|
||||
} else {
|
||||
return HAL_ERROR;
|
||||
}
|
||||
HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn);
|
||||
|
||||
return HAL_TIM_Base_Start_IT(&htim6);
|
||||
}
|
||||
|
||||
/* HAL_SuspendTick/ResumeTick 도 TIM6 기준으로 재정의(HAL_Delay 등 일관성). */
|
||||
void HAL_SuspendTick(void)
|
||||
{
|
||||
__HAL_TIM_DISABLE_IT(&htim6, TIM_IT_UPDATE);
|
||||
}
|
||||
|
||||
void HAL_ResumeTick(void)
|
||||
{
|
||||
__HAL_TIM_ENABLE_IT(&htim6, TIM_IT_UPDATE);
|
||||
}
|
||||
|
||||
/* TIM6 업데이트 인터럽트 → HAL 틱 증가.
|
||||
* (startup_stm32f407xx.s 의 벡터 이름과 일치해야 한다: TIM6_DAC_IRQHandler) */
|
||||
void TIM6_DAC_IRQHandler(void)
|
||||
{
|
||||
if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE) != RESET &&
|
||||
__HAL_TIM_GET_IT_SOURCE(&htim6, TIM_IT_UPDATE) != RESET) {
|
||||
__HAL_TIM_CLEAR_IT(&htim6, TIM_IT_UPDATE);
|
||||
HAL_IncTick();
|
||||
}
|
||||
}
|
||||
23
firmware/common/bsp.h
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* =============================================================================
|
||||
* bsp.h - 보드 지원 패키지 (클럭/HAL/공통 주변장치 초기화)
|
||||
*
|
||||
* 구현(bsp.c): HAL_Init(), SystemClock_Config()(HSE 8MHz -> 168MHz, PLL),
|
||||
* 공통 GPIO/RCC 클럭 게이팅, 상태 LED. FreeRTOS 사용 시 HAL 타임베이스는
|
||||
* SysTick 이 아닌 별도 TIM 으로 둔다(권장).
|
||||
* ===========================================================================*/
|
||||
#ifndef BSP_H
|
||||
#define BSP_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* 클럭/HAL/공통 주변장치 초기화 (main 진입 직후 1회). */
|
||||
void bsp_init(void);
|
||||
|
||||
/* 상태 LED 제어 (운영 가시성). */
|
||||
void bsp_led_set(int on);
|
||||
void bsp_led_toggle(void);
|
||||
|
||||
/* 치명적 오류: LED 점멸 + (디버그) 정지. 워치독이 리셋시킨다. */
|
||||
void bsp_fatal(const char *reason);
|
||||
|
||||
#endif /* BSP_H */
|
||||
446
firmware/common/ethernetif.c
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/* =============================================================================
|
||||
* ethernetif.c - LwIP <-> STM32 HAL_ETH 글루 (STM32F407 + LAN8720 RMII)
|
||||
*
|
||||
* ST 공식 LwIP "ethernetif.c" 템플릿(STM32F4 + LAN8720)을 RTOS(NO_SYS=0)용으로
|
||||
* 적응. 핵심 구성:
|
||||
* - low_level_init(): HAL_ETH 핸들/MAC(STM32 UID 유도)/DMA 디스크립터/버퍼
|
||||
* 설정 후 ETH 시작.
|
||||
* - low_level_output(): pbuf 체인 -> HAL_ETH_Transmit (Tx 디스크립터).
|
||||
* - ethernetif_input task: RX 인터럽트 -> 세마포어 -> 태스크가 프레임을 꺼내
|
||||
* pbuf 로 복사 후 netif->input(=tcpip_input) 호출.
|
||||
* - HAL_ETH_MspInit(): RMII GPIO/클럭/NVIC 설정.
|
||||
* - PHY 링크 폴링 태스크: LAN8720 BSR 를 읽어 링크/속도/듀플렉스 통지.
|
||||
*
|
||||
* 디스크립터/버퍼는 일반 SRAM(0x2000xxxx) 에 두며 4바이트 정렬. ETH DMA 는
|
||||
* CCM(0x10000000) 에 접근할 수 없으므로 CCM 배치 금지.
|
||||
*
|
||||
* 참고 핀맵(RMII, app/하드웨어 명세):
|
||||
* REF_CLK PA1, MDIO PA2, MDC PC1, CRS_DV PA7,
|
||||
* RXD0 PC4, RXD1 PC5, TXEN PB11, TXD0 PB12, TXD1 PB13
|
||||
* LAN8720 PHY 주소 = 0 (TODO(hw) 보드에서 확인).
|
||||
* ===========================================================================*/
|
||||
#include "lwip/opt.h"
|
||||
#include "lwip/timeouts.h"
|
||||
#include "lwip/netif.h"
|
||||
#include "lwip/netifapi.h" /* netifapi_netif_set_link_up/down: 코어락 직렬화(tcpip 스레드 외부에서 호출) */
|
||||
#include "lwip/pbuf.h"
|
||||
#include "lwip/stats.h"
|
||||
#include "lwip/snmp.h"
|
||||
#include "lwip/ethip6.h"
|
||||
#include "netif/etharp.h"
|
||||
#include "netif/ethernet.h"
|
||||
|
||||
#include "app_config.h"
|
||||
#include "applog.h"
|
||||
|
||||
#include "stm32f4xx_hal.h"
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
#include "semphr.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* ── 컴파일타임 상수 ────────────────────────────────────────────────────── */
|
||||
|
||||
/* LAN8720 PHY 주소(RMII 스트랩). 기본 0. TODO(hw): 보드 스트랩 핀으로 확정. */
|
||||
#ifndef LAN8720_PHY_ADDRESS
|
||||
#define LAN8720_PHY_ADDRESS 0x00U
|
||||
#endif
|
||||
|
||||
/* DMA 디스크립터/버퍼 개수. RAM 절약 + 1 HTTPS 커넥션 처리에 충분.
|
||||
* 각 버퍼는 ETH_RX_BUF_SIZE(=ETH_MAX_PACKET_SIZE, 1524) 바이트. */
|
||||
#define ETH_RXBUFNB 4U
|
||||
#define ETH_TXBUFNB 4U
|
||||
|
||||
/* 이더넷 프레임 처리 정의가 헤더(stm32f4xx_hal_eth.h)에 없을 수 있어 보강. */
|
||||
#ifndef ETH_MAX_PACKET_SIZE
|
||||
#define ETH_MAX_PACKET_SIZE 1524U
|
||||
#endif
|
||||
#ifndef ETH_RX_BUF_SIZE
|
||||
#define ETH_RX_BUF_SIZE ETH_MAX_PACKET_SIZE
|
||||
#endif
|
||||
#ifndef ETH_TX_BUF_SIZE
|
||||
#define ETH_TX_BUF_SIZE ETH_MAX_PACKET_SIZE
|
||||
#endif
|
||||
|
||||
/* netif 식별자. */
|
||||
#define IFNAME0 'e'
|
||||
#define IFNAME1 'n'
|
||||
|
||||
/* PHY 링크 폴링 주기. */
|
||||
#define PHY_LINK_POLL_MS 500U
|
||||
|
||||
/* ── DMA 디스크립터/버퍼 (4바이트 정렬, 일반 SRAM) ──────────────────────── */
|
||||
/* ETH DMA 는 CCM 접근 불가 -> 링커가 .bss(일반 SRAM)에 두도록 한다.
|
||||
* 일부 프로젝트는 별도 섹션(.RxDecripSection 등)에 배치하지만, 기본 .bss 면 충분. */
|
||||
__attribute__((aligned(4))) static ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB];
|
||||
__attribute__((aligned(4))) static ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB];
|
||||
__attribute__((aligned(4))) static uint8_t Rx_Buff[ETH_RXBUFNB][ETH_RX_BUF_SIZE];
|
||||
__attribute__((aligned(4))) static uint8_t Tx_Buff[ETH_TXBUFNB][ETH_TX_BUF_SIZE];
|
||||
|
||||
/* ── HAL ETH 핸들 + RTOS 동기 객체 ──────────────────────────────────────── */
|
||||
|
||||
static ETH_HandleTypeDef s_heth;
|
||||
static SemaphoreHandle_t s_rx_sem; /* RX IRQ -> input 태스크 통지 */
|
||||
static struct netif *s_netif; /* 콜백/태스크에서 참조 */
|
||||
|
||||
/* ── 전방 선언 ──────────────────────────────────────────────────────────── */
|
||||
static void ethernetif_input_task(void *arg);
|
||||
static void phy_link_task(void *arg);
|
||||
static struct pbuf *low_level_input(struct netif *netif);
|
||||
|
||||
/* =============================================================================
|
||||
* HAL MSP: RMII GPIO / 클럭 / NVIC
|
||||
* HAL_ETH_Init() 내부에서 HAL 이 자동 호출한다.
|
||||
* ===========================================================================*/
|
||||
void HAL_ETH_MspInit(ETH_HandleTypeDef *heth)
|
||||
{
|
||||
GPIO_InitTypeDef gpio = {0};
|
||||
|
||||
if (heth->Instance != ETH) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* GPIO 포트 클럭. */
|
||||
__HAL_RCC_GPIOA_CLK_ENABLE();
|
||||
__HAL_RCC_GPIOB_CLK_ENABLE();
|
||||
__HAL_RCC_GPIOC_CLK_ENABLE();
|
||||
|
||||
/* SYSCFG 는 RMII/MII 선택에 필요. ETH 클럭(MAC + Tx + Rx)을 모두 켠다.
|
||||
* STM32F4 HAL 의 표준 ETH 클럭 매크로명. */
|
||||
__HAL_RCC_SYSCFG_CLK_ENABLE();
|
||||
__HAL_RCC_ETHMAC_CLK_ENABLE();
|
||||
__HAL_RCC_ETHMACTX_CLK_ENABLE();
|
||||
__HAL_RCC_ETHMACRX_CLK_ENABLE();
|
||||
|
||||
/* RMII 인터페이스 선택(반드시 ETH 클럭 활성화 후, MAC reset 전).
|
||||
* HAL_ETH_Init 의 MediaInterface=RMII 가 SYSCFG 를 설정하지만 명시적으로 보장. */
|
||||
SYSCFG->PMC |= SYSCFG_PMC_MII_RMII_SEL; /* 1 = RMII */
|
||||
|
||||
/* 공통 RMII AF 설정 헬퍼. 모든 핀은 AF11_ETH, push-pull, very-high speed. */
|
||||
gpio.Mode = GPIO_MODE_AF_PP;
|
||||
gpio.Pull = GPIO_NOPULL;
|
||||
gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
|
||||
gpio.Alternate = GPIO_AF11_ETH;
|
||||
|
||||
/* PORTA: PA1(REF_CLK), PA2(MDIO), PA7(CRS_DV) */
|
||||
gpio.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7;
|
||||
HAL_GPIO_Init(GPIOA, &gpio);
|
||||
|
||||
/* PORTB: PB11(TX_EN), PB12(TXD0), PB13(TXD1) */
|
||||
gpio.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13;
|
||||
HAL_GPIO_Init(GPIOB, &gpio);
|
||||
|
||||
/* PORTC: PC1(MDC), PC4(RXD0), PC5(RXD1) */
|
||||
gpio.Pin = GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5;
|
||||
HAL_GPIO_Init(GPIOC, &gpio);
|
||||
|
||||
/* ETH 전역 인터럽트 활성화(RX 완료 시 input 태스크 깨움). */
|
||||
HAL_NVIC_SetPriority(ETH_IRQn, 7, 0); /* FreeRTOS 안전 우선순위 범위 내 */
|
||||
HAL_NVIC_EnableIRQ(ETH_IRQn);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* ETH 인터럽트: HAL 디스패치 -> RxCpltCallback 에서 세마포어 give
|
||||
* ===========================================================================*/
|
||||
void ETH_IRQHandler(void)
|
||||
{
|
||||
HAL_ETH_IRQHandler(&s_heth);
|
||||
}
|
||||
|
||||
void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth)
|
||||
{
|
||||
(void)heth;
|
||||
BaseType_t hp_woken = pdFALSE;
|
||||
if (s_rx_sem != NULL) {
|
||||
xSemaphoreGiveFromISR(s_rx_sem, &hp_woken);
|
||||
}
|
||||
portYIELD_FROM_ISR(hp_woken);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* low_level_init: HAL_ETH 초기화 + MAC 주소 + DMA 디스크립터 + 시작
|
||||
* ===========================================================================*/
|
||||
static void mac_from_uid(uint8_t mac[6])
|
||||
{
|
||||
/* STM32 96bit 고유 ID 에서 MAC 유도. 로컬 관리(LAA) 비트 set, 멀티캐스트 clear. */
|
||||
uint32_t u0 = HAL_GetUIDw0();
|
||||
uint32_t u1 = HAL_GetUIDw1();
|
||||
uint32_t u2 = HAL_GetUIDw2();
|
||||
uint32_t mix = u0 ^ u1 ^ u2;
|
||||
|
||||
mac[0] = 0x02; /* locally administered, unicast */
|
||||
mac[1] = (uint8_t)(u2 & 0xFF);
|
||||
mac[2] = (uint8_t)((u1 >> 8) & 0xFF);
|
||||
mac[3] = (uint8_t)((mix >> 16) & 0xFF);
|
||||
mac[4] = (uint8_t)((mix >> 8) & 0xFF);
|
||||
mac[5] = (uint8_t)(mix & 0xFF);
|
||||
}
|
||||
|
||||
static void low_level_init(struct netif *netif)
|
||||
{
|
||||
static uint8_t mac[6];
|
||||
mac_from_uid(mac);
|
||||
|
||||
s_heth.Instance = ETH;
|
||||
s_heth.Init.MACAddr = mac;
|
||||
s_heth.Init.AutoNegotiation= ETH_AUTONEGOTIATION_ENABLE;
|
||||
s_heth.Init.Speed = ETH_SPEED_100M; /* AutoNeg 시 무시 */
|
||||
s_heth.Init.DuplexMode = ETH_MODE_FULLDUPLEX; /* AutoNeg 시 무시 */
|
||||
s_heth.Init.MediaInterface = ETH_MEDIA_INTERFACE_RMII;
|
||||
s_heth.Init.RxMode = ETH_RXINTERRUPT_MODE; /* 인터럽트 기반 RX */
|
||||
s_heth.Init.ChecksumMode = ETH_CHECKSUM_BY_SOFTWARE; /* lwipopts SW 체크섬과 일치 */
|
||||
s_heth.Init.PhyAddress = LAN8720_PHY_ADDRESS;
|
||||
|
||||
if (HAL_ETH_Init(&s_heth) != HAL_OK) {
|
||||
/* AutoNeg 실패(케이블 없음 등)도 HAL_TIMEOUT 을 줄 수 있음 -> 계속 진행하고
|
||||
* PHY 폴링 태스크가 이후 링크를 처리하게 한다. */
|
||||
LOGW("eth: HAL_ETH_Init 경고(링크 없음 가능)");
|
||||
}
|
||||
|
||||
/* MAC 길이/타입. */
|
||||
netif->hwaddr_len = ETH_HWADDR_LEN; /* = 6 */
|
||||
memcpy(netif->hwaddr, mac, 6);
|
||||
netif->mtu = 1500;
|
||||
|
||||
netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP;
|
||||
/* 링크 업/다운은 PHY 폴링 태스크가 netif_set_link_up/down 으로 통지하므로
|
||||
* 여기서 NETIF_FLAG_LINK_UP 플래그를 미리 세우지 않는다. */
|
||||
|
||||
/* DMA 디스크립터 체인 초기화(Rx/Tx). */
|
||||
HAL_ETH_DMATxDescListInit(&s_heth, DMATxDscrTab, &Tx_Buff[0][0], ETH_TXBUFNB);
|
||||
HAL_ETH_DMARxDescListInit(&s_heth, DMARxDscrTab, &Rx_Buff[0][0], ETH_RXBUFNB);
|
||||
|
||||
/* MAC/DMA 시작(Tx/Rx enable). */
|
||||
if (HAL_ETH_Start(&s_heth) != HAL_OK) {
|
||||
LOGE("eth: HAL_ETH_Start 실패");
|
||||
}
|
||||
|
||||
LOGI("eth: MAC=%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* low_level_output: pbuf 체인 -> HAL_ETH_Transmit
|
||||
* ===========================================================================*/
|
||||
static err_t low_level_output(struct netif *netif, struct pbuf *p)
|
||||
{
|
||||
(void)netif;
|
||||
|
||||
/* 현재 Tx 디스크립터의 버퍼로 pbuf 체인을 선형 복사.
|
||||
* (lwipopts: LWIP_NETIF_TX_SINGLE_PBUF=1 이라 보통 단일 조각이지만 안전하게 순회) */
|
||||
uint8_t *buf = (uint8_t *)(s_heth.TxDesc->Buffer1Addr);
|
||||
if (buf == NULL) {
|
||||
return ERR_BUF;
|
||||
}
|
||||
|
||||
uint32_t framelen = 0;
|
||||
for (struct pbuf *q = p; q != NULL; q = q->next) {
|
||||
if (framelen + q->len > ETH_TX_BUF_SIZE) {
|
||||
LOGW("eth: TX 프레임 과대(%lu)", (unsigned long)(framelen + q->len));
|
||||
return ERR_BUF;
|
||||
}
|
||||
memcpy(&buf[framelen], q->payload, q->len);
|
||||
framelen += q->len;
|
||||
}
|
||||
|
||||
if (HAL_ETH_TransmitFrame(&s_heth, framelen) != HAL_OK) {
|
||||
/* 디스크립터가 CPU 소유가 아니면(Tx underflow) 복구: 디스크립터 리셋. */
|
||||
if ((s_heth.DMASR & ETH_DMASR_TUS) != (uint32_t)RESET) {
|
||||
s_heth.Instance->DMASR = ETH_DMASR_TUS;
|
||||
s_heth.Instance->DMATPDR = 0; /* Transmit Poll Demand 로 재개 */
|
||||
}
|
||||
LINK_STATS_INC(link.drop);
|
||||
return ERR_IF;
|
||||
}
|
||||
|
||||
LINK_STATS_INC(link.xmit);
|
||||
return ERR_OK;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* low_level_input: HAL_ETH RX 디스크립터 -> 새 pbuf
|
||||
* ===========================================================================*/
|
||||
static struct pbuf *low_level_input(struct netif *netif)
|
||||
{
|
||||
(void)netif;
|
||||
struct pbuf *p = NULL;
|
||||
|
||||
/* 수신 프레임 1개 가져오기. 없으면 NULL. */
|
||||
if (HAL_ETH_GetReceivedFrame_IT(&s_heth) != HAL_OK) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint32_t len = s_heth.RxFrameInfos.length;
|
||||
uint8_t *src = (uint8_t *)s_heth.RxFrameInfos.buffer;
|
||||
|
||||
if (len > 0 && len <= ETH_RX_BUF_SIZE) {
|
||||
p = pbuf_alloc(PBUF_RAW, (u16_t)len, PBUF_POOL);
|
||||
if (p != NULL) {
|
||||
/* pbuf 체인 각 조각으로 복사(POOL 버퍼가 분할될 수 있음). */
|
||||
uint32_t copied = 0;
|
||||
for (struct pbuf *q = p; q != NULL && copied < len; q = q->next) {
|
||||
uint32_t n = q->len;
|
||||
if (copied + n > len) n = len - copied;
|
||||
memcpy(q->payload, &src[copied], n);
|
||||
copied += n;
|
||||
}
|
||||
LINK_STATS_INC(link.recv);
|
||||
} else {
|
||||
LINK_STATS_INC(link.memerr);
|
||||
LINK_STATS_INC(link.drop);
|
||||
}
|
||||
} else {
|
||||
LINK_STATS_INC(link.lenerr);
|
||||
LINK_STATS_INC(link.drop);
|
||||
}
|
||||
|
||||
/* DMA 디스크립터를 다시 DMA 소유로 반환(여러 디스크립터에 걸친 프레임 포함). */
|
||||
{
|
||||
__IO ETH_DMADescTypeDef *dmarxdesc = s_heth.RxFrameInfos.FSRxDesc;
|
||||
for (uint32_t i = 0; i < s_heth.RxFrameInfos.SegCount; i++) {
|
||||
dmarxdesc->Status |= ETH_DMARXDESC_OWN;
|
||||
dmarxdesc = (ETH_DMADescTypeDef *)(dmarxdesc->Buffer2NextDescAddr);
|
||||
}
|
||||
s_heth.RxFrameInfos.SegCount = 0;
|
||||
}
|
||||
|
||||
/* RX 버퍼 언어베일러블(RBUS) 비트가 섰으면 RX 재개. */
|
||||
if ((s_heth.Instance->DMASR & ETH_DMASR_RBUS) != (uint32_t)RESET) {
|
||||
s_heth.Instance->DMASR = ETH_DMASR_RBUS;
|
||||
s_heth.Instance->DMARPDR = 0; /* Receive Poll Demand */
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* ethernetif_input_task: RX 세마포어 대기 -> 가용 프레임 모두 input 으로 전달
|
||||
* ===========================================================================*/
|
||||
static void ethernetif_input_task(void *arg)
|
||||
{
|
||||
struct netif *netif = (struct netif *)arg;
|
||||
|
||||
for (;;) {
|
||||
/* RX IRQ 통지 대기. 타임아웃을 둬서 IRQ 누락 시에도 주기적으로
|
||||
* 디스크립터를 비워(폴링) 록업을 방지한다. 반환값과 무관하게 매 주기
|
||||
* 가용 프레임을 모두 처리한다. */
|
||||
(void)xSemaphoreTake(s_rx_sem, pdMS_TO_TICKS(100));
|
||||
|
||||
struct pbuf *p;
|
||||
while ((p = low_level_input(netif)) != NULL) {
|
||||
/* netif->input 은 netif_add 등록상 tcpip_input(=ethernet_input 경유). */
|
||||
if (netif->input(p, netif) != ERR_OK) {
|
||||
pbuf_free(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* phy_link_task: LAN8720 BSR 폴링 -> 링크 업/다운 + 속도/듀플렉스 반영
|
||||
* ===========================================================================*/
|
||||
static void phy_link_task(void *arg)
|
||||
{
|
||||
struct netif *netif = (struct netif *)arg;
|
||||
int prev_up = -1;
|
||||
|
||||
for (;;) {
|
||||
uint32_t bsr = 0;
|
||||
int link_up = 0;
|
||||
|
||||
if (HAL_ETH_ReadPHYRegister(&s_heth, PHY_BSR, &bsr) == HAL_OK) {
|
||||
link_up = (bsr & PHY_LINKED_STATUS) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (link_up != prev_up) {
|
||||
if (link_up) {
|
||||
/* 자동협상 결과로 MAC 속도/듀플렉스 갱신 후 ETH 재시작. */
|
||||
uint32_t sr = 0;
|
||||
/* LAN8720 special control/status 레지스터(0x1F)에서 속도 추출.
|
||||
* 비표준 매크로 회피 위해 BCR/BSR 기반 보수적 처리. */
|
||||
if (HAL_ETH_ReadPHYRegister(&s_heth, PHY_SR, &sr) == HAL_OK) {
|
||||
/* LAN8720 PHY_SR(0x1F): bit4=duplex(1=full), bit2..3=speed
|
||||
* (10=10M,100=100M). 보드 PHY 별 상이 가능 -> TODO(hw) 확인. */
|
||||
uint32_t speed = ((sr >> 2) & 0x03); /* 01=10,10=100 (LAN8720) */
|
||||
uint32_t duplex = (sr >> 4) & 0x01;
|
||||
|
||||
s_heth.Init.DuplexMode = duplex ? ETH_MODE_FULLDUPLEX
|
||||
: ETH_MODE_HALFDUPLEX;
|
||||
s_heth.Init.Speed = (speed == 0x02) ? ETH_SPEED_100M
|
||||
: ETH_SPEED_10M;
|
||||
}
|
||||
HAL_ETH_ConfigMAC(&s_heth, NULL);
|
||||
HAL_ETH_Start(&s_heth);
|
||||
|
||||
/* LWIP_TCPIP_CORE_LOCKING=1: 이 태스크는 tcpip 스레드가 아니므로
|
||||
* raw netif_set_link_up 을 직접 부르면 코어 상태 레이스가 난다.
|
||||
* netifapi_* 로 호출하면 tcpip 스레드 문맥(코어락 보유)에서 실행되어
|
||||
* 안전하다. (링크 콜백 net_link_cb 도 그 문맥에서 raw netif_set_up
|
||||
* 을 호출하므로 중첩 락 데드락이 없다.) */
|
||||
netifapi_netif_set_link_up(netif);
|
||||
LOGI("eth: PHY 링크 업 (speed=%s)",
|
||||
(s_heth.Init.Speed == ETH_SPEED_100M) ? "100M" : "10M");
|
||||
} else {
|
||||
HAL_ETH_Stop(&s_heth);
|
||||
netifapi_netif_set_link_down(netif);
|
||||
LOGW("eth: PHY 링크 다운");
|
||||
}
|
||||
prev_up = link_up;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(PHY_LINK_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* ethernetif_init: LwIP netif_add 콜백 (net.c 가 등록)
|
||||
* ===========================================================================*/
|
||||
err_t ethernetif_init(struct netif *netif)
|
||||
{
|
||||
LWIP_ASSERT("netif != NULL", (netif != NULL));
|
||||
|
||||
s_netif = netif;
|
||||
|
||||
#if LWIP_NETIF_HOSTNAME
|
||||
netif->hostname = BOARD_DEVICE_ID;
|
||||
#endif
|
||||
|
||||
netif->name[0] = IFNAME0;
|
||||
netif->name[1] = IFNAME1;
|
||||
|
||||
/* IPv4 출력 = etharp_output, 링크 출력 = low_level_output.
|
||||
* netif->input 은 netif_add 의 마지막 인자(tcpip_input)로 설정됨. */
|
||||
netif->output = etharp_output;
|
||||
netif->linkoutput = low_level_output;
|
||||
|
||||
MIB2_INIT_NETIF(netif, snmp_ifType_ethernet_csmacd, 100000000);
|
||||
|
||||
/* RX 통지 세마포어. */
|
||||
s_rx_sem = xSemaphoreCreateBinary();
|
||||
if (s_rx_sem == NULL) {
|
||||
LOGE("eth: RX 세마포어 생성 실패");
|
||||
return ERR_MEM;
|
||||
}
|
||||
|
||||
/* HW 초기화(MAC/PHY/DMA/시작). */
|
||||
low_level_init(netif);
|
||||
|
||||
/* RX 처리 태스크 + PHY 링크 폴링 태스크 기동.
|
||||
* 우선순위/스택은 lwipopts.h 의 ETHIF_RX_* 사용. */
|
||||
if (xTaskCreate(ethernetif_input_task, ETHIF_RX_THREAD_NAME,
|
||||
ETHIF_RX_THREAD_STACKSIZE, netif,
|
||||
ETHIF_RX_THREAD_PRIO, NULL) != pdPASS) {
|
||||
LOGE("eth: RX 태스크 생성 실패");
|
||||
return ERR_MEM;
|
||||
}
|
||||
if (xTaskCreate(phy_link_task, "eth_phy",
|
||||
256, netif,
|
||||
DEFAULT_THREAD_PRIO, NULL) != pdPASS) {
|
||||
LOGE("eth: PHY 태스크 생성 실패");
|
||||
return ERR_MEM;
|
||||
}
|
||||
|
||||
return ERR_OK;
|
||||
}
|
||||
14
firmware/common/hexutil.c
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* =============================================================================
|
||||
* hexutil.c
|
||||
* ===========================================================================*/
|
||||
#include "hexutil.h"
|
||||
|
||||
void hex_encode_lower(const uint8_t *in, size_t len, char *out)
|
||||
{
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
out[i * 2 + 0] = digits[(in[i] >> 4) & 0x0F];
|
||||
out[i * 2 + 1] = digits[in[i] & 0x0F];
|
||||
}
|
||||
out[len * 2] = '\0';
|
||||
}
|
||||
14
firmware/common/hexutil.h
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* =============================================================================
|
||||
* hexutil.h - 바이트 <-> 소문자 hex 변환
|
||||
* ===========================================================================*/
|
||||
#ifndef HEXUTIL_H
|
||||
#define HEXUTIL_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* in[0..len) 를 소문자 hex 로 out 에 기록하고 NUL 종료.
|
||||
* out 버퍼는 최소 (len*2 + 1) 바이트여야 한다. */
|
||||
void hex_encode_lower(const uint8_t *in, size_t len, char *out);
|
||||
|
||||
#endif /* HEXUTIL_H */
|
||||
105
firmware/common/httpapi.c
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/* =============================================================================
|
||||
* httpapi.c - HTTP POST 작성/전송 (이식성: 전송은 transport 콜백에 위임)
|
||||
* ===========================================================================*/
|
||||
#include "httpapi.h"
|
||||
#include "sig.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── 경계 검사 어펜더 (httpapi 전용) ────────────────────────────────────── */
|
||||
typedef struct { char *p; size_t cap; size_t len; int ok; } ha_buf;
|
||||
|
||||
static void ha_init(ha_buf *b, char *out, size_t cap)
|
||||
{
|
||||
b->p = out; b->cap = cap; b->len = 0; b->ok = (cap > 0);
|
||||
if (b->ok) b->p[0] = '\0';
|
||||
}
|
||||
static void ha_putc(ha_buf *b, char c)
|
||||
{
|
||||
if (!b->ok) return;
|
||||
if (b->len + 1 >= b->cap) { b->ok = 0; return; }
|
||||
b->p[b->len++] = c; b->p[b->len] = '\0';
|
||||
}
|
||||
static void ha_raw(ha_buf *b, const char *s) { while (*s) ha_putc(b, *s++); }
|
||||
static void ha_u(ha_buf *b, unsigned long v)
|
||||
{
|
||||
char tmp[20]; int n = 0;
|
||||
if (v == 0) { ha_putc(b, '0'); return; }
|
||||
while (v > 0 && n < (int)sizeof(tmp)) { tmp[n++] = (char)('0' + (v % 10)); v /= 10; }
|
||||
while (n > 0) ha_putc(b, tmp[--n]);
|
||||
}
|
||||
|
||||
int http_build_post(char *out, size_t cap,
|
||||
const char *host, const char *path,
|
||||
const char *device_id, const char *sig_hex,
|
||||
const char *body)
|
||||
{
|
||||
ha_buf b;
|
||||
ha_init(&b, out, cap);
|
||||
|
||||
ha_raw(&b, "POST "); ha_raw(&b, path); ha_raw(&b, " HTTP/1.1\r\n");
|
||||
ha_raw(&b, "Host: "); ha_raw(&b, host); ha_raw(&b, "\r\n");
|
||||
ha_raw(&b, "User-Agent: SHT30Sensor-STM32/" ); /* 버전은 본문 app_version 참고 */
|
||||
ha_raw(&b, "1.0\r\n");
|
||||
ha_raw(&b, "X-Device-Id: "); ha_raw(&b, device_id); ha_raw(&b, "\r\n");
|
||||
ha_raw(&b, "X-Signature: "); ha_raw(&b, sig_hex); ha_raw(&b, "\r\n");
|
||||
ha_raw(&b, "Content-Type: application/json\r\n");
|
||||
ha_raw(&b, "Content-Length: "); ha_u(&b, (unsigned long)strlen(body)); ha_raw(&b, "\r\n");
|
||||
ha_raw(&b, "Connection: close\r\n");
|
||||
ha_raw(&b, "\r\n");
|
||||
ha_raw(&b, body);
|
||||
|
||||
return b.ok ? (int)b.len : -1;
|
||||
}
|
||||
|
||||
int http_parse_status(const uint8_t *resp, size_t len)
|
||||
{
|
||||
/* "HTTP/1.1 200 OK..." 에서 상태 코드 추출 */
|
||||
size_t i = 0;
|
||||
/* 첫 공백까지 스킵 (HTTP/1.x) */
|
||||
while (i < len && resp[i] != ' ' && resp[i] != '\r' && resp[i] != '\n') i++;
|
||||
while (i < len && resp[i] == ' ') i++;
|
||||
if (i + 3 > len) return -1;
|
||||
if (resp[i] < '0' || resp[i] > '9') return -1;
|
||||
|
||||
int code = 0, digits = 0;
|
||||
while (i < len && resp[i] >= '0' && resp[i] <= '9' && digits < 3) {
|
||||
code = code * 10 + (resp[i] - '0');
|
||||
i++; digits++;
|
||||
}
|
||||
return (digits == 3) ? code : -1;
|
||||
}
|
||||
|
||||
int api_post_once(transport_t *t, const char *host, uint16_t port,
|
||||
const char *path, const char *device_id,
|
||||
const char *api_key, const char *body, uint32_t timeout_ms)
|
||||
{
|
||||
char sig_hex[SIG_HEX_BUFSZ];
|
||||
char req[1024];
|
||||
uint8_t resp[256];
|
||||
|
||||
sig_raw_body(api_key, body, strlen(body), sig_hex);
|
||||
|
||||
int reqlen = http_build_post(req, sizeof(req), host, path, device_id, sig_hex, body);
|
||||
if (reqlen < 0) return -1000; /* 요청 버퍼 부족 */
|
||||
|
||||
int rc = t->connect(t->ctx, host, port, timeout_ms);
|
||||
if (rc < 0) return -2000 + rc;
|
||||
|
||||
int sent = t->send(t->ctx, (const uint8_t *)req, (size_t)reqlen, timeout_ms);
|
||||
if (sent < 0) { t->close(t->ctx); return -3000 + sent; }
|
||||
|
||||
/* 상태 라인을 포함하는 첫 응답까지만 읽는다(Connection: close). */
|
||||
size_t total = 0;
|
||||
int have_line = 0;
|
||||
while (total < sizeof(resp) - 1) {
|
||||
int n = t->recv(t->ctx, resp + total, sizeof(resp) - 1 - total, timeout_ms);
|
||||
if (n <= 0) break; /* 연결 종료 또는 오류 */
|
||||
total += (size_t)n;
|
||||
for (size_t k = 0; k < total; k++) { if (resp[k] == '\n') { have_line = 1; break; } }
|
||||
if (have_line) break;
|
||||
}
|
||||
t->close(t->ctx);
|
||||
|
||||
if (total == 0) return -4000; /* 응답 없음 */
|
||||
return http_parse_status(resp, total); /* HTTP 상태 코드 */
|
||||
}
|
||||
30
firmware/common/httpapi.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* =============================================================================
|
||||
* httpapi.h - HTTP/1.1 POST 요청 작성 및 전송 (raw-body 서명 포함)
|
||||
*
|
||||
* RPi 대응: sht30_monitor.py post_reading()
|
||||
* 의 requests.post + 재시도. 서명은 X-Signature 헤더로 전송한다.
|
||||
* ===========================================================================*/
|
||||
#ifndef HTTPAPI_H
|
||||
#define HTTPAPI_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include "transport.h"
|
||||
|
||||
/* POST 요청 문자열을 out 에 작성한다(본문 포함). 반환: 길이 또는 -1(버퍼 부족).
|
||||
* sig_hex 는 X-Signature 헤더 값(sig_raw_body 결과). */
|
||||
int http_build_post(char *out, size_t cap,
|
||||
const char *host, const char *path,
|
||||
const char *device_id, const char *sig_hex,
|
||||
const char *body);
|
||||
|
||||
/* 응답 버퍼에서 HTTP 상태 코드를 파싱한다. 반환: 코드(예 200) 또는 -1. */
|
||||
int http_parse_status(const uint8_t *resp, size_t len);
|
||||
|
||||
/* 한 번의 POST 시도: 서명 -> 요청 작성 -> connect/send/recv -> 상태 파싱 -> close.
|
||||
* 반환: HTTP 상태 코드(>0) 또는 음수(전송 계층 오류). */
|
||||
int api_post_once(transport_t *t, const char *host, uint16_t port,
|
||||
const char *path, const char *device_id,
|
||||
const char *api_key, const char *body, uint32_t timeout_ms);
|
||||
|
||||
#endif /* HTTPAPI_H */
|
||||
149
firmware/common/jsonbody.c
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/* =============================================================================
|
||||
* jsonbody.c - 요청 본문 생성 (경계 검사 포함)
|
||||
* ===========================================================================*/
|
||||
#include "jsonbody.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── 경계 검사 어펜더 ───────────────────────────────────────────────────── */
|
||||
typedef struct {
|
||||
char *p;
|
||||
size_t cap; /* NUL 자리 포함 전체 버퍼 크기 */
|
||||
size_t len; /* 현재 길이(NUL 제외) */
|
||||
int ok; /* 0 이면 오버플로 발생 */
|
||||
} jb_buf;
|
||||
|
||||
static void jb_init(jb_buf *b, char *out, size_t cap)
|
||||
{
|
||||
b->p = out;
|
||||
b->cap = cap;
|
||||
b->len = 0;
|
||||
b->ok = (cap > 0);
|
||||
if (b->ok) b->p[0] = '\0';
|
||||
}
|
||||
|
||||
static void jb_putc(jb_buf *b, char c)
|
||||
{
|
||||
if (!b->ok) return;
|
||||
if (b->len + 1 >= b->cap) { b->ok = 0; return; } /* +1: NUL 자리 보존 */
|
||||
b->p[b->len++] = c;
|
||||
b->p[b->len] = '\0';
|
||||
}
|
||||
|
||||
static void jb_raw(jb_buf *b, const char *s)
|
||||
{
|
||||
while (*s) jb_putc(b, *s++);
|
||||
}
|
||||
|
||||
/* JSON 문자열 값(따옴표 없이) 이스케이프하여 추가.
|
||||
* ASCII 제어문자(<0x20)는 \u00XX, " 와 \ 는 백슬래시 이스케이프,
|
||||
* 그 외(멀티바이트 UTF-8 포함)는 그대로. '/' 는 이스케이프하지 않는다. */
|
||||
static void jb_estr(jb_buf *b, const char *s)
|
||||
{
|
||||
static const char hexd[] = "0123456789abcdef";
|
||||
const unsigned char *u = (const unsigned char *)s;
|
||||
while (*u) {
|
||||
unsigned char c = *u++;
|
||||
if (c == '"') { jb_putc(b, '\\'); jb_putc(b, '"'); }
|
||||
else if (c == '\\') { jb_putc(b, '\\'); jb_putc(b, '\\'); }
|
||||
else if (c < 0x20) {
|
||||
jb_putc(b, '\\'); jb_putc(b, 'u');
|
||||
jb_putc(b, '0'); jb_putc(b, '0');
|
||||
jb_putc(b, hexd[(c >> 4) & 0x0F]);
|
||||
jb_putc(b, hexd[c & 0x0F]);
|
||||
} else {
|
||||
jb_putc(b, (char)c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 부호 없는 정수 추가 */
|
||||
static void jb_u32(jb_buf *b, uint32_t v)
|
||||
{
|
||||
char tmp[10];
|
||||
int n = 0;
|
||||
if (v == 0) { jb_putc(b, '0'); return; }
|
||||
while (v > 0 && n < (int)sizeof(tmp)) { tmp[n++] = (char)('0' + (v % 10)); v /= 10; }
|
||||
while (n > 0) jb_putc(b, tmp[--n]);
|
||||
}
|
||||
|
||||
/* 부호 있는 정수 추가 */
|
||||
static void jb_i32(jb_buf *b, int v)
|
||||
{
|
||||
if (v < 0) { jb_putc(b, '-'); jb_u32(b, (uint32_t)(-(long)v)); }
|
||||
else jb_u32(b, (uint32_t)v);
|
||||
}
|
||||
|
||||
/* 소수 2자리 고정 포맷 (locale·newlib float-printf 비의존, 결정적 반올림).
|
||||
* 예: 24.0 -> "24.00", 48.5 -> "48.50", -3.245 -> "-3.25"
|
||||
* Python 레퍼런스(reference.py)와 바이트 동일해야 한다. */
|
||||
static void jb_fixed2(jb_buf *b, double v)
|
||||
{
|
||||
int neg = 0;
|
||||
if (v < 0) { neg = 1; v = -v; }
|
||||
|
||||
/* 반올림하여 1/100 단위 정수로 (round half up) */
|
||||
long long scaled = (long long)(v * 100.0 + 0.5);
|
||||
long long ip = scaled / 100;
|
||||
int fp = (int)(scaled % 100);
|
||||
|
||||
if (neg && scaled != 0) jb_putc(b, '-');
|
||||
|
||||
/* 정수부 */
|
||||
if (ip == 0) {
|
||||
jb_putc(b, '0');
|
||||
} else {
|
||||
char tmp[20];
|
||||
int n = 0;
|
||||
while (ip > 0 && n < (int)sizeof(tmp)) { tmp[n++] = (char)('0' + (ip % 10)); ip /= 10; }
|
||||
while (n > 0) jb_putc(b, tmp[--n]);
|
||||
}
|
||||
/* 소수부 (항상 2자리) */
|
||||
jb_putc(b, '.');
|
||||
jb_putc(b, (char)('0' + (fp / 10)));
|
||||
jb_putc(b, (char)('0' + (fp % 10)));
|
||||
}
|
||||
|
||||
/* 키 헬퍼: ,"key": */
|
||||
static void jb_key(jb_buf *b, int first, const char *key)
|
||||
{
|
||||
if (!first) jb_putc(b, ',');
|
||||
jb_putc(b, '"');
|
||||
jb_raw(b, key);
|
||||
jb_putc(b, '"');
|
||||
jb_putc(b, ':');
|
||||
}
|
||||
|
||||
static void jb_str_field(jb_buf *b, int first, const char *key, const char *val)
|
||||
{
|
||||
jb_key(b, first, key);
|
||||
jb_putc(b, '"');
|
||||
jb_estr(b, val);
|
||||
jb_putc(b, '"');
|
||||
}
|
||||
|
||||
int jb_sht30_event(char *out, size_t cap,
|
||||
const char *device_id, const char *device_location,
|
||||
int sensor_id, const char *sensor_name,
|
||||
const char *event_type, uint32_t timestamp,
|
||||
double temperature_c, double humidity_percent,
|
||||
const char *metric_status, const char *app_version)
|
||||
{
|
||||
jb_buf b;
|
||||
jb_init(&b, out, cap);
|
||||
|
||||
jb_putc(&b, '{');
|
||||
jb_str_field(&b, 1, "device_id", device_id);
|
||||
jb_str_field(&b, 0, "device_location", device_location);
|
||||
jb_key(&b, 0, "sensor_id"); jb_i32(&b, sensor_id);
|
||||
jb_str_field(&b, 0, "sensor_name", sensor_name);
|
||||
jb_str_field(&b, 0, "event_type", event_type);
|
||||
jb_key(&b, 0, "timestamp"); jb_u32(&b, timestamp);
|
||||
jb_str_field(&b, 0, "metric_type", "sht30");
|
||||
jb_key(&b, 0, "temperature_c"); jb_fixed2(&b, temperature_c);
|
||||
jb_key(&b, 0, "humidity_percent"); jb_fixed2(&b, humidity_percent);
|
||||
jb_str_field(&b, 0, "metric_status", metric_status);
|
||||
jb_str_field(&b, 0, "app_version", app_version);
|
||||
jb_putc(&b, '}');
|
||||
|
||||
return b.ok ? (int)b.len : -1;
|
||||
}
|
||||
26
firmware/common/jsonbody.h
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* =============================================================================
|
||||
* jsonbody.h - 서버로 보낼 요청 본문(JSON) 생성
|
||||
*
|
||||
* raw-body 서명을 쓰므로 본문은 "유효한 JSON"이기만 하면 되고, 서버가 받은
|
||||
* 바이트 그대로 서명을 검증한다. 따라서 키 정렬/PHP json_encode 포맷 재현이
|
||||
* 불필요하다. (단, 서명한 본문 바이트와 실제 전송 바이트는 100% 동일해야 한다.)
|
||||
*
|
||||
* RPi 대응:
|
||||
* jb_sht30_event <- sht30_monitor.py post_reading() data
|
||||
* ===========================================================================*/
|
||||
#ifndef JSONBODY_H
|
||||
#define JSONBODY_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* SHT30 보드 이벤트 본문. temperature_c/humidity_percent 는 소수 2자리로
|
||||
* 직렬화된다. 반환값: 기록한 길이(NUL 제외), 버퍼 부족 시 -1. */
|
||||
int jb_sht30_event(char *out, size_t cap,
|
||||
const char *device_id, const char *device_location,
|
||||
int sensor_id, const char *sensor_name,
|
||||
const char *event_type, uint32_t timestamp,
|
||||
double temperature_c, double humidity_percent,
|
||||
const char *metric_status, const char *app_version);
|
||||
|
||||
#endif /* JSONBODY_H */
|
||||
255
firmware/common/net.c
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/* =============================================================================
|
||||
* net.c - 이더넷/LwIP 네트워크 (net.h 구현)
|
||||
*
|
||||
* 부트 흐름:
|
||||
* net_init():
|
||||
* 1) tcpip_init() -> LwIP tcpip 스레드 기동(FreeRTOS).
|
||||
* 2) netif_add(ethernetif_init, tcpip_input) 으로 ETH netif 등록.
|
||||
* 3) default netif 지정 + status/link 콜백 등록.
|
||||
* 4) APP_NET_USE_DHCP 면 dhcp_start, 아니면 static 적용.
|
||||
* net_wait_up(): 링크 업 + IP 확보까지 블록(타임아웃). DHCP 타임아웃 시
|
||||
* static 으로 폴백.
|
||||
*
|
||||
* tcpip 스레드 외부(애플리케이션 태스크)에서 LwIP 코어를 만지므로,
|
||||
* netifapi_* / tcpip_callback 으로 직렬화한다(LWIP_TCPIP_CORE_LOCKING=1).
|
||||
* ===========================================================================*/
|
||||
#include "net.h"
|
||||
#include "app_config.h"
|
||||
#include "applog.h"
|
||||
|
||||
#include "lwip/tcpip.h"
|
||||
#include "lwip/netif.h"
|
||||
#include "lwip/netifapi.h"
|
||||
#include "lwip/dhcp.h"
|
||||
#include "lwip/dns.h"
|
||||
#include "lwip/ip_addr.h"
|
||||
#include "lwip/init.h"
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* ethernetif.c 가 제공하는 LwIP netif 초기화 콜백. */
|
||||
extern err_t ethernetif_init(struct netif *netif);
|
||||
|
||||
/* ── 내부 상태 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
static struct netif s_netif;
|
||||
|
||||
/* 링크 업 + IP 확보 여부(콜백/태스크 경합 -> volatile). */
|
||||
static volatile int s_link_up = 0;
|
||||
static volatile int s_addr_set = 0; /* 0.0.0.0 가 아닌 IP 가 할당됨 */
|
||||
static int s_dhcp_mode = 0; /* 현재 DHCP 로 시도 중인지 */
|
||||
|
||||
/* ── 콜백 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* IP 주소가 바뀌면(특히 0->유효) 호출됨. */
|
||||
static void net_status_cb(struct netif *netif)
|
||||
{
|
||||
if (netif_is_up(netif) && !ip4_addr_isany_val(*netif_ip4_addr(netif))) {
|
||||
s_addr_set = 1;
|
||||
char ip[16];
|
||||
ip4addr_ntoa_r(netif_ip4_addr(netif), ip, sizeof(ip));
|
||||
LOGI("net: IP 할당 %s", ip);
|
||||
} else {
|
||||
s_addr_set = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 물리 링크(케이블) 상태 변경 시 호출됨(ethernetif 가 PHY 폴링으로 통지). */
|
||||
static void net_link_cb(struct netif *netif)
|
||||
{
|
||||
if (netif_is_link_up(netif)) {
|
||||
s_link_up = 1;
|
||||
LOGI("net: 링크 업");
|
||||
/* 링크가 올라오면 인터페이스 up + 주소 절차 시작.
|
||||
* 이 콜백은 ethernetif 의 netifapi_netif_set_link_up 경유로 tcpip 코어락을
|
||||
* 이미 보유한 문맥에서 호출된다. 따라서 여기서 netifapi_*(다시 코어락 시도)
|
||||
* 를 부르면 비재귀 뮤텍스 자기-데드락이 난다. raw netif_set_up 을 쓴다. */
|
||||
netif_set_up(netif);
|
||||
} else {
|
||||
s_link_up = 0;
|
||||
s_addr_set = 0;
|
||||
LOGW("net: 링크 다운");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 주소 적용 헬퍼 ─────────────────────────────────────────────────────── */
|
||||
|
||||
static void net_apply_static(void)
|
||||
{
|
||||
ip4_addr_t ip, mask, gw, dns;
|
||||
|
||||
if (!ip4addr_aton(APP_NET_STATIC_IP, &ip)) IP4_ADDR(&ip, 192,168,0,50);
|
||||
if (!ip4addr_aton(APP_NET_STATIC_NETMASK,&mask))IP4_ADDR(&mask, 255,255,255,0);
|
||||
if (!ip4addr_aton(APP_NET_STATIC_GW, &gw)) IP4_ADDR(&gw, 192,168,0,1);
|
||||
|
||||
/* DHCP 가 돌고 있었다면 멈춘다(폴백 경로). */
|
||||
if (s_dhcp_mode) {
|
||||
netifapi_dhcp_stop(&s_netif);
|
||||
s_dhcp_mode = 0;
|
||||
}
|
||||
|
||||
netifapi_netif_set_addr(&s_netif, &ip, &mask, &gw);
|
||||
|
||||
/* DNS 서버 설정. dns_setserver 는 raw 코어 API 이고, net_apply_static 은
|
||||
* net_wait_up(앱 태스크, tcpip 스레드 아님)에서도 호출되므로 코어락으로
|
||||
* 직렬화한다. (netifapi_* 호출과 중첩되지 않게 이 호출만 짧게 감싼다.) */
|
||||
if (ip4addr_aton(APP_NET_STATIC_DNS, &dns)) {
|
||||
ip_addr_t d;
|
||||
ip_addr_copy_from_ip4(d, dns);
|
||||
LOCK_TCPIP_CORE();
|
||||
dns_setserver(0, &d);
|
||||
UNLOCK_TCPIP_CORE();
|
||||
}
|
||||
|
||||
netifapi_netif_set_up(&s_netif);
|
||||
|
||||
s_addr_set = 1;
|
||||
LOGI("net: static 적용 ip=%s gw=%s dns=%s",
|
||||
APP_NET_STATIC_IP, APP_NET_STATIC_GW, APP_NET_STATIC_DNS);
|
||||
}
|
||||
|
||||
/* ── 공개 API ───────────────────────────────────────────────────────────── */
|
||||
|
||||
void net_init(void)
|
||||
{
|
||||
ip4_addr_t ip0, mask0, gw0;
|
||||
|
||||
/* tcpip 스레드 기동. NULL 콜백 = 동기 완료 대기 불필요. */
|
||||
tcpip_init(NULL, NULL);
|
||||
|
||||
/* netif 초기 주소: DHCP 면 0.0.0.0 으로 시작, static 이면 곧바로 채운다.
|
||||
* (정적 값은 아래 분기에서 다시 set_addr 로 적용) */
|
||||
ip4_addr_set_zero(&ip0);
|
||||
ip4_addr_set_zero(&mask0);
|
||||
ip4_addr_set_zero(&gw0);
|
||||
|
||||
/* ethernetif_init 가 MAC/PHY/DMA 를 세팅하고, 수신은 tcpip_input 으로 전달. */
|
||||
if (netifapi_netif_add(&s_netif, &ip0, &mask0, &gw0, NULL,
|
||||
ethernetif_init, tcpip_input) != ERR_OK) {
|
||||
LOGE("net: netif_add 실패");
|
||||
return;
|
||||
}
|
||||
|
||||
netifapi_netif_set_default(&s_netif);
|
||||
|
||||
#if LWIP_NETIF_HOSTNAME
|
||||
s_netif.hostname = BOARD_DEVICE_ID; /* DHCP 호스트명(옵션 12) */
|
||||
#endif
|
||||
|
||||
/* 콜백 등록(코어 컨텍스트 안전: netif 핸들 직접 접근은 set 함수로). */
|
||||
netif_set_status_callback(&s_netif, net_status_cb);
|
||||
netif_set_link_callback(&s_netif, net_link_cb);
|
||||
|
||||
/* 인터페이스 administratively up. 실제 링크 업은 PHY 폴링이 통지. */
|
||||
netifapi_netif_set_up(&s_netif);
|
||||
|
||||
#if APP_NET_USE_DHCP
|
||||
s_dhcp_mode = 1;
|
||||
if (netifapi_dhcp_start(&s_netif) != ERR_OK) {
|
||||
LOGE("net: dhcp_start 실패 -> static 폴백");
|
||||
net_apply_static();
|
||||
} else {
|
||||
LOGI("net: DHCP 시작(타임아웃 %ums 후 static 폴백)", APP_NET_DHCP_TIMEOUT_MS);
|
||||
}
|
||||
#else
|
||||
net_apply_static();
|
||||
#endif
|
||||
}
|
||||
|
||||
int net_wait_up(uint32_t timeout_ms)
|
||||
{
|
||||
const uint32_t step_ms = 100;
|
||||
uint32_t waited = 0;
|
||||
|
||||
/* 1단계: 물리 링크 업 대기(케이블 연결). */
|
||||
while (!s_link_up) {
|
||||
if (timeout_ms != 0 && waited >= timeout_ms) {
|
||||
LOGW("net: 링크 업 타임아웃");
|
||||
return -1;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(step_ms));
|
||||
waited += step_ms;
|
||||
}
|
||||
|
||||
/* 2단계: IP 주소 확보 대기. */
|
||||
#if APP_NET_USE_DHCP
|
||||
uint32_t dhcp_waited = 0;
|
||||
while (!s_addr_set) {
|
||||
/* DHCP 타임아웃 -> static 폴백(1회). */
|
||||
if (s_dhcp_mode && dhcp_waited >= APP_NET_DHCP_TIMEOUT_MS) {
|
||||
LOGW("net: DHCP 타임아웃 -> static 폴백");
|
||||
net_apply_static();
|
||||
}
|
||||
if (timeout_ms != 0 && waited >= timeout_ms) {
|
||||
LOGW("net: IP 확보 타임아웃");
|
||||
return -1;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(step_ms));
|
||||
waited += step_ms;
|
||||
dhcp_waited += step_ms;
|
||||
}
|
||||
#else
|
||||
while (!s_addr_set) {
|
||||
if (timeout_ms != 0 && waited >= timeout_ms) {
|
||||
LOGW("net: IP 확보 타임아웃");
|
||||
return -1;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(step_ms));
|
||||
waited += step_ms;
|
||||
}
|
||||
#endif
|
||||
|
||||
char ip[16];
|
||||
net_ip_str(ip, sizeof(ip));
|
||||
LOGI("net: 준비 완료 ip=%s", ip);
|
||||
return 0;
|
||||
}
|
||||
|
||||
net_status_t net_status(void)
|
||||
{
|
||||
return (s_link_up && s_addr_set && netif_is_up(&s_netif)) ? NET_UP : NET_DOWN;
|
||||
}
|
||||
|
||||
void net_ip_str(char *buf, uint32_t buflen)
|
||||
{
|
||||
if (buf == NULL || buflen == 0) {
|
||||
return;
|
||||
}
|
||||
if (s_addr_set && !ip4_addr_isany_val(*netif_ip4_addr(&s_netif))) {
|
||||
char tmp[16];
|
||||
ip4addr_ntoa_r(netif_ip4_addr(&s_netif), tmp, sizeof(tmp));
|
||||
/* 안전 복사(널 종료 보장). */
|
||||
size_t n = strlen(tmp);
|
||||
if (n >= buflen) n = buflen - 1;
|
||||
memcpy(buf, tmp, n);
|
||||
buf[n] = '\0';
|
||||
} else {
|
||||
const char *z = "0.0.0.0";
|
||||
size_t n = strlen(z);
|
||||
if (n >= buflen) n = buflen - 1;
|
||||
memcpy(buf, z, n);
|
||||
buf[n] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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() 제공. */
|
||||
__attribute__((weak)) uint32_t bsp_rand32(void)
|
||||
{
|
||||
/* xorshift32 + FreeRTOS 틱 시드(예측 가능 — TLS/DHCP 트랜잭션 ID 보안엔 부적합).
|
||||
* 운영에서는 반드시 HW RNG 구현으로 대체. */
|
||||
static uint32_t s = 0x1234abcdU;
|
||||
s ^= (uint32_t)xTaskGetTickCount();
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
if (s == 0) s = 0xa5a5a5a5U;
|
||||
return s;
|
||||
}
|
||||
27
firmware/common/net.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* =============================================================================
|
||||
* net.h - 이더넷/LwIP 네트워크 (RPi WiFi 대응 -> 유선 LAN8720 RMII)
|
||||
*
|
||||
* 구현(net.c + ethernetif.c): STM32 ETH MAC + LAN8720 PHY(RMII) + LwIP.
|
||||
* DHCP 우선(APP_NET_USE_DHCP), 실패 시 static fallback(app_config.h).
|
||||
* FreeRTOS 환경에서 LwIP tcpip 스레드를 사용한다.
|
||||
* ===========================================================================*/
|
||||
#ifndef NET_H
|
||||
#define NET_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum { NET_DOWN = 0, NET_UP = 1 } net_status_t;
|
||||
|
||||
/* LwIP + ETH + PHY 초기화 (부팅 시 1회). */
|
||||
void net_init(void);
|
||||
|
||||
/* 링크 업 + IP 확보까지 대기. 0 성공, 음수 타임아웃. */
|
||||
int net_wait_up(uint32_t timeout_ms);
|
||||
|
||||
/* 현재 네트워크 상태. */
|
||||
net_status_t net_status(void);
|
||||
|
||||
/* 디버그용: 현재 IP 를 점-십진 문자열로 buf 에 기록(없으면 "0.0.0.0"). */
|
||||
void net_ip_str(char *buf, uint32_t buflen);
|
||||
|
||||
#endif /* NET_H */
|
||||
162
firmware/common/reporter.c
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/* =============================================================================
|
||||
* reporter.c - 이벤트 보고 헬퍼 구현 (본문 생성 -> 서명 -> TLS POST + 재시도)
|
||||
*
|
||||
* SHT30 온습도 보드가 사용한다. RPi 의 post_reading() 의
|
||||
* "본문 구성 -> requests.post -> 실패 시 재시도" 흐름을 그대로 옮긴다.
|
||||
*
|
||||
* 동작 개요(report_sht30_event):
|
||||
* 1) jsonbody.c 로 JSON 본문 생성 (BOARD_* 식별값 + timesync_now() timestamp)
|
||||
* 2) tls_transport_init() 로 TLS 전송(transport_t) 획득
|
||||
* 3) httpapi.c::api_post_once() 로 1회 POST (서명/연결/송수신/상태파싱/종료 포함)
|
||||
* 4) HTTP 200 이면 성공(0), 아니면 APP_HTTP_RETRY_DELAY_MS 대기 후 재시도
|
||||
* (총 시도 = 1 + APP_HTTP_RETRY_COUNT)
|
||||
*
|
||||
* 동시성:
|
||||
* 현재 보고는 측정 태스크 단독에서 호출되지만, 향후 다중 호출자에 대비해
|
||||
* mbedTLS 컨텍스트/LwIP 소켓 세션을 정적 뮤텍스로 직렬화한다(한 번에 하나의
|
||||
* TLS 세션만 수립).
|
||||
* ===========================================================================*/
|
||||
#include "reporter.h"
|
||||
|
||||
#include "app_config.h" /* APP_API_HOST/PORT/PATH, APP_HTTP_*, APP_VERSION */
|
||||
#include "board_config.h" /* BOARD_DEVICE_ID/LOCATION/SENSOR_ID/SENSOR_NAME */
|
||||
#include "jsonbody.h" /* jb_sht30_event */
|
||||
#include "httpapi.h" /* api_post_once */
|
||||
#include "tls.h" /* tls_transport_init */
|
||||
#include "timesync.h" /* timesync_now / timesync_is_set */
|
||||
#include "applog.h" /* LOGI / LOGW / LOGE */
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h" /* vTaskDelay, xTaskGetSchedulerState */
|
||||
#include "semphr.h" /* xSemaphoreCreateMutexStatic 등 */
|
||||
|
||||
/* ── 상수 ───────────────────────────────────────────────────────────────── */
|
||||
#define REPORT_BODY_BUFSZ 512u /* 본문 버퍼 (SHT30 본문에 충분) */
|
||||
#define HTTP_STATUS_OK 200 /* 성공으로 간주하는 HTTP 상태 코드 */
|
||||
|
||||
/* ── TLS 세션 직렬화용 정적 뮤텍스 ─────────────────────────────────────────
|
||||
* 정적 할당(xSemaphoreCreateMutexStatic)을 사용해 힙/초기화 순서 의존성을
|
||||
* 없앤다. report_init() 호출 없이도 첫 사용 시 lazy 생성한다. */
|
||||
static StaticSemaphore_t s_tls_mutex_storage;
|
||||
static SemaphoreHandle_t s_tls_mutex = NULL;
|
||||
|
||||
/* 뮤텍스 lazy 초기화. 스케줄러 시작 전 호출되어도 안전하도록 임계구역으로
|
||||
* 보호한다(동시 생성 방지). 생성 실패는 NULL 로 남으며, 이후 락 단계에서
|
||||
* "락 없이 진행"으로 폴백한다. */
|
||||
static SemaphoreHandle_t reporter_mutex(void)
|
||||
{
|
||||
if (s_tls_mutex == NULL) {
|
||||
taskENTER_CRITICAL();
|
||||
if (s_tls_mutex == NULL) {
|
||||
s_tls_mutex = xSemaphoreCreateMutexStatic(&s_tls_mutex_storage);
|
||||
}
|
||||
taskEXIT_CRITICAL();
|
||||
}
|
||||
return s_tls_mutex;
|
||||
}
|
||||
|
||||
/* 보고 절차 진입: 뮤텍스 확보. 스케줄러가 동작 중일 때만 블로킹 take 한다.
|
||||
* (스케줄러 시작 전 부팅 단계 보고에서는 동시성이 없으므로 락을 건너뛴다.) */
|
||||
static void reporter_lock(void)
|
||||
{
|
||||
SemaphoreHandle_t m = reporter_mutex();
|
||||
if (m != NULL && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING) {
|
||||
(void)xSemaphoreTake(m, portMAX_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
static void reporter_unlock(void)
|
||||
{
|
||||
SemaphoreHandle_t m = s_tls_mutex;
|
||||
if (m != NULL && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING) {
|
||||
(void)xSemaphoreGive(m);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 본문 1개를 재시도 포함하여 POST ───────────────────────────────────────
|
||||
* body 는 이미 완성된 JSON 본문. what 은 로그용 라벨(예: "sht30/periodic").
|
||||
* 반환: HTTP 200 이면 0, 그 외/통신 실패는 음수. */
|
||||
static int report_post_body(const char *what, const char *body)
|
||||
{
|
||||
/* 총 시도 횟수 = 최초 1회 + 재시도 APP_HTTP_RETRY_COUNT (RPi 재시도 루프와 동일) */
|
||||
const unsigned max_attempts = 1u + APP_HTTP_RETRY_COUNT;
|
||||
int last_rc = -1;
|
||||
|
||||
reporter_lock();
|
||||
for (unsigned attempt = 1u; attempt <= max_attempts; ++attempt) {
|
||||
/* 매 시도마다 새 transport 를 바인딩한다(connect 시 핸드셰이크). */
|
||||
transport_t t;
|
||||
int init_rc = tls_transport_init(&t);
|
||||
if (init_rc != 0) {
|
||||
last_rc = init_rc;
|
||||
LOGE("report %s: TLS init 실패 rc=%d (시도 %u/%u)",
|
||||
what, init_rc, attempt, max_attempts);
|
||||
} else {
|
||||
int code = api_post_once(&t, APP_API_HOST, APP_API_PORT, APP_API_PATH,
|
||||
BOARD_DEVICE_ID, APP_API_KEY, body,
|
||||
APP_HTTP_TIMEOUT_MS);
|
||||
if (code == HTTP_STATUS_OK) {
|
||||
LOGI("report %s: HTTP 200 OK (시도 %u/%u)", what, attempt, max_attempts);
|
||||
reporter_unlock();
|
||||
return 0; /* 성공 */
|
||||
}
|
||||
|
||||
if (code > 0) {
|
||||
/* 서버 응답은 받았으나 비-200 (예: 4xx/5xx) */
|
||||
last_rc = -code;
|
||||
LOGW("report %s: HTTP %d (시도 %u/%u)", what, code, attempt, max_attempts);
|
||||
} else {
|
||||
/* 전송 계층 오류(연결/송수신/응답없음) */
|
||||
last_rc = code;
|
||||
LOGW("report %s: 전송 오류 rc=%d (시도 %u/%u)",
|
||||
what, code, attempt, max_attempts);
|
||||
}
|
||||
}
|
||||
|
||||
/* 마지막 시도가 아니면 대기 후 재시도 */
|
||||
if (attempt < max_attempts) {
|
||||
vTaskDelay(pdMS_TO_TICKS(APP_HTTP_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
reporter_unlock();
|
||||
|
||||
LOGE("report %s: 모든 시도 실패 (last rc=%d)", what, last_rc);
|
||||
return (last_rc < 0) ? last_rc : -1;
|
||||
}
|
||||
|
||||
/* 현재 timestamp 획득. 미동기 시 0 이며 경고를 남기되 그대로 전송한다
|
||||
* (서버가 수신 시각으로 보정할 수 있도록). */
|
||||
static uint32_t reporter_timestamp(const char *what)
|
||||
{
|
||||
if (!timesync_is_set()) {
|
||||
LOGW("report %s: 시간 미동기(timestamp=0) 상태로 전송", what);
|
||||
}
|
||||
return timesync_now();
|
||||
}
|
||||
|
||||
/* ── 공개 API ───────────────────────────────────────────────────────────── */
|
||||
|
||||
int report_sht30_event(const char *event_type, double temp_c, double rh,
|
||||
const char *metric_status)
|
||||
{
|
||||
char body[REPORT_BODY_BUFSZ];
|
||||
uint32_t ts = reporter_timestamp("sht30");
|
||||
|
||||
/* SHT30 본문에는 app_version(APP_VERSION) 을 포함한다. */
|
||||
int n = jb_sht30_event(body, sizeof(body),
|
||||
BOARD_DEVICE_ID, BOARD_DEVICE_LOCATION,
|
||||
BOARD_SENSOR_ID, BOARD_SENSOR_NAME,
|
||||
event_type, ts, temp_c, rh,
|
||||
metric_status, APP_VERSION);
|
||||
if (n < 0) {
|
||||
LOGE("report sht30: 본문 버퍼 부족(event=%s)", event_type ? event_type : "?");
|
||||
return -1;
|
||||
}
|
||||
|
||||
LOGI("report sht30: event=%s status=%s ts=%lu (%d bytes)",
|
||||
event_type ? event_type : "?",
|
||||
metric_status ? metric_status : "?",
|
||||
(unsigned long)ts, n);
|
||||
|
||||
return report_post_body(event_type ? event_type : "sht30", body);
|
||||
}
|
||||
18
firmware/common/reporter.h
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* =============================================================================
|
||||
* reporter.h - 이벤트 보고 헬퍼 (본문 생성 -> 서명 -> TLS POST + 재시도)
|
||||
*
|
||||
* app_config.h 의 호스트/경로/재시도 설정과 board_config.h 의 기기 식별값을
|
||||
* 사용한다. timesync_now() 로 timestamp 를 채운다.
|
||||
*
|
||||
* RPi 대응:
|
||||
* report_sht30_event <- sht30_monitor.py post_reading()
|
||||
* ===========================================================================*/
|
||||
#ifndef REPORTER_H
|
||||
#define REPORTER_H
|
||||
|
||||
/* SHT30 측정 보고. event_type: "startup"|"periodic".
|
||||
* 반환: 0 성공(HTTP 200), 음수 실패. 내부에서 재시도한다. */
|
||||
int report_sht30_event(const char *event_type, double temp_c, double rh,
|
||||
const char *metric_status);
|
||||
|
||||
#endif /* REPORTER_H */
|
||||
121
firmware/common/rtos_hooks.c
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/* =============================================================================
|
||||
* rtos_hooks.c - FreeRTOS 후크/정적 메모리 제공자
|
||||
*
|
||||
* FreeRTOSConfig.h 에서 켜진 후크들을 구현한다:
|
||||
* - configCHECK_FOR_STACK_OVERFLOW=2 -> vApplicationStackOverflowHook
|
||||
* - configUSE_MALLOC_FAILED_HOOK=1 -> vApplicationMallocFailedHook
|
||||
* - configUSE_IDLE_HOOK=1 -> vApplicationIdleHook (IWDG refresh)
|
||||
* - configSUPPORT_STATIC_ALLOCATION=1-> vApplicationGetIdleTaskMemory /
|
||||
* vApplicationGetTimerTaskMemory
|
||||
* - configASSERT -> vAssertCalled
|
||||
*
|
||||
* 치명적 오류(스택 오버플로/malloc 실패/assert) 처리 전략:
|
||||
* 인터럽트를 끄고, 디버거가 붙어 있으면 정지(BKPT), 아니면 무한 루프에 머문다.
|
||||
* 이때 IWDG refresh 가 멈추므로 워치독이 MCU 를 안전하게 리셋한다.
|
||||
* 가능하면 bsp_fatal() 로 LED 점멸/로그를 남긴다(약결합 weak fallback 제공).
|
||||
* ===========================================================================*/
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
#include "timers.h"
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "bsp.h" /* bsp_fatal(const char*) — 안전한 오류 표시기 */
|
||||
#include "watchdog.h" /* watchdog_refresh() — idle 후크에서 호출 */
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* bsp_fatal 약결합 fallback.
|
||||
* bsp.c 가 실제 구현(LED 점멸 + 디버그 정지)을 제공한다. 단위/호스트 테스트나
|
||||
* bsp 미링크 환경에서도 본 파일이 단독으로 빌드되도록 weak 기본 구현을 둔다.
|
||||
* 실제 펌웨어 링크 시에는 bsp.c 의 강한 심볼이 이 정의를 대체한다.
|
||||
* ---------------------------------------------------------------------------*/
|
||||
__attribute__((weak)) void bsp_fatal(const char *reason)
|
||||
{
|
||||
(void)reason;
|
||||
|
||||
/* 모든 인터럽트 차단 (FromISR 경로 포함). 이후 워치독만이 복구 경로. */
|
||||
taskDISABLE_INTERRUPTS();
|
||||
|
||||
#if defined(__GNUC__) && (defined(__ARM_ARCH) || defined(__arm__))
|
||||
/* 디버거가 붙어 있으면 정지(C_DEBUGEN 비트). 아니면 그냥 통과 후 루프. */
|
||||
volatile uint32_t *const dhcsr = (volatile uint32_t *)0xE000EDF0u;
|
||||
if ((*dhcsr & 0x1u) != 0u) {
|
||||
__asm volatile ("bkpt #0");
|
||||
}
|
||||
#endif
|
||||
|
||||
/* 무한 루프 — watchdog_refresh() 가 호출되지 않으므로 IWDG 가 리셋시킨다. */
|
||||
for (;;) {
|
||||
/* 의도적으로 비움. */
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 스택 오버플로 후크 (configCHECK_FOR_STACK_OVERFLOW=2)
|
||||
* 스케줄러가 컨텍스트 스위치 시 스택 한계를 넘었다고 판단하면 호출한다.
|
||||
* 태스크 이름을 reason 으로 넘겨 bsp_fatal 로 이관.
|
||||
* ===========================================================================*/
|
||||
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
|
||||
{
|
||||
(void)xTask;
|
||||
bsp_fatal(pcTaskName ? pcTaskName : "stack-overflow");
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* malloc 실패 후크 (configUSE_MALLOC_FAILED_HOOK=1)
|
||||
* heap_4 의 pvPortMalloc 이 실패하면 호출. 힙 고갈/단편화 -> 안전 리셋.
|
||||
* ===========================================================================*/
|
||||
void vApplicationMallocFailedHook(void)
|
||||
{
|
||||
bsp_fatal("malloc-failed");
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Idle 후크 (configUSE_IDLE_HOOK=1)
|
||||
* Idle 태스크가 돌 때마다 호출. 모든 애플리케이션 태스크가 정상적으로 양보하여
|
||||
* Idle 까지 도달했다는 신호이므로 여기서 IWDG 를 갱신한다.
|
||||
* (헬스 루프 데드락/기아 상태면 Idle 에 도달하지 못해 워치독이 리셋.)
|
||||
*
|
||||
* 주의: Idle 후크 안에서는 블로킹 API(vTaskDelay 등)를 호출하면 안 된다.
|
||||
* watchdog_refresh() 는 IWDG_KR 레지스터 한 번 쓰기로 논블로킹.
|
||||
* ===========================================================================*/
|
||||
void vApplicationIdleHook(void)
|
||||
{
|
||||
watchdog_refresh();
|
||||
/* WFI 로 코어를 잠재워 소비전력/발열을 줄인다(다음 인터럽트까지 대기). */
|
||||
__asm volatile ("wfi");
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* configASSERT 처리기
|
||||
* 파일/라인 정보를 보존하기 위해 지역 정적에 저장한 뒤 bsp_fatal 로 정지.
|
||||
* ===========================================================================*/
|
||||
void vAssertCalled(const char *pcFile, unsigned long ulLine)
|
||||
{
|
||||
/* 디버거에서 검사할 수 있도록 컨텍스트 보존(최적화 제거 방지: volatile). */
|
||||
static volatile const char *file;
|
||||
static volatile unsigned long line;
|
||||
file = pcFile;
|
||||
line = ulLine;
|
||||
(void)file;
|
||||
(void)line;
|
||||
|
||||
bsp_fatal("assert");
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 정적 할당 콜백 (Idle/Timer 태스크 메모리)
|
||||
*
|
||||
* FreeRTOSConfig.h 는 configSUPPORT_STATIC_ALLOCATION=1 +
|
||||
* configKERNEL_PROVIDED_STATIC_MEMORY=1 (FreeRTOS-Kernel V11.1.0, 벤더 고정)로
|
||||
* 설정되어 있다. 이 조합에서 커널의 tasks.c/timers.c 가 vApplicationGetIdleTaskMemory
|
||||
* / vApplicationGetTimerTaskMemory 를 **강한(non-weak) 심볼로 직접 제공**한다.
|
||||
* 따라서 여기서 같은 함수를 정의하면 다중 정의(multiple definition) 링크 오류가 난다.
|
||||
* -> 이 파일에서는 두 콜백을 정의하지 않는다(커널이 소유).
|
||||
*
|
||||
* 만약 더 낮은 커널(예: V10.x, configKERNEL_PROVIDED_STATIC_MEMORY 미지원)으로
|
||||
* 교체하면 위 두 콜백이 미정의(undefined reference)가 되므로, 그때 이 자리에
|
||||
* 명시적 제공자를 다시 추가한다. (커널 프로토타입의 stack-size 인자 타입은
|
||||
* configSTACK_DEPTH_TYPE* 임에 유의.)
|
||||
* ===========================================================================*/
|
||||
16
firmware/common/secrets.h.example
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/* =============================================================================
|
||||
* secrets.h.example - 운영 비밀값 템플릿
|
||||
*
|
||||
* 사용법: 이 파일을 secrets.h 로 복사한 뒤 실제 값으로 채운다.
|
||||
* cp firmware/common/secrets.h.example firmware/common/secrets.h
|
||||
* secrets.h 는 저장소에 커밋하지 않는다(.gitignore 등록).
|
||||
*
|
||||
* APP_API_KEY 는 서버 php/config.php 의 API_KEY 와 반드시 동일해야 한다.
|
||||
* (raw-body 서명: X-Signature = sha256(APP_API_KEY || 본문) )
|
||||
* ===========================================================================*/
|
||||
#ifndef SECRETS_H
|
||||
#define SECRETS_H
|
||||
|
||||
#define APP_API_KEY "change-this-api-key"
|
||||
|
||||
#endif /* SECRETS_H */
|
||||
30
firmware/common/sha256_backend.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* =============================================================================
|
||||
* sha256_backend.h - SHA-256 추상 인터페이스
|
||||
*
|
||||
* 서명(sig.c)은 이 인터페이스만 사용한다. 기본 구현은 sha256_sw.c (공개 도메인,
|
||||
* 자급식)이며, 원하면 mbedTLS 백엔드로 교체할 수 있다(둘 다 동일 다이제스트).
|
||||
* 자급식 구현 덕분에 TLS 스택 없이도 호스트/ARM 컴파일 게이트에서 검증 가능.
|
||||
* ===========================================================================*/
|
||||
#ifndef SHA256_BACKEND_H
|
||||
#define SHA256_BACKEND_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define SHA256_DIGEST_LEN 32
|
||||
|
||||
typedef struct {
|
||||
uint32_t state[8];
|
||||
uint64_t bitlen;
|
||||
uint8_t buffer[64];
|
||||
uint32_t buflen;
|
||||
} sha256_ctx;
|
||||
|
||||
void sha256_init(sha256_ctx *ctx);
|
||||
void sha256_update(sha256_ctx *ctx, const uint8_t *data, size_t len);
|
||||
void sha256_final(sha256_ctx *ctx, uint8_t out[SHA256_DIGEST_LEN]);
|
||||
|
||||
/* 원샷 헬퍼: out = SHA256(data[0..len)) */
|
||||
void sha256(const uint8_t *data, size_t len, uint8_t out[SHA256_DIGEST_LEN]);
|
||||
|
||||
#endif /* SHA256_BACKEND_H */
|
||||
116
firmware/common/sha256_sw.c
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/* =============================================================================
|
||||
* sha256_sw.c - 자급식 SHA-256 (FIPS 180-4)
|
||||
*
|
||||
* 공개 도메인 구현(Brad Conte 계열)을 stdint 기반으로 정리한 것.
|
||||
* 외부 의존성 없음 → 펌웨어 서명 및 호스트/ARM 컴파일 게이트에서 그대로 사용.
|
||||
* 정확성은 sha256_selftest()의 NIST 표준 벡터로 부팅 시 검증할 수 있다.
|
||||
* ===========================================================================*/
|
||||
#include "sha256_backend.h"
|
||||
#include <string.h>
|
||||
|
||||
#define ROTR(a, b) (((a) >> (b)) | ((a) << (32 - (b))))
|
||||
#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z)))
|
||||
#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
|
||||
#define EP0(x) (ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22))
|
||||
#define EP1(x) (ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25))
|
||||
#define SIG0(x) (ROTR(x, 7) ^ ROTR(x, 18) ^ ((x) >> 3))
|
||||
#define SIG1(x) (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))
|
||||
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
|
||||
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
||||
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
|
||||
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
||||
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
|
||||
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
};
|
||||
|
||||
static void sha256_transform(sha256_ctx *ctx, const uint8_t data[64])
|
||||
{
|
||||
uint32_t a, b, c, d, e, f, g, h, t1, t2, m[64];
|
||||
int i, j;
|
||||
|
||||
for (i = 0, j = 0; i < 16; i++, j += 4) {
|
||||
m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16) |
|
||||
((uint32_t)data[j + 2] << 8) | ((uint32_t)data[j + 3]);
|
||||
}
|
||||
for (; i < 64; i++) {
|
||||
m[i] = SIG1(m[i - 2]) + m[i - 7] + SIG0(m[i - 15]) + m[i - 16];
|
||||
}
|
||||
|
||||
a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
|
||||
e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
|
||||
|
||||
for (i = 0; i < 64; i++) {
|
||||
t1 = h + EP1(e) + CH(e, f, g) + K[i] + m[i];
|
||||
t2 = EP0(a) + MAJ(a, b, c);
|
||||
h = g; g = f; f = e; e = d + t1;
|
||||
d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
|
||||
ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
|
||||
ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
|
||||
}
|
||||
|
||||
void sha256_init(sha256_ctx *ctx)
|
||||
{
|
||||
ctx->bitlen = 0;
|
||||
ctx->buflen = 0;
|
||||
ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85;
|
||||
ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a;
|
||||
ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c;
|
||||
ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19;
|
||||
}
|
||||
|
||||
void sha256_update(sha256_ctx *ctx, const uint8_t *data, size_t len)
|
||||
{
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
ctx->buffer[ctx->buflen++] = data[i];
|
||||
if (ctx->buflen == 64) {
|
||||
sha256_transform(ctx, ctx->buffer);
|
||||
ctx->bitlen += 512;
|
||||
ctx->buflen = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sha256_final(sha256_ctx *ctx, uint8_t out[SHA256_DIGEST_LEN])
|
||||
{
|
||||
uint32_t i = ctx->buflen;
|
||||
|
||||
ctx->bitlen += (uint64_t)ctx->buflen * 8;
|
||||
|
||||
/* 패딩: 0x80, 그리고 길이(64비트 빅엔디언)가 들어갈 자리까지 0 */
|
||||
ctx->buffer[i++] = 0x80;
|
||||
if (i > 56) {
|
||||
while (i < 64) ctx->buffer[i++] = 0x00;
|
||||
sha256_transform(ctx, ctx->buffer);
|
||||
i = 0;
|
||||
}
|
||||
while (i < 56) ctx->buffer[i++] = 0x00;
|
||||
|
||||
for (int b = 7; b >= 0; b--) {
|
||||
ctx->buffer[56 + (7 - b)] = (uint8_t)(ctx->bitlen >> (b * 8));
|
||||
}
|
||||
sha256_transform(ctx, ctx->buffer);
|
||||
|
||||
for (i = 0; i < 8; i++) {
|
||||
out[i * 4 + 0] = (uint8_t)(ctx->state[i] >> 24);
|
||||
out[i * 4 + 1] = (uint8_t)(ctx->state[i] >> 16);
|
||||
out[i * 4 + 2] = (uint8_t)(ctx->state[i] >> 8);
|
||||
out[i * 4 + 3] = (uint8_t)(ctx->state[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void sha256(const uint8_t *data, size_t len, uint8_t out[SHA256_DIGEST_LEN])
|
||||
{
|
||||
sha256_ctx ctx;
|
||||
sha256_init(&ctx);
|
||||
sha256_update(&ctx, data, len);
|
||||
sha256_final(&ctx, out);
|
||||
}
|
||||
181
firmware/common/sht30.c
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/* =============================================================================
|
||||
* sht30.c - SHT30 I2C 드라이버 구현 (sht30.h)
|
||||
*
|
||||
* RPi sht30_monitor.py read_sht30() 대응:
|
||||
* - 0x2C 0x06 명령 전송(고반복성, clock stretching 비활성)
|
||||
* - 측정 완료까지 대기(고반복성 ~15ms, 마진 포함 20ms)
|
||||
* - 6바이트 read (T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc)
|
||||
* - CRC 검증/물리값 변환은 이식성 있는 sht30_convert.c(sht30_parse)에 위임
|
||||
*
|
||||
* 버스/핀: I2C1 (TODO(hw): PB6=SCL, PB7=SDA, AF4, Open-Drain, 100~400kHz)
|
||||
* F407 의 I2C1 SCL/SDA 기본 핀은 PB6/PB7. 보드에 따라 PB8/PB9(AF4)도 가능하니
|
||||
* 배선에 맞게 핀/AF 만 조정하면 된다(아래 SHT30_I2C_* 매크로).
|
||||
*
|
||||
* 동시성: HAL_I2C 블로킹 API 사용. 호출은 reporter 태스크(SHT30 보드)에서만
|
||||
* 직렬로 이뤄진다는 전제(단일 소유). 다중 태스크에서 공유한다면 호출측에서
|
||||
* 뮤텍스로 감싸야 한다.
|
||||
*
|
||||
* 재시도: 단발 read 만 수행한다. 재시도/주기 정책은 호출측(reporter)이 담당.
|
||||
* ===========================================================================*/
|
||||
#include "sht30.h"
|
||||
|
||||
#include "app_config.h" /* APP_SHT30_I2C_ADDR */
|
||||
#include "sht30_convert.h" /* sht30_parse */
|
||||
#include "applog.h" /* LOGW/LOGE (진단 로그) */
|
||||
|
||||
#include "stm32f4xx_hal.h" /* CubeF4 HAL (third_party 에 vendored) */
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* ── 버스/핀 구성 (TODO(hw): 배선에 맞게 확인) ───────────────────────────── */
|
||||
#define SHT30_I2C_INSTANCE I2C1
|
||||
#define SHT30_I2C_CLK_ENABLE() __HAL_RCC_I2C1_CLK_ENABLE()
|
||||
#define SHT30_I2C_CLK_DISABLE() __HAL_RCC_I2C1_CLK_DISABLE()
|
||||
#define SHT30_I2C_FORCE_RESET() __HAL_RCC_I2C1_FORCE_RESET()
|
||||
#define SHT30_I2C_RELEASE_RESET() __HAL_RCC_I2C1_RELEASE_RESET()
|
||||
|
||||
/* SCL = PB6, SDA = PB7 (I2C1, AF4). 둘 다 같은 포트(GPIOB)라는 전제. */
|
||||
#define SHT30_GPIO_PORT GPIOB
|
||||
#define SHT30_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE()
|
||||
#define SHT30_SCL_PIN GPIO_PIN_6
|
||||
#define SHT30_SDA_PIN GPIO_PIN_7
|
||||
#define SHT30_GPIO_AF GPIO_AF4_I2C1
|
||||
|
||||
/* 표준 모드 100kHz (SHT30 은 최대 1MHz 까지 가능하나 배선/풀업 여유 위해 보수적).
|
||||
* 필요 시 400000 으로 올릴 수 있다(Fast Mode). */
|
||||
#ifndef SHT30_I2C_CLOCK_HZ
|
||||
#define SHT30_I2C_CLOCK_HZ 100000u
|
||||
#endif
|
||||
|
||||
/* 측정 명령: 고반복성, clock stretching 비활성(0x2C06).
|
||||
* MSB=0x2C, LSB=0x06. */
|
||||
#define SHT30_CMD_MSB 0x2Cu
|
||||
#define SHT30_CMD_LSB 0x06u
|
||||
|
||||
/* 고반복성 측정 최대 시간 15ms. 마진 포함 20ms 대기. */
|
||||
#define SHT30_MEAS_DELAY_MS 20u
|
||||
|
||||
/* 단일 트랜잭션 타임아웃(블로킹 HAL). 측정 대기는 별도(HAL_Delay)로 처리. */
|
||||
#define SHT30_I2C_TIMEOUT_MS 50u
|
||||
|
||||
#define SHT30_FRAME_LEN 6u
|
||||
|
||||
/* HAL 은 7-bit 주소를 상위 7비트에 정렬(좌측 1비트 시프트)하여 사용한다. */
|
||||
#define SHT30_I2C_HAL_ADDR ((uint16_t)(APP_SHT30_I2C_ADDR << 1))
|
||||
|
||||
/* I2C 핸들. 본 모듈에서 소유/초기화한다(헤더 계약상 sht30_init 책임). */
|
||||
static I2C_HandleTypeDef s_i2c;
|
||||
static int s_inited = 0;
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* MSP: GPIO/클럭 초기화. HAL_I2C_Init() 내부에서 콜백된다.
|
||||
* 약한 심볼(__weak)을 오버라이드하므로 다른 I2C 인스턴스가 있어도 안전하게
|
||||
* 인스턴스별로 분기한다.
|
||||
* ---------------------------------------------------------------------------*/
|
||||
void HAL_I2C_MspInit(I2C_HandleTypeDef *hi2c)
|
||||
{
|
||||
if (hi2c->Instance != SHT30_I2C_INSTANCE) {
|
||||
return; /* 다른 I2C 는 해당 모듈이 처리 */
|
||||
}
|
||||
|
||||
GPIO_InitTypeDef gpio = {0};
|
||||
|
||||
SHT30_GPIO_CLK_ENABLE();
|
||||
SHT30_I2C_CLK_ENABLE();
|
||||
|
||||
/* SCL/SDA: AF, Open-Drain, 풀업은 외부 저항 사용 권장.
|
||||
* TODO(hw): 외부 풀업이 없다면 GPIO_PULLUP 으로 바꾸되, I2C 신뢰성을 위해
|
||||
* 보드에 4.7kΩ 외부 풀업 장착을 권장한다. */
|
||||
gpio.Pin = SHT30_SCL_PIN | SHT30_SDA_PIN;
|
||||
gpio.Mode = GPIO_MODE_AF_OD;
|
||||
gpio.Pull = GPIO_NOPULL;
|
||||
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
|
||||
gpio.Alternate = SHT30_GPIO_AF;
|
||||
HAL_GPIO_Init(SHT30_GPIO_PORT, &gpio);
|
||||
}
|
||||
|
||||
void HAL_I2C_MspDeInit(I2C_HandleTypeDef *hi2c)
|
||||
{
|
||||
if (hi2c->Instance != SHT30_I2C_INSTANCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
SHT30_I2C_FORCE_RESET();
|
||||
SHT30_I2C_RELEASE_RESET();
|
||||
SHT30_I2C_CLK_DISABLE();
|
||||
|
||||
HAL_GPIO_DeInit(SHT30_GPIO_PORT, SHT30_SCL_PIN | SHT30_SDA_PIN);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* sht30_init: I2C1 주변장치 초기화. 0 성공, 음수 실패.
|
||||
* ---------------------------------------------------------------------------*/
|
||||
int sht30_init(void)
|
||||
{
|
||||
if (s_inited) {
|
||||
return 0; /* 멱등 */
|
||||
}
|
||||
|
||||
s_i2c.Instance = SHT30_I2C_INSTANCE;
|
||||
s_i2c.Init.ClockSpeed = SHT30_I2C_CLOCK_HZ;
|
||||
s_i2c.Init.DutyCycle = I2C_DUTYCYCLE_2; /* 표준/패스트 공통 안전값 */
|
||||
s_i2c.Init.OwnAddress1 = 0; /* 마스터: 자기 주소 불필요 */
|
||||
s_i2c.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
|
||||
s_i2c.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
|
||||
s_i2c.Init.OwnAddress2 = 0;
|
||||
s_i2c.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
|
||||
s_i2c.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
|
||||
|
||||
if (HAL_I2C_Init(&s_i2c) != HAL_OK) {
|
||||
LOGE("sht30: HAL_I2C_Init failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
s_inited = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* sht30_measure: 단발 측정.
|
||||
* 반환: 0 성공, -1 I2C 통신 오류, -2 CRC 오류.
|
||||
* ---------------------------------------------------------------------------*/
|
||||
int sht30_measure(double *temp_c, double *rh)
|
||||
{
|
||||
if (!s_inited) {
|
||||
/* 초기화 안 된 상태를 통신 오류로 취급(호출측이 재시도/복구). */
|
||||
LOGW("sht30: measure before init");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 1) 측정 명령 전송: 0x2C 0x06 (high repeatability, no clock stretch) */
|
||||
uint8_t cmd[2] = { SHT30_CMD_MSB, SHT30_CMD_LSB };
|
||||
HAL_StatusTypeDef st = HAL_I2C_Master_Transmit(
|
||||
&s_i2c, SHT30_I2C_HAL_ADDR, cmd, sizeof(cmd), SHT30_I2C_TIMEOUT_MS);
|
||||
if (st != HAL_OK) {
|
||||
LOGW("sht30: cmd tx fail (hal=%d)", (int)st);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 2) 측정 완료 대기(~15ms + 마진). clock stretching 을 끈 모드이므로
|
||||
* 호스트가 명시적으로 대기해야 한다. HAL 타임베이스(TIM)에 의존하므로
|
||||
* 스케줄러 유무와 무관하게 동작한다. */
|
||||
HAL_Delay(SHT30_MEAS_DELAY_MS);
|
||||
|
||||
/* 3) 6바이트 read: T_msb,T_lsb,T_crc, RH_msb,RH_lsb,RH_crc */
|
||||
uint8_t frame[SHT30_FRAME_LEN] = {0};
|
||||
st = HAL_I2C_Master_Receive(
|
||||
&s_i2c, SHT30_I2C_HAL_ADDR, frame, SHT30_FRAME_LEN, SHT30_I2C_TIMEOUT_MS);
|
||||
if (st != HAL_OK) {
|
||||
LOGW("sht30: data rx fail (hal=%d)", (int)st);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* 4) CRC 검증 + 물리값 변환(이식성 코어). -1/-2 -> CRC 오류(-2)로 매핑. */
|
||||
int rc = sht30_parse(frame, temp_c, rh);
|
||||
if (rc != 0) {
|
||||
LOGW("sht30: CRC error (parse=%d)", rc);
|
||||
return -2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
17
firmware/common/sht30.h
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/* =============================================================================
|
||||
* sht30.h - SHT30 I2C 드라이버 (RPi sht30_monitor.py read_sht30() 대응)
|
||||
*
|
||||
* 구현(sht30.c): STM32 HAL I2C 로 0x2C06(high-repeatability, clock stretch off)
|
||||
* 명령 전송 -> ~20ms 대기 -> 6바이트 read -> sht30_convert.c 로 CRC 검증/변환.
|
||||
* ===========================================================================*/
|
||||
#ifndef SHT30_H
|
||||
#define SHT30_H
|
||||
|
||||
/* I2C 주변장치 초기화. 0 성공, 음수 실패. */
|
||||
int sht30_init(void);
|
||||
|
||||
/* 단발 측정. *temp_c(섭씨), *rh(%) 에 결과 기록.
|
||||
* 반환: 0 성공, -1 I2C 통신 오류, -2 CRC 오류. */
|
||||
int sht30_measure(double *temp_c, double *rh);
|
||||
|
||||
#endif /* SHT30_H */
|
||||
30
firmware/common/sht30_convert.c
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* =============================================================================
|
||||
* sht30_convert.c
|
||||
* ===========================================================================*/
|
||||
#include "sht30_convert.h"
|
||||
|
||||
uint8_t sht30_crc8(const uint8_t *data, size_t len)
|
||||
{
|
||||
uint8_t crc = 0xFF;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int b = 0; b < 8; b++) {
|
||||
if (crc & 0x80) crc = (uint8_t)((crc << 1) ^ 0x31);
|
||||
else crc = (uint8_t)(crc << 1);
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
int sht30_parse(const uint8_t frame[6], double *temp_c, double *rh)
|
||||
{
|
||||
if (sht30_crc8(&frame[0], 2) != frame[2]) return -1;
|
||||
if (sht30_crc8(&frame[3], 2) != frame[5]) return -2;
|
||||
|
||||
uint16_t raw_t = (uint16_t)((frame[0] << 8) | frame[1]);
|
||||
uint16_t raw_rh = (uint16_t)((frame[3] << 8) | frame[4]);
|
||||
|
||||
if (temp_c) *temp_c = -45.0 + 175.0 * ((double)raw_t / 65535.0);
|
||||
if (rh) *rh = 100.0 * ((double)raw_rh / 65535.0);
|
||||
return 0;
|
||||
}
|
||||
22
firmware/common/sht30_convert.h
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/* =============================================================================
|
||||
* sht30_convert.h - SHT30 원시 데이터 변환/CRC (이식성 있는 순수 로직)
|
||||
*
|
||||
* I2C 전송과 분리하여 호스트/ARM 컴파일 게이트에서 검증 가능하게 한다.
|
||||
* RPi 대응: sht30_monitor.py crc8_sht3x() / read_sht30() 변환식.
|
||||
* ===========================================================================*/
|
||||
#ifndef SHT30_CONVERT_H
|
||||
#define SHT30_CONVERT_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* SHT3x CRC-8: poly 0x31, init 0xFF, no reflection, no final xor. */
|
||||
uint8_t sht30_crc8(const uint8_t *data, size_t len);
|
||||
|
||||
/* 6바이트 측정 프레임(T_msb,T_lsb,T_crc,RH_msb,RH_lsb,RH_crc) 검증 + 변환.
|
||||
* *temp_c = -45 + 175 * raw_t / 65535
|
||||
* *rh = 100 * raw_rh / 65535
|
||||
* 반환: 0 성공, -1 온도 CRC 오류, -2 습도 CRC 오류. */
|
||||
int sht30_parse(const uint8_t frame[6], double *temp_c, double *rh);
|
||||
|
||||
#endif /* SHT30_CONVERT_H */
|
||||
56
firmware/common/sig.c
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* =============================================================================
|
||||
* sig.c - raw-body 서명 구현 (HMAC-SHA256)
|
||||
*
|
||||
* 서버 규약(php/config.php:verify_signature_raw):
|
||||
* X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=raw_body_bytes ) )
|
||||
*
|
||||
* HMAC(키 접두 SHA256 이 아님)을 쓰는 이유: 길이확장(length-extension) 공격에
|
||||
* 견디고, 서버 hash_hmac('sha256', body, API_KEY) 와 바이트 단위로 일치한다.
|
||||
* reference.py:sign_raw() (hmac.new) 와도 동일하다.
|
||||
* ===========================================================================*/
|
||||
#include "sig.h"
|
||||
#include "sha256_backend.h"
|
||||
#include "hexutil.h"
|
||||
#include <string.h>
|
||||
|
||||
/* SHA-256 블록 크기(HMAC 키 패딩 단위). */
|
||||
#define SIG_HMAC_BLOCK 64u
|
||||
|
||||
void sig_raw_body(const char *key, const char *body, size_t body_len,
|
||||
char out_hex[SIG_HEX_BUFSZ])
|
||||
{
|
||||
uint8_t k0[SIG_HMAC_BLOCK]; /* 블록 크기로 정규화한 키 K' */
|
||||
uint8_t ipad[SIG_HMAC_BLOCK];
|
||||
uint8_t opad[SIG_HMAC_BLOCK];
|
||||
uint8_t inner[SHA256_DIGEST_LEN];
|
||||
uint8_t digest[SHA256_DIGEST_LEN];
|
||||
sha256_ctx ctx;
|
||||
size_t key_len = strlen(key);
|
||||
|
||||
/* K' = 키를 블록 크기로 맞춘 값. 64바이트 초과면 SHA256 으로 줄이고 0 패딩. */
|
||||
memset(k0, 0, sizeof(k0));
|
||||
if (key_len > SIG_HMAC_BLOCK) {
|
||||
sha256((const uint8_t *)key, key_len, k0); /* 앞 32바이트, 나머지는 0 유지 */
|
||||
} else {
|
||||
memcpy(k0, key, key_len);
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < SIG_HMAC_BLOCK; i++) {
|
||||
ipad[i] = (uint8_t)(k0[i] ^ 0x36u);
|
||||
opad[i] = (uint8_t)(k0[i] ^ 0x5cu);
|
||||
}
|
||||
|
||||
/* inner = SHA256(ipad || body) */
|
||||
sha256_init(&ctx);
|
||||
sha256_update(&ctx, ipad, SIG_HMAC_BLOCK);
|
||||
sha256_update(&ctx, (const uint8_t *)body, body_len);
|
||||
sha256_final(&ctx, inner);
|
||||
|
||||
/* digest = SHA256(opad || inner) */
|
||||
sha256_init(&ctx);
|
||||
sha256_update(&ctx, opad, SIG_HMAC_BLOCK);
|
||||
sha256_update(&ctx, inner, SHA256_DIGEST_LEN);
|
||||
sha256_final(&ctx, digest);
|
||||
|
||||
hex_encode_lower(digest, SHA256_DIGEST_LEN, out_hex);
|
||||
}
|
||||
25
firmware/common/sig.h
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* =============================================================================
|
||||
* sig.h - raw-body 서명 (STM32 마이그레이션 R1, HMAC-SHA256)
|
||||
*
|
||||
* 서버 규약(php/config.php:verify_signature_raw):
|
||||
* X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=raw_body_bytes ) )
|
||||
*
|
||||
* 클라이언트는 전송할 본문 바이트 그대로를 서명만 하면 된다. JSON 키 정렬·
|
||||
* 부동소수 포맷·슬래시 이스케이프를 재현할 필요가 없다. (키 접두 SHA256 이 아닌
|
||||
* HMAC 을 사용해 길이확장 공격에 견딘다.)
|
||||
* ===========================================================================*/
|
||||
#ifndef SIG_H
|
||||
#define SIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#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 는 최소 SIG_HEX_BUFSZ 바이트.
|
||||
* key 는 NUL 종료 문자열, body 는 임의 바이트열(body_len 길이). */
|
||||
void sig_raw_body(const char *key, const char *body, size_t body_len,
|
||||
char out_hex[SIG_HEX_BUFSZ]);
|
||||
|
||||
#endif /* SIG_H */
|
||||
310
firmware/common/timesync.c
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/* =============================================================================
|
||||
* timesync.c - SNTP 시간 동기 + RTC 반영 (timesync.h 구현)
|
||||
*
|
||||
* 동작:
|
||||
* - timesync_init(): LwIP apps/sntp 를 POLL 모드로 시작(서버=APP_SNTP_SERVER).
|
||||
* - SNTP 응답 수신 시 lwipopts.h 의 SNTP_SET_SYSTEM_TIME(sec) 매크로가
|
||||
* timesync_apply_epoch() 를 호출 -> Unix 초를 분해해 HAL_RTC_Set{Time,Date}.
|
||||
* - timesync_now(): RTC 를 다시 읽어 Unix 초로 환산. (RTC 미설정 시 0)
|
||||
*
|
||||
* 시간대: 모든 시간은 UTC 로 다룬다. 서버 timestamp / TLS 인증서 유효기간 검증
|
||||
* 모두 UTC 기준. RTC 에도 UTC 를 저장한다.
|
||||
*
|
||||
* RTC 클럭소스: 기본 LSI(저정밀, 외부 부품 불필요). 보드에 32.768kHz LSE 가
|
||||
* 있으면 정밀도가 좋다. TODO(hw): 실제 보드에 맞춰 bsp 에서 RTC 클럭
|
||||
* 소스를 LSE 로 전환(아래 RTC 초기화 가드 참고).
|
||||
* ===========================================================================*/
|
||||
#include "timesync.h"
|
||||
#include "app_config.h"
|
||||
#include "applog.h"
|
||||
|
||||
#include "stm32f4xx_hal.h"
|
||||
|
||||
/* LwIP SNTP 앱 */
|
||||
#include "lwip/apps/sntp.h"
|
||||
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
/* ── 내부 상태 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* HAL RTC 핸들. bsp 가 RTC 클럭/도메인을 켠 뒤 여기서 Init 한다. */
|
||||
static RTC_HandleTypeDef s_hrtc;
|
||||
|
||||
/* 한 번이라도 SNTP 동기가 끝났는지(volatile: 콜백/태스크 경합). */
|
||||
static volatile int s_is_set = 0;
|
||||
|
||||
/* RTC 가 HAL_RTC_Init 까지 완료됐는지. */
|
||||
static int s_rtc_ready = 0;
|
||||
|
||||
/* RTC 연도 기준(STM32 RTC 는 2자리 연도 BCD: 0..99). 2000년 기준 사용. */
|
||||
#define TS_RTC_YEAR_BASE 2000
|
||||
/* RTC 가 표현 가능한 최소 Unix epoch(2000-01-01 00:00:00 UTC). */
|
||||
#define TS_EPOCH_2000 946684800UL
|
||||
|
||||
/* ── 시간 분해/합성 유틸 (gmtime/timegm 대체, UTC 고정) ─────────────────── */
|
||||
|
||||
static int ts_is_leap(int year)
|
||||
{
|
||||
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0);
|
||||
}
|
||||
|
||||
/* Unix 초(UTC) -> 분해 시각. 윤년/월일 정확. */
|
||||
typedef struct {
|
||||
int year; /* 절대 연도, 예: 2026 */
|
||||
int mon; /* 1..12 */
|
||||
int mday; /* 1..31 */
|
||||
int hour; /* 0..23 */
|
||||
int min; /* 0..59 */
|
||||
int sec; /* 0..59 */
|
||||
int wday; /* 1..7, 월=1 ... 일=7 (HAL RTC_WeekDay 호환) */
|
||||
} ts_tm_t;
|
||||
|
||||
static void ts_epoch_to_tm(uint32_t epoch, ts_tm_t *out)
|
||||
{
|
||||
static const int mdays[12] = { 31,28,31,30,31,30,31,31,30,31,30,31 };
|
||||
|
||||
uint32_t days = epoch / 86400UL;
|
||||
uint32_t rem = epoch % 86400UL;
|
||||
|
||||
out->hour = (int)(rem / 3600UL);
|
||||
rem %= 3600UL;
|
||||
out->min = (int)(rem / 60UL);
|
||||
out->sec = (int)(rem % 60UL);
|
||||
|
||||
/* 1970-01-01 은 목요일(Thursday). HAL 의 RTC_WEEKDAY: 월=1..일=7.
|
||||
* Unix wday(일=0..토=6) -> ISO(월=1..일=7) 로 변환. */
|
||||
int unix_wday = (int)((days + 4) % 7); /* 0=Sun..6=Sat */
|
||||
out->wday = (unix_wday == 0) ? 7 : unix_wday; /* 1=Mon..7=Sun */
|
||||
|
||||
int year = 1970;
|
||||
while (1) {
|
||||
int ydays = ts_is_leap(year) ? 366 : 365;
|
||||
if (days >= (uint32_t)ydays) {
|
||||
days -= (uint32_t)ydays;
|
||||
year++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out->year = year;
|
||||
|
||||
int mon = 0;
|
||||
while (mon < 12) {
|
||||
int dm = mdays[mon];
|
||||
if (mon == 1 && ts_is_leap(year)) dm = 29;
|
||||
if (days >= (uint32_t)dm) {
|
||||
days -= (uint32_t)dm;
|
||||
mon++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out->mon = mon + 1;
|
||||
out->mday = (int)days + 1;
|
||||
}
|
||||
|
||||
/* 분해 시각(UTC) -> Unix 초. */
|
||||
static uint32_t ts_tm_to_epoch(int year, int mon, int mday,
|
||||
int hour, int min, int sec)
|
||||
{
|
||||
static const int mdays[12] = { 31,28,31,30,31,30,31,31,30,31,30,31 };
|
||||
uint32_t days = 0;
|
||||
|
||||
for (int y = 1970; y < year; y++) {
|
||||
days += ts_is_leap(y) ? 366 : 365;
|
||||
}
|
||||
for (int m = 0; m < (mon - 1); m++) {
|
||||
int dm = mdays[m];
|
||||
if (m == 1 && ts_is_leap(year)) dm = 29;
|
||||
days += (uint32_t)dm;
|
||||
}
|
||||
days += (uint32_t)(mday - 1);
|
||||
|
||||
return days * 86400UL + (uint32_t)hour * 3600UL
|
||||
+ (uint32_t)min * 60UL + (uint32_t)sec;
|
||||
}
|
||||
|
||||
/* ── RTC 초기화 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* RTC 페리페럴 초기화(1회). 클럭소스 선택 + 비동기/동기 프리스케일러로 1Hz.
|
||||
* 반환 0 성공, 음수 실패. */
|
||||
static int ts_rtc_init(void)
|
||||
{
|
||||
if (s_rtc_ready) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* RTC 백업도메인 접근/클럭소스 설정.
|
||||
* 기본은 LSI(~32kHz, 부품 불필요). LSE 사용 시 아래 가드를 LSE 로 전환.
|
||||
* TODO(hw): 보드에 32.768kHz 크리스털이 있으면 LSE 경로를 활성화. */
|
||||
RCC_OscInitTypeDef osc = {0};
|
||||
RCC_PeriphCLKInitTypeDef pclk = {0};
|
||||
|
||||
/* 백업도메인 쓰기 보호 해제(PWR 클럭은 bsp 에서 켜졌다고 가정하나, 안전하게 한 번 더). */
|
||||
__HAL_RCC_PWR_CLK_ENABLE();
|
||||
HAL_PWR_EnableBkUpAccess();
|
||||
|
||||
#if defined(TS_USE_LSE) /* TODO(hw): LSE 보드면 빌드에 -DTS_USE_LSE 추가 */
|
||||
osc.OscillatorType = RCC_OSCILLATORTYPE_LSE;
|
||||
osc.PLL.PLLState = RCC_PLL_NONE;
|
||||
osc.LSEState = RCC_LSE_ON;
|
||||
if (HAL_RCC_OscConfig(&osc) != HAL_OK) {
|
||||
return -1;
|
||||
}
|
||||
pclk.PeriphClockSelection = RCC_PERIPHCLK_RTC;
|
||||
pclk.RTCClockSelection = RCC_RTCCLKSOURCE_LSE;
|
||||
if (HAL_RCCEx_PeriphCLKConfig(&pclk) != HAL_OK) {
|
||||
return -2;
|
||||
}
|
||||
/* LSE 32768Hz -> async(127)+1, sync(255)+1 => 1Hz */
|
||||
const uint32_t async_pre = 127;
|
||||
const uint32_t sync_pre = 255;
|
||||
#else
|
||||
osc.OscillatorType = RCC_OSCILLATORTYPE_LSI;
|
||||
osc.PLL.PLLState = RCC_PLL_NONE;
|
||||
osc.LSIState = RCC_LSI_ON;
|
||||
if (HAL_RCC_OscConfig(&osc) != HAL_OK) {
|
||||
return -1;
|
||||
}
|
||||
pclk.PeriphClockSelection = RCC_PERIPHCLK_RTC;
|
||||
pclk.RTCClockSelection = RCC_RTCCLKSOURCE_LSI;
|
||||
if (HAL_RCCEx_PeriphCLKConfig(&pclk) != HAL_OK) {
|
||||
return -2;
|
||||
}
|
||||
/* LSI 약 32000Hz -> async(124)+1=125, sync(255)+1=256 => 125*256=32000 -> 1Hz.
|
||||
* LSI 는 오차가 크므로 SNTP 가 주기적으로 보정한다(SNTP_UPDATE_DELAY). */
|
||||
const uint32_t async_pre = 124;
|
||||
const uint32_t sync_pre = 255;
|
||||
#endif
|
||||
|
||||
__HAL_RCC_RTC_ENABLE();
|
||||
|
||||
s_hrtc.Instance = RTC;
|
||||
s_hrtc.Init.HourFormat = RTC_HOURFORMAT_24;
|
||||
s_hrtc.Init.AsynchPrediv = async_pre;
|
||||
s_hrtc.Init.SynchPrediv = sync_pre;
|
||||
s_hrtc.Init.OutPut = RTC_OUTPUT_DISABLE;
|
||||
s_hrtc.Init.OutPutPolarity = RTC_OUTPUT_POLARITY_HIGH;
|
||||
s_hrtc.Init.OutPutType = RTC_OUTPUT_TYPE_OPENDRAIN;
|
||||
|
||||
if (HAL_RTC_Init(&s_hrtc) != HAL_OK) {
|
||||
return -3;
|
||||
}
|
||||
|
||||
s_rtc_ready = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── 공개 API ───────────────────────────────────────────────────────────── */
|
||||
|
||||
void timesync_init(void)
|
||||
{
|
||||
/* RTC 를 먼저 준비(아직 시간은 미설정). SNTP 응답이 와야 실제 시간이 채워진다. */
|
||||
if (ts_rtc_init() != 0) {
|
||||
LOGE("timesync: RTC init failed (계속: SNTP 동기 시 재시도 안 함)");
|
||||
/* RTC 가 없어도 SNTP 자체는 동작하지만, 시간 보존 불가.
|
||||
* apply_epoch 에서 다시 init 을 시도한다. */
|
||||
}
|
||||
|
||||
sntp_setoperatingmode(SNTP_OPMODE_POLL);
|
||||
sntp_setservername(0, APP_SNTP_SERVER);
|
||||
sntp_init();
|
||||
|
||||
LOGI("timesync: SNTP 시작 server=%s", APP_SNTP_SERVER);
|
||||
}
|
||||
|
||||
void timesync_apply_epoch(uint32_t unix_seconds)
|
||||
{
|
||||
/* 비정상적으로 작은 값(2000년 이전)은 무시: 잘못된 SNTP 응답 방지. */
|
||||
if (unix_seconds < TS_EPOCH_2000) {
|
||||
LOGW("timesync: epoch 무시(%lu < 2000) ", (unsigned long)unix_seconds);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 콜백 컨텍스트(tcpip 스레드)에서 호출될 수 있음. RTC 미준비 시 1회 시도. */
|
||||
if (!s_rtc_ready) {
|
||||
if (ts_rtc_init() != 0) {
|
||||
/* RTC 사용 불가: 플래그만 세워 timesync_is_set 은 만족시키되,
|
||||
* timesync_now 는 RTC 미설정으로 0 을 줄 수 있음. */
|
||||
s_is_set = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ts_tm_t tm;
|
||||
ts_epoch_to_tm(unix_seconds, &tm);
|
||||
|
||||
RTC_TimeTypeDef t = {0};
|
||||
RTC_DateTypeDef d = {0};
|
||||
|
||||
t.Hours = (uint8_t)tm.hour;
|
||||
t.Minutes = (uint8_t)tm.min;
|
||||
t.Seconds = (uint8_t)tm.sec;
|
||||
t.TimeFormat = RTC_HOURFORMAT12_AM; /* 24h 모드에선 무시됨 */
|
||||
t.DayLightSaving = RTC_DAYLIGHTSAVING_NONE;
|
||||
t.StoreOperation = RTC_STOREOPERATION_RESET;
|
||||
|
||||
d.WeekDay = (uint8_t)tm.wday; /* RTC_WEEKDAY_MONDAY=1 .. SUNDAY=7 */
|
||||
d.Month = (uint8_t)tm.mon; /* 1..12, BCD 변환은 HAL 이 처리(BIN 포맷) */
|
||||
d.Date = (uint8_t)tm.mday;
|
||||
d.Year = (uint8_t)(tm.year - TS_RTC_YEAR_BASE); /* 0..99 */
|
||||
|
||||
if (HAL_RTC_SetTime(&s_hrtc, &t, RTC_FORMAT_BIN) != HAL_OK ||
|
||||
HAL_RTC_SetDate(&s_hrtc, &d, RTC_FORMAT_BIN) != HAL_OK) {
|
||||
LOGE("timesync: RTC set 실패");
|
||||
/* 그래도 동기됐다고 표시: 최소한 한 번 시간이 들어왔음. */
|
||||
s_is_set = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
s_is_set = 1;
|
||||
LOGI("timesync: 동기 완료 epoch=%lu (%04d-%02d-%02d %02d:%02d:%02d UTC)",
|
||||
(unsigned long)unix_seconds,
|
||||
tm.year, tm.mon, tm.mday, tm.hour, tm.min, tm.sec);
|
||||
}
|
||||
|
||||
uint32_t timesync_now(void)
|
||||
{
|
||||
if (!s_is_set || !s_rtc_ready) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* HAL_RTC_GetTime 을 먼저 호출해야 섀도 레지스터가 잠기고,
|
||||
* 이후 GetDate 호출로 잠금이 풀린다(HAL 규약). 순서 준수 필수. */
|
||||
RTC_TimeTypeDef t = {0};
|
||||
RTC_DateTypeDef d = {0};
|
||||
|
||||
if (HAL_RTC_GetTime(&s_hrtc, &t, RTC_FORMAT_BIN) != HAL_OK) {
|
||||
return 0;
|
||||
}
|
||||
if (HAL_RTC_GetDate(&s_hrtc, &d, RTC_FORMAT_BIN) != HAL_OK) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int year = TS_RTC_YEAR_BASE + (int)d.Year;
|
||||
return ts_tm_to_epoch(year, (int)d.Month, (int)d.Date,
|
||||
(int)t.Hours, (int)t.Minutes, (int)t.Seconds);
|
||||
}
|
||||
|
||||
int timesync_is_set(void)
|
||||
{
|
||||
return s_is_set ? 1 : 0;
|
||||
}
|
||||
|
||||
int timesync_wait(uint32_t timeout_ms)
|
||||
{
|
||||
const uint32_t step_ms = 100;
|
||||
uint32_t waited = 0;
|
||||
|
||||
while (!s_is_set) {
|
||||
if (timeout_ms != 0 && waited >= timeout_ms) {
|
||||
return -1;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(step_ms));
|
||||
waited += step_ms;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
29
firmware/common/timesync.h
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/* =============================================================================
|
||||
* timesync.h - SNTP 시간 동기 + RTC (RPi OS NTP 대응)
|
||||
*
|
||||
* TLS 인증서 유효기간 검증과 보고 본문의 timestamp 필드에 정확한 Unix 시간이
|
||||
* 필요하다. SNTP 는 UDP 라 TLS 가 필요 없으므로 TLS 연결보다 먼저 수행한다.
|
||||
* 구현(timesync.c): LwIP apps/sntp 로 시간 획득 후 RTC 에 반영.
|
||||
* ===========================================================================*/
|
||||
#ifndef TIMESYNC_H
|
||||
#define TIMESYNC_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* SNTP 클라이언트 시작 (네트워크 업 이후 호출). */
|
||||
void timesync_init(void);
|
||||
|
||||
/* 최초 시간 동기 완료까지 대기. 0 성공, 음수 타임아웃. */
|
||||
int timesync_wait(uint32_t timeout_ms);
|
||||
|
||||
/* 시간이 한 번이라도 동기화되었으면 1. */
|
||||
int timesync_is_set(void);
|
||||
|
||||
/* 현재 Unix epoch(초). 미동기 시 0. */
|
||||
uint32_t timesync_now(void);
|
||||
|
||||
/* SNTP 수신 콜백(내부용): lwipopts.h 의 SNTP_SET_SYSTEM_TIME 에서 호출되어
|
||||
* 획득한 Unix 시간을 RTC 에 반영하고 "동기됨" 플래그를 세운다. */
|
||||
void timesync_apply_epoch(uint32_t unix_seconds);
|
||||
|
||||
#endif /* TIMESYNC_H */
|
||||
21
firmware/common/tls.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/* =============================================================================
|
||||
* tls.h - mbedTLS over LwIP 소켓 전송 (transport_t 제공)
|
||||
*
|
||||
* 구현(tls_mbedtls.c): TLS1.2 클라이언트(ECDHE-RSA + AES-GCM), 하드웨어 RNG
|
||||
* 엔트로피, 임베드된 서버 루트 CA(certs/server_ca.h)로 서버 인증서 검증, SNI.
|
||||
* 메모리 절약을 위해 mbedtls_config.h 에서 content length 를 축소한다.
|
||||
* ===========================================================================*/
|
||||
#ifndef TLS_H
|
||||
#define TLS_H
|
||||
|
||||
#include "transport.h"
|
||||
|
||||
/* TLS 전역 초기화 (부팅 시 1회): 엔트로피/DRBG 시드, CA 인증서 파싱.
|
||||
* 반환: 0 성공, 음수 실패. */
|
||||
int tls_init(void);
|
||||
|
||||
/* transport_t 를 mbedTLS 컨텍스트에 바인딩한다. connect 시 핸드셰이크 수행.
|
||||
* t 는 호출자가 보유하는 저장소(스택/정적). 반환: 0 성공, 음수 실패. */
|
||||
int tls_transport_init(transport_t *t);
|
||||
|
||||
#endif /* TLS_H */
|
||||
619
firmware/common/tls_mbedtls.c
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
/* =============================================================================
|
||||
* tls_mbedtls.c - mbedTLS(TLS 1.2 클라이언트) over LwIP 소켓 전송
|
||||
*
|
||||
* 역할:
|
||||
* - tls.h 구현: tls_init() (부팅 1회), tls_transport_init() (transport_t 바인딩).
|
||||
* - transport_t 의 connect/send/recv/close 를 mbedTLS + LwIP 소켓으로 제공.
|
||||
* - 엔트로피: STM32 하드웨어 RNG → ctr_drbg 시드 (MBEDTLS_ENTROPY_HARDWARE_ALT).
|
||||
* - 서버 인증서: certs/server_ca.h 의 임베드 루트 CA 로 풀 검증(VERIFY_REQUIRED).
|
||||
* - SNI: mbedtls_ssl_set_hostname(host).
|
||||
*
|
||||
* 설계 메모:
|
||||
* - 전역 1회 초기화(tls_init)에서 만든 entropy/ctr_drbg/CA(x509_crt) 는 모든
|
||||
* 연결이 공유한다(상주). ssl_config 와 ssl_context 는 연결마다 새로 만들고
|
||||
* close 에서 해제한다(매 보고가 Connection: close 단발 연결이므로 단순/안전).
|
||||
* - LwIP 소켓은 블로킹 모드 + SO_RCVTIMEO 로 타임아웃을 처리한다. mbedTLS BIO
|
||||
* 콜백은 EAGAIN/EWOULDBLOCK 을 MBEDTLS_ERR_SSL_WANT_READ/WRITE 로 매핑한다.
|
||||
*
|
||||
* 폐쇄망(air-gapped): mbedTLS/LwIP 는 third_party 에 벤더링. 런타임 외부 fetch 없음.
|
||||
* 인증서는 flash 임베드(런타임 CA 다운로드 불가).
|
||||
* ===========================================================================*/
|
||||
|
||||
#include "tls.h"
|
||||
#include "app_config.h"
|
||||
#include "applog.h"
|
||||
#include "timesync.h" /* timesync_now/timesync_is_set: 인증서 유효기간 검증용 시간 */
|
||||
#include "certs/server_ca.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <time.h> /* time_t (mbedTLS PLATFORM_TIME_MACRO 콜백) */
|
||||
|
||||
/* ── mbedTLS ─────────────────────────────────────────────────────────────── */
|
||||
#include "mbedtls/ssl.h"
|
||||
#include "mbedtls/entropy.h"
|
||||
#include "mbedtls/ctr_drbg.h"
|
||||
#include "mbedtls/x509_crt.h"
|
||||
#include "mbedtls/error.h"
|
||||
#include "mbedtls/platform.h"
|
||||
|
||||
/* ── LwIP 소켓 ───────────────────────────────────────────────────────────── */
|
||||
/* 주의(net 에이전트 lwipopts.h 의존): LWIP_SOCKET=1, LWIP_DNS=1, LWIP_NETCONN=1,
|
||||
* LWIP_SO_RCVTIMEO=1, LWIP_SO_SNDTIMEO=1, LWIP_DNS_API_DECLARE_STRUCTS=1(getaddrinfo)
|
||||
* 이 켜져 있어야 한다(SO_RCVTIMEO/getaddrinfo 사용). */
|
||||
#include "lwip/sockets.h"
|
||||
#include "lwip/netdb.h"
|
||||
/* errno 값(EWOULDBLOCK/EAGAIN/ECONNRESET 등)은 LwIP 소켓 계층이 set_errno() 로
|
||||
* 설정하므로, 표준 <errno.h> 가 아니라 LwIP 의 정의를 사용해 값 일치를 보장한다.
|
||||
* (lwipopts.h 에서 LWIP_PROVIDE_ERRNO=1 또는 시스템 errno 매핑 둘 다 호환) */
|
||||
#include "lwip/errno.h"
|
||||
|
||||
/* ── STM32 HAL (하드웨어 RNG) ────────────────────────────────────────────── */
|
||||
#include "stm32f4xx_hal.h"
|
||||
|
||||
/* ── FreeRTOS 힙 (플랫폼 calloc/free 매핑) ───────────────────────────────── */
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
|
||||
/* =============================================================================
|
||||
* 0) 플랫폼 메모리: mbedtls calloc/free → FreeRTOS heap
|
||||
* mbedtls_config.h 가 CALLOC_MACRO/FREE_MACRO 로 이 두 함수를 가리킨다.
|
||||
* ===========================================================================*/
|
||||
void *mbedtls_platform_calloc(size_t n, size_t size)
|
||||
{
|
||||
size_t total = n * size;
|
||||
/* 곱셈 오버플로 방어 (n,size 둘 다 0 이 아니면 검사) */
|
||||
if (n != 0 && total / n != size) {
|
||||
return NULL;
|
||||
}
|
||||
void *p = pvPortMalloc(total);
|
||||
if (p != NULL) {
|
||||
memset(p, 0, total); /* calloc 계약: 0 초기화 */
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
void mbedtls_platform_free(void *p)
|
||||
{
|
||||
if (p != NULL) {
|
||||
vPortFree(p);
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 0b) 플랫폼 시간: mbedtls_time → RTC/SNTP 기반 Unix 시간
|
||||
* mbedtls_config.h 가 MBEDTLS_PLATFORM_TIME_MACRO 로 이 함수를 가리킨다.
|
||||
* X.509 인증서 notBefore/notAfter 검증(MBEDTLS_HAVE_TIME_DATE)에 사용된다.
|
||||
* 시간 미동기 시 0 을 반환하지만, tls_connect() 가 timesync_is_set() 으로
|
||||
* 선제 차단하므로 0 시각으로 검증이 수행되는 일은 없다(이중 안전장치).
|
||||
* ===========================================================================*/
|
||||
time_t tls_platform_time(time_t *t)
|
||||
{
|
||||
time_t now = (time_t)timesync_now();
|
||||
if (t != NULL) {
|
||||
*t = now;
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 1) 하드웨어 RNG (STM32 RNG 주변장치) + 엔트로피 폴 콜백
|
||||
* mbedtls_config.h: MBEDTLS_ENTROPY_HARDWARE_ALT → 아래 콜백을 entropy 가 호출.
|
||||
* ===========================================================================*/
|
||||
static RNG_HandleTypeDef s_hrng; /* TLS 모듈이 소유하는 RNG 핸들 */
|
||||
static int s_rng_ready = 0;
|
||||
|
||||
/* RNG 주변장치 초기화 (tls_init 에서 1회). 0 성공, 음수 실패. */
|
||||
static int hw_rng_init(void)
|
||||
{
|
||||
if (s_rng_ready) {
|
||||
return 0;
|
||||
}
|
||||
__HAL_RCC_RNG_CLK_ENABLE(); /* RNG 클럭 게이팅 ON */
|
||||
|
||||
s_hrng.Instance = RNG;
|
||||
if (HAL_RNG_Init(&s_hrng) != HAL_OK) {
|
||||
return -1;
|
||||
}
|
||||
s_rng_ready = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* mbedTLS 하드웨어 엔트로피 소스.
|
||||
* RNG 로 32비트 난수를 반복 생성하여 output[0..len) 을 채운다.
|
||||
* 반환: 0 성공(*olen=len), 음수 실패. */
|
||||
int mbedtls_hardware_poll(void *data, unsigned char *output, size_t len, size_t *olen)
|
||||
{
|
||||
(void)data;
|
||||
|
||||
if (!s_rng_ready) {
|
||||
if (hw_rng_init() != 0) {
|
||||
return MBEDTLS_ERR_ENTROPY_SOURCE_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
size_t produced = 0;
|
||||
while (produced < len) {
|
||||
uint32_t rnd = 0;
|
||||
if (HAL_RNG_GenerateRandomNumber(&s_hrng, &rnd) != HAL_OK) {
|
||||
/* RNG 클럭/시드 오류(CEIS/SEIS). 핸들 재초기화 후 1회 재시도하지 않고
|
||||
* 즉시 실패 보고 — 엔트로피 품질을 절대 가장하지 않는다. */
|
||||
return MBEDTLS_ERR_ENTROPY_SOURCE_FAILED;
|
||||
}
|
||||
size_t chunk = len - produced;
|
||||
if (chunk > sizeof(rnd)) {
|
||||
chunk = sizeof(rnd);
|
||||
}
|
||||
memcpy(output + produced, &rnd, chunk);
|
||||
produced += chunk;
|
||||
}
|
||||
|
||||
*olen = produced;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 2) 전역 TLS 상태 (모든 연결 공유, 상주)
|
||||
* ===========================================================================*/
|
||||
static mbedtls_entropy_context s_entropy;
|
||||
static mbedtls_ctr_drbg_context s_ctr_drbg;
|
||||
static mbedtls_x509_crt s_ca_chain;
|
||||
static int s_tls_inited = 0;
|
||||
|
||||
/* ctr_drbg 개인화 라벨 (선택적 추가 엔트로피) */
|
||||
static const char *const TLS_DRBG_PERS = "sht30-sms-stm32-tls";
|
||||
|
||||
/* mbedTLS 오류 코드를 사람이 읽는 문자열로 로그 (MBEDTLS_ERROR_C 활성 시). */
|
||||
static void log_mbedtls_err(const char *what, int ret)
|
||||
{
|
||||
#if defined(MBEDTLS_ERROR_C)
|
||||
char buf[96];
|
||||
mbedtls_strerror(ret, buf, sizeof(buf));
|
||||
LOGE("TLS %s: -0x%04X (%s)", what, (unsigned)(-ret), buf);
|
||||
#else
|
||||
LOGE("TLS %s: -0x%04X", what, (unsigned)(-ret));
|
||||
#endif
|
||||
}
|
||||
|
||||
int tls_init(void)
|
||||
{
|
||||
if (s_tls_inited) {
|
||||
return 0; /* 멱등 */
|
||||
}
|
||||
|
||||
/* 플랫폼 메모리 콜백은 CALLOC_MACRO/FREE_MACRO 로 정적 바인딩되어 별도 set 불필요.
|
||||
* (MBEDTLS_PLATFORM_MEMORY + *_MACRO 사용) */
|
||||
|
||||
/* 하드웨어 RNG 먼저 준비 (엔트로피 폴 콜백이 사용). */
|
||||
if (hw_rng_init() != 0) {
|
||||
LOGE("TLS hw_rng_init failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
mbedtls_entropy_init(&s_entropy);
|
||||
mbedtls_ctr_drbg_init(&s_ctr_drbg);
|
||||
mbedtls_x509_crt_init(&s_ca_chain);
|
||||
|
||||
/* DRBG 시드: entropy(하드웨어 RNG ALT) 를 소스로 사용. */
|
||||
int ret = mbedtls_ctr_drbg_seed(
|
||||
&s_ctr_drbg, mbedtls_entropy_func, &s_entropy,
|
||||
(const unsigned char *)TLS_DRBG_PERS, strlen(TLS_DRBG_PERS));
|
||||
if (ret != 0) {
|
||||
log_mbedtls_err("ctr_drbg_seed", ret);
|
||||
goto fail;
|
||||
}
|
||||
|
||||
/* 임베드 루트 CA(PEM) 파싱. PEM 은 buflen 에 종결 NUL 포함 필수
|
||||
* (server_ca.h 계약: SERVER_CA_PEM_LEN == sizeof(SERVER_CA_PEM)). */
|
||||
ret = mbedtls_x509_crt_parse(
|
||||
&s_ca_chain, (const unsigned char *)SERVER_CA_PEM, SERVER_CA_PEM_LEN);
|
||||
if (ret != 0) {
|
||||
/* ret > 0 이면 일부 인증서 파싱 실패 개수. 어느 경우든 신뢰체인 불완전. */
|
||||
log_mbedtls_err("x509_crt_parse(CA)", ret);
|
||||
goto fail;
|
||||
}
|
||||
|
||||
s_tls_inited = 1;
|
||||
LOGI("TLS init ok (CA parsed, DRBG seeded via HW RNG)");
|
||||
return 0;
|
||||
|
||||
fail:
|
||||
mbedtls_x509_crt_free(&s_ca_chain);
|
||||
mbedtls_ctr_drbg_free(&s_ctr_drbg);
|
||||
mbedtls_entropy_free(&s_entropy);
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 3) 연결별 컨텍스트 (transport_t.ctx 가 가리킴)
|
||||
* ===========================================================================*/
|
||||
typedef struct {
|
||||
mbedtls_ssl_context ssl;
|
||||
mbedtls_ssl_config conf;
|
||||
int fd; /* LwIP 소켓 fd (-1 = 미연결) */
|
||||
int in_use; /* 진행 중 연결 여부 */
|
||||
} tls_ctx_t;
|
||||
|
||||
/* 단일 동시 TLS 연결 가정(보고는 단발). 정적 1개로 RAM 고정. 필요 시 풀로 확장. */
|
||||
static tls_ctx_t s_tls_ctx;
|
||||
|
||||
/* ── BIO 콜백: LwIP 소켓 send/recv ↔ mbedTLS WANT_* 매핑 ────────────────── */
|
||||
static int bio_send(void *vctx, const unsigned char *buf, size_t len)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
if (c->fd < 0) {
|
||||
return MBEDTLS_ERR_NET_INVALID_CONTEXT;
|
||||
}
|
||||
int n = (int)lwip_send(c->fd, buf, len, 0);
|
||||
if (n >= 0) {
|
||||
return n;
|
||||
}
|
||||
if (errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR) {
|
||||
return MBEDTLS_ERR_SSL_WANT_WRITE;
|
||||
}
|
||||
if (errno == EPIPE || errno == ECONNRESET) {
|
||||
return MBEDTLS_ERR_NET_CONN_RESET;
|
||||
}
|
||||
return MBEDTLS_ERR_NET_SEND_FAILED;
|
||||
}
|
||||
|
||||
static int bio_recv(void *vctx, unsigned char *buf, size_t len)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
if (c->fd < 0) {
|
||||
return MBEDTLS_ERR_NET_INVALID_CONTEXT;
|
||||
}
|
||||
int n = (int)lwip_recv(c->fd, buf, len, 0);
|
||||
if (n > 0) {
|
||||
return n;
|
||||
}
|
||||
if (n == 0) {
|
||||
return 0; /* 원격 종료 — mbedTLS 는 0 을 정상 종료로 처리 */
|
||||
}
|
||||
if (errno == EWOULDBLOCK || errno == EAGAIN || errno == EINTR) {
|
||||
/* SO_RCVTIMEO 만료도 여기로 온다(EWOULDBLOCK). 상위 핸드셰이크/read 루프가
|
||||
* 자체 타임아웃을 관리하므로 WANT_READ 로 재시도 신호를 준다. */
|
||||
return MBEDTLS_ERR_SSL_WANT_READ;
|
||||
}
|
||||
if (errno == ECONNRESET) {
|
||||
return MBEDTLS_ERR_NET_CONN_RESET;
|
||||
}
|
||||
return MBEDTLS_ERR_NET_RECV_FAILED;
|
||||
}
|
||||
|
||||
/* ── 소켓 정리 헬퍼 ──────────────────────────────────────────────────────── */
|
||||
static void close_socket(tls_ctx_t *c)
|
||||
{
|
||||
if (c->fd >= 0) {
|
||||
lwip_close(c->fd);
|
||||
c->fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* DNS 해석 + TCP connect. 0 성공, 음수 실패. fd 는 c->fd 에 저장. */
|
||||
static int tcp_connect(tls_ctx_t *c, const char *host, uint16_t port, uint32_t timeout_ms)
|
||||
{
|
||||
char portstr[6];
|
||||
/* uint16_t 포트 → 문자열 (최대 "65535") */
|
||||
{
|
||||
unsigned p = port;
|
||||
int idx = 5;
|
||||
portstr[idx] = '\0';
|
||||
if (p == 0) {
|
||||
portstr[--idx] = '0';
|
||||
} else {
|
||||
while (p > 0 && idx > 0) {
|
||||
portstr[--idx] = (char)('0' + (p % 10));
|
||||
p /= 10;
|
||||
}
|
||||
}
|
||||
/* 좌측 정렬로 복사 */
|
||||
memmove(portstr, portstr + idx, (size_t)(6 - idx));
|
||||
}
|
||||
|
||||
struct addrinfo hints;
|
||||
struct addrinfo *res = NULL;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_INET; /* IPv4 (LAN8720 + LwIP 기본 구성) */
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_protocol = IPPROTO_TCP;
|
||||
|
||||
int gai = lwip_getaddrinfo(host, portstr, &hints, &res);
|
||||
if (gai != 0 || res == NULL) {
|
||||
LOGE("TLS DNS resolve failed for %s (gai=%d)", host, gai);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int fd = lwip_socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||||
if (fd < 0) {
|
||||
LOGE("TLS socket() failed");
|
||||
lwip_freeaddrinfo(res);
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* 수신/송신 타임아웃 설정 (블로킹 + SO_*TIMEO). */
|
||||
struct timeval tv;
|
||||
tv.tv_sec = (long)(timeout_ms / 1000u);
|
||||
tv.tv_usec = (long)((timeout_ms % 1000u) * 1000u);
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
lwip_setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
|
||||
if (lwip_connect(fd, res->ai_addr, res->ai_addrlen) != 0) {
|
||||
LOGE("TLS TCP connect failed to %s:%u", host, (unsigned)port);
|
||||
lwip_close(fd);
|
||||
lwip_freeaddrinfo(res);
|
||||
return -3;
|
||||
}
|
||||
|
||||
lwip_freeaddrinfo(res);
|
||||
c->fd = fd;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 4) transport_t 콜백 구현
|
||||
* ===========================================================================*/
|
||||
|
||||
/* connect: TCP 연결 → SSL config/setup → 핸드셰이크(+ 인증서 검증). */
|
||||
static int tls_connect(void *vctx, const char *host, uint16_t port, uint32_t timeout_ms)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
int ret;
|
||||
|
||||
if (!s_tls_inited) {
|
||||
LOGE("TLS connect before tls_init()");
|
||||
return -1;
|
||||
}
|
||||
/* 시간이 동기되지 않았으면 인증서 유효기간(notBefore/notAfter) 검증이
|
||||
* 무의미해진다(시각 0 이면 모든 인증서가 '아직 유효하지 않음'으로도 보임).
|
||||
* 보고 경로는 timesync_wait 로 동기 후 호출하지만, 방어적으로 차단한다. */
|
||||
if (!timesync_is_set()) {
|
||||
LOGE("TLS connect before time sync (cert validity check would be unsafe)");
|
||||
return -1;
|
||||
}
|
||||
if (c->in_use) {
|
||||
/* 이전 연결이 정리되지 않았다면 강제 정리 후 진행. */
|
||||
mbedtls_ssl_free(&c->ssl);
|
||||
mbedtls_ssl_config_free(&c->conf);
|
||||
close_socket(c);
|
||||
c->in_use = 0;
|
||||
}
|
||||
|
||||
c->fd = -1;
|
||||
mbedtls_ssl_init(&c->ssl);
|
||||
mbedtls_ssl_config_init(&c->conf);
|
||||
|
||||
/* (1) TCP 연결 + DNS */
|
||||
ret = tcp_connect(c, host, port, timeout_ms);
|
||||
if (ret != 0) {
|
||||
goto fail_cleanup;
|
||||
}
|
||||
|
||||
/* (2) SSL config: TLS1.2 클라이언트, 서버 인증 필수, CA, RNG */
|
||||
ret = mbedtls_ssl_config_defaults(&c->conf,
|
||||
MBEDTLS_SSL_IS_CLIENT,
|
||||
MBEDTLS_SSL_TRANSPORT_STREAM,
|
||||
MBEDTLS_SSL_PRESET_DEFAULT);
|
||||
if (ret != 0) {
|
||||
log_mbedtls_err("config_defaults", ret);
|
||||
goto fail_cleanup;
|
||||
}
|
||||
|
||||
mbedtls_ssl_conf_authmode(&c->conf, MBEDTLS_SSL_VERIFY_REQUIRED);
|
||||
mbedtls_ssl_conf_ca_chain(&c->conf, &s_ca_chain, NULL);
|
||||
mbedtls_ssl_conf_rng(&c->conf, mbedtls_ctr_drbg_random, &s_ctr_drbg);
|
||||
|
||||
/* TLS 1.2 만 허용 (min == max). mbedTLS 3.x API.
|
||||
* (config 에 MBEDTLS_SSL_PROTO_TLS1_3 미정의이므로 빌드 자체가 TLS1.2-only 이나,
|
||||
* 명시적으로 한 번 더 고정한다.) */
|
||||
mbedtls_ssl_conf_min_tls_version(&c->conf, MBEDTLS_SSL_VERSION_TLS1_2);
|
||||
mbedtls_ssl_conf_max_tls_version(&c->conf, MBEDTLS_SSL_VERSION_TLS1_2);
|
||||
|
||||
#if defined(MBEDTLS_SSL_MAX_FRAGMENT_LENGTH)
|
||||
/* RAM 절감: 최대 단편 길이 협상(서버 지원 시). IN_CONTENT_LEN 4096 에 맞춤. */
|
||||
mbedtls_ssl_conf_max_frag_len(&c->conf, MBEDTLS_SSL_MAX_FRAG_LEN_4096);
|
||||
#endif
|
||||
|
||||
/* (3) SSL setup */
|
||||
ret = mbedtls_ssl_setup(&c->ssl, &c->conf);
|
||||
if (ret != 0) {
|
||||
log_mbedtls_err("ssl_setup", ret);
|
||||
goto fail_cleanup;
|
||||
}
|
||||
|
||||
/* (4) SNI: 서버 호스트명 (인증서 CN/SAN 검증에도 사용) */
|
||||
ret = mbedtls_ssl_set_hostname(&c->ssl, host);
|
||||
if (ret != 0) {
|
||||
log_mbedtls_err("set_hostname", ret);
|
||||
goto fail_cleanup;
|
||||
}
|
||||
|
||||
/* (5) BIO: LwIP 소켓 send/recv 콜백 바인딩 */
|
||||
mbedtls_ssl_set_bio(&c->ssl, c, bio_send, bio_recv, NULL);
|
||||
|
||||
/* (6) 핸드셰이크 (WANT_READ/WRITE 재시도). 자체 타임아웃: 벽시계 기반. */
|
||||
{
|
||||
uint32_t start = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
for (;;) {
|
||||
ret = mbedtls_ssl_handshake(&c->ssl);
|
||||
if (ret == 0) {
|
||||
break; /* 핸드셰이크 완료 */
|
||||
}
|
||||
if (ret != MBEDTLS_ERR_SSL_WANT_READ &&
|
||||
ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
log_mbedtls_err("handshake", ret);
|
||||
goto fail_cleanup;
|
||||
}
|
||||
/* WANT_* : 소켓 타임아웃(SO_RCVTIMEO)이 이미 블로킹을 제한한다.
|
||||
* 전체 핸드셰이크 상한도 별도로 강제한다. */
|
||||
uint32_t now = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
if ((now - start) >= timeout_ms) {
|
||||
LOGE("TLS handshake timeout (%ums)", (unsigned)timeout_ms);
|
||||
ret = -1;
|
||||
goto fail_cleanup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* (7) 인증서 검증 결과 확인 (VERIFY_REQUIRED 면 핸드셰이크가 이미 실패하지만,
|
||||
* 진단을 위해 명시적으로 로그한다). */
|
||||
{
|
||||
uint32_t flags = mbedtls_ssl_get_verify_result(&c->ssl);
|
||||
if (flags != 0) {
|
||||
#if defined(MBEDTLS_X509_REMOVE_INFO)
|
||||
LOGE("TLS cert verify failed: flags=0x%08X", (unsigned)flags);
|
||||
#else
|
||||
char vbuf[160];
|
||||
mbedtls_x509_crt_verify_info(vbuf, sizeof(vbuf), " ! ", flags);
|
||||
LOGE("TLS cert verify failed (0x%08X):\n%s", (unsigned)flags, vbuf);
|
||||
#endif
|
||||
ret = -1;
|
||||
goto fail_cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
c->in_use = 1;
|
||||
LOGI("TLS connected: %s:%u (%s, %s)",
|
||||
host, (unsigned)port,
|
||||
mbedtls_ssl_get_version(&c->ssl),
|
||||
mbedtls_ssl_get_ciphersuite(&c->ssl));
|
||||
return 0;
|
||||
|
||||
fail_cleanup:
|
||||
mbedtls_ssl_free(&c->ssl);
|
||||
mbedtls_ssl_config_free(&c->conf);
|
||||
close_socket(c);
|
||||
c->in_use = 0;
|
||||
return (ret < 0) ? ret : -100;
|
||||
}
|
||||
|
||||
/* send: buf 전체 전송(부분 쓰기 루프). 전송 바이트(>0) / 음수 실패. */
|
||||
static int tls_send(void *vctx, const uint8_t *buf, size_t len, uint32_t timeout_ms)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
if (!c->in_use) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t sent = 0;
|
||||
uint32_t start = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
|
||||
while (sent < len) {
|
||||
int ret = mbedtls_ssl_write(&c->ssl, buf + sent, len - sent);
|
||||
if (ret > 0) {
|
||||
sent += (size_t)ret;
|
||||
continue;
|
||||
}
|
||||
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
uint32_t now = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
if ((now - start) >= timeout_ms) {
|
||||
LOGE("TLS send timeout");
|
||||
return -2;
|
||||
}
|
||||
continue; /* 블로킹 소켓이 곧 진행시킨다 */
|
||||
}
|
||||
log_mbedtls_err("ssl_write", ret);
|
||||
return -3;
|
||||
}
|
||||
return (int)sent;
|
||||
}
|
||||
|
||||
/* recv: 최대 cap 바이트 1회 수신. 바이트(>0) / 0 종료 / 음수 오류. */
|
||||
static int tls_recv(void *vctx, uint8_t *buf, size_t cap, uint32_t timeout_ms)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
if (!c->in_use) {
|
||||
return -1;
|
||||
}
|
||||
if (cap == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t start = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
|
||||
for (;;) {
|
||||
int ret = mbedtls_ssl_read(&c->ssl, buf, cap);
|
||||
if (ret > 0) {
|
||||
return ret; /* 읽은 바이트 */
|
||||
}
|
||||
if (ret == 0 ||
|
||||
ret == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
|
||||
return 0; /* 정상 종료 */
|
||||
}
|
||||
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
uint32_t now = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
if ((now - start) >= timeout_ms) {
|
||||
LOGE("TLS recv timeout");
|
||||
return -2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
log_mbedtls_err("ssl_read", ret);
|
||||
return -3;
|
||||
}
|
||||
}
|
||||
|
||||
/* close: close_notify → ssl/conf 해제 → 소켓 close. */
|
||||
static void tls_close(void *vctx)
|
||||
{
|
||||
tls_ctx_t *c = (tls_ctx_t *)vctx;
|
||||
if (!c->in_use) {
|
||||
/* 이미 정리됨 (또는 connect 실패). 소켓만 혹시 남아있으면 닫는다. */
|
||||
close_socket(c);
|
||||
return;
|
||||
}
|
||||
|
||||
/* close_notify 는 best-effort. 죽은/멈춘 피어에서 무한 대기하지 않도록
|
||||
* 짧은 벽시계 상한(CLOSE_NOTIFY_MAX_MS)으로 묶는다. (블로킹 소켓은 매 시도마다
|
||||
* SO_SNDTIMEO 까지 블록되므로, 상한이 없으면 reporter 가 TLS 뮤텍스를 쥔 채
|
||||
* 여러 차례 길게 멈출 수 있다.) 상한 초과 시 그냥 해제/소켓 close 로 진행한다. */
|
||||
{
|
||||
const uint32_t CLOSE_NOTIFY_MAX_MS = 1000u;
|
||||
uint32_t start = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
int ret;
|
||||
do {
|
||||
ret = mbedtls_ssl_close_notify(&c->ssl);
|
||||
if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
|
||||
break; /* 완료 또는 (무시 가능한) 오류 */
|
||||
}
|
||||
uint32_t now = (uint32_t)xTaskGetTickCount() * (uint32_t)portTICK_PERIOD_MS;
|
||||
if ((now - start) >= CLOSE_NOTIFY_MAX_MS) {
|
||||
break; /* 상한 초과 → best-effort 포기 */
|
||||
}
|
||||
} while (1);
|
||||
}
|
||||
|
||||
mbedtls_ssl_free(&c->ssl);
|
||||
mbedtls_ssl_config_free(&c->conf);
|
||||
close_socket(c);
|
||||
c->in_use = 0;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 5) transport_t 바인딩
|
||||
* ===========================================================================*/
|
||||
int tls_transport_init(transport_t *t)
|
||||
{
|
||||
if (t == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (!s_tls_inited) {
|
||||
LOGE("tls_transport_init before tls_init()");
|
||||
return -2;
|
||||
}
|
||||
|
||||
/* 정적 연결 컨텍스트 초기 상태로 리셋(이전 잔여 정리). */
|
||||
if (s_tls_ctx.in_use) {
|
||||
mbedtls_ssl_free(&s_tls_ctx.ssl);
|
||||
mbedtls_ssl_config_free(&s_tls_ctx.conf);
|
||||
}
|
||||
close_socket(&s_tls_ctx);
|
||||
memset(&s_tls_ctx, 0, sizeof(s_tls_ctx));
|
||||
s_tls_ctx.fd = -1;
|
||||
|
||||
t->ctx = &s_tls_ctx;
|
||||
t->connect = tls_connect;
|
||||
t->send = tls_send;
|
||||
t->recv = tls_recv;
|
||||
t->close = tls_close;
|
||||
return 0;
|
||||
}
|
||||
30
firmware/common/transport.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* =============================================================================
|
||||
* transport.h - 바이트 스트림 전송 추상화 (TLS/평문 공용)
|
||||
*
|
||||
* httpapi.c 는 이 인터페이스만 사용한다. 실제 구현:
|
||||
* tls_mbedtls.c : mbedTLS over LwIP socket (운영, HTTPS)
|
||||
* (테스트) : 메모리/루프백 스텁
|
||||
* ===========================================================================*/
|
||||
#ifndef TRANSPORT_H
|
||||
#define TRANSPORT_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
typedef struct transport_s {
|
||||
void *ctx;
|
||||
|
||||
/* host:port 연결(+ TLS 핸드셰이크). 0 성공, 음수 실패. */
|
||||
int (*connect)(void *ctx, const char *host, uint16_t port, uint32_t timeout_ms);
|
||||
|
||||
/* buf[0..len) 전체 전송. 전송한 바이트 수(>0) 또는 음수 실패. */
|
||||
int (*send)(void *ctx, const uint8_t *buf, size_t len, uint32_t timeout_ms);
|
||||
|
||||
/* 최대 cap 바이트 수신. 읽은 바이트 수(>0), 0=연결 종료, 음수=오류. */
|
||||
int (*recv)(void *ctx, uint8_t *buf, size_t cap, uint32_t timeout_ms);
|
||||
|
||||
/* 연결 종료/정리 */
|
||||
void (*close)(void *ctx);
|
||||
} transport_t;
|
||||
|
||||
#endif /* TRANSPORT_H */
|
||||
109
firmware/common/watchdog.c
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/* =============================================================================
|
||||
* watchdog.c - 독립 워치독(IWDG) (RPi systemd 자동재시작 대응)
|
||||
*
|
||||
* IWDG 는 LSI(저속 내부 RC, STM32F407 기준 약 32kHz) 로 구동된다.
|
||||
* timeout_ms 동안 watchdog_refresh() 호출이 없으면 MCU 가 하드 리셋된다.
|
||||
*
|
||||
* 타임아웃 계산:
|
||||
* t_IWDG[s] = (reload + 1) * prescaler / f_LSI
|
||||
* → reload = timeout_s * f_LSI / prescaler - 1
|
||||
* prescaler 는 4,8,16,32,64,128,256 중 선택, reload 는 12비트(0..4095) 클램프.
|
||||
*
|
||||
* 주의: LSI 실주파수는 부품별로 17~47kHz 편차가 있다. 본 구현은 공칭 32kHz 로
|
||||
* reload 를 계산하므로 실제 타임아웃은 ±오차가 있다. 안전을 위해 메인
|
||||
* 헬스 루프의 refresh 주기는 설정 타임아웃의 1/2 이하로 둘 것.
|
||||
* ===========================================================================*/
|
||||
#include "watchdog.h"
|
||||
#include "stm32f4xx_hal.h"
|
||||
#include <stddef.h> /* size_t */
|
||||
|
||||
/* LSI 공칭 주파수(Hz). 데이터시트 typ. 32kHz. */
|
||||
#define WDG_LSI_HZ 32000u
|
||||
|
||||
/* IWDG reload 레지스터는 12비트. */
|
||||
#define WDG_RELOAD_MAX 0x0FFFu /* 4095 */
|
||||
|
||||
/* IWDG 핸들(이 모듈 소유). */
|
||||
static IWDG_HandleTypeDef hiwdg;
|
||||
|
||||
/* prescaler 코드 → 분주값 매핑(인덱스 = HAL 의 IWDG_PRESCALER_*). */
|
||||
typedef struct {
|
||||
uint32_t code; /* IWDG_PRESCALER_x */
|
||||
uint32_t div; /* 실제 분주값 */
|
||||
} wdg_presc_t;
|
||||
|
||||
static const wdg_presc_t WDG_PRESC[] = {
|
||||
{ IWDG_PRESCALER_4, 4u },
|
||||
{ IWDG_PRESCALER_8, 8u },
|
||||
{ IWDG_PRESCALER_16, 16u },
|
||||
{ IWDG_PRESCALER_32, 32u },
|
||||
{ IWDG_PRESCALER_64, 64u },
|
||||
{ IWDG_PRESCALER_128, 128u },
|
||||
{ IWDG_PRESCALER_256, 256u },
|
||||
};
|
||||
#define WDG_PRESC_COUNT (sizeof(WDG_PRESC) / sizeof(WDG_PRESC[0]))
|
||||
|
||||
void watchdog_init(uint32_t timeout_ms)
|
||||
{
|
||||
uint32_t chosen_code = IWDG_PRESCALER_256; /* 안전 기본(최대 타임아웃) */
|
||||
uint32_t chosen_reload = WDG_RELOAD_MAX;
|
||||
size_t i;
|
||||
|
||||
if (timeout_ms == 0u) {
|
||||
timeout_ms = 1u; /* 0 방지 */
|
||||
}
|
||||
|
||||
/* 요청 타임아웃을 12비트 reload 안에 담을 수 있는 가장 작은 prescaler 선택
|
||||
* (분해능을 최대화). 안 되면 가장 큰 prescaler(256)로 최대 범위 사용. */
|
||||
for (i = 0; i < WDG_PRESC_COUNT; ++i) {
|
||||
uint32_t div = WDG_PRESC[i].div;
|
||||
/* reload = timeout_ms * f_LSI / (1000 * div) - 1
|
||||
* 오버플로 방지를 위해 64비트 중간 계산. */
|
||||
uint64_t ticks = ((uint64_t)timeout_ms * (uint64_t)WDG_LSI_HZ) / 1000u;
|
||||
uint64_t reload64;
|
||||
|
||||
if (ticks == 0u) {
|
||||
reload64 = 0u;
|
||||
} else {
|
||||
reload64 = (ticks / div);
|
||||
if (reload64 > 0u) {
|
||||
reload64 -= 1u; /* reload 는 (값+1) 카운트 */
|
||||
}
|
||||
}
|
||||
|
||||
if (reload64 <= WDG_RELOAD_MAX) {
|
||||
chosen_code = WDG_PRESC[i].code;
|
||||
chosen_reload = (uint32_t)reload64;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* 루프가 끝까지 못 맞췄다면(타임아웃이 너무 큼) 256 분주 + 최대 reload 클램프.
|
||||
* (32kHz, /256, reload=4095 → 약 32.7초가 IWDG 최대 타임아웃) */
|
||||
if (i >= WDG_PRESC_COUNT) {
|
||||
chosen_code = IWDG_PRESCALER_256;
|
||||
chosen_reload = WDG_RELOAD_MAX;
|
||||
}
|
||||
|
||||
if (chosen_reload > WDG_RELOAD_MAX) {
|
||||
chosen_reload = WDG_RELOAD_MAX;
|
||||
}
|
||||
|
||||
hiwdg.Instance = IWDG;
|
||||
hiwdg.Init.Prescaler = chosen_code;
|
||||
hiwdg.Init.Reload = chosen_reload;
|
||||
/* IWDG_Init 은 카운터를 시작하고 즉시 한 번 refresh 한다. */
|
||||
if (HAL_IWDG_Init(&hiwdg) != HAL_OK) {
|
||||
/* 워치독을 켜지 못하면 시스템 보호가 불가하므로 무한 대기 → 외부 관찰 가능.
|
||||
* (여기서 bsp_fatal 을 부르지 않는 이유: 의존성 최소화.) */
|
||||
for (;;) {
|
||||
__NOP();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void watchdog_refresh(void)
|
||||
{
|
||||
/* 카운터를 reload 값으로 재적재. 실패해도 다음 주기에 재시도. */
|
||||
(void)HAL_IWDG_Refresh(&hiwdg);
|
||||
}
|
||||
15
firmware/common/watchdog.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* =============================================================================
|
||||
* watchdog.h - 독립 워치독(IWDG) (systemd 자동재시작 대응)
|
||||
*
|
||||
* 구현(watchdog.c): IWDG 를 timeout_ms 로 설정. 메인 헬스 루프가 주기적으로
|
||||
* watchdog_refresh() 를 호출하지 못하면 MCU 가 리셋된다.
|
||||
* ===========================================================================*/
|
||||
#ifndef WATCHDOG_H
|
||||
#define WATCHDOG_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
void watchdog_init(uint32_t timeout_ms);
|
||||
void watchdog_refresh(void);
|
||||
|
||||
#endif /* WATCHDOG_H */
|
||||
173
firmware/config/FreeRTOSConfig.h
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/* =============================================================================
|
||||
* FreeRTOSConfig.h - STM32F407VGT6 (Cortex-M4F @168MHz) 용 FreeRTOS 커널 설정
|
||||
*
|
||||
* 누수감지/SHT30 노드 펌웨어. 네이티브 FreeRTOS API(task/queue/timers/semphr)
|
||||
* 만 사용한다(CMSIS-RTOS 미사용).
|
||||
*
|
||||
* 핵심 결정 사항:
|
||||
* - configCPU_CLOCK_HZ = 168MHz (bsp.c SystemClock_Config: HSE 8MHz -> PLL).
|
||||
* - Tick = 1kHz (1ms). vTaskDelay/타이머 분해능 1ms.
|
||||
* - heap_4 사용. 힙 64KB. (필요 시 linker 로 CCM/SRAM 배치 가능 — 단, LwIP/HAL
|
||||
* DMA 디스크립터는 CCM 에 두지 말 것. 일반 동적할당은 SRAM heap_4 로 충분.)
|
||||
* - FPU(Cortex-M4F) 사용: 포트가 lazy-stacking 으로 부동소수 컨텍스트 저장.
|
||||
* - NVIC: STM32 는 4 우선순위 비트(configPRIO_BITS=4). HAL/EXTI 등 *FromISR
|
||||
* API 를 호출하는 ISR 은 반드시 MAX_SYSCALL 이하(=수치상 크거나 같은)
|
||||
* 우선순위(=논리적으로 낮은 선점도)를 가져야 한다.
|
||||
*
|
||||
* HAL 타임베이스 주의:
|
||||
* vPortSVCHandler/xPortPendSVHandler/xPortSysTickHandler 를 CMSIS 벡터명
|
||||
* (SVC_Handler/PendSV_Handler/SysTick_Handler)으로 매핑한다(아래 #define).
|
||||
* 따라서 SysTick 은 FreeRTOS 전용이 되며, HAL 의 HAL_GetTick() 타임베이스는
|
||||
* SysTick 과 충돌하면 안 된다. -> bsp 가 HAL_InitTick() 을 override 하여 HAL
|
||||
* 타임베이스를 TIM6 으로 둔다(stm32f4xx_hal_conf.h 의 TICK 우선순위 참고).
|
||||
* ===========================================================================*/
|
||||
#ifndef FREERTOS_CONFIG_H
|
||||
#define FREERTOS_CONFIG_H
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 어셈블러에서 인클루드될 때는 C 전용 선언을 숨긴다.
|
||||
* (portasm 등에서 본 헤더를 포함할 수 있음)
|
||||
* ---------------------------------------------------------------------------*/
|
||||
#ifndef __IASMARM__
|
||||
#include <stdint.h>
|
||||
extern uint32_t SystemCoreClock; /* CMSIS system_stm32f4xx.c 가 갱신 */
|
||||
#endif
|
||||
|
||||
/* =============================================================================
|
||||
* 스케줄러 기본 동작
|
||||
* ===========================================================================*/
|
||||
#define configUSE_PREEMPTION 1
|
||||
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 /* Cortex-M CLZ 기반 선택 */
|
||||
#define configUSE_TICKLESS_IDLE 0 /* IWDG/주기보고가 있어 미사용 */
|
||||
|
||||
/* CPU/틱 클럭. SystemClock_Config 가 168MHz 로 설정. */
|
||||
#define configCPU_CLOCK_HZ ( 168000000UL )
|
||||
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
|
||||
|
||||
#define configMAX_PRIORITIES ( 7 )
|
||||
#define configMINIMAL_STACK_SIZE ( ( uint16_t ) 128 ) /* words(=512B) */
|
||||
#define configMAX_TASK_NAME_LEN ( 16 )
|
||||
#define configUSE_16_BIT_TICKS 0
|
||||
#define configIDLE_SHOULD_YIELD 1
|
||||
#define configUSE_TASK_NOTIFICATIONS 1
|
||||
#define configTASK_NOTIFICATION_ARRAY_ENTRIES 3
|
||||
#define configUSE_MUTEXES 1
|
||||
#define configUSE_RECURSIVE_MUTEXES 1
|
||||
#define configUSE_COUNTING_SEMAPHORES 1
|
||||
#define configQUEUE_REGISTRY_SIZE 8
|
||||
#define configUSE_QUEUE_SETS 0
|
||||
#define configUSE_TIME_SLICING 1
|
||||
#define configUSE_NEWLIB_REENTRANT 0
|
||||
#define configENABLE_BACKWARD_COMPATIBILITY 0
|
||||
#define configSTACK_DEPTH_TYPE uint16_t
|
||||
#define configMESSAGE_BUFFER_LENGTH_TYPE size_t
|
||||
|
||||
/* =============================================================================
|
||||
* 메모리 할당
|
||||
* - 동적/정적 둘 다 지원. 정적: idle/timer 태스크 메모리는 rtos_hooks.c 제공.
|
||||
* - heap_4(coalescing first-fit) 사용 권장. 빌드 시스템이 heap_4.c 를 링크.
|
||||
* - 64KB. LwIP+mbedTLS TLS 핸드셰이크/세션은 메모리를 많이 쓰므로 여유 확보.
|
||||
* (mbedTLS 자체 버퍼는 보통 calloc/정적으로 잡지만, 태스크 스택/큐/세마포어
|
||||
* 등 커널 객체 + 애플리케이션 동적할당을 모두 이 힙에서 충당.)
|
||||
* ===========================================================================*/
|
||||
#define configSUPPORT_STATIC_ALLOCATION 1
|
||||
#define configSUPPORT_DYNAMIC_ALLOCATION 1
|
||||
#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 64 * 1024 ) )
|
||||
#define configAPPLICATION_ALLOCATED_HEAP 0 /* heap_4 내부 ucHeap[] 사용 */
|
||||
#define configKERNEL_PROVIDED_STATIC_MEMORY 1
|
||||
|
||||
/* =============================================================================
|
||||
* 후크(Hook) 함수 - 구현은 common/rtos_hooks.c
|
||||
* ===========================================================================*/
|
||||
#define configUSE_IDLE_HOOK 1 /* IWDG refresh / 저전력 hint */
|
||||
#define configUSE_TICK_HOOK 0
|
||||
#define configUSE_MALLOC_FAILED_HOOK 1 /* vApplicationMallocFailedHook */
|
||||
#define configCHECK_FOR_STACK_OVERFLOW 2 /* method 2: 스택 패턴 검사 */
|
||||
#define configUSE_DAEMON_TASK_STARTUP_HOOK 0
|
||||
|
||||
/* =============================================================================
|
||||
* 소프트웨어 타이머 (누수 입력 디바운스 타이머에 사용)
|
||||
* ===========================================================================*/
|
||||
#define configUSE_TIMERS 1
|
||||
#define configTIMER_TASK_PRIORITY ( configMAX_PRIORITIES - 1 )
|
||||
#define configTIMER_QUEUE_LENGTH 10
|
||||
#define configTIMER_TASK_STACK_DEPTH ( configMINIMAL_STACK_SIZE * 2 )
|
||||
|
||||
/* =============================================================================
|
||||
* 런타임/디버그 통계 (기본 비활성 — 필요 시 bsp 타이머로 ConfigureForRunTimeStats)
|
||||
* ===========================================================================*/
|
||||
#define configGENERATE_RUN_TIME_STATS 0
|
||||
#define configUSE_TRACE_FACILITY 1
|
||||
#define configUSE_STATS_FORMATTING_FUNCTIONS 0
|
||||
#define configRECORD_STACK_HIGH_ADDRESS 1
|
||||
|
||||
/* =============================================================================
|
||||
* assert() — 개발 중에는 reason 을 잡고 정지, 운영 중엔 IWDG 가 리셋.
|
||||
* vAssertCalled 는 rtos_hooks.c 에 구현(인터럽트 끄고 디버그 정지).
|
||||
* ===========================================================================*/
|
||||
#ifndef __IASMARM__
|
||||
void vAssertCalled( const char *pcFile, unsigned long ulLine );
|
||||
#define configASSERT( x ) \
|
||||
if( ( x ) == 0 ) vAssertCalled( __FILE__, __LINE__ )
|
||||
#endif
|
||||
|
||||
/* =============================================================================
|
||||
* 포함할 API (INCLUDE_*)
|
||||
* ===========================================================================*/
|
||||
#define INCLUDE_vTaskPrioritySet 1
|
||||
#define INCLUDE_uxTaskPriorityGet 1
|
||||
#define INCLUDE_vTaskDelete 1
|
||||
#define INCLUDE_vTaskSuspend 1
|
||||
#define INCLUDE_vTaskDelayUntil 1
|
||||
#define INCLUDE_xTaskDelayUntil 1
|
||||
#define INCLUDE_vTaskDelay 1
|
||||
#define INCLUDE_xTaskGetSchedulerState 1
|
||||
#define INCLUDE_xTaskGetCurrentTaskHandle 1
|
||||
#define INCLUDE_uxTaskGetStackHighWaterMark 1
|
||||
#define INCLUDE_xTaskGetIdleTaskHandle 1
|
||||
#define INCLUDE_eTaskGetState 1
|
||||
#define INCLUDE_xTimerPendFunctionCall 1
|
||||
#define INCLUDE_xTaskAbortDelay 1
|
||||
#define INCLUDE_xQueueGetMutexHolder 1
|
||||
#define INCLUDE_xSemaphoreGetMutexHolder 1
|
||||
#define INCLUDE_xTaskGetHandle 1
|
||||
#define INCLUDE_xTaskResumeFromISR 1
|
||||
|
||||
/* =============================================================================
|
||||
* Cortex-M4F 인터럽트 우선순위 설정 (NVIC)
|
||||
*
|
||||
* STM32F4 는 우선순위 4비트(16단계). 숫자가 작을수록 높은 선점도.
|
||||
* - LOWEST : 15 (가장 낮은 선점도)
|
||||
* - MAX_SYSCALL : 5 — 이보다 *높은 선점도(숫자 < 5)* 의 ISR 에서는 FreeRTOS
|
||||
* API 를 호출하면 안 된다. ETH/I2C/EXTI/UART 등 *FromISR 을 부르는 HAL IRQ 는
|
||||
* NVIC 우선순위를 5..15 범위(숫자 ≥ 5)로 설정해야 한다.
|
||||
* - 부팅 직후 NVIC_SetPriorityGrouping(0) (4비트 전부 preempt, sub 0)를 HAL_Init
|
||||
* 이 설정함을 전제.
|
||||
* ===========================================================================*/
|
||||
#define configPRIO_BITS 4
|
||||
|
||||
/* 가장 낮은 선점도(= 가장 큰 숫자). */
|
||||
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15
|
||||
|
||||
/* RTOS-aware ISR(=FromISR 사용 ISR)이 가질 수 있는 최고 선점도(=가장 작은 숫자). */
|
||||
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
|
||||
|
||||
/* 커널이 PendSV/SysTick 에 쓰는 raw 우선순위 (LOWEST << (8-PRIO_BITS)). */
|
||||
#define configKERNEL_INTERRUPT_PRIORITY \
|
||||
( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << ( 8 - configPRIO_BITS ) )
|
||||
|
||||
/* basepri 마스크 임계값 (MAX_SYSCALL << shift). taskENTER_CRITICAL 등에서 사용. */
|
||||
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
|
||||
( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << ( 8 - configPRIO_BITS ) )
|
||||
|
||||
/* =============================================================================
|
||||
* Cortex-M 포트 핸들러 -> CMSIS 벡터명 매핑
|
||||
* startup_stm32f407xx.s 의 벡터 테이블은 SVC_Handler/PendSV_Handler/
|
||||
* SysTick_Handler 를 참조한다. FreeRTOS 의 핸들러를 그 이름으로 alias.
|
||||
* SysTick 은 FreeRTOS 전용 — HAL 타임베이스는 TIM6 (bsp 가 HAL_InitTick override).
|
||||
* ===========================================================================*/
|
||||
#define vPortSVCHandler SVC_Handler
|
||||
#define xPortPendSVHandler PendSV_Handler
|
||||
#define xPortSysTickHandler SysTick_Handler
|
||||
|
||||
#endif /* FREERTOS_CONFIG_H */
|
||||
206
firmware/config/lwipopts.h
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/* =============================================================================
|
||||
* lwipopts.h - LwIP 설정 (STM32F407 + LAN8720 RMII + FreeRTOS)
|
||||
*
|
||||
* 타깃: Cortex-M4F @168MHz, 192KB SRAM(+64KB CCM). RAM 이 빠듯하므로
|
||||
* 풀/버퍼 크기를 보수적으로 잡되 HTTPS(mbedTLS over TCP) 1 커넥션 +
|
||||
* DHCP/DNS/SNTP(UDP) 가 동작하도록 충분히 둔다.
|
||||
*
|
||||
* 동작 모드: NO_SYS=0 (RTOS). LwIP 의 tcpip 스레드가 모든 코어 처리를
|
||||
* 직렬화하고, 애플리케이션은 sockets/netconn API 로 접근한다.
|
||||
* ethernetif.c 의 RX 는 별도 FreeRTOS 태스크에서 tcpip_input() 으로
|
||||
* pbuf 를 밀어 넣는다.
|
||||
*
|
||||
* 빌드: 이 헤더는 third_party/lwip(vendored) 의 헤더보다 먼저 include 경로상
|
||||
* 발견되어야 한다(컴파일러 -I config 우선). 폐쇄망 빌드: 외부 fetch 없음.
|
||||
* ===========================================================================*/
|
||||
#ifndef LWIPOPTS_H
|
||||
#define LWIPOPTS_H
|
||||
|
||||
#include <stdint.h> /* SNTP_SET_SYSTEM_TIME 캐스팅용 */
|
||||
|
||||
/* ── OS / 스레딩 ─────────────────────────────────────────────────────────── */
|
||||
/* NO_SYS=0 : FreeRTOS 위에서 동작 (sys_arch.c 가 sys_* 를 제공해야 함). */
|
||||
#define NO_SYS 0
|
||||
#define SYS_LIGHTWEIGHT_PROT 1
|
||||
#define LWIP_TCPIP_CORE_LOCKING 1
|
||||
#define LWIP_TCPIP_CORE_LOCKING_INPUT 0
|
||||
|
||||
/* sys_now()/sys_jiffies() 는 sys_arch.c(또는 cc.h) 에서 FreeRTOS 틱 기반 제공.
|
||||
* LWIP_TIMEVAL_PRIVATE=0 으로 newlib 의 struct timeval 사용. */
|
||||
#define LWIP_TIMEVAL_PRIVATE 0
|
||||
|
||||
/* ── 메모리 모델 ─────────────────────────────────────────────────────────── */
|
||||
/* 4바이트 정렬(Cortex-M4 DMA/워드 접근 안정). */
|
||||
#define MEM_ALIGNMENT 4U
|
||||
/* C 라이브러리 malloc 대신 LwIP 내부 힙 사용(결정적, 단편화 회피). */
|
||||
#define MEM_LIBC_MALLOC 0
|
||||
#define MEMP_MEM_MALLOC 0
|
||||
|
||||
/* 핵심 힙: pbuf PBUF_RAM, TCP 송신 큐 등에서 사용. 16KB. */
|
||||
#define MEM_SIZE (16 * 1024)
|
||||
|
||||
/* ── 메모리 풀(memp) ─────────────────────────────────────────────────────── */
|
||||
#define MEMP_NUM_PBUF 16 /* PBUF_ROM/REF 용 헤더 */
|
||||
#define MEMP_NUM_UDP_PCB 6 /* DHCP + DNS + SNTP + 여유 */
|
||||
#define MEMP_NUM_TCP_PCB 5 /* 동시 TCP 연결(HTTPS 1 + 여유) */
|
||||
#define MEMP_NUM_TCP_PCB_LISTEN 2
|
||||
#define MEMP_NUM_TCP_SEG 16
|
||||
#define MEMP_NUM_REASSDATA 4
|
||||
#define MEMP_NUM_FRAG_PBUF 8
|
||||
#define MEMP_NUM_ARP_QUEUE 6
|
||||
#define MEMP_NUM_NETBUF 4
|
||||
#define MEMP_NUM_NETCONN 6
|
||||
#define MEMP_NUM_TCPIP_MSG_API 8
|
||||
#define MEMP_NUM_TCPIP_MSG_INPKT 12
|
||||
#define MEMP_NUM_SYS_TIMEOUT (LWIP_NUM_SYS_TIMEOUT_INTERNAL + 4)
|
||||
|
||||
/* ── PBUF 풀(수신 경로) ──────────────────────────────────────────────────── */
|
||||
/* DMA 수신 디스크립터/재조립용. PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE 만큼 RAM 점유. */
|
||||
#define PBUF_POOL_SIZE 12
|
||||
/* 이더넷 프레임(1500 MTU + 14 헤더) 한 조각에 담기도록. 4바이트 정렬은 LwIP 가 처리. */
|
||||
#define PBUF_POOL_BUFSIZE LWIP_MEM_ALIGN_SIZE(1524)
|
||||
/* TCP/IP/이더넷 헤더 선두 여유(송신 시 헤더 prepend 용). */
|
||||
#define PBUF_LINK_HLEN (14 + ETH_PAD_SIZE)
|
||||
#define PBUF_LINK_ENCAPSULATION_HLEN 0
|
||||
/* RMII/HAL_ETH 는 보통 패딩 불필요(2바이트 정렬 트릭 미사용). */
|
||||
#define ETH_PAD_SIZE 0
|
||||
|
||||
/* ── ARP ─────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_ARP 1
|
||||
#define ARP_TABLE_SIZE 10
|
||||
#define ARP_QUEUEING 1
|
||||
|
||||
/* ── IP / ICMP ───────────────────────────────────────────────────────────── */
|
||||
#define LWIP_IPV4 1
|
||||
#define LWIP_IPV6 0
|
||||
#define IP_FORWARD 0
|
||||
#define IP_REASSEMBLY 1
|
||||
#define IP_FRAG 1
|
||||
#define LWIP_ICMP 1
|
||||
#define LWIP_RAW 0
|
||||
#define LWIP_BROADCAST_PING 0
|
||||
|
||||
/* ── DHCP ────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_DHCP 1
|
||||
/* DHCP 후 gratuitous ARP 로 IP 충돌 점검. */
|
||||
#define LWIP_DHCP_DOES_ACD_CHECK 0
|
||||
|
||||
/* ── AUTOIP (사용 안 함) ─────────────────────────────────────────────────── */
|
||||
#define LWIP_AUTOIP 0
|
||||
|
||||
/* ── DNS ─────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_DNS 1
|
||||
#define DNS_TABLE_SIZE 4
|
||||
#define DNS_MAX_NAME_LENGTH 256
|
||||
#define DNS_MAX_SERVERS 2
|
||||
|
||||
/* ── UDP ─────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_UDP 1
|
||||
#define LWIP_UDPLITE 0
|
||||
|
||||
/* ── TCP ─────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_TCP 1
|
||||
#define TCP_TTL 255
|
||||
/* MSS = MTU(1500) - IP(20) - TCP(20). */
|
||||
#define TCP_MSS 1460
|
||||
/* 송신 버퍼: TLS 레코드(최대 ~16KB)를 한 번에 큐잉할 필요는 없고, mbedTLS 가
|
||||
* 작은 청크로 write 하므로 4*MSS 면 충분. RAM 절약. */
|
||||
#define TCP_SND_BUF (4 * TCP_MSS)
|
||||
#define TCP_SND_QUEUELEN ((4 * TCP_SND_BUF) / TCP_MSS)
|
||||
/* 수신 윈도우: HTTPS 응답(JSON 짧음) 위주라 4*MSS. */
|
||||
#define TCP_WND (4 * TCP_MSS)
|
||||
#define TCP_QUEUE_OOSEQ 1
|
||||
#define LWIP_TCP_SACK_OUT 0
|
||||
#define TCP_LISTEN_BACKLOG 0
|
||||
|
||||
/* ── Netconn / Socket API (mbedTLS 가 BSD 소켓 사용) ─────────────────────── */
|
||||
#define LWIP_NETCONN 1
|
||||
#define LWIP_SOCKET 1
|
||||
/* mbedTLS net_sockets.c 와의 식별자 충돌 방지: read/write/close 등은 lwip_ 접두. */
|
||||
#define LWIP_COMPAT_SOCKETS 0
|
||||
#define LWIP_POSIX_SOCKETS_IO_NAMES 0
|
||||
#define LWIP_SOCKET_SELECT 1
|
||||
#define LWIP_SO_RCVTIMEO 1 /* recv 타임아웃(HTTP_TIMEOUT) */
|
||||
#define LWIP_SO_SNDTIMEO 1
|
||||
#define LWIP_SO_RCVBUF 1
|
||||
#define SO_REUSE 1
|
||||
#define LWIP_TCP_KEEPALIVE 1
|
||||
|
||||
/* ── 콜백 / 상태 ─────────────────────────────────────────────────────────── */
|
||||
#define LWIP_NETIF_STATUS_CALLBACK 1 /* IP 할당 감지(net_wait_up) */
|
||||
#define LWIP_NETIF_LINK_CALLBACK 1 /* 케이블 링크 업/다운 감지 */
|
||||
#define LWIP_NETIF_HOSTNAME 1
|
||||
#define LWIP_NETIF_API 1
|
||||
|
||||
/* ── 체크섬 (HAL_ETH 가 하드웨어 체크섬 오프로드 가능; 여기선 SW 로 안전하게) ─ */
|
||||
/* 일부 STM32 ETH 는 RX 하드웨어 체크섬을 지원하나, 디스크립터 설정과 결합되므로
|
||||
* 이식성/안정성 위해 LwIP 의 SW 체크섬을 사용한다. 필요 시 ethernetif.c 의
|
||||
* DMA 설정과 함께 CHECKSUM_GEN/CHECK_* 를 0 으로 내려 오프로드할 수 있다.
|
||||
* TODO(hw): 하드웨어 오프로드로 CPU 절감 시 아래를 0 으로 내리고 DMA 설정 동기화. */
|
||||
#define CHECKSUM_GEN_IP 1
|
||||
#define CHECKSUM_GEN_UDP 1
|
||||
#define CHECKSUM_GEN_TCP 1
|
||||
#define CHECKSUM_GEN_ICMP 1
|
||||
#define CHECKSUM_CHECK_IP 1
|
||||
#define CHECKSUM_CHECK_UDP 1
|
||||
#define CHECKSUM_CHECK_TCP 1
|
||||
#define CHECKSUM_CHECK_ICMP 1
|
||||
|
||||
/* ── 스레드 우선순위/스택 (FreeRTOS) ─────────────────────────────────────── */
|
||||
/* LwIP 우선순위는 FreeRTOS 절대 우선순위. 애플리케이션 태스크보다 약간 높게 둬서
|
||||
* 네트워크 처리가 굶지 않도록 한다. configMAX_PRIORITIES 가 7 이라고 가정.
|
||||
* TODO(hw): FreeRTOSConfig.h 의 configMAX_PRIORITIES 와 정합 확인. */
|
||||
#define TCPIP_THREAD_NAME "tcpip"
|
||||
#define TCPIP_THREAD_STACKSIZE 1024 /* 워드 단위(StackType_t) */
|
||||
#define TCPIP_THREAD_PRIO (5)
|
||||
#define TCPIP_MBOX_SIZE 12
|
||||
|
||||
#define DEFAULT_THREAD_NAME "lwip_def"
|
||||
#define DEFAULT_THREAD_STACKSIZE 512
|
||||
#define DEFAULT_THREAD_PRIO (3)
|
||||
|
||||
#define DEFAULT_UDP_RECVMBOX_SIZE 8
|
||||
#define DEFAULT_TCP_RECVMBOX_SIZE 12
|
||||
#define DEFAULT_RAW_RECVMBOX_SIZE 8
|
||||
#define DEFAULT_ACCEPTMBOX_SIZE 8
|
||||
|
||||
/* ethernetif RX 태스크가 사용할 우선순위/스택(ethernetif.c 에서 참조). */
|
||||
#define ETHIF_RX_THREAD_NAME "eth_rx"
|
||||
#define ETHIF_RX_THREAD_STACKSIZE 512
|
||||
#define ETHIF_RX_THREAD_PRIO (6) /* tcpip 보다 약간 높게 */
|
||||
|
||||
/* ── SNTP 애플리케이션 (apps/sntp) ───────────────────────────────────────── */
|
||||
/* timesync.c 가 sntp_init() 호출. 시스템 시간 콜백으로 RTC 반영. */
|
||||
extern void timesync_apply_epoch(uint32_t unix_seconds);
|
||||
|
||||
#define SNTP_SERVER_DNS 1 /* pool.ntp.org 등 호스트명 사용 */
|
||||
#define SNTP_SUPPORT 1
|
||||
#define SNTP_CHECK_RESPONSE 1 /* 응답 무결성(원본 타임스탬프) 검증 */
|
||||
#define SNTP_COMP_ROUNDTRIP 0
|
||||
#define SNTP_STARTUP_DELAY 0 /* 부팅 후 즉시 첫 요청 */
|
||||
#define SNTP_RECV_TIMEOUT 6000
|
||||
/* 폴 주기: 동기 후엔 자주 갱신 불필요. 1시간(서버 보고 주기와 동일 수준). */
|
||||
#define SNTP_UPDATE_DELAY (60u * 60u * 1000u)
|
||||
/* 시스템 시간 설정 훅: LwIP 가 획득한 초 단위 Unix 시간을 RTC 로 반영. */
|
||||
#define SNTP_SET_SYSTEM_TIME(sec) do { timesync_apply_epoch((uint32_t)(sec)); } while (0)
|
||||
/* 마이크로초 정밀도는 불필요(_US 버전 미정의). */
|
||||
|
||||
/* ── 통계 / 디버그 ───────────────────────────────────────────────────────── */
|
||||
#define LWIP_STATS 0
|
||||
#define LWIP_STATS_DISPLAY 0
|
||||
|
||||
/* 운영 빌드에서는 디버그 끔. 필요 시 개별 *_DEBUG 를 LWIP_DBG_ON 으로. */
|
||||
#define LWIP_DEBUG 0
|
||||
#define LWIP_NOASSERT 0 /* assert 유지(개발 단계 안전) */
|
||||
|
||||
/* ── 기타 ────────────────────────────────────────────────────────────────── */
|
||||
#define LWIP_PROVIDE_ERRNO 1 /* newlib errno 와 분리(독립 errno 제공) */
|
||||
#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 에 약한 대체 구현이 있을 수 있음) */
|
||||
uint32_t bsp_rand32(void);
|
||||
|
||||
#endif /* LWIPOPTS_H */
|
||||
203
firmware/config/mbedtls_config.h
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/* =============================================================================
|
||||
* mbedtls_config.h - STM32F407 (Cortex-M4F, 192KB SRAM + 64KB CCM) 용
|
||||
* 최소 TLS 1.2 클라이언트 mbedTLS 설정
|
||||
*
|
||||
* 목표 / 제약:
|
||||
* - 클라이언트 전용, TLS 1.2 ONLY (SSL3/TLS1.0/1.1 비활성).
|
||||
* - 키교환: ECDHE-RSA (+ ECDHE-ECDSA 선택) — 서버 인증서 RSA/ECDSA 모두 대응.
|
||||
* - 암호: AES-128/256-GCM (AEAD). CBC/RC4/3DES/ChaCha 등은 미사용.
|
||||
* - 해시: SHA-256/224(주), SHA-384/512(인증서 체인 서명에 필요할 수 있음).
|
||||
* - 인증서 검증: X.509 CRT 파싱 + 임베드 루트 CA(certs/server_ca.h)로 풀 검증.
|
||||
* - 엔트로피: 플랫폼(파일/시스템) 엔트로피 OFF, STM32 하드웨어 RNG(ALT) 사용.
|
||||
* - I/O: mbedTLS 내장 net_sockets/파일시스템 OFF — LwIP 소켓 BIO 를 직접 제공.
|
||||
* - 메모리: SSL 레코드 버퍼 축소(IN 4096 / OUT 2048) + MFL 협상으로 RAM 절감.
|
||||
* - 플랫폼 calloc/free → FreeRTOS heap (pvPortMalloc/vPortFree) 로 매핑.
|
||||
*
|
||||
* 폐쇄망(air-gapped): 모든 의존성은 third_party/mbedtls 에 벤더링되어 있으며,
|
||||
* 이 설정은 빌드/런타임 모두 외부 네트워크 fetch 를 유발하지 않는다.
|
||||
*
|
||||
* ── RAM 사용 추정(대략) ─────────────────────────────────────────────────────
|
||||
* SSL 레코드 버퍼: IN 4096 + OUT 2048 ≈ 6.1 KB
|
||||
* (+ 각 버퍼당 헤더/MAC/패딩 여유 약 수십~수백 B)
|
||||
* mbedtls_ssl_context (세션/핸드셰이크 트랜스폼 등): 약 1.5~2.5 KB
|
||||
* 핸드셰이크 임시(ECDHE 키쌍, 인증서 파싱 시 일시적 MPI/스택): 피크 약 6~10 KB
|
||||
* (핸드셰이크 종료 후 대부분 해제)
|
||||
* ctr_drbg + entropy 컨텍스트: 약 0.4 KB
|
||||
* 파싱된 루트 CA 체인(x509_crt, RSA-2048 기준): 약 1.5~2.5 KB (상주)
|
||||
* => 연결 1개 정상 운영 시 상주 약 10~12 KB, 핸드셰이크 피크 약 18~22 KB.
|
||||
* 192KB SRAM 예산 내에서 LwIP/FreeRTOS 와 공존 가능(단일 동시 TLS 연결 가정).
|
||||
* 필요 시 IN_CONTENT_LEN 을 더 줄이면(서버가 MFL 또는 작은 레코드 지원 시)
|
||||
* 추가 절감 가능. OUT 2048 은 우리의 HTTP POST 요청(<~1.3KB)에 충분하다.
|
||||
* ===========================================================================*/
|
||||
#ifndef MBEDTLS_CONFIG_H
|
||||
#define MBEDTLS_CONFIG_H
|
||||
|
||||
/* 대상 라이브러리: mbedTLS 3.6.2 (LTS) — third_party/mbedtls.
|
||||
*
|
||||
* PSA 정책(중요): 이 설정은 **레거시 크립토 경로**(MBEDTLS_USE_PSA_CRYPTO 미정의,
|
||||
* MBEDTLS_PSA_CRYPTO_C 미정의)를 사용한다. TLS 1.3 미사용(MBEDTLS_SSL_PROTO_TLS1_3
|
||||
* 미정의)이므로 3.6 에서 psa_crypto_init() 호출이 필요 없다(코드/RAM 절감).
|
||||
* → 빌드 시 -DMBEDTLS_USE_PSA_CRYPTO 등을 추가하지 말 것.
|
||||
* 빌드 시 -DMBEDTLS_CONFIG_FILE='"mbedtls_config.h"' 로 이 파일을 지정한다. */
|
||||
|
||||
#include <stddef.h> /* size_t (아래 calloc/free 프로토타입용) */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 1) 플랫폼 계층 (베어메탈 + FreeRTOS, 표준 C 라이브러리 일부만 사용)
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_PLATFORM_C
|
||||
/* calloc/free 를 FreeRTOS 힙으로 매핑한다(전용 매크로 사용 → 별도 set 함수 불필요).
|
||||
* pvPortMalloc 은 calloc 시그니처(nmemb,size)와 다르므로 래퍼를 tls_mbedtls.c
|
||||
* 에서 제공하고 여기서는 그 래퍼를 가리킨다.
|
||||
*
|
||||
* 주의(gcc15/C23): 매크로는 mbedTLS 의 모든 .c 에서 텍스트 치환되므로, 해당 함수의
|
||||
* 프로토타입이 그곳에서 보여야 한다(암시적 선언은 오류). 따라서 여기서 선언한다. */
|
||||
#define MBEDTLS_PLATFORM_MEMORY
|
||||
#define MBEDTLS_PLATFORM_CALLOC_MACRO mbedtls_platform_calloc
|
||||
#define MBEDTLS_PLATFORM_FREE_MACRO mbedtls_platform_free
|
||||
void *mbedtls_platform_calloc(size_t n, size_t size); /* tls_mbedtls.c 에 정의 */
|
||||
void mbedtls_platform_free(void *p); /* tls_mbedtls.c 에 정의 */
|
||||
/* snprintf/printf 는 표준 newlib 사용(로그/디버그용 최소). */
|
||||
|
||||
/* ── 시간(인증서 유효기간 검증) ─────────────────────────────────────────────
|
||||
* MBEDTLS_HAVE_TIME_DATE 가 없으면 X.509 notBefore/notAfter 검증이 컴파일되지
|
||||
* 않아 만료/미래 인증서도 통과한다(치명적). SNTP→RTC 로 동기한 Unix 시간을
|
||||
* mbedTLS 에 공급한다. newlib 의 time()(시스템콜, nosys 에서 -1)에 의존하지
|
||||
* 않도록 PLATFORM_TIME_MACRO 로 우리 함수(tls_platform_time)를 직접 가리킨다.
|
||||
* 날짜 분해는 newlib 의 gmtime_r(순수 계산, 시스템콜 없음)이 처리한다.
|
||||
* (time_t 타입 일치를 위해 <time.h> 포함; mbedtls_time_t 는 기본 time_t.) */
|
||||
#include <time.h>
|
||||
#define MBEDTLS_HAVE_TIME
|
||||
#define MBEDTLS_HAVE_TIME_DATE
|
||||
#define MBEDTLS_PLATFORM_TIME_MACRO tls_platform_time
|
||||
time_t tls_platform_time(time_t *t); /* tls_mbedtls.c 에 정의 (timesync_now 기반) */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 2) 엔트로피 / RNG — 플랫폼 엔트로피 OFF, STM32 하드웨어 RNG(ALT) 사용
|
||||
* tls_mbedtls.c 가 int mbedtls_hardware_poll(...) 를 구현한다.
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_NO_PLATFORM_ENTROPY
|
||||
#define MBEDTLS_ENTROPY_HARDWARE_ALT
|
||||
#define MBEDTLS_ENTROPY_C
|
||||
#define MBEDTLS_CTR_DRBG_C
|
||||
/* SHA-512 가 빌드에 있으므로 entropy 는 SHA-512 를 기본 사용. (아래 SHA512 활성) */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 3) 프로토콜 버전: TLS 1.2 클라이언트 ONLY
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_SSL_TLS_C
|
||||
#define MBEDTLS_SSL_CLI_C
|
||||
#define MBEDTLS_SSL_PROTO_TLS1_2
|
||||
/* SSL3 / TLS1.0 / TLS1.1 / TLS1.3 / DTLS / 서버 모드는 활성하지 않는다. */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 4) 키교환: ECDHE-RSA (주) + ECDHE-ECDSA (선택, 서버가 ECDSA 인증서일 때)
|
||||
* => RSA, ECDSA, ECDH, DHM(미사용) 중 ECDHE 만.
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED
|
||||
#define MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 5) 공개키 / 서명 알고리즘
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_PK_C
|
||||
#define MBEDTLS_PK_PARSE_C
|
||||
#define MBEDTLS_RSA_C
|
||||
#define MBEDTLS_PKCS1_V15 /* RSA PKCS#1 v1.5 서명/검증 */
|
||||
#define MBEDTLS_PKCS1_V21 /* RSASSA-PSS / OAEP (v2.1) — 일부 체인에 필요 */
|
||||
#define MBEDTLS_ECDSA_C
|
||||
#define MBEDTLS_ECDH_C
|
||||
#define MBEDTLS_ECP_C
|
||||
#define MBEDTLS_BIGNUM_C
|
||||
#define MBEDTLS_ASN1_PARSE_C
|
||||
#define MBEDTLS_ASN1_WRITE_C /* ECDSA 서명 작성/일부 PK 경로에서 사용 */
|
||||
#define MBEDTLS_OID_C
|
||||
|
||||
/* ECDSA 결정론적 서명(클라이언트 인증서 미사용이므로 필수는 아님). 비활성으로 RAM 절감. */
|
||||
/* #define MBEDTLS_ECDSA_DETERMINISTIC */
|
||||
|
||||
/* 지원 타원곡선: 서버/CA 가 가장 흔히 쓰는 secp256r1, secp384r1, x25519. */
|
||||
#define MBEDTLS_ECP_DP_SECP256R1_ENABLED
|
||||
#define MBEDTLS_ECP_DP_SECP384R1_ENABLED
|
||||
#define MBEDTLS_ECP_DP_CURVE25519_ENABLED
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 6) 해시
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_MD_C
|
||||
#define MBEDTLS_SHA224_C
|
||||
#define MBEDTLS_SHA256_C
|
||||
#define MBEDTLS_SHA384_C
|
||||
#define MBEDTLS_SHA512_C
|
||||
/* SHA-1: 일부 구형 인증서 체인 호환을 위해 켜두되, TLS 핸드셰이크 PRF 에는 미사용.
|
||||
* 보안상 불필요하면 주석 처리 가능. 기본은 호환성 위해 활성. */
|
||||
#define MBEDTLS_SHA1_C
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 7) 대칭 암호 (AEAD only): AES-GCM
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_AES_C
|
||||
#define MBEDTLS_GCM_C
|
||||
#define MBEDTLS_CIPHER_C
|
||||
/* CBC/CTR/ChaCha20-Poly1305/3DES/ARC4 등은 활성하지 않는다(AEAD-GCM 전용).
|
||||
* → ECDHE-RSA/ECDSA-AES128/256-GCM 만 협상 가능. CBC 모드 미포함으로 코드/RAM 절감. */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 8) X.509 인증서 검증 (서버 인증서 + 임베드 루트 CA)
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_X509_USE_C
|
||||
#define MBEDTLS_X509_CRT_PARSE_C
|
||||
/* CRL/CSR 작성은 불필요 → 비활성으로 코드/RAM 절감.
|
||||
* MBEDTLS_X509_CRL_PARSE_C / MBEDTLS_X509_CSR_PARSE_C 미정의. */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 9) TLS 세부 옵션 / 확장
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_SSL_SERVER_NAME_INDICATION /* SNI (mbedtls_ssl_set_hostname) */
|
||||
#define MBEDTLS_SSL_MAX_FRAGMENT_LENGTH /* MFL 협상으로 레코드 버퍼 절감 */
|
||||
#define MBEDTLS_SSL_ENCRYPT_THEN_MAC /* EtM 확장(GCM 엔 무영향, 켜둬도 무해) */
|
||||
#define MBEDTLS_SSL_EXTENDED_MASTER_SECRET /* 보안 권장 확장 */
|
||||
/* 세션 티켓/재협상/캐시: 단발 connection-close 운영이라 불필요 → 비활성. */
|
||||
/* #define MBEDTLS_SSL_SESSION_TICKETS */
|
||||
/* #define MBEDTLS_SSL_RENEGOTIATION */
|
||||
|
||||
/* ── 레코드 버퍼 크기 축소 (RAM 절감 핵심) ──────────────────────────────────
|
||||
* IN 4096: 서버 인증서(중간 CA 포함) 단편이 한 레코드에 들어오도록 여유.
|
||||
* OUT 2048: 우리 HTTP POST 요청은 ~1.3KB 미만 → 충분.
|
||||
* (RFC 6066 MFL 과 함께 동작; 서버 미지원 시 표준 16K 대신 이 값으로 제한.) */
|
||||
#define MBEDTLS_SSL_IN_CONTENT_LEN 4096
|
||||
#define MBEDTLS_SSL_OUT_CONTENT_LEN 2048
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 10) I/O / 파일시스템: mbedTLS 내장 비활성 (LwIP BIO 직접 제공)
|
||||
* - MBEDTLS_NET_C (mbedTLS 자체 BSD 소켓): 미정의
|
||||
* - MBEDTLS_FS_IO (파일에서 인증서 로드): 미정의 (PEM 은 flash 임베드)
|
||||
* - MBEDTLS_TIMING_C: 미정의 (블로킹 소켓 타임아웃은 SO_RCVTIMEO 로 처리)
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 11) 오류 문자열 / 디버그
|
||||
* - MBEDTLS_ERROR_C: mbedtls_strerror() 로 사람이 읽는 오류(로그용, 코드↑).
|
||||
* 플래시 1MB 라 여유 → 운영 진단 가치가 커서 활성. 용량 부족 시 제거.
|
||||
* - MBEDTLS_DEBUG_C: 핸드셰이크 상세 디버그는 기본 OFF(필요 시만 켜기).
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#define MBEDTLS_ERROR_C
|
||||
/* #define MBEDTLS_DEBUG_C */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 12) Cortex-M4 최적화/안전 옵션
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
/* AES/SHA 하드웨어 가속기가 없는 MCU 이므로 소프트웨어 구현 사용(기본).
|
||||
* NIST 곡선 최적화(MBEDTLS_ECP_NIST_OPTIM): 속도↑(secp256/384). */
|
||||
#define MBEDTLS_ECP_NIST_OPTIM
|
||||
/* 사이드채널 완화 무작위화는 RNG 가용하므로 기본 동작 유지. */
|
||||
|
||||
/* 정수 곱셈 최적화 타깃(선택). arm-none-eabi 가 기본 처리하므로 미지정. */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 13) 설정 검증 (헤더 의존성/조합 오류를 컴파일 타임에 잡는다)
|
||||
* 반드시 파일 맨 끝에 둔다.
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
#include "mbedtls/check_config.h"
|
||||
|
||||
#endif /* MBEDTLS_CONFIG_H */
|
||||
190
firmware/config/stm32f4xx_hal_conf.h
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/* =============================================================================
|
||||
* stm32f4xx_hal_conf.h - STM32CubeF4 HAL 설정 (STM32F407VGT6)
|
||||
*
|
||||
* ST 표준 템플릿을 본 프로젝트에서 실제로 쓰는 모듈만 남기도록 정리한 것.
|
||||
* 활성 모듈: RCC, GPIO, CORTEX, ETH, I2C, RNG, RTC, IWDG, UART, TIM, PWR,
|
||||
* FLASH, DMA, EXTI. (그 외 SPI/SDIO/CAN/USB/DAC/ADC 등은 비활성)
|
||||
*
|
||||
* 주의:
|
||||
* - HSE_VALUE = 8MHz. TODO(hw): 보드 크리스털 실제 값 확인(STM32F4-DISCO 는
|
||||
* 8MHz, 커스텀 보드면 25MHz 등일 수 있음). 잘못되면 클럭 전부 어긋남.
|
||||
* - TICK_INT_PRIORITY: HAL 타임베이스(TIM6, bsp 의 HAL_InitTick override)용
|
||||
* 인터럽트 우선순위. FreeRTOS MAX_SYSCALL(=5) 이하 선점도(숫자 ≥ 5)여야
|
||||
* *FromISR 충돌이 없다. 여기서는 15(가장 낮음) -> 안전.
|
||||
* - USE_RTOS 매크로는 ST 권고에 따라 0 으로 둔다(HAL 내부 RTOS 잠금 비활성;
|
||||
* 동기화는 애플리케이션이 FreeRTOS 객체로 직접 처리).
|
||||
* ===========================================================================*/
|
||||
#ifndef STM32F4xx_HAL_CONF_H
|
||||
#define STM32F4xx_HAL_CONF_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* =============================================================================
|
||||
* 1) 활성화할 HAL 모듈
|
||||
* ===========================================================================*/
|
||||
#define HAL_MODULE_ENABLED
|
||||
#define HAL_CORTEX_MODULE_ENABLED
|
||||
#define HAL_RCC_MODULE_ENABLED
|
||||
#define HAL_GPIO_MODULE_ENABLED
|
||||
#define HAL_EXTI_MODULE_ENABLED
|
||||
#define HAL_DMA_MODULE_ENABLED
|
||||
#define HAL_FLASH_MODULE_ENABLED
|
||||
#define HAL_PWR_MODULE_ENABLED
|
||||
#define HAL_ETH_MODULE_ENABLED
|
||||
#define HAL_I2C_MODULE_ENABLED
|
||||
#define HAL_RNG_MODULE_ENABLED
|
||||
#define HAL_RTC_MODULE_ENABLED
|
||||
#define HAL_IWDG_MODULE_ENABLED
|
||||
#define HAL_UART_MODULE_ENABLED
|
||||
#define HAL_TIM_MODULE_ENABLED
|
||||
|
||||
/* 명시적으로 비활성(템플릿 흔적 제거 — 참고용 주석)
|
||||
* #define HAL_ADC_MODULE_ENABLED
|
||||
* #define HAL_CAN_MODULE_ENABLED
|
||||
* #define HAL_CRC_MODULE_ENABLED
|
||||
* #define HAL_DAC_MODULE_ENABLED
|
||||
* #define HAL_DCMI_MODULE_ENABLED (카메라 기능 DROP)
|
||||
* #define HAL_SD_MODULE_ENABLED
|
||||
* #define HAL_SPI_MODULE_ENABLED
|
||||
* #define HAL_USART_MODULE_ENABLED (UART 모듈로 충분)
|
||||
* #define HAL_PCD_MODULE_ENABLED (USB 미사용)
|
||||
* #define HAL_WWDG_MODULE_ENABLED (IWDG 사용)
|
||||
*/
|
||||
|
||||
/* =============================================================================
|
||||
* 2) 발진기(Oscillator) 값
|
||||
* ===========================================================================*/
|
||||
#if !defined (HSE_VALUE)
|
||||
#define HSE_VALUE 8000000U /* TODO(hw): 외부 HSE 크리스털 실제 주파수(Hz) */
|
||||
#endif
|
||||
#if !defined (HSE_STARTUP_TIMEOUT)
|
||||
#define HSE_STARTUP_TIMEOUT 100U /* ms */
|
||||
#endif
|
||||
|
||||
#if !defined (HSI_VALUE)
|
||||
#define HSI_VALUE 16000000U /* 내부 RC 16MHz */
|
||||
#endif
|
||||
|
||||
#if !defined (LSI_VALUE)
|
||||
#define LSI_VALUE 32000U /* IWDG/RTC 용 내부 저속 RC (~32kHz, 오차 큼) */
|
||||
#endif
|
||||
|
||||
#if !defined (LSE_VALUE)
|
||||
#define LSE_VALUE 32768U /* TODO(hw): RTC 외부 32.768kHz 크리스털 유무 */
|
||||
#endif
|
||||
#if !defined (LSE_STARTUP_TIMEOUT)
|
||||
#define LSE_STARTUP_TIMEOUT 5000U
|
||||
#endif
|
||||
|
||||
#if !defined (EXTERNAL_CLOCK_VALUE)
|
||||
#define EXTERNAL_CLOCK_VALUE 12288000U /* I2S 등 미사용 — 템플릿 기본 */
|
||||
#endif
|
||||
|
||||
/* =============================================================================
|
||||
* 3) 시스템 설정
|
||||
* ===========================================================================*/
|
||||
#define VDD_VALUE 3300U /* mV */
|
||||
#define TICK_INT_PRIORITY 15U /* HAL 타임베이스(TIM6) IRQ 우선순위
|
||||
(가장 낮은 선점도 -> RTOS 안전) */
|
||||
#define USE_RTOS 0U
|
||||
#define PREFETCH_ENABLE 1U
|
||||
#define INSTRUCTION_CACHE_ENABLE 1U
|
||||
#define DATA_CACHE_ENABLE 1U
|
||||
|
||||
/* assert_param: 운영 빌드에서는 끈다(USE_FULL_ASSERT 정의 시 활성). */
|
||||
/* #define USE_FULL_ASSERT 1U */
|
||||
|
||||
#define USE_HAL_ETH_REGISTER_CALLBACKS 0U
|
||||
#define USE_HAL_I2C_REGISTER_CALLBACKS 0U
|
||||
#define USE_HAL_RNG_REGISTER_CALLBACKS 0U
|
||||
#define USE_HAL_RTC_REGISTER_CALLBACKS 0U
|
||||
#define USE_HAL_TIM_REGISTER_CALLBACKS 0U
|
||||
#define USE_HAL_UART_REGISTER_CALLBACKS 0U
|
||||
|
||||
/* =============================================================================
|
||||
* 4) Ethernet (LAN8720 PHY / RMII) 파라미터
|
||||
* LwIP 와 stm32f4xx_hal_eth.c 가 참조한다.
|
||||
* ===========================================================================*/
|
||||
/* RMII 모드는 25MHz REF_CLK 가 PHY/MCO 로 공급된다고 가정. */
|
||||
#define ETH_TX_DESC_CNT 4U /* 송신 DMA 디스크립터 개수 */
|
||||
#define ETH_RX_DESC_CNT 4U /* 수신 DMA 디스크립터 개수 */
|
||||
|
||||
/* LAN8720 PHY 주소. TODO(hw): 보드 스트랩 핀에 따라 0 또는 1. */
|
||||
#define LAN8742_PHY_ADDRESS 0x00U /* CubeF4 ETH 코드가 참조하는 매크로 */
|
||||
|
||||
/* MAC 주소 (locally-administered, 02:xx). TODO(hw): 노드별 고유값으로 교체. */
|
||||
#define MAC_ADDR0 0x02U
|
||||
#define MAC_ADDR1 0x00U
|
||||
#define MAC_ADDR2 0x00U
|
||||
#define MAC_ADDR3 0x00U
|
||||
#define MAC_ADDR4 0x00U
|
||||
#define MAC_ADDR5 0x01U
|
||||
|
||||
/* =============================================================================
|
||||
* 5) SysTick 관련 — FreeRTOS 가 SysTick 을 점유하므로 HAL 은 TIM6 타임베이스.
|
||||
* (bsp.c 가 HAL_InitTick() 을 weak override 한다. 여기서는 별도 정의 없음.)
|
||||
* ===========================================================================*/
|
||||
|
||||
/* =============================================================================
|
||||
* 6) 모듈별 헤더 인클루드 (활성 모듈만)
|
||||
* ===========================================================================*/
|
||||
#ifdef HAL_RCC_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_rcc.h"
|
||||
#endif
|
||||
#ifdef HAL_GPIO_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_gpio.h"
|
||||
#endif
|
||||
#ifdef HAL_EXTI_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_exti.h"
|
||||
#endif
|
||||
#ifdef HAL_DMA_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_dma.h"
|
||||
#endif
|
||||
#ifdef HAL_CORTEX_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_cortex.h"
|
||||
#endif
|
||||
#ifdef HAL_FLASH_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_flash.h"
|
||||
#endif
|
||||
#ifdef HAL_PWR_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_pwr.h"
|
||||
#endif
|
||||
#ifdef HAL_ETH_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_eth.h"
|
||||
#endif
|
||||
#ifdef HAL_I2C_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_i2c.h"
|
||||
#endif
|
||||
#ifdef HAL_RNG_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_rng.h"
|
||||
#endif
|
||||
#ifdef HAL_RTC_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_rtc.h"
|
||||
#endif
|
||||
#ifdef HAL_IWDG_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_iwdg.h"
|
||||
#endif
|
||||
#ifdef HAL_UART_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_uart.h"
|
||||
#endif
|
||||
#ifdef HAL_TIM_MODULE_ENABLED
|
||||
#include "stm32f4xx_hal_tim.h"
|
||||
#endif
|
||||
|
||||
/* =============================================================================
|
||||
* 7) assert_param
|
||||
* ===========================================================================*/
|
||||
#ifdef USE_FULL_ASSERT
|
||||
#define assert_param(expr) ((expr) ? (void)0U : assert_failed((uint8_t *)__FILE__, __LINE__))
|
||||
void assert_failed(uint8_t *file, uint32_t line);
|
||||
#else
|
||||
#define assert_param(expr) ((void)0U)
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* STM32F4xx_HAL_CONF_H */
|
||||
231
firmware/docs/BUILD_OFFLINE.md
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# BUILD_OFFLINE.md — 폐쇄망(air-gapped) 빌드·전달·플래시 절차
|
||||
|
||||
> **핵심 원칙 (공통 빌드/배포 규칙):** 모든 산출물은 **완전 자급식(all-inclusive)** 이어야 한다.
|
||||
> 배포 대상(설치 호스트)은 **인터넷 접근이 없다** — 빌드 시점에도, 런타임에도. 다운로드는 **오직 빌드 머신에서만**
|
||||
> 일어나며, 그 결과물(`.bin`)이 에어갭을 넘어 전달된다. 대상은 어떤 것도 직접 가져오지 않는다.
|
||||
>
|
||||
> 즉: STM32CubeF4 HAL+CMSIS / FreeRTOS-Kernel / lwip / mbedtls 는 모두 `firmware/third_party/` 에
|
||||
> **고정 버전으로 벤더링**되어 빌드·런타임에 외부에서 가져오지 않는다. 인증서·SNTP 대상까지 사전 준비한다.
|
||||
|
||||
전체 흐름:
|
||||
|
||||
```
|
||||
[인터넷 가능 빌드 머신] [에어갭] [격리된 설치 호스트]
|
||||
1) 의존성 벤더링(고정 버전) ─┐
|
||||
2) secrets.h 작성 ├─▶ 4) cmake+ninja ─▶ sht30_fw.bin/.hex/.elf ─▶(USB/매체)─▶ 5) ST-Link 플래시
|
||||
3) 실제 CA 임베드 ─┘ 6) 망 단절 상태로 검증
|
||||
7) 실패 시 롤백
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. 사전 준비 (빌드 머신, 1회)
|
||||
|
||||
| 도구 | 버전 | 비고 |
|
||||
|------|------|------|
|
||||
| `arm-none-eabi-gcc` | **15.2** | 크로스 컴파일러 (newlib 포함) |
|
||||
| CMake | ≥ 3.21 | |
|
||||
| Ninja | 최신 | |
|
||||
| ST-Link 툴 | `st-flash`/`STM32_Programmer_CLI` 또는 OpenOCD | 플래시용 (설치 호스트에도 필요) |
|
||||
| `git` | 임의 | 벤더링 시점에만 사용 |
|
||||
| (선택) `openssl` | 임의 | 서버 인증서 체인 확인용 |
|
||||
|
||||
> 빌드 머신에는 위 도구 일체를 **오프라인 설치본**으로도 갖춰 둔다(추후 빌드 머신도 망에서 분리될 수 있음).
|
||||
|
||||
---
|
||||
|
||||
## 1. 의존성 벤더링 (빌드 머신에서, 외부 다운로드는 여기서만)
|
||||
|
||||
`firmware/third_party/` 에 아래를 **고정 버전(태그/커밋 핀)** 으로 배치한다. 빌드·런타임에 추가 fetch 0건.
|
||||
|
||||
```
|
||||
firmware/third_party/
|
||||
├─ CMSIS/ ARM CMSIS Core (STM32CubeF4 동봉본)
|
||||
├─ STM32F4_HAL/ STM32CubeF4 HAL + Device 헤더 (stm32f4xx_hal_*, stm32f407xx.h, startup, linker .ld)
|
||||
├─ FreeRTOS-Kernel/ FreeRTOS 커널 (네이티브 API; CMSIS-RTOS 래퍼는 미사용)
|
||||
├─ lwip/ LwIP (tcpip + apps/sntp 포함)
|
||||
└─ mbedtls/ mbedTLS (TLS1.2 클라이언트)
|
||||
```
|
||||
|
||||
### 1.1 벤더 스크립트
|
||||
|
||||
```bash
|
||||
# 빌드 머신(인터넷 가능)에서 1회 실행. 고정 버전을 받아 third_party/ 로 정리한다.
|
||||
bash firmware/scripts/vendor_deps.sh # (또는 vendor_deps.ps1)
|
||||
```
|
||||
|
||||
> `TODO(hw):` `firmware/scripts/vendor_deps.{sh,ps1}` 는 별도 작업으로 작성/제공된다. 권장 고정 버전(예시):
|
||||
> STM32CubeF4 `v1.28.x`, FreeRTOS-Kernel `V11.x`, lwip `STABLE-2.2.x`, mbedtls `v3.6.x` (LTS).
|
||||
> 스크립트 미제공 시: 각 릴리스 tarball 을 수동으로 받아 위 디렉터리 구조로 풀고, **버전을 기록**한다.
|
||||
|
||||
### 1.2 벤더링 검증 (오프라인성 확인)
|
||||
|
||||
```bash
|
||||
# third_party 가 채워졌고, 빌드가 네트워크 없이 가능한지 확인
|
||||
ls firmware/third_party/{CMSIS,STM32F4_HAL,FreeRTOS-Kernel,lwip,mbedtls}
|
||||
# 무결성: 받은 tarball 의 해시를 릴리스 페이지 게시값과 대조하여 기록
|
||||
sha256sum firmware/third_party/*.tar.gz > firmware/third_party/VENDORED_HASHES.txt # (받은 경우)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 비밀값 준비 — `secrets.h` (빌드 머신, 1회)
|
||||
|
||||
```bash
|
||||
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)`.)
|
||||
|
||||
> - `secrets.h` 는 **저장소에 커밋하지 않는다**(`.gitignore` 등록). 산출 `.bin` 안에는 키가 임베드되므로
|
||||
> `.bin` 자체를 비밀로 취급한다(공통 보안 규칙: 산출물에 비밀이 새지 않도록 유통 통제).
|
||||
> - `TODO(hw):` 운영 API_KEY 값은 운영자만 알고 있으며 본 작업 범위가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 서버 루트 CA 임베드 — `certs/server_ca.c` (빌드 머신, 1회 / 갱신 시)
|
||||
|
||||
폐쇄망에서는 런타임 CA 다운로드가 불가하므로 **서버(Cafe24) 인증서 체인의 루트 CA PEM 을 펌웨어에 고정**한다.
|
||||
[`certs/server_ca.h`](../certs/server_ca.h) 가 `SERVER_CA_PEM` / `SERVER_CA_PEM_LEN` 을 `extern` 으로 선언하므로,
|
||||
실제 PEM 을 담은 **`certs/server_ca.c`** 를 생성해야 한다.
|
||||
|
||||
### 3.1 인증서 체인 확인 (빌드 머신, 망 가능)
|
||||
|
||||
```bash
|
||||
# 실제 서버에서 체인/cipher/만료 확인 (이식 계획 R3)
|
||||
openssl s_client -connect your-domain.example:443 -servername your-domain.example -showcerts </dev/null
|
||||
# → 출력 체인에서 "루트 CA" 인증서를 식별하여 PEM 으로 저장
|
||||
```
|
||||
|
||||
### 3.2 `server_ca.c` 생성
|
||||
|
||||
```c
|
||||
/* certs/server_ca.c — 실제 Cafe24 루트 CA PEM (운영자가 채움) */
|
||||
#include "server_ca.h"
|
||||
|
||||
const char SERVER_CA_PEM[] =
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
"...실제 루트 CA 본문...\n"
|
||||
"-----END CERTIFICATE-----\n"; /* 마지막 NUL 포함 */
|
||||
|
||||
/* mbedtls_x509_crt_parse() 는 PEM 일 때 buflen 에 종결 NUL 을 포함해야 한다 */
|
||||
const unsigned int SERVER_CA_PEM_LEN = sizeof(SERVER_CA_PEM);
|
||||
```
|
||||
|
||||
> 중요: `SERVER_CA_PEM_LEN == sizeof(...)` (종결 NUL **포함**). `strlen` 을 쓰면 mbedTLS PEM 파싱이 실패한다
|
||||
> ([`certs/server_ca.h`](../certs/server_ca.h) 주석 참고).
|
||||
>
|
||||
> `TODO(hw):` 실제 PEM 본문은 운영 서버의 인증서 발급기관에 따라 달라진다. 위는 자리표시자.
|
||||
> 만료/발급기관 교체 시(R3) 폐쇄망이라 OTA 불가 → **펌웨어 재빌드/재배포**가 유일한 갱신 경로다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 빌드 — `cmake + ninja` (sht30_fw 단일 타깃, 빌드 머신)
|
||||
|
||||
```bash
|
||||
# 구성 (크로스 툴체인 파일 지정)
|
||||
cmake -S firmware -B firmware/build -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=firmware/cmake/arm-none-eabi.cmake
|
||||
|
||||
# SHT30 보드 산출물 빌드 (보드 매크로 -DBOARD_SHT30 은 타깃이 자동 주입)
|
||||
cmake --build firmware/build --target sht30_fw # → sht30_fw.elf / .bin / .hex
|
||||
```
|
||||
|
||||
산출물:
|
||||
|
||||
| 파일 | 보드 | 적재 주소 |
|
||||
|------|------|-----------|
|
||||
| `firmware/build/sht30_fw.bin` | SHT30(sensor_id=2) | `0x08000000` |
|
||||
| `firmware/build/sht30_fw.hex` | SHT30(sensor_id=2) | (주소 내장) |
|
||||
| `firmware/build/sht30_fw.elf` | SHT30(sensor_id=2) | (심볼/디버그) |
|
||||
|
||||
빌드 검증 (오프라인성/메모리):
|
||||
|
||||
```bash
|
||||
arm-none-eabi-size firmware/build/sht30_fw.elf # Flash(1MB)/RAM(192KB) 예산 내 확인
|
||||
# CCM 배치/IN/OUT_CONTENT_LEN 축소가 적용되어 .data/.bss 가 SRAM 예산을 넘지 않는지 확인 (이식 계획 R2)
|
||||
```
|
||||
|
||||
> 빌드 중 인터넷 접근이 발생하면 **벤더링 누락**이다 — `third_party/` 를 보강하고 외부 fetch 0건을 보장한다.
|
||||
> `TODO(hw):` `firmware/cmake/arm-none-eabi.cmake` / `*.ld`(CCM 스택 배치) 는 별도 작업으로 제공된다.
|
||||
|
||||
### 4.1 (선택) 호스트 패리티 게이트 — 서버 기대값 대조
|
||||
|
||||
`.bin` 을 만들기 전, **이식성 순수 로직**(서명/JSON/CRC/변환)을 호스트에서 PHP 기대값과 대조한다.
|
||||
|
||||
```bash
|
||||
# firmware/test/host : sha256(API_KEY||body) 와 PHP verify_signature_raw 결과 일치 확인
|
||||
python3 firmware/test/host/parity_test.py
|
||||
php firmware/test/host/php_verify.php # 동일 본문에 대한 서버측 서명 기대값
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 에어갭 전달 + 플래시 (격리된 설치 호스트)
|
||||
|
||||
1. `sht30_fw.bin`(또는 `.hex`/`.elf`) (+ 무결성 해시) 를 **신뢰 매체**(USB 등)로 격리 호스트에 전달.
|
||||
```bash
|
||||
sha256sum firmware/build/sht30_fw.bin > FW_HASHES.txt # 빌드 머신
|
||||
# 격리 호스트에서 재계산하여 대조 (무결성)
|
||||
```
|
||||
2. ST-Link/J-Link 로 플래시 (sht30_fw 고정):
|
||||
```bash
|
||||
# 권장: 동봉 스크립트(보드 파라미터 없이 sht30_fw 자동 탐지·플래시)
|
||||
pwsh firmware/scripts/flash.ps1
|
||||
|
||||
# 대안: st-flash
|
||||
st-flash write sht30_fw.bin 0x08000000
|
||||
# 대안: OpenOCD
|
||||
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
|
||||
-c "program sht30_fw.elf verify reset exit"
|
||||
# 대안: ST 공식 CLI
|
||||
STM32_Programmer_CLI -c port=SWD -w sht30_fw.hex -v -rst
|
||||
```
|
||||
3. 플래시 직후 IWDG 가 동작하므로, 정상 부팅하면 주기 refresh 로 리셋되지 않는다(20s 타임아웃).
|
||||
|
||||
---
|
||||
|
||||
## 6. 망 단절 상태 검증 (격리 호스트)
|
||||
|
||||
> 산출물이 **격리망 안에서만** 동작·검증되는지 확인한다. 외부 인터넷 없이 서버(또는 스테이징 PHP)에만 도달한다.
|
||||
|
||||
1. **콘솔(USART3 PD8/PD9, 115200 8N1)** 으로 부팅 로그 관찰:
|
||||
- `bsp_init` → 링크업 → DHCP(또는 static fallback) → **SNTP 시간 동기** → TLS 핸드셰이크 → 첫 `startup` POST.
|
||||
2. 서버에서 수신 확인:
|
||||
- SHT30 보드: `startup` → (주기 300s) `periodic`, `temperature_c`/`humidity_percent`/`metric_status` 필드.
|
||||
- 서버 임계(`config.php` METRIC_*) 초과 시 SMS 발송 트리거 확인.
|
||||
- 응답 **HTTP 200** + 서버 로그/DB 적재 확인. 서명 불일치(401)면 §2(API_KEY)/§3(CA) 재점검.
|
||||
3. **회복 시험**: LAN 케이블 분리 → 백오프 재시도 로그 → 재연결 시 정상 보고 재개. SNTP 미동기 시 경보(R4).
|
||||
4. **워치독 시험**: (디버그) 의도적으로 헬스 루프 정지 → 약 20s 내 자동 리셋 관찰.
|
||||
5. **24h 안정성**: 메모리 누수/리셋 루프 없이 주기 보고 지속(이식 계획 Phase 8).
|
||||
|
||||
> 검증 시 빌드 머신/인터넷에 의존하지 않음을 명시적으로 확인한다(어떤 fetch 도 발생하면 안 됨).
|
||||
|
||||
---
|
||||
|
||||
## 7. 롤백
|
||||
|
||||
| 시나리오 | 절차 |
|
||||
|----------|------|
|
||||
| 신규 펌웨어 불량 | 직전 **검증된 `.bin`** 을 동일 주소(`0x08000000`)로 재플래시 (이전 산출물을 항상 보관) |
|
||||
| RPi 로 임시 복귀 | 서버 `X-Signature` ↔ 레거시 본문 서명 **이중 모드** 가 유지되므로, 같은 사이트에 기존 RPi 노드를 다시 투입해도 서버는 양쪽을 수용(이식 계획 R1) |
|
||||
| 인증서 만료(R3) | 새 루트 CA 로 §3 재수행 → §4 재빌드 → §5 재플래시 (OTA 불가) |
|
||||
| 전체 무력화 | 플래시 소거 후 직전 골든 이미지 복원: `st-flash erase` → `st-flash write <golden>.bin 0x08000000` |
|
||||
|
||||
권장: 검증 통과한 모든 `.bin` 은 **버전 라벨 + 해시 + 빌드 일자**로 보관(롤백 자산). 펌웨어 버전은 보고 본문의
|
||||
`app_version="v2606-sht30"`(`firmware/VERSION` = `fw-2.0.0`) 로 서버에서 식별 가능.
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트 (배포 전 최종)
|
||||
|
||||
- [ ] `third_party/` 고정 버전 벤더링 완료 + 버전/해시 기록 (외부 fetch 0건)
|
||||
- [ ] `secrets.h` 의 `APP_API_KEY` = 서버 `API_KEY`
|
||||
- [ ] `certs/server_ca.c` 에 실제 루트 CA PEM 임베드 (`LEN = sizeof`, NUL 포함)
|
||||
- [ ] `sht30_fw.bin` 빌드 + `size` 가 Flash/RAM 예산 내
|
||||
- [ ] (선택) 호스트 패리티 게이트 PASS
|
||||
- [ ] `.bin` 무결성 해시 생성 → 격리 호스트에서 대조
|
||||
- [ ] 망 단절 검증: 부팅 → SNTP → TLS → 200, 회복/워치독 시험
|
||||
- [ ] 직전 골든 이미지 + 해시 보관(롤백)
|
||||
- [ ] HARDWARE.md §0 의 **PA2(USART2↔MDIO) 충돌 해소(USART3 PD8/PD9)** 반영 확인
|
||||
222
firmware/docs/HARDWARE.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# HARDWARE.md — STM32F407VGT6 핀맵 & 배선 (SHT30 단일 보드)
|
||||
|
||||
대상 MCU: **STM32F407VGT6** (LQFP-100), Cortex-M4F @168MHz.
|
||||
권장 기준 보드: STM32F4-DISCOVERY 계열 또는 동등한 커스텀 PCB. 핀은 LQFP-100 패키지 기준.
|
||||
|
||||
이 문서는 **SHT30 온습도 보드**의 핀 배치, 외부 배선, 전원, 클럭원, 그리고 반드시 해소해야 하는
|
||||
**핀 충돌(USART2 ↔ LAN8720 MDIO, 둘 다 PA2)** 을 다룬다.
|
||||
|
||||
> 사이트/하드웨어 결정이 필요한 항목은 `TODO(hw):` 로 표시했다. 코드는 합리적 기본값으로 컴파일된다.
|
||||
|
||||
---
|
||||
|
||||
## 0. ⚠️ PA2 핀 충돌 (USART2 TX vs. LAN8720 MDIO) — ✅ 코드에서 해소 완료
|
||||
|
||||
**문제(배경).** 초기에는 디버그 로그 UART 가 **USART2 (PA2=TX / PA3=RX)** 로 설계되었으나(ST-Link VCP
|
||||
호환), LAN8720 RMII 연결에서 **MDIO 신호 역시 PA2 (`ETH_MDIO`, AF11)** 를 사용한다. PA2 를 두 기능이
|
||||
동시에 점유할 수 없고, RMII 동작에 MDIO(PHY 레지스터 설정/링크 폴링)가 필수이므로 PA2 는 반드시
|
||||
`ETH_MDIO` 여야 한다.
|
||||
|
||||
**✅ 해소(코드 반영됨).** 로그 UART 를 **USART3 (PD8=TX / PD9=RX, AF7)** 로 이동했다. 반영 위치:
|
||||
- [`common/applog.c`](../common/applog.c): `HAL_UART_MspInit`/`applog_init` 가 USART3 + GPIOD(PD8/PD9, AF7)로 초기화.
|
||||
- [`common/app_config.h`](../common/app_config.h): `APP_LOG_UART_BAUD` 주석을 USART3 PD8/PD9 로 정정.
|
||||
PA2 는 `ETH_MDIO` 전용이며, 코드에는 USART2 사용처가 더 이상 없다.
|
||||
|
||||
### 권장 해소안 (택일)
|
||||
|
||||
| 방안 | 로그 UART 핀 | AF | ST-Link VCP | 권장도 | 비고 |
|
||||
|------|-------------|----|-------------|--------|------|
|
||||
| **A. USART3 → PD8(TX)/PD9(RX)** | PD8/PD9 | AF7 | 별도 USB-UART 어댑터 필요 | **★ 권장** | RMII/I2C 와 충돌 없음. PD 포트는 본 설계에서 비어 있음 |
|
||||
| B. USART1 → PA9(TX)/PA10(RX) | PA9/PA10 | AF7 | 별도 어댑터 | 차선 | PA9/PA10 미사용. 단, PA9 는 USB-OTG VBUS 와 보드에 따라 충돌 가능 — 디스커버리 보드 확인 |
|
||||
| C. USART2 유지 + RMII 포기 | PA2/PA3 | — | VCP 사용 | ✗ 불가 | 이더넷이 필수이므로 채택 불가 |
|
||||
|
||||
**채택: 방안 A — 로그 UART = USART3 (PD8 TX / PD9 RX, AF7, 115200 8N1).**
|
||||
USART3 의 다른 핀쌍(PB10/PB11, PC10/PC11)은 RMII(PB11=TXEN)·SHT30 I2C 와 겹치므로 **PD8/PD9 를 사용한다.**
|
||||
|
||||
> **상태:**
|
||||
> 1. ✅ 코드 반영됨: `applog.c` 가 USART3 + GPIOD(PD8/PD9, AF7)로 초기화, `app_config.h` 주석 정정.
|
||||
> 2. ⬜ `TODO(hw)`: ST-Link VCP(PA2/PA3)를 못 쓰므로 운영 로그는 **외부 3.3V USB-UART 어댑터를 PD8/PD9 에 연결**.
|
||||
> 본 문서의 모든 표는 **방안 A(USART3 PD8/PD9)** 기준이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. LAN8720 Ethernet PHY (RMII)
|
||||
|
||||
STM32 내장 ETH MAC + 외부 LAN8720 PHY, **RMII 모드**. 모든 RMII 핀은 대체기능 **AF11 (`ETH`)**.
|
||||
|
||||
| STM32 핀 | 신호 (RMII) | AF | LAN8720 핀 | 방향(MCU 기준) | 비고 |
|
||||
|----------|--------------------|------|-----------|----------------|------|
|
||||
| **PA1** | `ETH_REF_CLK` | AF11 | nINT/REFCLKO | 입력 | 50MHz 기준 클럭 입력 (§1.1) |
|
||||
| **PA2** | `ETH_MDIO` | AF11 | MDIO | 양방향 | ⚠️ §0 충돌 핀 — USART2 금지 |
|
||||
| **PC1** | `ETH_MDC` | AF11 | MDC | 출력 | 관리 클럭 |
|
||||
| **PA7** | `ETH_CRS_DV` | AF11 | CRS_DV | 입력 | 캐리어 감지/데이터 유효 |
|
||||
| **PC4** | `ETH_RXD0` | AF11 | RXD0 | 입력 | 수신 데이터 0 |
|
||||
| **PC5** | `ETH_RXD1` | AF11 | RXD1 | 입력 | 수신 데이터 1 |
|
||||
| **PB11** | `ETH_TX_EN` | AF11 | TXEN | 출력 | 송신 인에이블 |
|
||||
| **PB12** | `ETH_TXD0` | AF11 | TXD0 | 출력 | 송신 데이터 0 |
|
||||
| **PB13** | `ETH_TXD1` | AF11 | TXD1 | 출력 | 송신 데이터 1 |
|
||||
|
||||
추가 배선(RMII 표준):
|
||||
|
||||
| LAN8720 핀 | 연결 | 비고 |
|
||||
|-----------|------|------|
|
||||
| MDIO 풀업 | 1.5kΩ → 3.3V | 권장 |
|
||||
| nRST | MCU GPIO 또는 RC 리셋 | `TODO(hw):` 소프트 리셋 핀 배정 시 GPIO 1개 추가(예 PE0). 미배정 시 전원-온 RC 리셋 |
|
||||
| 25MHz XTAL / OSC | §1.1 클럭 구성에 따라 | |
|
||||
| RXER | (선택) 미사용 가능 | RMII 에서 생략 가능 |
|
||||
|
||||
> RMII 는 RXD2/RXD3/TXD2/TXD3/COL/CRS/RX_CLK/TX_CLK 가 **없다**(MII 대비 핀 절감). 위 9개 + MDC/MDIO 가 전부다.
|
||||
|
||||
### 1.1 RMII 50MHz REF_CLK 소싱 (중요)
|
||||
|
||||
RMII 는 MAC 과 PHY 가 **공통 50MHz REF_CLK** 를 공유해야 한다. PA1(`ETH_REF_CLK`)은 MCU **입력**이며,
|
||||
이 50MHz 를 어디서 만들지가 보드 설계의 핵심이다. 두 가지 표준 구성:
|
||||
|
||||
1. **PHY 가 50MHz 생성 → MCU 로 공급 (권장, 디스커버리 보드 방식)**
|
||||
- LAN8720 에 25MHz 크리스털을 달고, PHY 내부에서 50MHz 를 만들어 `REFCLKO` → MCU `PA1` 으로 공급.
|
||||
- 보드에 따라 MCU `MCO1(PA8)` 로 25MHz 를 PHY 에 주고 PHY 가 50MHz 를 되돌리는 변형도 있음.
|
||||
2. **외부 50MHz 오실레이터 → MCU PA1 + PHY 동시 공급**
|
||||
- 50MHz 캔 오실레이터 1개로 두 칩에 동시 분배. 가장 단순하고 안정적.
|
||||
|
||||
> `TODO(hw):` 보드 설계에 맞춰 1) 또는 2) 중 하나를 확정하고 BOM/스템핑을 결정한다.
|
||||
> 펌웨어 측 RCC `RMII` 클럭 선택(`SYSCFG->PMC.MII_RMII_SEL=RMII`)은 [`common/net.c`](../common/net.c)/`ethernetif.c`
|
||||
> 초기화에서 처리한다(코드 기본값 = RMII).
|
||||
|
||||
---
|
||||
|
||||
## 2. SHT30 온습도 센서 (`-DBOARD_SHT30`, sensor_id=2)
|
||||
|
||||
Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py` 와 동일 규약.
|
||||
|
||||
### 2.1 I2C 배선
|
||||
|
||||
| STM32 핀 | 신호 | AF | I2C | 비고 |
|
||||
|----------|------|----|-----|------|
|
||||
| **PB6** | `I2C1_SCL` | AF4 | I2C1 | 오픈드레인, 외부 풀업 필요 |
|
||||
| **PB7** | `I2C1_SDA` | AF4 | I2C1 | 오픈드레인, 외부 풀업 필요 |
|
||||
|
||||
- 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)).
|
||||
운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고).
|
||||
|
||||
```
|
||||
SHT30 모듈 STM32
|
||||
┌────────┐
|
||||
│ VDD ──┼────────────── 3.3V ──┬──[4.7kΩ]──┐ ┌──[4.7kΩ]── 3.3V
|
||||
│ SCL ──┼────────────── PB6 ───┘ │ │
|
||||
│ SDA ──┼────────────── PB7 ────────────────┼───┘
|
||||
│ GND ──┼────────────── GND │
|
||||
│ ADDR ─┼── GND (주소 0x44; VDD 면 0x45) │
|
||||
└────────┘
|
||||
```
|
||||
|
||||
> `TODO(hw):` ADDR 핀 결선으로 0x44(ADDR→GND) / 0x45(ADDR→VDD) 가 결정된다. 본 설계는 **0x44** 고정.
|
||||
|
||||
### 2.2 SHT30 보드 핀 요약
|
||||
|
||||
| 핀 | 용도 |
|
||||
|----|------|
|
||||
| PB6 / PB7 | I2C1 SCL / SDA (0x44) |
|
||||
| (RMII 9핀) | §1 표 그대로 — **단 PB11/PB12/PB13 가 RMII 에 쓰임에 주의** |
|
||||
| PD8/PD9 | 로그 UART (USART3, §0 방안 A) |
|
||||
| 상태 LED | §3 |
|
||||
|
||||
> 주의: I2C1 은 PB6/PB7 를 쓰고 RMII 는 PB11/PB12/PB13 을 쓴다 — **PB 포트 내부에서 충돌 없음**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 공통 주변장치 — 상태 LED, 클럭원, 워치독/RTC, 전원
|
||||
|
||||
### 3.1 상태 LED ([`common/bsp.h`](../common/bsp.h) `bsp_led_*`)
|
||||
|
||||
| 핀 | 용도 | 비고 |
|
||||
|----|------|------|
|
||||
| **PD12** | 상태 LED (녹색) | `TODO(hw):` 디스커버리 보드 온보드 LED(PD12~PD15) 사용 가정. 커스텀 PCB 면 임의 GPIO 1개로 변경 |
|
||||
|
||||
권장 점멸 패턴(운영 가시성): 부팅=점등 / 정상 보고=느린 토글 / 망 단절·TLS 실패=빠른 점멸 / `bsp_fatal()`=고속 점멸.
|
||||
|
||||
### 3.2 시스템 클럭 / 오실레이터
|
||||
|
||||
| 항목 | 값 | 비고 |
|
||||
|------|----|------|
|
||||
| HSE | **8MHz 크리스털** | `SystemClock_Config()` 가 PLL 로 168MHz 생성([`common/bsp.h`](../common/bsp.h) 주석) |
|
||||
| SYSCLK | 168MHz | Cortex-M4F |
|
||||
| RMII REF_CLK | 50MHz | §1.1 (HSE 와 별개 경로) |
|
||||
|
||||
> `TODO(hw):` 보드에 HSE 8MHz 크리스털 + 부하 커패시터 실장 확인. 없으면 `SystemClock_Config` 의 HSE 값 정정 필요.
|
||||
|
||||
### 3.3 RTC 클럭원 (SNTP → RTC 시간 보존)
|
||||
|
||||
| 항목 | 권장 | 비고 |
|
||||
|------|------|------|
|
||||
| RTC 클럭원 | **LSE 32.768kHz 크리스털** | TLS 인증서 유효기간 검증 + `timestamp` 필드용. 정전 시 VBAT 로 시간 보존 |
|
||||
| 대안 | LSI(~32kHz, 내장) | 정밀도 낮음. SNTP 로 주기 보정하면 허용. **VBAT 백업 불가** |
|
||||
| VBAT | 코인셀(CR2032) 또는 VBAT→3.3V | LSE + 백업 도메인 유지용. 미실장 시 매 부팅 SNTP 재동기 필요 |
|
||||
|
||||
> `TODO(hw):` LSE 크리스털 + VBAT 백업 실장 여부 확정. 미실장이면 [`common/timesync.c`](../common/timesync.c) 는 매 부팅 SNTP 동기에 의존(이식 계획 R4: SNTP 실패 시 경보).
|
||||
|
||||
### 3.4 워치독 (IWDG)
|
||||
|
||||
| 항목 | 값 | 비고 |
|
||||
|------|----|------|
|
||||
| IWDG 클럭원 | **LSI (~32kHz, 내장)** | IWDG 는 항상 LSI 구동(별도 핀 없음) |
|
||||
| 타임아웃 | `APP_WATCHDOG_TIMEOUT_MS = 20000` (20s) | 이 시간 내 `watchdog_refresh()` 없으면 MCU 리셋([`common/watchdog.h`](../common/watchdog.h)) |
|
||||
| BOR | 활성 권장 | 브라운아웃 리셋(이식 계획 Phase 7) — `TODO(hw):` 옵션 바이트로 BOR 레벨 설정 |
|
||||
|
||||
### 3.5 전원
|
||||
|
||||
| 레일 | 용도 | 비고 |
|
||||
|------|------|------|
|
||||
| 3.3V | MCU VDD/VDDA, LAN8720, SHT30, 풀업 | LAN8720 RMII I/O 는 3.3V. PHY 전류 여유(~수십 mA) 확보 |
|
||||
| VDDA | ADC/PLL 기준 | 0.1µF + 1µF 디커플링, 페라이트 비드 권장 |
|
||||
| VBAT | RTC 백업(§3.3) | 코인셀/점퍼 |
|
||||
| 디커플링 | 각 VDD 핀당 0.1µF + 벌크 4.7µF | 표준 STM32 권장 |
|
||||
|
||||
> `TODO(hw):` PoE/외부 12V→3.3V 등 실제 급전 방식은 설치 환경에 따라 확정. RPi 가 쓰던 5V USB 어댑터 재사용 가능(5V→3.3V 레귤레이터 추가).
|
||||
|
||||
---
|
||||
|
||||
## 4. 최종 권장 핀맵 (전체 통합)
|
||||
|
||||
> §0 방안 A(로그 UART = USART3 PD8/PD9) 채택을 가정한 **최종 권장** 핀맵. 충돌 해소 완료 상태.
|
||||
|
||||
| STM32 핀 | SHT30 보드 | 기능 | AF | 충돌 여부 |
|
||||
|----------|------------|------|----|-----------|
|
||||
| PA1 | ✔ | ETH_REF_CLK (RMII 50MHz in) | AF11 | OK |
|
||||
| PA2 | ✔ | **ETH_MDIO** (USART2 금지) | AF11 | **§0 해소** |
|
||||
| PA7 | ✔ | ETH_CRS_DV | AF11 | OK |
|
||||
| PB6 | ✔ | I2C1_SCL | AF4 | OK |
|
||||
| PB7 | ✔ | I2C1_SDA | AF4 | OK |
|
||||
| PB11 | ✔ | ETH_TX_EN | AF11 | OK |
|
||||
| PB12 | ✔ | ETH_TXD0 | AF11 | OK |
|
||||
| PB13 | ✔ | ETH_TXD1 | AF11 | OK |
|
||||
| PC1 | ✔ | ETH_MDC | AF11 | OK |
|
||||
| PC4 | ✔ | ETH_RXD0 | AF11 | OK |
|
||||
| PC5 | ✔ | ETH_RXD1 | AF11 | OK |
|
||||
| PD8 | ✔ | USART3_TX (로그) | AF7 | OK (방안 A) |
|
||||
| PD9 | ✔ | USART3_RX (로그) | AF7 | OK (방안 A) |
|
||||
| PD12 | ✔ | 상태 LED | — | OK |
|
||||
| OSC_IN/OUT | ✔ | HSE 8MHz | — | OK |
|
||||
| PC14/PC15 | ✔ | LSE 32.768kHz (RTC, 선택) | — | §3.3 |
|
||||
| VBAT | ✔ | RTC 백업 전원(선택) | — | §3.3 |
|
||||
|
||||
**미사용/주의:** PA2 는 **절대 USART2_TX 로 설정하지 말 것**(이더넷 MDIO 전용). PA3 도 USART2_RX 로 쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 핀 충돌 점검표 (요약)
|
||||
|
||||
| 잠재 충돌 | 상태 | 해소 |
|
||||
|-----------|------|------|
|
||||
| **PA2: USART2_TX ↔ ETH_MDIO** | ✅ 해소(코드) | 로그 UART 를 USART3(PD8/PD9)로 이동(`applog.c`). PA2=ETH_MDIO 고정 |
|
||||
| USART3 PB10/PB11 ↔ ETH_TX_EN(PB11) | 회피됨 | USART3 를 PD8/PD9 핀쌍으로 사용 |
|
||||
| USART3 PC10/PC11 ↔ ETH RXD(PC4/PC5) | 회피됨 | 동일(PD8/PD9 사용) |
|
||||
| 로그 UART(PD8/PD9) ↔ 상태 LED(PD12) | 없음 | 같은 D 포트, 다른 핀 |
|
||||
| I2C1(PB6/PB7) ↔ RMII(PB11~13) | 없음 | PB 포트 내 핀 분리 |
|
||||
| RMII 클럭 게이팅(`bsp.c`) | 무해 | `bsp.c` 가 공통 클럭으로 GPIOA/B/C/D 를 켬. ETH MspInit 도 GPIOA/B/C 를 자체 enable(중복 무해) |
|
||||
|
||||
> 결론: 코드 기준으로 **남은 핀 충돌은 없다.** PA2 충돌은 로그 UART 를 USART3 PD8/PD9 로 옮겨 해소했고
|
||||
> (코드 반영 완료), 나머지는 모두 포트 내 핀 분리로 충돌이 없다. 남은 항목은 §0의 외부 USB-UART 어댑터 배선
|
||||
> 과 §3 의 클럭원/전원 `TODO(hw)` 뿐이다.
|
||||