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 제외.
377 lines
14 KiB
PHP
377 lines
14 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// sms_send.php - SMS 발송 모듈 (Cafe24 SMS API 사용)
|
|
// Cafe24: https://sslsms.cafe24.com/sms_sender.php
|
|
//
|
|
// 담당 알림:
|
|
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
|
|
// - 온습도 정상 복귀 : send_metric_recovery_sms()
|
|
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
|
|
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
|
|
// =============================================================================
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
/**
|
|
* 발신번호를 3분할 (sphone1, sphone2, sphone3)
|
|
*
|
|
* @return array{0:string, 1:string, 2:string}
|
|
*/
|
|
function split_sender_number(string $phone): array {
|
|
$digits = preg_replace('/\D+/', '', $phone);
|
|
$len = strlen($digits);
|
|
|
|
if ($len < 9) {
|
|
throw new RuntimeException('발신번호 형식이 올바르지 않습니다: ' . $phone);
|
|
}
|
|
|
|
if ($len === 10) {
|
|
return [substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4)];
|
|
}
|
|
|
|
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
|
|
}
|
|
|
|
/**
|
|
* Cafe24 SMS API로 문자 발송
|
|
*
|
|
* @param string $receiver 수신자 번호 (숫자만)
|
|
* @param string $message 메시지 내용
|
|
* @return array ['success'=>bool, 'result_code'=>string, 'message'=>string]
|
|
*/
|
|
function send_sms_cafe24(string $receiver, string $message): array {
|
|
$receiver = preg_replace('/\D+/', '', $receiver);
|
|
|
|
if ($receiver === '' || trim($message) === '') {
|
|
return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.'];
|
|
}
|
|
|
|
[$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER);
|
|
|
|
$encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message));
|
|
if ($encodedMessage === false || $encodedMessage === '') {
|
|
return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패'];
|
|
}
|
|
|
|
$postData = [
|
|
'action' => 'go',
|
|
'user_id' => base64_encode(SMS_USER_ID),
|
|
'secure' => base64_encode(SMS_SECURE),
|
|
'msg' => base64_encode($encodedMessage),
|
|
'rphone' => base64_encode($receiver),
|
|
'sphone1' => base64_encode($sphone1),
|
|
'sphone2' => base64_encode($sphone2),
|
|
'sphone3' => base64_encode($sphone3),
|
|
'rdate' => base64_encode(''),
|
|
'rtime' => base64_encode(''),
|
|
'mode' => base64_encode('1'),
|
|
'returnurl' => base64_encode(''),
|
|
'testflag' => base64_encode(''),
|
|
'destination'=> strtr(base64_encode(''), '+/=', '-,'),
|
|
'repeatFlag' => base64_encode(''),
|
|
'repeatNum' => base64_encode(''),
|
|
'repeatTime' => base64_encode(''),
|
|
'smsType' => base64_encode('S'),
|
|
];
|
|
|
|
$ch = curl_init();
|
|
if ($ch === false) {
|
|
return ['success' => false, 'result_code' => 'CURL_INIT_ERR', 'message' => 'cURL 초기화 실패'];
|
|
}
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_URL => SMS_ENDPOINT,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => $postData,
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 10,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$errorNo = curl_errno($ch);
|
|
$errorMsg = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($response === false || $errorNo !== 0) {
|
|
$errText = $errorMsg !== '' ? $errorMsg : '알 수 없는 오류';
|
|
error_log(sprintf('[Cafe24 SMS] cURL 오류(code=%d): %s', $errorNo, $errText));
|
|
return ['success' => false, 'result_code' => 'CURL_ERR', 'message' => $errText];
|
|
}
|
|
|
|
$responseBody = trim($response);
|
|
if ($responseBody === '') {
|
|
return ['success' => false, 'result_code' => 'EMPTY_RESP', 'message' => '빈 응답'];
|
|
}
|
|
|
|
$parts = explode(',', $responseBody);
|
|
$resultCode = $parts[0] ?? '';
|
|
$remaining = $parts[1] ?? '';
|
|
|
|
error_log(sprintf('[Cafe24 SMS] 응답: result=%s, remaining=%s', $resultCode, $remaining));
|
|
|
|
$success = in_array(strtolower($resultCode), ['success', 'reserved'], true);
|
|
|
|
return [
|
|
'success' => $success,
|
|
'result_code' => $resultCode,
|
|
'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다.
|
|
*
|
|
* @param PDO $db
|
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id', ...]
|
|
* @param string $message 발송할 메시지 본문
|
|
* @param string $logLabel error_log 식별 라벨
|
|
* @return bool 전원 발송 성공 여부
|
|
*/
|
|
function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel): bool {
|
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
|
$allSuccess = true;
|
|
|
|
foreach (SMS_RECIPIENTS as $receiver) {
|
|
$receiverDigits = preg_replace('/\D+/', '', $receiver);
|
|
$result = send_sms_cafe24($receiverDigits, $message);
|
|
|
|
$stmt = $db->prepare("
|
|
INSERT INTO sms_log
|
|
(sensor_id, sensor_name, device_id, message, receivers,
|
|
status, result_code, result_message, sent_at)
|
|
VALUES
|
|
(:sensor_id, :sensor_name, :device_id, :message, :receivers,
|
|
:status, :result_code, :result_message, NOW())
|
|
");
|
|
$stmt->execute([
|
|
':sensor_id' => $sensor_data['sensor_id'],
|
|
':sensor_name' => $name,
|
|
':device_id' => $sensor_data['device_id'] ?? '',
|
|
':message' => $message,
|
|
':receivers' => $receiverDigits,
|
|
':status' => $result['success'] ? 'success' : 'fail',
|
|
':result_code' => $result['result_code'],
|
|
':result_message' => $result['message'],
|
|
]);
|
|
|
|
if ($result['success']) {
|
|
error_log("[SMS] {$logLabel} 발송 성공 - 센서{$sensor_data['sensor_id']} → {$receiverDigits}");
|
|
} else {
|
|
error_log("[SMS] {$logLabel} 발송 실패 - {$receiverDigits}: {$result['result_code']} {$result['message']}");
|
|
$allSuccess = false;
|
|
}
|
|
}
|
|
|
|
return $allSuccess;
|
|
}
|
|
|
|
/**
|
|
* 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30".
|
|
*/
|
|
function metric_value_str(float $value): string {
|
|
$s = number_format($value, 1, '.', '');
|
|
if (strpos($s, '.') !== false) {
|
|
$s = rtrim(rtrim($s, '0'), '.');
|
|
}
|
|
return $s;
|
|
}
|
|
|
|
/**
|
|
* 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다.
|
|
* @return array{breaches: string[], status: string}
|
|
* breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태
|
|
*/
|
|
function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array {
|
|
$breaches = [];
|
|
if ($temp_c !== null) {
|
|
if ($temp_c >= METRIC_TEMP_HIGH_C) {
|
|
$breaches[] = 'high_temp';
|
|
} elseif ($temp_c <= METRIC_TEMP_LOW_C) {
|
|
$breaches[] = 'low_temp';
|
|
}
|
|
}
|
|
if ($rh !== null) {
|
|
if ($rh >= METRIC_RH_HIGH) {
|
|
$breaches[] = 'high_humidity';
|
|
} elseif ($rh <= METRIC_RH_LOW) {
|
|
$breaches[] = 'low_humidity';
|
|
}
|
|
}
|
|
return [
|
|
'breaches' => $breaches,
|
|
'status' => empty($breaches) ? 'normal' : $breaches[0],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true.
|
|
* 측정 결측(null)은 해당 지표를 정상으로 간주한다.
|
|
*/
|
|
function metric_is_recovered(?float $temp_c, ?float $rh): bool {
|
|
$tOk = ($temp_c === null) || (
|
|
$temp_c < METRIC_TEMP_HIGH_C - METRIC_TEMP_HYSTERESIS_C &&
|
|
$temp_c > METRIC_TEMP_LOW_C + METRIC_TEMP_HYSTERESIS_C
|
|
);
|
|
$hOk = ($rh === null) || (
|
|
$rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS &&
|
|
$rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS
|
|
);
|
|
return $tOk && $hOk;
|
|
}
|
|
|
|
/**
|
|
* 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가
|
|
* 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준).
|
|
*/
|
|
function metric_was_alerting(PDO $db, int $sensor_id): bool {
|
|
$stmt = $db->prepare("
|
|
SELECT message FROM sms_log
|
|
WHERE sensor_id = :sid
|
|
AND status = 'success'
|
|
AND (message LIKE '[고온경보]%' OR message LIKE '[저온경보]%'
|
|
OR message LIKE '[고습경보]%' OR message LIKE '[저습경보]%'
|
|
OR message LIKE '[정상복귀]%')
|
|
ORDER BY sent_at DESC
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':sid' => $sensor_id]);
|
|
$msg = $stmt->fetchColumn();
|
|
if ($msg === false) {
|
|
return false;
|
|
}
|
|
return !str_starts_with($msg, '[정상복귀]');
|
|
}
|
|
|
|
/**
|
|
* 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함).
|
|
*
|
|
* 쿨다운: 같은 종류([고온경보] 등)의 최근 성공 SMS가 METRIC_ALERT_COOLDOWN_SEC
|
|
* 이내면 발송을 생략한다(중복 폭주 방지).
|
|
*
|
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
|
* @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity'
|
|
* @param float $value 측정값 (온도℃ 또는 습도%)
|
|
* @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false)
|
|
*/
|
|
function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool {
|
|
$db = get_db();
|
|
$sensor_id = (int)$sensor_data['sensor_id'];
|
|
|
|
// [태그, 지표명, 단위, 임계값, 방향문구]
|
|
$labels = [
|
|
'high_temp' => ['[고온경보]', '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
|
|
'low_temp' => ['[저온경보]', '온도', '℃', METRIC_TEMP_LOW_C, '이하'],
|
|
'high_humidity' => ['[고습경보]', '습도', '%', METRIC_RH_HIGH, '이상'],
|
|
'low_humidity' => ['[저습경보]', '습도', '%', METRIC_RH_LOW, '이하'],
|
|
];
|
|
if (!isset($labels[$kind])) {
|
|
error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}");
|
|
return false;
|
|
}
|
|
[$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind];
|
|
|
|
// ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ──────────────
|
|
$recent = $db->prepare("
|
|
SELECT sent_at FROM sms_log
|
|
WHERE sensor_id = :sensor_id
|
|
AND status = 'success'
|
|
AND message LIKE :tag
|
|
ORDER BY sent_at DESC
|
|
LIMIT 1
|
|
");
|
|
$recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']);
|
|
$last_at = $recent->fetchColumn();
|
|
if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) {
|
|
$elapsed = time() - strtotime($last_at);
|
|
error_log("[SMS] {$tag} 쿨다운 생략 - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)");
|
|
return false;
|
|
}
|
|
|
|
// ── 메시지 구성 ──────────────────────────────────────────
|
|
$dt = date('Y-m-d H:i:s');
|
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}";
|
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
|
$valStr = metric_value_str($value);
|
|
$thrStr = metric_value_str((float)$threshold);
|
|
|
|
$message = "{$tag}\n"
|
|
. "위치: {$loc}\n"
|
|
. "센서: {$name}\n"
|
|
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
|
|
. "시각: {$dt}";
|
|
|
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
|
|
}
|
|
|
|
/**
|
|
* 온습도 정상 복귀 SMS 발송.
|
|
*
|
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
|
* @param float $temp_c 현재 온도(℃)
|
|
* @param float $rh 현재 습도(%)
|
|
* @return bool 발송 성공 여부
|
|
*/
|
|
function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
|
|
$db = get_db();
|
|
|
|
$dt = date('Y-m-d H:i:s');
|
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
|
|
|
$message = "[정상복귀]\n"
|
|
. "위치: {$loc}\n"
|
|
. "센서: {$name}\n"
|
|
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
|
|
. "시각: {$dt}\n"
|
|
. "온습도가 정상 범위로 복귀했습니다.";
|
|
|
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
|
|
}
|
|
|
|
/**
|
|
* 장비 오프라인 SMS 발송
|
|
*
|
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location','last_seen']
|
|
* @return bool 발송 성공 여부
|
|
*/
|
|
function send_device_offline_sms(array $sensor_data): bool {
|
|
$db = get_db();
|
|
|
|
$dt = date('Y-m-d H:i:s');
|
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
|
$last = $sensor_data['last_seen'] ?? '알 수 없음';
|
|
|
|
$message = "[장비 오프라인]\n"
|
|
. "위치: {$loc}\n"
|
|
. "센서: {$name}\n"
|
|
. "마지막 통신: {$last}\n"
|
|
. "감지 시각: {$dt}\n"
|
|
. "장비 상태를 확인하세요!";
|
|
|
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인");
|
|
}
|
|
|
|
/**
|
|
* 장비 온라인 복구 SMS 발송
|
|
*
|
|
* @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location']
|
|
* @return bool 발송 성공 여부
|
|
*/
|
|
function send_device_online_sms(array $sensor_data): bool {
|
|
$db = get_db();
|
|
|
|
$dt = date('Y-m-d H:i:s');
|
|
$name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}";
|
|
$loc = $sensor_data['device_location'] ?? '알 수 없음';
|
|
|
|
$message = "[장비 복구]\n"
|
|
. "위치: {$loc}\n"
|
|
. "센서: {$name}\n"
|
|
. "복구 시각: {$dt}\n"
|
|
. "통신이 정상 복구되었습니다.";
|
|
|
|
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
|
|
}
|