누수 감지↔복구 플래핑 SMS 폭탄 방지, 온습도 측정 주기 1시간화(부수 오탐
차단), 센서별 SMS 수신자 그룹 지정 3종을 함께 반영한다.
1) 누수 디바운스 (서버 전용, 펌웨어 무변경)
- leak_sensor_status 에 leak_confirmed / leak_pending_since 컬럼 추가.
- leak_debounce_decide(): 단일 대칭 타이머 순수 함수. raw 상태가 확인창
동안 지속돼야 확정하고 확정 엣지에서만 SMS 1회. dry 블립<복구창이면
복구 미확정 → 확정 유지 → 재감지·복구 SMS 둘 다 차단(폭탄 원천 봉쇄).
- apply_leak_debounce()/sweep_pending_leak_transitions(): 센서 네임드 락으로
직렬화, cron sweep 으로 보고 끊겨도 확정 해소. 사고(leak_incident)
open/close 도 확정 엣지로 이동(월간보고 사고건수 정합). raw 이벤트는
leak_sensor_log 에 그대로 보존(플래핑 진단).
- 컬럼 없는 구배포는 레거시 즉시 발송으로 graceful degrade.
- 확인창 기본: 감지 60s / 복구 180s (config.local.php 조정).
2) 온습도 1시간 주기 + 센서별 히트비트 타임아웃
- raspberry_pi4_sht30/config.py REPORT_INTERVAL 300→3600.
- heartbeat_timeout_for(): 명시 오버라이드 > 온습도 기본(7500s) > 전역(1200s).
cron_heartbeat / dashboard 오프라인 판정을 센서별로. 매시간 오탐 제거,
누수 오프라인은 20분 유지.
- STUCK_DETECT_SAMPLES 6→3(1시간 주기 ≈3시간), 관련 주석 정정.
3) 센서별 수신자 그룹
- SENSOR_RECIPIENTS 맵 + recipients_for_sensor() 전역 폴백.
- sms_dispatch_to_recipients 가 센서별로 수신자 해소(에스컬레이션 TIER2
명시 전달은 유지, 헬스/잔액은 전역).
대시보드: 누수 카드 '확인중(디바운스)' 배지, 시스템 탭에 확인창·센서별
타임아웃·수신자 그룹 표시.
마이그레이션: sql/migration_leak_debounce.sql (컬럼 2개), schema_sht30.sql 갱신.
테스트: leak_debounce(12) / recipients_group / heartbeat_timeout 순수 단위 추가.
설계: docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md
664 lines
38 KiB
PHP
664 lines
38 KiB
PHP
<?php
|
||
// =============================================================================
|
||
// config.php - 데이터베이스 및 공통 설정 (v2605)
|
||
//
|
||
// 운영 비밀값은 config.local.php 또는 환경 변수로 주입합니다.
|
||
// config.local.php는 이 파일과 같은 디렉터리에 두며, 배열을 return 합니다.
|
||
// 예: return ['DB_PASS' => '...', 'API_KEY' => '...'];
|
||
// =============================================================================
|
||
|
||
define('APP_VERSION', 'v2606');
|
||
|
||
$local_config_file = __DIR__ . '/config.local.php';
|
||
$local_config = is_file($local_config_file) ? require $local_config_file : [];
|
||
|
||
function cfg(string $key, $default = '') {
|
||
global $local_config;
|
||
if (array_key_exists($key, $local_config)) {
|
||
return $local_config[$key];
|
||
}
|
||
|
||
$env = getenv($key);
|
||
return $env !== false ? $env : $default;
|
||
}
|
||
|
||
// ─── DB 설정 ───────────────────────────────────────────────
|
||
define('DB_HOST', cfg('DB_HOST', 'localhost'));
|
||
define('DB_PORT', (int)cfg('DB_PORT', 3306));
|
||
define('DB_NAME', cfg('DB_NAME', 'your_db_name'));
|
||
define('DB_USER', cfg('DB_USER', 'your_db_user'));
|
||
define('DB_PASS', cfg('DB_PASS', 'change-me'));
|
||
define('DB_CHARSET', cfg('DB_CHARSET', 'utf8mb4'));
|
||
|
||
// ─── API 보안 키 (STM32 펌웨어 secrets.h의 API_KEY와 동일하게 설정) ───
|
||
define('API_KEY', cfg('API_KEY', 'change-this-api-key'));
|
||
|
||
// 운영 비밀값이 예시 기본값 그대로인지(=교체되지 않았는지) 판정. 네트워크 경계
|
||
// (api/sensor_data.php)가 부팅/요청 시 기본 API_KEY 로는 수신을 거부하도록 한다.
|
||
define('API_KEY_DEFAULT', 'change-this-api-key');
|
||
function api_key_is_default(): bool {
|
||
return !defined('API_KEY') || API_KEY === '' || API_KEY === API_KEY_DEFAULT;
|
||
}
|
||
|
||
// 수신 본문 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 를 차단한다.
|
||
define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
|
||
|
||
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
|
||
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
|
||
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
|
||
// leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
|
||
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
|
||
|
||
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
|
||
// 운영 클라이언트(STM32)는 HMAC raw-body 서명만 쓰므로, 약한 경로를 닫아 둔다.
|
||
// 과거 RPi 클라이언트가 남아 있을 때만 1 로 켠다.
|
||
define('ALLOW_LEGACY_BODY_SIGNATURE', (bool)cfg('ALLOW_LEGACY_BODY_SIGNATURE', false));
|
||
|
||
// ─── SMS 설정 (Cafe24 SMS API) ──────────────────────────────
|
||
define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id'));
|
||
define('SMS_SECURE', cfg('SMS_SECURE', 'your-cafe24-sms-secure-key'));
|
||
define('SMS_SENDER', cfg('SMS_SENDER', '01000000000'));
|
||
define('SMS_ENDPOINT', cfg('SMS_ENDPOINT', 'https://sslsms.cafe24.com/sms_sender.php'));
|
||
|
||
// SMS 수신자 목록 (복수 가능)
|
||
$sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
|
||
if (is_string($sms_recipients)) {
|
||
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
|
||
}
|
||
define('SMS_RECIPIENTS', array_values($sms_recipients));
|
||
|
||
// ─── 센서별 수신자 그룹 (config.local.php 맵) ────────────────────────────────
|
||
// 센서(sensor_id)별로 SMS 를 받을 수신자 그룹을 지정한다. 지정이 없는 센서는 전역
|
||
// SMS_RECIPIENTS 로 폴백한다(SENSOR_LABELS 와 동일 원칙 — Pi 재배포 없이 config.local.php
|
||
// 만 바꾸면 됨). 값은 문자열 배열 또는 쉼표문자열(숫자만 정제). 예:
|
||
// 'SENSOR_RECIPIENTS' => [
|
||
// 1 => ['010-1111-1111', '010-2222-2222'], // 누수 → 시설팀
|
||
// 3 => ['010-3333-3333'], // 3번 → 고객센터
|
||
// ]
|
||
$sensor_recipients_raw = cfg('SENSOR_RECIPIENTS', []);
|
||
$sensor_recipients = [];
|
||
if (is_array($sensor_recipients_raw)) {
|
||
foreach ($sensor_recipients_raw as $sid => $list) {
|
||
$sid = (int)$sid;
|
||
if ($sid < 1 || $sid > 255) {
|
||
continue; // 유효 sensor_id(1..255) 만
|
||
}
|
||
if (is_string($list)) {
|
||
$list = explode(',', $list);
|
||
}
|
||
if (!is_array($list)) {
|
||
continue;
|
||
}
|
||
$nums = [];
|
||
foreach ($list as $num) {
|
||
$d = preg_replace('/\D+/', '', (string)$num);
|
||
if ($d !== '') {
|
||
$nums[] = $d;
|
||
}
|
||
}
|
||
if ($nums !== []) { // 유효 번호가 하나도 없으면 버림(전역 폴백되게)
|
||
$sensor_recipients[$sid] = $nums;
|
||
}
|
||
}
|
||
}
|
||
define('SENSOR_RECIPIENTS', $sensor_recipients);
|
||
|
||
/**
|
||
* 센서별 수신자 그룹 해소(순수 로직). 그룹 지정 시 그 그룹, 없으면 전역 폴백.
|
||
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
|
||
*/
|
||
function resolve_sensor_recipients(int $sid, array $groups, array $global): array {
|
||
return $groups[$sid] ?? $global;
|
||
}
|
||
|
||
/**
|
||
* 이 센서의 SMS 수신자 목록. SENSOR_RECIPIENTS[sid] 우선, 없으면 전역 SMS_RECIPIENTS.
|
||
* sid=0(헬스/잔액/시스템 통지)은 그룹이 없으므로 전역으로 폴백한다.
|
||
*/
|
||
function recipients_for_sensor(int $sid): array {
|
||
return resolve_sensor_recipients($sid, SENSOR_RECIPIENTS, SMS_RECIPIENTS);
|
||
}
|
||
|
||
// Cafe24 SMS('S' 타입)는 EUC-KR 기준 약 90바이트까지만 단문으로 발송된다. 초과하면
|
||
// 게이트웨이가 잘라/거부하므로, 발송 직전에 EUC-KR 바이트 기준으로 안전하게 자른다
|
||
// (멀티바이트 경계 보존; sms_truncate_euckr). 위치/센서명이 길어도 무성 절단을 막는다.
|
||
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
|
||
|
||
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
|
||
// leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
|
||
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
|
||
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
|
||
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
|
||
const SMS_TAG_HIGH_TEMP = '[고온경보]';
|
||
const SMS_TAG_LOW_TEMP = '[저온경보]';
|
||
const SMS_TAG_HIGH_HUMIDITY = '[고습경보]';
|
||
const SMS_TAG_LOW_HUMIDITY = '[저습경보]';
|
||
const SMS_TAG_RECOVERY = '[정상복귀]';
|
||
const SMS_TAG_DEVICE_OFFLINE = '[장비 오프라인]';
|
||
const SMS_TAG_DEVICE_ONLINE = '[장비 복구]';
|
||
const SMS_TAG_CONDENSATION = '[결로주의]'; // 이슬점 근접(결로 위험)
|
||
const SMS_TAG_BALANCE_LOW = '[SMS 잔액부족]'; // 발송 잔여 건수 부족 사전경보
|
||
const SMS_TAG_HEALTH = '[정기점검]'; // 일일 생존신호(데드맨)
|
||
const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-but-stuck) 의심
|
||
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
|
||
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
|
||
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
|
||
|
||
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
|
||
// breach 종류와 1:1 대응한다.
|
||
const SMS_METRIC_TAGS = [
|
||
'high_temp' => SMS_TAG_HIGH_TEMP,
|
||
'low_temp' => SMS_TAG_LOW_TEMP,
|
||
'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
|
||
'low_humidity' => SMS_TAG_LOW_HUMIDITY,
|
||
];
|
||
|
||
/**
|
||
* leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
|
||
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
|
||
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
|
||
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
|
||
* device_offline, device_online, leak_alert, leak_recovery, sms_fail
|
||
*/
|
||
function sms_summary_select_sql(): string {
|
||
return
|
||
"SUM(message LIKE '" . SMS_TAG_HIGH_TEMP . "%') AS high_temp,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_LEAK_ALERT . "%') AS leak_alert,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_LEAK_RECOVERY . "%') AS leak_recovery,\n"
|
||
. " SUM(status = 'fail') AS sms_fail";
|
||
}
|
||
|
||
// ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
|
||
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
|
||
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
|
||
// config.local.php 로 값을 조정할 수 있습니다.
|
||
define('METRIC_TEMP_HIGH_C', (float)cfg('METRIC_TEMP_HIGH_C', 30)); // 고온 임계(℃)
|
||
define('METRIC_TEMP_LOW_C', (float)cfg('METRIC_TEMP_LOW_C', 10)); // 저온 임계(℃)
|
||
define('METRIC_RH_HIGH', (float)cfg('METRIC_RH_HIGH', 70)); // 고습 임계(%)
|
||
define('METRIC_RH_LOW', (float)cfg('METRIC_RH_LOW', 20)); // 저습 임계(%)
|
||
// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지)
|
||
define('METRIC_TEMP_HYSTERESIS_C', (float)cfg('METRIC_TEMP_HYSTERESIS_C', 1.0));
|
||
define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
|
||
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
|
||
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800));
|
||
|
||
// 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를
|
||
// 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분.
|
||
define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
|
||
|
||
// ─── 누수 감지/복구 디바운스 (감지↔복구 플래핑 SMS 폭탄 방지) ──────────────────
|
||
// 센서에 닿는 물이 애매하면 GPIO 가 leak/dry 사이를 초 단위로 진동(채터링)한다. 상태
|
||
// 전이를 즉시 발송하지 않고, 확인창(감지/복구) 동안 raw 상태가 지속돼야 "확정"하고
|
||
// 확정 엣지에서만 SMS 를 1회 보낸다. 누수 지속 중 짧은 dry 블립(< 복구 확인창)은 복구를
|
||
// 확정하지 못하므로, 확정 상태가 유지되어 재감지·복구 SMS 가 둘 다 나가지 않는다(폭탄 차단).
|
||
// leak_sensor_status.leak_confirmed / leak_pending_since 컬럼으로 상태를 저장한다.
|
||
// 해당 컬럼이 없는 구배포에서는 자동으로 레거시(즉시 발송) 경로로 graceful degrade.
|
||
define('LEAK_DEBOUNCE_ENABLED', (bool)cfg('LEAK_DEBOUNCE_ENABLED', true));
|
||
define('LEAK_ALERT_CONFIRM_SEC', (int)cfg('LEAK_ALERT_CONFIRM_SEC', 60)); // 감지 확인창(초)
|
||
define('LEAK_RECOVERY_CONFIRM_SEC', (int)cfg('LEAK_RECOVERY_CONFIRM_SEC', 180)); // 복구 확인창(초)
|
||
|
||
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
|
||
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
|
||
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
|
||
// 단일 SHT30 측정만으로 산출(이슬점=Magnus 근사). 0 이하로 두면 결로 경보 비활성.
|
||
define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
|
||
define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0));
|
||
|
||
// ─── 예보·대응 (대시보드 예방 신호 + 조치 가이드) ─────────────────────────────
|
||
// 기존 측정 데이터만으로 "경보가 터지기 전"을 대시보드에 표시한다(php/forecast.php).
|
||
// 임계 여유·추세 ETA·이슬점 마진·변화율을 계산하고, 위험 시 상태별 대응 체크리스트를
|
||
// 보여준다. DB 스키마 변경 없음(전부 기존 leak_sensor_metric/status 로 산출).
|
||
define('FORECAST_ENABLED', (bool)cfg('FORECAST_ENABLED', true));
|
||
define('FORECAST_RECENT_POINTS', (int)cfg('FORECAST_RECENT_POINTS', 12)); // 회귀에 쓸 최근 측정 수(≈1시간)
|
||
define('FORECAST_MIN_POINTS', (int)cfg('FORECAST_MIN_POINTS', 6)); // ETA 산출 최소 점수
|
||
define('FORECAST_MIN_R2', (float)cfg('FORECAST_MIN_R2', 0.5)); // 추세 신뢰(결정계수) 하한
|
||
define('FORECAST_HORIZON_MIN', (int)cfg('FORECAST_HORIZON_MIN', 180)); // ETA 표시 지평(분)
|
||
define('FORECAST_WARN_MARGIN_TEMP_C', (float)cfg('FORECAST_WARN_MARGIN_TEMP_C', 2.0)); // 온도 여유 근접 기준(℃)
|
||
define('FORECAST_WARN_MARGIN_RH', (float)cfg('FORECAST_WARN_MARGIN_RH', 8.0)); // 습도 여유 근접 기준(%)
|
||
define('FORECAST_RATE_TEMP_C_PER_10MIN', (float)cfg('FORECAST_RATE_TEMP_C_PER_10MIN', 3.0)); // 급변 기준(℃/10분)
|
||
define('FORECAST_RATE_RH_PER_10MIN', (float)cfg('FORECAST_RATE_RH_PER_10MIN', 10.0)); // 급변 기준(%/10분)
|
||
|
||
// 상태별 대응 체크리스트(플레이북). 조건 키 → ['title'=>, 'steps'=>[...]].
|
||
// config.local.php 에서 'RESPONSE_PLAYBOOKS' 로 통째 오버라이드 가능(현장 절차 반영).
|
||
$default_playbooks = [
|
||
'leak' => ['title' => '누수 대응', 'steps' => ['현장 즉시 확인', '해당 구역 급수 밸브 차단', '누수 지점·범위 사진 확인', '전기 설비 침수 여부 안전 점검', '조치 후 복구 확인']],
|
||
'high_temp' => ['title' => '고온 대응', 'steps' => ['냉방·환기 동작 확인', '실외기/흡배기·필터 막힘 점검', '발열 장비·부하 확인', '필요 시 임시 냉각']],
|
||
'low_temp' => ['title' => '저온 대응', 'steps' => ['난방 동작 확인', '외기 유입·틈새 점검', '동결 위험 배관 확인']],
|
||
'high_humidity' => ['title' => '고습 대응', 'steps' => ['제습기 동작 확인', '누수·침수 여부 확인', '환기 상태 점검']],
|
||
'low_humidity' => ['title' => '저습 대응', 'steps' => ['가습 설비 확인', '정전기 민감 장비 점검']],
|
||
'condensation' => ['title' => '결로 위험 대응', 'steps' => ['차가운 표면·배관 결로 확인', '제습·환기로 이슬점 마진 확보', '장비 표면 물기 점검']],
|
||
'offline' => ['title' => '오프라인 대응', 'steps' => ['장비 전원·상태 표시등 확인', '유선/무선 네트워크 연결 확인', 'NTP 시각 동기 확인', '필요 시 장비 재부팅', '복구 후 수신 재개 확인']],
|
||
];
|
||
define('RESPONSE_PLAYBOOKS', cfg('RESPONSE_PLAYBOOKS', $default_playbooks));
|
||
|
||
// 대응 담당자 연락처: [['role'=>'시설담당','name'=>'홍길동','phone'=>'010-...'], ...].
|
||
// 비면(기본) 대응 섹션에서 연락처 블록을 생략한다. config.local.php 에서 채운다.
|
||
define('RESPONSE_CONTACTS', cfg('RESPONSE_CONTACTS', []));
|
||
|
||
// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
|
||
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
|
||
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
|
||
define('SMS_BALANCE_WARN_THRESHOLD', (int)cfg('SMS_BALANCE_WARN_THRESHOLD', 30));
|
||
define('SMS_BALANCE_WARN_COOLDOWN_SEC',(int)cfg('SMS_BALANCE_WARN_COOLDOWN_SEC', 21600)); // 6시간
|
||
|
||
// ─── 일일 헬스(생존신호) SMS ──────────────────────────────────────────────────
|
||
// 하루 1통 '[정기점검]' 요약(정상 센서 수/최근 수신/SMS 잔여)을 보낸다. '무소식=정상'
|
||
// 과 '무소식=경보경로 전체 고장'의 모호성을 제거하는 데드맨(관리자는 '안 오면 점검'만
|
||
// 기억하면 된다). HEALTH_SMS_HOUR 시(0~23) 이후의 첫 체크에서 하루 1회 발송.
|
||
define('HEALTH_SMS_ENABLED', (bool)cfg('HEALTH_SMS_ENABLED', true));
|
||
define('HEALTH_SMS_HOUR', (int)cfg('HEALTH_SMS_HOUR', 9));
|
||
|
||
// ─── 경보 신뢰성 (재시도 / 에스컬레이션 / 고착 검출 / 드릴) ────────────────────
|
||
// 오프라인 SMS 발송 실패 시 offline_alerted 를 되돌려 다음 주기에 재시도한다(critical
|
||
// 오프라인 미통지 방지 — 일회성 발송이 실패하면 영영 못 알리던 갭).
|
||
define('SMS_RETRY_OFFLINE', (bool)cfg('SMS_RETRY_OFFLINE', true));
|
||
|
||
// 미복귀 경보 에스컬레이션: 임계 경보가 ESCALATE_AFTER_SEC 이상 미복귀로 지속되면 2차
|
||
// (야간) 수신자(SMS_RECIPIENTS_TIER2)에게 1회 통지. TIER2 가 비면 비활성(opt-in).
|
||
$sms_tier2 = cfg('SMS_RECIPIENTS_TIER2', []);
|
||
if (is_string($sms_tier2)) {
|
||
$sms_tier2 = array_filter(array_map('trim', explode(',', $sms_tier2)));
|
||
}
|
||
define('SMS_RECIPIENTS_TIER2', $sms_tier2);
|
||
define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
|
||
|
||
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
|
||
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
|
||
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
|
||
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true));
|
||
// 온습도 보고 주기가 1시간이므로 표본 수가 크면 고착 감지가 너무 늦다. 기본 3(= 3회 연속
|
||
// 완전 평탄 ≈ 3시간). 주기를 바꾸면 config.local.php 로 조정.
|
||
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 3)); // 3 × 1시간 ≈ 3시간
|
||
define('STUCK_DETECT_EPSILON', (float)cfg('STUCK_DETECT_EPSILON', 0.01)); // 분해능 이하
|
||
define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); // 6시간
|
||
|
||
// 카오스 드릴: 켜면 send_sms_cafe24 가 실제 전송 없이 실패를 반환한다(실패 경로 시험).
|
||
// 운영에서는 반드시 false. tests/chaos_drill.php 가 일시적으로 켜서 검증한다.
|
||
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
|
||
|
||
// ─── Heartbeat (장비 오프라인 감지) — 센서별 타임아웃 ──────────────────────────
|
||
// 온습도(SHT30) 보고 주기는 1시간(raspberry_pi4_sht30/config.py REPORT_INTERVAL=3600),
|
||
// 누수 모듈 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 보고 주기가
|
||
// 센서마다 크게 다르므로 오프라인 판정 타임아웃을 센서별로 해소한다(전역 단일값이면
|
||
// 온습도 1시간 주기가 20분 타임아웃에 매시간 오프라인 오탐, 또는 전역을 1시간+로 올리면
|
||
// 누수 노드 다운을 늦게 감지).
|
||
// heartbeat_timeout_for(sid): 명시 오버라이드 > 온습도(METRIC) 기본 > 전역 기본.
|
||
// ⚠️ 각 값은 해당 센서의 보고 주기보다 넉넉히 길어야 오탐이 없다.
|
||
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); // 전역 기본(누수 등 20분)
|
||
// 온습도 센서(METRIC_SENSOR_IDS) 오프라인 판정 기본. 1시간 주기 2회 누락 + 여유 ≈ 125분.
|
||
define('METRIC_HEARTBEAT_TIMEOUT_SEC', (int)cfg('METRIC_HEARTBEAT_TIMEOUT_SEC', 7500));
|
||
// 센서별 명시 오버라이드 맵: [sensor_id => 초]. 예: 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 900]
|
||
$sensor_hb_raw = cfg('SENSOR_HEARTBEAT_TIMEOUT_SEC', []);
|
||
$sensor_hb = [];
|
||
if (is_array($sensor_hb_raw)) {
|
||
foreach ($sensor_hb_raw as $sid => $sec) {
|
||
$sid = (int)$sid;
|
||
$sec = (int)$sec;
|
||
if ($sid >= 1 && $sid <= 255 && $sec > 0) {
|
||
$sensor_hb[$sid] = $sec;
|
||
}
|
||
}
|
||
}
|
||
define('SENSOR_HEARTBEAT_TIMEOUT_SEC', $sensor_hb);
|
||
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
|
||
define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC)
|
||
|
||
/**
|
||
* 센서별 오프라인 판정 타임아웃 해소(순수 로직). 명시 오버라이드 > 온습도 기본 > 전역 기본.
|
||
* 테스트 용이성을 위해 상수 의존 없는 순수 함수로 분리한다.
|
||
*/
|
||
function resolve_heartbeat_timeout(int $sid, array $overrides, array $metricIds, int $metricDefault, int $globalDefault): int {
|
||
if (array_key_exists($sid, $overrides)) {
|
||
return (int)$overrides[$sid];
|
||
}
|
||
if (in_array($sid, $metricIds, true)) {
|
||
return $metricDefault;
|
||
}
|
||
return $globalDefault;
|
||
}
|
||
|
||
/**
|
||
* 이 센서의 오프라인 판정 타임아웃(초). cron_heartbeat / dashboard 신선도 판정 공용.
|
||
*/
|
||
function heartbeat_timeout_for(int $sid): int {
|
||
return resolve_heartbeat_timeout(
|
||
$sid, SENSOR_HEARTBEAT_TIMEOUT_SEC, METRIC_SENSOR_IDS,
|
||
METRIC_HEARTBEAT_TIMEOUT_SEC, HEARTBEAT_TIMEOUT_SEC
|
||
);
|
||
}
|
||
|
||
// ─── 누수 모듈 sensor_id 목록 ────────────────────────────
|
||
// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로
|
||
// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용)
|
||
// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖·
|
||
// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드
|
||
// 전체가 사라지는 것 방지).
|
||
$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]);
|
||
if (is_string($leak_sensor_ids)) {
|
||
$leak_sensor_ids = explode(',', $leak_sensor_ids);
|
||
}
|
||
$leak_sensor_ids = array_values(array_filter(
|
||
array_map('intval', (array)$leak_sensor_ids),
|
||
static fn($id) => $id >= 1 && $id <= 255
|
||
));
|
||
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]);
|
||
|
||
// ─── 센서별 표시 라벨 오버라이드 (서버측) ────────────────────
|
||
// SMS·대시보드에 표시할 센서별 "위치"와 "관리주체(표시명)"를 서버에서 지정한다
|
||
// (Pi 재배포 없이 config.local.php 만 바꾸면 됨). 지정이 없으면 Pi 가 보고한
|
||
// device_location / sensor_name 을 그대로 쓴다. 예:
|
||
// 'SENSOR_LABELS' => [
|
||
// 1 => ['location' => '서버실(안)', 'name' => '정보기반'],
|
||
// 2 => ['location' => '문서고', 'name' => '조직혁신'],
|
||
// 3 => ['location' => '서버실(밖)', 'name' => '고객센터'],
|
||
// ]
|
||
$sensor_labels_raw = cfg('SENSOR_LABELS', []);
|
||
$sensor_labels = [];
|
||
if (is_array($sensor_labels_raw)) {
|
||
foreach ($sensor_labels_raw as $sid => $lbl) {
|
||
$sid = (int)$sid;
|
||
if ($sid < 1 || $sid > 255 || !is_array($lbl)) {
|
||
continue;
|
||
}
|
||
$entry = [];
|
||
if (isset($lbl['location']) && is_string($lbl['location']) && $lbl['location'] !== '') {
|
||
$entry['location'] = $lbl['location'];
|
||
}
|
||
if (isset($lbl['name']) && is_string($lbl['name']) && $lbl['name'] !== '') {
|
||
$entry['name'] = $lbl['name'];
|
||
}
|
||
if ($entry !== []) {
|
||
$sensor_labels[$sid] = $entry;
|
||
}
|
||
}
|
||
}
|
||
define('SENSOR_LABELS', $sensor_labels);
|
||
|
||
/**
|
||
* 센서 표시 라벨 결정: SENSOR_LABELS(서버 오버라이드) 우선, 없으면 보고값(Pi) 폴백.
|
||
* SMS·대시보드 등 사람이 보는 모든 곳에서 센서의 위치/관리주체를 이 헬퍼로 얻는다.
|
||
*
|
||
* @return array{0:string,1:string} [위치, 관리주체(표시명)]
|
||
*/
|
||
function sensor_label(int $sensor_id, ?string $reported_location, ?string $reported_name): array {
|
||
$ov = SENSOR_LABELS[$sensor_id] ?? [];
|
||
$loc = $ov['location'] ?? ((($reported_location ?? '') !== '') ? $reported_location : '알 수 없음');
|
||
$name = $ov['name'] ?? ((($reported_name ?? '') !== '') ? $reported_name : "센서{$sensor_id}");
|
||
return [$loc, $name];
|
||
}
|
||
|
||
// ─── 대시보드 세션 인증 ──────────────────────────────────
|
||
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
|
||
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
|
||
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps
|
||
define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap
|
||
define('SESSION_TIMEOUT', 3600); // 유휴(idle) 타임아웃: 마지막 활동 후 1시간
|
||
// 절대 세션 수명(초). 연속 활동 중이어도 로그인 시각 기준 이 시간이 지나면 만료한다
|
||
// (유휴 타임아웃만 있으면 활동 중 세션이 무한정 유지되는 문제 보완). 기본 12시간.
|
||
define('SESSION_ABSOLUTE_TIMEOUT', (int)cfg('SESSION_ABSOLUTE_TIMEOUT', 43200));
|
||
|
||
// ─── Retention policy ────────────────────────────────────────────────────────
|
||
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365));
|
||
define('SENSOR_LOG_RETENTION_DAYS', (int)cfg('SENSOR_LOG_RETENTION_DAYS', 365));
|
||
define('SENSOR_METRIC_RETENTION_DAYS', (int)cfg('SENSOR_METRIC_RETENTION_DAYS', 365));
|
||
define('ADMIN_AUDIT_RETENTION_DAYS', (int)cfg('ADMIN_AUDIT_RETENTION_DAYS', 365));
|
||
|
||
// ─── 타임존 ───────────────────────────────────────────────
|
||
date_default_timezone_set('Asia/Seoul');
|
||
|
||
// ─── DB 연결 함수 ──────────────────────────────────────────
|
||
function get_db(): PDO {
|
||
static $pdo = null;
|
||
if ($pdo === null) {
|
||
$dsn = sprintf(
|
||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||
DB_HOST, DB_PORT, DB_NAME, DB_CHARSET
|
||
);
|
||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
PDO::ATTR_EMULATE_PREPARES => false,
|
||
]);
|
||
}
|
||
return $pdo;
|
||
}
|
||
|
||
// ─── JSON 응답 헬퍼 ───────────────────────────────────────
|
||
function json_response(array $data, int $http_code = 200): void {
|
||
http_response_code($http_code);
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
exit;
|
||
}
|
||
|
||
// ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
|
||
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
|
||
// 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
|
||
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
|
||
function mask_phone(string $value): string {
|
||
if (strpos($value, ',') !== false) {
|
||
$parts = array_map('trim', explode(',', $value));
|
||
return implode(', ', array_map('mask_phone', $parts));
|
||
}
|
||
$d = preg_replace('/\D+/', '', $value);
|
||
$len = strlen($d);
|
||
if ($len < 4) {
|
||
return str_repeat('*', $len);
|
||
}
|
||
$last4 = substr($d, -4);
|
||
$prefix = $len >= 7 ? substr($d, 0, 3) : '';
|
||
return ($prefix !== '' ? $prefix . '-' : '') . '****-' . $last4;
|
||
}
|
||
|
||
// 최근 성공 발송의 잔여 SMS 건수(result_message "발송 성공 (잔여 N건)" 에서 파싱).
|
||
// 별도 컬럼 없이 기존 기록에서 읽는다. 알 수 없으면 null. (잔액 점검/헬스 요약 공용)
|
||
function latest_sms_balance(PDO $db): ?int {
|
||
try {
|
||
$rm = $db->query("
|
||
SELECT result_message FROM leak_sms_log
|
||
WHERE status = 'success' AND result_message LIKE '%잔여%'
|
||
ORDER BY id DESC LIMIT 1
|
||
")->fetchColumn();
|
||
} catch (Throwable $e) {
|
||
return null;
|
||
}
|
||
if ($rm !== false && preg_match('/잔여\s*(\d+)\s*건/u', (string)$rm, $m)) {
|
||
return (int)$m[1];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ─── 수집 거부 사유 카운터 (파일 기반, DoS 안전) ──────────────────────────────
|
||
// 인증 전 거부(403/400/413/503/범위)를 사유별 일자 버킷으로 집계한다. DB 를 건드리지
|
||
// 않아(파일 flock) 위조요청 폭주에도 부담이 작다. 대시보드/ops 가 읽어 '왜 데이터가
|
||
// 안 들어오는가'(시계오차/키불일치/위조시도)를 빠르게 분별한다.
|
||
function ingest_rejection_path(): string {
|
||
return __DIR__ . '/var/ingest_rejections.json';
|
||
}
|
||
|
||
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)
|
||
}
|
||
if (flock($fh, LOCK_EX)) {
|
||
$today = date('Y-m-d');
|
||
$raw = stream_get_contents($fh);
|
||
$data = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
|
||
if (!is_array($data)) { $data = []; }
|
||
if (!isset($data[$today]) || !is_array($data[$today])) { $data[$today] = []; }
|
||
$data[$today][$reason] = ($data[$today][$reason] ?? 0) + 1;
|
||
if (count($data) > 14) { // 최근 14일만 보존(파일 무한증가 방지)
|
||
ksort($data);
|
||
$data = array_slice($data, -14, null, true);
|
||
}
|
||
ftruncate($fh, 0);
|
||
rewind($fh);
|
||
fwrite($fh, json_encode($data));
|
||
fflush($fh);
|
||
flock($fh, LOCK_UN);
|
||
}
|
||
fclose($fh);
|
||
}
|
||
|
||
// 최근 $days 일의 거부 사유별 합계(내림차순). 반환: ['reason' => count, ...].
|
||
function read_ingest_rejections(int $days = 1): array {
|
||
$file = ingest_rejection_path();
|
||
if (!is_file($file)) {
|
||
return [];
|
||
}
|
||
$data = json_decode((string)@file_get_contents($file), true);
|
||
if (!is_array($data)) {
|
||
return [];
|
||
}
|
||
$cutoff = date('Y-m-d', time() - max(0, $days - 1) * 86400);
|
||
$totals = [];
|
||
foreach ($data as $day => $reasons) {
|
||
if ((string)$day < $cutoff || !is_array($reasons)) {
|
||
continue;
|
||
}
|
||
foreach ($reasons as $reason => $cnt) {
|
||
$totals[$reason] = ($totals[$reason] ?? 0) + (int)$cnt;
|
||
}
|
||
}
|
||
arsort($totals);
|
||
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 펌웨어)는 사용하지 않는다.
|
||
// 키 접두 sha256 이라 길이확장에 약하므로 신규 클라이언트는 절대 사용하지 말 것.
|
||
// (남은 레거시 클라이언트가 없으면 차후 제거 가능.)
|
||
function verify_signature(array $data): bool {
|
||
if (empty($data['signature'])) return false;
|
||
$received_sig = $data['signature'];
|
||
unset($data['signature']);
|
||
ksort($data);
|
||
$payload = API_KEY . json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
$expected = hash('sha256', $payload);
|
||
return hash_equals($expected, $received_sig);
|
||
}
|
||
|
||
// ─── 서명 검증 (신규: raw-body 기반, HMAC-SHA256) ──────────
|
||
// 요청 본문 바이트 전체에 대해 HMAC-SHA256(key=API_KEY, msg=raw) 를 계산하고
|
||
// X-Signature 헤더 값과 상수시간 비교한다.
|
||
//
|
||
// JSON 재직렬화(키 정렬·부동소수 포맷·슬래시 이스케이프)에 의존하지 않으므로
|
||
// MCU(STM32 등)에서 부동소수 표기 차이로 서명이 불일치하던 문제를 제거한다.
|
||
// 키 접두(sha256(key.body)) 대신 HMAC 을 써서 길이확장 공격에 견딘다.
|
||
// 펌웨어 sig.c(HMAC) / reference.py(hmac.new) 와 바이트 단위로 일치한다.
|
||
// 클라이언트는 전송하는 본문 바이트 그대로를 서명하기만 하면 된다.
|
||
function verify_signature_raw(string $raw_body): bool {
|
||
$received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
|
||
if ($received_sig === '') return false;
|
||
$expected = hash_hmac('sha256', $raw_body, API_KEY);
|
||
return hash_equals($expected, $received_sig);
|
||
}
|
||
|