From 7fa39406d39da8ab605468e6929dd560c15c1ab8 Mon Sep 17 00:00:00 2001 From: cw2007 Date: Thu, 9 Jul 2026 13:43:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(observability):=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EC=B6=9C=EC=B2=98=20IP=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20+=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수집 거부 카운터는 사유별 숫자만 남겨, "우리 노드 오설정 vs 외부 스캐너"를 구분할 수 없었다. 서명불일치 2건이 우리 노드의 키 불일치인지 인터넷 프로브인지 대시보드만으론 판별 불가했다. - config.php: 최근 거부 이벤트 링버퍼(var/ingest_reject_events.json, 상한 INGEST_REJECT_EVENTS_MAX=50, flock 기반 DoS 안전) 추가. record_ingest_rejection() 이 모든 거부에서 출처 IP(REMOTE_ADDR, 형식검증)를 자동 기록한다. 본문 유래 값(device_id 등)은 신뢰불가 → ingest_sanitize_token() 으로 제어문자 제거·절단 (로그 인젝션 방어). var/ 는 .htaccess 로 웹 접근 차단됨. - api/sensor_data.php: 서명 거부 시 서명모드(raw=키불일치/none=헤더스트립)와 device_id 를 컨텍스트로 함께 남겨 출처 판별을 돕는다. - dashboard.php: '수집 거부' 위젯 아래 '최근 거부 출처' 표(시각·IP·사유·모드· device_id) 노출. 모든 필드 htmlspecialchars. - tests/ingest_reject_log_test.php: 로그 인젝션 제거·IP 형식검증 회귀 추가, run_tests.sh 등록. security_hardening_test.php 소스 회귀 5종 추가. 주: config.php 에는 작업 트리에 있던 선행 변경(METRIC_SENSOR_IDS — 대시보드 온습도 센서카드 목록)이 함께 포함된다. --- php/api/sensor_data.php | 4 +- php/config.php | 97 ++++++++++++++++++++++++++++++- php/dashboard.php | 21 +++++++ run_tests.sh | 1 + tests/ingest_reject_log_test.php | 55 ++++++++++++++++++ tests/security_hardening_test.php | 6 ++ 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/ingest_reject_log_test.php diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php index c8fb443..1cdf97f 100644 --- a/php/api/sensor_data.php +++ b/php/api/sensor_data.php @@ -178,7 +178,9 @@ if ($has_raw_sig) { } if (!$sig_ok) { error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})"); - record_ingest_rejection('signature'); + // 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께 + // 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별. + record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]); json_response(['status' => 'error', 'message' => '인증 실패'], 403); } diff --git a/php/config.php b/php/config.php index 75d6a1a..9c3563c 100644 --- a/php/config.php +++ b/php/config.php @@ -43,6 +43,8 @@ function api_key_is_default(): bool { // 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은 // 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다. define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600)); +// 최근 거부 이벤트 링버퍼 크기(출처 IP 로그). 무단 접근 출처 식별용, 파일 무한증가 방지. +define('INGEST_REJECT_EVENTS_MAX', (int)cfg('INGEST_REJECT_EVENTS_MAX', 50)); // 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~1KB 미만. 인증 전 대용량 // 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 를 차단한다. @@ -247,6 +249,21 @@ $leak_sensor_ids = array_values(array_filter( )); define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]); +// ─── 온습도(SHT30) 센서 sensor_id 목록 ─────────────────── +// 상단 "센서 현재 상태" 카드에 항상 표시할 온습도 센서. 누수 카드와 동일 원칙 — +// 아직 데이터가 없어도(미수신) placeholder 카드로 노출해, 기대 센서가 화면에서 +// 조용히 사라지지 않게 한다(예: SHT30 최초 기동 전). 기본 [2](2번=SHT30 온습도). +// 신규 온습도 지점은 config.local.php 로 오버라이드한다. 예: [2, 4] (문자열 "2,4" 허용) +$metric_sensor_ids = cfg('METRIC_SENSOR_IDS', [2]); +if (is_string($metric_sensor_ids)) { + $metric_sensor_ids = explode(',', $metric_sensor_ids); +} +$metric_sensor_ids = array_values(array_filter( + array_map('intval', (array)$metric_sensor_ids), + static fn($id) => $id >= 1 && $id <= 255 +)); +define('METRIC_SENSOR_IDS', $metric_sensor_ids !== [] ? $metric_sensor_ids : [2]); + // ─── 대시보드 세션 인증 ────────────────────────────────── define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 @@ -336,7 +353,11 @@ function ingest_rejection_path(): string { return __DIR__ . '/var/ingest_rejections.json'; } -function record_ingest_rejection(string $reason): void { +function record_ingest_rejection(string $reason, array $ctx = []): void { + // 출처 IP 포함 최근 거부 이벤트를 별도 링 파일에 남긴다(카운터와 독립 — 하나가 + // 실패해도 다른 하나는 기록). '무단 접근' 출처(우리 노드 vs 외부 스캐너) 식별용. + record_ingest_reject_event($reason, $ctx); + $fh = @fopen(ingest_rejection_path(), 'c+'); if ($fh === false) { return; // 카운터 실패가 수집 처리를 막아선 안 됨(best-effort) @@ -385,6 +406,80 @@ function read_ingest_rejections(int $days = 1): array { return $totals; } +// ─── 수집 거부 이벤트 링 (출처 IP 포함 — 무단 접근 식별) ────────────────────── +// 카운터(사유별 숫자)만으론 "우리 노드 오설정 vs 외부 스캐너"를 구분 못 한다. 최근 거부 +// 몇 건을 출처 IP·사유·서명모드와 함께 별도 파일 링버퍼에 남겨, 대시보드/ops 가 출처를 +// 즉시 분별한다. 인증 전 경로라 DB 대신 파일 flock(위조 폭주에도 부담 작음), 개수 상한 +// (INGEST_REJECT_EVENTS_MAX)으로 무한증가를 막는다. 본문 유래 값(device_id 등)은 신뢰 +// 불가 → 로그 인젝션 방지를 위해 제어문자 제거·절단한다. var/ 는 .htaccess 로 웹 차단됨. +function ingest_reject_events_path(): string { + return __DIR__ . '/var/ingest_reject_events.json'; +} + +// 신뢰 가능한 출처 IP(REMOTE_ADDR). 형식 검증 실패/부재는 '?'. XFF 는 위조 가능하므로 +// 신뢰 출처로 쓰지 않는다(참고용은 record 에서 별도 'fwd' 필드로만 남긴다). +function ingest_client_ip(): string { + $ip = $_SERVER['REMOTE_ADDR'] ?? ''; + return (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP)) ? $ip : '?'; +} + +// 로그 인젝션/제어문자 방지: 인쇄가능 문자만 남기고(제어문자→공백) 최대 길이로 절단. +function ingest_sanitize_token(string $s, int $max = 64): string { + $s = preg_replace('/[[:cntrl:]]+/u', ' ', $s); + $s = trim((string)$s); + if ($max > 0) { + $s = function_exists('mb_substr') ? mb_substr($s, 0, $max) : substr($s, 0, $max); + } + return $s; +} + +// 최근 거부 이벤트 1건을 링버퍼(별도 파일)에 추가한다(best-effort, DoS 안전). +// $ctx: ['mode'=>서명모드, 'device_id'=>본문 device_id(신뢰불가)] — 선택. +function record_ingest_reject_event(string $reason, array $ctx = []): void { + $fh = @fopen(ingest_reject_events_path(), 'c+'); + if ($fh === false) { + return; + } + if (flock($fh, LOCK_EX)) { + $raw = stream_get_contents($fh); + $events = ($raw !== false && $raw !== '') ? json_decode($raw, true) : []; + if (!is_array($events)) { $events = []; } + $fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; + $events[] = [ + 't' => date('Y-m-d H:i:s'), + 'ip' => ingest_client_ip(), + 'reason' => ingest_sanitize_token($reason, 24), + 'mode' => isset($ctx['mode']) ? ingest_sanitize_token((string)$ctx['mode'], 12) : '', + 'dev' => isset($ctx['device_id']) ? ingest_sanitize_token((string)$ctx['device_id'], 32) : '', + 'fwd' => (is_string($fwd) && $fwd !== '') ? ingest_sanitize_token($fwd, 48) : '', + ]; + $max = (int)INGEST_REJECT_EVENTS_MAX; + if ($max > 0 && count($events) > $max) { + $events = array_slice($events, -$max); + } + ftruncate($fh, 0); + rewind($fh); + fwrite($fh, (string)json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + fflush($fh); + flock($fh, LOCK_UN); + } + fclose($fh); +} + +// 최근 거부 이벤트 최신순 $limit 건. 대시보드 '최근 거부 출처' 표에 사용. +function read_ingest_reject_events(int $limit = 20): array { + $file = ingest_reject_events_path(); + if (!is_file($file)) { + return []; + } + $events = json_decode((string)@file_get_contents($file), true); + if (!is_array($events)) { + return []; + } + $events = array_reverse($events); // 최신 먼저 + return $limit > 0 ? array_slice($events, 0, $limit) : $events; +} + // ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ─────── // 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트 // 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다. diff --git a/php/dashboard.php b/php/dashboard.php index c60ea16..223c8fe 100644 --- a/php/dashboard.php +++ b/php/dashboard.php @@ -265,6 +265,8 @@ $recent_alert_fail = (int)$db->query(" // 최근 24시간(오늘) 수집 거부 사유별 집계(왜 데이터가 안 들어오는가). $ingest_rejections = read_ingest_rejections(1); $rejection_total = array_sum($ingest_rejections); +// 최근 거부 출처(IP·사유·모드) — '우리 노드 오설정 vs 외부 스캐너' 판별용. +$reject_events = read_ingest_reject_events(10); // ── 누수 모듈 현재 상태 + 사고/사진 ─────────────────── // 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로 @@ -601,6 +603,25 @@ foreach ($chart_points as $ci => $cp) { } echo implode(' · ', $parts); ?>

