POSA_LEAKSMS/php/config.php
cw2007 031a0c0fdf feat: 대시보드 예보·대응 정보 (예방 신호 + 조치 가이드)
기존 측정 데이터만으로 "경보가 터지기 전"을 보여주고, 위험 시 무엇을 할지
안내한다. 새 센서·ML·DB 스키마 변경 없음(폐쇄망 정합).

신규 php/forecast.php — 순수 계산 함수(DB·출력 없음, 단위테스트 용이):
- dew_point_c(Magnus 이슬점), linear_trend(최소자승 회귀), threshold_eta_min
  (임계 도달 예상 분), headroom_state(여유 3단계), forecast_metric/freshness,
  risk_items(대응 섹션용 정규화 위험 목록).
tests/forecast_test.php — 31 검사(이슬점 알려진값·회귀·ETA·여유·신선도·위험 엣지).

config.php — FORECAST_*(지평/최소점/근접여유/급변 기준) + RESPONSE_PLAYBOOKS
(상태별 대응 체크리스트) + RESPONSE_CONTACTS. 전부 config.local.php 오버라이드.

dashboard.php — 센서별 최근점으로 예보 계산 →
- 카드 예보줄(상시): 임계 여유·추세 ETA·이슬점 결로·변화율(근접/초과만 강조).
- 대응 필요 섹션(위험 시만): 조건·ETA + 상태별 권장 조치 체크리스트 + 담당자.
기존 offline 판정을 단일 진실원으로 사용, 누수 노드 중복 없음.

app.css — .forecast-chip(near/over 상태 3중 인코딩)·.response-panel·.playbook·
.contact (토큰 기반, 라이트/다크 자동). run_tests.sh 에 forecast_test 등록.

검증: forecast_test 31/0, php -l clean(7페이지+forecast), 헤드리스 렌더 확인.
2026-07-05 14:02:48 +09:00

418 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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));
// 수신 본문 최대 크기(바이트). 정상 디바이스 본문은 ~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', $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));
// ─── 결로(이슬점) 경보 ────────────────────────────────────────────────────────
// 온도·습도가 각각 정상이어도 (현재온도 − 이슬점)=결로마진 이 좁으면 차가운 표면에
// 결로가 생긴다(장비 손상). 마진이 이 임계 이하이면 결로주의 경보를 보낸다.
// 단일 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));
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 6)); // 6 × 5분 = 30분
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 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈
// 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인
// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능.
// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
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)
// ─── 누수 모듈 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]);
// ─── 대시보드 세션 인증 ──────────────────────────────────
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): void {
$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;
}
// ─── 서명 검증 (레거시: 본문 필드 기반, 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);
}