'error', 'message' => 'Method Not Allowed']); exit; } require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/sms_send.php'; require_once dirname(__DIR__) . '/cron_heartbeat.php'; // ── 운영 비밀값 강제: 기본 API_KEY 로는 수신을 거부(fail-closed) ────────── // config.local.php 미설치/미설정 상태에서 공개된 기본 키로 위조 데이터가 // 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증) if (api_key_is_default()) { error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요."); json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503); } // 디바이스가 보낸 문자열에서 제어문자(개행 등)를 제거해 SMS/로그 라인 주입을 막는다. // (서명은 raw 본문에 대해 이미 검증되었으므로 파싱 후 정제해도 안전하다.) function api_sanitize_str(string $s): string { return preg_replace('/[[:cntrl:]]+/', ' ', $s); } function api_table_exists(PDO $db, string $table): bool { try { $stmt = $db->prepare(" SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table "); $stmt->execute([':table' => $table]); return (int)$stmt->fetchColumn() > 0; } catch (Throwable $e) { error_log("[API] table check failed: {$table} " . $e->getMessage()); return false; } } // ── 요청 파싱 ───────────────────────────────────────────── $raw = file_get_contents('php://input'); // 본문 크기 상한: 인증 전 대용량 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 차단. if (strlen($raw) > INGEST_MAX_BODY_BYTES) { error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413); } $data = json_decode($raw, true); if (!$data) { json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400); } // ── 서명 검증 ───────────────────────────────────────────── // X-Signature 헤더가 있으면 raw-body HMAC 서명(STM32 펌웨어)으로 검증한다. // 레거시 본문 필드 서명(키-접두 sha256)은 기본 비활성(ALLOW_LEGACY_BODY_SIGNATURE). // 운영 클라이언트(STM32)는 HMAC 만 쓰므로 약한 다운그레이드 경로를 닫아 둔다. $has_raw_sig = isset($_SERVER['HTTP_X_SIGNATURE']) && $_SERVER['HTTP_X_SIGNATURE'] !== ''; if ($has_raw_sig) { $sig_ok = verify_signature_raw($raw); $sig_mode = 'raw'; } elseif (ALLOW_LEGACY_BODY_SIGNATURE) { $sig_ok = verify_signature($data); $sig_mode = 'legacy'; } else { $sig_ok = false; // X-Signature 헤더 필수 (레거시 비활성) $sig_mode = 'none'; } if (!$sig_ok) { error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})"); json_response(['status' => 'error', 'message' => '인증 실패'], 403); } // ── 필수 필드 검증 ──────────────────────────────────────── $required = ['device_id', 'sensor_id', 'event_type', 'timestamp']; foreach ($required as $field) { if (!isset($data[$field])) { json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400); } } // ── 값 추출 및 타입 변환 ────────────────────────────────── $device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64)); $device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100)); $sensor_id = (int)$data['sensor_id']; $sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100)); $event_type = in_array($data['event_type'], ['startup', 'periodic'], true) ? $data['event_type'] : 'periodic'; $ts = (int)$data['timestamp']; // ── 재전송(replay) 방어: timestamp 신선도 검증 ──────────── // 캡처한 유효 서명 요청의 무한 재사용을 막는다. 펌웨어는 SNTP 동기 후 정확한 // Unix 시각을 싣는다(reporter.c). 허용 오차를 벗어나면 거부. $now = time(); if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) { error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from " . ($_SERVER['REMOTE_ADDR'] ?? '?')); json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400); } $sensor_dt = date('Y-m-d H:i:s', $ts); $metric_type = api_sanitize_str(substr(trim($data['metric_type'] ?? ''), 0, 30)); $temperature_c = isset($data['temperature_c']) && is_numeric($data['temperature_c']) ? round((float)$data['temperature_c'], 2) : null; $humidity_percent = isset($data['humidity_percent']) && is_numeric($data['humidity_percent']) ? round((float)$data['humidity_percent'], 2) : null; // 센서 물리 범위를 벗어난 값은 결측(null)으로 처리(센서 오류 방어) if ($temperature_c !== null && ($temperature_c < -40 || $temperature_c > 125)) { $temperature_c = null; } if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent > 100)) { $humidity_percent = null; } $has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humidity_percent !== null); // ── 서버측 임계 판정 (config.php METRIC_*) ───────────────── // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, // 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다. $metric_eval = $has_metric_payload ? evaluate_metric_thresholds($temperature_c, $humidity_percent) : ['breaches' => [], 'status' => 'normal']; $metric_status = $metric_eval['status']; $metric_saved = false; // ── DB 저장 ─────────────────────────────────────────────── // 주기(periodic) 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 // (대시보드 로그 정리). startup 은 1회 sensor_log 기록. $skip_log = ($event_type === 'periodic' && $has_metric_payload); $log_id = null; try { $db = get_db(); if (!$skip_log) { $stmt = $db->prepare(" INSERT INTO sensor_log (device_id, device_location, sensor_id, sensor_name, event_type, sensor_time, created_at) VALUES (:device_id, :device_location, :sensor_id, :sensor_name, :event_type, :sensor_time, NOW()) "); $stmt->execute([ ':device_id' => $device_id, ':device_location' => $device_location, ':sensor_id' => $sensor_id, ':sensor_name' => $sensor_name, ':event_type' => $event_type, ':sensor_time' => $sensor_dt, ]); $log_id = $db->lastInsertId(); } // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── $was_offline = false; $chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id"); $chk->execute([':sensor_id' => $sensor_id]); $offline_flag = $chk->fetchColumn(); if ($offline_flag === '1' || $offline_flag === 1) { $was_offline = true; } // ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ── $stmt2 = $db->prepare(" INSERT INTO sensor_status (sensor_id, device_id, sensor_name, device_location, last_event_type, last_seen, offline_alerted) VALUES (:sensor_id, :device_id, :sensor_name, :device_location, :event_type, NOW(), 0) ON DUPLICATE KEY UPDATE device_id = VALUES(device_id), sensor_name = VALUES(sensor_name), device_location = VALUES(device_location), last_event_type = VALUES(last_event_type), last_seen = NOW(), offline_alerted = 0 "); $stmt2->execute([ ':sensor_id' => $sensor_id, ':device_id' => $device_id, ':sensor_name' => $sensor_name, ':device_location' => $device_location, ':event_type' => $event_type, ]); if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) { $metric_stmt = $db->prepare(" INSERT INTO sensor_metric (device_id, device_location, sensor_id, sensor_name, metric_type, temperature_c, humidity_percent, metric_status, measured_at, created_at) VALUES (:device_id, :device_location, :sensor_id, :sensor_name, :metric_type, :temperature_c, :humidity_percent, :metric_status, :measured_at, NOW()) "); $metric_stmt->execute([ ':device_id' => $device_id, ':device_location' => $device_location, ':sensor_id' => $sensor_id, ':sensor_name' => $sensor_name, ':metric_type' => $metric_type, ':temperature_c' => $temperature_c, ':humidity_percent' => $humidity_percent, ':metric_status' => $metric_status, ':measured_at' => $sensor_dt, ]); $metric_saved = true; } // ── 오프라인→온라인 복구 SMS 발송 ───────────────────── if ($was_offline) { $online_info = [ 'sensor_id' => $sensor_id, 'sensor_name' => $sensor_name, 'device_id' => $device_id, 'device_location' => $device_location, ]; send_device_online_sms($online_info); error_log("[API] 장비 온라인 복구 - 센서{$sensor_id}"); } } catch (PDOException $e) { error_log("[API] DB 오류: " . $e->getMessage()); json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500); } // ── 온습도 임계 경보 / 정상 복귀 SMS ─────────────────────── $sms_sent = false; $sensor_info = [ 'sensor_id' => $sensor_id, 'sensor_name' => $sensor_name, 'device_id' => $device_id, 'device_location' => $device_location, ]; if ($has_metric_payload) { if (!empty($metric_eval['breaches'])) { // 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용) foreach ($metric_eval['breaches'] as $kind) { $value = in_array($kind, ['high_temp', 'low_temp'], true) ? $temperature_c : $humidity_percent; if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) { $sms_sent = true; } } } elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) { // 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회 $sms_sent = send_metric_recovery_sms( $sensor_info, (float)($temperature_c ?? 0.0), (float)($humidity_percent ?? 0.0) ); } } // ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ── check_offline_sensors_throttled(); // ── 응답 ────────────────────────────────────────────────── json_response([ 'status' => 'ok', 'message' => $skip_log ? '상태 업데이트 완료' : '저장 완료', 'log_id' => $log_id ? (int)$log_id : null, 'metric_saved' => $metric_saved, 'metric_status' => $metric_status, 'sms_sent' => $sms_sent, ]);