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);
?>
+
+
+ 최근 거부 출처 = count($reject_events) ?>건 (우리 노드 vs 외부 스캐너 판별)
+
+ | 시각 | 출처 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'] ?? '')) ?> |
+
+
+
+
+
+
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.');