'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 에 운영 키를 설정하세요."); record_ingest_rejection('key_default'); 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; } } // ── 재전송(replay) 멱등 선점 ────────────────────────────────── // 인증·신선도(timestamp)를 통과한 요청의 서명을 nonce 로 원자적 기록한다. 시계오차 // 창 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 한 번만 반영된다. // 'claimed' : 이번에 선점 → 계속 처리(저장/발송) // 'duplicate' : 이미 처리됨 → 멱등 200 으로 종료(재저장/재발송 없음) // 'unavailable' : nonce 비활성/테이블 없음/오류 → 종전대로 진행(graceful degrade) function ingest_nonce_claim(PDO $db, string $sig): string { if (!INGEST_NONCE_ENABLED || $sig === '') { return 'unavailable'; } try { $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins->execute([':sig' => substr($sig, 0, 128)]); } catch (PDOException $e) { $code = $e->getCode(); if ($code === '23000') { return 'duplicate'; // PK 충돌 = 동일 서명 재수신 } if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성 error_log("[API] nonce claim 오류({$code}): " . $e->getMessage()); } return 'unavailable'; } // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움). try { $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); } catch (Throwable $e) { /* 정리는 실패해도 무방 */ } return 'claimed'; } // 처리 중 저장이 실패하면(예: DB 오류) 선점한 nonce 를 되돌려, 디바이스 재시도가 // 다시 처리될 수 있게 한다(데이터 유실 방지). function ingest_nonce_release(PDO $db, string $sig): void { if ($sig === '') { return; } try { $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig") ->execute([':sig' => substr($sig, 0, 128)]); } catch (Throwable $e) { error_log("[API] nonce release 오류: " . $e->getMessage()); } } // ── 요청 파싱 ───────────────────────────────────────────── $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'] ?? '?')); record_ingest_rejection('body_too_large'); json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413); } $data = json_decode($raw, true); if (!$data) { record_ingest_rejection('invalid_json'); 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})"); // 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께 // 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별. record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]); json_response(['status' => 'error', 'message' => '인증 실패'], 403); } // ── 필수 필드 검증 ──────────────────────────────────────── $required = ['device_id', 'sensor_id', 'event_type', 'timestamp']; foreach ($required as $field) { if (!isset($data[$field])) { record_ingest_rejection('missing_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_id 범위 검증(스키마 TINYINT UNSIGNED: 1..255). 범위를 벗어나면 DB INSERT // 단계에서 500(DB 저장 실패)이 되므로, 경계에서 깔끔하게 400 으로 거른다. if ($sensor_id < 1 || $sensor_id > 255) { record_ingest_rejection('sensor_id_range'); json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400); } $sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100)); // 통합 수집: 온습도(startup/periodic) + 누수(leak_alert/leak_recovery) 이벤트 허용. $event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery'], true) ? $data['event_type'] : 'periodic'; // 누수 여부(0/1). 1번 모듈만 보낸다. leak_alert 는 항상 누수=1, leak_recovery 는 0 으로 // 강제해 이벤트와 상태의 일관성을 보장한다(필드 누락/오값 방어). $is_leak = !empty($data['is_leak']) ? 1 : 0; if ($event_type === 'leak_alert') { $is_leak = 1; } elseif ($event_type === 'leak_recovery') { $is_leak = 0; } $is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery'); $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'] ?? '?')); record_ingest_rejection('timestamp'); 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 판정이라 운영 경보로는 신뢰하지 않고, // 서버가 운영 임계로 다시 판정하여 leak_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) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert // (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면 // periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵. // startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다 // (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상). $skip_log = ($event_type === 'periodic'); $log_id = null; try { $db = get_db(); // 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시 // 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조. $use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db); // ── 재전송(replay) 멱등 선점 ── // 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차 // 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200. $req_sig = $has_raw_sig ? (string)$_SERVER['HTTP_X_SIGNATURE'] : (string)($data['signature'] ?? ''); $nonce_state = ingest_nonce_claim($db, $req_sig); if ($nonce_state === 'duplicate') { json_response([ 'status' => 'ok', 'message' => '이미 처리된 요청(멱등)', 'duplicate' => true, ]); } if (!$skip_log) { $stmt = $db->prepare(" INSERT INTO leak_sensor_log (device_id, device_location, sensor_id, sensor_name, event_type, is_leak, sensor_time, created_at) VALUES (:device_id, :device_location, :sensor_id, :sensor_name, :event_type, :is_leak, :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, ':is_leak' => $is_leak, ':sensor_time' => $sensor_dt, ]); $log_id = $db->lastInsertId(); } // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── $was_offline = false; $chk = $db->prepare("SELECT offline_alerted FROM leak_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; } // ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ── // is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고 // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서 // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게). $stmt2 = $db->prepare(" INSERT INTO leak_sensor_status (sensor_id, device_id, sensor_name, device_location, last_event_type, is_leak, last_seen, offline_alerted) VALUES (:sensor_id, :device_id, :sensor_name, :device_location, :event_type, :is_leak_ins, 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), is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak), 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, ':is_leak_ins' => $is_leak, ':is_leak_event' => $is_leak_event ? 1 : 0, ]); if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) { $metric_stmt = $db->prepare(" INSERT INTO leak_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; } // ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ── // leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음). // leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움). // leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다. // 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로 // 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게). if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) { if ($event_type === 'leak_alert') { $open = $db->prepare(" SELECT id FROM leak_incident WHERE sensor_id = :sensor_id AND recovered_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1 "); $open->execute([':sensor_id' => $sensor_id]); if ($open->fetchColumn() === false) { $ins_inc = $db->prepare(" INSERT INTO leak_incident (sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at) VALUES (:sensor_id, :device_id, :sensor_name, :device_location, :started_at, :log_id_alert, NOW()) "); $ins_inc->execute([ ':sensor_id' => $sensor_id, ':device_id' => $device_id, ':sensor_name' => $sensor_name, ':device_location' => $device_location, ':started_at' => $sensor_dt, ':log_id_alert' => $log_id !== null ? (int)$log_id : null, ]); } } else { // leak_recovery $close = $db->prepare(" UPDATE leak_incident SET recovered_at = :recovered_at, log_id_recovery = :log_id_recovery WHERE sensor_id = :sensor_id AND recovered_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1 "); $close->execute([ ':recovered_at' => $sensor_dt, ':log_id_recovery' => $log_id !== null ? (int)$log_id : null, ':sensor_id' => $sensor_id, ]); } } // ── 오프라인→온라인 복구 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()); // 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지). if (isset($nonce_state, $req_sig) && $nonce_state === 'claimed') { ingest_nonce_release($db, $req_sig); } 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) { if ($kind === 'condensation_risk') { $value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃) } elseif (in_array($kind, ['high_temp', 'low_temp'], true)) { $value = $temperature_c; } else { $value = $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) ); } } // ── 누수 경보 / 복구 SMS (1번 모듈) ──────────────────────── // [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회. // 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기 // 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다. // [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정). if ($use_debounce) { if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) { if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) { $sms_sent = true; } } } elseif ($is_leak_event) { if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) { if (send_leak_alert_sms($sensor_info)) { $sms_sent = true; } } elseif ($event_type === 'leak_recovery' && function_exists('send_leak_recovery_sms')) { if (send_leak_recovery_sms($sensor_info)) { $sms_sent = true; } } } // ── 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, // 서버 현재 시각(Unix). 펌웨어가 RTC 드리프트 보정/시계 헬스 점검에 사용할 수 있도록 // 응답에 싣는다(폐쇄망에서 SNTP 가 간헐적일 때 서버시각으로 RTC 를 미세보정하는 후속 // 펌웨어 작업의 입력). 보안 영향 없음(인증된 200 응답에만 포함). 'server_now' => $now, ]);