From 7a9d4af9ad801d02e035708efecb6e232402c771 Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 03:14:09 +0900 Subject: [PATCH] =?UTF-8?q?Control=20=EB=B3=B4=EC=95=88=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=84=A4=EC=A0=95=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +- config/config.php | 282 ++++++++++++++++++++++++++++++++++++++++--- public/api.php | 152 +---------------------- public/assets/app.js | 84 +++++++++++-- public/index.php | 31 ++++- 5 files changed, 380 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 00cfde9..d75d494 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - 팬 모드 `auto`, `manual`, `off` 제어 - PWM slider 기반 수동 팬 제어 -- 오버레이 설정 모달에서 팬 자동곡선, 이벤트 판정, 팬 이상감지, 배터리 예측 파라미터 조정 +- 오버레이 설정 모달에서 보안 정책, 팬 자동곡선, 이벤트 판정, 팬 이상감지, 배터리 예측 파라미터 조정 - CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트 - RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인 - 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정 @@ -111,6 +111,7 @@ WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과 설정 가능한 항목은 다음 범위입니다. - 팬 자동 제어: 팬 시작 온도, 최대 온도, 즉시 최대 PWM 온도, 자동 상승/하강 PWM step +- 보안 정책: 자동 로그인 유지 기간, remember 쿠키 Secure/SameSite, CSRF 길이, remember 토큰 길이, User-Agent/IP 접두사 검증, 로그인 실패 잠금, 재부팅 확인 문구, 명령 timeout, WiFi 조작 허용 여부 - 저전압/스로틀링: 최근 판정창, 복구 중 유지 시간, 반복 발생 기준 횟수 - 팬 이상감지: 기준 표본 수, 최신 표본 제외 수, RPM/온도 이상 감지 차이, RPM/온도 복구 차이 - 배터리 예측: 단기/장기 학습 범위, 후보 필터 비율, 부하 보정 강도, 전압 하한, 용량 모델 가중치 @@ -182,7 +183,7 @@ control-wifi-observe.service 7. WiFi 신호/송신 속도/수신 속도 값이 비어 있으면 실제로 존재하는 다른 무선 지표와 대역별 기준으로 값을 채웁니다. 8. `control-wifi-observe.timer`는 사용자 접속과 무관하게 10초마다 5G 관측 세션을 갱신합니다. 9. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -10. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +10. Reboot 버튼은 설정된 확인 문구와 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 11. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 12. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 13. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다. @@ -236,7 +237,10 @@ control-wifi-observe.service - 로그인 세션과 CSRF token을 사용합니다. - POST 조작 API는 CSRF 검증을 통과해야 합니다. - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. -- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다. +- 재부팅 API는 CSRF, 로그인 세션, 설정된 확인 문구, 관리자 암호 재검증을 모두 요구합니다. +- 로그인 화면은 CSRF 검사를 수행하고, 설정한 실패 횟수/집계 시간/잠금 시간에 따라 세션 단위 잠금을 적용합니다. +- Remember login은 설정에 따라 Secure/SameSite, 토큰 길이, 만료 기간, User-Agent 고정, IPv4 접두사 고정을 조정할 수 있습니다. +- WiFi restart/reload 허용 여부와 root 명령 timeout은 설정 모달에서 조정합니다. - 앱 비밀번호와 HA 알림 설정은 저장소 밖 secret 파일로 관리합니다. - HA webhook id는 `/home/seo/secret/control.php`의 `ha_notify.webhook_id`에 둡니다. 값이 없으면 발송하지 않고 `ha_notify_logs`에 실패로 기록합니다. diff --git a/config/config.php b/config/config.php index cef580b..56c8aab 100644 --- a/config/config.php +++ b/config/config.php @@ -124,6 +124,223 @@ function db_connection_lost(PDOException $e): bool || str_contains($message, 'Lost connection'); } +function clamp_float(float $value, float $min, float $max): float +{ + return max($min, min($max, $value)); +} + +function setting_definitions(): array +{ + return [ + 'security.remember_days' => ['group' => 'security', 'label' => '자동 로그인 유지 기간', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365, 'step' => 1, 'unit' => '일'], + 'security.cookie_secure_mode' => ['group' => 'security', 'label' => 'Remember 쿠키 Secure', 'type' => 'select', 'default' => 'auto', 'options' => ['auto' => 'HTTPS일 때 자동', 'always' => '항상 사용', 'never' => '사용 안함']], + 'security.cookie_samesite' => ['group' => 'security', 'label' => 'Remember 쿠키 SameSite', 'type' => 'select', 'default' => 'Lax', 'options' => ['Lax' => 'Lax', 'Strict' => 'Strict', 'None' => 'None']], + 'security.csrf_bytes' => ['group' => 'security', 'label' => 'CSRF 토큰 바이트', 'type' => 'number', 'default' => 24, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'], + 'security.remember_selector_bytes' => ['group' => 'security', 'label' => 'Remember selector 바이트', 'type' => 'number', 'default' => 12, 'min' => 8, 'max' => 32, 'step' => 1, 'unit' => 'bytes'], + 'security.remember_validator_bytes' => ['group' => 'security', 'label' => 'Remember validator 바이트', 'type' => 'number', 'default' => 32, 'min' => 16, 'max' => 64, 'step' => 1, 'unit' => 'bytes'], + 'security.remember_bind_user_agent' => ['group' => 'security', 'label' => 'Remember User-Agent 고정', 'type' => 'boolean', 'default' => 1], + 'security.remember_bind_ip_prefix_v4' => ['group' => 'security', 'label' => 'Remember IPv4 접두사 고정', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 32, 'step' => 1, 'unit' => 'bit'], + 'security.session_regenerate_auto_login' => ['group' => 'security', 'label' => '자동 로그인 시 세션 재발급', 'type' => 'boolean', 'default' => 1], + 'security.prune_expired_remember_tokens' => ['group' => 'security', 'label' => '만료 Remember 토큰 자동 정리', 'type' => 'boolean', 'default' => 1], + 'security.login_csrf_required' => ['group' => 'security', 'label' => '로그인 CSRF 검사', 'type' => 'boolean', 'default' => 1], + 'security.login_max_failures' => ['group' => 'security', 'label' => '로그인 실패 허용 횟수', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 30, 'step' => 1, 'unit' => '회'], + 'security.login_window_seconds' => ['group' => 'security', 'label' => '로그인 실패 집계 시간', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 86400, 'step' => 60, 'unit' => '초'], + 'security.login_lock_seconds' => ['group' => 'security', 'label' => '로그인 잠금 시간', 'type' => 'number', 'default' => 300, 'min' => 10, 'max' => 86400, 'step' => 10, 'unit' => '초'], + 'security.reboot_phrase' => ['group' => 'security', 'label' => '재부팅 확인 문구', 'type' => 'text', 'default' => '재부팅', 'min_length' => 2, 'max_length' => 32], + 'security.reboot_timeout_seconds' => ['group' => 'security', 'label' => '재부팅 명령 timeout', 'type' => 'number', 'default' => 5, 'min' => 2, 'max' => 30, 'step' => 1, 'unit' => '초'], + 'security.wifi_command_timeout_seconds' => ['group' => 'security', 'label' => 'WiFi 명령 timeout', 'type' => 'number', 'default' => 25, 'min' => 5, 'max' => 90, 'step' => 1, 'unit' => '초'], + 'security.shell_default_timeout_seconds' => ['group' => 'security', 'label' => '기본 명령 timeout', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 60, 'step' => 1, 'unit' => '초'], + 'security.allow_wifi_restart' => ['group' => 'security', 'label' => 'WiFi restart 허용', 'type' => 'boolean', 'default' => 1], + 'security.allow_wifi_reload' => ['group' => 'security', 'label' => 'WiFi reload 허용', 'type' => 'boolean', 'default' => 1], + + 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], + 'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], + 'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], + 'fan.ramp_up_step' => ['group' => 'fan', 'label' => '자동 상승 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], + 'fan.ramp_down_step' => ['group' => 'fan', 'label' => '자동 하강 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], + 'event.recent_window_seconds' => ['group' => 'event', 'label' => '저전압/스로틀링 최근 판정창', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 7200, 'step' => 30, 'unit' => '초'], + 'event.recovery_seconds' => ['group' => 'event', 'label' => '복구 중 유지 시간', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'], + 'event.repeated_count' => ['group' => 'event', 'label' => '반복 발생 기준 횟수', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 20, 'step' => 1, 'unit' => '회'], + 'notice.baseline_samples' => ['group' => 'notice', 'label' => '팬 이상 기준 표본 수', 'type' => 'number', 'default' => 180, 'min' => 30, 'max' => 600, 'step' => 10, 'unit' => '개'], + 'notice.baseline_skip_recent' => ['group' => 'notice', 'label' => '기준 계산 제외 최신 표본', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 30, 'step' => 1, 'unit' => '개'], + 'notice.rpm_delta_threshold' => ['group' => 'notice', 'label' => '팬 RPM 이상 감지 차이', 'type' => 'number', 'default' => 1000, 'min' => 100, 'max' => 5000, 'step' => 50, 'unit' => 'RPM'], + 'notice.temp_delta_threshold' => ['group' => 'notice', 'label' => '온도 이상 감지 차이', 'type' => 'number', 'default' => 3.0, 'min' => 0.5, 'max' => 15, 'step' => 0.1, 'unit' => 'C'], + 'notice.recover_rpm_delta' => ['group' => 'notice', 'label' => '팬 RPM 복구 차이', 'type' => 'number', 'default' => 500, 'min' => 50, 'max' => 3000, 'step' => 50, 'unit' => 'RPM'], + 'notice.recover_temp_delta' => ['group' => 'notice', 'label' => '온도 복구 차이', 'type' => 'number', 'default' => 1.5, 'min' => 0.2, 'max' => 10, 'step' => 0.1, 'unit' => 'C'], + 'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'], + 'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'], + 'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.load_adjust_regression' => ['group' => 'battery', 'label' => '강건 회귀 부하 보정 강도', 'type' => 'number', 'default' => 0.38, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.load_adjust_voltage' => ['group' => 'battery', 'label' => '전압 후보 부하 보정 강도', 'type' => 'number', 'default' => 0.28, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.voltage_floor_high' => ['group' => 'battery', 'label' => '전압 하한 높음', 'type' => 'number', 'default' => 3.28, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], + 'battery.voltage_floor_mid' => ['group' => 'battery', 'label' => '전압 하한 중간', 'type' => 'number', 'default' => 3.18, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], + 'battery.voltage_floor_low' => ['group' => 'battery', 'label' => '전압 하한 낮음', 'type' => 'number', 'default' => 3.05, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], + 'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + 'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'], + 'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], + ]; +} + +function setting_rows(bool $reload = false): array +{ + static $cache = null; + if (!$reload && $cache !== null) { + return $cache; + } + + try { + $rows = db()->query("SELECT setting_key, setting_value FROM app_settings")->fetchAll(); + } catch (Throwable) { + $cache = []; + return $cache; + } + + $cache = []; + foreach ($rows as $row) { + $cache[(string)$row['setting_key']] = (string)$row['setting_value']; + } + + return $cache; +} + +function setting_value(string $key): int|float|string|bool|null +{ + $defs = setting_definitions(); + if (!isset($defs[$key])) { + return null; + } + $def = $defs[$key]; + $raw = setting_rows()[$key] ?? $def['default']; + $type = (string)($def['type'] ?? ''); + + if ($type === 'number') { + $value = is_numeric($raw) ? (float)$raw : (float)$def['default']; + $value = clamp_float($value, (float)$def['min'], (float)$def['max']); + return fmod((float)$def['step'], 1.0) === 0.0 ? (int)round($value) : $value; + } + + if ($type === 'boolean') { + return in_array((string)$raw, ['1', 'true', 'yes', 'on'], true); + } + + if ($type === 'select') { + $options = is_array($def['options'] ?? null) ? $def['options'] : []; + return array_key_exists((string)$raw, $options) ? (string)$raw : (string)$def['default']; + } + + if ($type === 'text') { + $value = trim((string)$raw); + $min = (int)($def['min_length'] ?? 0); + $max = (int)($def['max_length'] ?? 255); + if (mb_strlen($value) < $min) { + return (string)$def['default']; + } + return mb_substr($value, 0, $max); + } + + return $raw; +} + +function settings_payload(): array +{ + $values = setting_rows(); + $groups = [ + 'security' => '보안 정책', + 'fan' => '팬 자동 제어', + 'battery' => '배터리 예측', + 'event' => '저전압/스로틀링', + 'notice' => '팬 이상 감지', + ]; + + $items = []; + foreach (setting_definitions() as $key => $def) { + $items[] = array_merge($def, [ + 'key' => $key, + 'value' => setting_value($key), + 'default' => $def['default'], + 'custom' => array_key_exists($key, $values), + ]); + } + + return [ + 'groups' => $groups, + 'items' => $items, + ]; +} + +function save_settings(array $input): void +{ + $defs = setting_definitions(); + $stmt = db()->prepare(" + INSERT INTO app_settings (setting_key, setting_value) + VALUES (:setting_key, :setting_value) + ON DUPLICATE KEY UPDATE + setting_value = VALUES(setting_value), + updated_at = CURRENT_TIMESTAMP + "); + + foreach ($input as $key => $value) { + if (!isset($defs[$key])) { + continue; + } + $def = $defs[$key]; + $type = (string)($def['type'] ?? ''); + if ($type === 'number') { + if (!is_numeric($value)) { + continue; + } + $value = clamp_float((float)$value, (float)$def['min'], (float)$def['max']); + } elseif ($type === 'boolean') { + $value = in_array((string)$value, ['1', 'true', 'yes', 'on'], true) ? '1' : '0'; + } elseif ($type === 'select') { + $options = is_array($def['options'] ?? null) ? $def['options'] : []; + if (!array_key_exists((string)$value, $options)) { + continue; + } + } elseif ($type === 'text') { + $value = trim((string)$value); + $min = (int)($def['min_length'] ?? 0); + $max = (int)($def['max_length'] ?? 255); + if (mb_strlen($value) < $min) { + continue; + } + $value = mb_substr($value, 0, $max); + } + $stmt->execute([ + ':setting_key' => $key, + ':setting_value' => (string)$value, + ]); + } + setting_rows(true); +} + +function reset_settings(): void +{ + $keys = array_keys(setting_definitions()); + if ($keys === []) { + return; + } + $placeholders = implode(',', array_fill(0, count($keys), '?')); + db()->prepare("DELETE FROM app_settings WHERE setting_key IN ($placeholders)")->execute($keys); + setting_rows(true); +} + +function ip_prefix_match(string $left, string $right, int $bits): bool +{ + if ($bits <= 0) { + return true; + } + + $leftLong = ip2long($left); + $rightLong = ip2long($right); + if ($leftLong === false || $rightLong === false) { + return false; + } + + $mask = $bits >= 32 ? 0xffffffff : (~((1 << (32 - $bits)) - 1) & 0xffffffff); + return (((int)$leftLong & $mask) === ((int)$rightLong & $mask)); +} + function bootstrap_db(): void { static $done = false; @@ -884,21 +1101,34 @@ function require_login(): void function remember_cookie_options(int $expires): array { + $secureMode = (string)setting_value('security.cookie_secure_mode'); + $secure = match ($secureMode) { + 'always' => true, + 'never' => false, + default => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + }; + $sameSite = (string)setting_value('security.cookie_samesite'); + if ($sameSite === 'None') { + $secure = true; + } + return [ 'expires' => $expires, 'path' => '/', 'domain' => '', - 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'secure' => $secure, 'httponly' => true, - 'samesite' => 'Lax', + 'samesite' => $sameSite, ]; } function issue_remember_token(): void { - $selector = bin2hex(random_bytes(12)); - $validator = bin2hex(random_bytes(32)); - $expires = time() + REMEMBER_DAYS * 86400; + $selectorBytes = (int)setting_value('security.remember_selector_bytes'); + $validatorBytes = (int)setting_value('security.remember_validator_bytes'); + $selector = bin2hex(random_bytes($selectorBytes)); + $validator = bin2hex(random_bytes($validatorBytes)); + $expires = time() + ((int)setting_value('security.remember_days') * 86400); $stmt = db()->prepare(" INSERT INTO remember_tokens @@ -933,10 +1163,12 @@ function issue_remember_token(): void remember_cookie_options($expires) ); - db()->exec(" - DELETE FROM remember_tokens - WHERE expires_at < NOW() - "); + if ((bool)setting_value('security.prune_expired_remember_tokens')) { + db()->exec(" + DELETE FROM remember_tokens + WHERE expires_at < NOW() + "); + } } function clear_remember_token(): void @@ -944,7 +1176,7 @@ function clear_remember_token(): void $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? ''); $parts = explode(':', $raw, 2); - if (count($parts) === 2 && preg_match('/^[a-f0-9]{24}$/', $parts[0])) { + if (count($parts) === 2 && preg_match('/^[a-f0-9]{16,64}$/', $parts[0])) { $stmt = db()->prepare(" DELETE FROM remember_tokens WHERE selector = :selector @@ -976,8 +1208,8 @@ function auto_login_from_cookie(): bool if ( count($parts) !== 2 - || !preg_match('/^[a-f0-9]{24}$/', $parts[0]) - || !preg_match('/^[a-f0-9]{64}$/', $parts[1]) + || !preg_match('/^[a-f0-9]{16,64}$/', $parts[0]) + || !preg_match('/^[a-f0-9]{32,128}$/', $parts[1]) ) { clear_remember_token(); return false; @@ -989,7 +1221,9 @@ function auto_login_from_cookie(): bool SELECT id, validator_hash, - UNIX_TIMESTAMP(expires_at) AS expires_ts + UNIX_TIMESTAMP(expires_at) AS expires_ts, + last_ip, + user_agent FROM remember_tokens WHERE selector = :selector LIMIT 1 @@ -1011,7 +1245,23 @@ function auto_login_from_cookie(): bool return false; } + $currentUserAgent = mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); + if ((bool)setting_value('security.remember_bind_user_agent') && (string)($row['user_agent'] ?? '') !== $currentUserAgent) { + clear_remember_token(); + return false; + } + + $ipPrefix = (int)setting_value('security.remember_bind_ip_prefix_v4'); + $currentIp = (string)($_SERVER['REMOTE_ADDR'] ?? 'cli'); + if ($ipPrefix > 0 && !ip_prefix_match((string)($row['last_ip'] ?? ''), $currentIp, $ipPrefix)) { + clear_remember_token(); + return false; + } + $_SESSION['control_login'] = true; + if ((bool)setting_value('security.session_regenerate_auto_login') && PHP_SAPI !== 'cli') { + session_regenerate_id(true); + } $stmt = db()->prepare(" UPDATE remember_tokens @@ -1034,7 +1284,7 @@ function auto_login_from_cookie(): bool function csrf_token(): string { if (empty($_SESSION['csrf'])) { - $_SESSION['csrf'] = bin2hex(random_bytes(24)); + $_SESSION['csrf'] = bin2hex(random_bytes((int)setting_value('security.csrf_bytes'))); } return (string)$_SESSION['csrf']; @@ -1056,6 +1306,10 @@ function require_csrf(): void function sh(array $cmd, bool $root = false, int $timeout = 8): array { + if ($timeout === 8) { + $timeout = (int)setting_value('security.shell_default_timeout_seconds'); + } + $full = $cmd; if ( diff --git a/public/api.php b/public/api.php index 8aaf15e..2ca7c87 100644 --- a/public/api.php +++ b/public/api.php @@ -72,144 +72,6 @@ function human_remaining_seconds(int $seconds): string return sprintf('%dm', max(1, $minutes)); } -function setting_definitions(): array -{ - return [ - 'fan.auto_min_temp' => ['group' => 'fan', 'label' => '팬 시작 온도', 'type' => 'number', 'default' => 50, 'min' => 30, 'max' => 90, 'step' => 0.5, 'unit' => 'C'], - 'fan.auto_max_temp' => ['group' => 'fan', 'label' => '팬 최대 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], - 'fan.full_temp' => ['group' => 'fan', 'label' => '즉시 최대 PWM 온도', 'type' => 'number', 'default' => 80, 'min' => 45, 'max' => 100, 'step' => 0.5, 'unit' => 'C'], - 'fan.ramp_up_step' => ['group' => 'fan', 'label' => '자동 상승 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], - 'fan.ramp_down_step' => ['group' => 'fan', 'label' => '자동 하강 PWM step', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 80, 'step' => 1, 'unit' => 'PWM'], - 'event.recent_window_seconds' => ['group' => 'event', 'label' => '저전압/스로틀링 최근 판정창', 'type' => 'number', 'default' => 600, 'min' => 60, 'max' => 7200, 'step' => 30, 'unit' => '초'], - 'event.recovery_seconds' => ['group' => 'event', 'label' => '복구 중 유지 시간', 'type' => 'number', 'default' => 300, 'min' => 30, 'max' => 3600, 'step' => 30, 'unit' => '초'], - 'event.repeated_count' => ['group' => 'event', 'label' => '반복 발생 기준 횟수', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 20, 'step' => 1, 'unit' => '회'], - 'notice.baseline_samples' => ['group' => 'notice', 'label' => '팬 이상 기준 표본 수', 'type' => 'number', 'default' => 180, 'min' => 30, 'max' => 600, 'step' => 10, 'unit' => '개'], - 'notice.baseline_skip_recent' => ['group' => 'notice', 'label' => '기준 계산 제외 최신 표본', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 30, 'step' => 1, 'unit' => '개'], - 'notice.rpm_delta_threshold' => ['group' => 'notice', 'label' => '팬 RPM 이상 감지 차이', 'type' => 'number', 'default' => 1000, 'min' => 100, 'max' => 5000, 'step' => 50, 'unit' => 'RPM'], - 'notice.temp_delta_threshold' => ['group' => 'notice', 'label' => '온도 이상 감지 차이', 'type' => 'number', 'default' => 3.0, 'min' => 0.5, 'max' => 15, 'step' => 0.1, 'unit' => 'C'], - 'notice.recover_rpm_delta' => ['group' => 'notice', 'label' => '팬 RPM 복구 차이', 'type' => 'number', 'default' => 500, 'min' => 50, 'max' => 3000, 'step' => 50, 'unit' => 'RPM'], - 'notice.recover_temp_delta' => ['group' => 'notice', 'label' => '온도 복구 차이', 'type' => 'number', 'default' => 1.5, 'min' => 0.2, 'max' => 10, 'step' => 0.1, 'unit' => 'C'], - 'battery.trend_hours' => ['group' => 'battery', 'label' => '단기 배터리 학습 범위', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 48, 'step' => 1, 'unit' => '시간'], - 'battery.profile_days' => ['group' => 'battery', 'label' => '장기 배터리 학습 범위', 'type' => 'number', 'default' => 45, 'min' => 3, 'max' => 90, 'step' => 1, 'unit' => '일'], - 'battery.load_adjust_recent' => ['group' => 'battery', 'label' => '최근 추세 부하 보정 강도', 'type' => 'number', 'default' => 0.45, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], - 'battery.load_adjust_regression' => ['group' => 'battery', 'label' => '강건 회귀 부하 보정 강도', 'type' => 'number', 'default' => 0.38, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], - 'battery.load_adjust_voltage' => ['group' => 'battery', 'label' => '전압 후보 부하 보정 강도', 'type' => 'number', 'default' => 0.28, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], - 'battery.voltage_floor_high' => ['group' => 'battery', 'label' => '전압 하한 높음', 'type' => 'number', 'default' => 3.28, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], - 'battery.voltage_floor_mid' => ['group' => 'battery', 'label' => '전압 하한 중간', 'type' => 'number', 'default' => 3.18, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], - 'battery.voltage_floor_low' => ['group' => 'battery', 'label' => '전압 하한 낮음', 'type' => 'number', 'default' => 3.05, 'min' => 2.8, 'max' => 3.7, 'step' => 0.01, 'unit' => 'V'], - 'battery.candidate_min_ratio' => ['group' => 'battery', 'label' => '후보 최소 속도 비율', 'type' => 'number', 'default' => 0.38, 'min' => 0.05, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], - 'battery.candidate_max_ratio' => ['group' => 'battery', 'label' => '후보 최대 속도 비율', 'type' => 'number', 'default' => 2.65, 'min' => 1, 'max' => 8, 'step' => 0.05, 'unit' => 'x'], - 'battery.capacity_model_weight' => ['group' => 'battery', 'label' => '용량 모델 가중치', 'type' => 'number', 'default' => 0.18, 'min' => 0, 'max' => 1, 'step' => 0.01, 'unit' => 'x'], - ]; -} - -function setting_rows(bool $reload = false): array -{ - static $cache = null; - if (!$reload && $cache !== null) { - return $cache; - } - - try { - $rows = db()->query("SELECT setting_key, setting_value FROM app_settings")->fetchAll(); - } catch (Throwable) { - $cache = []; - return $cache; - } - - $cache = []; - foreach ($rows as $row) { - $cache[(string)$row['setting_key']] = (string)$row['setting_value']; - } - - return $cache; -} - -function setting_value(string $key): int|float|string|bool|null -{ - $defs = setting_definitions(); - if (!isset($defs[$key])) { - return null; - } - $def = $defs[$key]; - $raw = setting_rows()[$key] ?? $def['default']; - - if (($def['type'] ?? '') === 'number') { - $value = is_numeric($raw) ? (float)$raw : (float)$def['default']; - $value = clamp_float($value, (float)$def['min'], (float)$def['max']); - return fmod((float)$def['step'], 1.0) === 0.0 ? (int)round($value) : $value; - } - - return $raw; -} - -function settings_payload(): array -{ - $values = setting_rows(); - $groups = [ - 'fan' => '팬 자동 제어', - 'battery' => '배터리 예측', - 'event' => '저전압/스로틀링', - 'notice' => '팬 이상 감지', - ]; - - $items = []; - foreach (setting_definitions() as $key => $def) { - $items[] = array_merge($def, [ - 'key' => $key, - 'value' => setting_value($key), - 'default' => $def['default'], - 'custom' => array_key_exists($key, $values), - ]); - } - - return [ - 'groups' => $groups, - 'items' => $items, - ]; -} - -function save_settings(array $input): void -{ - $defs = setting_definitions(); - $stmt = db()->prepare(" - INSERT INTO app_settings (setting_key, setting_value) - VALUES (:setting_key, :setting_value) - ON DUPLICATE KEY UPDATE - setting_value = VALUES(setting_value), - updated_at = CURRENT_TIMESTAMP - "); - - foreach ($input as $key => $value) { - if (!isset($defs[$key])) { - continue; - } - $def = $defs[$key]; - if (($def['type'] ?? '') === 'number') { - if (!is_numeric($value)) { - continue; - } - $value = clamp_float((float)$value, (float)$def['min'], (float)$def['max']); - } - $stmt->execute([ - ':setting_key' => $key, - ':setting_value' => (string)$value, - ]); - } - setting_rows(true); -} - -function reset_settings(): void -{ - $keys = array_keys(setting_definitions()); - if ($keys === []) { - return; - } - $placeholders = implode(',', array_fill(0, count($keys), '?')); - db()->prepare("DELETE FROM app_settings WHERE setting_key IN ($placeholders)")->execute($keys); - setting_rows(true); -} - function dmesg_log(): array { $path = '/tmp/dmesg.log'; @@ -876,11 +738,6 @@ function numeric_trimmed_average(array $values, float $trimRatio = 0.1): ?float return array_sum($numbers) / count($numbers); } -function clamp_float(float $value, float $min, float $max): float -{ - return max($min, min($max, $value)); -} - function weighted_linear_regression(array $points): ?array { $weightSum = 0.0; @@ -3524,8 +3381,9 @@ function control_api_dispatch(): void if ($action === 'reboot') { $phrase = trim((string)($_POST['phrase'] ?? '')); $password = (string)($_POST['password'] ?? ''); + $expectedPhrase = (string)setting_value('security.reboot_phrase'); - if ($phrase !== '재부팅') { + if ($phrase !== $expectedPhrase) { json_out([ 'ok' => false, 'error' => 'bad_reboot_phrase', @@ -3549,7 +3407,7 @@ function control_api_dispatch(): void true ); - $result = sh(['/usr/sbin/reboot'], true, 5); + $result = sh(['/usr/sbin/reboot'], true, (int)setting_value('security.reboot_timeout_seconds')); if ($result['code'] !== 0) { json_out([ 'ok' => false, @@ -3578,6 +3436,8 @@ function control_api_dispatch(): void if ( !in_array($unit, $allowedUnits, true) || !in_array($verb, ['restart', 'reload'], true) + || ($verb === 'restart' && !(bool)setting_value('security.allow_wifi_restart')) + || ($verb === 'reload' && !(bool)setting_value('security.allow_wifi_reload')) ) { json_out([ 'ok' => false, @@ -3585,7 +3445,7 @@ function control_api_dispatch(): void ], 400); } - $result = sh(['/usr/bin/systemctl', $verb, $unit], true, 25); + $result = sh(['/usr/bin/systemctl', $verb, $unit], true, (int)setting_value('security.wifi_command_timeout_seconds')); $out = $result['out']; add_fan_action( diff --git a/public/assets/app.js b/public/assets/app.js index 7331d6d..58cacdf 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -616,6 +616,54 @@ if (!byGroup[group]) byGroup[group] = []; byGroup[group].push(item); }); + const fieldHtml = item => { + const key = escapeHtml(item.key); + const value = item.value ?? item.default ?? ''; + const type = item.type || 'number'; + if (type === 'boolean') { + const checked = value === true || value === 1 || value === '1' || value === 'true'; + return ` + + + ${checked ? 'ON' : 'OFF'} + + `; + } + if (type === 'select') { + const options = item.options || {}; + return ` + + `; + } + if (type === 'text') { + return ` + + `; + } + return ` + + `; + }; + const defaultValueText = item => { + if (item.type === 'select' && item.options && item.options[item.default]) { + return item.options[item.default]; + } + return item.default ?? ''; + }; els.settingsBody.innerHTML = Object.entries(byGroup).map(([group, items]) => `
@@ -628,20 +676,29 @@ ${escapeHtml(item.unit || '')} - - ${t('settingsDefault')} ${escapeHtml(item.default ?? '')} + ${fieldHtml(item)} + ${t('settingsDefault')} ${escapeHtml(defaultValueText(item))} `).join('')}
`).join(''); + els.settingsBody.querySelectorAll('.settings-toggle input').forEach(input => { + input.addEventListener('change', () => { + const label = input.closest('.settings-toggle')?.querySelector('span'); + if (label) label.textContent = input.checked ? 'ON' : 'OFF'; + }); + input.closest('.settings-toggle')?.addEventListener('click', () => { + input.checked = !input.checked; + input.dispatchEvent(new Event('change')); + }); + }); + } + + function settingByKey(key, fallback = null) { + const item = (state.settingsPayload?.items || []).find(row => row.key === key); + return item ? item.value : fallback; } async function openSettings() { @@ -668,7 +725,9 @@ function currentSettingsFormValues() { const values = {}; els.settingsBody?.querySelectorAll('[data-setting-key]').forEach(input => { - values[input.dataset.settingKey] = input.value; + values[input.dataset.settingKey] = input.type === 'checkbox' + ? (input.checked ? '1' : '0') + : input.value; }); return values; } @@ -1906,12 +1965,13 @@ title: t('rebootTitle'), okText: t('next'), danger: true, - placeholder: t('rebootPhrase'), + placeholder: String(settingByKey('security.reboot_phrase', t('rebootPhrase'))), autocomplete: 'off', }); if (phrase === null) return; - if (String(phrase).trim() !== t('rebootPhrase')) { + const expectedPhrase = String(settingByKey('security.reboot_phrase', t('rebootPhrase'))); + if (String(phrase).trim() !== expectedPhrase) { await window.customAlert(t('rebootPhraseMismatch'), { title: t('rebootCanceled'), danger: true, @@ -1932,7 +1992,7 @@ els.rebootBtn.disabled = true; notice(t('rebootSending'), 'info'); await api('reboot', { - phrase: '재부팅', + phrase: expectedPhrase, password, }); await window.customAlert(t('rebootStartedBody'), { diff --git a/public/index.php b/public/index.php index 0dfe41f..a90e9c9 100644 --- a/public/index.php +++ b/public/index.php @@ -6,6 +6,8 @@ if (session_status() !== PHP_SESSION_ACTIVE) { } require_once __DIR__ . '/../config/config.php'; +$csrf = csrf_token(); + if (isset($_GET['logout'])) { clear_remember_token(); $_SESSION = []; @@ -20,9 +22,24 @@ if (isset($_GET['logout'])) { $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) { - if (hash_equals(APP_PASSWORD, (string)$_POST['login_password'])) { + $now = time(); + $failures = array_values(array_filter( + (array)($_SESSION['login_failures'] ?? []), + static fn($ts): bool => is_numeric($ts) && $now - (int)$ts <= (int)setting_value('security.login_window_seconds') + )); + $_SESSION['login_failures'] = $failures; + $lockedUntil = (int)($_SESSION['login_locked_until'] ?? 0); + $csrfOk = !(bool)setting_value('security.login_csrf_required') + || hash_equals($csrf, (string)($_POST['csrf'] ?? '')); + + if ($lockedUntil > $now) { + $error = '로그인 잠금 중입니다. 잠시 후 다시 시도하세요.'; + } elseif (!$csrfOk) { + $error = '보안 토큰이 올바르지 않습니다.'; + } elseif (hash_equals(APP_PASSWORD, (string)$_POST['login_password'])) { session_regenerate_id(true); $_SESSION['control_login'] = true; + unset($_SESSION['login_failures'], $_SESSION['login_locked_until']); if (!empty($_POST['remember_login'])) { issue_remember_token(); } else { @@ -30,12 +47,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_password'])) { } header('Location: /'); exit; + } else { + $failures[] = $now; + $_SESSION['login_failures'] = $failures; + if (count($failures) >= (int)setting_value('security.login_max_failures')) { + $_SESSION['login_locked_until'] = $now + (int)setting_value('security.login_lock_seconds'); + } + $error = '비밀번호가 올바르지 않습니다.'; } - $error = '비밀번호가 올바르지 않습니다.'; } $loggedIn = signed_in(); -$csrf = csrf_token(); ?> @@ -113,7 +135,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da} .notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:var(--table-head);color:var(--text);border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)} .status-value.interactive{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:1px;text-underline-offset:4px}.runtime-tooltip{position:fixed;left:0;top:0;z-index:110;display:none;width:min(520px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:hidden;padding:14px 16px;border:1px solid rgba(84,101,128,.78);border-radius:16px;background:var(--card);color:var(--text);box-shadow:0 18px 48px rgba(0,0,0,.38);font-size:13px;line-height:1.55;white-space:pre-line;word-break:keep-all;overflow-wrap:anywhere;pointer-events:none}.runtime-tooltip[data-open="1"]{display:block}.runtime-tooltip::before{content:"";position:absolute;left:18px;top:-7px;width:12px;height:12px;border-left:1px solid rgba(84,101,128,.78);border-top:1px solid rgba(84,101,128,.78);background:var(--card);transform:rotate(45deg)}html[data-theme="light"] .runtime-tooltip{background:#f8fbff;border-color:#aebed2;color:#172033;box-shadow:0 18px 44px rgba(28,43,64,.18)}html[data-theme="light"] .runtime-tooltip::before{background:#f8fbff;border-color:#aebed2} .dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:var(--card);box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828} -.settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)} +.settings-layer{align-items:stretch}.settings-box{width:min(980px,100%);max-height:calc(100vh - 44px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;padding:0;overflow:hidden}.settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:18px 20px;border-bottom:1px solid var(--line)}.settings-sub{margin:0;color:var(--sub);font-size:13px}.settings-body{overflow:auto;padding:16px 20px;display:grid;gap:14px}.settings-group{border:1px solid var(--line);border-radius:16px;background:var(--card2);padding:14px}.settings-group h4{margin:0 0 12px;font-size:15px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.settings-field{display:grid;gap:7px;min-width:0}.settings-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--sub);font-size:13px}.settings-label span:last-child{white-space:nowrap;color:var(--sub)}.settings-control{display:flex;align-items:center;gap:9px}.settings-control input,.settings-select{width:100%;height:42px;border-radius:12px;border:1px solid var(--line);background:var(--input);color:var(--text);padding:0 12px;outline:none}.settings-control input:focus,.settings-select:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.settings-toggle{display:flex;align-items:center;width:100%;height:42px;border:1px solid var(--line);border-radius:12px;background:var(--input);padding:4px;cursor:pointer}.settings-toggle input{position:absolute;opacity:0;pointer-events:none}.settings-toggle span{display:grid;place-items:center;width:72px;height:32px;border-radius:10px;background:#334155;color:#cbd5e1;font-weight:700;font-size:12px}.settings-toggle input:checked+span{background:var(--blue);color:#fff}.settings-default{font-size:12px;color:var(--sub);white-space:nowrap}.settings-actions{display:flex;justify-content:flex-end;gap:10px;padding:14px 20px;border-top:1px solid var(--line)} @media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}} @media(max-width:720px){.settings-grid{grid-template-columns:1fr}.settings-head{align-items:stretch;flex-direction:column}.settings-actions{display:grid;grid-template-columns:1fr 1fr}.settings-box{max-height:calc(100vh - 24px)}} @@ -126,6 +148,7 @@ html[data-theme="light"] .details-panel{background:#edf3fa;border-color:#b8c7da}

Fan and WiFi control panel

+