2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증. 1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp. 서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약. DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마. 보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS. 문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
7.7 KiB
PHP
115 lines
7.7 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// ops_checks.php - shared operational/security self-check helpers
|
|
// =============================================================================
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/admin_security.php';
|
|
|
|
function ops_table_exists(PDO $db, string $table): bool {
|
|
$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;
|
|
}
|
|
|
|
function ops_column_exists(PDO $db, string $table, string $column): bool {
|
|
$stmt = $db->prepare("
|
|
SELECT COUNT(*)
|
|
FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = :table
|
|
AND COLUMN_NAME = :column
|
|
");
|
|
$stmt->execute([':table' => $table, ':column' => $column]);
|
|
return (int)$stmt->fetchColumn() > 0;
|
|
}
|
|
|
|
function ops_check(string $category, string $label, bool $ok, string $detail, string $fix = '', string $severity = 'warn', string $evidence = ''): array {
|
|
return [
|
|
'category' => $category,
|
|
'label' => $label,
|
|
'ok' => $ok,
|
|
'detail' => $detail,
|
|
'fix' => $fix,
|
|
'severity' => $severity,
|
|
'evidence' => $evidence,
|
|
];
|
|
}
|
|
|
|
function collect_ops_checks(): array {
|
|
$checks = [];
|
|
|
|
try {
|
|
$db = get_db();
|
|
$checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면');
|
|
|
|
$required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log'];
|
|
foreach ($required_tables as $table) {
|
|
$exists = ops_table_exists($db, $table);
|
|
$checks[] = ops_check(
|
|
'DB',
|
|
"테이블 {$table}",
|
|
$exists,
|
|
$exists ? '확인됨' : '누락됨',
|
|
'sql/schema_sht30.sql 실행',
|
|
'critical',
|
|
'DB 테이블 목록 또는 설치 점검 화면'
|
|
);
|
|
}
|
|
|
|
// 최근 온습도 측정 수신 여부(센서 동작/수집 확인)
|
|
if (ops_table_exists($db, 'sensor_metric')) {
|
|
$recent_metric_stmt = $db->query("
|
|
SELECT COUNT(*)
|
|
FROM sensor_metric
|
|
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
|
|
");
|
|
$recent_metric = (int)$recent_metric_stmt->fetchColumn();
|
|
$checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신');
|
|
}
|
|
|
|
if (ops_table_exists($db, 'sms_log')) {
|
|
$recent_sms_fail_stmt = $db->query("
|
|
SELECT COUNT(*)
|
|
FROM sms_log
|
|
WHERE status = 'fail'
|
|
AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
|
");
|
|
$recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn();
|
|
$checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수');
|
|
|
|
// SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호).
|
|
$sms_balance = latest_sms_balance($db);
|
|
if ($sms_balance !== null) {
|
|
$checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여');
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
$checks[] = ops_check('서버', 'DB 연결', false, '연결 실패: ' . $e->getMessage(), 'config.local.php DB 설정 확인', 'critical', 'DB 연결 오류 화면');
|
|
}
|
|
|
|
$default_recipients = count(SMS_RECIPIENTS) === 1 && preg_replace('/\D+/', '', SMS_RECIPIENTS[0]) === '01000000000';
|
|
$checks[] = ops_check('설정', 'API 키', API_KEY !== 'change-this-api-key', 'API_KEY 운영값 설정 여부', 'config.local.php와 /etc/sht30-sensor.env(또는 펌웨어 secrets.h)의 API 키를 동일하게 설정', 'critical', 'config.local.php 원문 미공개 설정 확인');
|
|
$checks[] = ops_check('설정', '관리자 비밀번호', ADMIN_PASSWORD_HASH !== '', 'ADMIN_PASSWORD_HASH 설정 여부', 'php setup_hash.php "new-password" 실행 후 config.local.php에 반영', 'critical', '관리자 해시 설정 확인');
|
|
$checks[] = ops_check('관리자 인증', '관리자 MFA', admin_mfa_configured(), 'ADMIN_TOTP_SECRET 설정 여부', '인증 앱용 Base32 비밀키를 config.local.php에 설정', 'critical', 'MFA 등록 확인표');
|
|
$checks[] = ops_check('관리자 인증', 'MFA 설정 토큰 정리', MFA_SETUP_TOKEN === '', MFA_SETUP_TOKEN === '' ? '정리됨(비어 있음)' : '설정됨 — 등록 후 제거 필요', 'MFA 등록을 마쳤으면 config.local.php의 MFA_SETUP_TOKEN을 빈 값으로 비우세요. 남아 있으면 토큰 보유자가 setup_mfa.php로 새 TOTP 비밀키를 발급·검증할 수 있습니다.', 'warn', 'config.local.php MFA_SETUP_TOKEN 미설정 확인');
|
|
$checks[] = ops_check('API 보안', '레거시 서명 폴백', !ALLOW_LEGACY_BODY_SIGNATURE, ALLOW_LEGACY_BODY_SIGNATURE ? '활성(약한 키-접두 sha256 허용)' : '비활성(HMAC 전용)', '운영 클라이언트(STM32)는 HMAC만 사용하므로 config.local.php에서 ALLOW_LEGACY_BODY_SIGNATURE를 false로 두세요(레거시 RPi 클라이언트가 없는 경우).', 'warn', 'config.php ALLOW_LEGACY_BODY_SIGNATURE');
|
|
$checks[] = ops_check('관리자 인증', '감사로그', is_file(admin_audit_log_path()) || is_writable(__DIR__ . '/var'), admin_audit_log_path(), '로그인 성공/실패, 로그아웃, 관리자 활동 기록 확인', 'warn', 'php/var/admin_audit.log');
|
|
$checks[] = ops_check('개인정보', 'SMS 수신자', !empty(SMS_RECIPIENTS) && !$default_recipients, count(SMS_RECIPIENTS) . '명 설정', 'config.local.php의 SMS_RECIPIENTS 현행화', 'critical', '수신자 목록 현행화 확인표');
|
|
$thr_ok = (METRIC_TEMP_LOW_C < METRIC_TEMP_HIGH_C) && (METRIC_RH_LOW < METRIC_RH_HIGH);
|
|
$checks[] = ops_check('설정', '온습도 임계치', $thr_ok, sprintf('온도 %s~%s℃ / 습도 %s~%s%%', METRIC_TEMP_LOW_C, METRIC_TEMP_HIGH_C, METRIC_RH_LOW, METRIC_RH_HIGH), 'config.local.php의 METRIC_* 임계치를 운영값으로 설정(저온<고온, 저습<고습)', 'warn', '임계치 설정 확인');
|
|
$checks[] = ops_check('파일', '상태 파일 폴더', is_dir(__DIR__ . '/var') && is_writable(__DIR__ . '/var'), __DIR__ . '/var', 'php/var 생성 및 쓰기 권한 확인', 'warn', '폴더 권한 화면');
|
|
$checks[] = ops_check('보관기간', '보관기간 정리', is_file(__DIR__ . '/retention_cleanup.php'), 'SMS ' . SMS_LOG_RETENTION_DAYS . '일 / 센서로그 ' . SENSOR_LOG_RETENTION_DAYS . '일 / 측정 ' . SENSOR_METRIC_RETENTION_DAYS . '일', 'php retention_cleanup.php --dry-run 결과 확인 후 --execute 수행', 'warn', 'retention_cleanup.php 실행 결과');
|
|
$checks[] = ops_check('백업', '백업 증적', is_file(dirname(__DIR__) . '/scripts/backup_evidence.php'), 'scripts/backup_evidence.php', '백업 파일 목록과 복구 테스트 결과를 Markdown으로 보관', 'warn', 'backup-evidence 보고서');
|
|
|
|
return $checks;
|
|
}
|
|
|
|
function ops_failed_count(array $checks): int {
|
|
return count(array_filter($checks, fn($check) => !$check['ok']));
|
|
}
|