Compare commits

...

8 commits

Author SHA1 Message Date
cw2007
de51d1c3d4 fix(security): Pi 노드 하드닝 6종 medium 갭 보완 + 회귀 수정
적대적 보안 감사에서 확정된 medium 6건 보완 (critical/high 0):
- systemd 유닛(양 노드): NoNewPrivileges + ProtectSystem=strict/
  ProtectHome=read-only/ProtectKernel*/ProtectControlGroups/RestrictSUIDSGID/
  LockPersonality/RestrictRealtime/RestrictAddressFamilies(+AF_NETLINK) 샌드박싱.
- harden_pi.sh: SSH 차단을 마지막 단계([6/6])로 재정렬 — 앞 단계 실패 시 set -e 로
  SSH 가 살아있는 상태에서 중단(원격 복구 가능). nftables·unattended-upgrades 를
  오프라인(debs/ --no-download)으로도 설치 지원(폐쇄망 harden). 미발견 secret 경고.
- install.sh(양 노드): config_local.py 설치 즉시 chmod 600 (harden 전 노출창 제거).
- vendor.sh(양 노드): .deb 를 파일명이 아닌 dpkg-deb Architecture 필드로 실검사
  (arm64/all 만 허용, 그 외 hard-fail). nftables·unattended-upgrades 를 번들에 포함.

검증(3-리뷰어 워크플로)이 잡은 회귀 동반 수정:
- [critical] ProtectHome=read-only 가 lgpio 의 작업디렉터리(.lgd-nfy 파이프) 쓰기를
  막아 add_event_detect(누수 인터럽트 등록) 실패 → 데몬 크래시루프 → 감지 불능.
  Environment=LG_WD=/tmp 로 PrivateTmp 쓰기가능 /tmp 이동(누수 노드 한정, SHT30 무관).
- [medium] 오프라인 설치에 --no-install-recommends 추가(vendor --no-recommends 정합,
  네트워크 폴백 방지) — install.sh x2 + harden_pi.sh.
- SupplementaryGroups=video gpio 명시 · nft|head 에 ||true(SIGPIPE 중단 방지) ·
  online harden 분기에 apt-get update.

※ 온디바이스 런타임 테스트 1회 필요(빌드머신 Windows 에선 systemd 실행 불가):
  실 Pi 에서 systemctl start 후 add_event_detect 콜백·카메라 촬영·업로드 정상 확인.
2026-07-03 09:28:44 +09:00
cw2007
96c5205f61 feat(deploy): 센서별 배선도·핀맵 HTML + 번들 생성기 반영
- raspberry_pi4/WIRING.html, raspberry_pi4_sht30/WIRING.html 추가:
  자체 완결형(인라인 CSS/SVG, 외부참조 0 → 폐쇄망 오프라인 열람) 40핀 핀맵 도해
  + 핀맵 표 + 배선 스키매틱 + 전기설계 경고 + 설치 체크리스트. 인쇄 대응.
  누수 노드(Pi-A/Pi-C 공통, GPIO17)·SHT30 노드(I2C1, 0x44) 각 1장.
- make_deploy_bundles.py: TEXT_EXTS 에 .html 추가(번들 복사 시 CRLF→LF 정규화),
  Pi SETUP.md 에 WIRING.html 안내 줄 추가. copy_tree 가 각 Pi 번들에 자동 포함.
  (동반: 기존 dotfile .htaccess/.gitignore LF 정규화 반영.)
2026-07-03 09:28:16 +09:00
cw2007
59d952953d chore: 실수로 커밋된 tools/__pycache__ 제거 + __pycache__ gitignore
git add tools/ 가 컴파일 .pyc(문자열 상수에 도메인 포함)까지 스테이징한 것을 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:56:33 +09:00
cw2007
fb3630d868 docs: 문서 현행화(leak_ 접두사·경로 스크럽) + 코드 투어 + settings deny 수정
- 전 운영 문서 테이블명 leak_ 접두사 반영, 삭제된 마이그레이션 참조 정리,
  구경로 raspi_leck_detecter→<WEB_DIR> 스크럽(DEPLOYMENT_PLAN).
- docs/DEPLOYMENT_PLAN_RPI4x3.md(신규): 3대 Pi + 신규 서버 배포 로드맵.
- .tours/architect-full-system.tour(신규): 19스텝 전체 아키텍처 투어.
- README.md(루트, 0바이트였음): 시스템 인덱스.
- .claude/settings.local.json: /doctor 지적 deny 규칙 5건 수정
  (파이프 4건 와일드카드형, 포크밤 삭제) + 죽은 allow 항목 3건 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:52:15 +09:00
cw2007
7a3b571b05 feat(deploy): 기기별 턴키 배포 번들 + Pi OS 하드닝 + 원샷 설치
tools/make_deploy_bundles.py: deploy/ 아래 서버1+Pi3대 폴더 생성 —
API_KEY 1개 자동 생성·4곳 동일 주입, 관리자 bcrypt(비번 stdin 전달), LF 정규화,
단일 schema.sql, var/ 런타임 상태 제외, CRON_SECRET 사전 계산. 난독화 경로
WEB_DIR 은 소스가 아닌 deploy/.secrets/web_dir.txt 에서 로드(커밋 트리 유출 차단).
시크릿 생성 전에 deploy/.gitignore 선작성 + 루트 .gitignore 에 deploy/ 이중 차단.

tools/harden_pi.sh: SSH 완전 비활성(outbound 전용 설계) · 인바운드 전부 차단
(nftables) · 불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치
(unattended-upgrades, 자동 리부팅 끔) · config_local.py 권한 600 · 검증 증적 출력.

setup.sh(번들 생성): 설치→서비스→검증→하드닝 원샷. 검증 실패 시 set -e 로
하드닝(SSH 차단) 전에 중단 → SSH 유지되어 디버깅 가능.

검색 비노출 하드닝: php/.htaccess X-Robots-Tag noindex + Referrer-Policy
no-referrer, api/photo_upload.php CORS 제거, test_mobile.php 세션 인증 가드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:46 +09:00
cw2007
12b0c74806 feat: sensor_id 3 신규 누수 지점 노드 지원
3번째 Pi(신규 지점, 누수+카메라)를 코드 수정 없이 배포 가능하게:
- raspberry_pi4/config.py: config_local.py 에서 SENSORS 오버라이드 허용
  + fail-closed 형식 검증(id 1..255/gpio/name, 중복 금지) — 오설정으로
  기본 sensor_id 1 로 조용히 기동해 타 지점 상태를 덮어쓰는 사고 방지.
- REPORT_INTERVAL 3600→600: 서버 HEARTBEAT_TIMEOUT_SEC(1200) 절반 —
  기존 하트비트 오탐(매시간 오프라인/복귀 SMS) 해소.
- php/config.php LEAK_SENSOR_IDS 상수(기본 [1], 1..255 정규화·빈값 복귀).
- php/dashboard.php: 누수 카드 sensor_id===1 하드코딩 → LEAK_SENSOR_IDS 루프.
- php/api/sensor_data.php: periodic 보고 전면 skip_log(누수 노드 증가 시
  이벤트 로그 창 보존) + CORS 헤더 제거(서버간 POST 전용, 도메인 지문 노출 차단).
- test_connection.py(양 모듈): 하드코딩 sensor_id 제거→기기 SENSORS 기반,
  서명 200 실패 시 exit 1(setup.sh 원샷 게이트용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:18 +09:00
cw2007
9fef7031fc refactor(db)!: DB 테이블 전체 leak_ 접두사 통일 + 구 마이그레이션 폐기
같은 cafe24 DB의 다른 시스템 테이블과 혼동/충돌을 막기 위해 5개 테이블을
leak_ 네임스페이스로 격리:
  sensor_log→leak_sensor_log, sensor_status→leak_sensor_status,
  sensor_metric→leak_sensor_metric, sms_log→leak_sms_log,
  ingest_nonce→leak_ingest_nonce (leak_incident/leak_photo는 이미 접두사).
컬럼명은 불변(sensor_id, is_leak 등) — 테이블명만 변경.

sql/migration_add_leak.sql, migration_drop_leak.sql 삭제: 리네임 후 구버전
무접두 테이블 대상이라 실행 즉시 에러. 신규 설치는 schema_sht30.sql 하나로 완결.

BREAKING CHANGE: 기존 무접두 테이블 배포와 호환 안 됨(신규 설치 전용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:50:56 +09:00
cw2007
c8b448c225 chore: 온습도 임계 예시 갱신 (고온 27℃·고습 60%, 저습·결로 경보 비활성)
- METRIC_TEMP_HIGH_C 30→27
- METRIC_RH_HIGH 70→60
- METRIC_RH_LOW 20→-1 (저습 경보 OFF; RH 항상 ≥0 라 미발화)
- METRIC_DEWPOINT_MARGIN_C 추가 0 (결로 경보 OFF; >0 분기 스킵)
- 기본값 주석에서 결로 항목 제거
2026-06-24 16:07:40 +09:00
60 changed files with 2443 additions and 531 deletions

View file

@ -1,10 +1,7 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(tail:*)", "Bash(tail:*)"
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && pyinstaller --onefile --noconsole --name sensor_checker sensor_checker.py 2>&1)",
"Bash(cd \"C:/Users/Posafe/leak_sms_v2/windows_checker\" && python sensor_checker.py 2>&1)",
"Bash(sort -k2)"
], ],
"deny": [ "deny": [
"Bash(rm -rf:*)", "Bash(rm -rf:*)",
@ -14,13 +11,12 @@
"Bash(chmod 777:*)", "Bash(chmod 777:*)",
"Bash(dd:*)", "Bash(dd:*)",
"Bash(mkfs:*)", "Bash(mkfs:*)",
"Bash(:(){ :|:& };:)",
"Bash(git push --force:*)", "Bash(git push --force:*)",
"Bash(git push -f:*)", "Bash(git push -f:*)",
"Bash(curl:* | sh)", "Bash(curl* | sh)",
"Bash(curl:* | bash)", "Bash(curl* | bash)",
"Bash(wget:* | sh)", "Bash(wget* | sh)",
"Bash(wget:* | bash)", "Bash(wget* | bash)",
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(scp:*)", "Bash(scp:*)",
"Read(./**/config.local.php)", "Read(./**/config.local.php)",

8
.gitignore vendored
View file

@ -12,6 +12,10 @@ firmware/**/secrets.h
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개 # ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
_backup/ _backup/
# ── 배포 번들: 라이브 API 키·관리자 비밀번호·난독화 경로 포함 (생성기가 내부
# .gitignore 도 만들지만, 생성 실패 시에도 새지 않도록 루트에서 이중 차단) ──
deploy/
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ──── # ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
php/var/* php/var/*
!php/var/.gitkeep !php/var/.gitkeep
@ -30,3 +34,7 @@ firmware/build/
# ── OS 잡파일 ───────────────────────────────────────────────────────────── # ── OS 잡파일 ─────────────────────────────────────────────────────────────
Thumbs.db Thumbs.db
.DS_Store .DS_Store
# ── 파이썬 캐시 ──
__pycache__/
*.pyc

View file

@ -0,0 +1,119 @@
{
"$schema": "https://aka.ms/codetour-schema",
"title": "전체 시스템 아키텍처 투어 — 누수+온습도 모니터링 (RPi4×3 → cafe24)",
"description": "센서 노드(RPi4) → HMAC 서명 HTTPS 수집 → MySQL(leak_ 접두사 테이블) → SMS 경보/대시보드까지 전체 데이터 경로를 따라가는 아키텍처 투어. v2607 기준 (테이블 leak_ 접두사, sensor_id 3 신규 지점, 턴키 배포 번들).",
"ref": "feat/pi-c-sensor3-leak-node",
"steps": [
{
"file": "SYSTEM_OVERVIEW.md",
"line": 1,
"title": "1. 전체 그림",
"description": "**상황**: 2종 센서 모듈(누수+카메라 / SHT30 온습도)이 단일 cafe24 PHP 서버로 보고하는 통합 시스템. **구조**: 모든 모듈이 동일한 raw-body HMAC-SHA256 X-Signature 스킴 사용. **왜 중요**: 이 문서가 모듈↔서버 계약(서명·sensor_id·엔드포인트)의 단일 출처. **주의**: v2607부터 DB 테이블 전체가 leak_ 접두사 — 같은 DB의 타 시스템 테이블과 격리."
},
{
"directory": "raspberry_pi4",
"title": "2. 모듈 1 — 누수 + 카메라 (sensor_id 1, 3)",
"description": "**상황**: e-con 5C-08 저항형 누수센서(GPIO17, 외부 4.7MΩ 풀다운) + Pi Camera. **구조**: leak_sensor.py 단일 데몬 + config.py(비밀 아님) + config_local.py(비밀, 미커밋). **왜 중요**: Pi-A(sensor 1)와 Pi-C(sensor 3, 신규 지점)가 같은 코드 폴더를 씀 — 기기별 차이는 전부 config_local.py로."
},
{
"file": "raspberry_pi4/config.py",
"line": 13,
"title": "3. 센서 정의 + 보고 주기",
"description": "**상황**: SENSORS 리스트가 이 기기의 sensor_id/GPIO를 정의. **구조**: REPORT_INTERVAL=600초 — 서버 HEARTBEAT_TIMEOUT_SEC(1200초)의 절반이라 2회 누락 시 오프라인 판정. **주의**: 예전 3600초는 매시간 오프라인 오탐을 냈던 버그 — 주기를 늘릴 땐 반드시 서버 타임아웃과 함께 판단."
},
{
"file": "raspberry_pi4/config.py",
"line": 92,
"title": "4. 기기별 오버라이드 (Pi-C 핵심)",
"description": "**상황**: config_local.py에 SENSORS가 있으면 기본값을 덮어씀. **구조**: 2단 import — 필수 비밀값은 fail-closed(없으면 기동 거부), SENSORS는 선택. **왜 중요**: Pi-C(sensor_id 3)는 추적 파일(config.py)을 건드리지 않고 배포 가능 — 기기별 drift 방지. **주의**: sensor_id 1/2 재사용 시 기존 지점 상태를 조용히 덮어씀(leak_sensor_status는 sensor_id 단독 키)."
},
{
"file": "raspberry_pi4/leak_sensor.py",
"line": 194,
"title": "5. 클라이언트 서명 — raw-body HMAC",
"description": "**상황**: 요청 본문 바이트에 HMAC-SHA256(API_KEY)을 계산해 X-Signature 헤더로 전송. **구조**: 본문을 고정 필드 순서로 직렬화한 뒤 그 바이트 그대로 서명·전송 — 서버도 같은 바이트로 검증. **주의**: 구버전 sha256(API_KEY+정렬JSON) 스킴은 폐기 — 서버가 기본 거부(ALLOW_LEGACY_BODY_SIGNATURE=false)."
},
{
"file": "raspberry_pi4/leak_sensor.py",
"line": 451,
"title": "6. 보고 상태머신",
"description": "**상황**: 메인 루프의 주기 선택 — 정상 600초, 누수 중 10초(LEAK_REPORT_INTERVAL). **구조**: 누수 감지 → leak_alert 즉시 보고 + 사진 촬영/업로드 → 지속 재보고 → 복구 시 leak_recovery. **왜 중요**: SMS 중복 방지는 클라이언트가 아니라 서버 쿨다운이 담당 — 클라이언트는 상태만 계속 밀어넣는 단순 설계."
},
{
"file": "raspberry_pi4_sht30/sht30_sensor.py",
"line": 179,
"title": "7. 모듈 2 — SHT30 온습도 (sensor_id 2)",
"description": "**상황**: I2C(0x44) SHT30을 300초 주기로 측정·보고하는 메인 루프. **구조**: 측정 → CRC 검증 → 같은 HMAC 서명 → 같은 엔드포인트. 임계 판정은 전부 서버 몫. **주의**: smbus2 부재/I2C 실패 시 시뮬레이션 값으로 폴백(WARN 로그만) — 배포 검증 때 journalctl에서 시뮬레이션 경고 부재를 반드시 확인."
},
{
"file": "php/api/sensor_data.php",
"line": 73,
"title": "8. 수집 경계 — fail-closed 인증",
"description": "**상황**: 모든 센서 보고가 들어오는 단일 진입점. **구조**: API_KEY가 기본값이면 503 거부 → 크기 상한 → 타임스탬프 ±600초 → raw-body HMAC 검증 순서. **왜 중요**: 여기가 시스템의 유일한 신뢰 경계 — 이 파일 통과 = DB 기록 + SMS 발송 권한."
},
{
"file": "php/api/sensor_data.php",
"line": 112,
"title": "9. 재전송(replay) 멱등 방어",
"description": "**상황**: 서명 자체를 nonce로 leak_ingest_nonce에 기록. **구조**: 같은 서명 재수신 = 중복 → 재저장/재발송 생략. 시계오차 창 지나면 자동 정리. **주의**: 테이블 없으면 조용히 비활성(graceful degrade) — setup_wizard가 이 테이블 누락을 검사하지 않으므로 신규 설치 후 7개 테이블 존재를 직접 확인."
},
{
"file": "php/api/sensor_data.php",
"line": 371,
"title": "10. 누수 사고(incident) 추적",
"description": "**상황**: leak_alert 수신 시 leak_incident 에피소드 개시, leak_recovery 시 종료. **구조**: sensor_id 단위로 열린 사고를 찾아 갱신 — sensor_id 3도 코드 수정 없이 동작(자동 등록 upsert). **왜 중요**: 사고 단위 추적이라 SMS·사진·대시보드 이력이 에피소드로 묶임."
},
{
"file": "php/api/photo_upload.php",
"line": 221,
"title": "11. 사진 업로드 검증 체인",
"description": "**상황**: 누수 사진 전용 업로드 엔드포인트. **구조**: canonical 문자열(device_id|sensor_id|log_id|timestamp) HMAC 서명 → log_id 실존 확인 → finfo MIME(image/jpeg만) → 크기 상한 → 서버 생성 무작위 파일명. **주의**: 파일명을 클라이언트가 정하지 않음 — 경로 조작 원천 차단. 저장 위치 php/var/photos/는 웹에서 403."
},
{
"file": "php/sms_send.php",
"line": 546,
"title": "12. SMS 경보 상태머신",
"description": "**상황**: 누수 SMS 발송 함수 — 쿨다운(기본 30분) 적용, 복구 SMS는 경보 후에만. **구조**: 별도 상태 테이블 없이 leak_sms_log의 메시지 접두 태그([누수감지] 등)가 사실상 상태 키 — 태그는 config.php SMS_TAG_* 상수가 단일 출처. **주의**: 태그 문자열을 직접 쓰면 여러 파일의 LIKE 검색이 어긋나 무성 장애 — 반드시 상수 참조."
},
{
"file": "php/cron_heartbeat.php",
"line": 22,
"title": "13. 오프라인 감지 (cron 1분)",
"description": "**상황**: last_seen이 HEARTBEAT_TIMEOUT_SEC(1200초) 초과한 센서를 찾아 오프라인 SMS. **구조**: offline_alerted 0→1 원자적 선점(UPDATE ... WHERE offline_alerted=0)으로 다중 진입점 동시 실행에도 중복 발송 방지. **주의**: 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다 길어야 오탐 없음."
},
{
"file": "php/config.php",
"line": 217,
"title": "14. LEAK_SENSOR_IDS — 지점 확장점",
"description": "**상황**: 대시보드 누수 카드 표시 대상 sensor_id 목록 (기본 [1]). **구조**: config.local.php에서 [1,3] 오버라이드 — 1..255 정수만 통과, 잘못된 값은 버리고 비면 [1] 복귀. **왜 중요**: 신규 누수 지점 추가 시 서버 코드 수정 없이 이 설정 하나로 대시보드 노출."
},
{
"file": "php/dashboard.php",
"line": 700,
"title": "15. 대시보드 — 누수 카드 렌더",
"description": "**상황**: LEAK_SENSOR_IDS 루프로 지점별 누수 카드 렌더 (v2607 이전엔 sensor_id 1 하드코딩). **구조**: 우선순위 오프라인 > 누수 > 정상, 미수신이면 '데이터 없음' 카드. **주의**: 온습도 차트는 여전히 sensor 2 고정 — SHT30 지점을 추가하면 차트 선택 UI 작업 필요(누수 지점은 무관)."
},
{
"file": "php/login.php",
"line": 110,
"title": "16. 관리자 인증 — 비밀번호 + TOTP",
"description": "**상황**: bcrypt 해시 검증 + TOTP MFA. **구조**: ADMIN_TOTP_SECRET 미설정이면 로그인 자체가 불가(fail-closed) — setup_mfa.php?token= 으로 최초 1회 등록 후 MFA_SETUP_TOKEN을 반드시 비움. **주의**: 세션 쿠키 Secure라 HTTPS 없이는 로그인 불가 — cafe24 패널에서 HTTPS 강제가 선행 조건."
},
{
"file": "sql/schema_sht30.sql",
"line": 24,
"title": "17. 스키마 — 단일 파일, leak_ 네임스페이스",
"description": "**상황**: 신규 설치용 완전 스키마 — 테이블 7개(leak_sensor_log/status/metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo) 전부 이 파일 하나. **왜 중요**: v2607에서 구버전 마이그레이션 파일 폐기 — 이 파일이 유일한 DB 설치 경로. **주의**: 컬럼명은 접두사 없음(sensor_id, is_leak 등) — 테이블명만 leak_."
},
{
"file": "tools/make_deploy_bundles.py",
"line": 29,
"title": "18. 턴키 배포 번들 생성기",
"description": "**상황**: deploy/ 아래 서버 1 + Pi 3대 폴더를 생성 — API_KEY 자동 생성·4곳 동일 주입, 관리자 bcrypt, LF 정규화. **구조**: WEB_DIR/BASE_URL만 고치고 재실행하면 전체 재생성(.secrets/ 비밀 보존). **주의**: deploy/는 비밀 포함이라 gitignore — 절대 커밋 금지. 남는 수동 설정은 DB 계정·SMS 계정·TOTP 등록 3가지뿐."
},
{
"title": "다음 단계",
"description": "이제 센서 GPIO 인터럽트부터 SMS 발송까지 전체 경로를 파일 단위로 추적할 수 있다. 배포 절차는 docs/DEPLOYMENT_PLAN_RPI4x3.md, 기기별 설치는 deploy/*/SETUP.md, 보안 증적은 docs/SOURCE_SECURITY_EVIDENCE.md 참고. 신규 지점 추가 = Pi 폴더 복사 + config_local.py(SENSORS id) + 서버 LEAK_SENSOR_IDS 갱신 — 코드 수정 불필요."
}
]
}

View file

@ -0,0 +1,23 @@
# 서버실 통합 모니터링 시스템 (누수 + 온습도)
Raspberry Pi 4 센서 모듈 2대 — **1번: 누수 감지 + 카메라**, **2번: SHT30 온습도** — 와
선택적 **3번째 노드**(신규 지점, sensor_id 3)가 **동일한 cafe24 PHP 서버**로 HTTPS 보고
(raw-body 서명 `X-Signature = HMAC-SHA256(API_KEY, 요청본문)`)를 하고, 서버가 MySQL 저장,
임계/누수 판정, SMS 발송, 통합 대시보드(`dashboard.php`) 표시를 담당하는 단일 시스템입니다.
## 모듈 구성
| 폴더 | 역할 | sensor_id |
|---|---|---|
| `raspberry_pi4/` | 누수 감지(e-con 5C-08) + Pi Camera 촬영·사진 업로드 | 1 (신규 지점 노드는 3) |
| `raspberry_pi4_sht30/` | SHT30 온습도 측정(I2C) | 2 |
| `php/` | cafe24 서버 — API 수신, DB 저장, 판정/SMS, 대시보드 | — |
| `sql/` | DB 스키마(`schema_sht30.sql` = 신규 설치 완결) + 마이그레이션 | — |
| `firmware/` | STM32F407 마이그레이션 트랙(별도) — **RPi4 배포와 무관** | — |
## 문서
- [SYSTEM_OVERVIEW.md](./SYSTEM_OVERVIEW.md) — 전체 아키텍처·모듈 매핑·서명 스킴 (기준 문서)
- [docs/DEPLOYMENT_PLAN_RPI4x3.md](./docs/DEPLOYMENT_PLAN_RPI4x3.md) — RPi4 × 3 + cafe24 신규 설치 배포 계획
- [docs/INSTALL_PI_SERVER.md](./docs/INSTALL_PI_SERVER.md) — cafe24 서버 설치 절차 (STM32 펌웨어 중심)
- [docs/OPERATIONS_SECURITY_CHECKLIST.md](./docs/OPERATIONS_SECURITY_CHECKLIST.md) — 운영·보안 체크리스트

View file

@ -25,7 +25,7 @@
| 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 | | 구분 | 폴더 | 보드 | 센서 | sensor_id | 서버 보고 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` | | **1번 모듈** | `raspberry_pi4/` | Raspberry Pi 4 | e-con 5C-08 누수 + Pi Camera | 1 | `leak_alert`/`leak_recovery`/`periodic` + `photo_upload.php` |
| **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`sensor_metric`) | | **2번 모듈** | `raspberry_pi4_sht30/` | Raspberry Pi 4 | SHT30 온습도 (I2C) | 2 | `startup`/`periodic` + 온도/습도 (`leak_sensor_metric`) |
두 모듈 모두: 두 모듈 모두:
- 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움. - 비밀값은 `config_local.py`(미커밋)에만 — `cp config_local.example.py config_local.py` 후 채움.
@ -36,8 +36,8 @@
## 서버 (cafe24 `php/`) ## 서버 (cafe24 `php/`)
- 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고. - 웹 루트(예): `public_html/raspi_leck_detecter/` — `docs/INSTALL_PI_SERVER.md` 참고.
- DB 스키마: `sql/schema_sht30.sql` (신규) 또는 `sql/migration_drop_leak.sql` (전환). - DB 스키마: `sql/schema_sht30.sql` — 신규 설치는 이 파일 하나로 완결(테이블 전체 `leak_` 접두사).
- 대시보드: `dashboard.php` — `sensor_status`/`sensor_metric`/`sms_log` 표시, 기본 차트 sensor 2. - 대시보드: `dashboard.php` — `leak_sensor_status`/`leak_sensor_metric`/`leak_sms_log` 표시, 기본 차트 sensor 2.
- 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`. - 인증: `X-Signature = HMAC-SHA256(API_KEY, raw_body)`. 모든 모듈의 `API_KEY` = 서버 `config.local.php` 의 `API_KEY`.
--- ---
@ -54,21 +54,20 @@
본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다. 본문 JSON 을 고정 필드 순서 문자열로 만들어 그 바이트에 서명하고 그대로 POST 합니다.
(구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.) (구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 폐기 — 서버 기본 설정이 거부.)
사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음). 사진 업로드 서명 canonical = `device_id|sensor_id|log_id|timestamp` (공백 없음).
- **스키마(가산·멱등):** `sensor_log.is_leak` / `sensor_status.is_leak` 컬럼, `event_type` 의 - **스키마(가산·멱등):** `leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `event_type` 의
`leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을 `leak_alert`/`leak_recovery`, `leak_incident`(사고 추적) + `leak_photo`(사진 메타) 테이블을
다시 추가했습니다. `sensor_metric`(SHT30)은 그대로 보존합니다. 다시 추가했습니다. `leak_sensor_metric`(SHT30)은 그대로 보존합니다.
- **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `sensor_log`/`sensor_status` 갱신, - **수신:** `api/sensor_data.php` 가 누수 이벤트를 받아 `leak_sensor_log`/`leak_sensor_status` 갱신,
`leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지. `leak_incident` 개시/종료, `leak_alert` 응답에 `log_id` 반환. SHT30 측정 경로는 100% 유지.
- **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명). - **사진:** 새 `api/photo_upload.php` (서명·신선도·MIME(finfo)·크기 상한·서버 생성 무작위 파일명).
- **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만). - **SMS:** `sms_send.php` 에 `send_leak_alert_sms`(쿨다운) / `send_leak_recovery_sms`(경보 후에만).
### 운영 적용 절차 (기존 SHT30 서버에 누수 통합) ### 운영 적용 절차 (신규 설치)
1. 기존 서버에서 **`sql/migration_add_leak.sql` 1회 실행**(가산·멱등 — 운영 데이터 보존, 재실행 안전). 1. **`sql/schema_sht30.sql` 1회 실행** — 이 파일 하나로 완결(누수 요소 포함, 테이블 전체 `leak_` 접두사).
신규(통합) 설치는 `sql/schema_sht30.sql` 하나만 실행하면 됩니다(이미 누수 요소 포함). (구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다.)
2. 아래 서버 파일을 (재)배포: 2. 아래 서버 파일을 (재)배포:
`api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`, `api/sensor_data.php`, `api/photo_upload.php`, `sms_send.php`, `dashboard.php`, `config.php`.
`sql/migration_add_leak.sql`.
3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도). 3. **쓰기 가능한 `php/var/photos/`** 디렉터리 확보(웹서버 사용자 쓰기 권한; 없으면 업로드 시 자동 생성 시도).
4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로, 4. 1번 모듈 `raspberry_pi4/config_local.py` 의 `API_URL`/`PHOTO_UPLOAD_URL` 을 이 통합 서버로,
`API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정. `API_KEY` 를 서버 `config.local.php` 의 `API_KEY` 와 동일하게 설정.

View file