+ +
+ 최근 거부 출처 건 (우리 노드 vs 외부 스캐너 판별) + + + + + + + + + + + + + +
시각출처 IP사유모드device_id
(xff )
+
+ diff --git a/run_tests.sh b/run_tests.sh index 13ff142..d1c0777 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -28,6 +28,7 @@ run_php tests/raw_body_signature_test.php run_php tests/security_hardening_test.php run_php tests/threshold_sms_test.php run_php tests/cooldown_episode_test.php # 에피소드 인지 쿨다운(복구 후 재발 누락 회귀) +run_php tests/ingest_reject_log_test.php # 수집 거부 출처 로그(IP·로그인젝션 방지) run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴 run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수 diff --git a/tests/ingest_reject_log_test.php b/tests/ingest_reject_log_test.php new file mode 100644 index 0000000..1b7fde0 --- /dev/null +++ b/tests/ingest_reject_log_test.php @@ -0,0 +1,55 @@ + no truncation'); + +// ── ingest_client_ip: REMOTE_ADDR 형식검증 ────────────────── +$_SERVER['REMOTE_ADDR'] = '203.0.113.9'; +eq(ingest_client_ip(), '203.0.113.9', 'valid IPv4 returned'); +$_SERVER['REMOTE_ADDR'] = '2001:db8::1'; +eq(ingest_client_ip(), '2001:db8::1', 'valid IPv6 returned'); +$_SERVER['REMOTE_ADDR'] = 'not-an-ip'; +eq(ingest_client_ip(), '?', 'invalid IP format -> ?'); +unset($_SERVER['REMOTE_ADDR']); +eq(ingest_client_ip(), '?', 'missing REMOTE_ADDR -> ?'); + +if ($failures) { + fwrite(STDERR, "Ingest reject-log test failures:\n"); + foreach ($failures as $f) { + fwrite(STDERR, "- {$f}\n"); + } + exit(1); +} + +echo "Ingest reject-log checks passed.\n"; diff --git a/tests/security_hardening_test.php b/tests/security_hardening_test.php index 1daddcc..955311d 100644 --- a/tests/security_hardening_test.php +++ b/tests/security_hardening_test.php @@ -222,6 +222,12 @@ check(str_contains($config, 'function record_ingest_rejection'), 'config.php sho check(str_contains($config, 'function read_ingest_rejections'), 'config.php should provide read_ingest_rejections().'); check(str_contains($sensorApi, 'record_ingest_rejection'), 'sensor_data.php should count ingest rejections.'); check(str_contains($dashboard, 'ingest_rejections'), 'dashboard.php should surface ingest rejections.'); +// 무단 접근 출처 로그(거부 이벤트 링 — IP·서명모드로 우리 노드 vs 스캐너 판별) +check(str_contains($config, 'function record_ingest_reject_event'), 'config.php should log reject events with source IP.'); +check(str_contains($config, 'function read_ingest_reject_events'), 'config.php should read recent reject events.'); +check(str_contains($config, 'function ingest_client_ip'), 'config.php should resolve a validated client IP.'); +check(str_contains($config, 'function ingest_sanitize_token'), 'config.php should sanitize logged tokens (log-injection guard).'); +check(str_contains($dashboard, 'reject_events'), 'dashboard.php should surface recent reject sources.'); // M#2 발송실패 가시화 + 오프라인 재시도 check(str_contains($cron, 'SMS_RETRY_OFFLINE') && str_contains($cron, 'offline_alerted = 0'), 'cron_heartbeat should retry offline SMS on send failure.'); check(str_contains($dashboard, 'recent_alert_fail'), 'dashboard.php should show a failed-alert-SMS banner.');