SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트). 전체 코드리뷰에서 도출된 보안 하드닝 10건 반영: - 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시) - 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php) - 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점) - CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php) - 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화 _backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
302 lines
14 KiB
PHP
302 lines
14 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// api/sensor_data.php - STM32 SHT30 보드로부터 온습도 측정 데이터를 받는 API
|
|
//
|
|
// [요청 형식] POST application/json
|
|
// {
|
|
// "device_id": "stm32-sht30-01",
|
|
// "device_location": "서버실",
|
|
// "sensor_id": 2,
|
|
// "sensor_name": "2번 센서 (SHT30)",
|
|
// "event_type": "periodic" | "startup",
|
|
// "timestamp": 1700000000,
|
|
// "metric_type": "sht30",
|
|
// "temperature_c": 24.50,
|
|
// "humidity_percent": 48.30,
|
|
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
|
|
// "app_version": "v2606-sht30"
|
|
// }
|
|
//
|
|
// [인증 방식 2가지]
|
|
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
|
|
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
|
|
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어
|
|
// 2) 레거시: 본문 "signature" 필드 = sha256(API_KEY . ksort(json)) (하위호환, deprecated)
|
|
//
|
|
// [온습도 임계 경보] config.php 의 METRIC_* 임계로 서버가 고온/저온/고습/저습을
|
|
// 판정하고 종류별 쿨다운으로 SMS를 발송한다. 정상 복귀 시 1회 복귀 SMS를 보낸다.
|
|
// (판정/발송 로직은 sms_send.php)
|
|
//
|
|
// [응답 형식] 200 OK application/json
|
|
// {"status":"ok","message":"저장 완료", ...}
|
|
// =============================================================================
|
|
|
|
// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요)
|
|
header('Access-Control-Allow-Origin: https://pores.kr');
|
|
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// POST만 허용
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode(['status' => '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');
|
|
$data = json_decode($raw, true);
|
|
|
|
if (!$data) {
|
|
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
|
|
}
|
|
|
|
// ── 서명 검증 ─────────────────────────────────────────────
|
|
// X-Signature 헤더가 있으면 raw-body 서명(STM32 펌웨어)으로 검증하고,
|
|
// 없으면 본문 필드 기반 레거시 서명으로 검증한다.
|
|
$has_raw_sig = isset($_SERVER['HTTP_X_SIGNATURE']) && $_SERVER['HTTP_X_SIGNATURE'] !== '';
|
|
$sig_ok = $has_raw_sig ? verify_signature_raw($raw) : verify_signature($data);
|
|
if (!$sig_ok) {
|
|
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?')
|
|
. " (mode=" . ($has_raw_sig ? 'raw' : 'legacy') . ")");
|
|
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,
|
|
]);
|