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); ?>
+ +| 시각 | 출처 IP | 사유 | 모드 | device_id |
|---|---|---|---|---|
| = htmlspecialchars((string)($ev['t'] ?? '')) ?> | += htmlspecialchars((string)($ev['ip'] ?? '?')) ?> (xff = htmlspecialchars((string)$ev['fwd']) ?>) | += htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?> | += htmlspecialchars((string)($ev['mode'] ?? '')) ?> | += htmlspecialchars((string)($ev['dev'] ?? '')) ?> | +