feat(observability): 수집 거부 출처 IP 로그 + 대시보드 노출

수집 거부 카운터는 사유별 숫자만 남겨, "우리 노드 오설정 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 — 대시보드
온습도 센서카드 목록)이 함께 포함된다.
This commit is contained in:
cw2007 2026-07-09 13:43:12 +09:00
parent 0728e45894
commit 7fa39406d3
6 changed files with 182 additions and 2 deletions

View file

@ -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);
}

View file

@ -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 펌웨어)는 사용하지 않는다.

View file

@ -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);
?></p>
<?php if (!empty($reject_events)): ?>
<details class="reject-sources">
<summary>최근 거부 출처 <?= count($reject_events) ?>건 (우리 노드 vs 외부 스캐너 판별)</summary>
<table>
<thead><tr><th>시각</th><th>출처 IP</th><th>사유</th><th>모드</th><th>device_id</th></tr></thead>
<tbody>
<?php foreach ($reject_events as $ev): ?>
<tr>
<td><?= htmlspecialchars((string)($ev['t'] ?? '')) ?></td>
<td><?= htmlspecialchars((string)($ev['ip'] ?? '?')) ?><?php if (!empty($ev['fwd'])): ?> <small>(xff <?= htmlspecialchars((string)$ev['fwd']) ?>)</small><?php endif; ?></td>
<td><?= htmlspecialchars($reason_ko[$ev['reason'] ?? ''] ?? (string)($ev['reason'] ?? '')) ?></td>
<td><?= htmlspecialchars((string)($ev['mode'] ?? '')) ?></td>
<td><?= htmlspecialchars((string)($ev['dev'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
</div>
</section>
<?php endif; ?>

View file

@ -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/여유/신선도/위험) 순수함수

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
// 수집 거부 출처 로그 헬퍼 회귀 (DB/파일 없이 순수 로직만).
// ingest_sanitize_token(): 로그 인젝션 방지(제어문자 제거·절단)
// ingest_client_ip(): REMOTE_ADDR 형식검증(부재/위조형식 → '?')
// 함수/상수 존재: record_ingest_reject_event / read_ingest_reject_events / INGEST_REJECT_EVENTS_MAX
$root = dirname(__DIR__);
require_once $root . '/php/config.php';
$failures = [];
function check(bool $cond, string $msg): void { global $failures; if (!$cond) { $failures[] = $msg; } }
function eq($a, $b, string $msg): void {
check($a === $b, $msg . ' (expected ' . var_export($b, true) . ', got ' . var_export($a, true) . ')');
}
// ── 함수/상수 존재 ──────────────────────────────────────────
check(function_exists('ingest_sanitize_token'), 'ingest_sanitize_token() should exist.');
check(function_exists('ingest_client_ip'), 'ingest_client_ip() should exist.');
check(function_exists('record_ingest_reject_event'), 'record_ingest_reject_event() should exist.');
check(function_exists('read_ingest_reject_events'), 'read_ingest_reject_events() should exist.');
check(defined('INGEST_REJECT_EVENTS_MAX'), 'INGEST_REJECT_EVENTS_MAX should be defined.');
// ── ingest_sanitize_token: 로그 인젝션/제어문자 방지 ────────
eq(ingest_sanitize_token('rpi4-leak-02', 32), 'rpi4-leak-02', 'plain token unchanged');
// 개행/캐리지리턴 → 위조 로그줄 주입 차단(제어문자는 공백 처리)
check(strpos(ingest_sanitize_token("rpi\n[API] 서명 불일치 from evil", 64), "\n") === false,
'newline stripped (log injection blocked)');
check(strpos(ingest_sanitize_token("a\r\nb", 64), "\r") === false, 'carriage return stripped');
check(strpos(ingest_sanitize_token("a\tb", 64), "\t") === false, 'tab stripped');
// 길이 절단
check(mb_strlen(ingest_sanitize_token(str_repeat('x', 100), 32)) === 32, 'truncated to max length');
eq(ingest_sanitize_token(' spaced ', 32), 'spaced', 'trimmed');
eq(ingest_sanitize_token('abc', 0), 'abc', 'max 0 -> 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";

View file

@ -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.');