@ -0,0 +1,279 @@
# 배포 계획: 누수+온습도 통합 모니터링 시스템 (cafe24 서버 1 + Raspberry Pi 4 × 3)
기준 저장소: `C:/Users/USER/Desktop/complete/leak_sms_v3`
확정 조건: RPi4 3대 모두 신품(OS 없음, SD 새로 플래시), 설치 현장 인터넷 가용(오프라인 vendor 번들은 선택이나 재현성 위해 권장), cafe24 서버 신규 설치.
---
## 1. 전체 로드맵
**순서 원칙: 서버 먼저.** 모든 Pi의 `test_connection.py` 검증과 API_KEY 발급이 서버에 의존하므로 서버가 선행되어야 한다 (`raspberry_pi4/config_local.example.py`, `raspberry_pi4_sht30/README.md` — API_KEY는 서버 `config.local.php`와 byte-단위 동일해야 함).
```
[0단계] cafe24 서버 신규 설치 ──────────────► API_KEY 확정, 엔드포인트 URL 확정
│
├─ (병렬 가능) SD 카드 3장 OS 플래시 + 공통 OS 프로비저닝
│ ※ 서버와 의존성 없음 — 서버 작업과 동시 진행 가능
│
▼
[1단계] Pi-A (모듈1: 누수+카메라, sensor_id=1) ┐
[2단계] Pi-B (모듈2: SHT30, sensor_id=2) ├─ 서로 독립, 병렬 설치 가능
[3단계] Pi-C (sensor_id=3, 신규 지점) ┘ (단, Pi-C는 센서 종류 결정 선행 — §6)
│
▼
[4단계] end-to-end 검증 (대시보드 / SMS / 사진 / 오프라인 감지) + 보안 체크리스트 (§7)
```
**병렬 가능 구간:**
- 서버 설치(0단계)와 Pi 3대의 OS 플래시·공통 프로비저닝(§3)은 완전 병렬.
- Pi-A / Pi-B / Pi-C의 모듈 설치는 서로 독립 — 서버의 API_KEY만 확정되면 동시 진행 가능.
- (선택) 오프라인 vendor 번들 생성(`vendor.sh`)은 인터넷 연결된 aarch64 Bookworm Pi 1대에서 선행 — 첫 번째로 부팅한 Pi에서 만들어 나머지에 복사하는 흐름 권장 (`raspberry_pi4/vendor.sh`, `raspberry_pi4_sht30/vendor.sh`의 .deb은 타깃 아키텍처에서만 받을 수 있음).
**Pi-C 블로커:** 센서 종류(누수 / SHT30 / 둘 다) 미결정 → §6의 작업량 비교로 먼저 결정 필요.
---
## 2. 0단계: cafe24 서버 신규 설치
### 2.1 DB 스키마
- [ ] cafe24 phpMyAdmin(또는 MySQL 콘솔)에서 **`sql/schema_sht30.sql` 1회 실행**. 신규 설치는 이 파일 하나로 완결 — leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo 및 leak 컬럼/ENUM 전부 포함 (`sql/schema_sht30.sql:25-167`).
- [ ] 마이그레이션 파일 없음 — v2607 테이블 리네임(`leak_` 접두사)과 함께 폐기. 신규 설치는 스키마 파일 하나로 완결.
### 2.2 파일 업로드
- [ ] `php/` 전체를 웹루트 `public_html/<WEB_DIR>/`에 업로드 (`docs/INSTALL_PI_SERVER.md` §3, `php/.htaccess` 헤더).
- [ ] **주의: `docs/INSTALL_PI_SERVER.md:33-56`의 업로드 목록은 STALE** — `api/photo_upload.php`, `photo.php`, `var/.htaccess`, `var/index.html`, `var/photos/`가 누락되어 있음. 누수 사진 파이프라인에 필수이므로 **php/ 트리 전체**를 기준으로 업로드할 것.
- [ ] `php/var/` 를 PHP 쓰기 가능하게 설정 (heartbeat_last.txt, ingest_rejections.json, login_attempts.json, admin_audit.log 저장). 코드 기준 디렉터리 0750 / 파일 0640 (`php/api/photo_upload.php:303,321`) — cafe24 공유호스팅에서 755/775가 필요할 수 있음(갭, §8). `var/photos/`는 첫 업로드 시 자동 생성 + .htaccess 가드 자동 작성 (`php/api/photo_upload.php:139-153, 301-321`).
- [ ] 웹에서 `/var/` 접근이 403인지 확인 (`php/.htaccess:14` + `php/var/.htaccess`).
### 2.3 config.local.php / API_KEY
- [ ] `php/config.local.example.php` 복사 → `config.local.php` 작성:
- `DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASS` — cafe24 MySQL 자격증명
- `API_KEY` — **긴 랜덤 비밀키 생성.** 기본값 `'change-this-api-key'`이면 ingest가 503으로 거부됨(fail-closed, `php/api/sensor_data.php:73-77`). 이 키가 Pi 3대 전부의 `config_local.py` API_KEY와 byte-단위 동일해야 함.
- `ALLOW_LEGACY_BODY_SIGNATURE` — **false 유지** (`php/config.php:60`, `php/ops_checks.php:101`)
- `SMS_USER_ID / SMS_SECURE / SMS_SENDER / SMS_RECIPIENTS` — cafe24 SMS 계정, **사전 등록된 발신번호**, 잔액 충전 필요 (게이트웨이 `php/config.php:66`)
- 임계값: `METRIC_TEMP_HIGH_C=27`, `METRIC_RH_HIGH=60`, `METRIC_RH_LOW=-1`(저습 비활성), `METRIC_DEWPOINT_MARGIN_C=0`(결로 비활성) — 예시값 기준 (`php/config.local.example.php:44-50`, commit c8b448c)
### 2.4 관리자 인증 (fail-closed — 미설정 시 로그인 자체가 불가)
- [ ] `php setup_hash.php "새-관리자-비밀번호"` 실행 → bcrypt 출력물을 `ADMIN_PASSWORD_HASH`에 (`docs/INSTALL_PI_SERVER.md:135-141`)
- [ ] MFA 등록: `MFA_SETUP_TOKEN`에 임시 랜덤값 설정 → `setup_mfa.php?token=<토큰>` 접속 → TOTP 앱 등록 → 표시된 `ADMIN_TOTP_SECRET`을 config.local.php에 → **`MFA_SETUP_TOKEN` 반드시 비우기** (`docs/INSTALL_PI_SERVER.md:100-106`, `php/ops_checks.php:100`)
- [ ] `ADMIN_TOTP_SECRET`은 서버에서 생성하기 전까지 **절대 자리표시자 값을 넣지 말 것** (`php/config.local.example.php:33-40`)
### 2.5 HTTPS / cron
- [ ] HTTPS 리다이렉트는 **cafe24 호스팅 관리 패널에서** 설정 — .htaccess 리다이렉트는 리버스프록시 뒤에서 무한루프 위험으로 의도적으로 비활성 (`php/.htaccess:6-8`, `docs/SOURCE_SECURITY_EVIDENCE.md` §2.1). 세션 쿠키가 Secure라 HTTPS 없이는 로그인 불가.
- [ ] cron 1분 주기: CLI형 `* * * * * php /home/<user>/public_html/<WEB_DIR>/cron_heartbeat.php`. HTTP형 사용 시 **`?key=<CRON_SECRET>` 필수** — `CRON_SECRET = hash_hmac('sha256','cron',API_KEY)` (`php/config.php:199`, `php/cron_heartbeat.php:163-169`). 이 값은 UI 어디에도 표시되지 않아 직접 계산해야 함(갭). `docs/INSTALL_PI_SERVER.md:165`의 URL 예시는 key 파라미터가 빠져 있어 그대로 쓰면 403.
- [ ] (선택) 보존기간 정리: `php retention_cleanup.php --execute` CLI 전용 (`php/retention_cleanup.php:11-16`) — cron 등록 안 하면 365일 보존이 실제로 집행되지 않음.
### 2.6 서버 검증
- [ ] `login.php` 로그인(비밀번호+TOTP) → `setup_wizard.php`에서 전체 점검: DB 연결, 필수 테이블, API_KEY 교체 여부, 관리자 해시/TOTP, MFA 토큰 제거, 레거시 서명 비활성, SMS 수신자, 임계값, var 쓰기권한, **테스트 SMS 발송 버튼** (`php/setup_wizard.php:29-45`, `php/ops_checks.php:44-111`)
- [ ] 주의: setup_wizard는 4개 코어 테이블만 검사 — `leak_incident`/`leak_photo`/`leak_ingest_nonce` 누락은 감지 못 하고 조용히 기능 저하됨 (`php/ops_checks.php:51`). 스키마 실행 후 phpMyAdmin에서 7개 테이블 존재를 직접 확인할 것.
- [ ] CORS 오리진이 `https://pores.kr`로 하드코딩되어 있음 (`php/api/sensor_data.php:49`, `php/api/photo_upload.php:29`) — 다른 도메인 배포 시 수정 필요(갭).
---
## 3. 공통: 새 Pi OS 프로비저닝 (3대 공통)
### 3.1 문서화된 요구사항 (repo에 근거 있음)
| 항목 | 값 | 근거 |
|---|---|---|
| OS | Raspberry Pi OS **Bookworm 64-bit (aarch64)** | `raspberry_pi4/install.sh:4`, `raspberry_pi4_sht30/install.sh:4`, 양쪽 README §2 |
| Python | 시스템 3.11 (Bookworm 기본; wheel이 cp311로 핀) | `vendor.sh` TARGET_PYVER=311 |
| 사용자 | `pi` (sudo 가능) — systemd 유닛 User=pi, 경로 /home/pi/* 하드코딩 | 양쪽 `systemd/*.service`, install.sh SERVICE_USER 기본값 |
| 네트워크 | 유선 Gigabit 또는 WiFi, 서버로 HTTPS egress | 양쪽 README §2 |
| 시각 동기 | NTP 필수 — 서버가 타임스탬프 ±600초 밖이면 거부 | `raspberry_pi4_sht30/README.md:61`, `php/config.php:45` |
| I2C (Pi-B) | install.sh가 자동 처리 (dtparam=i2c_arm=on 등) — raspi-config는 수동 폴백만 | `raspberry_pi4_sht30/install.sh:62-88` |
### 3.2 문서에 없어 현장에서 결정해야 하는 것 (전부 갭 — repo에 절차 없음)
- **SD 이미징 절차 없음**: OS 이미지 버전/체크섬/Imager 사용법 미문서화. → 결정: Raspberry Pi Imager 등으로 Bookworm 64-bit(Lite 권장 여부도 미명시)를 플래시하되, 사용한 이미지 버전을 기록해 3대 동일하게 맞출 것.
- **`pi` 사용자 생성 절차 없음**: Bookworm 이미지는 기본 `pi` 계정이 없음 — Imager 커스텀 설정 또는 userconf.txt로 `pi` 계정을 만들어야 systemd 유닛/경로가 무수정으로 동작. 비밀번호 정책 미문서화.
- **SSH/헤드리스 활성화 절차 없음**: Imager 설정 또는 boot 파티션 `ssh` 파일 방식 중 결정 필요.
- **호스트네임 규약 없음**: DEVICE_ID(rpi4-leak-01 등)는 애플리케이션 식별자일 뿐 OS 호스트네임 아님. → 결정 권장: 호스트네임을 DEVICE_ID와 일치시켜 운영 혼선 방지.
- **네트워크 설정(고정 IP vs DHCP, WiFi 프로비저닝) 없음.**
- **로케일/타임존, apt 보안 업데이트 정책, OS 하드닝 절차 없음** (§7·§8 참조).
### 3.3 (선택·권장) 오프라인 vendor 번들
현장에 인터넷이 있으므로 install.sh 온라인 경로로도 설치 가능하나, 재현성을 위해:
- [ ] 첫 부팅한 Pi(인터넷 연결, aarch64 Bookworm) 1대에서 각 모듈의 `./vendor.sh` 실행 → `wheelhouse/` + `debs/` 생성. **.deb은 반드시 타깃 아키텍처(aarch64 Bookworm)에서만 받을 수 있음** (`vendor.sh:7-10,55`) — x86 빌드머신 불가.
- [ ] 생성된 번들을 나머지 Pi의 모듈 폴더에 복사 → `install.sh`가 자동으로 오프라인 모드 진입(불완전 번들은 하드 에러, 온라인 폴백 없음).
- [ ] 갭: 번들 무결성 해시 검증 절차 없음 — sha256sum 목록을 직접 만들어 전달 권장.
---
## 4. Pi-A: 모듈 1 (누수 + Pi Camera, sensor_id=1)
소스: `raspberry_pi4/` — 근거는 `raspberry_pi4/README.md`, `install.sh`, `config.py`, `systemd/leak-sensor.service`.
### 4.1 배선 (README.md §2.1)
- [ ] e-con 5C-08 누수센서 (~1.5MΩ 고임피던스): 센서 입력 → **GPIO17(BCM) = 물리핀 11**, 3.3V → 핀 1, GND → 핀 6
- [ ] **외부 4.7MΩ 풀다운 필수**, 내부 풀 OFF 유지 (`config.py` USE_INTERNAL_PULL=False → PUD_OFF; 내부 50kΩ 풀다운이 1.5MΩ 센서 신호를 죽임 — README.md:49-53)
- [ ] 로직: ACTIVE_LOW=False — 건조=LOW, 누수=HIGH; 디바운스 500ms (`config.py:20-24`)
- [ ] Pi Camera: Pi 4의 **15핀 1.0mm CSI 커넥터** (Zero 2 W용 22핀 리본 비호환 — 15핀 리본 또는 어댑터 필요). 전원 USB-C 5V/3A(카메라 사용 시 여유 권장).
### 4.2 설치
- [ ] 폴더를 `/home/pi/raspberry_pi4`에 배치 (유닛 파일이 경로 하드코딩 — `systemd/leak-sensor.service:12-13,26-27`; 다르면 유닛 수정)
- [ ] `cp config_local.example.py config_local.py` 후 5개 값 기입:
- `API_URL` = `https://<서버>/<WEB_DIR>/api/sensor_data.php`
- `PHOTO_UPLOAD_URL` = `.../api/photo_upload.php`
- `API_KEY` = 서버 config.local.php와 byte-단위 동일
- `DEVICE_ID` = 예: `rpi4-leak-01`, `DEVICE_LOCATION` = 설치 장소
- 누락 시 fail-closed로 기동 거부 (`config.py:69-83`); 절대 커밋 금지 (`.gitignore`)
- [ ] `chmod +x install.sh && ./install.sh` — 온라인: apt로 `python3-rpi-lgpio python3-requests python3-venv rpicam-apps`(또는 libcamera-apps) 설치; 오프라인 번들 있으면 자동 오프라인. venv(--system-site-packages) + `requests==2.32.3`, 로그파일 `/var/log/leak_sensor.log` (640, pi 소유) 생성.
- [ ] **주의(모순 문서)**: `raspberry_pi4/README.md` §3(57-68행)의 "php/ 서버는 누수 이벤트 거부, 레거시 서버로 향하라"는 안내는 **STALE** — `config_local.example.py:8-16`과 머지된 PR #1 기준 **통합 서버가 정답**. README §3을 따르지 말 것.
### 4.3 systemd
- [ ] `sudo cp systemd/leak-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable leak-sensor && sudo systemctl start leak-sensor`
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=video**(카메라 접근 필수), Restart=always/10s, PrivateTmp=true, network-online.target 이후 기동
### 4.4 End-to-end 검증
- [ ] `source venv/bin/activate && python3 test_connection.py` — 3단계: test.php POST / 무서명 POST(거부 확인) / HMAC 서명 POST(200 확인)
- [ ] 수동 실행 `python3 leak_sensor.py` → startup 이벤트가 서버 sensor_log에 기록되는지 대시보드 확인
- [ ] **카메라 사전 검증(갭 — 문서화된 절차 없음)**: 설치 절차에 카메라 감지 확인이 없어 첫 누수 때에야 실패가 드러남. `rpicam-still`로 수동 테스트샷을 찍어볼 것을 권장.
- [ ] 누수 모의 시험: 센서 단락 → 대시보드 누수 카드(sensor_id=1 전용, `php/dashboard.php:256-264`) 표시 + `[누수감지]` SMS + leak_incident 행 + `photo.php?id=N`으로 사진 열람(관리자 세션 필요)
- [ ] `systemctl status leak-sensor`, `journalctl -u leak-sensor -f` 정상 확인
---
## 5. Pi-B: 모듈 2 (SHT30 온습도, sensor_id=2)
소스: `raspberry_pi4_sht30/` — 근거는 해당 README.md, install.sh, config.py, systemd/sht30-sensor.service.
### 5.1 배선 (README.md §2.1)
- [ ] Sensirion SHT30 (한진데이타 P4422-3), I2C 버스 1: VCC → **3.3V 핀 1 (5V 금지)**, GND → 핀 6 등, SDA → GPIO2/핀 3, SCL → GPIO3/핀 5
- [ ] **ADDR → GND = 주소 0x44** (VDD면 0x45 — `config.py` I2C_ADDR와 일치시킬 것)
- [ ] SDA/SCL에 3.3V 풀업 4.7kΩ ×2 (모듈 온보드 풀업 있으면 생략)
### 5.2 설치
- [ ] 폴더를 `/home/pi/raspberry_pi4_sht30`에 배치 (유닛 경로 하드코딩)
- [ ] `cp config_local.example.py config_local.py` → `API_URL`(Pi-A와 동일 엔드포인트) / `API_KEY`(동일 키) / `DEVICE_ID`(예: rpi4-sht30-01) / `DEVICE_LOCATION` 기입 (fail-closed)
- [ ] `./install.sh` — [1/4] apt: `python3-requests python3-venv i2c-tools`; **[2/4] I2C 자동 활성화**: `/boot/firmware/config.txt`에 dtparam=i2c_arm=on, i2c-dev 모듈 로드+영속화, `usermod -aG i2c pi`; [3/4] venv + `requests==2.32.3, smbus2==0.5.0`; [4/4] `/var/log/sht30_sensor.log`
- [ ] install.sh가 리부트 안내를 출력하면 `sudo reboot` (I2C 신규 활성화 시 — README §4.2). **주의(갭)**: `usermod -aG i2c`도 재로그인/리부트 후 반영 — 리부트 없이 곧바로 test_sensor.py를 돌리면 /dev/i2c-1 권한 오류 가능.
### 5.3 systemd
- [ ] `sudo cp systemd/sht30-sensor.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable sht30-sensor && sudo systemctl start sht30-sensor`
- [ ] 유닛 특성: User=pi, **SupplementaryGroups=i2c**, Restart=always/10s, PrivateTmp=true. install.sh는 유닛을 설치하지 않으므로 이 수동 단계 누락 금지(갭).
### 5.4 End-to-end 검증
- [ ] `i2cdetect -y 1` → **0x44** 표시 확인 (README §5)
- [ ] `python3 test_sensor.py` (CRC/포맷 자가검사 + 1회 측정) — **주의: 하드웨어 없이도 시뮬레이션으로 통과하므로**, i2cdetect 확인을 반드시 선행. 시뮬레이션 폴백이 배선 오류를 가릴 수 있음(갭): 서비스 로그에 시뮬레이션 WARN이 없는지 `journalctl`로 확인할 것.
- [ ] `python3 test_connection.py` — 무서명 400/403, 서명 200; 403이면 API_KEY 불일치, 400이면 NTP/타임스탬프 의심
- [ ] 수동 실행 후 startup 이벤트 → 이후 300초 주기 보고. 대시보드에서 sensor_id=2 카드 + 차트(기본 sensor 2, `php/dashboard.php:125-129`) + leak_sensor_metric 5분 축적 확인
- [ ] 임계값 시험: 고온/고습 유발 → 태그된 SMS + 30분 쿨다운 + 회복 SMS 확인
---
## 6. Pi-C: sensor_id=3 신규 지점 (extension-pattern 기준)
근거: `docs/solutions/best-practices/sensor-telemetry-extension-pattern-2026-05-21.md` (단, 이 문서 자체가 legacy 선언 — 현행 코드 기준으로 재검증된 아래 내용을 따를 것).
### 6.1 그냥 되는 것 (서버 무변경)
- **등록 절차 없음**: sensor_id는 HMAC 서명된 본문으로 도착, 1..255 범위검사 후 `leak_sensor_status`에 UPSERT로 자동 등록 (`php/api/sensor_data.php:199-205, 316-340`). SQL 마이그레이션 불필요.
- 온습도 보고 시 metric 저장, **임계값 SMS/회복 SMS(센서별 쿨다운)**, 오프라인/온라인 SMS, 에스컬레이션, stuck 감지, 월간 리포트 GROUP BY — 전부 sensor_id 단위로 자동 동작 (`php/cron_heartbeat.php:22-83`, `php/sms_send.php:426-509`).
- 대시보드 **센서 상태 카드 목록**에는 sensor 3이 자동 표시 (`php/dashboard.php:87, 635-690`).
- 누수 이벤트도 서버 처리 자체는 됨: `[누수감지]` SMS + leak_incident 생성 + 사고 테이블 표시 (`php/sms_send.php:546-608`, `php/api/sensor_data.php:371-411`, incident 쿼리는 무필터).
### 6.2 코드/스키마 작업이 필요한 것
| 항목 | 내용 | 근거 |
|---|---|---|
| 차트 | sensor_id=2 하드코딩, 선택 UI 없음 — sensor 3 이력은 저장되지만 차트로 못 봄 | `php/dashboard.php:126-129` |
| 최근 온습도 카드 | 차트 센서(=2)의 최신값만 표시 | `php/dashboard.php:218-220, 596-601` |
| 30일 평균 | 전 센서 혼합 집계(GROUP BY 없음) — 지점이 다른 sensor 3이 들어오면 평균이 무의미해짐 | `php/dashboard.php:210-216` |
| 누수 상태 카드 | sensor_id===1 하드코딩 — Pi-C가 누수형이면 실시간 카드 미표시 | `php/dashboard.php:256-264, 693` |
| 임계값/쿨다운 | METRIC_* 전역 단일값 — 지점별 임계 필요 시 코드/스키마 변경 | `php/config.php:134-153` |
| 하트비트 타임아웃 | 전역 1200초 — **누수형 Pi-C(보고 주기 3600초)는 매 사이클 오프라인 오탐** → 전역 상향 시 5분 센서의 감지 지연 | `php/config.php:197`, `raspberry_pi4/config.py` REPORT_INTERVAL=3600 |
| SENSOR_ID 위치 (SHT30형) | git 추적되는 `config.py:16-17`을 직접 수정해야 함 — config_local.py에 넣어도 무시됨(import 목록 고정, `config.py:70-76`) | drift 위험 |
### 6.3 센서 종류별 작업량 비교
| | **SHT30형 (권장 최소)** | **누수형** | **둘 다 (한 Pi)** |
|---|---|---|---|
| Pi측 | `raspberry_pi4_sht30/` 복사, `config.py` SENSOR_ID=3/SENSOR_NAME 수정(추적 파일!), config_local.py 작성(DEVICE_ID 예: rpi4-sht30-02) | `raspberry_pi4/` 복사, `config.py` SENSORS에 `{"id":3,"gpio":22,...}` 추가(주석 예시 기존재, `config.py:17`), config_local.py + PHOTO_UPLOAD_URL | 두 모듈 폴더 모두 설치 — 핀 충돌 없음(PR_BODY.md §7). 단 두 모듈이 각각 sensor_id를 가지므로 id 3·4 두 개를 쓸지 결정 필요(갭 — 문서화된 패턴 없음) |
| 서버측 필수 | **없음** | **없음** (수신·SMS·incident 동작) | 없음 |
| 서버측 권장 수정 | 차트/최근값 카드가 sensor 2 고정 → 보고 싶으면 dashboard.php 수정; 30일 평균 혼합 문제 | + 누수 카드 sensor 1 고정 수정, **HEARTBEAT_TIMEOUT_SEC 충돌 해결(가장 큰 이슈)** | 양쪽 전부 |
| 결론 | **작업량 최소** — 대시보드 차트만 감수하면 무수정 운영 가능 | 서버 무수정 시 오프라인 오탐 + 대시보드 비가시 감수 필요 | 최대 작업량 |
공통 주의: sensor_status는 sensor_id 단독 키 — Pi-C가 실수로 id 1/2를 재사용하면 기존 지점 상태를 **조용히 덮어씀** (`php/api/sensor_data.php:316-340`). DEVICE_ID는 표시용일 뿐 인증 정체성이 아님.
---
## 7. 배포 시점 보안 체크리스트 (repo 근거 항목만)
**서버 (근거: `docs/OPERATIONS_SECURITY_CHECKLIST.md`, `docs/SOURCE_SECURITY_EVIDENCE.md`, `php/ops_checks.php`)**
- [ ] API_KEY 기본값 교체 확인 (기본값이면 ingest 503 — `php/api/sensor_data.php:73-77`)
- [ ] `ALLOW_LEGACY_BODY_SIGNATURE=false` (`php/ops_checks.php:101`)
- [ ] MFA_SETUP_TOKEN 비움 확인 (`php/ops_checks.php:100`)
- [ ] HTTPS를 cafe24 패널에서 강제 + 인증서/리다이렉트 화면 증적 캡처 (`docs/SOURCE_SECURITY_EVIDENCE.md` §2.1 — .htaccess는 의도적으로 리다이렉트 안 함, **조용히 누락되기 쉬운 수동 단계**)
- [ ] 위조 서명 403 / 정상 서명 200 테스트 증적 (`docs/SOURCE_SECURITY_EVIDENCE.md` §5)
- [ ] config.local.php 등 민감 파일 직접 접근 차단 확인 (`php/.htaccess` FilesMatch — `docs/SOURCE_SECURITY_EVIDENCE.md` §2.8)
- [ ] `php/var/admin_audit.log`에 로그인/로그아웃/MFA 이벤트 기록 확인
- [ ] `curl -I https://<도메인>/<WEB_DIR>/login.php` 응답 헤더 점검 (checklist §3)
- [ ] SMS 잔액/발신번호/수신자 최신성 확인; 백업 + 복원 테스트(`php scripts/backup_evidence.php`) — checklist §9
**Pi 3대 공통**
- [ ] `config_local.py` 커밋 금지(.gitignore 확인); 노출 시 서버·전 노드 동시 API_KEY 교체 (양쪽 README §7)
- [ ] 단일 공유 API_KEY 구조 인지: Pi 1대 탈취 = 전 지점 ingest 자격 노출, 기기별 폐기 불가 (extension-pattern 갭) — 지점 추가마다 노출면 확대
- [ ] 기기 인바운드 차단 증적: `ss -lntup`으로 불필요 리스닝 데몬 없음 확인 (`docs/SOURCE_SECURITY_EVIDENCE.md` §4 — 단 달성 방법은 미문서화, §8 갭)
- [ ] `systemctl status leak-sensor`(및 sht30-sensor) 화면 증적 (§5 최소 증적 세트)
- [ ] NTP 동기 확인 (±600초 초과 시 전 POST 거부)
**repo에 없어 자체 수립 필요 (문서화 갭 — §8):** Pi 계정/SSH 하드닝, 호스트 방화벽(ufw 등), 시크릿 파일 권한(chmod 600), OS 패치 정책.
---
## 8. 문서화 갭 / 결정 필요 사항 (통합·우선순위)
### P0 — 배포를 잘못되게 만들 수 있는 것
1. **STALE 문서 모순 — 모듈1 대상 서버**: `raspberry_pi4/README.md` §3·§7은 "php/ 서버가 누수 거부 / 레거시 sha256 서명"이라 함. 현행은 통합 서버 + raw-body HMAC (`config_local.example.py:8-16`, SYSTEM_OVERVIEW.md, PR #1). README만 보고 배포하면 오설정. → README §3/§7 갱신 필요.
2. **`docs/INSTALL_PI_SERVER.md` 업로드 목록 STALE**: photo_upload.php/photo.php/var 가드 누락 (33-56행) — 그대로 따르면 누수 사진 파이프라인 결손. 또한 이 문서 자체가 STM32(v2606) 중심이며 RPi4 신규 설치 통합 가이드는 부재 (root README.md는 0바이트).
3. **cron URL 오류**: INSTALL_PI_SERVER.md:165에 `?key=<CRON_SECRET>` 누락 → 403. CRON_SECRET 표시 UI 없음, 수동 계산 필요.
4. **신품 Pi 프로비저닝 절차 전무**: SD 이미징, `pi` 계정 생성(Bookworm 기본 미존재), SSH 활성화, 호스트네임, 네트워크 설정 — 전부 미문서화. **결정 필요**: 이미지 버전, 계정/비밀번호 정책, 고정IP vs DHCP, 호스트네임 규약.
5. **Pi-C 센서 종류 결정**: §6.3 비교 기준으로 결정. 누수형 선택 시 HEARTBEAT_TIMEOUT_SEC 충돌(전역 1200초 vs 보고 3600초)을 반드시 먼저 해결.
### P1 — 운영 중 사고로 이어질 수 있는 것
6. **Pi OS 하드닝 부재**: 계정/SSH/방화벽/시크릿 파일 권한(600)/패치 정책이 4개 보안 문서 어디에도 없음 — `docs/SOURCE_SECURITY_EVIDENCE.md`도 "코드로 증명 불가, 기관 자료 필요"로만 표기. → 자체 표준 수립 필요.
7. **단일 공유 API_KEY / 센서 레지스트리 부재**: 키 보유자는 임의 sensor_id 생성·기존 id 덮어쓰기 가능; 기기별 폐기 불가. 지점이 늘수록(Pi-C) 위험 증가 — device_id↔sensor_id 서버측 바인딩 검토 권장.
8. **시뮬레이션 폴백이 오류 은폐**: SHT30 모듈은 I2C 실패 시 그럴듯한 더미값을 운영 서버로 전송(WARN 로그만) — FORCE_REAL 옵션 없음. 배포 검증 시 journalctl 확인을 절차화할 것.
9. **NTP 의존 미절차화**: 서버가 ±600초 강제하는데 시각동기 확인 단계가 설치 절차에 없음 (현장 인터넷 가용이라 기본 NTP로 충족 예상되나 확인 단계 추가 권장).
10. **카메라 검증 단계 부재** (Pi-A): 첫 누수 때에야 실패 발견 — 설치 시 테스트샷 절차 추가 필요.
### P2 — 정리/개선
11. **설정 규약 이원화**: `docs/SHT30_SENSOR2_SETUP.md` §3과 `php/ops_checks.php:97`은 `/etc/sht30-sensor.env`를 참조하나 실제 모듈은 `config_local.py`만 읽음 — 문서 갱신 필요.
12. **SENSOR_ID가 추적 파일(config.py)에 위치** (SHT30형): config_local 오버라이드 허용하는 소규모 수정 권장 (Pi-C에서 실익).
13. **systemd 유닛 경로/User=pi 하드코딩** 2개 파일 — 비표준 배치 시 수동 수정 2곳.
14. **센서 폐기 UI 없음**: leak_sensor_status 행 수동 SQL 삭제 전까지 영구 오프라인 표시.
15. **임계값 문서 드리프트**: SOURCE_SECURITY_EVIDENCE.md §2.4(30℃/70%)와 현행 권장값(27℃/60%, commit c8b448c) 불일치.
16. **retention_cleanup cron 미문서화**; **오프라인 번들 해시 검증 부재**(--require-hashes 없음); **CORS pores.kr 잔재**; **로그 권한 실패 시 조용한 콘솔 폴백**(양 모듈); **var/ 권한 비트 미문서화**(cafe24에서 setup_wizard '상태 파일 폴더' 체크로 확인).
17. **firmware/(STM32) 트랙은 이번 배포와 무관** — RPi4 배포에 불필요 (`docs/stm32f407_migration_plan.md`는 별도 마이그레이션 트랙, 링크/실행 미검증 상태).
---
## 9. [확정] Pi-C = 누수 + 카메라 (sensor_id 3) — 결정 반영 부록
> ✅ **v2607 구현 완료:** §9.2 필수 코드 작업 3건 전부 반영됨 — REPORT_INTERVAL=600(하트비트 오탐 해소), 대시보드 LEAK_SENSOR_IDS 루프(누수 카드 다중 지점), config_local.py SENSORS 오버라이드(+형식 검증 fail-closed). 테이블은 전부 leak_ 접두사로 리네임됨. 배포는 tools/make_deploy_bundles.py 번들 사용.
사용자 결정(2026-07-02): Pi-C는 **누수 + 카메라** 구성.
### 9.1 누수형 선택의 이점 (SHT30형 대비)
- 대시보드 차트 sensor 2 하드코딩 문제(§6.2) **해당 없음** — Pi-C는 metric을 안 보냄.
- 30일 평균 혼합 오염(§6.2) **해당 없음** — 동일 이유.
- SENSOR_ID 추적파일(config.py) 문제는 누수 모듈도 동일하나, `raspberry_pi4/config.py:13` SENSORS 리스트에 id=3 항목 추가 형태라 구조는 명확.
### 9.2 필수 코드 작업 (검증 완료 — file:line 확인됨)
1. **하트비트 오탐 (P0, Pi-A에도 이미 존재하는 버그)**
- `php/cron_heartbeat.php:25-30` — leak_sensor_status 전체를 전역 `HEARTBEAT_TIMEOUT_SEC`(기본 1200s, `php/config.php:197`)로 검사. 필터 없음.
- `raspberry_pi4/config.py:35` — 누수 모듈 정상 주기 보고 3600s.
- 결과: 누수 노드(id 1, id 3)는 **매 사이클 오프라인 SMS 오탐 → 복귀 SMS 반복**.
- 권장 수정(택1):
- (a) **최소 변경**: 누수 모듈 `REPORT_INTERVAL` 3600 → 600 (Pi-A·Pi-C 공통). 주기 POST는 소형 JSON이라 부하 무시 가능. 서버 무수정.
- (b) **구조적**: sensor_status에 per-sensor timeout 컬럼 추가 + cron_heartbeat/dashboard 반영 (스키마+코드 변경).
2. **대시보드 누수 카드 (Pi-C 가시성)**
- `php/dashboard.php:260` — `(int)$s['sensor_id'] === 1` 하드코딩. Pi-C 누수 상태 카드 미표시.
- 수정: is_leak 값이 있는 모든 센서에 대해 카드 렌더 (사고 테이블은 이미 무필터 — 수정 불요).
3. **Pi-C 설정값**
- `raspberry_pi4/config.py` SENSORS에 `{"id": 3, ...}` (gpio 17 유지 — 별도 Pi이므로 핀 충돌 없음), DEVICE_ID 예: `rpi4-leak-02`, DEVICE_LOCATION = 신규 지점명.
### 9.3 Pi-C 추가 하드웨어 (구매 확인 필요)
- e-con 5C-08 누수센서 1개 + 4.7MΩ 풀다운 저항
- Pi Camera (15핀 CSI 리본) 1개
- SD카드·USB-C 5V/3A 전원 (3대 공통)
### 9.4 보안 유의 (extension-pattern 갭)
- 단일 공유 API_KEY로 지점 3개 — 노출면 확대. sensor_status는 sensor_id 단독 키이므로 Pi-C가 id 1/2 오설정 시 기존 지점 상태를 조용히 덮어씀 (`php/api/sensor_data.php:316-340`). 배포 전 config 교차 점검 필수.

View file

@ -281,13 +281,12 @@ return [
<div class="step"> <div class="step">
<h3>스키마 실행</h3> <h3>스키마 실행</h3>
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>. <p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
생성 테이블: <code>sensor_log</code>, <code>sensor_status</code>, <code>sensor_metric</code>, <code>sms_log</code> 생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
(+ 재전송 방어용 <code>ingest_nonce</code>).</p> (+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
<table> <table>
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead> <thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
<tbody> <tbody>
<tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr> <tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr>
<tr><td><code>sql/migration_drop_leak.sql</code></td><td>기존 누수(v2605) 설치 → 온습도 전용 전환. <b>비가역 — 실행 전 DB 전체 백업</b></td></tr>
</tbody> </tbody>
</table> </table>
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. <div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
@ -301,7 +300,7 @@ return [
<div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div> <div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div>
<div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div> <div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div>
</div> </div>
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption> <figcaption>그림 3. DB 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
</figure> </figure>
</section> </section>
@ -575,7 +574,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<ul style="margin:6px 0 0;"> <ul style="margin:6px 0 0;">
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li> <li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li> <li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li> <li><code>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li> <li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
</ul> </ul>
</div> </div>
@ -599,8 +598,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr> <tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
<tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr> <tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr>
<tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr> <tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr>
<tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr> <tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>sensor_metric</code>, 펌웨어 측정 로그</td></tr> <tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</code>, 펌웨어 측정 로그</td></tr>
<tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr> <tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr>
<tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr> <tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr> <tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>

View file

@ -281,13 +281,12 @@ return [
<div class="step"> <div class="step">
<h3>스키마 실행</h3> <h3>스키마 실행</h3>
<p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>. <p><b>신규 설치</b>는 <code>sql/schema_sht30.sql</code> 전체를 복사해 <span class="kbd">SQL</span> 탭에 붙여넣고 <span class="kbd">실행</span>.
생성 테이블: <code>sensor_log</code>, <code>sensor_status</code>, <code>sensor_metric</code>, <code>sms_log</code> 생성 테이블: <code>leak_sensor_log</code>, <code>leak_sensor_status</code>, <code>leak_sensor_metric</code>, <code>leak_sms_log</code>
(+ 재전송 방어용 <code>ingest_nonce</code>).</p> (+ 재전송 방어용 <code>leak_ingest_nonce</code>).</p>
<table> <table>
<thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead> <thead><tr><th>SQL 파일</th><th>적용 상황</th></tr></thead>
<tbody> <tbody>
<tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr> <tr><td><code>sql/schema_sht30.sql</code></td><td>신규 설치(통합 스키마)</td></tr>
<tr><td><code>sql/migration_drop_leak.sql</code></td><td>기존 누수(v2605) 설치 → 온습도 전용 전환. <b>비가역 — 실행 전 DB 전체 백업</b></td></tr>
</tbody> </tbody>
</table> </table>
<div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다. <div class="crit"><b>중복 실행 주의.</b> 이미 적용한 마이그레이션을 다시 실행하면 오류가 날 수 있습니다.
@ -301,7 +300,7 @@ return [
<div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div> <div class="ph-title">캡처 위치 — 그림 3 <span class="fname">→ screenshots/03-db-phpmyadmin.png</span></div>
<div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div> <div class="ph-what">phpMyAdmin <b>SQL 탭에 <code>schema_sht30.sql</code> 붙여넣고 [실행]한 결과</b>(“쿼리 실행 성공” + 좌측 4~5개 테이블 생성된 화면)</div>
</div> </div>
<figcaption>그림 3. DB 스키마 실행 결과 (sensor_log/sensor_status/sensor_metric/sms_log/ingest_nonce)</figcaption> <figcaption>그림 3. DB 스키마 실행 결과 (leak_sensor_log/leak_sensor_status/leak_sensor_metric/leak_sms_log/leak_ingest_nonce)</figcaption>
</figure> </figure>
</section> </section>
@ -575,7 +574,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<ul style="margin:6px 0 0;"> <ul style="margin:6px 0 0;">
<li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li> <li><code>setup_wizard.php</code> 필수 점검 항목 통과 + 테스트 SMS 수신</li>
<li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li> <li><code>dashboard.php</code> 에 최신 온/습도와 센서 상태(<code>sensor_id=2</code>) 표시</li>
<li><code>sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li> <li><code>leak_sensor_metric</code> 에 주기 측정값(periodic) 저장 · STM32 콘솔에 서버 200 기록</li>
<li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li> <li>임계 초과 테스트 시 <code>metric_status</code> 경보 + SMS 수신 / 월간 보고서에 내역 조회</li>
</ul> </ul>
</div> </div>
@ -599,8 +598,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php</code></pr
<tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr> <tr><td>API 403</td><td>서버 <code>API_KEY</code> ↔ 펌웨어 <code>APP_API_KEY</code> 일치, raw-body 서명</td></tr>
<tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr> <tr><td>API 500</td><td>Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부</td></tr>
<tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr> <tr><td>로그인 불가</td><td><code>ADMIN_PASSWORD_HASH</code>, <code>ADMIN_TOTP_SECRET</code>, 인증 앱 시간 동기</td></tr>
<tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr> <tr><td>SMS 미발송</td><td>Cafe24 SMS 계정·secure key·발신번호 등록·<b>잔액</b>, <code>leak_sms_log</code>. 대시보드 ‘수집 거부/발송 실패’ 배너 확인</td></tr>
<tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>sensor_metric</code>, 펌웨어 측정 로그</td></tr> <tr><td>온습도 미표시</td><td>I²C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, <code>leak_sensor_metric</code>, 펌웨어 측정 로그</td></tr>
<tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr> <tr><td>TLS 실패</td><td><code>server_ca.c</code> 실제 CA 반영, <b>SNTP 시간 동기</b>(<code>APP_SNTP_SERVER</code>=LAN NTP)</td></tr>
<tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr> <tr><td>데이터 전혀 안 들어옴</td><td>대시보드 <b>수집 거부 사유 카운터</b>(서명불일치→키/펌웨어, 시각오차→SNTP/RTC)로 원인 분별</td></tr>
<tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr> <tr><td>오프라인 알림 과다/지연</td><td><code>HEARTBEAT_TIMEOUT_SEC</code>(1200초), cron 주기</td></tr>

View file

@ -2,6 +2,8 @@
이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다. 이 문서는 서버실 온습도(SHT30) 모니터링 시스템 v2606을 실제 운영 환경에 배포할 때 STM32F407VGT6 펌웨어와 Cafe24 PHP/MySQL 서버에 각각 무엇을 설치하고 설정해야 하는지 정리한 설치용 문서입니다.
> **참고:** 이 문서는 **STM32 펌웨어 중심**의 설치 문서입니다. Raspberry Pi 4 모듈(누수+카메라 / SHT30) 배포자는 [docs/DEPLOYMENT_PLAN_RPI4x3.md](./DEPLOYMENT_PLAN_RPI4x3.md)와 [SYSTEM_OVERVIEW.md](../SYSTEM_OVERVIEW.md)를 먼저 참고하세요. 서버 설치 절차(§3~§6)는 공통입니다.
## 1. 설치 대상 ## 1. 설치 대상
| 구분 | 설치 위치 | 역할 | | 구분 | 설치 위치 | 역할 |
@ -28,19 +30,20 @@
## 3. 서버에 설치할 파일 ## 3. 서버에 설치할 파일
Cafe24 웹 루트 아래에 다음 구조로 PHP 파일을 업로드합니다. Cafe24 웹 루트 아래에 **repo 의 `php/` 트리 전체를 그대로 업로드**합니다. 개별 파일을 선별해 올리면 누수 사진 파이프라인(`api/photo_upload.php`, `photo.php`, `var/photos/`)이 빠지기 쉽습니다.
```text ```text
public_html/raspi_leck_detecter/ public_html/raspi_leck_detecter/ ← php/ 트리 전체 업로드
.htaccess .htaccess
blocked.php blocked.php
config.php config.php
config.local.php config.local.php ← 서버에서 example 복사 후 작성(저장소에 없음)
config.local.example.php config.local.example.php
ops_checks.php ops_checks.php
sms_send.php sms_send.php
cron_heartbeat.php cron_heartbeat.php
dashboard.php dashboard.php
photo.php ← 누수 사진 조회(관리자 인증 필요)
setup_mfa.php setup_mfa.php
admin_security.php admin_security.php
setup_wizard.php setup_wizard.php
@ -49,10 +52,17 @@ public_html/raspi_leck_detecter/
login.php login.php
retention_cleanup.php retention_cleanup.php
setup_hash.php setup_hash.php
test_mobile.php
api/ api/
sensor_data.php sensor_data.php
photo_upload.php ← 누수 사진 업로드 API
var/ var/
.gitkeep .gitkeep
.htaccess ← var/ 웹 직접 접근 차단(403)
index.html
photos/ ← 누수 사진 저장 폴더(.htaccess/index.html 포함,
.htaccess 없으면 첫 업로드 시 자동 생성 시도)
index.html
``` ```
운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다. 운영 서버에는 `config.local.php`가 반드시 필요합니다. `config.local.example.php`를 복사해서 만들고 실제 운영 값으로 교체합니다.
@ -113,16 +123,7 @@ https://your-domain.example/raspi_leck_detecter/setup_mfa.php?token=임시토큰
SOURCE /path/to/sql/schema_sht30.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`입니다. phpMyAdmin에서는 [schema_sht30.sql](../sql/schema_sht30.sql) 내용을 복사해서 SQL 실행 창에 붙여넣습니다. 생성 테이블은 총 7개 — `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`, `leak_ingest_nonce`(재전송 방어), `leak_incident`, `leak_photo`(누수 사고/사진) — 이며, 신규 설치는 이 파일 하나로 완결됩니다(누수 컬럼/테이블 포함). 모든 테이블은 `leak_` 접두사를 사용해 같은 DB 의 다른 테이블과 격리됩니다.
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
| SQL 파일 | 적용 상황 |
|---|---|
| [schema_sht30.sql](../sql/schema_sht30.sql) | 신규 설치(통합 스키마) |
| [migration_drop_leak.sql](../sql/migration_drop_leak.sql) | 기존 누수 설치 → 온습도 전용 전환(레거시 컬럼/테이블 정리, `sensor_metric` 보장) |
이미 적용된 마이그레이션을 중복 실행하면 DB 오류가 날 수 있으므로, 기존 설치에서는 `setup_wizard.php`의 점검 결과를 먼저 확인합니다.
## 5. 서버 권한 및 관리자 설정 ## 5. 서버 권한 및 관리자 설정
@ -149,7 +150,7 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
점검 화면에서 확인할 항목은 다음과 같습니다. 점검 화면에서 확인할 항목은 다음과 같습니다.
- DB 연결 - DB 연결
- 필수 테이블 존재 여부(`sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`) - 필수 테이블 존재 여부(`leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`)
- API 키 기본값 교체 여부 - API 키 기본값 교체 여부
- 관리자 해시 설정 여부 - 관리자 해시 설정 여부
- 관리자 TOTP 설정 여부 - 관리자 TOTP 설정 여부
@ -162,10 +163,12 @@ https://your-domain.example/raspi_leck_detecter/setup_wizard.php
장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다. 장비 오프라인 감지를 위해 `cron_heartbeat.php`를 주기 실행합니다. Cafe24 cron 또는 외부 스케줄러에서 다음 URL을 1분 간격으로 호출합니다.
```text ```text
https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php https://your-domain.example/raspi_leck_detecter/cron_heartbeat.php?key=<CRON_SECRET>
``` ```
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다. HTTP 호출에는 `?key=<CRON_SECRET>` 파라미터가 **필수**입니다(누락 시 403). `CRON_SECRET`은 `API_KEY`에서 파생되며 다음으로 계산합니다: `php -r "echo hash_hmac('sha256','cron','<API_KEY>');"`
서버 CLI cron을 사용할 수 있다면 다음처럼 구성할 수 있습니다(CLI 실행에는 key가 필요 없습니다).
```cron ```cron
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
@ -223,7 +226,7 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
- `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시 - `dashboard.php`에 최신 온도/습도와 센서 상태(`sensor_id=2`)가 표시
- `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능 - `security_evidence.php`에서 운영 점검 결과와 보안통제 매트릭스 조회 가능
- STM32 콘솔 로그에 서버 200 응답 기록 확인 - STM32 콘솔 로그에 서버 200 응답 기록 확인
- `sensor_metric`에 주기 측정값(periodic) 저장 - `leak_sensor_metric`에 주기 측정값(periodic) 저장
- 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신 - 임계 초과 테스트 시 `metric_status` 경보 기록 및 SMS 수신
- 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능 - 월간 보고서에서 임계 경보/복귀/오프라인 내역 조회 가능
@ -234,8 +237,8 @@ https://your-domain.example/raspi_leck_detecter/security_evidence.php
| API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 | | API 403 | 서버 `API_KEY`와 펌웨어 `APP_API_KEY` 일치 여부, raw-body 서명 |
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 | | API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, 테이블 생성 여부 |
| 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 | | 로그인 불가 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
| SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `sms_log` | | SMS 미발송 | Cafe24 SMS 계정, secure key, 발신번호 등록, 잔액, `leak_sms_log` |
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 | | 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) | | 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*`, `METRIC_ALERT_COOLDOWN_SEC`(1800초) |
| TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) | | TLS 실패 | `firmware/certs/server_ca.c` 실제 CA 반영, SNTP 시간 동기(인증서 유효기간 검증) |
| 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 | | 오프라인 알림 과다/지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초), cron 주기 |

View file

@ -10,7 +10,7 @@
| 주기 | 점검 목적 | 보관 증적 | | 주기 | 점검 목적 | 보관 증적 |
|---|---|---| |---|---|---|
| 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 | | 일일 | 온습도 임계 경보, 장비 오프라인, SMS 실패 여부 확인 | 대시보드 화면 또는 이상 없음 기록 |
| 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `sensor_metric` 최근 기록 | | 주간 | STM32 보고 수신 상태, 최근 측정값, 측정 공백 여부 확인 | 대시보드 온습도 추이, `leak_sensor_metric` 최근 기록 |
| 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 | | 월간 | 보안통제 매트릭스 점검, 백업, 수신자 현행화 | 보안 증적 보고서, 월간 보고서, 백업 목록 |
| 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 | | 월간 | 관리자 MFA, 감사로그, 보관기간 정리 확인 | MFA 등록 확인표, `php/var/admin_audit.log`, retention 실행 결과 |
| 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 | | 변경 시 | API 키, 서버 경로, 수신자, 임계값, 네트워크 변경 검증 | 변경 승인 기록, 재점검 결과 |
@ -23,9 +23,9 @@
| 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 | | 설치 점검 | `setup_wizard.php` 접속 | 중요 항목 0건 또는 조치계획 기록 | 설치 점검 캡처 |
| 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 | | 보안 증적 | `security_evidence.php` 접속 | 운영 점검 결과와 보안통제 매트릭스 출력 | Markdown 다운로드 |
| 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV | | 월간 보고 | `monthly_report.php` 조회 | 임계 경보, 복귀, 오프라인, SMS 현황 표시 | PDF 또는 CSV |
| 장비 상태 | `sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 | | 장비 상태 | `leak_sensor_status`(`sensor_id=2`) | 최근 수신 시각이 `HEARTBEAT_TIMEOUT_SEC` 이내 | 대시보드 센서 카드 |
| 측정 수집 | `sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 | | 측정 수집 | `leak_sensor_metric` | 5분 주기 측정값 누적 | 온습도 추이 캡처 |
| SMS | 최근 실패 | 반복 실패 없음 | `sms_log` 또는 대시보드 | | SMS | 최근 실패 | 반복 실패 없음 | `leak_sms_log` 또는 대시보드 |
| 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) | | 임계 설정 | `METRIC_*` | 기관 기준과 일치 | `config.local.php` 발췌(값만) |
| 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 | | 백업 | DB/설정 백업 | 정해진 주기대로 백업 존재 | 백업 파일 목록 |
| 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 | | 관리자 MFA | `ADMIN_TOTP_SECRET` | 인증 앱 등록 완료 | MFA 등록 확인표 |
@ -65,7 +65,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
|---|---|---| |---|---|---|
| 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 | | 임계 초과 감지 | 측정값이 임계 초과 시 `metric_status` 경보가 기록되는가 | 감지 시각/항목 |
| SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 | | SMS 발송 | 수신자가 임계 경보 SMS를 받는가 | 수신 시각 |
| 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `sms_log` 간격 | | 쿨다운 | 동일 종류 경보가 30분 내 중복 발송되지 않는가 | `leak_sms_log` 간격 |
| 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 | | 정상복귀 | 히스테리시스 포함 정상 회복 시 복귀 SMS 1회 발송되는가 | 복귀 시각 |
| 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 | | 보고서 반영 | 월간 보고서에 경보/복귀가 반영되는가 | 보고서 파일 |
@ -76,7 +76,7 @@ USART3(PD8/PD9, 115200) 콘솔 로그로 다음을 확인합니다.
| 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) | | 임계 경보 발생 | 당일 원인 확인 후 조치 결과 기록(공조/환기/장비 점검) |
| SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 | | SMS 반복 실패 | Cafe24 SMS 잔액, 발신번호, secure key, 수신자 확인 |
| 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 | | 장비 오프라인 | 전원, LAN 연결, PHY 링크, 펌웨어 콘솔 로그 확인 |
| 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블 확인 | | 온습도 미수집 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블 확인 |
| HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 | | HTTPS/TLS 미확인 | Cafe24 SSL 설정, 펌웨어 CA 반영, SNTP 시간 동기 확인 |
| 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 | | 백업 미수행 | 백업 주기 재설정 및 다음 월 점검에 포함 |

View file

@ -51,8 +51,7 @@ php/
blocked.php # 차단 안내 화면 blocked.php # 차단 안내 화면
api/sensor_data.php # STM32 센서 데이터 수신 api/sensor_data.php # STM32 센서 데이터 수신
sql/ sql/
schema_sht30.sql # 신규 설치용 통합 스키마 schema_sht30.sql # 신규 설치용 통합 스키마 (이 파일 하나로 완결)
migration_drop_leak.sql # 기존 누수 설치 → 온습도 전용 전환 마이그레이션
_backup/ _backup/
pre_v2605_20260519/ # 정리 전 원본 백업 pre_v2605_20260519/ # 정리 전 원본 백업
``` ```
@ -69,7 +68,7 @@ _backup/
SOURCE /path/to/sql/schema_sht30.sql; SOURCE /path/to/sql/schema_sht30.sql;
``` ```
phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log`입니다. phpMyAdmin에서 실행할 때는 `sql/schema_sht30.sql` 내용을 복사해 실행합니다. 생성 테이블은 `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log`입니다.
3. PHP 파일을 업로드합니다. 3. PHP 파일을 업로드합니다.
@ -126,29 +125,21 @@ _backup/
* * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php * * * * * php /home/hosting_user/public_html/raspi_leck_detecter/cron_heartbeat.php
``` ```
## 기존 누수 설치 → 온습도 전용 전환 ## 설치 스키마
1. 현재 운영 파일과 DB를 백업합니다. 신규 설치는 `sql/schema_sht30.sql` 하나만 실행합니다(누수+온습도 통합, 테이블 전체 `leak_` 접두사).
2. `php/config.local.example.php` 기준으로 `config.local.php`를 갱신합니다(METRIC_* 임계 확인). 구버전 무접두 테이블 대상 마이그레이션 파일들은 v2607 테이블 리네임과 함께 폐기되었습니다(git 이력 참고).
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 | | 이벤트 | 조건 | DB | SMS |
|---|---|---|---| |---|---|---|---|
| `startup` | 기기 시작 첫 측정 | `sensor_log` | X | | `startup` | 기기 시작 첫 측정 | `leak_sensor_log` | X |
| `periodic` | 정상 주기 측정(5분) | `sensor_metric` | X | | `periodic` | 정상 주기 측정(5분) | `leak_sensor_metric` | X |
| 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `sensor_metric.metric_status` | O (종류별 30분 쿨다운) | | 고온/저온/고습/저습 경보 | 측정값이 임계 초과 | `leak_sensor_metric.metric_status` | O (종류별 30분 쿨다운) |
| 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `sensor_metric` | O (1회) | | 정상복귀 | 경보 후 히스테리시스 포함 정상 회복 | `leak_sensor_metric` | O (1회) |
| 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `sensor_status` | O | | 장비 오프라인 | 마지막 수신 후 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분) 초과 | `leak_sensor_status` | O |
| 장비 복구 | 오프라인 후 재수신 | `sensor_status` | O | | 장비 복구 | 오프라인 후 재수신 | `leak_sensor_status` | O |
## 운영 기준 ## 운영 기준
@ -161,7 +152,7 @@ _backup/
- 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다. - 로그인 성공/실패, 로그아웃, MFA 등록 검증은 `php/var/admin_audit.log`에 감사로그로 기록합니다.
- heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다. - heartbeat 체크 실행 주기와 오프라인 판정 시간(`HEARTBEAT_TIMEOUT_SEC`, 기본 1200초)은 분리되어 있습니다.
- 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다. - 대시보드는 위험 상태를 첫 화면에 우선 표시하고, 최신 온습도, 운영 자가진단, 30일 운영 요약을 함께 보여줍니다.
- 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다. - 운영 자가진단은 API 키, 관리자 해시, SMS 수신자, 필수 테이블(`leak_sensor_metric` 포함), 상태 파일 폴더 권한, 최근 SMS 실패를 확인합니다.
- 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다. - 월간 보고서는 온습도 임계 경보, 정상복귀, 오프라인/복구, SMS 현황을 조회하고 CSV/인쇄/PDF로 출력할 수 있습니다.
- 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다. - 보관기간 정리는 `php/retention_cleanup.php --dry-run`으로 삭제 대상을 먼저 확인한 뒤, 운영 백업 완료 후 `--execute`로 수행합니다.
- 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다. - 실행-검토 과정에서 나온 실수와 교훈은 `docs/solutions/`에 남겨 다음 개선 사이클에서 반복하지 않습니다.
@ -198,10 +189,10 @@ php scripts/backup_evidence.php --backup-dir /path/to/backups --restore-test "YY
| 증상 | 확인 지점 | | 증상 | 확인 지점 |
|---|---| |---|---|
| API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 | | API 403 | 펌웨어 `APP_API_KEY`와 서버 `API_KEY` 일치 여부, raw-body 서명 |
| API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `sensor_metric` 등 테이블 생성 여부 | | API 500 | Cafe24 PHP 오류 로그, DB 접속 정보, `leak_sensor_metric` 등 테이블 생성 여부 |
| 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 | | 로그인 불가 | `config.local.php`의 `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, 인증 앱 시간 동기화 |
| SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `sms_log` | | SMS 미발송 | Cafe24 SMS 인증값, 발신번호 등록, 잔액, `leak_sms_log` |
| 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `sensor_metric` 테이블, 펌웨어 측정 로그 | | 온습도 미표시 | I2C 배선(PB6/PB7, 0x44), 4.7kΩ 풀업, `leak_sensor_metric` 테이블, 펌웨어 측정 로그 |
| 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) | | 임계 경보 미발송 | `config.php`/`config.local.php`의 `METRIC_*` 임계, `METRIC_ALERT_COOLDOWN_SEC`(30분) |
| 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) | | 오프라인 알림 지연 | `HEARTBEAT_CHECK_INTERVAL_SEC`, `HEARTBEAT_TIMEOUT_SEC`(기본 1200초) |
| 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 | | 대시보드 진단 경고 | `config.local.php`, DB 마이그레이션, `php/var` 권한 |

View file

@ -14,9 +14,9 @@
| raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) | | raw-body API 서명 검증 (HMAC-SHA256) | `config.php` `verify_signature_raw()`=`hash_hmac('sha256', $raw_body, API_KEY)`, 펌웨어 `sig.c`/`secrets.h` (`X-Signature`) | 반영 (서명 방식 정정 후 일치, 아래 §2.1 참조) |
| 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 | | 측정값 범위 검증 | `api/sensor_data.php` (온도 -40~125℃ / 습도 0~100%) | 반영 |
| 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 | | 서버 임계 판정·쿨다운 | `config.php` `METRIC_*`(고온30/저온10·고습70/저습20, 30분 쿨다운, 히스테리시스), `api/sensor_data.php` | 반영 |
| 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 | | 임계 경보/정상복귀 SMS | `api/sensor_data.php`, `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | 반영 |
| 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `sensor_status.offline_alerted`, `sms_log` | 반영 | | 장비 오프라인/복구 SMS | `cron_heartbeat.php`, `leak_sensor_status.offline_alerted`, `leak_sms_log` | 반영 |
| 측정 이력 저장 | `sensor_metric` 테이블 | 반영 | | 측정 이력 저장 | `leak_sensor_metric` 테이블 | 반영 |
| 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 | | 보관기간 정리 | `retention_cleanup.php --dry-run`, `--execute` (SMS/센서/측정/감사로그) | 반영 |
| 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 | | 백업·복구 증빙 | `scripts/backup_evidence.php` | 반영 |
| 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 | | 보안 증적 보고서 | `security_evidence.php?format=md` | 반영 |

View file

@ -16,7 +16,7 @@
| 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 | | 5 | 네트워크 증적 | 방화벽 정책, 단말 인바운드 차단 확인, HTTPS 인증서 화면 |
| 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID | | 6 | 물리 설치 증적 | STM32 보드, SHT30 센서 설치 사진과 장비 ID |
| 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 | | 7 | 관리자 접근통제 증적 | MFA 등록 화면, 로그인 화면, 감사로그 존재 확인 |
| 8 | 측정/임계 경보 증적 | 측정값 저장(`sensor_metric`), 종류별 경보·정상복귀 SMS(`sms_log`) 화면 | | 8 | 측정/임계 경보 증적 | 측정값 저장(`leak_sensor_metric`), 종류별 경보·정상복귀 SMS(`leak_sms_log`) 화면 |
| 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 | | 9 | 운영 절차 증적 | 운영·보안 점검표, 보관기간 정리 dry-run, 백업 파일 목록, 수신자 현행화 확인 |
| 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 | | 10 | 소스코드 기반 설명 | `SOURCE_SECURITY_EVIDENCE.md`의 기술통제 설명 |
@ -79,7 +79,7 @@ security-evidence-YYYYMMDD/
- [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다. - [ ] `security_evidence.php` 점검 결과에 중요 실패 항목이 있으면 조치 결과 또는 예외 사유를 적는다.
- [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다. - [ ] 단말 증적에는 환경변수 존재 여부만 있고 원문 값은 없다.
- [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다. - [ ] MFA 증빙은 운영 비밀키가 아닌 증빙용 임시 키 또는 마스킹된 키를 사용한다.
- [ ] 측정/경보 증빙은 측정값 저장(`sensor_metric`)과 종류별 경보·정상복귀 SMS(`sms_log`)를 함께 보여준다. - [ ] 측정/경보 증빙은 측정값 저장(`leak_sensor_metric`)과 종류별 경보·정상복귀 SMS(`leak_sms_log`)를 함께 보여준다.
- [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다. - [ ] 실제 도메인은 `https://`로 접속되는 화면을 포함한다.
- [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다. - [ ] 네트워크 증적에는 단말로 들어오는 불필요한 포트가 없다는 근거가 포함된다.
- [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다. - [ ] 월간 보고서에는 생성 시각과 보고 범위가 보인다.

View file

@ -210,7 +210,7 @@ DB 백업 주기: 매일 / 매주 / 매월
| 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 | | 관리자 인증 | 관리자 로그인 화면, MFA 등록 증빙, 감사로그 | 비밀번호 해시, TOTP MFA, 세션 보호, 실패 제한 코드 설명 |
| 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 | | 측정/임계 | 임계 운영 기준(`METRIC_*`) | 측정값 범위 검증, 서버 임계 판정·쿨다운 설명 |
| 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 | | 개인정보 | SMS 수신자 관리 기준 | SMS 설정 구조, 로그 테이블 설명 |
| 로그관리 | 로그 보관기간, 관리자 감사로그 | `sensor_log`, `sensor_metric`, `sms_log`, `php/var/admin_audit.log` 설명 | | 로그관리 | 로그 보관기간, 관리자 감사로그 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, `php/var/admin_audit.log` 설명 |
| 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 | | 백업 | DB/설정 백업 주기, 복구 테스트 결과 | `backup_evidence.php` 결과와 백업·복구 문구 |
| 패치 | 월간 패치 점검 담당자 | 점검표 양식 | | 패치 | 월간 패치 점검 담당자 | 점검표 양식 |
| 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 | | 물리보안 | 설치 사진, 자산 정보 | 자산대장 양식 |

View file

@ -14,7 +14,7 @@
현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다. 현재 HWP 보안대책서는 사업 목적, 추진체계, 관리적·물리적·기술적 보안대책의 큰 틀은 갖추고 있습니다. 다만 실제 구현된 시스템과 비교하면 본문이 과거 범위(화재·누수·현장 사진·사고 대응)를 기준으로 작성되어 있어, 현재 구현 범위인 **서버실 온습도(SHT30) 모니터링 + 임계 SMS 경보 + 장비 오프라인 알림**으로 정정해야 합니다.
- 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다. - 사업 범위가 `화재/누수/장비 전원 이상`으로 넓게 쓰여 있으나, 현재 구현은 `서버실 온습도(SHT30) 측정 + 임계(고온/저온/고습/저습) SMS 경보 + 정상복귀 SMS + 장비 오프라인/복구 SMS + 대시보드`입니다. 누수센서·카메라·현장 사진 기능은 제거되었습니다.
- 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`sensor_metric`), SMS 이력(`sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다. - 구성도에는 큰 흐름이 있으나, 실제 구성요소인 STM32 보드, SHT30 온습도 센서, Cafe24 PHP API, MySQL, 측정 이력 테이블(`leak_sensor_metric`), SMS 이력(`leak_sms_log`), 월간 보고서가 명확히 표현되어 있지 않습니다.
- API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다. - API 키, 서명 검증, `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`), 서버 임계 판정(`config.php`의 `METRIC_*`), 관리자 TOTP MFA, 관리자 감사로그 같은 실제 보안 구현 항목이 본문에 구체적으로 반영되어야 합니다.
- 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다. - 로그 보관기간, SMS 수신자 개인정보 관리, `retention_cleanup.php` 기반 정리 절차, 백업/복구 증빙, 장애 대응 절차가 선언 수준에 머물러 있어 운영 기준표가 필요합니다.
- '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다. - '현장 사진/시설정보'는 더 이상 처리하지 않으므로 해당 절은 삭제하고, 통제 공백처럼 보이지 않도록 **온습도 임계 경보 + 오발송 방지(30분 쿨다운 / 복구 히스테리시스)**로 대체 서술합니다.
@ -35,7 +35,7 @@
| 권한 및 계정관리 (L106~L110) | 관리자 계정 최소 부여, 비밀번호 정책 | `ADMIN_PASSWORD_HASH`, `ADMIN_TOTP_SECRET`, `MFA_SETUP_TOKEN`, 세션 쿠키 옵션, 로그인 실패 잠금, 감사로그, 비밀번호/TOTP 교체 절차 추가 | 높음 | | 권한 및 계정관리 (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`와 일치, 불필요 기능 비활성화 추가. (카메라 권한 관리 문구 불필요) | 높음 | | 단말기 보안대책 (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/매직바이트 검증 문구 삭제) | 높음 | | 플랫폼/서비스 보안대책 (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 로그 삭제) | 높음 | | 로그관리 (L94~L95) | 로그 보관기간 정의, 위변조 방지 | `leak_sensor_log`, `leak_sensor_metric`, `leak_sms_log`, 서버 `php/var/admin_audit.log`, `retention_cleanup.php` 기준으로 보관·점검 기준 추가. (`leak_incident`/`leak_photo`/Pi 로그 삭제) | 높음 |
| 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 | | 장비 설치·보호 (L114, L118~L120) | "화재·누수 감지센서, IoT 보드 및 **카메라**" | "SHT30 온습도 센서, STM32 보드"로 교체. 카메라 문구 삭제. 자산대장 등록은 유지 | 높음 |
## 3. HWP에 추가해야 할 장·절 ## 3. HWP에 추가해야 할 장·절
@ -50,7 +50,7 @@
| SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 | | SHT30 온습도 센서 | I2C 온습도 측정(5분 주기) | 서버실 고정 설치, 임의 탈거 방지, 자산대장 등록 |
| PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 | | PHP API | `api/sensor_data.php` | API 키 서명 검증, HTTPS, 측정값 범위 검증, 서버 임계 판정 |
| 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 | | 임계 판정 | `config.php`의 `METRIC_*` 상수 | 고온30/저온10℃·고습70/저습20% 임계, 복구 히스테리시스(1.0℃/3.0%), 30분 쿨다운 |
| MySQL DB | `sensor_log`, `sensor_status`, `sensor_metric`, `sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 | | MySQL DB | `leak_sensor_log`, `leak_sensor_status`, `leak_sensor_metric`, `leak_sms_log` | 최소 권한 DB 계정, 백업, 보관기간 관리 |
| 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 | | 대시보드 | `dashboard.php` | 관리자 로그인, TOTP MFA, 세션 보호, 온습도/경보 현황 표시 |
| MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 | | MFA 등록 | `setup_mfa.php` | 임시 등록 토큰, 인증 앱 등록 검증, 외부 QR/API 미사용 |
| 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 | | 설치 점검 | `setup_wizard.php` | DB/권한/SMS/임계 설정 점검 |
@ -69,7 +69,7 @@
2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다. 2. STM32 보드가 측정값을 수집하여 정상 보고(`periodic`/`startup`)를 구성한다.
3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만). 3. 단말이 HTTPS로 PHP API에 측정값을 전송한다(인바운드 포트 미개방, 아웃바운드 POST만).
4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다. 4. 서버가 API 서명, API 키 일치, 필수 필드, 측정값 범위(온도 -40~125℃ / 습도 0~100%)를 검증한다.
5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `sensor_metric`에 저장하고, 정상 보고는 `sensor_status`를 갱신한다. 5. 서버가 운영 임계(`METRIC_*`)로 고온/저온/고습/저습을 재판정하여 `leak_sensor_metric`에 저장하고, 정상 보고는 `leak_sensor_status`를 갱신한다.
6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다. 6. 임계 이탈 시 30분 쿨다운과 복구 히스테리시스를 적용하여 종류별 SMS를 발송하고, 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다. 7. heartbeat 타임아웃이 지나면 장비 오프라인 SMS를, 보고 재개 시 복구 SMS를 발송한다.
8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다. 8. 대시보드와 월간 보고서에서 온습도·경보·SMS·오프라인 현황을 표시한다.
@ -85,12 +85,12 @@
| 단계 | 처리 내용 | 증적 | | 단계 | 처리 내용 | 증적 |
|---|---|---| |---|---|---|
| 측정 | SHT30 측정값 수신, `sensor_metric` 기록, 임계 판정 | `sensor_metric.metric_status` | | 측정 | SHT30 측정값 수신, `leak_sensor_metric` 기록, 임계 판정 | `leak_sensor_metric.metric_status` |
| 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) | | 경보 | 임계 이탈 시 종류별 SMS 발송(쿨다운 적용) | `leak_sms_log` (`[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) |
| 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 | | 확인 | 담당자가 대시보드에서 온습도/경보 상태 확인 | 대시보드 캡처 |
| 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 | | 조치 | 공조/환기 등 현장 점검 및 환경 정상화 | 운영 점검 기록 |
| 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `sms_log` 복구 메시지 | | 복구 | 임계 안쪽으로 회복 시 정상복귀 SMS | `leak_sms_log` 복구 메시지 |
| 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `sms_log` | | 오프라인 | heartbeat 타임아웃 시 오프라인 SMS, 복구 시 복구 SMS | `cron_heartbeat.php` 실행 로그, `leak_sms_log` |
| 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` | | 보고 | 월간 보고서에 경보·SMS·오프라인 현황 반영 | `monthly_report.php` |
### 3.4 설치·운영 점검 절차 ### 3.4 설치·운영 점검 절차
@ -100,8 +100,8 @@
| 점검 항목 | 확인 방법 | 불합격 시 조치 | | 점검 항목 | 확인 방법 | 불합격 시 조치 |
|---|---|---| |---|---|---|
| DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 | | DB 연결 | 설치 점검 화면 | `config.local.php` DB 설정 수정 |
| 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 또는 `sql/migration_drop_leak.sql` 적용 | | 필수 테이블 | 설치 점검 화면 | `sql/schema_sht30.sql` 적용 |
| 측정 이력 테이블 | 설치 점검 화면 | `sensor_metric` 생성 확인 | | 측정 이력 테이블 | 설치 점검 화면 | `leak_sensor_metric` 생성 확인 |
| 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 | | 임계 설정 | 설치 점검 화면 | `config.php`/`config.local.php`의 `METRIC_*` 값 확인 |
| API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 | | API 키 | 설치 점검 화면 | 서버와 단말의 키 일치 확인 |
| 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 | | 관리자 해시 | 설치 점검 화면 | `php setup_hash.php`로 해시 생성 |
@ -120,9 +120,9 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
| 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 | | 데이터 | 보관 위치 | 권장 보관기간 | 파기/정리 기준 |
|---|---|---:|---| |---|---|---:|---|
| 센서 이벤트 로그 | `sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) | | 센서 이벤트 로그 | `leak_sensor_log` | 365일 | 백업 후 기간 초과분 삭제(`retention_cleanup.php`) |
| 온습도 측정 이력 | `sensor_metric` | 365일 | 기간 초과분 삭제 | | 온습도 측정 이력 | `leak_sensor_metric` | 365일 | 기간 초과분 삭제 |
| SMS 발송 로그 | `sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 | | SMS 발송 로그 | `leak_sms_log` | 365일 | 개인정보 최소화 후 기간 초과분 삭제 |
| 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 | | 관리자 감사로그 | `php/var/admin_audit.log` | 365일 | 기간 초과분 정리 |
| 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 | | 설정 비밀값 | `config.local.php`, `firmware/common/secrets.h`(`APP_API_KEY`, 미커밋) | 운영 중 | 담당자 변경 또는 유출 의심 시 즉시 교체 |
@ -196,7 +196,7 @@ HWP의 "보관기간은 최소한으로 설정" 문구를 아래처럼 구체화
교체 문구: 교체 문구:
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다. > 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않으며, 처리하는 개인정보는 SMS 수신자 휴대전화번호로 한정한다. 수신자 번호는 알림 발송 목적에 한하여 최소 수집·이용하고, 담당자 변경 시 즉시 현행화한다. 온습도 임계 경보는 동일 종류 30분 쿨다운과 복구 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. SMS 발송 이력(`leak_sms_log`)은 장애 대응·감사 목적 범위에서만 열람하고, 보관기간 경과 시 삭제 또는 비식별 처리한다.
## 6. 추가 증적 목록 ## 6. 추가 증적 목록

View file

@ -100,10 +100,10 @@ HWP 추가 문구:
| 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` | | 전송구간 | 센서 데이터 탈취·변조 | HTTPS/TLS 사용, API 서명 검증 | `sensor_data.php`, 단말 펌웨어 | 실제 HTTPS 화면, `security_evidence.php?format=md` |
| API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 | | API 인증 | 임의 측정값 주입 | 공유 API 키와 요청 서명 검증 | PHP API, `firmware/common/secrets.h`(`APP_API_KEY`) | 403 테스트 결과, 보안 증적 보고서 |
| 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 | | 측정값 검증 | 비정상 측정값 저장 | 필수 필드·측정값 범위(온도 -40~125℃ / 습도 0~100%) 검증 | `sensor_data.php` | 비정상 요청 차단 테스트 결과 |
| 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `sms_log`, `sensor_metric` 상태 | | 임계 경보 | 오발송·미발송 | 서버 임계 판정(`METRIC_*`), 30분 쿨다운, 복구 히스테리시스 | `config.php`, `sensor_data.php` | `leak_sms_log`, `leak_sensor_metric` 상태 |
| 비밀값 | DB/SMS/API 키 유출 | 서버는 `config.local.php`, STM32 단말은 `firmware/common/secrets.h`(`APP_API_KEY`)로 분리, 저장소 미포함(.gitignore) | 서버/단말 설정 | STM32 콘솔 로그, .gitignore 설정, 저장소 검색 결과 | | 비밀값 | 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 등록 증빙, 감사로그, 보안 증적 보고서 | | 관리자 인증 | 대시보드 무단 접근 | 비밀번호 해시, TOTP MFA, 세션 쿠키 보호, 로그인 실패 제한, 감사로그 | `login.php`, `setup_mfa.php`, `admin_security.php`, `dashboard.php`, `security_evidence.php` | 로그인 화면, MFA 등록 증빙, 감사로그, 보안 증적 보고서 |
| 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `sms_log` | 수신자 목록 관리대장 | | 개인정보 | SMS 수신자 번호 노출 | 최소 수집, 담당자 변경 시 현행화, 로그 접근 제한 | `config.local.php`, `leak_sms_log` | 수신자 목록 관리대장 |
| 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 | | 로그관리 | 추적 불가 | 센서/측정/SMS 로그와 관리자 감사로그 저장, 보관기간 정리 | MySQL, `php/var/admin_audit.log`, `retention_cleanup.php` | 월간 보고서, 단말 증적 보고서, 감사로그, retention dry-run 결과 |
| 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 | | 백업 | 장애 시 복구 불가 | DB·설정 파일 백업, 복구 점검, 백업 증빙 생성 | 운영 절차, `scripts/backup_evidence.php` | 백업 이력, 복구 테스트 결과, backup-evidence 보고서 |
| 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 | | 패치 | 취약한 OS/PHP 사용 | 단말 OS, PHP, DB 정기 업데이트 | 운영 절차 | `OPERATIONS_SECURITY_CHECKLIST.md`, 월간 점검표 |
@ -126,13 +126,13 @@ HWP에 아래 문구를 추가합니다.
### 5.2 측정값 검증 및 서버 임계 판정 ### 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 오발송을 방지한다. > 센서 데이터 API는 POST JSON 요청과 필수 필드(장비 ID·센서 ID·이벤트 유형·타임스탬프)를 검증하고, 온도(-40~125℃)·습도(0~100%) 범위를 벗어난 값은 무효 처리한다. 임계 판정은 펌웨어가 아니라 서버 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 재수행하여 `leak_sensor_metric`에 상태를 기록한다. 동일 종류 경보는 30분 쿨다운, 복구는 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 SMS 오발송을 방지한다.
확인 증적: 확인 증적:
- 정상 측정값 저장 결과(`sensor_metric`) - 정상 측정값 저장 결과(`leak_sensor_metric`)
- 비정상 메소드/필드 누락/범위 초과 차단 결과 - 비정상 메소드/필드 누락/범위 초과 차단 결과
- 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`sms_log`) - 임계 초과 시 종류별 SMS, 회복 시 정상복귀 SMS 발송 이력(`leak_sms_log`)
### 5.3 관리자 페이지 접근통제 ### 5.3 관리자 페이지 접근통제
@ -164,7 +164,7 @@ STM32 단말 firmware/common/secrets.h: 저장소 미커밋(.gitignore), 빌드
확인 증적: 확인 증적:
- `sensor_status` 마지막 보고 시각 - `leak_sensor_status` 마지막 보고 시각
- `cron_heartbeat.php` 실행 로그 - `cron_heartbeat.php` 실행 로그
- 오프라인 SMS 발송 이력 - 오프라인 SMS 발송 이력
@ -213,7 +213,7 @@ HWP에 아래 책임표를 추가합니다.
|---|---|---:|---| |---|---|---:|---|
| SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 | | SMS 수신자 번호 | 개인정보 | 담당자 재직/업무 담당 기간 | 담당자 변경 즉시 삭제·수정 |
| SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 | | SMS 발송 로그 | 개인정보 포함 가능 | 1년 | DB 삭제 또는 비식별 처리 |
| 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`sensor_metric`) | | 온습도 측정 이력 | 운영 데이터 | 1년 | 기간 초과 DB 삭제(`leak_sensor_metric`) |
| 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 | | 센서 로그 | 운영 로그 | 1년 | 기간 초과 DB 삭제 |
| 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 | | 관리자 감사로그 | 감사/운영 이력 | 1년 | 기간 초과분 정리 |
| API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 | | API 키/SMS 인증키 | 인증정보 | 운영 중 | 교체 후 구 키 폐기 |
@ -266,9 +266,9 @@ HWP에 아래 책임표를 추가합니다.
| 정보 | 예시 | 목적 | 접근자 | 저장 위치 | | 정보 | 예시 | 목적 | 접근자 | 저장 위치 |
|---|---|---|---|---| |---|---|---|---|---|
| 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `sms_log` | | 담당자 휴대전화번호 | SMS 수신자 번호 | 이상상태 알림 | 운영 관리자 | `config.local.php`, `leak_sms_log` |
| 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `sensor_metric`, MySQL | | 온습도 측정값 | 온도, 습도, 시각, 임계 상태 | 환경 모니터링 | 운영 관리자 | `leak_sensor_metric`, MySQL |
| 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `sensor_log`, MySQL | | 센서 이벤트 | 센서 ID, 상태, 시각 | 장애 감지 | 운영 관리자 | `leak_sensor_log`, MySQL |
| 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` | | 관리자 계정 | 관리자 ID, 비밀번호 해시, TOTP 비밀키 | 대시보드 접근 | 운영 관리자 | `config.local.php`, `php/var/admin_audit.log` |
| 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB | | 장비 식별자 | Device ID, 위치명 | 장비 관리 | 운영 관리자 | 단말 설정, DB |
@ -278,7 +278,7 @@ HWP에 아래 책임표를 추가합니다.
### 9.3 경보 오발송 방지 문구 ### 9.3 경보 오발송 방지 문구
> 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `sms_log`에 기록하며, 보관기간 경과 시 정리한다. > 본 시스템은 현장 사진 등 시설정보를 수집·저장하지 않는다. 온습도 임계 경보는 서버에서 운영 임계(`METRIC_*`)로 판정하며, 동일 종류 경보는 30분 쿨다운, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 초과·정상복귀·장비 오프라인·복구 이력은 `leak_sms_log`에 기록하며, 보관기간 경과 시 정리한다.
## 10. 보안성 검토 제출 관점의 필수 첨부물 ## 10. 보안성 검토 제출 관점의 필수 첨부물
@ -317,7 +317,7 @@ HWP 부록에 아래 체크리스트를 넣습니다.
| 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | | | 11 | 장비 오프라인 감지가 동작하는가 | heartbeat 기준 알림 | | |
| 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | | | 12 | DB 백업과 복구 점검을 수행했는가 | 복구 테스트 완료 | | |
| 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | | | 13 | 단말이 부팅 후 자동 보고를 시작하는가 | 전원 인가 시 startup 보고·주기 보고 동작 | | |
| 14 | 온습도 측정·저장이 검증되었는가 | `sensor_metric` 측정 이력 저장 | | | | 14 | 온습도 측정·저장이 검증되었는가 | `leak_sensor_metric` 측정 이력 저장 | | |
| 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | | | 15 | 월간 보고서가 생성되는가 | 월별 조회/CSV 가능 | | |
| 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | | | 16 | 관리자 감사로그가 남는가 | 로그인 성공/실패, 로그아웃 기록 | | |
| 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | | | 17 | 보관기간 정리 절차가 있는가 | `retention_cleanup.php --dry-run` 결과 확인 | | |
@ -336,8 +336,8 @@ HWP 부록에 아래 체크리스트를 넣습니다.
| 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 | | 월간 보고서 화면 캡처 | 경보 처리 및 SMS/온습도 현황 확인 |
| API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 | | API 정상/비정상 테스트 로그 | 정상 요청 저장, 비정상 키 거부 확인 |
| 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 | | 테스트 SMS 수신 화면 | 담당자 알림 경로 확인 |
| 측정값 저장 결과 | `sensor_metric` 기록 및 임계 상태 확인 | | 측정값 저장 결과 | `leak_sensor_metric` 기록 및 임계 상태 확인 |
| 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`sms_log`) 확인 | | 임계 경보/복구 SMS 결과 | 종류별 경보·정상복귀 SMS 발송(`leak_sms_log`) 확인 |
| 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 | | 관리자 감사로그 | 로그인 성공/실패, 로그아웃 기록 확인 |
| 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 | | 보관기간 정리 dry-run | 측정/로그/감사로그 정리 대상 확인 |
| 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 | | 백업·복구 증빙 보고서 | 백업 파일 목록, 설정/감사로그 존재, 복구 테스트 결과 |

View file

@ -10,12 +10,6 @@
SOURCE sql/schema_sht30.sql; SOURCE sql/schema_sht30.sql;
``` ```
기존 누수 설치를 온습도 전용으로 전환하는 경우에는 전환 마이그레이션을 적용합니다.
```sql
SOURCE sql/migration_drop_leak.sql;
```
2. 서버 파일을 배포합니다. 2. 서버 파일을 배포합니다.
```text ```text
@ -26,7 +20,7 @@
sql/schema_sht30.sql sql/schema_sht30.sql
``` ```
API는 `sht30` 측정을 수신하면 `sensor_status`를 갱신하고 측정값을 `sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다. API는 `sht30` 측정을 수신하면 `leak_sensor_status`를 갱신하고 측정값을 `leak_sensor_metric`에 저장합니다. 서버가 `METRIC_*` 임계로 고온/저온/고습/저습을 판정하고, 초과 시 종류별 30분 쿨다운으로 SMS를 발송합니다.
## 2. 기본 구성 — STM32F407VGT6 (`sht30_fw`) ## 2. 기본 구성 — STM32F407VGT6 (`sht30_fw`)
@ -112,8 +106,8 @@ SHT30_I2C_ADDR=0x44
첫 정상 보고 후: 첫 정상 보고 후:
- `sensor_status`에 `sensor_id=2` 항목이 생성됩니다. - `leak_sensor_status`에 `sensor_id=2` 항목이 생성됩니다.
- `sensor_metric`에 온도/습도 이력이 누적됩니다. - `leak_sensor_metric`에 온도/습도 이력이 누적됩니다.
- `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다. - `dashboard.php`가 센서 카드에 최신 온도/습도와 임계 상태를 표시합니다.
장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다. 장비가 `HEARTBEAT_TIMEOUT_SEC`(기본 1200초=20분)을 초과해 보고를 멈추면 서버가 오프라인으로 판정하고 SMS를 발송합니다.

View file

@ -103,14 +103,14 @@ HWP 반영 문구:
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- 정상 측정값 저장 화면 또는 DB 기록(`sensor_metric`) - 정상 측정값 저장 화면 또는 DB 기록(`leak_sensor_metric`)
- 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과 - 비정상 메소드/필드 누락/범위 초과 요청 차단 테스트 결과
### 2.4 서버 임계 판정 및 SMS 오발송 방지 ### 2.4 서버 임계 판정 및 SMS 오발송 방지
HWP 반영 문구: HWP 반영 문구:
> 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다. > 온습도 임계 판정은 펌웨어가 아니라 서버에서 수행한다. 서버는 `config.php`의 운영 임계(`METRIC_*`: 고온30/저온10℃, 고습70/저습20%)로 고온·저온·고습·저습을 재판정하여 `leak_sensor_metric.metric_status`에 기록하고, 임계 이탈 시 종류별 SMS를 발송한다. 동일 종류 경보는 30분 쿨다운을 적용하고, 정상복귀 판정은 히스테리시스(온도 1.0℃, 습도 3.0%)를 적용하여 경계 채터링과 SMS 오발송을 방지한다. 임계 안쪽으로 회복하면 정상복귀 SMS를 발송한다.
코드 근거: 코드 근거:
@ -119,13 +119,13 @@ HWP 반영 문구:
- `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0) - `METRIC_TEMP_HYSTERESIS_C`(1.0), `METRIC_RH_HYSTERESIS`(3.0)
- `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분) - `METRIC_ALERT_COOLDOWN_SEC`(1800, 30분)
- `php/api/sensor_data.php` - `php/api/sensor_data.php`
- `evaluate_metric_thresholds()`로 서버 재판정 → `sensor_metric` - `evaluate_metric_thresholds()`로 서버 재판정 → `leak_sensor_metric`
- 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정 - 경보 종류(`high_temp`/`low_temp`/`high_humidity`/`low_humidity`)별 SMS, 쿨다운/복구 판정
- 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()` - 임계 이탈 시 `send_metric_alert_sms()`, 회복 시 `send_metric_recovery_sms()`
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- 임계 초과 시 종류별 경보 SMS 발송 이력(`sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`) - 임계 초과 시 종류별 경보 SMS 발송 이력(`leak_sms_log`: `[고온경보]`/`[저온경보]`/`[고습경보]`/`[저습경보]`)
- 정상복귀 SMS 발송 이력 - 정상복귀 SMS 발송 이력
- 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과 - 쿨다운 적용으로 동일 경보가 30분 내 재발송되지 않는 결과
@ -140,15 +140,15 @@ HWP 반영 문구:
- `php/config.php` - `php/config.php`
- `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300) - `HEARTBEAT_TIMEOUT_SEC`(1200), `HEARTBEAT_CHECK_INTERVAL_SEC`(300)
- `php/cron_heartbeat.php` - `php/cron_heartbeat.php`
- 마지막 보고 시각 기준 오프라인 판정, `sensor_status.offline_alerted` 관리 - 마지막 보고 시각 기준 오프라인 판정, `leak_sensor_status.offline_alerted` 관리
- `php/api/sensor_data.php` - `php/api/sensor_data.php`
- 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송 - 오프라인 상태였던 센서가 다시 보고하면 복구 SMS 발송
사용자가 제공해야 하는 증적: 사용자가 제공해야 하는 증적:
- `sensor_status` 마지막 보고 시각 - `leak_sensor_status` 마지막 보고 시각
- `cron_heartbeat.php` 실행 로그 - `cron_heartbeat.php` 실행 로그
- 오프라인/복구 SMS 발송 이력(`sms_log`) - 오프라인/복구 SMS 발송 이력(`leak_sms_log`)
### 2.6 비밀값 코드 분리 ### 2.6 비밀값 코드 분리
@ -270,6 +270,6 @@ curl -I https://example.com/sht30_monitor/login.php
2. `setup_wizard.php` 점검 화면 2. `setup_wizard.php` 점검 화면
3. API 정상/403 테스트 결과 3. API 정상/403 테스트 결과
4. 단말 `systemctl status leak-sensor` 화면 4. 단말 `systemctl status leak-sensor` 화면
5. 측정값 저장(`sensor_metric`) 및 임계 경보 SMS(`sms_log`) 화면 5. 측정값 저장(`leak_sensor_metric`) 및 임계 경보 SMS(`leak_sms_log`) 화면
이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다. 이 5개가 있으면 소스코드 기반 보안대책과 운영 적용 증적을 어느 정도 연결할 수 있습니다.

View file

@ -117,7 +117,7 @@
<h2>운영 자가진단</h2> <h2>운영 자가진단</h2>
<div class="diag-list"> <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>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"><i class="dot"></i><div><strong>SHT30 metric table · 정상</strong><span>leak_sensor_metric 저장 가능</span></div></div>
<div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div> <div class="diag warn"><i class="dot"></i><div><strong>최근 SMS 실패 · 확인 필요</strong><span>최근 7일 실패 1건</span></div></div>
</div> </div>
</aside> </aside>

View file

@ -316,7 +316,7 @@
<div class="grid"> <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><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>등록 센서, 임계 경보, 오프라인, 30일 임계 SMS, SMS 실패를 빠르게 확인한다.</span></div>
<div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div> <div class="tile"><strong>운영 자가진단</strong><span>API 키, DB 테이블, leak_sensor_metric 저장, SMS 실패 등 운영 준비 상태를 보여준다.</span></div>
</div> </div>
</section> </section>
@ -401,7 +401,7 @@
<tr> <tr>
<td><span class="state warn">센서 온습도 미표시</span></td> <td><span class="state warn">센서 온습도 미표시</span></td>
<td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td> <td>SHT30 데이터가 저장되지 않았거나 측정이 실패했다.</td>
<td><code>sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td> <td><code>leak_sensor_metric</code> 테이블, <code>sht30-sensor</code> 서비스, I2C 주소 <code>0x44/0x45</code> 확인.</td>
</tr> </tr>
<tr> <tr>
<td><span class="state warn">SMS 실패</span></td> <td><span class="state warn">SMS 실패</span></td>

View file

@ -317,7 +317,7 @@
<div class="hero-copy"> <div class="hero-copy">
<div class="eyebrow">설치 목표</div> <div class="eyebrow">설치 목표</div>
<h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2> <h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2>
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p> <p>두 장비 모두 SHT30 센서를 I2C로 읽어 `leak_sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
<div class="footer-actions"> <div class="footer-actions">
<button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button> <button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button>
<a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a> <a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a>
@ -521,7 +521,7 @@ journalctl -u sht30-sensor -f</code></pre>
</tr> </tr>
<tr> <tr>
<td><span class="status warn">metric_saved false</span></td> <td><span class="status warn">metric_saved false</span></td>
<td>서버는 받았지만 `sensor_metric` 테이블이 없음.</td> <td>서버는 받았지만 `leak_sensor_metric` 테이블이 없음.</td>
<td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td> <td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td>
</tr> </tr>
<tr> <tr>

View file

@ -69,7 +69,7 @@ SHT30 모듈은 3.3V 단일 전원으로 동작합니다. SCL/SDA는 오픈드
정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`. 정상 측정 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): 온도 `[-40, 125]°C`, 상대습도 `[0, 100]%`.
> 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다. > 참고: `out_of_range` 는 **펌웨어 전용 타당성(plausibility) 분류**입니다. 서버는 이 값을 무시하고 운영 임계(`METRIC_*`)로 재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되며 `out_of_range` 는 저장되지 않습니다.
--- ---

View file

@ -74,7 +74,7 @@
| 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) | | 프로세스 관리 | systemd 재시작 | IWDG 워치독 + 재연결 | 변경(이식) |
| 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) | | 로깅 | 로테이팅 파일 로그 | UART 콘솔 | 변경(축소) |
> **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음). > **임계 판정의 위치:** 펌웨어는 `temperature_c`/`humidity_percent` 원값과 `metric_status`(센서 물리/타당성 범위 기준 `normal`/`out_of_range`)만 보고한다. 고온/저온/고습/저습 경보와 SMS 트리거는 **서버 `config.php` 의 `METRIC_*` 임계**가 담당한다. 폐쇄망에서 임계값을 조정할 때 펌웨어 재플래시가 불필요하도록 의도된 분리다. `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 저장되지 않음).
자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고. 자세한 함수 단위 매핑은 [`docs/PORTING_NOTES.md`](docs/PORTING_NOTES.md) 참고.

View file

@ -100,7 +100,7 @@ Sensirion SHT30 (한진데이터 P4422-3 모듈), I2C. RPi `sht30_monitor.py`
- 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장. - 풀업: SCL/SDA 각각 **4.7kΩ → 3.3V** (모듈에 내장 풀업이 있으면 생략). I2C 속도 100kHz(Standard) 권장.
- plausibility 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)). - plausibility 범위(벗어나면 펌웨어가 `metric_status="out_of_range"` 로 표시): T `[-40, 125]°C`, RH `[0, 100]%` ([`common/app_config.h`](../common/app_config.h)).
운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고). 운영 경보 임계(고온/저온/고습/저습 → SMS)는 **서버 `php/config.php` 의 `METRIC_*` 상수**가 판정한다(펌웨어는 원값만 보고).
`out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음). `out_of_range` 는 **펌웨어 전용 타당성 분류**이며 서버가 무시·재판정하므로, DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장된다(`out_of_range` 는 DB에 남지 않음).
``` ```
SHT30 모듈 STM32 SHT30 모듈 STM32

View file

@ -120,7 +120,7 @@ SHT30 측정에서 SMS 발송까지의 책임 분담:
센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해 센서 물리/타당성 범위(`APP_SHT30_TEMP/RH_LOW/HIGH`) 기준으로 `metric_status`(`normal`/`out_of_range`)만 부여해
`sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.** `sensor_data.php` 로 POST 한다. 펌웨어는 **고온/저온/고습/저습 같은 운영 임계를 판정하지 않는다.**
여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로 여기서 `out_of_range` 는 **펌웨어 전용 타당성 분류**다. 서버는 이 값을 무시하고 운영 임계로 재판정하므로
DB `sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `out_of_range` 는 저장되지 않는다. DB `leak_sensor_metric.metric_status` 에는 `normal`/`high_temp`/`low_temp`/`high_humidity`/`low_humidity` 만 저장되고 `out_of_range` 는 저장되지 않는다.
2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해 2. **서버(임계·SMS)**: `sensor_data.php` 가 본문을 DB 에 적재하고, `config.php` 의 `METRIC_*` 임계와 비교해
고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다. 고온/저온/고습/저습 조건이면 **SMS 게이트웨이로 문자**를 발송한다.

View file

@ -1,6 +1,5 @@
# ============================================================================= # =============================================================================
# .htaccess - 누수감지 시스템 보안 설정 # .htaccess - 누수감지 시스템 보안 설정
# 배포 경로: pores.kr/raspi_leck_detecter/
# ============================================================================= # =============================================================================
# ─── HTTPS 강제 리다이렉트 ───────────────────────────────── # ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
@ -37,7 +36,11 @@
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY" Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin" # 내부 관리 시스템 — 검색엔진 색인 금지(HTTP 레벨, 전 응답 공통).
# robots.txt 는 쓰지 않는다: 공개 파일이라 난독화 경로를 오히려 광고하게 됨.
Header always set X-Robots-Tag "noindex, nofollow, noarchive"
# 외부로 Referer 자체를 보내지 않음(도메인 확인 유출 차단). 내부 이동은 영향 없음.
Header always set Referrer-Policy "no-referrer"
# TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부 # TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부
# 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다. # 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'" Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"

View file

@ -26,9 +26,7 @@
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."} // [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
// ============================================================================= // =============================================================================
header('Access-Control-Allow-Origin: https://pores.kr'); // CORS 헤더 없음 — 기기 서버측 POST 전용(브라우저 미관여), Allow-Origin 은 도메인 지문만 노출.
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
http_response_code(204); http_response_code(204);
@ -101,7 +99,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
return 'unavailable'; return 'unavailable';
} }
try { try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]); $ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) { } catch (PDOException $e) {
$code = $e->getCode(); $code = $e->getCode();
@ -115,7 +113,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
} }
try { try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) { } catch (Throwable $e) {
/* 정리는 실패해도 무방 */ /* 정리는 실패해도 무방 */
} }
@ -127,7 +125,7 @@ function photo_nonce_release(PDO $db, string $sig): void {
return; return;
} }
try { try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]); ->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[PHOTO] nonce release 오류: " . $e->getMessage()); error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
@ -248,9 +246,9 @@ $filename = '';
try { try {
$db = get_db(); $db = get_db();
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다 // log_id 가 leak_sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id. // (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
$chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id"); $chk = $db->prepare("SELECT sensor_id, device_id FROM leak_sensor_log WHERE id = :id");
$chk->execute([':id' => $log_id]); $chk->execute([':id' => $log_id]);
$log_row = $chk->fetch(); $log_row = $chk->fetch();
if (!$log_row) { if (!$log_row) {

View file

@ -45,10 +45,8 @@
// {"status":"ok","message":"저장 완료", ...} // {"status":"ok","message":"저장 완료", ...}
// ============================================================================= // =============================================================================
// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요) // CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
header('Access-Control-Allow-Origin: https://pores.kr'); // same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); http_response_code(204);
@ -109,7 +107,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
return 'unavailable'; return 'unavailable';
} }
try { try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]); $ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) { } catch (PDOException $e) {
$code = $e->getCode(); $code = $e->getCode();
@ -124,7 +122,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움). // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
try { try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) { } catch (Throwable $e) {
/* 정리는 실패해도 무방 */ /* 정리는 실패해도 무방 */
} }
@ -138,7 +136,7 @@ function ingest_nonce_release(PDO $db, string $sig): void {
return; return;
} }
try { try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]); ->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[API] nonce release 오류: " . $e->getMessage()); error_log("[API] nonce release 오류: " . $e->getMessage());
@ -246,7 +244,7 @@ $has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humi
// ── 서버측 임계 판정 (config.php METRIC_*) ───────────────── // ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
// 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다. // 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
$metric_eval = $has_metric_payload $metric_eval = $has_metric_payload
? evaluate_metric_thresholds($temperature_c, $humidity_percent) ? evaluate_metric_thresholds($temperature_c, $humidity_percent)
: ['breaches' => [], 'status' => 'normal']; : ['breaches' => [], 'status' => 'normal'];
@ -255,11 +253,12 @@ $metric_status = $metric_eval['status'];
$metric_saved = false; $metric_saved = false;
// ── DB 저장 ─────────────────────────────────────────────── // ── DB 저장 ───────────────────────────────────────────────
// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 // 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록. // (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다 // periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵.
// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상). // (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic' && $has_metric_payload); $skip_log = ($event_type === 'periodic');
$log_id = null; $log_id = null;
try { try {
@ -280,7 +279,7 @@ try {
if (!$skip_log) { if (!$skip_log) {
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sensor_log INSERT INTO leak_sensor_log
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
event_type, is_leak, sensor_time, created_at) event_type, is_leak, sensor_time, created_at)
VALUES VALUES
@ -302,7 +301,7 @@ try {
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
$was_offline = false; $was_offline = false;
$chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id"); $chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id");
$chk->execute([':sensor_id' => $sensor_id]); $chk->execute([':sensor_id' => $sensor_id]);
$offline_flag = $chk->fetchColumn(); $offline_flag = $chk->fetchColumn();
if ($offline_flag === '1' || $offline_flag === 1) { if ($offline_flag === '1' || $offline_flag === 1) {
@ -314,7 +313,7 @@ try {
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서 // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게). // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare(" $stmt2 = $db->prepare("
INSERT INTO sensor_status INSERT INTO leak_sensor_status
(sensor_id, device_id, sensor_name, device_location, (sensor_id, device_id, sensor_name, device_location,
last_event_type, is_leak, last_seen, offline_alerted) last_event_type, is_leak, last_seen, offline_alerted)
VALUES VALUES
@ -339,9 +338,9 @@ try {
':is_leak_event' => $is_leak_event ? 1 : 0, ':is_leak_event' => $is_leak_event ? 1 : 0,
]); ]);
if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) { if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
$metric_stmt = $db->prepare(" $metric_stmt = $db->prepare("
INSERT INTO sensor_metric INSERT INTO leak_sensor_metric
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
metric_type, temperature_c, humidity_percent, metric_status, metric_type, temperature_c, humidity_percent, metric_status,
measured_at, created_at) measured_at, created_at)

View file

@ -41,19 +41,24 @@ return [
// Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment. // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment.
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
// 누수 모듈 sensor_id 목록 — 대시보드 누수 상태 카드 표시 대상 (기본 [1]).
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
'LEAK_SENSOR_IDS' => [1],
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정. // 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
'METRIC_TEMP_HIGH_C' => 30, // 고온 임계(℃) 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃) 'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)
'METRIC_RH_HIGH' => 70, // 고습 임계(%) 'METRIC_RH_HIGH' => 60, // 고습 임계(%)
'METRIC_RH_LOW' => 20, // 저습 임계(%) 'METRIC_RH_LOW' => -1, // 저습 경보 비활성(RH 는 항상 ≥0 → 절대 발화 안 함)
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로(이슬점) 경보 비활성(0 이면 판정 분기 스킵)
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분) 'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
// 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in). // 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
'SMS_RECIPIENTS_TIER2' => [], 'SMS_RECIPIENTS_TIER2' => [],
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지 'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
// 아래는 기본값으로 동작(필요 시에만 override): // 아래는 기본값으로 동작(필요 시에만 override):
// METRIC_DEWPOINT_MARGIN_C(3℃ 결로), SMS_BALANCE_WARN_THRESHOLD(30건), // SMS_BALANCE_WARN_THRESHOLD(30건), HEALTH_SMS_HOUR(9시),
// HEALTH_SMS_HOUR(9시), STUCK_DETECT_SAMPLES(6), SMS_RETRY_OFFLINE(true) // STUCK_DETECT_SAMPLES(6), SMS_RETRY_OFFLINE(true)
'SMS_LOG_RETENTION_DAYS' => 365, 'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365, 'SENSOR_LOG_RETENTION_DAYS' => 365,

View file

@ -51,7 +51,7 @@ define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창 // 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/ // (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다. // 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). // leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true)); define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
@ -78,7 +78,7 @@ define('SMS_RECIPIENTS', $sms_recipients);
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90)); define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ────────────────────────────── // ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 // leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다. // 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성 // 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다. // 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
@ -107,7 +107,7 @@ const SMS_METRIC_TAGS = [
]; ];
/** /**
* sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery, * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
@ -192,12 +192,30 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
// ─── Heartbeat (장비 오프라인 감지) ──────────────────────── // ─── Heartbeat (장비 오프라인 감지) ────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본 // SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. // 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인
// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능.
// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC)
// ─── 누수 모듈 sensor_id 목록 ────────────────────────────
// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로
// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용)
// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖·
// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드
// 전체가 사라지는 것 방지).
$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]);
if (is_string($leak_sensor_ids)) {
$leak_sensor_ids = explode(',', $leak_sensor_ids);
}
$leak_sensor_ids = array_values(array_filter(
array_map('intval', (array)$leak_sensor_ids),
static fn($id) => $id >= 1 && $id <= 255
));
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
// ─── 대시보드 세션 인증 ────────────────────────────────── // ─── 대시보드 세션 인증 ──────────────────────────────────
define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
@ -244,7 +262,7 @@ function json_response(array $data, int $http_code = 200): void {
// ─── 개인정보(수신번호) 마스킹 ───────────────────────────── // ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록 // 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). // 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다. // 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
function mask_phone(string $value): string { function mask_phone(string $value): string {
if (strpos($value, ',') !== false) { if (strpos($value, ',') !== false) {
@ -266,7 +284,7 @@ function mask_phone(string $value): string {
function latest_sms_balance(PDO $db): ?int { function latest_sms_balance(PDO $db): ?int {
try { try {
$rm = $db->query(" $rm = $db->query("
SELECT result_message FROM sms_log SELECT result_message FROM leak_sms_log
WHERE status = 'success' AND result_message LIKE '%잔여%' WHERE status = 'success' AND result_message LIKE '%잔여%'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
")->fetchColumn(); ")->fetchColumn();

View file

@ -23,7 +23,7 @@ function check_offline_sensors(): int {
$db = get_db(); $db = get_db();
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT * FROM sensor_status SELECT * FROM leak_sensor_status
WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
AND offline_alerted = 0 AND offline_alerted = 0
"); ");
@ -36,7 +36,7 @@ function check_offline_sensors(): int {
// last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면 // last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면
// (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다. // (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다.
$claim = $db->prepare(" $claim = $db->prepare("
UPDATE sensor_status SET offline_alerted = 1 UPDATE leak_sensor_status SET offline_alerted = 1
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND offline_alerted = 0 AND offline_alerted = 0
AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND)
@ -66,7 +66,7 @@ function check_offline_sensors(): int {
if (!$sent && SMS_RETRY_OFFLINE) { if (!$sent && SMS_RETRY_OFFLINE) {
// 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에 // 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에
// 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭). // 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭).
$db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid") $db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid")
->execute([':sid' => $sensor['sensor_id']]); ->execute([':sid' => $sensor['sensor_id']]);
error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약"); error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약");
} }

View file

@ -6,7 +6,7 @@
// 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다. // 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다.
// 임계 판정과 SMS // 임계 판정과 SMS
// 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고, // 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고,
// 이 대시보드는 그 결과(sensor_metric/sensor_status/sms_log)를 표시한다. // 이 대시보드는 그 결과(leak_sensor_metric/leak_sensor_status/leak_sms_log)를 표시한다.
// ============================================================================= // =============================================================================
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
@ -84,16 +84,16 @@ function table_exists(PDO $db, string $table): bool {
} }
// ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── // ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll(); $statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
// 센서별 최신 sensor_metric (최대 id 기준) // 센서별 최신 leak_sensor_metric (최대 id 기준)
$latest_metrics = []; $latest_metrics = [];
$metric_rows = $db->query(" $metric_rows = $db->query("
SELECT sm.* SELECT sm.*
FROM sensor_metric sm FROM leak_sensor_metric sm
INNER JOIN ( INNER JOIN (
SELECT sensor_id, MAX(id) AS latest_id SELECT sensor_id, MAX(id) AS latest_id
FROM sensor_metric FROM leak_sensor_metric
GROUP BY sensor_id GROUP BY sensor_id
) latest ON latest.latest_id = sm.id ) latest ON latest.latest_id = sm.id
ORDER BY sm.sensor_id ORDER BY sm.sensor_id
@ -140,7 +140,7 @@ $chart_points = [];
if ($chart_sensor_id > 0) { if ($chart_sensor_id > 0) {
$chart_stmt = $db->prepare(" $chart_stmt = $db->prepare("
SELECT measured_at, temperature_c, humidity_percent SELECT measured_at, temperature_c, humidity_percent
FROM sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid WHERE sensor_id = :sid
ORDER BY id DESC ORDER BY id DESC
LIMIT 100 LIMIT 100
@ -158,36 +158,36 @@ if ($chart_sensor_id > 0) {
} }
} }
// ── 최근 측정 이력 (sensor_metric 30건) ───────────────────── // ── 최근 측정 이력 (leak_sensor_metric 30건) ─────────────────────
$recent_metrics = $db->query(" $recent_metrics = $db->query("
SELECT sensor_id, sensor_name, device_location, SELECT sensor_id, sensor_name, device_location,
temperature_c, humidity_percent, metric_status, measured_at temperature_c, humidity_percent, metric_status, measured_at
FROM sensor_metric FROM leak_sensor_metric
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ── // ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ──
$logs = $db->query(" $logs = $db->query("
SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time
FROM sensor_log FROM leak_sensor_log
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
// ── 최근 SMS 발송 이력 ───────────────────────────────────── // ── 최근 SMS 발송 이력 ─────────────────────────────────────
$sms_logs = $db->query(" $sms_logs = $db->query("
SELECT * FROM sms_log SELECT * FROM leak_sms_log
ORDER BY id DESC ORDER BY id DESC
LIMIT 20 LIMIT 20
")->fetchAll(); ")->fetchAll();
// ── 30일 운영 요약 ───────────────────────────────────────── // ── 30일 운영 요약 ─────────────────────────────────────────
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준) // 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준)
$sms_summary_rows = $db->query(" $sms_summary_rows = $db->query("
SELECT SELECT
" . sms_summary_select_sql() . " " . sms_summary_select_sql() . "
FROM sms_log FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
@ -209,7 +209,7 @@ $summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
// 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값) // 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값)
$metric_avg = $db->query(" $metric_avg = $db->query("
SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
$avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null; $avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null;
@ -239,7 +239,7 @@ $normal_count = count($statuses) - $offline_count - $alert_count;
// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ───── // ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ─────
// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고). // 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고).
$recent_alert_fail = (int)$db->query(" $recent_alert_fail = (int)$db->query("
SELECT COUNT(*) FROM sms_log SELECT COUNT(*) FROM leak_sms_log
WHERE status = 'fail' WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
@ -252,14 +252,19 @@ $recent_alert_fail = (int)$db->query("
$ingest_rejections = read_ingest_rejections(1); $ingest_rejections = read_ingest_rejections(1);
$rejection_total = array_sum($ingest_rejections); $rejection_total = array_sum($ingest_rejections);
// ── 누수(1번 모듈) 현재 상태 + 사고/사진 ─────────────────── // ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
// 누수 센서(sensor_id=1)의 sensor_status.is_leak 로 누수/정상을 표시한다. 오프라인 // 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로
// 판정은 위 $statuses 계산과 동일한 로직을 따른다(행이 없으면 '데이터 없음'). // 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을
$leak_status = null; // 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의
// 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다.
$leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null)
foreach (LEAK_SENSOR_IDS as $leak_sid) {
$leak_statuses[$leak_sid] = null;
}
foreach ($statuses as $s) { foreach ($statuses as $s) {
if ((int)$s['sensor_id'] === 1) { $sid = (int)$s['sensor_id'];
$leak_status = $s; if (array_key_exists($sid, $leak_statuses)) {
break; $leak_statuses[$sid] = $s;
} }
} }
@ -689,32 +694,32 @@ if ($alert_count > 0) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 누수(1번 모듈) 상태 + 사고 이력 + 사진 --> <!-- 누수 모듈 상태 + 사고 이력 + 사진 -->
<h2 id="leak-section">누수 감지 (1번 모듈)</h2> <h2 id="leak-section">누수 감지</h2>
<?php
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
if ($leak_status === null) {
$leak_card_class = '';
$leak_badge_class = 'offline';
$leak_badge_text = '데이터 없음';
} elseif ($leak_status['is_offline']) {
$leak_card_class = 'offline';
$leak_badge_class = 'offline';
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
$leak_card_class = 'leak';
$leak_badge_class = 'leak';
$leak_badge_text = '누수';
} else {
$leak_card_class = '';
$leak_badge_class = 'ok';
$leak_badge_text = '정상';
}
?>
<div class="sensor-grid"> <div class="sensor-grid">
<?php foreach ($leak_statuses as $leak_sid => $leak_status):
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
if ($leak_status === null) {
$leak_card_class = '';
$leak_badge_class = 'offline';
$leak_badge_text = '데이터 없음';
} elseif ($leak_status['is_offline']) {
$leak_card_class = 'offline';
$leak_badge_class = 'offline';
$leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ((int)($leak_status['is_leak'] ?? 0) === 1) {
$leak_card_class = 'leak';
$leak_badge_class = 'leak';
$leak_badge_text = '누수';
} else {
$leak_card_class = '';
$leak_badge_class = 'ok';
$leak_badge_text = '정상';
}
?>
<div class="sensor-card leak-status-card <?= $leak_card_class ?>"> <div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?> <?php if ($leak_status === null): ?>
<strong>1번 센서 (누수)</strong><br> <strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
<div style="margin-top:10px"> <div style="margin-top:10px">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span> <span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div> </div>
@ -733,6 +738,7 @@ if ($alert_count > 0) {
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?>
</div> </div>
<details class="mobile-detail" data-mobile-collapsed open> <details class="mobile-detail" data-mobile-collapsed open>

View file

@ -4,7 +4,7 @@
// //
// 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구, // 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구,
// SMS 실패, 측정 건수, 평균/최저/최고 온도·습도. // SMS 실패, 측정 건수, 평균/최저/최고 온도·습도.
// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력). // 데이터 출처: leak_sms_log(message 태그 prefix 기준), leak_sensor_metric(온습도 측정 이력).
// ============================================================================= // =============================================================================
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
@ -80,7 +80,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS';
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT SELECT
" . sms_summary_select_sql() . " " . sms_summary_select_sql() . "
FROM sms_log FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at WHERE sent_at >= :start_at AND sent_at < :end_at
"); ");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
@ -116,7 +116,7 @@ $stmt = $db->prepare("
AVG(humidity_percent) AS avg_humidity, AVG(humidity_percent) AS avg_humidity,
MIN(humidity_percent) AS min_humidity, MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity MAX(humidity_percent) AS max_humidity
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at WHERE measured_at >= :start_at AND measured_at < :end_at
"); ");
$stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]);
@ -133,7 +133,7 @@ $summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null);
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT sensor_id, sensor_name, SELECT sensor_id, sensor_name,
" . sms_summary_select_sql() . " " . sms_summary_select_sql() . "
FROM sms_log FROM leak_sms_log
WHERE sent_at >= :start_at AND sent_at < :end_at WHERE sent_at >= :start_at AND sent_at < :end_at
GROUP BY sensor_id, sensor_name GROUP BY sensor_id, sensor_name
"); ");
@ -154,7 +154,7 @@ $stmt = $db->prepare("
MIN(humidity_percent) AS min_humidity, MIN(humidity_percent) AS min_humidity,
MAX(humidity_percent) AS max_humidity, MAX(humidity_percent) AS max_humidity,
MAX(measured_at) AS last_measured_at MAX(measured_at) AS last_measured_at
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= :start_at AND measured_at < :end_at WHERE measured_at >= :start_at AND measured_at < :end_at
GROUP BY sensor_id, sensor_name, device_location GROUP BY sensor_id, sensor_name, device_location
"); ");

View file

@ -48,7 +48,7 @@ function collect_ops_checks(): array {
$db = get_db(); $db = get_db();
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면'); $checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log']; $required_tables = ['leak_sensor_log', 'leak_sensor_status', 'leak_sensor_metric', 'leak_sms_log'];
foreach ($required_tables as $table) { foreach ($required_tables as $table) {
$exists = ops_table_exists($db, $table); $exists = ops_table_exists($db, $table);
$checks[] = ops_check( $checks[] = ops_check(
@ -63,30 +63,30 @@ function collect_ops_checks(): array {
} }
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인) // 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
if (ops_table_exists($db, 'sensor_metric')) { if (ops_table_exists($db, 'leak_sensor_metric')) {
$recent_metric_stmt = $db->query(" $recent_metric_stmt = $db->query("
SELECT COUNT(*) SELECT COUNT(*)
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
"); ");
$recent_metric = (int)$recent_metric_stmt->fetchColumn(); $recent_metric = (int)$recent_metric_stmt->fetchColumn();
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신'); $checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'leak_sensor_metric 최근 수신');
} }
if (ops_table_exists($db, 'sms_log')) { if (ops_table_exists($db, 'leak_sms_log')) {
$recent_sms_fail_stmt = $db->query(" $recent_sms_fail_stmt = $db->query("
SELECT COUNT(*) SELECT COUNT(*)
FROM sms_log FROM leak_sms_log
WHERE status = 'fail' WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
"); ");
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn(); $recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수'); $checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'leak_sms_log 최근 실패 건수');
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호). // SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
$sms_balance = latest_sms_balance($db); $sms_balance = latest_sms_balance($db);
if ($sms_balance !== null) { if ($sms_balance !== null) {
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여'); $checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'leak_sms_log 최근 발송 잔여');
} }
} }
} catch (Throwable $e) { } catch (Throwable $e) {

View file

@ -46,9 +46,9 @@ $summary = [
'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS, 'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS,
'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS, 'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS,
'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS, 'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS,
'sms_log' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sms_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_log' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sensor_log' => ['matched' => 0, 'rows_deleted' => 0],
'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0], 'leak_sensor_metric' => ['matched' => 0, 'rows_deleted' => 0],
'admin_audit' => ['kept_lines' => null, 'removed_lines' => null], 'admin_audit' => ['kept_lines' => null, 'removed_lines' => null],
]; ];
@ -62,16 +62,16 @@ try {
} }
$smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS); $smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS);
$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]); $summary['leak_sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]);
$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun); $summary['leak_sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun);
$sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS); $sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS);
$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]); $summary['leak_sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]);
$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun); $summary['leak_sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun);
$metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS); $metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS);
$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]); $summary['leak_sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]);
$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun); $summary['leak_sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun);
$auditPath = admin_audit_log_path(); $auditPath = admin_audit_log_path();
if (is_file($auditPath)) { if (is_file($auditPath)) {
@ -113,9 +113,9 @@ if (is_file($auditPath)) {
admin_audit_log('retention_cleanup', 'system', true, [ admin_audit_log('retention_cleanup', 'system', true, [
'mode' => $summary['mode'], 'mode' => $summary['mode'],
'sms_matches' => $summary['sms_log']['matched'], 'sms_matches' => $summary['leak_sms_log']['matched'],
'sensor_matches' => $summary['sensor_log']['matched'], 'sensor_matches' => $summary['leak_sensor_log']['matched'],
'metric_matches' => $summary['sensor_metric']['matched'], 'metric_matches' => $summary['leak_sensor_metric']['matched'],
]); ]);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');

View file

@ -30,8 +30,8 @@ $control_rows = [
['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'], ['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'],
['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'], ['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'],
['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'], ['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'],
['로그관리', '센서 측정 이력(sensor_metric)·SMS 발송 이력(sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'], ['로그관리', '센서 측정 이력(leak_sensor_metric)·SMS 발송 이력(leak_sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'],
['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, sensor_metric 이력'], ['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, leak_sensor_metric 이력'],
['백업', 'DB/설정 백업 목록과 복구 테스트 증적 관리', 'scripts/backup_evidence.php', 'backup-evidence 보고서'], ['백업', 'DB/설정 백업 목록과 복구 테스트 증적 관리', 'scripts/backup_evidence.php', 'backup-evidence 보고서'],
]; ];

View file

@ -169,7 +169,7 @@ function send_sms_cafe24(string $receiver, string $message): array {
} }
/** /**
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다. * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다.
* *
* @param PDO $db * @param PDO $db
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...] * @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
@ -191,7 +191,7 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message
} }
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sms_log INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers, (sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at) status, result_code, result_message, sent_at)
VALUES VALUES
@ -234,7 +234,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
} }
// 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지). // 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지).
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE message LIKE :tag WHERE message LIKE :tag
ORDER BY sent_at DESC ORDER BY sent_at DESC
LIMIT 1 LIMIT 1
@ -254,7 +254,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
$receiverDigits = preg_replace('/\D+/', '', $receiver); $receiverDigits = preg_replace('/\D+/', '', $receiver);
$result = send_sms_cafe24($receiverDigits, $message); $result = send_sms_cafe24($receiverDigits, $message);
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sms_log INSERT INTO leak_sms_log
(sensor_id, sensor_name, device_id, message, receivers, (sensor_id, sensor_name, device_id, message, receivers,
status, result_code, result_message, sent_at) status, result_code, result_message, sent_at)
VALUES VALUES
@ -329,7 +329,7 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool {
/** /**
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다. * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
* @return array{breaches: string[], status: string} * @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태 * breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
*/ */
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array { function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
$breaches = []; $breaches = [];
@ -379,7 +379,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool {
/** /**
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가 * 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준). * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준).
*/ */
function metric_was_alerting(PDO $db, int $sensor_id): bool { function metric_was_alerting(PDO $db, int $sensor_id): bool {
// 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지. // 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지.
@ -397,7 +397,7 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool {
$likes[] = "message LIKE {$key}"; $likes[] = "message LIKE {$key}";
$params[$key] = $tag . '%'; $params[$key] = $tag . '%';
} }
$sql = "SELECT message FROM sms_log $sql = "SELECT message FROM leak_sms_log
WHERE sensor_id = :sid WHERE sensor_id = :sid
AND status = 'success' AND status = 'success'
AND (" . implode(' OR ', $likes) . ") AND (" . implode(' OR ', $likes) . ")
@ -442,7 +442,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
// ── 동시 발송 직렬화 (중복 SMS 방지) ──────────────────────── // ── 동시 발송 직렬화 (중복 SMS 방지) ────────────────────────
// 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이 // 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이
// 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위 // 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위
// MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를 // MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를
// 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로 // 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로
@ -466,9 +466,9 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
} }
try { try {
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ────────────── // ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ──────────────
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND message LIKE :tag
@ -567,9 +567,9 @@ function send_leak_alert_sms(array $sensor_data): bool {
} }
try { try {
// ── 쿨다운 (sms_log 메시지 prefix 기준) ── // ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ──
$recent = $db->prepare(" $recent = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sensor_id WHERE sensor_id = :sensor_id
AND status = 'success' AND status = 'success'
AND message LIKE :tag AND message LIKE :tag
@ -621,7 +621,7 @@ function send_leak_recovery_sms(array $sensor_data): bool {
// 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일 // 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일
// 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다. // 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다.
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT message FROM sms_log SELECT message FROM leak_sms_log
WHERE sensor_id = :sid WHERE sensor_id = :sid
AND status = 'success' AND status = 'success'
AND (message LIKE :alert OR message LIKE :recovery) AND (message LIKE :alert OR message LIKE :recovery)
@ -702,14 +702,14 @@ function send_device_online_sms(array $sensor_data): bool {
* 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건" * 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건"
*/ */
function build_health_summary(PDO $db): string { function build_health_summary(PDO $db): string {
$online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn(); $online = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status WHERE offline_alerted = 0")->fetchColumn();
$total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn(); $total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn();
$lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM sensor_status")->fetchColumn(); $lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM leak_sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전'); $recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전');
$alerts = (int)$db->query(" $alerts = (int)$db->query("
SELECT COUNT(*) FROM sms_log SELECT COUNT(*) FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND status = 'success' AND status = 'success'
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
@ -735,7 +735,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
return; return;
} }
$today = $db->prepare(" $today = $db->prepare("
SELECT 1 FROM sms_log SELECT 1 FROM leak_sms_log
WHERE message LIKE :tag AND DATE(sent_at) = CURDATE() WHERE message LIKE :tag AND DATE(sent_at) = CURDATE()
LIMIT 1 LIMIT 1
"); ");
@ -785,7 +785,7 @@ function maybe_send_daily_health_sms(PDO $db): void {
*/ */
function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool { function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool {
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT sent_at FROM sms_log SELECT sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE :tag WHERE sensor_id = :sid AND message LIKE :tag
ORDER BY sent_at DESC LIMIT 1 ORDER BY sent_at DESC LIMIT 1
"); ");
@ -828,7 +828,7 @@ function latest_alert_state_row(PDO $db, int $sensor_id): ?array {
} }
// id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이 // id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이
// 결정적으로 선택되게 한다(sent_at 은 1초 해상도). // 결정적으로 선택되게 한다(sent_at 은 1초 해상도).
$sql = "SELECT message, sent_at FROM sms_log $sql = "SELECT message, sent_at FROM leak_sms_log
WHERE sensor_id = :sid AND status = 'success' WHERE sensor_id = :sid AND status = 'success'
AND (" . implode(' OR ', $likes) . ") AND (" . implode(' OR ', $likes) . ")
ORDER BY sent_at DESC, id DESC LIMIT 1"; ORDER BY sent_at DESC, id DESC LIMIT 1";
@ -848,7 +848,7 @@ function maybe_escalate_alerts(PDO $db): void {
return; return;
} }
$alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION]; $alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION];
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll(); $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) { foreach ($sensors as $s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
$row = latest_alert_state_row($db, $sid); $row = latest_alert_state_row($db, $sid);
@ -897,12 +897,12 @@ function maybe_detect_stuck_sensors(PDO $db): void {
return; return;
} }
$n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인 $n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인
$sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll(); $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll();
foreach ($sensors as $s) { foreach ($sensors as $s) {
$sid = (int)$s['sensor_id']; $sid = (int)$s['sensor_id'];
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT temperature_c, humidity_percent SELECT temperature_c, humidity_percent
FROM sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid WHERE sensor_id = :sid
ORDER BY measured_at DESC ORDER BY measured_at DESC
LIMIT {$n} LIMIT {$n}

View file

@ -1,6 +1,18 @@
<?php <?php
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로 // 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어). // text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
//
// 인증 가드(심층 방어): 1차 차단은 .htaccess FilesMatch(403)지만, .htaccess 가
// 무시되는 배포(AllowOverride None 등)에서도 비인증 노출되지 않도록 세션을 검사한다.
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) {
http_response_code(403);
exit;
}
header('Content-Type: text/plain; charset=utf-8'); header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';

View file

@ -59,13 +59,17 @@ e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에 이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에
보고합니다. 보고합니다.
> ⚠️ **현재 이 repo 의 `php/` 서버는 SHT30 온습도 전용으로 전환**되었습니다 > ✅ **이 repo 의 `php/` 서버가 바로 그 서버입니다(단일 서버 통합 완료).**
> (`sql/migration_drop_leak.sql` 가 `leak_photo`/`leak_incident` 테이블과 `is_leak` 컬럼을 > 1번(누수+카메라)·2번(SHT30 온습도) 두 모듈 모두 **동일한 cafe24 `php/` 서버**에
> 제거하고 `event_type` 을 `startup|periodic` 로 축소). 따라서 현재 repo 서버는 leak 이벤트와 > raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
> 사진 업로드를 **거부**합니다. > 로 보고하며, 누수 상태·사진·온습도가 하나의 대시보드(`dashboard.php`)에 함께 표시됩니다.
> >
> 이 Pi 4 모듈은 **leak 를 받는 기존 서버**(기존 `raspi_leck_detecter` 엔드포인트)로 보고하도록 > - **신규 설치:** `sql/schema_sht30.sql` 하나만 실행 — 누수 요소
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 설정해야 합니다. > (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `leak_incident`, `leak_photo`,
> `leak_ingest_nonce` 테이블)가 이미 포함되어 있습니다.
>
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 이 통합 서버로 설정하고,
> `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **바이트 단위로 동일**하게 맞춥니다.
--- ---
@ -146,7 +150,11 @@ journalctl -u leak-sensor -f
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다. - 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
커밋되는 파일에는 placeholder 만 존재합니다. 커밋되는 파일에는 placeholder 만 존재합니다.
- 보고 본문은 `API_KEY + 정렬된 JSON` 의 SHA-256 서명으로 위변조를 방지합니다 - 보고 본문은 **raw-body HMAC 서명**으로 위변조를 방지합니다:
(`leak_sensor.make_signature` ↔ 서버 검증 로직과 동일 포맷). `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
- ⚠️ 이 서명은 **레거시 누수 서버와의 호환을 위해 의도적으로** `SHA-256(API_KEY + JSON)` 방식을 유지합니다. HMAC-SHA256 이 길이연장 공격에 면역이라 더 안전하지만, 전환하려면 **서버 검증부를 동시에 변경**해야 합니다. (`leak_sensor.sign_raw` ↔ 서버 `config.php::verify_signature_raw`, 2번 모듈과 동일 스킴).
- 사진 업로드 서명의 canonical 문자열은 `device_id|sensor_id|log_id|timestamp`
(공백 없음)입니다 (`config_local.example.py` 참고).
- 구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 **폐기**되었고, 서버 기본
설정이 거부합니다(`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요. - `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.

414
raspberry_pi4/WIRING.html Normal file
View file

@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>누수 감지 노드 · 배선 &amp; 핀맵 (Raspberry Pi 4)</title>
<style>
:root{
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
--sig:#2f9e44; --sig-d:#2b8a3e; --pwr:#e03131; --pwr-d:#c92a2a;
--gnd:#1971c2; --gnd-d:#1864ab; --exp:#e8590c; --exp-b:#ffa94d;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR",sans-serif;
--mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0}
body{font-family:var(--sans);background:var(--bg);color:var(--ink);line-height:1.6;
-webkit-font-smoothing:antialiased;padding:24px 16px 60px}
.wrap{max-width:1000px;margin:0 auto}
svg text{font-family:var(--sans)}
/* Header */
.hero{background:linear-gradient(135deg,#0b2545 0%,#134074 55%,#1971c2 100%);
color:#fff;border-radius:16px;padding:26px 28px;box-shadow:0 8px 24px rgba(11,37,69,.18)}
.hero .eyebrow{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#a5c4e6;font-weight:700;margin:0 0 6px}
.hero h1{margin:0 0 10px;font-size:26px;line-height:1.25;font-weight:800}
.hero p{margin:0 0 16px;font-size:15px;color:#dbe7f5;max-width:70ch}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#fff;
border-radius:999px;padding:5px 12px;font-size:12.5px;font-weight:600;white-space:nowrap}
.chip.mono{font-family:var(--mono);letter-spacing:.02em}
/* Section cards */
section{background:var(--card);border:1px solid var(--line);border-radius:14px;
padding:22px 24px;margin-top:20px;box-shadow:0 2px 8px rgba(16,24,40,.05)}
h2{display:flex;align-items:center;gap:12px;margin:0 0 18px;font-size:19px;font-weight:800}
h2 .num{flex:none;width:30px;height:30px;border-radius:9px;background:#134074;color:#fff;
font-size:15px;display:flex;align-items:center;justify-content:center;font-weight:800}
h3{margin:22px 0 10px;font-size:15px;font-weight:700;color:#134074}
/* Pinmap block */
.pinwrap{display:grid;grid-template-columns:minmax(260px,1.15fr) 1fr;gap:24px;align-items:start}
.diagram{background:#fbfcfd;border:1px solid var(--line);border-radius:12px;padding:14px}
.legend{display:flex;flex-direction:column;gap:11px}
.legend .lg{display:flex;align-items:flex-start;gap:11px;font-size:13.5px}
.sw{flex:none;width:22px;height:22px;border-radius:6px;margin-top:1px}
.sw.sig{background:var(--sig);border:2px solid var(--sig-d)}
.sw.pwr{background:var(--pwr);border:2px solid var(--pwr-d)}
.sw.gnd{background:var(--gnd);border:2px solid var(--gnd-d)}
.sw.alt{background:#e7f5ff;border:2px dashed #4dabf7}
.sw.exp{background:#fff4e6;border:2px dashed var(--exp-b)}
.sw.off{background:#e9ecef;border:2px solid #ced4da}
.lg b{display:block;font-weight:700}
.lg span{color:var(--muted);font-size:12.5px}
.hint{margin-top:6px;font-size:12.5px;color:var(--muted);background:#f8f9fb;border:1px dashed #d5dbe3;
border-radius:8px;padding:9px 11px}
/* Table */
table{width:100%;border-collapse:collapse;font-size:14px}
caption{caption-side:bottom;text-align:left;color:var(--muted);font-size:12.5px;padding-top:10px}
th,td{padding:11px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
thead th{background:#f3f6fa;color:#134074;font-weight:700;font-size:13px;border-bottom:2px solid #d6e0ec}
td.mono,th.mono{font-family:var(--mono);white-space:nowrap}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;line-height:1.7}
.tag.sig{background:#e7f6ec;color:var(--sig-d)}
.tag.pwr{background:#fdecec;color:var(--pwr-d)}
.tag.gnd{background:#e7f1fb;color:var(--gnd-d)}
.tag.exp{background:#fff1e3;color:var(--exp)}
tr.exprow td{background:#fffaf3}
/* Electrical notes */
.notes{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.note{border:1px solid var(--line);border-left:4px solid #1971c2;border-radius:10px;padding:13px 15px;background:#fbfcfd}
.note h4{margin:0 0 4px;font-size:14px}
.note p{margin:0;font-size:13.5px;color:#374151}
.note code,.inline-code{font-family:var(--mono);background:#eef2f7;padding:1px 6px;border-radius:5px;font-size:12.5px}
.crit{grid-column:1 / -1;border:2px solid var(--pwr);border-left-width:6px;background:#fff5f5;border-radius:10px;padding:15px 18px}
.crit .badge{display:inline-block;background:var(--pwr);color:#fff;font-weight:800;font-size:12px;
padding:3px 11px;border-radius:6px;letter-spacing:.04em;margin-bottom:8px}
.crit h4{margin:0 0 6px;font-size:16px;color:var(--pwr-d)}
.crit p{margin:0;font-size:14px;color:#5c1a1a}
.cam{margin-top:16px;border:1px solid var(--line);border-left:4px solid #7048e8;border-radius:10px;
padding:13px 16px;background:#fbfaff}
.cam h4{margin:0 0 6px;font-size:14px;color:#5f3dc4}
.cam p{margin:0;font-size:13.5px;color:#374151}
/* Checklist */
ol.check{counter-reset:c;list-style:none;margin:0;padding:0}
ol.check li{position:relative;padding:12px 14px 12px 80px;border-bottom:1px solid #f1f3f5;
line-height:1.5;font-size:14px}
ol.check li:last-child{border-bottom:none}
ol.check li::before{counter-increment:c;content:counter(c);position:absolute;left:12px;top:11px;
width:26px;height:26px;border-radius:50%;background:#e7f1fb;color:#1971c2;font-weight:800;
font-size:13px;display:flex;align-items:center;justify-content:center}
ol.check li::after{content:"";position:absolute;left:50px;top:13px;width:18px;height:18px;
border:2px solid #adb5bd;border-radius:4px}
ol.check li.key{background:#fff8f8}
ol.check li b{color:var(--pwr-d)}
@media (max-width:720px){
.pinwrap{grid-template-columns:1fr}
.notes{grid-template-columns:1fr}
.hero h1{font-size:22px}
}
@media print{
:root{--bg:#fff}
body{background:#fff;color:#000;padding:0;font-size:12px}
.wrap{max-width:100%}
.hero{background:#fff!important;color:#000;border:2px solid #134074;box-shadow:none}
.hero .eyebrow{color:#134074}.hero p{color:#333}
.chip{background:#fff;border:1px solid #999;color:#000}
section{box-shadow:none;border:1px solid #999;page-break-inside:avoid;break-inside:avoid}
h2 .num{background:#134074!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
svg{max-width:100%!important;height:auto}
table,tr,td,th,.note,.crit,.cam,ol.check li{page-break-inside:avoid;break-inside:avoid}
*{-webkit-print-color-adjust:exact;print-color-adjust:exact}
.diagram,.note,.crit,.cam{background:#fff}
}
</style>
</head>
<body>
<div class="wrap">
<!-- 1) HEADER -->
<header class="hero">
<p class="eyebrow">Field Wiring &amp; Pinmap · 누수 감지 노드</p>
<h1>누수 감지 노드 · 배선 &amp; 핀맵</h1>
<p>e-con 5C-08 저항형 누수감지센서를 <b>GPIO17(물리 핀 11)</b>에 연결하는 표준 배선도입니다.
Pi-A(sensor_id=1 · 1번 지점 · 서버실)와 Pi-C(sensor_id=3 · 3번 지점 · 신규)는 배선이 동일한 <b>공통 노드</b>입니다.</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">e-con 5C-08 · 저항형 ~1.5MΩ</span>
<span class="chip mono">GPIO17 / 핀11</span>
<span class="chip">외부 4.7MΩ 풀다운 필수</span>
</div>
</header>
<!-- 2) 40핀 헤더 핀맵 도해 -->
<section>
<h2><span class="num">2</span>40핀 헤더 핀맵 도해</h2>
<div class="pinwrap">
<div class="diagram">
<svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40핀 GPIO 헤더 핀맵" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
<rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="131" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="255" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="317" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="379" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="441" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="503" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="565" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/>
<rect x="234" y="30" width="112" height="633" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<rect x="236" y="36" width="32" height="32" rx="6" fill="none" stroke="#e03131" stroke-width="1.5" stroke-dasharray="3 2"/>
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">홀수 핀</text>
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">짝수 핀</text>
<text x="220" y="56" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#7d1616">3V3</text>
<text x="360" y="56" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
<circle cx="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text>
<circle cx="330" cy="52" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">2</text>
<text x="220" y="87" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO2 (SDA1)</text>
<text x="360" y="87" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
<circle cx="252" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">3</text>
<circle cx="330" cy="83" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="86.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">4</text>
<text x="220" y="118" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO3 (SCL1)</text>
<text x="360" y="118" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#123a63">GND</text>
<circle cx="252" cy="114" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">5</text>
<circle cx="330" cy="114" r="12" fill="#1971c2" stroke="#1864ab" stroke-width="1.5"/><text x="330" y="117.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">6</text>
<text x="220" y="149" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO4</text>
<text x="360" y="149" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO14 (TXD)</text>
<circle cx="252" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">7</text>
<circle cx="330" cy="145" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="148.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">8</text>
<text x="220" y="180" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<text x="360" y="180" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO15 (RXD)</text>
<circle cx="252" cy="176" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">9</text>
<circle cx="330" cy="176" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="179.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">10</text>
<text x="220" y="211" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#1b4332">GPIO17</text>
<text x="360" y="211" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO18</text>
<circle cx="252" cy="207" r="12" fill="#2f9e44" stroke="#2b8a3e" stroke-width="1.5"/><text x="252" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">11</text>
<circle cx="330" cy="207" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="210.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">12</text>
<text x="220" y="242" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO27</text>
<text x="360" y="242" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="238" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">13</text>
<circle cx="330" cy="238" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="241.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">14</text>
<text x="220" y="273" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#e8590c">GPIO22</text>
<text x="360" y="273" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO23</text>
<circle cx="252" cy="269" r="12" fill="#fff4e6" stroke="#ffa94d" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#e8590c">15</text>
<circle cx="330" cy="269" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="272.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">16</text>
<text x="220" y="304" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">3V3</text>
<text x="360" y="304" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO24</text>
<circle cx="252" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">17</text>
<circle cx="330" cy="300" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="303.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">18</text>
<text x="220" y="335" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO10 (MOSI)</text>
<text x="360" y="335" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="331" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">19</text>
<circle cx="330" cy="331" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="334.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">20</text>
<text x="220" y="366" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO9 (MISO)</text>
<text x="360" y="366" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO25</text>
<circle cx="252" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">21</text>
<circle cx="330" cy="362" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="365.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">22</text>
<text x="220" y="397" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO11 (SCLK)</text>
<text x="360" y="397" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO8 (CE0)</text>
<circle cx="252" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">23</text>
<circle cx="330" cy="393" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="396.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">24</text>
<text x="220" y="428" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<text x="360" y="428" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO7 (CE1)</text>
<circle cx="252" cy="424" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">25</text>
<circle cx="330" cy="424" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="427.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">26</text>
<text x="220" y="459" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO0 (ID_SD)</text>
<text x="360" y="459" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO1 (ID_SC)</text>
<circle cx="252" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">27</text>
<circle cx="330" cy="455" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="458.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">28</text>
<text x="220" y="490" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO5</text>
<text x="360" y="490" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="486" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">29</text>
<circle cx="330" cy="486" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="489.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">30</text>
<text x="220" y="521" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO6</text>
<text x="360" y="521" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO12</text>
<circle cx="252" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">31</text>
<circle cx="330" cy="517" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="520.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">32</text>
<text x="220" y="552" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO13</text>
<text x="360" y="552" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<circle cx="252" cy="548" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">33</text>
<circle cx="330" cy="548" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="330" y="551.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">34</text>
<text x="220" y="583" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO19</text>
<text x="360" y="583" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO16</text>
<circle cx="252" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">35</text>
<circle cx="330" cy="579" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="582.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">36</text>
<text x="220" y="614" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO26</text>
<text x="360" y="614" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO20</text>
<circle cx="252" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="252" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">37</text>
<circle cx="330" cy="610" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="613.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">38</text>
<text x="220" y="645" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="600" fill="#1971c2">GND</text>
<text x="360" y="645" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">GPIO21</text>
<circle cx="252" cy="641" r="12" fill="#e7f5ff" stroke="#4dabf7" stroke-width="1.5" stroke-dasharray="3 2"/><text x="252" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#1971c2">39</text>
<circle cx="330" cy="641" r="12" fill="#e9ecef" stroke="#ced4da" stroke-width="1.5"/><text x="330" y="644.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#868e96">40</text>
</svg>
</div>
<div class="legend">
<div class="lg"><span class="sw sig"></span><b>신호 (GPIO17 · 핀 11)<span>센서 신호선 입력</span></b></div>
<div class="lg"><span class="sw pwr"></span><b>전원 3V3 (핀 1)<span>센서 전원 공급</span></b></div>
<div class="lg"><span class="sw gnd"></span><b>접지 GND (핀 6 · 사용)<span>센서 GND 기본 연결</span></b></div>
<div class="lg"><span class="sw alt"></span><b>접지 대체 가능<span>핀 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div>
<div class="lg"><span class="sw exp"></span><b>확장 예시 (선택)<span>GPIO27·핀 13, GPIO22·핀 15</span></b></div>
<div class="lg"><span class="sw off"></span><b>미사용 핀<span>이 노드에서 연결하지 않음</span></b></div>
<div class="hint">붉은 점선 사각형이 <b>핀 1</b> 위치입니다. 헤더 방향(핀 1 기준)을 반드시 확인하고 배선하세요. 실제 사용 핀은 <b>1 · 6 · 11</b> 세 개뿐입니다.</div>
</div>
</div>
</section>
<!-- 3) 핀맵 표 -->
<section>
<h2><span class="num">3</span>핀맵 표</h2>
<table>
<thead>
<tr><th>신호</th><th class="mono">BCM</th><th class="mono">물리 핀</th><th>비고</th></tr>
</thead>
<tbody>
<tr>
<td><span class="tag sig">신호</span> 센서 신호선</td>
<td class="mono">GPIO17</td>
<td class="mono">핀 11</td>
<td>센서 신호 입력. 외부 4.7MΩ 풀다운을 <b>핀 11 ↔ GND</b> 사이에 연결.</td>
</tr>
<tr>
<td><span class="tag pwr">전원</span> 센서 전원 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">핀 1</td>
<td>센서 VCC 공급. (예비 3V3 = 핀 17)</td>
</tr>
<tr>
<td><span class="tag gnd">접지</span> 센서 GND</td>
<td class="mono">GND</td>
<td class="mono">핀 6</td>
<td>대체 가능: 핀 9 / 14 / 20 / 25 / 30 / 34 / 39 중 아무 GND.</td>
</tr>
<tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td class="mono">GPIO27</td>
<td class="mono">핀 13</td>
<td>선택. <span class="inline-code">config.py</span>의 SENSORS에 BCM 번호로 추가.</td>
</tr>
<tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td>
<td class="mono">GPIO22</td>
<td class="mono">핀 15</td>
<td>선택. 위와 동일한 방법으로 추가(각 채널마다 외부 풀다운 필요).</td>
</tr>
</tbody>
<caption>사용 핀은 3개(핀 1 · 6 · 11). 확장 행은 다중 센서 구성 시 참고용 예시입니다. 기기별 sensor_id / location은 <span class="inline-code">config_local.py</span>에서 설정.</caption>
</table>
</section>
<!-- 4) 배선 스키매틱 -->
<section>
<h2><span class="num">4</span>배선 스키매틱</h2>
<div class="diagram">
<svg viewBox="0 0 640 400" role="img" aria-label="누수센서 배선 스키매틱 — 3V3·GPIO17·GND 및 외부 4.7MΩ 풀다운" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<!-- Pi header block -->
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40핀 헤더</text>
<!-- header pin rows -->
<circle cx="60" cy="120" r="5" fill="#e03131"/>
<text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀1 · 3V3</text>
<rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<circle cx="60" cy="195" r="5" fill="#2f9e44"/>
<text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀11 · GPIO17</text>
<rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<circle cx="60" cy="270" r="5" fill="#1971c2"/>
<text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀6 · GND</text>
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<!-- sensor block -->
<rect x="470" y="95" width="150" height="190" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/>
<text x="545" y="84" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 누수센서</text>
<rect x="462" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">VCC 3.3V</text>
<rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">SIG 신호</text>
<rect x="462" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text>
<!-- wires -->
<path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/>
<path d="M192,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/>
<path d="M192,270 H462" stroke="#1971c2" stroke-width="2.5" fill="none"/>
<text x="327" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">3.3V 전원 · 핀1</text>
<text x="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">신호 · GPIO17 · 핀11</text>
<text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · 핀6</text>
<!-- external pulldown between SIG wire and GND wire -->
<circle cx="340" cy="195" r="4" fill="#2f9e44"/>
<circle cx="340" cy="270" r="4" fill="#1971c2"/>
<polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/>
<text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text>
<text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">외부 풀다운(필수)</text>
<!-- internal pull off note -->
<rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="72" y="317" text-anchor="start" font-size="11" font-weight="700" fill="#c92a2a">내부 풀 OFF · USE_INTERNAL_PULL=False (PUD_OFF)</text>
<!-- logic box -->
<rect x="60" y="350" width="520" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/>
<text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO=LOW(0) · 누수 → GPIO=HIGH(1) · ACTIVE_LOW=False</text>
</svg>
</div>
<div class="hint">동작 원리: 건조 시 센서는 초고임피던스(거의 개방) 상태라 외부 4.7MΩ 풀다운이 GPIO17을 <b>LOW</b>로 유지합니다. 누수로 물이 전극을 연결하면 3.3V가 ~1.5MΩ을 통해 신호선을 끌어올려 GPIO17이 <b>HIGH</b>로 전환됩니다.</div>
</section>
<!-- 5) 전기 설계 · 주의 -->
<section>
<h2><span class="num">5</span>전기 설계 · 주의</h2>
<div class="crit">
<span class="badge">★ 절대 주의 · CRITICAL</span>
<h4>내부 풀다운을 켜지 마세요</h4>
<p>내부 <b>50kΩ</b> 풀다운을 활성화하면, ~1.5MΩ 고임피던스 센서가 GPIO를 HIGH로 끌어올리지 못해
<b>누수를 감지하지 못합니다(감지 실패)</b>. 반드시 내부 풀은 <span class="inline-code">PUD_OFF</span>로 끄고,
<b>외부 4.7MΩ 풀다운만</b> 사용하세요. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
</div>
<div class="notes" style="margin-top:14px">
<div class="note">
<h4>극성 · 로직 레벨</h4>
<p><span class="inline-code">ACTIVE_LOW = False</span> → 건조 시 <b>GPIO=LOW(0)</b>, 누수 시 <b>GPIO=HIGH(1)</b>.</p>
</div>
<div class="note">
<h4>외부 풀다운 (필수)</h4>
<p>4.7MΩ 저항을 <b>GPIO17(핀 11) ↔ GND</b> 사이에 연결. 고임피던스 센서용으로 반드시 필요.</p>
</div>
<div class="note">
<h4>내부 풀 설정</h4>
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — 내부 풀은 <span class="inline-code">PUD_OFF</span> 유지.</p>
</div>
<div class="note">
<h4>디바운스</h4>
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — 고임피던스 특성상 넉넉하게 설정.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>설정 파일</h4>
<p>전기 파라미터는 <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
기기별 <b>sensor_id / location</b>은 <span class="inline-code">config_local.py</span>
(Pi-A = id 1 · 1번 지점 · 서버실 / Pi-C = id 3 · 3번 지점 · 신규).</p>
</div>
</div>
<div class="cam">
<h4>카메라 (선택 · CAMERA_ENABLED=True)</h4>
<p>Raspberry Pi 4는 <b>15핀(1.0mm 피치) 표준 CSI 커넥터</b>입니다. Zero 2 W는 22핀(0.5mm)이라
리본·카메라보드가 <b>비호환</b> → Pi 4용 <b>15핀 리본</b> 또는 <b>15↔22 변환 어댑터</b>가 필요합니다.
촬영은 <span class="inline-code">rpicam-still</span>(Bookworm) / <span class="inline-code">libcamera-still</span>(Bullseye), <b>640×480 JPEG</b>.</p>
</div>
</section>
<!-- 6) 설치 체크리스트 -->
<section>
<h2><span class="num">6</span>설치 체크리스트</h2>
<ol class="check">
<li>Raspberry Pi 전원을 <b>완전히 차단</b>한 상태에서 배선을 시작한다.</li>
<li>헤더 방향(핀 1 위치)을 확인한다. 붉은 점선 표시 = 핀 1.</li>
<li>센서 신호선을 <b>핀 11(GPIO17)</b>에 연결한다.</li>
<li>센서 전원(3.3V)을 <b>핀 1(3V3)</b>, 센서 GND를 <b>핀 6(GND)</b>에 연결한다.</li>
<li class="key">외부 <b>4.7MΩ 풀다운</b>을 <b>핀 11 ↔ GND</b> 사이에 연결한다. <b>(필수 · 생략 시 감지 실패)</b></li>
<li class="key">내부 풀이 꺼져 있는지 확인한다 — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>내부 풀다운 절대 금지.</b></li>
<li><span class="inline-code">config.py</span> 확인: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, SENSORS에 GPIO17 등록.</li>
<li><span class="inline-code">config_local.py</span>에서 기기별 <b>sensor_id / location</b> 설정 (Pi-A=1/서버실, Pi-C=3/신규).</li>
<li>전원 인가 후, <b>건조 상태</b>에서 GPIO17 = <b>LOW(0)</b>인지 로그로 확인한다.</li>
<li>물(또는 젖은 천)로 센서를 적셔 GPIO17이 <b>HIGH(1)</b>로 전환되고 누수 이벤트가 기록되는지 확인한다.</li>
<li>(선택) 카메라 사용 시 리본 방향·커넥터 규격(15핀 / 어댑터)을 확인하고 640×480 촬영 테스트를 수행한다.</li>
</ol>
</section>
</div>
</body>
</html>

View file

@ -32,7 +32,9 @@ USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 주기 설정 # 주기 설정
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간 REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분
# 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야
# 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정).
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초) LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정 DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
@ -80,3 +82,32 @@ except ImportError as e:
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)" "기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
) from e ) from e
# ─────────────────────────────────────────────
# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다.
# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로,
# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다.
# ─────────────────────────────────────────────
try:
from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드
except ImportError:
pass # config_local 에 SENSORS 가 없으면 위 기본값 사용
# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id
# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다.
if not isinstance(SENSORS, list) or not SENSORS:
raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).")
_ids, _gpios = set(), set()
for _s in SENSORS:
if not (isinstance(_s, dict)
and isinstance(_s.get("id"), int) and 1 <= _s["id"] <= 255
and isinstance(_s.get("gpio"), int)
and isinstance(_s.get("name"), str) and _s["name"]):
raise RuntimeError(
f"SENSORS 항목 형식 오류: {_s!r} — id(int 1..255)/gpio(int)/name(str) 필수."
)
if _s["id"] in _ids or _s["gpio"] in _gpios:
raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}")
_ids.add(_s["id"])
_gpios.add(_s["gpio"])
del _ids, _gpios

View file

@ -11,7 +11,7 @@
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음) # 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. # → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고, # 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함),
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다. # 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.) # (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
# ============================================================================= # =============================================================================
@ -33,3 +33,17 @@ API_KEY = "CHANGE_ME"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01" DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실" DEVICE_LOCATION = "서버실"
# ─────────────────────────────────────────────
# (선택) 센서 목록 오버라이드 — 신규 지점 노드용
# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용.
# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고,
# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는
# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것.
# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를
# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키).
# ─────────────────────────────────────────────
# SENSORS = [
# {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},
# ]
# (위를 켤 때 DEVICE_ID 예: "rpi4-leak-02", DEVICE_LOCATION 도 지점명으로 변경)

View file

@ -50,7 +50,8 @@ if [ "${OFFLINE}" = "1" ]; then
exit 1 exit 1
fi fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)" echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb # --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else else
echo " 온라인 apt 설치 (인터넷 필요)" echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update sudo apt-get update
@ -91,6 +92,15 @@ if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
fi fi
sudo chmod 640 /var/log/leak_sensor.log sudo chmod 640 /var/log/leak_sensor.log
# 3.5) config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
fi
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨) # 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then if [ ! -f config_local.py ]; then
echo "" echo ""

View file

@ -22,7 +22,8 @@ Wants=network-online.target
Type=simple Type=simple
User=pi User=pi
Group=pi Group=pi
SupplementaryGroups=video # rpi-lgpio 는 /dev/gpiochip0(root:gpio 660) 접근에 gpio 그룹, 카메라는 video 그룹 필요.
SupplementaryGroups=video gpio
WorkingDirectory=/home/pi/raspberry_pi4 WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
Restart=always Restart=always
@ -33,10 +34,38 @@ SyslogIdentifier=leak-sensor
# 환경 변수 # 환경 변수
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# lgpio(rpi-lgpio 백엔드)는 add_event_detect 시 작업디렉터리에 .lgd-nfy 알림 파이프를
# 만든다. 아래 ProtectHome=read-only 로 /home 이 읽기전용이 되므로, PrivateTmp 의
# 쓰기가능 /tmp 로 lgpio 작업디렉터리를 옮긴다. (없으면 누수 인터럽트 등록이 실패해
# 데몬이 크래시루프 → 누수 감지 불능.) LG_WD 는 lg 라이브러리가 읽는 환경변수.
# ※ 이 하드닝은 chardev 백엔드(python3-rpi-lgpio) 를 가정한다. sysfs 기반
# python3-rpi.gpio 로 되돌리면 ProtectKernelTunables/ProtectHome 와 충돌하므로
# 백엔드를 바꾸지 말 것(vendor.sh/install.sh 의 python3-rpi-lgpio 핀 유지).
Environment=LG_WD=/tmp
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지) # 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
PrivateTmp=true PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. 카메라/GPIO 는 /dev char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/gpiochip*, 카메라 노드가 숨겨져 동작 불능.
# 재시작 상한 (10분 내 5번 실패 시 중단) # 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5 StartLimitBurst=5
StartLimitInterval=600s StartLimitInterval=600s

View file

@ -4,10 +4,18 @@
import hashlib import hashlib
import hmac import hmac
import sys
import time import time
import subprocess import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
# 이 기기에 설정된 첫 센서로 테스트한다 — sensor_id 를 하드코딩하면(예: 1)
# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다.
TEST_SENSOR_ID = SENSORS[0]["id"]
TEST_SENSOR_NAME = SENSORS[0]["name"]
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api) # API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0] API_BASE = API_URL.rsplit("/", 1)[0]
@ -46,7 +54,7 @@ try:
"-A", "LeakSensor/2.0", "-A", "LeakSensor/2.0",
"-X", "POST", API_URL, "-X", "POST", API_URL,
"-H", "Content-Type: application/json", "-H", "Content-Type: application/json",
"-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'], "-d", f'{{"device_id":"test","sensor_id":{TEST_SENSOR_ID},"event_type":"startup","is_leak":0,"timestamp":1234567890}}'],
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
lines = result.stdout.strip().split("\n") lines = result.stdout.strip().split("\n")
@ -69,8 +77,8 @@ try:
'{' '{'
f'"device_id":"{DEVICE_ID}",' f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",' f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":1,' f'"sensor_id":{TEST_SENSOR_ID},'
f'"sensor_name":"테스트 센서",' f'"sensor_name":"{TEST_SENSOR_NAME} (연결테스트)",'
f'"event_type":"startup",' f'"event_type":"startup",'
f'"is_leak":0,' f'"is_leak":0,'
f'"timestamp":{ts}' f'"timestamp":{ts}'
@ -95,6 +103,7 @@ try:
print(f" 응답 내용: {resp.text[:200]}") print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True
print(" → 성공! 모든 연결 정상") print(" → 성공! 모든 연결 정상")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치") print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
@ -108,5 +117,6 @@ except Exception as e:
print(f" → 실패: {e}") print(f" → 실패: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료") print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -38,7 +38,8 @@ CAM="${CAM:-}"
if [ -z "${CAM}" ]; then if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi fi
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv ${CAM}" # nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \ DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)" | awk '/^\w/{print $1}' | sort -u)"
@ -55,4 +56,23 @@ done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; } [ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)" echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행." echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

View file

@ -0,0 +1,557 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>온습도 노드 배선·핀맵 — Pi-B / SHT30 (sensor_id=2)</title>
<style>
:root{
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
--accent:#1971c2; --accent-d:#155a9c; --warn:#e03131; --warnbg:#fff5f5;
--ok:#2f9e44; --gnd:#343a40; --chip:#eef2f6; --code:#0b3d2e;
--mono:"D2Coding","Consolas","Menlo","Courier New",ui-monospace,monospace;
--sans:"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
font-family:var(--sans); color:var(--ink); background:var(--bg);
line-height:1.6; -webkit-font-smoothing:antialiased;
}
.wrap{max-width:1000px; margin:0 auto; padding:22px 18px 60px;}
/* ---------- Header ---------- */
.hero{
background:linear-gradient(135deg,#1f2933 0%,#283746 55%,#2f4a5a 100%);
color:#fff; border-radius:16px; padding:26px 28px;
box-shadow:0 8px 24px rgba(20,30,40,.18);
}
.hero .eyebrow{font-size:12.5px; letter-spacing:.14em; text-transform:uppercase;
color:#9fc3e0; font-weight:700; margin:0 0 6px;}
.hero h1{margin:0 0 8px; font-size:27px; line-height:1.25; font-weight:800;}
.hero p.sum{margin:0 0 16px; font-size:14.5px; color:#d7e2ec; max-width:72ch;}
.chips{display:flex; flex-wrap:wrap; gap:8px;}
.chip{
display:inline-flex; align-items:center; gap:6px;
background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.22);
color:#eef5fb; font-size:12.5px; font-weight:600; padding:5px 11px; border-radius:999px;
}
.chip.mono{font-family:var(--mono); letter-spacing:.01em;}
.chip b{color:#fff;}
/* ---------- Sections ---------- */
section{
background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:22px 24px; margin-top:20px; box-shadow:0 2px 10px rgba(20,30,40,.05);
}
h2{
display:flex; align-items:center; gap:11px; margin:0 0 16px;
font-size:19px; font-weight:800; color:var(--ink);
}
h2 .n{
flex:0 0 auto; width:30px; height:30px; border-radius:9px;
background:var(--accent); color:#fff; font-size:15px; font-weight:800;
display:flex; align-items:center; justify-content:center;
}
h2 small{font-weight:600; font-size:13px; color:var(--muted);}
/* ---------- Pinmap SVG ---------- */
.svg-pin{display:block; width:100%; max-width:460px; height:auto; margin:2px auto 0;}
.diagram-cap{text-align:center; color:var(--muted); font-size:12.5px; margin:12px 0 0;}
.legend{
display:flex; flex-wrap:wrap; justify-content:center; gap:12px 18px;
margin:16px auto 2px; max-width:640px;
}
.legend span{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; color:var(--ink);}
.sw{width:15px; height:15px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.15);}
.sw.pwr{background:#2f9e44;} .sw.i2c{background:#1971c2;}
.sw.gnd{background:#343a40;}
.sw.altgnd{background:#ced4da; border:1.5px dashed #868e96;}
.sw.off{background:#dee2e6;}
/* SVG element classes */
.p-pwr{fill:#2f9e44; stroke:#248a3b; stroke-width:1.5;}
.p-i2c{fill:#1971c2; stroke:#155a9c; stroke-width:1.5;}
.p-gnd{fill:#343a40; stroke:#1f2429; stroke-width:1.5;}
.p-altgnd{fill:#ced4da; stroke:#868e96; stroke-width:1.5; stroke-dasharray:3 2;}
.p-off{fill:#dee2e6; stroke:#c3c9cf; stroke-width:1;}
.rail{fill:#f1f3f5; stroke:#ced4da; stroke-width:1.5;}
.pnum{font-family:var(--mono); font-size:12px; font-weight:700; text-anchor:middle; dominant-baseline:central;}
.pnum-l{fill:#ffffff;} .pnum-d{fill:#495057;}
.plbl{font-family:var(--mono); font-size:12px; dominant-baseline:central;}
.lbl-l{text-anchor:end;} .lbl-r{text-anchor:start;}
.lbl-used{fill:#1a1d21; font-weight:700;}
.lbl-alt{fill:#5c6570; font-weight:600;}
.lbl-off{fill:#adb5bd; font-weight:500;}
.cap-svg{font-family:var(--sans); font-size:12.5px; fill:#5c6570; font-weight:600; text-anchor:middle;}
/* ---------- Table ---------- */
table{width:100%; border-collapse:collapse; font-size:14px;}
thead th{
background:#f1f4f7; color:#2b3541; text-align:left; font-weight:700;
padding:10px 12px; border-bottom:2px solid #ccd4dc; font-size:13px;
}
tbody td{padding:11px 12px; border-bottom:1px solid var(--line); vertical-align:top;}
tbody tr:last-child td{border-bottom:none;}
.mono{font-family:var(--mono);}
.pinbadge{
display:inline-block; min-width:26px; text-align:center; font-family:var(--mono);
font-weight:700; font-size:13px; color:#fff; background:var(--accent);
border-radius:6px; padding:2px 7px;
}
.pinbadge.gnd{background:var(--gnd);} .pinbadge.pwr{background:var(--ok);}
.pinbadge.na{background:#adb5bd;}
td .warn-inline{color:var(--warn); font-weight:700;}
/* ---------- Schematic ---------- */
.svg-sch{display:block; width:100%; max-width:840px; height:auto; margin:0 auto;}
.box{fill:#f8f9fa; stroke:#495057; stroke-width:2; rx:8;}
.boxtitle{font-family:var(--sans); font-size:14px; font-weight:800; fill:#1a1d21; text-anchor:middle;}
.term{font-family:var(--mono); font-size:12.5px; font-weight:700; fill:#2b3541;}
.w-pwr{stroke:#e03131; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-i2c{stroke:#1971c2; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-gnd{stroke:#343a40; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-addr{stroke:#495057; stroke-width:2.5; fill:none; stroke-dasharray:6 4; stroke-linecap:round;}
.res{fill:#ffffff; stroke:#212529; stroke-width:2;}
.wlbl{font-family:var(--sans); font-size:12px; font-weight:700; fill:#2b3541;}
.rlbl{font-family:var(--mono); font-size:12px; font-weight:700; fill:#b02a2a;}
.jn{fill:#212529;}
.sch-note{color:var(--muted); font-size:12.5px; text-align:center; margin:12px 0 0;}
/* ---------- Callouts ---------- */
.warn{
background:var(--warnbg); border:1.5px solid #ffc9c9; border-left:6px solid var(--warn);
border-radius:12px; padding:16px 18px; margin:0 0 16px; display:flex; gap:14px; align-items:flex-start;
}
.warn .ic{flex:0 0 auto; width:34px; height:34px;}
.warn h3{margin:0 0 4px; color:#c92a2a; font-size:16px; font-weight:800;}
.warn p{margin:0; font-size:14px; color:#5a2020;}
.warn b{color:#c92a2a;}
ul.notes{margin:6px 0 0; padding:0; list-style:none;}
ul.notes li{position:relative; padding:7px 0 7px 24px; border-bottom:1px dashed var(--line); font-size:14px;}
ul.notes li:last-child{border-bottom:none;}
ul.notes li::before{content:""; position:absolute; left:4px; top:14px; width:8px; height:8px;
background:var(--accent); border-radius:2px; transform:rotate(45deg);}
code, .kbd{font-family:var(--mono); background:#eef2f6; color:#0b3d2e; padding:1px 6px; border-radius:5px; font-size:13px;}
.cmd{
font-family:var(--mono); background:#12211b; color:#c8f7d8; border-radius:10px;
padding:12px 14px; font-size:13.5px; margin:12px 0 0; overflow-x:auto;
}
.cmd .cm{color:#7fd6a3;} .cmd .ok{color:#ffd43b;}
/* ---------- Checklist ---------- */
ol.check{margin:0; padding:0; list-style:none; counter-reset:ck;}
ol.check li{
counter-increment:ck; position:relative; display:flex; align-items:flex-start; gap:12px;
padding:11px 4px 11px 0; border-bottom:1px solid var(--line); font-size:14px;
}
ol.check li:last-child{border-bottom:none;}
ol.check li .num{
flex:0 0 auto; width:24px; height:24px; border-radius:50%; background:#e7eef5; color:#1f5f97;
font-weight:800; font-size:12.5px; display:flex; align-items:center; justify-content:center;
}
ol.check li .num::before{content:counter(ck);}
ol.check li .cbox{flex:0 0 auto; width:18px; height:18px; border:2px solid #9aa6b1; border-radius:4px; margin-top:1px;}
ol.check li .txt{flex:1;}
ol.check li .txt b{color:var(--warn);}
footer{margin-top:26px; text-align:center; color:var(--muted); font-size:12px;}
/* ---------- Print ---------- */
@media print{
body{background:#fff;}
.wrap{max-width:none; padding:0;}
.hero{background:#fff !important; color:#111; border:2px solid #111; box-shadow:none;}
.hero .eyebrow{color:#444;} .hero p.sum{color:#222;}
.chip{background:#f2f2f2; color:#111; border:1px solid #999;}
.chip b{color:#000;}
h2 .n{background:#111;}
section{box-shadow:none; border:1px solid #999; break-inside:avoid; page-break-inside:avoid;}
table, tr, td, th, .warn, .card{break-inside:avoid; page-break-inside:avoid;}
.svg-pin, .svg-sch{max-width:100%;}
.cmd{background:#f2f2f2; color:#111; border:1px solid #999;}
a{color:#000;}
}
@media (max-width:560px){
.hero h1{font-size:22px;} h2{font-size:17px;}
}
</style>
</head>
<body>
<div class="wrap">
<!-- ============ 1. HEADER ============ -->
<header class="hero">
<p class="eyebrow">현장 설치 문서 · 배선 &amp; 핀맵</p>
<h1>온습도 노드 — 1번 지점 / 서버실</h1>
<p class="sum">
SHT30 온습도 센서를 라즈베리파이(Pi-B)의 I2C(버스 1, 주소 0x44)에 연결하는 측정 노드입니다.
이 노드는 측정값만 <b>5분 주기</b>로 서버에 보고하며, 고온·저온·고습·저습 임계 판정과 SMS 발송은 서버가 담당합니다.
</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">Sensirion SHT30 (한진데이터 P4422-3)</span>
<span class="chip mono">I2C bus <b>1</b></span>
<span class="chip mono">addr <b>0x44</b></span>
<span class="chip mono">sensor_id=<b>2</b></span>
<span class="chip">보고 주기 5분</span>
</div>
</header>
<!-- ============ 2. PINMAP SVG ============ -->
<section>
<h2><span class="n">2</span>40핀 헤더 핀맵 도해 <small>사용 핀만 강조 · 나머지는 회색</small></h2>
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="라즈베리파이 40핀 GPIO 헤더 핀맵">
<!-- caption -->
<text class="cap-svg" x="260" y="20">40핀 GPIO 헤더 (J8) · 물리 핀 번호</text>
<!-- rails -->
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
<!-- Row 1 : pin1 (square = pin1 marker) / pin2 -->
<rect class="p-pwr" x="188" y="48" width="24" height="24" rx="5"/>
<text class="pnum pnum-l" x="200" y="60">1</text>
<text class="plbl lbl-l lbl-used" x="176" y="60">3V3</text>
<circle class="p-off" cx="320" cy="60" r="12"/>
<text class="pnum pnum-d" x="320" y="60">2</text>
<text class="plbl lbl-r lbl-off" x="344" y="60">5V</text>
<!-- Row 2 : pin3 / pin4 -->
<circle class="p-i2c" cx="200" cy="90" r="12"/>
<text class="pnum pnum-l" x="200" y="90">3</text>
<text class="plbl lbl-l lbl-used" x="176" y="90">GPIO2 (SDA1)</text>
<circle class="p-off" cx="320" cy="90" r="12"/>
<text class="pnum pnum-d" x="320" y="90">4</text>
<text class="plbl lbl-r lbl-off" x="344" y="90">5V</text>
<!-- Row 3 : pin5 / pin6 -->
<circle class="p-i2c" cx="200" cy="120" r="12"/>
<text class="pnum pnum-l" x="200" y="120">5</text>
<text class="plbl lbl-l lbl-used" x="176" y="120">GPIO3 (SCL1)</text>
<circle class="p-gnd" cx="320" cy="120" r="12"/>
<text class="pnum pnum-l" x="320" y="120">6</text>
<text class="plbl lbl-r lbl-used" x="344" y="120">GND</text>
<!-- Row 4 : pin7 / pin8 -->
<circle class="p-off" cx="200" cy="150" r="12"/>
<text class="pnum pnum-d" x="200" y="150">7</text>
<text class="plbl lbl-l lbl-off" x="176" y="150">GPIO4</text>
<circle class="p-off" cx="320" cy="150" r="12"/>
<text class="pnum pnum-d" x="320" y="150">8</text>
<text class="plbl lbl-r lbl-off" x="344" y="150">GPIO14 (TXD)</text>
<!-- Row 5 : pin9 / pin10 -->
<circle class="p-altgnd" cx="200" cy="180" r="12"/>
<text class="pnum pnum-d" x="200" y="180">9</text>
<text class="plbl lbl-l lbl-alt" x="176" y="180">GND</text>
<circle class="p-off" cx="320" cy="180" r="12"/>
<text class="pnum pnum-d" x="320" y="180">10</text>
<text class="plbl lbl-r lbl-off" x="344" y="180">GPIO15 (RXD)</text>
<!-- Row 6 : pin11 / pin12 -->
<circle class="p-off" cx="200" cy="210" r="12"/>
<text class="pnum pnum-d" x="200" y="210">11</text>
<text class="plbl lbl-l lbl-off" x="176" y="210">GPIO17</text>
<circle class="p-off" cx="320" cy="210" r="12"/>
<text class="pnum pnum-d" x="320" y="210">12</text>
<text class="plbl lbl-r lbl-off" x="344" y="210">GPIO18</text>
<!-- Row 7 : pin13 / pin14 -->
<circle class="p-off" cx="200" cy="240" r="12"/>
<text class="pnum pnum-d" x="200" y="240">13</text>
<text class="plbl lbl-l lbl-off" x="176" y="240">GPIO27</text>
<circle class="p-altgnd" cx="320" cy="240" r="12"/>
<text class="pnum pnum-d" x="320" y="240">14</text>
<text class="plbl lbl-r lbl-alt" x="344" y="240">GND</text>
<!-- Row 8 : pin15 / pin16 -->
<circle class="p-off" cx="200" cy="270" r="12"/>
<text class="pnum pnum-d" x="200" y="270">15</text>
<text class="plbl lbl-l lbl-off" x="176" y="270">GPIO22</text>
<circle class="p-off" cx="320" cy="270" r="12"/>
<text class="pnum pnum-d" x="320" y="270">16</text>
<text class="plbl lbl-r lbl-off" x="344" y="270">GPIO23</text>
<!-- Row 9 : pin17 / pin18 -->
<circle class="p-off" cx="200" cy="300" r="12"/>
<text class="pnum pnum-d" x="200" y="300">17</text>
<text class="plbl lbl-l lbl-off" x="176" y="300">3V3</text>
<circle class="p-off" cx="320" cy="300" r="12"/>
<text class="pnum pnum-d" x="320" y="300">18</text>
<text class="plbl lbl-r lbl-off" x="344" y="300">GPIO24</text>
<!-- Row 10 : pin19 / pin20 -->
<circle class="p-off" cx="200" cy="330" r="12"/>
<text class="pnum pnum-d" x="200" y="330">19</text>
<text class="plbl lbl-l lbl-off" x="176" y="330">GPIO10 (MOSI)</text>
<circle class="p-altgnd" cx="320" cy="330" r="12"/>
<text class="pnum pnum-d" x="320" y="330">20</text>
<text class="plbl lbl-r lbl-alt" x="344" y="330">GND</text>
<!-- Row 11 : pin21 / pin22 -->
<circle class="p-off" cx="200" cy="360" r="12"/>
<text class="pnum pnum-d" x="200" y="360">21</text>
<text class="plbl lbl-l lbl-off" x="176" y="360">GPIO9 (MISO)</text>
<circle class="p-off" cx="320" cy="360" r="12"/>
<text class="pnum pnum-d" x="320" y="360">22</text>
<text class="plbl lbl-r lbl-off" x="344" y="360">GPIO25</text>
<!-- Row 12 : pin23 / pin24 -->
<circle class="p-off" cx="200" cy="390" r="12"/>
<text class="pnum pnum-d" x="200" y="390">23</text>
<text class="plbl lbl-l lbl-off" x="176" y="390">GPIO11 (SCLK)</text>
<circle class="p-off" cx="320" cy="390" r="12"/>
<text class="pnum pnum-d" x="320" y="390">24</text>
<text class="plbl lbl-r lbl-off" x="344" y="390">GPIO8 (CE0)</text>
<!-- Row 13 : pin25 / pin26 -->
<circle class="p-altgnd" cx="200" cy="420" r="12"/>
<text class="pnum pnum-d" x="200" y="420">25</text>
<text class="plbl lbl-l lbl-alt" x="176" y="420">GND</text>
<circle class="p-off" cx="320" cy="420" r="12"/>
<text class="pnum pnum-d" x="320" y="420">26</text>
<text class="plbl lbl-r lbl-off" x="344" y="420">GPIO7 (CE1)</text>
<!-- Row 14 : pin27 / pin28 -->
<circle class="p-off" cx="200" cy="450" r="12"/>
<text class="pnum pnum-d" x="200" y="450">27</text>
<text class="plbl lbl-l lbl-off" x="176" y="450">GPIO0 (ID_SD)</text>
<circle class="p-off" cx="320" cy="450" r="12"/>
<text class="pnum pnum-d" x="320" y="450">28</text>
<text class="plbl lbl-r lbl-off" x="344" y="450">GPIO1 (ID_SC)</text>
<!-- Row 15 : pin29 / pin30 -->
<circle class="p-off" cx="200" cy="480" r="12"/>
<text class="pnum pnum-d" x="200" y="480">29</text>
<text class="plbl lbl-l lbl-off" x="176" y="480">GPIO5</text>
<circle class="p-altgnd" cx="320" cy="480" r="12"/>
<text class="pnum pnum-d" x="320" y="480">30</text>
<text class="plbl lbl-r lbl-alt" x="344" y="480">GND</text>
<!-- Row 16 : pin31 / pin32 -->
<circle class="p-off" cx="200" cy="510" r="12"/>
<text class="pnum pnum-d" x="200" y="510">31</text>
<text class="plbl lbl-l lbl-off" x="176" y="510">GPIO6</text>
<circle class="p-off" cx="320" cy="510" r="12"/>
<text class="pnum pnum-d" x="320" y="510">32</text>
<text class="plbl lbl-r lbl-off" x="344" y="510">GPIO12</text>
<!-- Row 17 : pin33 / pin34 -->
<circle class="p-off" cx="200" cy="540" r="12"/>
<text class="pnum pnum-d" x="200" y="540">33</text>
<text class="plbl lbl-l lbl-off" x="176" y="540">GPIO13</text>
<circle class="p-altgnd" cx="320" cy="540" r="12"/>
<text class="pnum pnum-d" x="320" y="540">34</text>
<text class="plbl lbl-r lbl-alt" x="344" y="540">GND</text>
<!-- Row 18 : pin35 / pin36 -->
<circle class="p-off" cx="200" cy="570" r="12"/>
<text class="pnum pnum-d" x="200" y="570">35</text>
<text class="plbl lbl-l lbl-off" x="176" y="570">GPIO19</text>
<circle class="p-off" cx="320" cy="570" r="12"/>
<text class="pnum pnum-d" x="320" y="570">36</text>
<text class="plbl lbl-r lbl-off" x="344" y="570">GPIO16</text>
<!-- Row 19 : pin37 / pin38 -->
<circle class="p-off" cx="200" cy="600" r="12"/>
<text class="pnum pnum-d" x="200" y="600">37</text>
<text class="plbl lbl-l lbl-off" x="176" y="600">GPIO26</text>
<circle class="p-off" cx="320" cy="600" r="12"/>
<text class="pnum pnum-d" x="320" y="600">38</text>
<text class="plbl lbl-r lbl-off" x="344" y="600">GPIO20</text>
<!-- Row 20 : pin39 / pin40 -->
<circle class="p-altgnd" cx="200" cy="630" r="12"/>
<text class="pnum pnum-d" x="200" y="630">39</text>
<text class="plbl lbl-l lbl-alt" x="176" y="630">GND</text>
<circle class="p-off" cx="320" cy="630" r="12"/>
<text class="pnum pnum-d" x="320" y="630">40</text>
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
<!-- pin1 hint -->
<text class="cap-svg" x="260" y="662" style="font-size:11px;">■ 사각 표시 = 1번 핀 (기판 방향 기준)</text>
</svg>
<div class="legend">
<span><i class="sw pwr"></i>3.3V 전원 — 핀 1</span>
<span><i class="sw i2c"></i>I2C SDA/SCL — 핀 3·5</span>
<span><i class="sw gnd"></i>GND (사용) — 핀 6</span>
<span><i class="sw altgnd"></i>대체 GND (선택) — 9·14·20·25·30·34·39</span>
<span><i class="sw off"></i>미사용 핀</span>
</div>
<p class="diagram-cap">본 도해의 핀 번호·신호는 Raspberry Pi 4 Model B (BCM2711) 40핀 헤더 표준 배열과 일치합니다.</p>
</section>
<!-- ============ 3. PINMAP TABLE ============ -->
<section>
<h2><span class="n">3</span>핀맵 표 <small>SHT30 → 40핀 헤더</small></h2>
<table>
<thead>
<tr><th style="width:30%">신호</th><th style="width:24%">BCM</th><th style="width:14%">물리핀</th><th>비고</th></tr>
</thead>
<tbody>
<tr>
<td><b>3.3V 전원</b> (VCC)</td>
<td class="mono">—</td>
<td><span class="pinbadge pwr">1</span></td>
<td><span class="warn-inline">★ 반드시 3.3V — 5V(핀 2·4) 연결 금지</span></td>
</tr>
<tr>
<td><b>I2C 데이터</b> (SDA)</td>
<td class="mono">GPIO2 / SDA1</td>
<td><span class="pinbadge">3</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td>
</tr>
<tr>
<td><b>I2C 클럭</b> (SCL)</td>
<td class="mono">GPIO3 / SCL1</td>
<td><span class="pinbadge">5</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td>
</tr>
<tr>
<td><b>접지</b> (GND)</td>
<td class="mono">—</td>
<td><span class="pinbadge gnd">6</span></td>
<td>대체 GND 사용 가능: <span class="mono">9 · 14 · 20 · 25 · 30 · 34 · 39</span></td>
</tr>
<tr>
<td><b>주소 선택</b> (ADDR)</td>
<td class="mono">—</td>
<td><span class="pinbadge na">GND</span></td>
<td>ADDR → GND ⇒ <span class="mono">0x44</span> (선택) · ADDR → VDD ⇒ <span class="mono">0x45</span></td>
</tr>
</tbody>
</table>
</section>
<!-- ============ 4. SCHEMATIC SVG ============ -->
<section>
<h2><span class="n">4</span>배선 스키매틱 <small>센서 ↔ 헤더 ↔ 풀업 저항</small></h2>
<svg class="svg-sch" viewBox="0 0 840 400" role="img" aria-label="SHT30 배선 스키매틱">
<!-- component boxes -->
<rect class="box" x="70" y="90" width="150" height="290" rx="8"/>
<rect class="box" x="640" y="90" width="150" height="290" rx="8"/>
<text class="boxtitle" x="145" y="116">SHT30 센서 모듈</text>
<text class="boxtitle" x="715" y="116">Pi 40핀 헤더</text>
<!-- SHT30 terminal labels (right side) -->
<text class="term" x="210" y="150" text-anchor="end">VCC</text>
<text class="term" x="210" y="200" text-anchor="end">SDA</text>
<text class="term" x="210" y="250" text-anchor="end">SCL</text>
<text class="term" x="210" y="305" text-anchor="end">GND</text>
<text class="term" x="210" y="350" text-anchor="end">ADDR</text>
<!-- Pi terminal labels (left side) -->
<text class="term" x="650" y="150">3V3 (핀 1)</text>
<text class="term" x="650" y="200">SDA1 (핀 3)</text>
<text class="term" x="650" y="250">SCL1 (핀 5)</text>
<text class="term" x="650" y="305">GND (핀 6)</text>
<!-- POWER wire (also 3V3 rail for pull-ups) -->
<path class="w-pwr" d="M220 150 H640"/>
<text class="wlbl" x="300" y="140" fill="#b02a2a">전원 3.3V</text>
<!-- GND wire -->
<path class="w-gnd" d="M220 305 H640"/>
<text class="wlbl" x="300" y="322" fill="#2b3541">접지 GND</text>
<!-- SDA wire (with hop over SCL pull-up branch at x=480) -->
<path class="w-i2c" d="M220 200 H470 A10 9 0 0 1 490 200 H640"/>
<text class="wlbl" x="300" y="216" fill="#155a9c">SDA → GPIO2</text>
<!-- SCL wire -->
<path class="w-i2c" d="M220 250 H640"/>
<text class="wlbl" x="300" y="266" fill="#155a9c">SCL → GPIO3</text>
<!-- ADDR -> GND -->
<path class="w-addr" d="M220 350 H300 V305"/>
<text class="wlbl" x="228" y="368" fill="#3b444d">ADDR → GND = 0x44</text>
<circle class="jn" cx="300" cy="305" r="4"/>
<!-- Pull-up R1 on SDA (x=400) -->
<path class="w-i2c" d="M400 200 V190"/>
<rect class="res" x="390" y="165" width="20" height="25" rx="3"/>
<path class="w-i2c" d="M400 165 V150"/>
<circle class="jn" cx="400" cy="200" r="4"/>
<circle class="jn" cx="400" cy="150" r="4"/>
<text class="rlbl" x="416" y="180">R1 4.7kΩ</text>
<!-- Pull-up R2 on SCL (x=480), branch rises past SDA (hop) up to rail -->
<path class="w-i2c" d="M480 250 V188"/>
<rect class="res" x="470" y="163" width="20" height="25" rx="3"/>
<path class="w-i2c" d="M480 163 V150"/>
<circle class="jn" cx="480" cy="250" r="4"/>
<circle class="jn" cx="480" cy="150" r="4"/>
<text class="rlbl" x="496" y="178">R2 4.7kΩ</text>
</svg>
<p class="sch-note">
풀업 저항 R1·R2 (4.7kΩ ×2) 는 3.3V 레일로 연결됩니다. <b>SHT30 모듈에 내장되어 있으면 생략</b>하십시오.
SDA 선의 반원(hop)은 배선 교차 표시일 뿐 전기적 연결이 아닙니다.
</p>
</section>
<!-- ============ 5. ELECTRICAL NOTES ============ -->
<section>
<h2><span class="n">5</span>전기 설계 · 주의</h2>
<div class="warn">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2 L23 21 H1 Z" fill="#ffe3e3" stroke="#e03131" stroke-width="1.6" stroke-linejoin="round"/>
<line x1="12" y1="9" x2="12" y2="15" stroke="#e03131" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="18" r="1.4" fill="#e03131"/>
</svg>
<div>
<h3>치명적 경고 — 전원은 반드시 3.3V</h3>
<p>SHT30 의 <b>VCC 는 물리 핀 1 (3.3V)</b> 에만 연결하십시오.
<b>5V (핀 2·4) 연결 절대 금지</b> — 소자가 즉시 손상될 수 있습니다.
결선 전 핀 번호를 다시 확인하십시오.</p>
</div>
</div>
<ul class="notes">
<li><b>I2C 풀업:</b> SDA·SCL 각각 4.7kΩ 풀업 저항을 3.3V 로 연결. 센서 모듈에 내장된 경우 생략 가능.</li>
<li><b>주소 설정:</b> ADDR → GND 이면 <code>0x44</code>(기본), ADDR → VDD 이면 <code>0x45</code>. <code>config.py</code> 의 <code>I2C_ADDR</code> 값과 반드시 일치해야 합니다.</li>
<li><b>I2C 버스:</b> 버스 1 사용. 부팅 후 I2C 가 활성화되어 있어야 함(<code>raspi-config</code> 또는 <code>dtparam=i2c_arm=on</code>).</li>
<li><b>배선 길이:</b> I2C 는 짧은 케이블을 권장. 케이블이 길면 통신 오류·풀업 값 조정이 필요할 수 있습니다.</li>
<li><b>역할 분담:</b> 이 노드는 측정값만 5분 주기로 서버에 보고합니다. 고온·저온·고습·저습 <b>임계 판정과 SMS 발송은 서버</b>가 수행합니다.</li>
<li><b>설정 파일:</b> <code>raspberry_pi4_sht30/config.py</code> (<code>I2C_BUS=1</code>, <code>I2C_ADDR=0x44</code>). 기기별 값은 <code>config_local.py</code> 로 오버라이드.</li>
</ul>
<div class="cmd">
<span class="cm"># I2C 연결 확인 (0x44 가 표에 보이면 정상)</span>
$ i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f
40: -- -- -- -- <span class="ok">44</span> -- -- -- -- -- -- -- -- -- -- --
</div>
</section>
<!-- ============ 6. INSTALL CHECKLIST ============ -->
<section>
<h2><span class="n">6</span>설치 체크리스트</h2>
<ol class="check">
<li><span class="num"></span><span class="cbox"></span><span class="txt"><b>전원 차단 상태</b>에서 배선 작업 (핫플러그 금지)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">VCC → 물리 핀 1 (3.3V) 연결 — <b>5V 핀(2·4) 아님</b>을 재확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">GND → 물리 핀 6 연결 (대체 GND 사용 시 9·14·20·25·30·34·39)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">SDA → 핀 3 (GPIO2), SCL → 핀 5 (GPIO3) 연결</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">풀업 저항 4.7kΩ ×2 를 SDA·SCL → 3.3V 로 연결 (모듈 내장 시 생략)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">ADDR → GND 확인 (주소 0x44 선택)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">결선 전체 재확인 후 Pi 전원 인가</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">I2C 활성화 확인 (raspi-config / dtparam=i2c_arm=on)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt"><code>i2cdetect -y 1</code> 실행 → <code>0x44</code> 표시 확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">config.py / config_local.py 의 I2C_BUS=1, I2C_ADDR=0x44 확인</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">측정 로그 및 서버 수신 확인 (5분 주기 보고 정상)</span></li>
</ol>
</section>
<footer>
온습도 노드 · Pi-B (sensor_id=2, 1번 지점/서버실) · SHT30 I2C 배선 문서 — 현장 인쇄용
</footer>
</div>
</body>
</html>

View file

@ -11,7 +11,7 @@
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 센서 식별 (서버 대시보드 표시용; 비밀 아님) # 센서 식별 (서버 대시보드 표시용; 비밀 아님)
# SENSOR_ID 는 서버 sensor_metric / sensor_status 의 키. 대시보드 기본 차트=2. # SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
SENSOR_ID = 2 SENSOR_ID = 2
SENSOR_NAME = "2번 센서 (SHT30)" SENSOR_NAME = "2번 센서 (SHT30)"

View file

@ -52,7 +52,8 @@ if [ "${OFFLINE}" = "1" ]; then
exit 1 exit 1
fi fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)" echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb # --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else else
echo " 온라인 apt 설치 (인터넷 필요)" echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update sudo apt-get update
@ -120,6 +121,15 @@ if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; the
fi fi
sudo chmod 640 /var/log/sht30_sensor.log sudo chmod 640 /var/log/sht30_sensor.log
# config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
fi
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨) # config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then if [ ! -f config_local.py ]; then
echo "" echo ""

View file

@ -173,7 +173,7 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
# 측정 루프 (firmware sht30_measure_task 대응) # 측정 루프 (firmware sht30_measure_task 대응)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def measure_loop(sensor: SHT30): def measure_loop(sensor: SHT30):
# 최초 성공 측정만 startup, 이후 periodic (서버 sensor_log 1회 startup 기록) # 최초 성공 측정만 startup, 이후 periodic (서버 leak_sensor_log 1회 startup 기록)
first_success = True first_success = True
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})") log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")

View file

@ -38,6 +38,26 @@ Environment=PYTHONUNBUFFERED=1
# 프로세스 전용 /tmp 격리 # 프로세스 전용 /tmp 격리
PrivateTmp=true PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. I2C 는 /dev/i2c-1 char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/i2c-1 이 숨겨져 센서 접근 불능.
# 재시작 상한 (10분 내 5번 실패 시 중단) # 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5 StartLimitBurst=5
StartLimitInterval=600s StartLimitInterval=600s

View file

@ -4,11 +4,14 @@
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번 wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다. 서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
""" """
import sys
import time import time
import subprocess import subprocess
import requests import requests
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
from config import ( from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
SENSOR_ID, SENSOR_NAME, APP_VERSION, SENSOR_ID, SENSOR_NAME, APP_VERSION,
@ -68,6 +71,7 @@ try:
print(f" HTTP 응답코드: {resp.status_code}") print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}") print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)") print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치") print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치")
@ -81,5 +85,6 @@ except Exception as e:
print(f" → 실패: {e}") print(f" → 실패: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료") print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -35,7 +35,8 @@ echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
echo "[vendor] (2/2) apt .deb → debs/" echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs mkdir -p debs
BASE_PKGS="python3-requests python3-venv i2c-tools" # nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
BASE_PKGS="python3-requests python3-venv i2c-tools nftables unattended-upgrades"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \ DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)" | awk '/^\w/{print $1}' | sort -u)"
@ -52,4 +53,23 @@ done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; } [ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)" echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행." echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

View file

@ -115,7 +115,7 @@ def save_data_flow():
("1. 온습도 측정", ["SHT30 측정", "온도/습도 5분 주기"]), ("1. 온습도 측정", ["SHT30 측정", "온도/습도 5분 주기"]),
("2. 단말 보고", ["측정값 수집", "periodic/startup", "정상 보고"]), ("2. 단말 보고", ["측정값 수집", "periodic/startup", "정상 보고"]),
("3. API 전송", ["HTTPS/TLS", "HMAC/서명 검증", "API 키 일치"]), ("3. API 전송", ["HTTPS/TLS", "HMAC/서명 검증", "API 키 일치"]),
("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "sensor_metric", "sensor_log", "sms_log"]), ("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "leak_sensor_metric", "leak_sensor_log", "leak_sms_log"]),
("5. 알림/대응", ["임계 SMS 발송", "정상복귀 SMS", "대시보드 확인"]), ("5. 알림/대응", ["임계 SMS 발송", "정상복귀 SMS", "대시보드 확인"]),
] ]
xs = [45, 275, 505, 735, 965] xs = [45, 275, 505, 735, 965]

View file

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

View file

@ -1,106 +0,0 @@
-- =============================================================================
-- migration_drop_leak.sql - 기존 누수(v2605) 설치 → SHT30 온습도 전용 전환
--
-- ⚠️ 비가역(DROP COLUMN/TABLE) 변경입니다. 반드시 DB 전체 백업 후 실행하세요.
-- 운영 데이터(과거 누수 이벤트/사진/사고 이력)가 삭제됩니다.
--
-- 이미 v2605 스키마(sensor_log/sensor_status/sensor_metric/sms_log + leak_*)가
-- 적용된 운영 DB를 SHT30 전용으로 정리합니다. 측정 이력(sensor_metric)과 SMS
-- 이력(sms_log)은 보존합니다.
--
-- 신규 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하세요.
-- =============================================================================
SET NAMES utf8mb4;
-- 0) sensor_metric 이 없던 구버전 대비: 없으면 생성(있으면 무시).
CREATE TABLE IF NOT EXISTS `sensor_metric` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '',
`sensor_id` TINYINT UNSIGNED NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
`metric_type` VARCHAR(30) NOT NULL DEFAULT 'sht30',
`temperature_c` DECIMAL(6,2) NULL,
`humidity_percent` DECIMAL(5,2) NULL,
`metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal',
`measured_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_sensor_measured` (`sensor_id`, `measured_at`),
INDEX `idx_device_sensor` (`device_id`, `sensor_id`),
INDEX `idx_metric_type` (`metric_type`, `created_at`),
INDEX `idx_status_measured` (`metric_status`, `measured_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1) 누수 전용 테이블 제거
DROP TABLE IF EXISTS `leak_photo`;
DROP TABLE IF EXISTS `leak_incident`;
-- 2) sensor_log: 누수 enum 값 정리 → event_type 재정의 (둘 다 멱등)
-- (leak_alert/leak_recovery 행이 있으면 periodic 으로 강등 후 enum 축소.
-- 이미 정리된 DB면 매칭 행이 없어 no-op, MODIFY 도 동일 enum 재적용이라 안전.)
UPDATE `sensor_log` SET `event_type` = 'periodic'
WHERE `event_type` IN ('leak_alert', 'leak_recovery');
ALTER TABLE `sensor_log`
MODIFY `event_type` ENUM('startup','periodic') NOT NULL DEFAULT 'periodic';
-- 3~4) 인덱스/컬럼 제거·추가는 "존재할 때만" 수행하여 재실행해도 오류가 없게 한다.
-- (MySQL/MariaDB 는 DROP COLUMN/INDEX 에 IF EXISTS 호환이 일정치 않으므로
-- information_schema 로 가드하는 멱등 프로시저를 사용한다.)
DROP PROCEDURE IF EXISTS _mig_drop_leak;
DELIMITER //
CREATE PROCEDURE _mig_drop_leak()
BEGIN
DECLARE db VARCHAR(64);
SELECT DATABASE() INTO db;
-- sensor_log: is_leak 인덱스/컬럼 제거, event_type 인덱스 추가
IF EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_is_leak') THEN
ALTER TABLE `sensor_log` DROP INDEX `idx_is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_log` DROP COLUMN `is_leak`;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_event_type') THEN
ALTER TABLE `sensor_log` ADD INDEX `idx_event_type` (`event_type`, `created_at`);
END IF;
-- sensor_status: 누수 컬럼/인덱스 제거 (offline_alerted 는 오프라인 SMS에 필요 → 유지)
IF EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND INDEX_NAME = 'idx_is_leak') THEN
ALTER TABLE `sensor_status` DROP INDEX `idx_is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN
ALTER TABLE `sensor_status` DROP COLUMN `is_leak`;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'leak_periodic_count') THEN
ALTER TABLE `sensor_status` DROP COLUMN `leak_periodic_count`;
END IF;
-- sensor_metric: 임계 경보 조회용 인덱스 추가(없을 때만)
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_metric' AND INDEX_NAME = 'idx_status_measured') THEN
ALTER TABLE `sensor_metric` ADD INDEX `idx_status_measured` (`metric_status`, `measured_at`);
END IF;
END //
DELIMITER ;
CALL _mig_drop_leak();
DROP PROCEDURE IF EXISTS _mig_drop_leak;
-- 5) 수집 재전송(replay) 멱등 nonce 테이블 추가(없을 때만 — 가산/멱등).
-- api/sensor_data.php 가 서명을 nonce 로 기록해 시계오차 창 내 중복 수신을 멱등
-- 처리한다(중복 저장·중복 SMS 방지). 이 테이블이 없어도 수집은 동작한다.
CREATE TABLE IF NOT EXISTS `ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`signature`),
INDEX `idx_seen_at` (`seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)';

View file

@ -5,24 +5,23 @@
-- 신규 설치: 이 파일 하나만 실행합니다. -- 신규 설치: 이 파일 하나만 실행합니다.
-- SOURCE /path/to/sql/schema_sht30.sql; -- SOURCE /path/to/sql/schema_sht30.sql;
-- --
-- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을 -- v2607: 모든 테이블에 leak_ 접두사 적용 — 같은 DB 의 다른(비관련) 테이블과 격리.
-- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시 -- (구버전 무접두 테이블 대상 마이그레이션 파일들은 리네임과 함께 폐기 — git 이력 참고.)
-- 더하려면 sql/migration_add_leak.sql(가산·멱등).
-- --
-- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소: -- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소:
-- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*). -- - 온습도(SHT30): leak_sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*).
-- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼, -- - 누수(leak): leak_sensor_log.is_leak / leak_sensor_status.is_leak 컬럼,
-- event_type 의 leak_alert / leak_recovery, -- event_type 의 leak_alert / leak_recovery,
-- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블. -- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블.
-- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며, -- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며,
-- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음). -- 발송 이력은 leak_sms_log 에 남깁니다(leak_sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음).
-- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다. -- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다.
-- ============================================================================= -- =============================================================================
SET NAMES utf8mb4; SET NAMES utf8mb4;
SET time_zone = '+09:00'; SET time_zone = '+09:00';
CREATE TABLE IF NOT EXISTS `sensor_log` ( CREATE TABLE IF NOT EXISTS `leak_sensor_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL COMMENT 'STM32 기기 ID', `device_id` VARCHAR(64) NOT NULL COMMENT 'STM32 기기 ID',
`device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치', `device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치',
@ -43,7 +42,7 @@ CREATE TABLE IF NOT EXISTS `sensor_log` (
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
COMMENT='센서 이벤트 전체 로그'; COMMENT='센서 이벤트 전체 로그';
CREATE TABLE IF NOT EXISTS `sensor_status` ( CREATE TABLE IF NOT EXISTS `leak_sensor_status` (
`sensor_id` TINYINT UNSIGNED NOT NULL, `sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL, `device_id` VARCHAR(64) NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '', `sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
@ -61,7 +60,7 @@ CREATE TABLE IF NOT EXISTS `sensor_status` (
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
COMMENT='센서별 최신 상태 (업서트)'; COMMENT='센서별 최신 상태 (업서트)';
CREATE TABLE IF NOT EXISTS `sensor_metric` ( CREATE TABLE IF NOT EXISTS `leak_sensor_metric` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_id` VARCHAR(64) NOT NULL DEFAULT '', `device_id` VARCHAR(64) NOT NULL DEFAULT '',
`device_location` VARCHAR(100) NOT NULL DEFAULT '', `device_location` VARCHAR(100) NOT NULL DEFAULT '',
@ -85,7 +84,7 @@ CREATE TABLE IF NOT EXISTS `sensor_metric` (
COLLATE=utf8mb4_unicode_ci COLLATE=utf8mb4_unicode_ci
COMMENT='SHT30 온습도 측정 이력 (온도/습도/임계 상태)'; COMMENT='SHT30 온습도 측정 이력 (온도/습도/임계 상태)';
CREATE TABLE IF NOT EXISTS `sms_log` ( CREATE TABLE IF NOT EXISTS `leak_sms_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sensor_id` TINYINT UNSIGNED NOT NULL, `sensor_id` TINYINT UNSIGNED NOT NULL,
`sensor_name` VARCHAR(100) NOT NULL DEFAULT '', `sensor_name` VARCHAR(100) NOT NULL DEFAULT '',
@ -110,7 +109,7 @@ CREATE TABLE IF NOT EXISTS `sms_log` (
-- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 -- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이
-- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은 -- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은
-- 동작하며 멱등 방어만 비활성화된다 — graceful degrade). -- 동작하며 멱등 방어만 비활성화된다 — graceful degrade).
CREATE TABLE IF NOT EXISTS `ingest_nonce` ( CREATE TABLE IF NOT EXISTS `leak_ingest_nonce` (
`signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL `signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL
COMMENT '요청 서명(X-Signature) — 멱등 키', COMMENT '요청 서명(X-Signature) — 멱등 키',
`seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP `seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@ -133,8 +132,8 @@ CREATE TABLE IF NOT EXISTS `leak_incident` (
`device_location` VARCHAR(100) NOT NULL DEFAULT '', `device_location` VARCHAR(100) NOT NULL DEFAULT '',
`started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각', `started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각',
`recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)', `recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)',
`log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id', `log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert leak_sensor_log.id',
`log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id', `log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery leak_sensor_log.id',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@ -149,7 +148,7 @@ CREATE TABLE IF NOT EXISTS `leak_incident` (
-- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다. -- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다.
CREATE TABLE IF NOT EXISTS `leak_photo` ( CREATE TABLE IF NOT EXISTS `leak_photo` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id', `log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert leak_sensor_log.id',
`sensor_id` TINYINT UNSIGNED NOT NULL, `sensor_id` TINYINT UNSIGNED NOT NULL,
`device_id` VARCHAR(64) NOT NULL DEFAULT '', `device_id` VARCHAR(64) NOT NULL DEFAULT '',
`filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)', `filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)',

View file

@ -156,7 +156,7 @@ $opsChecks = file_text($root . '/php/ops_checks.php');
check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.'); check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.');
check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.'); check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.');
check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.'); check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.');
check(str_contains($schemaSql, 'ingest_nonce'), 'schema_sht30.sql should create the ingest_nonce table.'); check(str_contains($schemaSql, 'leak_ingest_nonce'), 'schema_sht30.sql should create the leak_ingest_nonce table.');
// sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400) // sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400)
check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.'); check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');

142
tools/harden_pi.sh Normal file
View file

@ -0,0 +1,142 @@
#!/bin/bash
# =============================================================================
# harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통)
#
# 정책 (2026-07 확정):
# - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST).
# 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의
# boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨).
# - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST).
# - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지).
#
# 실행 순서(중요): SSH 차단은 "맨 마지막" 단계다. 그 앞의 실패하기 쉬운 단계
# (패키지 설치·방화벽·권한)에서 문제가 나면 set -e 로 SSH 가 살아있는 상태에서
# 중단되어 원격 디버깅이 가능하다. 모든 앞 단계가 성공해야만 SSH 를 끊는다.
#
# 폐쇄망(air-gapped): 같은 폴더에 vendor.sh 산출물 debs/ 가 있으면 nftables·
# unattended-upgrades 도 오프라인(--no-download)으로 설치한다. 없으면 온라인 apt.
# (vendor.sh 가 이 두 패키지도 미리 받아 debs/ 에 포함한다.)
#
# 실행: 모듈 설치(install.sh)와 검증(test_connection.py)이 전부 끝난 "마지막"에.
# sudo bash harden_pi.sh
# ⚠️ SSH 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가.
# =============================================================================
set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; }
# debs/ 를 스크립트 위치 기준으로 찾도록 cwd 를 스크립트 폴더로 이동(절대경로 로직엔 무관).
cd "$(dirname "$0")" 2>/dev/null || true
# 오프라인 번들(debs/) 유무 판정 — install.sh 와 동일한 규칙.
OFFLINE_DEBS=0
if compgen -G "./debs/*.deb" >/dev/null 2>&1; then
OFFLINE_DEBS=1
echo "[INFO] 오프라인 번들(debs/) 감지 → 패키지 설치는 네트워크 미사용(--no-download)"
fi
# 필요 패키지 확보: 이미 설치돼 있으면 skip, 아니면 오프라인 debs/ → 온라인 apt 순.
# 설치 후 재검증하여 실패 시 즉시 중단(SSH 아직 살아있음).
ensure_pkgs() {
local missing=() p
for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done
if [ "${#missing[@]}" -eq 0 ]; then
echo " (${*} 이미 설치됨 — skip)"
elif [ "${OFFLINE_DEBS}" = 1 ]; then
echo " 오프라인 debs/ 에서 설치 (네트워크 미사용): ${missing[*]}"
# --no-install-recommends: vendor.sh 가 Recommends 를 번들에 안 담으므로(--no-recommends)
# 설치 때도 Recommends 를 요구하지 않게 맞춘다(폐쇄망 결정성 — 네트워크 폴백 방지).
apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치: ${missing[*]}"
# 단독 온라인 실행 대비(로컬 apt 목록이 비어있을 수 있음). install.sh 뒤면 no-op.
apt-get update -q || true
apt-get install -y -q "${missing[@]}"
fi
for p in "$@"; do
dpkg -s "$p" >/dev/null 2>&1 || {
echo "[ERROR] '$p' 설치 실패. 폐쇄망이면 vendor.sh(debs/)에 포함해 재배포하거나," >&2
echo " 온라인이면 네트워크를 확인하세요. (SSH 는 아직 차단되지 않음 — 복구 가능)" >&2
exit 1
}
done
}
echo "[1/6] 방화벽(nftables) — 인바운드 전부 차단..."
ensure_pkgs nftables
cat > /etc/nftables.conf <<'NFT'
#!/usr/sbin/nft -f
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만.
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
# 현장 진단용 ping 허용 (원치 않으면 아래 2줄 삭제)
ip protocol icmp icmp type echo-request accept
ip6 nexthdr ipv6-icmp accept
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
NFT
systemctl enable --now nftables
nft -f /etc/nftables.conf
echo "[2/6] 자동 보안 패치(unattended-upgrades)..."
ensure_pkgs unattended-upgrades
# Raspberry Pi OS 는 기본 Origins 패턴(Debian 전용)에 안 걸린다 — Raspbian/RPF origin 추가.
cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT'
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Raspbian,codename=${distro_codename},label=Raspbian";
"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation";
};
// 자동 리부팅 금지 — 커널 패치 후에도 센서 공백을 만들지 않는다(수동 리부팅).
Unattended-Upgrade::Automatic-Reboot "false";
APT
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT
systemctl enable --now unattended-upgrades 2>/dev/null || true
echo "[3/6] 로컬 권한 조이기..."
# 시크릿 파일 소유자 전용 (API_KEY 포함). 못 찾으면 조용히 넘기지 말고 경고.
SECRET_FOUND=0
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do
if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi
done
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py 를 표준 경로에서 못 찾음 — 배치 경로가 다르면" \
"수동으로 잠그세요: chmod 600 <경로>/config_local.py (API_KEY 노출 방지)"
# 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구)
if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_boot_behaviour B1 || true
fi
echo "[4/6] 불필요 리스닝/주변 서비스 비활성..."
for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \
triggerhappy.service triggerhappy.socket cups.service cups.socket \
cups-browsed.service ModemManager.service; do
systemctl disable --now "$svc" 2>/dev/null || true
done
echo "[5/6] 사전 검증 캡처 (SSH 차단 전 — 방화벽/리스닝 상태)..."
echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):"
ss -lntup || true
echo "-- nftables input chain (policy drop 확인):"
# || true: pipefail 하에서 head 가 파이프를 먼저 닫아 nft 가 SIGPIPE(141) 로 죽어도
# 이 검증 단계가 중단되지 않게 한다(SSH 차단 직전 단계라 오탐 중단 방지).
nft list chain inet filter input | head -12 || true
echo "[6/6] SSH 완전 비활성 (마지막 — 되돌리기 어려운 단계)..."
# 여기까지 왔다면 앞의 모든 단계가 성공한 것. 이제 SSH 를 끊는다.
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다.
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
echo "-- ssh 상태 (enabled 가 아니어야 함):"
systemctl is-enabled ssh.service 2>&1 || true
echo ""
echo "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것."
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"

View file

@ -0,0 +1,383 @@
# =============================================================================
# make_deploy_bundles.py — 기기별 턴키 배포 번들 생성기
#
# python tools/make_deploy_bundles.py
#
# repo 루트에 deploy/ 를 만들고, 서버 1 + Pi 3대 각각 "복사 → install.sh →
# systemctl enable" 만으로 운영 가능한 폴더를 생성한다.
# - API_KEY 는 최초 실행 시 자동 생성(deploy/.secrets/api_key.txt 에 보관,
# 재실행 시 재사용) → 서버/Pi 4곳에 동일 주입.
# - 관리자 비밀번호도 자동 생성 + bcrypt 해시(php 필요) 주입.
# - 텍스트 파일은 LF 로 정규화(Windows 체크아웃 CRLF → Pi bash 오류 방지).
# - deploy/ 는 비밀값 포함 — .gitignore 대상. 절대 커밋 금지.
#
# 남는 수동 설정(외부 비밀이라 사전 주입 불가):
# 1) 서버 DB 계정(cafe24 패널 값) 2) SMS 계정/발신·수신번호 3) TOTP 등록 1회
# =============================================================================
import hashlib
import hmac
import os
import re
import secrets
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy"
SECRETS_DIR = DEPLOY / ".secrets"
# ── 배포 기본값 ──────────────────────────────────────────────────────────────
# WEB_DIR(난독화 경로 폴더명)은 비밀값 — 커밋되는 이 파일에 두지 않고
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정.
DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
BUNDLES = [
# (폴더명, 원본, 종류)
("01_server_cafe24", None, "server"),
("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
("04_pi_c_leak_id3", "raspberry_pi4", "leak"),
]
PI_META = {
"02_pi_a_leak_id1": {
"device_id": "rpi4-leak-01", "location": "1번 지점(서버실)",
"sensors": None, # config.py 기본값 (sensor_id 1)
"service": "leak-sensor",
},
"03_pi_b_sht30_id2": {
"device_id": "rpi4-sht30-01", "location": "1번 지점(서버실)",
"sensors": None, # config.py 기본값 (sensor_id 2)
"service": "sht30-sensor",
},
"04_pi_c_leak_id3": {
"device_id": "rpi4-leak-02", "location": "3번 지점(신규)",
"sensors": '[\n {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},\n]',
"service": "leak-sensor",
},
}
def fail(msg: str) -> None:
print(f"[ERROR] {msg}", file=sys.stderr)
sys.exit(1)
def load_or_create_secret(name: str, generate) -> str:
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
f = SECRETS_DIR / name
if f.exists():
return f.read_text(encoding="utf-8").strip()
value = generate()
f.write_text(value + "\n", encoding="utf-8")
os.chmod(f, 0o600) # Windows 에선 read-only 근사지만 POSIX 빌드머신 대비
return value
def bcrypt_hash(password: str) -> str:
"""php 로 bcrypt 해시 생성 (서버 setup_hash.php 와 동일 결과).
비밀번호는 argv 가 아니라 stdin 으로 전달한다(공유 빌드머신 프로세스 목록 노출 방지)."""
try:
out = subprocess.run(
["php", "-r", "echo password_hash(rtrim(fgets(STDIN), \"\\r\\n\"), PASSWORD_BCRYPT);"],
input=password + "\n",
capture_output=True, text=True, check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
fail(f"php 실행 실패 — bcrypt 해시를 만들 수 없습니다: {e}")
h = out.stdout.strip()
if not h.startswith("$2y$"):
fail(f"bcrypt 해시 형식 이상: {h!r}")
return h
def normalize_lf(path: Path) -> None:
"""번들 내 텍스트 파일 CRLF → LF (Pi bash/systemd/Apache 오류 방지).
확장자 없는 설정 dotfile(.htaccess/.gitignore 등)도 확장자와 무관하게 포함한다
(.htaccess CRLF 는 Apache Header 값에 CR 를 섞을 수 있음)."""
dotfiles = {".htaccess", ".gitignore", ".env.example"}
for p in path.rglob("*"):
if p.is_file() and (p.suffix in TEXT_EXTS or p.name in dotfiles):
data = p.read_bytes()
if b"\r\n" in data:
p.write_bytes(data.replace(b"\r\n", b"\n"))
def copy_tree(src: Path, dst: Path) -> None:
shutil.copytree(
src, dst,
ignore=shutil.ignore_patterns(*EXCLUDE_NAMES, "*.pyc"),
dirs_exist_ok=False,
)
def write_lf(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8", newline="\n")
# ── 유출 방어 가드 먼저 — 비밀값을 쓰기 전에 ignore 파일부터 만든다 ─────────
# (루트 .gitignore 의 deploy/ 항목과 이중 방어. 생성 도중 스크립트가 실패해도
# deploy/ 아래 비밀값이 git 에 스테이징되지 않도록 순서를 보장한다.)
DEPLOY.mkdir(parents=True, exist_ok=True)
write_lf(DEPLOY / ".gitignore", "# 배포 번들 전체 커밋 금지 (비밀값 포함)\n*\n!.gitignore\n")
# ── 비밀값 준비 ──────────────────────────────────────────────────────────────
API_KEY = load_or_create_secret("api_key.txt", lambda: secrets.token_hex(32))
ADMIN_PASSWORD = load_or_create_secret("admin_password.txt", lambda: secrets.token_urlsafe(18))
MFA_SETUP_TOKEN = load_or_create_secret("mfa_setup_token.txt", lambda: secrets.token_hex(16))
ADMIN_HASH = bcrypt_hash(ADMIN_PASSWORD)
WEB_DIR = load_or_create_secret("web_dir.txt", lambda: secrets.token_hex(8))
BASE_URL = f"{DOMAIN}/{WEB_DIR}"
API_URL = f"{BASE_URL}/api/sensor_data.php"
PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
# cron HTTP 키 — 서버 config.php 의 hash_hmac('sha256','cron',API_KEY) 과 동일.
# 미리 계산해 SETUP.md 에 넣어, 운영자가 API_KEY 를 셸 명령줄(히스토리/프로세스
# 목록에 노출)에 올리지 않아도 되게 한다.
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
# ── 기존 번들 제거 후 재생성 (.secrets 는 보존) ─────────────────────────────
for name, _, _ in BUNDLES:
d = DEPLOY / name
if d.exists():
shutil.rmtree(d)
# ── Pi 번들 ──────────────────────────────────────────────────────────────────
for name, src, kind in BUNDLES:
if kind == "server":
continue
dst = DEPLOY / name
copy_tree(ROOT / src, dst)
shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh")
meta = PI_META[name]
sensors_block = ""
if meta["sensors"]:
sensors_block = (
"\n# 이 기기 전용 센서 목록 — config.py 기본값(sensor_id 1) 오버라이드\n"
f"SENSORS = {meta['sensors']}\n"
)
write_lf(dst / "config_local.py", f'''# =============================================================================
# config_local.py — {meta["device_id"]} 운영값 (자동 생성, 커밋 금지)
# =============================================================================
API_URL = "{API_URL}"
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (SHT30 모듈은 사진 업로드 없음)'}
API_KEY = "{API_KEY}"
DEVICE_ID = "{meta["device_id"]}"
DEVICE_LOCATION = "{meta["location"]}"
{sensors_block}''')
# ── 원샷 설치 래퍼: 설치 → 서비스 등록 → 검증 → 하드닝 ──────────────
# 검증(test_connection.py)이 실패하면 set -e 로 하드닝 전에 멈춘다 →
# SSH 가 살아있어 디버깅 가능. 성공했을 때만 하드닝(SSH 차단)까지 간다.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
# =============================================================================
# setup.sh — {meta["device_id"]} 원샷 설치 (설치→서비스→검증→하드닝)
# pi 사용자로 실행: bash setup.sh (sudo 로 실행하지 말 것 — 내부에서 sudo)
# 검증 실패 시 하드닝 전에 중단(set -e) → SSH 유지되어 디버깅 가능.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
if [ "$(id -u)" -eq 0 ]; then
echo "[ERROR] sudo 없이 pi 사용자로 실행하세요: bash setup.sh" >&2
exit 1
fi
echo "== [1/4] 패키지·venv 설치 =="
chmod +x install.sh && ./install.sh
echo "== [2/4] systemd 서비스 등록 =="
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now {meta["service"]}
echo "== [3/4] 서버 연결 검증 (실패 시 여기서 중단 — SSH 유지됨) =="
# shellcheck disable=SC1091
source venv/bin/activate
python3 test_connection.py
echo "== [4/4] 하드닝 (마지막 — SSH 가 차단된다!) =="
sudo bash harden_pi.sh
echo ""
echo "설치·검증·하드닝 완료. 마무리 리부팅 권장: sudo reboot"
''')
write_lf(dst / "SETUP.md", f'''# {name} 설치 — {meta["device_id"]}
사전: Raspberry Pi OS **Bookworm 64-bit**, 사용자 **pi**, SSH 활성(Imager 설정).
`config_local.py` 는 이미 채워져 있음(API_KEY/URL/기기 ID). 설치 장소가 바뀌면 DEVICE_LOCATION 만 수정.
📐 **배선·핀맵:** 같은 폴더의 `WIRING.html` 을 브라우저로 열어 확인(자체 완결형 — 오프라인 열람 가능).
## 원샷 설치 (권장)
```bash
# 1. 이 폴더를 Pi 로 복사
scp -r {name} pi@<PI_IP>:/home/pi/{src}
# 2. 한 번에: 설치 → 서비스 등록 → 검증 → 하드닝
cd /home/pi/{src} && bash setup.sh
# (sudo 붙이지 말 것 — 내부에서 sudo 사용)
```
`setup.sh` 는 4단계를 자동 수행하고, **검증(3단계)이 실패하면 하드닝 전에 멈춘다**
(SSH 가 살아있어 원인을 볼 수 있음). 검증이 통과해야만 하드닝까지 진행된다.
⚠️ 4단계 하드닝은 **SSH 를 완전히 끈다.** SSH 로 접속해 실행 중이면 완료 직후
세션이 끊기고 재접속 불가 — 이후 유지보수는 현장 모니터+키보드. 마지막에 `sudo reboot`.
## 수동(단계별) — 문제 발생 시
```bash
cd /home/pi/{src}
chmod +x install.sh && ./install.sh # 1. 설치
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. 서비스
source venv/bin/activate && python3 test_connection.py # 3. 검증(서명 200)
sudo bash harden_pi.sh # 4. 하드닝(SSH 차단)
```
**하드닝 정책**(`harden_pi.sh`): SSH 완전 비활성 · 인바운드 전부 차단(nftables) ·
불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치 · `config_local.py` 권한 600.
이 시스템은 outbound 전용이라 인바운드가 필요 없음. 재활성이 필요하면 SD boot 파티션에
빈 `ssh` 파일 생성(물리 접근 필요).
''')
# ── 서버 번들 ────────────────────────────────────────────────────────────────
server = DEPLOY / "01_server_cafe24"
copy_tree(ROOT / "php", server / WEB_DIR)
# 예시 파일은 혼동 방지를 위해 번들에서 제거 (완성본 config.local.php 를 쓴다)
(server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True)
# var/ 는 런타임 상태(접속 IP·감사로그·하트비트·사진) — 빌드 머신의 로컬 파일이
# 번들에 유입되지 않도록 내용물을 비우고 빈 스켈레톤만 만든다.
var_dir = server / WEB_DIR / "var"
if var_dir.exists():
shutil.rmtree(var_dir)
var_dir.mkdir()
write_lf(var_dir / "index.html", "") # 디렉터리 목록 가드
# SQL 은 단일 파일만 — schema_sht30.sql 이 신규 설치 완전 스키마(누수 요소 포함).
# migration_*.sql 은 기존 서버 레트로핏용이라 신규 설치 번들에서 제외한다.
(server / "sql").mkdir(parents=True)
schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8")
write_lf(server / "sql" / "schema.sql",
"-- schema.sql — 신규 설치용 단일 스키마 (원본: sql/schema_sht30.sql)\n"
"-- 누수(leak) + 온습도(SHT30) 요소 전부 포함 — 이 파일 하나만 1회 실행하면 됨.\n"
"-- 이 파일 하나로 설치 완결 (테이블 전체 leak_ 접두사 — 같은 DB 의 다른 테이블과 격리).\n\n"
+ schema_src)
write_lf(server / WEB_DIR / "config.local.php", f'''<?php
// config.local.php — 자동 생성 운영 설정 (커밋/공유 금지)
// [CHANGE_ME] 3곳만 채우면 운영 가능: DB 계정, SMS 계정, SMS 수신자.
return [
// ── cafe24 MySQL (호스팅 패널 > DB 정보) ── [CHANGE_ME]
'DB_HOST' => 'localhost',
'DB_PORT' => 3306,
'DB_NAME' => 'CHANGE_ME',
'DB_USER' => 'CHANGE_ME',
'DB_PASS' => 'CHANGE_ME',
// ── 공유 비밀키 (Pi 3대와 동일 값 자동 주입됨 — 수정 불필요) ──
'API_KEY' => '{API_KEY}',
'ALLOW_LEGACY_BODY_SIGNATURE' => false,
// ── cafe24 SMS (사전 등록된 발신번호 필수) ── [CHANGE_ME]
'SMS_USER_ID' => 'CHANGE_ME',
'SMS_SECURE' => 'CHANGE_ME',
'SMS_SENDER' => '01000000000',
'SMS_RECIPIENTS' => [
'01000000000', // [CHANGE_ME] 경보 수신 번호
],
// ── 관리자 로그인 (비밀번호는 deploy/.secrets/admin_password.txt) ──
'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}',
// TOTP 는 서버에서 1회 등록: setup_mfa.php?token=<아래 토큰> 접속 → 앱 등록
// → 표시된 시크릿을 ADMIN_TOTP_SECRET 에 넣고 → MFA_SETUP_TOKEN 은 '' 로 비울 것.
'ADMIN_TOTP_SECRET' => '',
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
// ── 누수 모듈 sensor_id (1번 지점 + 3번 신규 지점) ──
'LEAK_SENSOR_IDS' => [1, 3],
// ── 온습도 임계 (운영 권장값) ──
'METRIC_TEMP_HIGH_C' => 27,
'METRIC_TEMP_LOW_C' => 10,
'METRIC_RH_HIGH' => 60,
'METRIC_RH_LOW' => -1, // 저습 경보 비활성
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로 경보 비활성
'METRIC_ALERT_COOLDOWN_SEC' => 1800,
'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365,
'SENSOR_METRIC_RETENTION_DAYS' => 365,
'ADMIN_AUDIT_RETENTION_DAYS' => 365,
];
''')
write_lf(server / "SETUP.md", f'''# 01_server_cafe24 설치
1. **DB 스키마**: cafe24 phpMyAdmin 에서 `sql/schema.sql` **하나만 1회 실행** → 테이블 7개 확인
(leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo).
2. **업로드**: `{WEB_DIR}/` 폴더 전체를 `public_html/` 아래로 업로드.
3. **config.local.php 의 [CHANGE_ME] 3곳** 채우기: DB 계정 / SMS 계정 / SMS 수신번호.
4. **HTTPS 강제**: cafe24 호스팅 패널에서 설정 (필수 — 미설정 시 로그인 불가).
5. **관리자 로그인**: `{BASE_URL}/login.php`
- ID `admin` / 비밀번호는 `deploy/.secrets/admin_password.txt`
- 최초 1회 TOTP 등록: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN 값>` →
앱 등록 → 표시된 시크릿을 `ADMIN_TOTP_SECRET` 에 → `MFA_SETUP_TOKEN` 비우기.
6. **cron (1분)**: cafe24 크론탭에 `php /home/<계정>/public_html/{WEB_DIR}/cron_heartbeat.php`
(HTTP 방식이면 아래 URL 그대로 — CRON_SECRET 은 이미 계산되어 있음:
`{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`)
7. **검증**: `setup_wizard.php` 전 항목 녹색 + 테스트 SMS 발송.
''')
# ── 최상위 README + gitignore 방어 ──────────────────────────────────────────
write_lf(DEPLOY / "README.md", f'''# deploy/ — 기기별 턴키 배포 번들 (자동 생성: tools/make_deploy_bundles.py)
⚠️ **API_KEY·관리자 비밀번호 포함 — 절대 커밋/공유 금지.** 비밀값: `.secrets/`
⚠️ **`.secrets/ 백업 필수`** — `git clean -fdx`·재클론·폴더 삭제 시 소멸. 재생성하면
새 API_KEY 가 발급되어 **이미 배포된 서버·Pi 전부와 불일치**(전 노드 수집 거부)하게 됨.
배포 직후 `.secrets/` 를 오프라인 저장소(USB 등)에 복사할 것.
| 폴더 | 대상 | sensor_id | 남은 수동 설정 |
|---|---|---|---|
| 01_server_cafe24 | cafe24 웹호스팅 | — | DB 계정, SMS 계정/번호, TOTP 1회 |
| 02_pi_a_leak_id1 | Pi-A 누수+카메라 | 1 | 없음 |
| 03_pi_b_sht30_id2 | Pi-B SHT30 온습도 | 2 | 없음 |
| 04_pi_c_leak_id3 | Pi-C 누수+카메라(신규 지점) | 3 | 없음 |
## 순서
1. `01_server_cafe24/SETUP.md` — 서버 먼저 (Pi 검증이 서버에 의존)
2. Pi 3대 공통 OS: Raspberry Pi Imager → **Bookworm 64-bit**, 사용자 `pi`, SSH 켬, WiFi/유선 설정
3. 각 Pi 폴더의 `SETUP.md` (복사 → install.sh → systemctl, 병렬 가능)
## 참고
- 서버 주소: `{BASE_URL}` — 도메인은 `tools/make_deploy_bundles.py` 상단 DOMAIN, 경로 폴더명은 `.secrets/web_dir.txt` 수정 후 재실행.
- 오프라인(폐쇄망) 설치가 필요하면: 인터넷 되는 Pi 1대에서 각 모듈 `./vendor.sh` 실행 후
생성된 `wheelhouse/`+`debs/` 를 다른 Pi 번들 폴더에 복사 (install.sh 가 자동 오프라인 전환).
- 전체 배포 계획: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
''')
# (deploy/.gitignore 는 비밀값 생성 전에 이미 작성됨 — 순서 보장)
# ── LF 정규화 ────────────────────────────────────────────────────────────────
normalize_lf(DEPLOY)
print("deploy/ 생성 완료")
# cp949 콘솔(Windows 기본)에서도 죽지 않도록 em-dash 등 비호환 문자 금지.
print("[주의] deploy/.secrets/ 를 오프라인 저장소에 백업할 것 (git clean -fdx 로 소멸).")
print(" 재생성 시 새 API_KEY 가 배포된 전 노드와 불일치하게 됨.")
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
print(f" admin password : deploy/.secrets/admin_password.txt")
print(f" 서버 URL : {BASE_URL}")
for name, _, _ in BUNDLES:
print(f" - deploy/{name}")